Files
sharedinbox/packages/enough_mail/lib/src/private/imap/fetch_parser.dart
T
Thomas GüttlerandClaude Sonnet 4.6 71952ed36b Fix: vendor enough_mail as regular files instead of gitlink
The directory was tracked as a mode-160000 gitlink (bare submodule
reference) without a .gitmodules entry, causing 'has no commit checked
out' errors on commit. Re-added as ordinary tracked files so the
vendored copy is fully part of this repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:52:01 +02:00

657 lines
22 KiB
Dart

import '../../codecs/date_codec.dart';
import '../../codecs/mail_codec.dart';
import '../../imap/message_sequence.dart';
import '../../imap/response.dart';
import '../../mail_address.dart';
import '../../media_type.dart';
import '../../mime_data.dart';
import '../../mime_message.dart';
import 'imap_response.dart';
import 'parser_helper.dart';
import 'response_parser.dart';
/// Parses FETCH IMAP responses
class FetchParser extends ResponseParser<FetchImapResult> {
/// Creates a new parser
FetchParser({required this.isUidFetch});
final List<MimeMessage> _messages = <MimeMessage>[];
/// The most recent message that has been parsed
MimeMessage? lastParsedMessage;
/// The most recent VANISHED response
MessageSequence? vanishedMessages;
/// The modified sequence if defined in the FETCH response
MessageSequence? modifiedSequence;
/// Is the FETCH request based on UIDs instead of sequence-IDs?
final bool isUidFetch;
@override
FetchImapResult? parse(
ImapResponse imapResponse,
Response<FetchImapResult> response,
) {
final text = imapResponse.parseText;
final modifiedIndex = text.indexOf('[MODIFIED ');
if (modifiedIndex != -1) {
final modifiedEntries = ParserHelper.parseListIntEntries(
text,
modifiedIndex + '[MODIFIED '.length,
']',
',',
);
if (modifiedEntries != null) {
modifiedSequence =
MessageSequence.fromIds(modifiedEntries, isUid: isUidFetch);
}
}
final vanishedMessages = this.vanishedMessages;
if (response.isOkStatus ||
_messages.isNotEmpty ||
(vanishedMessages != null && vanishedMessages.isNotEmpty)) {
return FetchImapResult(
_messages,
vanishedMessages,
modifiedSequence: modifiedSequence,
);
}
return null;
}
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<FetchImapResult>? response,
) {
final firstLine = imapResponse.first.line;
if (firstLine == null) {
return false;
}
final fetchIndex = firstLine.indexOf(' FETCH ');
lastParsedMessage = null;
if (fetchIndex != -1) {
// eg "* 2389 FETCH (...)"
final sequenceId = parseInt(firstLine, 2, ' ');
MimeMessage message;
if (_messages.isNotEmpty && _messages.last.sequenceId == sequenceId) {
message = _messages.last;
} else {
message = MimeMessage()..sequenceId = sequenceId;
_messages.add(message);
}
lastParsedMessage = message;
final iterator = imapResponse.iterate();
for (final value in iterator.values) {
if (value.value == 'FETCH') {
_parseFetch(message, value, imapResponse);
}
}
return true;
} else if (firstLine.startsWith('* VANISHED (EARLIER) ')) {
final parseText = imapResponse.parseText;
final messageSequenceText = parseText.startsWith('*')
? parseText.substring('* VANISHED (EARLIER) '.length)
: parseText.substring('VANISHED (EARLIER) '.length);
vanishedMessages =
MessageSequence.parse(messageSequenceText, isUidSequence: true);
return true;
}
return super.parseUntagged(imapResponse, response);
}
void _parseFetch(
MimeMessage message,
ImapValue fetchValue,
ImapResponse imapResponse,
) {
final children = fetchValue.children ?? [];
for (var i = 0; i < children.length; i++) {
final child = children[i];
final hasNext = i < children.length - 1;
switch (child.value) {
case 'UID':
if (hasNext) {
message.uid = int.parse(children[i + 1].value ?? '-1');
i++;
}
break;
case 'MODSEQ':
if (hasNext && (children[i + 1].children?.length == 1)) {
message.modSequence =
int.tryParse(children[i + 1].children?[0].value ?? '');
i++;
}
break;
case 'FLAGS':
message.flags = List.from(
child.children?.map<String?>((flag) => flag.value) ?? <String>[],
);
break;
case 'INTERNALDATE':
if (hasNext) {
message.internalDate = children[i + 1].value;
i++;
}
break;
case 'RFC822.SIZE':
if (hasNext) {
message.size = int.parse(children[i + 1].value ?? '-1');
i++;
}
break;
case 'ENVELOPE':
_parseEnvelope(message, child);
break;
case 'BODY':
_parseBody(message, child);
break;
case 'BODYSTRUCTURE':
_parseBodyStructure(message, child);
break;
case 'BODY[HEADER]':
case 'RFC822.HEADER':
if (hasNext) {
i++;
_parseBodyHeader(message, children[i]);
}
break;
case 'BODY[TEXT]':
case 'RFC822.TEXT':
if (hasNext) {
i++;
_parseBodyText(message, children[i]);
}
break;
case 'BODY[]':
case 'RFC822':
if (hasNext) {
i++;
_parseBodyFull(message, children[i]);
}
break;
default:
final value = child.value;
if (hasNext &&
value != null &&
value.startsWith('BODY[') &&
value.endsWith(']')) {
i++;
_parseBodyPart(message, value, children[i]);
} else {
print(
'fetch: encountered unexpected/unsupported element '
'${child.value} at $i in ${imapResponse.parseText}',
);
}
}
}
}
/// Parse a body part
///
/// parses elements starting with `BODY[`, excluding `BODY[]` and
/// `BODY[HEADER]` which are handled separately
/// e.g. `BODY[0]` or `BODY[HEADER.FIELDS (REFERENCES)]`
void _parseBodyPart(
MimeMessage message,
String bodyPartDefinition,
ImapValue imapValue,
) {
// this matches
// BODY[HEADER.FIELDS (name1,name2)], as well as
// BODY[HEADER.FIELDS.NOT (name1,name2)]
if (bodyPartDefinition.startsWith('BODY[HEADER.FIELDS')) {
_parseBodyHeader(message, imapValue);
} else {
const startIndex = 'BODY['.length;
final endIndex = bodyPartDefinition.length - 1;
final fetchId = bodyPartDefinition.substring(startIndex, endIndex);
final part = MimePart();
final value = imapValue.value;
final data = imapValue.data;
if (value != null) {
part.mimeData = TextMimeData(value, containsHeader: false);
} else if (data != null) {
part.mimeData = BinaryMimeData(data, containsHeader: false);
}
part.parse();
//print('$fetchId: results in [${imapValue.value}]');
message.setPart(fetchId.replaceFirst('.HEADER', ''), part);
}
}
void _parseBodyFull(MimeMessage message, ImapValue bodyValue) {
//print("Parsing BODY[]\n[${bodyValue.value}]");
final data = bodyValue.data;
final value = bodyValue.value;
if (data != null) {
message.mimeData = BinaryMimeData(data, containsHeader: true);
} else if (value != null) {
message.mimeData = TextMimeData(value, containsHeader: true);
//print("Parsing BODY text \n$bodyText");
}
// ensure all headers are set:
message.parse();
}
HeaderParseResult _parseBodyHeader(
MimeMessage message,
ImapValue headerValue,
) {
//print('Parsing BODY[HEADER]\n[${headerValue.value}]');
final headerParseResult =
ParserHelper.parseHeader(headerValue.valueOrDataText ?? '');
message.headers = headerParseResult.headersList;
return headerParseResult;
}
void _parseBodyText(MimeMessage message, ImapValue textValue) {
//print('Parsing BODY[TEXT]\n[${textValue.value}]');
final data = textValue.data;
message.mimeData = data != null
? BinaryMimeData(data, containsHeader: false)
: TextMimeData(textValue.value ?? '', containsHeader: false);
}
/// Also compare:
/// * http://sgerwk.altervista.org/imapbodystructure.html
/// * https://tools.ietf.org/html/rfc3501#section-7.4.2
/// * http://hea-www.cfa.harvard.edu/~fine/opinions/IMAPsucks.html
void _parseBodyRecursive(BodyPart body, ImapValue bodyValue) {
// print('_parseBodyRecursive from $bodyValue');
var isMultipartSubtypeSet = false;
var multipartChildIndex = -1;
final children = bodyValue.children ?? [];
if (children.length >= 7 && children[0].children == null) {
// this is a direct type:
final parsed = _parseBodyStructureFrom(children);
body
..bodyRaw = parsed.bodyRaw
..contentDisposition = parsed.contentDisposition
..contentType = parsed.contentType
..description = parsed.description
..encoding = parsed.encoding
..envelope = parsed.envelope
..cid = parsed.cid
..numberOfLines = parsed.numberOfLines
..size = parsed.size;
return;
}
for (var childIndex = 0; childIndex < children.length; childIndex++) {
final child = children[childIndex];
final grandchildren = child.children;
if (child.value == null &&
grandchildren != null &&
grandchildren.isNotEmpty &&
grandchildren.first.value == null) {
// this is a nested structure
final part = BodyPart();
body.addPart(part);
_parseBodyRecursive(part, child);
} else if (!isMultipartSubtypeSet &&
grandchildren != null &&
grandchildren.length >= 7) {
// TODO just counting cannot be a big enough indicator,
// compare for example
// ""mixed" ("charset" "utf8" "boundary" "cs2da2ss7EsqRfMsG")"
// this is a structure value
final structures = grandchildren;
final part = _parseBodyStructureFrom(structures);
body.addPart(part);
} else if (!isMultipartSubtypeSet) {
// this is the type:
isMultipartSubtypeSet = true;
multipartChildIndex = childIndex;
body.contentType =
ContentTypeHeader('multipart/${child.value?.toLowerCase()}');
} else if (childIndex == multipartChildIndex + 1 &&
grandchildren != null &&
grandchildren.length > 1) {
final parameters = grandchildren;
for (var i = 0; i < parameters.length; i += 2) {
body.contentType?.setParameter(
parameters[i].value ?? '',
parameters[i + 1].valueOrDataText ?? '',
);
}
}
}
}
BodyPart _parseBodyStructureFrom(List<ImapValue> structures) {
final size = int.tryParse(structures[6].value ?? '');
final mediaType =
MediaType.fromText('${structures[0].value}/${structures[1].value}');
final part = BodyPart()
..cid = _checkForNil(structures[3].value)
..description = _checkForNil(structures[4].value)
..encoding = _checkForNil(structures[5].value)?.toLowerCase()
..size = size
..contentType = ContentTypeHeader.from(mediaType);
final contentTypeParameters = structures[2].children;
if (contentTypeParameters != null && contentTypeParameters.length > 1) {
for (var i = 0; i < contentTypeParameters.length; i += 2) {
final name = contentTypeParameters[i].value;
final value = contentTypeParameters[i + 1].valueOrDataText;
// print('content-type: $name=$value');
if (name != null && value != null) {
part.contentType?.setParameter(name, value);
}
}
}
var startIndex = 7;
if (mediaType.isText &&
structures.length > 7 &&
structures[7].value != null) {
part.numberOfLines = int.tryParse(structures[7].value ?? '');
startIndex = 8;
} else if (mediaType.isMessage &&
mediaType.sub == MediaSubtype.messageRfc822) {
// [7]
// A body type of type MESSAGE and subtype RFC822 contains,
// immediately after the basic fields, the envelope structure,
// body structure, and size in text lines of the encapsulated
// message.
if (structures.length > 9) {
part.envelope = _parseEnvelope(null, structures[7]);
final child = BodyPart();
part.addPart(child);
_parseBodyRecursive(child, structures[8]);
part.numberOfLines = int.tryParse(structures[9].value ?? '');
}
startIndex += 3;
}
if ((structures.length > startIndex + 1) &&
(structures[startIndex + 1].children?.isNotEmpty ?? false)) {
// read content disposition
// example: <null>[attachment, <null>[filename, testImage.jpg,
// modification-date, Fri, 27 Jan 2017 16:34:4 +0100, size, 13390]]
final parts = structures[startIndex + 1].children ?? [];
if (parts[0].value != null) {
final contentDisposition =
ContentDispositionHeader(parts[0].value?.toLowerCase() ?? '');
final parameters = parts[1].children;
if (parameters != null && parameters.length > 1) {
for (var i = 0; i < parameters.length; i += 2) {
final name = parameters[i].value;
final value = parameters[i + 1].valueOrDataText;
if (name != null && value != null) {
// print('content-disposition: $name=$value');
contentDisposition.setParameter(name, value);
}
}
}
part.contentDisposition = contentDisposition;
} else {
print('Unable to parse content disposition from:');
print(parts);
}
}
return part;
}
void _parseBody(MimeMessage message, ImapValue bodyValue) {
// A parenthesized list that describes the [MIME-IMB] body
// structure of a message. This is computed by the server by
// parsing the [MIME-IMB] header fields, defaulting various fields
// as necessary.
// For example, a simple text message of 48 lines and 2279 octets
// can have a body structure of: ("TEXT" "PLAIN" ("CHARSET"
// "US-ASCII") NIL NIL "7BIT" 2279 48)
// Multiple parts are indicated by parenthesis nesting. Instead
// of a body type as the first element of the parenthesized list,
// there is a sequence of one or more nested body structures. The
// second element of the parenthesized list is the multipart
// subtype (mixed, digest, parallel, alternative, etc.).
// For example, a two part message consisting of a text and a
// BASE64-encoded text attachment can have a body structure of:
// (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152
// 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff")
// "<960723163407.20117h@cac.washington.edu>" "Compiler diff"
// "BASE64" 4554 73) "MIXED")
// [0]body type
// A string giving the content media type name as defined in
// [MIME-IMB].
// [1]body subtype
// A string giving the content subtype name as defined in
// [MIME-IMB].
// [2] body parameter parenthesized list
// A parenthesized list of attribute/value pairs [e.g., ("foo"
// "bar" "baz" "rag") where "bar" is the value of "foo" and
// "rag" is the value of "baz"] as defined in [MIME-IMB].
// [3]body id
// A string giving the content id as defined in [MIME-IMB].
// [4]body description
// A string giving the content description as defined in
// [MIME-IMB].
// [5]body encoding
// A string giving the content transfer encoding as defined in
// [MIME-IMB].
// [6]body size
// A number giving the size of the body in octets. Note that
// this size is the size in its transfer encoding and not the
// resulting size after any decoding.
// [7]
// A body type of type MESSAGE and subtype RFC822 contains,
// immediately after the basic fields, the envelope structure,
// body structure, and size in text lines of the encapsulated
// message.
// A body type of type TEXT contains, immediately after the basic
// fields, the size of the body in text lines. Note that this
// size is the size in its content transfer encoding and not the
// resulting size after any decoding.
// Extension data follows the multipart subtype. Extension data
// is never returned with the BODY fetch, but can be returned with
// a BODYSTRUCTURE fetch. Extension data, if present, MUST be in
// the defined order. The extension data of a multipart body part
// are in the following order:
// [7 / 8]
// body parameter parenthesized list
// A parenthesized list of attribute/value pairs [e.g., ("foo"
// "bar" "baz" "rag") where "bar" is the value of "foo", and
// "rag" is the value of "baz"] as defined in [MIME-IMB].
// [8 / 9]
// body disposition
// A parenthesized list, consisting of a disposition type
// string, followed by a parenthesized list of disposition
// attribute/value pairs as defined in [DISPOSITION].
// [9 / 10]
// body language
// A string or parenthesized list giving the body language
// value as defined in [LANGUAGE-TAGS].
// [10 / 11]
// body location
// A string list giving the body content URI as defined in
// [LOCATION].
//
//
// The extension data of a non-multipart body part are in the
// following order:
// [7 / 8]
// body MD5
// A string giving the body MD5 value as defined in [MD5].
//
// [8 / 9]
// body disposition
// A parenthesized list with the same content and function as
// the body disposition for a multipart body part.
// [9 / 10]
// body language
// A string or parenthesized list giving the body language
// value as defined in [LANGUAGE-TAGS].
// [10 / 11]
// body location
// A string list giving the body content URI as defined in
// [LOCATION].
//print('body: $bodyValue');
final body = BodyPart();
_parseBodyRecursive(body, bodyValue);
message.body = body;
}
void _parseBodyStructure(MimeMessage message, ImapValue bodyValue) {
//print('bodystructure: $bodyValue');
_parseBody(message, bodyValue);
}
/// parses the envelope structure of a message
Envelope? _parseEnvelope(MimeMessage? message, ImapValue envelopeValue) {
// The fields of the envelope structure are in the following
// order: [0] date, [1]subject, [2]from, [3]sender, [4]reply-to, [5]to,
// [6]cc, [7]bcc, [8]in-reply-to, and [9]message-id.
//
// The date, subject, in-reply-to,
// and message-id fields are strings. The from, sender, reply-to,
// to, cc, and bcc fields are parenthesized lists of address
// structures.
// If the Date, Subject, In-Reply-To, and Message-ID header lines
// are absent in the [RFC-2822] header, the corresponding member
// of the envelope is NIL; if these header lines are present but
// empty the corresponding member of the envelope is the empty
// string.
Envelope? envelope;
final children = envelopeValue.children;
//print("envelope: $children");
if (children != null && children.length >= 10) {
final rawDate = _checkForNil(children[0].value);
final rawSubject = _checkForNil(children[1].valueOrDataText);
envelope = Envelope()
..date = rawDate != null ? DateCodec.decodeDate(rawDate) : null
..subject =
rawSubject != null ? MailCodec.decodeHeader(rawSubject) : null
..from = _parseAddressList(children[2])
..sender = _parseAddressListFirst(children[3])
..replyTo = _parseAddressList(children[4])
..to = _parseAddressList(children[5])
..cc = _parseAddressList(children[6])
..bcc = _parseAddressList(children[7])
..inReplyTo = _checkForNil(children[8].value)
..messageId = _checkForNil(children[9].value);
if (message != null) {
message.envelope = envelope;
if (rawDate != null) {
message.addHeader('Date', rawDate);
}
if (rawSubject != null) {
message.addHeader('Subject', rawSubject);
}
message
..addHeader('In-Reply-To', envelope.inReplyTo)
..addHeader('Message-ID', envelope.messageId);
}
}
return envelope;
}
MailAddress? _parseAddressListFirst(ImapValue addressValue) {
final addresses = _parseAddressList(addressValue);
if (addresses == null || addresses.isEmpty) {
return null;
}
return addresses.first;
}
List<MailAddress>? _parseAddressList(ImapValue addressValue) {
if (addressValue.value == 'NIL') {
return null;
}
final addresses = <MailAddress>[];
final addressChildren = addressValue.children;
if (addressChildren != null) {
for (final child in addressChildren) {
final address = _parseAddress(child);
if (address != null) {
addresses.add(address);
}
}
}
return addresses;
}
MailAddress? _parseAddress(ImapValue addressValue) {
// An address structure is a parenthesized list that describes an
// electronic mail address. The fields of an address structure
// are in the following order: personal name, [SMTP]
// at-domain-list (source route), mailbox name, and host name.
// [RFC-2822] group syntax is indicated by a special form of
// address structure in which the host name field is NIL. If the
// mailbox name field is also NIL, this is an end of group marker
// (semi-colon in RFC 822 syntax). If the mailbox name field is
// non-NIL, this is a start of group marker, and the mailbox name
// field holds the group name phrase.
final addressChildren = addressValue.children;
if (addressValue.value == 'NIL' ||
addressChildren == null ||
addressChildren.length < 4) {
return null;
}
final children = addressChildren;
final mailboxName = _checkForNil(children[2].value);
final hostName = _checkForNil(children[3].value);
if (mailboxName == null && hostName == null) {
print('Warning: invalid mail address in $addressValue: '
'both mailboxName and hostName are null');
return null;
}
String? personalName = '';
try {
personalName = MailCodec.decodeHeader(_checkForNil(children[0].value));
} catch (e) {
print('Warning: invalid mail address in $addressValue: '
'personalName is invalid: $e');
}
return MailAddress.fromEnvelope(
personalName: personalName,
//sourceRoute: _checkForNil(children[1].value),
mailboxName: mailboxName ?? '',
hostName: hostName ?? '',
);
}
String? _checkForNil(String? value) {
if (value == 'NIL') {
return null;
}
return value;
}
}