From b40d5d5b9ffbc04e873c633512283f1238aabeaf Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sat, 25 Jan 2020 16:09:16 +0100 Subject: [PATCH] initial commit --- .gitignore | 15 + CHANGELOG.md | 3 + README.md | 30 + analysis_options.yaml | 14 + example/enough_mail_example.dart | 14 + lib/encodings.dart | 201 +++++ lib/enough_mail.dart | 13 + lib/imap/address.dart | 19 + lib/imap/events.dart | 40 + lib/imap/imap_client.dart | 444 +++++++++++ lib/imap/mailbox.dart | 64 ++ lib/imap/message.dart | 220 ++++++ lib/imap/response.dart | 12 + lib/smtp/smtp_client.dart | 192 +++++ lib/smtp/smtp_response.dart | 65 ++ lib/src/enough_mail_base.dart | 6 + lib/src/enough_mailkit_base.dart | 6 + lib/src/imap/all_parsers.dart | 5 + lib/src/imap/capability_parser.dart | 48 ++ lib/src/imap/command.dart | 75 ++ lib/src/imap/fetch_parser.dart | 294 ++++++++ lib/src/imap/imap_response.dart | 195 +++++ lib/src/imap/imap_response_line.dart | 46 ++ lib/src/imap/imap_response_reader.dart | 83 +++ lib/src/imap/list_parser.dart | 139 ++++ lib/src/imap/logout_parser.dart | 23 + lib/src/imap/noop_parser.dart | 51 ++ lib/src/imap/parser_helper.dart | 159 ++++ lib/src/imap/response_parser.dart | 34 + lib/src/imap/search_parser.dart | 31 + lib/src/imap/select_parser.dart | 49 ++ lib/src/imap/status_parser.dart | 40 + lib/src/smtp/commands/all_commands.dart | 6 + lib/src/smtp/commands/smtp_auth_command.dart | 19 + lib/src/smtp/commands/smtp_ehlo_command.dart | 24 + lib/src/smtp/commands/smtp_quit_command.dart | 14 + .../smtp/commands/smtp_sendmail_command.dart | 69 ++ .../smtp/commands/smtp_starttls_command.dart | 6 + lib/src/smtp/smtp_command.dart | 23 + lib/src/util/uint8_list_reader.dart | 85 +++ lib/util/stack_list.dart | 31 + pubspec.yaml | 14 + test/encodings_test.dart | 19 + test/imap/imap_client_test.dart | 690 ++++++++++++++++++ test/imap/mock_imap_server.dart | 357 +++++++++ test/mock_socket.dart | 404 ++++++++++ test/smtp/mock_smtp_server.dart | 63 ++ test/smtp/smtp_client_test.dart | 130 ++++ test/src/imap/imap_response_line_test.dart | 34 + test/src/imap/imap_response_reader_test.dart | 197 +++++ test/src/imap/imap_response_test.dart | 283 +++++++ test/src/imap/parser_helper_test.dart | 75 ++ test/src/util/uint8_list_reader_test.dart | 145 ++++ 53 files changed, 5318 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 example/enough_mail_example.dart create mode 100644 lib/encodings.dart create mode 100644 lib/enough_mail.dart create mode 100644 lib/imap/address.dart create mode 100644 lib/imap/events.dart create mode 100644 lib/imap/imap_client.dart create mode 100644 lib/imap/mailbox.dart create mode 100644 lib/imap/message.dart create mode 100644 lib/imap/response.dart create mode 100644 lib/smtp/smtp_client.dart create mode 100644 lib/smtp/smtp_response.dart create mode 100644 lib/src/enough_mail_base.dart create mode 100644 lib/src/enough_mailkit_base.dart create mode 100644 lib/src/imap/all_parsers.dart create mode 100644 lib/src/imap/capability_parser.dart create mode 100644 lib/src/imap/command.dart create mode 100644 lib/src/imap/fetch_parser.dart create mode 100644 lib/src/imap/imap_response.dart create mode 100644 lib/src/imap/imap_response_line.dart create mode 100644 lib/src/imap/imap_response_reader.dart create mode 100644 lib/src/imap/list_parser.dart create mode 100644 lib/src/imap/logout_parser.dart create mode 100644 lib/src/imap/noop_parser.dart create mode 100644 lib/src/imap/parser_helper.dart create mode 100644 lib/src/imap/response_parser.dart create mode 100644 lib/src/imap/search_parser.dart create mode 100644 lib/src/imap/select_parser.dart create mode 100644 lib/src/imap/status_parser.dart create mode 100644 lib/src/smtp/commands/all_commands.dart create mode 100644 lib/src/smtp/commands/smtp_auth_command.dart create mode 100644 lib/src/smtp/commands/smtp_ehlo_command.dart create mode 100644 lib/src/smtp/commands/smtp_quit_command.dart create mode 100644 lib/src/smtp/commands/smtp_sendmail_command.dart create mode 100644 lib/src/smtp/commands/smtp_starttls_command.dart create mode 100644 lib/src/smtp/smtp_command.dart create mode 100644 lib/src/util/uint8_list_reader.dart create mode 100644 lib/util/stack_list.dart create mode 100644 pubspec.yaml create mode 100644 test/encodings_test.dart create mode 100644 test/imap/imap_client_test.dart create mode 100644 test/imap/mock_imap_server.dart create mode 100644 test/mock_socket.dart create mode 100644 test/smtp/mock_smtp_server.dart create mode 100644 test/smtp/smtp_client_test.dart create mode 100644 test/src/imap/imap_response_line_test.dart create mode 100644 test/src/imap/imap_response_reader_test.dart create mode 100644 test/src/imap/imap_response_test.dart create mode 100644 test/src/imap/parser_helper_test.dart create mode 100644 test/src/util/uint8_list_reader_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7e65bb7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Files and directories created by pub +.dart_tool/ +.packages +# Remove the following pattern if you wish to check in your lock file +pubspec.lock + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ + +# Generated coverage data +coverage + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..64aee88b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial alpha version diff --git a/README.md b/README.md new file mode 100644 index 00000000..8a02c381 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +An IMAP and SMTP client for Dart developers. + +Available under the commercial friendly +[MPL Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/). + +## Usage + +A simple usage example: + +```dart +import 'package:enough_mail/enough_mail.dart'; + +main() async { + var client = ImapClient(isLogEnabled: true); + await client.connectToServer('imap.example.com', 993, isSecure: true); + var loginResponse = await client.login('user.name', 'secret'); + if (loginResponse.isOkStatus) { + var listResponse = await client.listMailboxes(); + if (listResponse.isOkStatus) { + print('mailboxes: ${listResponse.result}'); + } + } +} +``` + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/Enough-Software/enough_mail/issues diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..a686c1b4 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,14 @@ +# Defines a default set of lint rules enforced for +# projects at Google. For details and rationale, +# see https://github.com/dart-lang/pedantic#enabled-lints. +include: package:pedantic/analysis_options.yaml + +# For lint rules and documentation, see http://dart-lang.github.io/linter/lints. +# Uncomment to specify additional rules. +# linter: +# rules: +# - camel_case_types + +analyzer: +# exclude: +# - path/to/excluded/files/** diff --git a/example/enough_mail_example.dart b/example/enough_mail_example.dart new file mode 100644 index 00000000..3ce8e702 --- /dev/null +++ b/example/enough_mail_example.dart @@ -0,0 +1,14 @@ +import 'package:enough_mail/enough_mail.dart'; + +void main() async { + var client = ImapClient(isLogEnabled: true); + await client.connectToServer('imap.example.com', 993, isSecure: true); + var loginResponse = await client.login('user.name', 'secret'); + if (loginResponse.isOkStatus) { + var listResponse = await client.listMailboxes(); + if (listResponse.isOkStatus) { + print('mailboxes: ${listResponse.result}'); + } + } + +} diff --git a/lib/encodings.dart b/lib/encodings.dart new file mode 100644 index 00000000..5f1fb624 --- /dev/null +++ b/lib/encodings.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; + +class EncodingsHelper { + static const String utfEncodingStart = '=?utf-8?'; + static const String utf8base64StartSequence = '=?utf-8?B?'; + static const String utf8QencodingStartSequence = '=?utf-8?Q?'; + static const String encodingEndSequence = '?='; + + static String decodeAny(String input) { + if (input == null) { + return null; + } + var sequenceStart = input.indexOf(utfEncodingStart); + if (sequenceStart != -1) { + var startIndex = input.indexOf(utf8base64StartSequence, sequenceStart); + if (startIndex != -1) { + return _decode( + input, utf8base64StartSequence, startIndex, decodeUtfBase64Part); + } else { + startIndex = input.indexOf(utf8QencodingStartSequence, sequenceStart); + if (startIndex != -1) { + return _decode(input, utf8QencodingStartSequence, startIndex, + decodeQuotedPrintablePart); + } + } + } + return input; + } + + static String decodeUtfBase64Part(String part) { + var outputList = base64.decode(part); + return String.fromCharCodes(outputList); + } + + static String decodeQuotedPrintablePart(String part) { + var buffer = StringBuffer(); + for (var i = 0; i < part.length; i++) { + var char = part[i]; + if (char == '=') { + var hexText = part.substring(i + 1, i + 3); + var charCode = int.parse(hexText, radix: 16); + buffer.writeCharCode(charCode); + i += 2; + } else if (char == '_') { + buffer.write(' '); + } else { + buffer.write(char); + } + } + return buffer.toString(); + } + + static String _decode(String input, String startSequence, int startIndex, + String Function(String) decodePart) { + var endIndex = + input.indexOf(encodingEndSequence, startIndex + startSequence.length); + var buffer = StringBuffer(); + if (startIndex > 0) { + buffer.write(input.substring(0, startIndex)); + } + while (startIndex != -1 && endIndex != -1) { + var part = input.substring(startIndex + startSequence.length, endIndex); + buffer.write(decodePart(part)); + startIndex = + input.indexOf(startSequence, endIndex + encodingEndSequence.length); + if (startIndex > endIndex + encodingEndSequence.length) { + buffer.write( + input.substring(endIndex + encodingEndSequence.length, startIndex)); + } else if (startIndex == -1 && + endIndex + encodingEndSequence.length < input.length) { + buffer.write(input.substring(endIndex + encodingEndSequence.length)); + } + if (startIndex != -1) { + endIndex = input.indexOf( + encodingEndSequence, startIndex + startSequence.length); + } + } + return buffer.toString(); + } + + static String encodeDate(DateTime dateTime) { + /* +Date and time values occur in several header fields. This section + specifies the syntax for a full date and time specification. Though + folding white space is permitted throughout the date-time + specification, it is RECOMMENDED that a single space be used in each + place that FWS appears (whether it is required or optional); some + older implementations will not interpret longer sequences of folding + white space correctly. + date-time = [ day-of-week "," ] date time [CFWS] + + day-of-week = ([FWS] day-name) / obs-day-of-week + + day-name = "Mon" / "Tue" / "Wed" / "Thu" / + "Fri" / "Sat" / "Sun" + + date = day month year + + day = ([FWS] 1*2DIGIT FWS) / obs-day + + month = "Jan" / "Feb" / "Mar" / "Apr" / + "May" / "Jun" / "Jul" / "Aug" / + "Sep" / "Oct" / "Nov" / "Dec" + + year = (FWS 4*DIGIT FWS) / obs-year + + time = time-of-day zone + + time-of-day = hour ":" minute [ ":" second ] + + hour = 2DIGIT / obs-hour + + minute = 2DIGIT / obs-minute + + second = 2DIGIT / obs-second + + zone = (FWS ( "+" / "-" ) 4DIGIT) / obs-zone + + The day is the numeric day of the month. The year is any numeric + year 1900 or later. + + The time-of-day specifies the number of hours, minutes, and + optionally seconds since midnight of the date indicated. + + The date and time-of-day SHOULD express local time. + + The zone specifies the offset from Coordinated Universal Time (UTC, + formerly referred to as "Greenwich Mean Time") that the date and + time-of-day represent. The "+" or "-" indicates whether the time-of- + day is ahead of (i.e., east of) or behind (i.e., west of) Universal + Time. The first two digits indicate the number of hours difference + from Universal Time, and the last two digits indicate the number of + additional minutes difference from Universal Time. (Hence, +hhmm + means +(hh * 60 + mm) minutes, and -hhmm means -(hh * 60 + mm) + minutes). The form "+0000" SHOULD be used to indicate a time zone at + Universal Time. Though "-0000" also indicates Universal Time, it is + used to indicate that the time was generated on a system that may be + in a local time zone other than Universal Time and that the date-time + contains no information about the local time zone. + + A date-time specification MUST be semantically valid. That is, the + day-of-week (if included) MUST be the day implied by the date, the + numeric day-of-month MUST be between 1 and the number of days allowed + for the specified month (in the specified year), the time-of-day MUST + be in the range 00:00:00 through 23:59:60 (the number of seconds + allowing for a leap second; see [RFC1305]), and the last two digits + of the zone MUST be within the range 00 through 59. + */ + var weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + var months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ]; + var buffer = StringBuffer(); + buffer.write(weekdays[dateTime.weekday - 1]); + buffer.write(', '); + buffer.write(dateTime.day); + buffer.write(' '); + buffer.write(months[dateTime.month - 1]); + buffer.write(' '); + buffer.write(dateTime.year); + buffer.write(' '); + buffer.write(dateTime.hour); + buffer.write(':'); + buffer.write(dateTime.minute); + buffer.write(':'); + buffer.write(dateTime.second); + buffer.write(' '); + if (dateTime.timeZoneOffset.inMinutes > 0) { + buffer.write('+'); + } else { + buffer.write('-'); + } + var hours = dateTime.timeZoneOffset.inHours; + if (hours < 10 && hours > -10) { + buffer.write('0'); + } + buffer.write(hours); + var minutes = dateTime.timeZoneOffset.inMinutes - + (dateTime.timeZoneOffset.inHours * 60); + if (minutes == 0) { + buffer.write('00'); + } else { + if (minutes < 10 && minutes > -10) { + buffer.write('0'); + } + buffer.write(minutes); + } + return buffer.toString(); + } +} diff --git a/lib/enough_mail.dart b/lib/enough_mail.dart new file mode 100644 index 00000000..260aabea --- /dev/null +++ b/lib/enough_mail.dart @@ -0,0 +1,13 @@ +library enough_mail; + +export 'imap/Address.dart'; +export 'imap/Events.dart'; +export 'imap/imap_client.dart'; +export 'imap/Mailbox.dart'; +export 'imap/Message.dart'; +export 'imap/Response.dart'; + +export 'smtp/smtp_client.dart'; +export 'smtp/smtp_response.dart'; + +export 'encodings.dart'; \ No newline at end of file diff --git a/lib/imap/address.dart b/lib/imap/address.dart new file mode 100644 index 00000000..062e66fe --- /dev/null +++ b/lib/imap/address.dart @@ -0,0 +1,19 @@ +/// An email address can consist of separate fields +class Address { + // personal name, [SMTP] at-domain-list (source route), mailbox name, and host name + String personalName; + String sourceRoute; + String mailboxName; + String hostName; + + String _emailAddress; + String get emailAddress => _getEmailAddress(); + set emailAddress (value) => _emailAddress = value; + + Address.fromEnvelope(this.personalName, this.sourceRoute, this.mailboxName, this.hostName); + + String _getEmailAddress() { + _emailAddress ??= '$mailboxName@$hostName'; + return _emailAddress; + } +} diff --git a/lib/imap/events.dart b/lib/imap/events.dart new file mode 100644 index 00000000..14e33d6b --- /dev/null +++ b/lib/imap/events.dart @@ -0,0 +1,40 @@ +/// Classification of IMAP events +/// +/// Compare [ImapEvent] +enum ImapEventType { expunge, fetch, exists, recent } + +/// Base class for any event that can be fired by the IMAP client at any time. +class ImapEvent { + final ImapEventType eventType; + ImapEvent(this.eventType); +} + +/// Notifies about a message that has been deleted +class ImapExpungeEvent extends ImapEvent { + int messageSequenceId; + ImapExpungeEvent(this.messageSequenceId) : super(ImapEventType.expunge); +} + +/// Notifies about a message that has changed its status +class ImapFetchEvent extends ImapEvent { + int messageSequenceId; + List flags; // TODO change to List + ImapFetchEvent(this.messageSequenceId, this.flags) + : super(ImapEventType.fetch); +} + +/// Notifies about new messages +class ImapMessagesExistEvent extends ImapEvent { + int newMessagesExists; + int oldMessagesExists; + ImapMessagesExistEvent(this.newMessagesExists, this.oldMessagesExists) + : super(ImapEventType.exists); +} + +/// Notifies about new messages +class ImapMessagesRecentEvent extends ImapEvent { + int newMessagesRecent; + int oldMessagesRecent; + ImapMessagesRecentEvent(this.newMessagesRecent, this.oldMessagesRecent) + : super(ImapEventType.recent); +} diff --git a/lib/imap/imap_client.dart b/lib/imap/imap_client.dart new file mode 100644 index 00000000..3dd2bd0e --- /dev/null +++ b/lib/imap/imap_client.dart @@ -0,0 +1,444 @@ +import 'dart:io'; +import 'package:event_bus/event_bus.dart'; +import 'package:enough_mail/imap/Mailbox.dart'; +import 'package:enough_mail/imap/Message.dart'; +import 'package:enough_mail/imap/Response.dart'; +import 'package:enough_mail/src/imap/capability_parser.dart'; +import 'package:enough_mail/src/imap/Command.dart'; +import 'package:enough_mail/src/imap/fetch_parser.dart'; +import 'package:enough_mail/src/imap/imap_response.dart'; +import 'package:enough_mail/src/imap/imap_response_reader.dart'; +import 'package:enough_mail/src/imap/list_parser.dart'; +import 'package:enough_mail/src/imap/logout_parser.dart'; +import 'package:enough_mail/src/imap/noop_parser.dart'; +import 'package:enough_mail/src/imap/response_parser.dart'; +import 'package:enough_mail/src/imap/search_parser.dart'; +import 'package:enough_mail/src/imap/select_parser.dart'; +import 'package:enough_mail/src/imap/status_parser.dart'; + +/// Describes a capability +class Capability { + String name; + Capability(this.name); + + @override + String toString() { + return name; + } +} + +/// Keeps information about the remote IMAP server +/// +/// Persist this information to improve initialization times. +class ImapServerInfo { + String host; + bool isSecure; + int port; + String pathSeparator; + String capabilitiesText; + List capabilities; +} + +enum StatusFlags { messages, recent, uidNext, uidValidity, unseen } + +/// Low-level IMAP library for Dartlang +/// +/// Compliant to IMAP4rev1 standard [RFC 3501]. +class ImapClient { + /// Information about the IMAP service + ImapServerInfo serverInfo; + + /// Allows to listens for events + /// + /// If no event bus is specified in the constructor, an aysnchronous bus is used. + /// Usage: + /// ``` + /// eventBus.on().listen((event) { + /// // All events are of type ImapExpungeEvent (or subtypes of it). + /// _log(event.messageSequenceId); + /// }); + /// + /// eventBus.on().listen((event) { + /// // All events are of type ImapEvent (or subtypes of it). + /// _log(event.eventType); + /// }); + /// ``` + EventBus eventBus; + bool get isLoggedIn => _isLoggedIn; + bool get isNotLoggedIn => !_isLoggedIn; + + bool _isLoggedIn = false; + Socket _socket; + int _lastUsedCommandId = 0; + CommandTask _currentCommandTask; + final Map _tasks = {}; + Mailbox _selectedMailbox; + bool _isLogEnabled; + ImapResponseReader _imapResponseReader; + + bool _isInIdleMode = false; + + /// Creates a new instance with the optional [bus] event bus. + /// + /// Compare [eventBus] for more information. + ImapClient({EventBus bus, bool isLogEnabled = false}) { + eventBus ??= EventBus(); + ; + _isLogEnabled = isLogEnabled ?? false; + _imapResponseReader = ImapResponseReader(onServerResponse); + } + + /// Connects to the specified server. + /// + /// Specify [isSecure] if you do not want to connect to a secure service. + Future connectToServer(String host, int port, + {bool isSecure = true}) async { + serverInfo = ImapServerInfo(); + serverInfo.host = host; + serverInfo.port = port; + serverInfo.isSecure = isSecure; + _log( + 'Connecting to $host:$port ${isSecure ? '' : 'NOT'} using a secure socket...'); + + var socket = isSecure + ? await SecureSocket.connect(host, port) + : await Socket.connect(host, port); + connect(socket); + return socket; + } + + /// Starts to liste on [socket]. + /// + /// This is mainly useful for testing purposes, ensure to set [serverInfo] manually in this case. + void connect(Socket socket) { + socket.listen(_imapResponseReader.onData, + onDone: () { + _isLoggedIn = false; + _log('Done, connection closed'); + }, + onError: (error) { + //TODO reconnect + _isLoggedIn = false; + _log('Error: $error'); + }); + _socket = socket; + } + + Future>> login(String name, String password) async { + var cmd = Command('LOGIN $name $password'); + cmd.logText = 'LOGIN $name (password scrambled)'; + var parser = CapabilityParser(serverInfo); + var response = await sendCommand>(cmd, parser); + _isLoggedIn = response.isOkStatus; + return response; + } + + Future> logout() async { + var cmd = Command('LOGOUT'); + var response = await sendCommand(cmd, LogoutParser()); + _isLoggedIn = false; + return response; + } + + Future> noop() { + var cmd = Command('NOOP'); + return sendCommand(cmd, NoopParser(eventBus, _selectedMailbox)); + } + + /// lists all mailboxes in the given [path]. + /// + /// The [path] default to "", meaning the currently selected mailbox, if there is none selected, then the root is used. + /// When [recursive] is true, then all submailboxes are also listed. + /// The LIST command will set the [serverInfo.pathSeparator] as a side-effect + Future>> listMailboxes( + {String path = '""', bool recursive = false}) { + return listMailboxesByReferenceAndName( + path, (recursive ? '*' : '%')); // list all folders in that path + } + + /// lists all mailboxes in the path [referenceName] that match the given [mailboxName] that can contain wildcards. + /// + /// The LIST command will set the [serverInfo.pathSeparator] as a side-effect + Future>> listMailboxesByReferenceAndName( + String referenceName, String mailboxName) { + var cmd = Command('LIST $referenceName $mailboxName'); + var parser = ListParser(serverInfo); + return sendCommand>(cmd, parser); + } + + Future>> listSubscribedMailboxes( + {String path = '""', bool recursive = false}) { + //Command cmd = Command("LIST \"INBOX/\" %"); + var cmd = Command('LSUB $path ' + + (recursive ? '*' : '%')); // list all folders in that path + var parser = ListParser(serverInfo, isLsubParser: true); + return sendCommand>(cmd, parser); + } + + Future> selectMailbox(Mailbox box) { + var cmd = Command('SELECT ' + box.path); + var parser = SelectParser(box); + _selectedMailbox = box; + return sendCommand(cmd, parser); + } + + Future closeMailbox() { + var cmd = Command('CLOSE'); + _selectedMailbox = null; + return sendCommand(cmd, null); + } + + Future>> searchMessages( + [String searchCriteria = 'UNSEEN']) { + var cmd = Command('SEARCH $searchCriteria'); + var parser = SearchParser(); + return sendCommand>(cmd, parser); + } + + Future>> fetchMessages(int lowerMessageSequenceId, + int upperMessageSequenceId, String fetchContentDefinition) { + var cmdText = StringBuffer(); + cmdText.write('FETCH '); + cmdText.write(lowerMessageSequenceId); + if (upperMessageSequenceId != -1 && + upperMessageSequenceId != lowerMessageSequenceId) { + cmdText.write(':'); + cmdText.write(upperMessageSequenceId); + } + cmdText.write(' '); + cmdText.write(fetchContentDefinition); + var cmd = Command(cmdText.toString()); + var parser = FetchParser(); + return sendCommand>(cmd, parser); + } + + Future>> fetchMessagesByCriteria( + String fetchIdsAndCriteria) { + var cmd = Command('FETCH $fetchIdsAndCriteria'); + var parser = FetchParser(); + return sendCommand>(cmd, parser); + } + + /// Examines the [mailbox] without selecting it. + /// + /// Also compare: statusMailbox(Mailbox, StatusFlags) + /// The EXAMINE command is identical to SELECT and returns the same + /// output; however, the selected mailbox is identified as read-only. + /// No changes to the permanent state of the mailbox, including + /// per-user state, are permitted; in particular, EXAMINE MUST NOT + /// cause messages to lose the \Recent flag. + Future> examineMailbox(Mailbox box) { + var cmd = Command('EXAMINE ${box.path}'); + var parser = SelectParser(box); + return sendCommand(cmd, parser); + } + + /// Checks the status of the currently not selected [mailbox]. + /// + /// The STATUS command requests the status of the indicated mailbox. + /// It does not change the currently selected mailbox, nor does it + /// affect the state of any messages in the queried mailbox (in + /// particular, STATUS MUST NOT cause messages to lose the \Recent + /// flag). + /// + /// The STATUS command provides an alternative to opening a second + /// IMAP4rev1 connection and doing an EXAMINE command on a mailbox to + /// query that mailbox's status without deselecting the current + /// mailbox in the first IMAP4rev1 connection. + Future> statusMailbox( + Mailbox box, List flags) { + var flagsStr = '('; + var addSpace = false; + for (var flag in flags) { + if (addSpace) { + flagsStr += ' '; + } + switch (flag) { + case StatusFlags.messages: + flagsStr += 'MESSAGES'; + addSpace = true; + break; + case StatusFlags.recent: + flagsStr += 'RECENT'; + addSpace = true; + break; + case StatusFlags.uidNext: + flagsStr += 'UIDNEXT'; + addSpace = true; + break; + case StatusFlags.uidValidity: + flagsStr += 'UIDVALIDITY'; + addSpace = true; + break; + case StatusFlags.unseen: + flagsStr += 'UNSEEN'; + addSpace = true; + break; + } + } + flagsStr += ')'; + var cmd = Command('STATUS ${box.path} $flagsStr'); + var parser = StatusParser(box); + return sendCommand(cmd, parser); + } + + Future> createMailbox(String path) async { + var cmd = Command('CREATE $path'); + var response = await sendCommand(cmd, null); + if (response.isOkStatus) { + var mailboxesResponse = await listMailboxes(path: path); + if (mailboxesResponse.isOkStatus && + mailboxesResponse.result != null && + mailboxesResponse.result.isNotEmpty) { + response.result = mailboxesResponse.result[0]; + return response; + } + } + return response; + } + + Future> deleteMailbox(Mailbox box) { + var cmd = Command('DELETE ${box.path}'); + return sendCommand(cmd, null); + } + + Future> renameMailbox(Mailbox box, String newName) async { + var cmd = Command('RENAME ${box.path} $newName'); + var response = await sendCommand(cmd, null); + if (response.isOkStatus) { + if (box.name == 'INBOX') { + /* Renaming INBOX is permitted, and has special behavior. It moves + all messages in INBOX to a new mailbox with the given name, + leaving INBOX empty. If the server implementation supports + inferior hierarchical names of INBOX, these are unaffected by a + rename of INBOX. + */ + // question: do we need to create a new mailbox and return that one instead? + } + box.name = newName; + } + return response; + } + + Future> subscribeMailbox(Mailbox box) { + var cmd = Command('SUBSCRIBE ${box.path}'); + return sendCommand(cmd, null); + } + + Future> unsubscribeMailbox(Mailbox box) { + var cmd = Command('UNSUBSCRIBE ${box.path}'); + return sendCommand(cmd, null); + } + + /// Switches to IDLE mode. + /// Requires a mailbox to be selected. + Future> idleStart() { + if (_selectedMailbox == null) { + print('idle: no mailbox selected'); + } + _isInIdleMode = true; + var cmd = Command('IDLE'); + return sendCommand(cmd, NoopParser(eventBus, _selectedMailbox)); + } + + /// Stops the IDLE mode, + /// for example after receiving information about a new . + /// Requires a mailbox to be selected. + void idleDone() { + _isInIdleMode = false; + return write('DONE'); + } + + String nextId() { + var id = _lastUsedCommandId++; + return 'a$id'; + } + + Future> sendCommand( + Command command, ResponseParser parser) { + var task = CommandTask(command, nextId(), parser); + _tasks[task.id] = task; + writeTask(task); + return task.completer.future; + } + + void writeTask(CommandTask task) { + _currentCommandTask = task; + _log('C: $task'); + _socket?.writeln(task.toImapRequest()); + } + + void write(String commandText) { + _log('C: $commandText'); + _socket?.writeln(commandText); + } + + void onServerResponse(ImapResponse imapResponse) { + _log('S: $imapResponse'); + var line = imapResponse.parseText; + //var log = imapResponse.toString().replaceAll("\r\n", "\n"); + //_log("S: $log"); + + //_log("subline: " + line); + if (line.startsWith('* ')) { + // this is an untagged response and can be anything + imapResponse.parseText = line.substring('* '.length); + onUntaggedResponse(imapResponse); + } else if (line.startsWith('+ ')) { + imapResponse.parseText = line.substring('+ '.length); + onContinuationResponse(imapResponse); + } else { + onCommandResult(imapResponse); + } + } + + void onCommandResult(ImapResponse imapResponse) { + var line = imapResponse.parseText; + var spaceIndex = line.indexOf(' '); + if (spaceIndex != -1) { + var commandId = line.substring(0, spaceIndex); + var task = _tasks[commandId]; + if (task != null) { + if (task == _currentCommandTask) { + _currentCommandTask = null; + } + imapResponse.parseText = line.substring(spaceIndex + 1); + var response = task.parse(imapResponse); + task.completer.complete(response); + } else { + _log('ERROR: no task found for command [$commandId]'); + } + } else { + _log('unexpected SERVER response: [$imapResponse]'); + } + } + + void onUntaggedResponse(ImapResponse imapResponse) { + var task = _currentCommandTask; + if (task == null || !task.parseUntaggedResponse(imapResponse)) { + _log('untagged not handled: [$imapResponse]'); + } + } + + void onContinuationResponse(ImapResponse imapResponse) { + if (!_isInIdleMode) { + _log('continuation not handled: [$imapResponse]'); + } + } + + void writeCommand(String command) { + var id = _lastUsedCommandId++; + _socket?.writeln('$id $command'); + } + + void close() async { + _log('Closing socket for host ${serverInfo.host}'); + await _socket?.close(); + } + + void _log(String text) { + if (_isLogEnabled) { + print(text); + } + } +} diff --git a/lib/imap/mailbox.dart b/lib/imap/mailbox.dart new file mode 100644 index 00000000..59e8854b --- /dev/null +++ b/lib/imap/mailbox.dart @@ -0,0 +1,64 @@ +/// Contains common flags for mailboxes +enum MailboxFlag { + marked, + unMarked, + hasChildren, + hasNoChildren, + noSelect, + select, + noInferior, + subscribed, + remote, + nonExistent, + all, + inbox, + sent, + drafts, + junk, + trash, + archive, + flagged +} + + +/// Stores meta data about a folder aka Mailbox +class Mailbox { + String name; + String path; + bool isMarked = false; + bool hasChildren = false; + bool isSelected = false; + bool isUnselectable = false; + int messagesRecent; + int messagesExists; + int firstUnseenMessageSequenceId; + int uidValidity; + int uidNext; + bool isReadWrite = false; + int highestModSequence; + List flags = []; + List messageFlags; + List permanentMessageFlags; + + bool get isInbox => hasFlag(MailboxFlag.inbox); + bool get isDrafts => hasFlag(MailboxFlag.drafts); + bool get isSent => hasFlag(MailboxFlag.sent); + bool get isJunk => hasFlag(MailboxFlag.junk); + bool get isTrash => hasFlag(MailboxFlag.trash); + bool get isArchive => hasFlag(MailboxFlag.archive); + + bool get isSpecialUse => + isInbox || isDrafts || isSent || isJunk || isTrash || isArchive; + + Mailbox(); + Mailbox.setup(this.name, this.flags) { + this.isMarked = hasFlag(MailboxFlag.marked); + this.hasChildren = hasFlag(MailboxFlag.hasChildren); + this.isSelected = hasFlag(MailboxFlag.select); + this.isUnselectable = hasFlag(MailboxFlag.noSelect); + } + + bool hasFlag(MailboxFlag flag) { + return flags.contains(flag); + } +} \ No newline at end of file diff --git a/lib/imap/message.dart b/lib/imap/message.dart new file mode 100644 index 00000000..134af372 --- /dev/null +++ b/lib/imap/message.dart @@ -0,0 +1,220 @@ +import 'package:enough_mail/src/imap/parser_helper.dart'; + +import '../encodings.dart'; +import '../enough_mail.dart'; + +/// Common flags for messages +enum MessageFlag { answered, flagged, deleted, seen, draft } + +/// An IMAP message +class Message { + List rawLines; + + /// The index of the message, if known + int sequenceId; + + /// Message flags like \Seen, \Recent, etc + List flags; + + String internalDate; + + int size; + + String subject; + String date; + String inReplyTo; + String messageId; + + String get fromEmail => _getFromEmail(); + + Address from; + Address sender; + Address replyTo; + Address to; + Address cc; + Address bcc; + + Body body; + List
headers; + List recipients = []; + + String bodyRaw; + + String _headerRaw; + String get headerRaw => _getHeaderRaw(); + set headerRaw(String headerRaw) => _headerRaw = headerRaw; + + void addHeader(String name, String value) { + _headerRaw = null; + headers ??=
[]; + headers.add(Header(name, value)); + } + + void setBodyPart(int partIndex, String content) { + body ??= Body(); + body.setBodyPart(partIndex, content); + } + + String getBodyPart(int partIndex) { + return body?.getBodyPart(partIndex); + } + + Iterable
getHeader(String name) => + _getHeaderLowercase(name.toLowerCase()); + + Iterable
_getHeaderLowercase(String name) => + headers?.where((h) => h.name.toLowerCase() == name); + + /// Retrieves the raw value of the first matching header. + /// + /// Some headers may contain encoded values such as '=?utf-8?B??='. + /// Compare [decodeHeaderValue] for retrieving the header value in decoded form. + /// Compare [getHeader] for retrieving the full header with the given name. + String getHeaderValue(String name) { + var headers = getHeader(name.toLowerCase()); + if (headers == null || headers.isEmpty) { + return null; + } + return headers.first.value; + } + + String decodeHeaderValue(String name) { + return EncodingsHelper.decodeAny(getHeaderValue(name)); + } + + String _getFromEmail() { + if (from != null) { + return from.emailAddress; + } else if (headers != null) { + var fromHeader = getHeader('from')?.first; + if (fromHeader != null) { + return ParserHelper.parseEmail(fromHeader.value); + } + } + return null; + } + + @override + String toString() { + var buffer = StringBuffer(); + buffer.write('id: ['); + buffer.write(sequenceId); + buffer.write(']\n'); + if (headers != null) { + for (var head in headers) { + head.toStringBuffer(buffer); + buffer.write('\n'); + } + buffer.write('\n'); + } + if (bodyRaw != null) { + buffer.write(bodyRaw); + } + return buffer.toString(); + } + + String _getHeaderRaw() { + if (_headerRaw != null) { + return _headerRaw; + } + if (headers == null) { + return null; + } + var buffer = StringBuffer(); + for (var header in headers) { + buffer.write(header.name); + buffer.write(': '); + buffer.write(header.value); + buffer.write('\r\n'); + } + _headerRaw = buffer.toString(); + return _headerRaw; + } +} + +class Header { + String name; + String value; + + Header(this.name, this.value); + + @override + String toString() { + return '$name: $value'; + } + + void toStringBuffer(StringBuffer buffer) { + buffer.write(name); + buffer.write(': '); + buffer.write(value); + } +} + +class BodyAttribute { + String name; + String value; + + BodyAttribute(this.name, this.value); +} + +class BodyStructure { + /// A string giving the content media type name as defined in [MIME-IMB]. + /// Examples: text, image + String type; + + /// A string giving the content subtype name as defined in [MIME-IMB]. + /// Example: plain, html, png + String subtype; + + /// body parameter parenthesized list as defined in [MIME-IMB]. + List attributes = []; + + /// A string giving the content id as defined in [MIME-IMB]. + String id; + + /// A string giving the content description as defined in [MIME-IMB]. + String description; + + /// A string giving the content transfer encoding as defined in [MIME-IMB]. + /// Examples: 7bit, utf-8, US-ASCII + String encoding; + + /// 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. + int size; + + /// Some message types like MESSAGE/RFC822 or TEXT also provide the number of lines + int numberOfLines; + + BodyStructure(this.type, this.subtype, this.id, this.description, + this.encoding, this.size); + + void addAttribute(String name, String value) { + attributes.add(BodyAttribute(name, value)); + } +} + +class Body { + List structures = []; + List parts = []; + String type; + + void addStructure(BodyStructure structure) { + structures.add(structure); + } + + void setBodyPart(int partIndex, String content) { + while (parts.length <= partIndex) { + parts.add(null); + } + parts[partIndex] = content; + } + + String getBodyPart(int partIndex) { + if (partIndex >= parts.length) { + return null; + } + return parts[partIndex]; + } +} diff --git a/lib/imap/response.dart b/lib/imap/response.dart new file mode 100644 index 00000000..581adcb5 --- /dev/null +++ b/lib/imap/response.dart @@ -0,0 +1,12 @@ +/// Status for command responses. +enum ResponseStatus { OK, No, Bad } + +/// Base class for command responses. +class Response { + ResponseStatus status; + String details; + T result; + + bool get isOkStatus => status == ResponseStatus.OK; + bool get isFailedStatus => !isOkStatus; +} \ No newline at end of file diff --git a/lib/smtp/smtp_client.dart b/lib/smtp/smtp_client.dart new file mode 100644 index 00000000..aae22e83 --- /dev/null +++ b/lib/smtp/smtp_client.dart @@ -0,0 +1,192 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:event_bus/event_bus.dart'; +import 'package:enough_mail/smtp/smtp_response.dart'; +import 'package:enough_mail/src/smtp/smtp_command.dart'; +import 'package:enough_mail/src/smtp/commands/all_commands.dart'; +import 'package:enough_mail/imap/Message.dart'; +import 'package:enough_mail/src/util/uint8_list_reader.dart'; + +/// Keeps information about the remote IMAP server +/// +/// Persist this information to improve initialization times. +class SmtpServerInfo { + String host; + bool isSecure; + int port; + String capabilitiesText; + List capabilities; +} + +/// Low-level SMTP library for Dartlang +/// +/// Compliant to Extended SMTP standard [RFC 5321]. +class SmtpClient { + /// Information about the IMAP service + SmtpServerInfo serverInfo; + + /// Allows to listens for events + /// + /// If no event bus is specified in the constructor, an aysnchronous bus is used. + /// Usage: + /// ``` + /// eventBus.on().listen((event) { + /// // All events are of type ImapSendEvent (or subtypes of it). + /// _log(event.messageSequenceId); + /// }); + /// + /// eventBus.on().listen((event) { + /// // All events are of type SmtpEvent (or subtypes of it). + /// _log(event.eventType); + /// }); + /// ``` + EventBus eventBus; + bool get isLoggedIn => _isLoggedIn; + bool get isNotLoggedIn => !_isLoggedIn; + + bool _isLoggedIn = false; + + String _clientDomain; + + Socket _socket; + final Uint8ListReader _uint8listReader = Uint8ListReader(); + bool _isLogEnabled; + SmtpCommand _currentCommand; + + /// Creates a new instance with the optional [bus] event bus. + /// + /// Compare [eventBus] for more information. + SmtpClient(String clientDomain, {EventBus bus, bool isLogEnabled = false}) { + _clientDomain = clientDomain; + bus ??= EventBus(); + eventBus = bus; + _isLogEnabled = isLogEnabled; + } + + /// Connects to the specified server. + /// + /// Specify [isSecure] if you do not want to connect to a secure service. + Future connectToServer(String host, int port, + {bool isSecure = true}) async { + _log('connecting to server $host:$port - secure: $isSecure'); + serverInfo = SmtpServerInfo(); + serverInfo.host = host; + serverInfo.port = port; + serverInfo.isSecure = isSecure; + + var socket = isSecure + ? await SecureSocket.connect(host, port) + : await Socket.connect(host, port); + connect(socket); + return socket; + } + + /// Starts to liste on [socket]. + /// + /// This is mainly useful for testing purposes, ensure to set [serverInfo] manually in this case. + void connect(Socket socket) { + socket.listen(onData, onDone: () { + _log('Done, connection closed'); + _isLoggedIn = false; + }, onError: (error) { + //TODO reconnect + _log('Error: $error'); + _isLoggedIn = false; + }); + _socket = socket; + } + + void onData(Uint8List data) { + //print('onData: [${String.fromCharCodes(data).replaceAll("\r\n", "\n")}]'); + _uint8listReader.add(data); + onServerResponse(_uint8listReader.readLines()); + } + + /// Issues the enhanced helo command to find out the capabilities of the SMTP server + /// + /// EHLO or HELO always needs to be the first command that is sent to the SMTP server. + Future ehlo() { + return sendCommand(SmtpEhloCommand(_clientDomain)); + } + + /// Upgrades the current insure connection to SSL. + /// + /// Opportunistic TLS (Transport Layer Security) refers to extensions + /// in plain text communication protocols, which offer a way to upgrade a plain text connection + /// to an encrypted (TLS or SSL) connection instead of using a separate port for encrypted communication. + Future startTls() async { + var response = await sendCommand(SmtpStartTlsCommand()); + if (response.isOkStatus) { + print('upgrading socket to secure one...'); + var secureSocket = await SecureSocket.secure(_socket); + print('done upgrading...'); + if (secureSocket != null) { + await _socket.close(); + await _socket.destroy(); + connect(secureSocket); + //_socket = secureSocket; + await ehlo(); + } + } + return response; + } + + Future sendMessage(Message message, + [bool use8BitEncoding = true]) { + return sendCommand(SmtpSendMailCommand(message, use8BitEncoding)); + } + + Future login(String name, String password) { + return sendCommand(SmtpAuthCommand(name, password)); + } + + Future quit() async { + var response = await sendCommand(SmtpQuitCommand(this)); + _isLoggedIn = false; + return response; + } + + Future sendCommand(SmtpCommand command) { + _currentCommand = command; + _log('C: ${command.command}'); + _socket?.writeln(command.command); + return command.completer.future; + } + + void write(String commandText) { + _log('C: $commandText'); + _socket?.writeln(commandText); + } + + void onServerResponse(List responseTexts) { + if (_isLogEnabled) { + for (var responseText in responseTexts) { + _log('S: $responseText'); + } + } + var response = SmtpResponse(responseTexts); + if (_currentCommand != null) { + var commandText = _currentCommand.nextCommand(response); + if (commandText != null) { + write(commandText); + } else if (_currentCommand.isCommandDone(response)) { + _currentCommand.completer.complete(response); + //_log("Done with command ${_currentCommand.command}"); + _currentCommand = null; + } + } + } + + void close() { + _socket?.close(); + } + + void _log(String text) { + if (_isLogEnabled) { + if (text.startsWith('C: AUTH PLAIN ')) { + text = 'C: AUTH PLAIN '; + } + print(text); + } + } +} diff --git a/lib/smtp/smtp_response.dart b/lib/smtp/smtp_response.dart new file mode 100644 index 00000000..35e20815 --- /dev/null +++ b/lib/smtp/smtp_response.dart @@ -0,0 +1,65 @@ +enum SmtpResponseType { + accepted, + success, + needInfo, + temporaryError, + fatalError, + unknown +} + +class SmtpResponse { + List responseLines = []; + int get code => responseLines.last.code; + String get message => responseLines.last.message; + SmtpResponseType get type => responseLines.last.type; + bool get isOkStatus => type == SmtpResponseType.success; + bool get isFailedStatus => !(isOkStatus || type == SmtpResponseType.accepted); + + SmtpResponse(List responseTexts) { + for (var responseText in responseTexts) { + if (responseText.isNotEmpty) { + responseLines.add(SmtpResponseLine(responseText)); + } + } + } +} + +class SmtpResponseLine { + int code; + String message; + SmtpResponseType get type => _getType(); + + SmtpResponseLine(String responseText) { + code = int.tryParse(responseText.substring(0, 3)); + if (code == null) { + message = responseText; + } else { + message = responseText.substring(4); + } + } + + SmtpResponseType _getType() { + SmtpResponseType type; + switch (code ~/ 100) { + case 1: + type = SmtpResponseType.accepted; + break; + case 2: + type = SmtpResponseType.success; + break; + case 3: + type = SmtpResponseType.needInfo; + break; + case 4: + type = SmtpResponseType.temporaryError; + break; + case 5: + type = SmtpResponseType.fatalError; + break; + + default: + type = SmtpResponseType.unknown; + } + return type; + } +} diff --git a/lib/src/enough_mail_base.dart b/lib/src/enough_mail_base.dart new file mode 100644 index 00000000..e8a6f159 --- /dev/null +++ b/lib/src/enough_mail_base.dart @@ -0,0 +1,6 @@ +// TODO: Put public facing types in this file. + +/// Checks if you are awesome. Spoiler: you are. +class Awesome { + bool get isAwesome => true; +} diff --git a/lib/src/enough_mailkit_base.dart b/lib/src/enough_mailkit_base.dart new file mode 100644 index 00000000..e8a6f159 --- /dev/null +++ b/lib/src/enough_mailkit_base.dart @@ -0,0 +1,6 @@ +// TODO: Put public facing types in this file. + +/// Checks if you are awesome. Spoiler: you are. +class Awesome { + bool get isAwesome => true; +} diff --git a/lib/src/imap/all_parsers.dart b/lib/src/imap/all_parsers.dart new file mode 100644 index 00000000..3b0f1f9d --- /dev/null +++ b/lib/src/imap/all_parsers.dart @@ -0,0 +1,5 @@ +export 'list_parser.dart'; +export 'fetch_parser.dart'; +export 'logout_parser.dart'; +export 'noop_parser.dart'; + diff --git a/lib/src/imap/capability_parser.dart b/lib/src/imap/capability_parser.dart new file mode 100644 index 00000000..9d56d211 --- /dev/null +++ b/lib/src/imap/capability_parser.dart @@ -0,0 +1,48 @@ +import 'package:enough_mail/imap/imap_client.dart'; +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +class CapabilityParser extends ResponseParser> { + final ImapServerInfo info; + CapabilityParser(this.info); + + @override + List parse(ImapResponse details, Response> response) { + if (response.isOkStatus) { + if (details.parseText.startsWith('OK [CAPABILITY ')) { + parseCapabilities(details.first.line, 'OK [CAPABILITY '.length); + } + return info.capabilities; + } + return null; + } + + @override + bool parseUntagged(ImapResponse details, Response> response) { + var line = details.parseText; + if (line.startsWith('OK [CAPABILITY ')) { + parseCapabilities(line, 'OK [CAPABILITY '.length); + return true; + } else if (line.startsWith('CAPABILITY ')) { + parseCapabilities(line, 'CAPABILITY '.length); + return true; + } + return super.parseUntagged(details, response); + } + + void parseCapabilities(String details, int startIndex) { + var closeIndex = details.lastIndexOf(']'); + var capText; + if (closeIndex == -1) { + capText = details.substring(startIndex); + } else { + capText = details.substring(startIndex, closeIndex); + } + info.capabilitiesText = capText; + var capNames = capText.split(' '); + var caps = capNames.map((name) => Capability(name)).toList(); + info.capabilities = caps; + } +} diff --git a/lib/src/imap/command.dart b/lib/src/imap/command.dart new file mode 100644 index 00000000..6d414230 --- /dev/null +++ b/lib/src/imap/command.dart @@ -0,0 +1,75 @@ +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; + + +import 'dart:async'; + +import 'imap_response.dart'; + +class Command +{ + String commandText; + String logText; + + Command(this.commandText); + + String toString() + { + return logText ?? commandText; + } +} + +class CommandTask +{ + final Command command; + final String id; + final ResponseParser parser; + + final Response response = Response(); + final Completer> completer = Completer>(); + + CommandTask(this.command, this.id, this.parser); + + @override + String toString() + { + return id + ' ' + command.toString(); + } + + String toImapRequest() + { + return id + ' ' + command.commandText; + } + + Response parse(ImapResponse imapResponse) + { + if (imapResponse.parseText.startsWith('OK ')) + { + response.status = ResponseStatus.OK; + } + else if (imapResponse.parseText.startsWith('NO ')) + { + response.status = ResponseStatus.No; + } + else + { + response.status = ResponseStatus.Bad; + } + if (parser != null) + { + response.result = parser.parse(imapResponse, response); + } + return response; + } + + bool parseUntaggedResponse(ImapResponse details) + { + if (parser != null) { + return parser.parseUntagged(details, response); + } else { + return false; + } + } + +} \ No newline at end of file diff --git a/lib/src/imap/fetch_parser.dart b/lib/src/imap/fetch_parser.dart new file mode 100644 index 00000000..e2acd4f0 --- /dev/null +++ b/lib/src/imap/fetch_parser.dart @@ -0,0 +1,294 @@ +import 'package:enough_mail/src/imap/parser_helper.dart'; +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +class FetchParser extends ResponseParser> { + final List _messages = []; + + @override + List parse(ImapResponse details, Response> response) { + return response.isOkStatus ? _messages : null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, Response> response) { + var details = imapResponse.first.line; + var fetchIndex = details.indexOf(' FETCH '); + var message = Message(); + // eg "* 2389 FETCH (...)" + + message.sequenceId = parseInt(details, 2, ' '); + _messages.add(message); + if (fetchIndex != -1) { + var iterator = imapResponse.iterate(); + for (var value in iterator.values) { + if (value.value == 'FETCH') { + _parseFetch(message, value); + } + } + + return true; + } + return super.parseUntagged(imapResponse, response); + } + + void _parseFetch(Message message, ImapValue fetchValue) { + var children = fetchValue.children; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var hasNext = i < children.length - 1; + switch (child.value) { + case 'FLAGS': + message.flags = + List.from(child.children.map((flag) => flag.value)); + 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); + i++; + } + break; + case 'ENVELOPE': + _parseEnvelope(message, child); + break; + case 'BODY': + _parseBody(message, child); + break; + case 'BODY[HEADER]': + if (hasNext) { + i++; + _parseBodyHeader(message, children[i]); + } + break; + case 'BODY[]': + if (hasNext) { + i++; + _parseBodyFull(message, children[i]); + } + break; + default: + if (hasNext && + child.value.startsWith('BODY[') && + child.value.endsWith(']')) { + i++; + _parseBodyPart(message, child.value, children[i]); + } else { + print( + 'fetch: encountered unexpected/unsupported element ${child.value}'); + } + } + } + } + + void _parseBodyPart( + Message message, String bodyPartDefinition, ImapValue imapValue) { + var startIndex = 'BODY['.length; + var endIndex = bodyPartDefinition.length - 1; + var partIndex = + int.parse(bodyPartDefinition.substring(startIndex, endIndex)); + //print("parse body part: $partIndex\n${headerValue.value}\n"); + message.setBodyPart(partIndex, imapValue.value); + } + + void _parseBodyFull(Message message, ImapValue headerValue) { + //print("Parsing BODY[]\n[${headerValue.value}]"); + var headerParseResult = _parseBodyHeader(message, headerValue); + if (headerParseResult.bodyStartIndex != null) { + if (headerParseResult.bodyStartIndex >= headerValue.value.length) { + print( + 'error: got invalid body start index ${headerParseResult.bodyStartIndex} with max index being ${(headerValue.value.length - 1)}'); + var i = 1; + for (var header in message.headers) { + print('-- $i: $header'); + i++; + } + return; + } + var bodyText = + headerValue.value.substring(headerParseResult.bodyStartIndex); + message.bodyRaw = bodyText; + //print("Parsing BODY text \n$bodyText"); + } + } + + HeaderParseResult _parseBodyHeader(Message message, ImapValue headerValue) { + //print('Parsing BODY[HEADER]\n[${headerValue.value}]'); + var headerParseResult = ParserHelper.parseHeader(headerValue.value); + var headers = headerParseResult.headers; + for (var header in headers) { + //print('addding header ${header.name}: ${header.value}'); + message.addHeader(header.name, header.value); + } + return headerParseResult; + } + + void _parseBody(Message 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]. + + // [1].children 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]. + + // [2]body id + // A string giving the content id as defined in [MIME-IMB]. + + // [3]body description + // A string giving the content description as defined in + // [MIME-IMB]. + + // [4]body encoding + // A string giving the content transfer encoding as defined in + // [MIME-IMB]. + + // [5]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. + + // [6] + // 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. + var children = bodyValue.children; + //print("body: $children"); + var body = Body(); + var isBodyTypeSet = false; + for (var child in children) { + if (child.children != null && child.children.length >= 6) { + // this is a structure value + var structs = child.children; + var size = int.tryParse(structs[5].value); + var structure = BodyStructure( + structs[0].value, + structs[1].value, + _checkForNil(structs[2].value), + _checkForNil(structs[3].value), + structs[4].value, + size); + if (structs.length > 6) { + structure.numberOfLines = int.tryParse(structs[6].value); + } + var attributeValues = structs[1].children; + if (attributeValues != null && attributeValues.length > 1) { + for (var i = 0; i < attributeValues.length; i += 2) { + structure.addAttribute( + attributeValues[i].value, attributeValues[i + 1].value); + } + } + body.addStructure(structure); + } else if (!isBodyTypeSet) { + // this is the type: + isBodyTypeSet = true; + body.type = child.value; + } + message.body = body; + } + } + + /// parses the envelope structure of a message + void _parseEnvelope(Message 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. + var children = envelopeValue.children; + //print("envelope: $children"); + if (children != null && children.length >= 10) { + message.date = children[0].value; + message.subject = children[1].value; + message.from = _parseAddress(children[2]); + message.sender = _parseAddress(children[3]); + message.replyTo = _parseAddress(children[4]); + message.to = _parseAddress(children[5]); + message.cc = _parseAddress(children[6]); + message.bcc = _parseAddress(children[7]); + message.inReplyTo = children[8].value; + message.messageId = children[9].value; + } + } + + Address _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. + + if (addressValue.value == 'NIL' || + addressValue.children == null || + addressValue.children.length < 4) { + return null; + } + var children = addressValue.children; + return Address.fromEnvelope( + _checkForNil(children[0].value), + _checkForNil(children[1].value), + _checkForNil(children[2].value), + _checkForNil(children[3].value)); + } + + String _checkForNil(String value) { + if (value == 'NIL') { + return null; + } + return value; + } +} diff --git a/lib/src/imap/imap_response.dart b/lib/src/imap/imap_response.dart new file mode 100644 index 00000000..c1e28240 --- /dev/null +++ b/lib/src/imap/imap_response.dart @@ -0,0 +1,195 @@ +import 'package:enough_mail/util/stack_list.dart'; + +import 'imap_response_line.dart'; + + +class ImapResponse { + List lines = []; + bool get isSimple => (lines.length == 1); + ImapResponseLine get first => lines.first; + String _parseText; + String get parseText => _getParseText(); + set parseText(String text) => _parseText = text; + + void add(ImapResponseLine line) { + lines.add(line); + } + + String _getParseText() { + if (_parseText == null) { + if (isSimple) { + _parseText = first.line; + } else { + var buffer = StringBuffer(); + for (var line in lines) { + buffer.write(line.line); + } + _parseText = buffer.toString(); + } + } + return _parseText; + } + + ImapValueIterator iterate() { + var root = ImapValue(null, true); + var current = root; + var nextLineIsValueOnly = false; + var parentheses = StackList(); + + for (var line in lines) { + if (nextLineIsValueOnly) { + current.addChild(ImapValue(line.line)); + } else { + // iterate through each value: + var isInValue = false; + String separatorChar; + var text = line.line; + int startIndex; + for (var charIndex = 0; charIndex < text.length; charIndex++) { + var char = text[charIndex]; + if (isInValue) { + if (char == separatorChar) { + // end of current word: + var valueText = text.substring(startIndex, charIndex); + current.addChild(ImapValue(valueText)); + isInValue = false; + } else if (parentheses.isNotEmpty && + separatorChar == ' ' && + char == ')') { + var valueText = text.substring(startIndex, charIndex); + current.addChild(ImapValue(valueText)); + isInValue = false; + charIndex = + _closeParentheses(charIndex, text, parentheses, current); + current = current.parent; + } + } else if (char == '"') { + separatorChar = char; + startIndex = charIndex + 1; + isInValue = true; + } else if (char == '(') { + // typically subvalues do start here, e.g. + // 123 FETCH (FLAGS () ...) + // Another notation is the double-opener, e.g. + // (("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" "domain.com")) + if (charIndex < text.length - 1 && text[charIndex + 1] == '(') { + // ok, this is a double opener: + parentheses.put(ImapValueParenthesis.double); + charIndex++; + var next = ImapValue(null, true); + current.addChild(next); + current = next; + } else { + // this is a normal opening list, typically this belongs to the current item + parentheses.put(ImapValueParenthesis.simple); + if (current.children == null || current.children.isEmpty) { + current.addChild(ImapValue(null, true)); + } + var next = current.children.last; + if (next.children == null) { + next.children = []; + } else { + next = ImapValue(null, true); + current.addChild(next); + } + current = next; + } + } else if (char == ')') { + charIndex = + _closeParentheses(charIndex, text, parentheses, current); + current = current.parent; + } else if (char != ' ') { + isInValue = true; + separatorChar = ' '; + startIndex = charIndex; + } + } + if (isInValue) { + isInValue = false; + var valueText = text.substring(startIndex); + current.addChild(ImapValue(valueText)); + } + } + nextLineIsValueOnly = line.isWithLiteral; + } + if (parentheses.isNotEmpty) { + print('Warning - some parentheses have not been closed: $parentheses'); + print(lines.toString()); + } + return ImapValueIterator(root.children); + } + + int _closeParentheses(int charIndex, String text, + StackList parentheses, ImapValue current) { + if (parentheses.peek() == ImapValueParenthesis.double) { + if (charIndex < text.length - 1 && text[charIndex + 1] == ')') { + charIndex++; + } else { + // edge case: previously two opening parentheses were wrongly interpreted as a double parentheses + // now move the current list underneath the last value, if possible: + var siblings = current.parent.children; + if (siblings.length > 1 && + siblings[siblings.length - 2].children == null) { + siblings[siblings.length - 2].addChild(current); + siblings.removeLast(); + parentheses.pop(); + parentheses.put(ImapValueParenthesis.simple); + parentheses.put(ImapValueParenthesis.simple); // this one is going to be popped next anyhow + } + } + } + parentheses.pop(); + return charIndex; + } + + @override + String toString() { + var buffer = StringBuffer(); + for (var line in lines) { + buffer.write(line.rawLine); + buffer.write('\n'); + } + return buffer.toString(); + } +} + +class ImapValueIterator { + final List values; + int _currentIndex = 0; + ImapValue get current => values[_currentIndex]; + + ImapValueIterator(this.values); + + bool next() { + if (_currentIndex < values.length - 1) { + _currentIndex++; + return true; + } + return false; + } +} + +enum ImapValueParenthesis { simple, double } + +class ImapValue { + ImapValue parent; + String value; + List children; + ImapValue(this.value, [bool hasChildren = false]) { + if (hasChildren) { + children = []; + } + } + + void addChild(ImapValue child) { + children ??= []; + child.parent = this; + children.add(child); + } + + @override + String toString() { + return (value == null ? '' : value) + + (children != null ? children.toString() : ''); + } +} diff --git a/lib/src/imap/imap_response_line.dart b/lib/src/imap/imap_response_line.dart new file mode 100644 index 00000000..8b45e9f7 --- /dev/null +++ b/lib/src/imap/imap_response_line.dart @@ -0,0 +1,46 @@ +import 'dart:typed_data'; + +import 'parser_helper.dart'; + +class ImapResponseLine { + String rawLine; + String line; + int literal; + bool get isWithLiteral => (literal != null && literal > 0); + Uint8List rawData; + + ImapResponseLine.raw(this.rawData) { + line = String.fromCharCodes(rawData); + rawLine = line; + } + + ImapResponseLine(this.rawLine) { + // Example for lines using the literal extension / rfc7888: + // C: A001 LOGIN {11+} + // C: FRED FOOBAR {7+} + // C: fat man + // S: A001 OK LOGIN completed + var text = rawLine; + line = text; + if (text.length > 3 && text[text.length - 1] == '}') { + var openIndex = text.lastIndexOf('{', text.length - 2); + var endIndex = text.length - 1; + if (text[endIndex -1] == '+') { + endIndex--; + } + literal = + ParserHelper.parseIntByIndex(text, openIndex + 1, endIndex); + if (literal != null) { + if (openIndex > 0 && text[openIndex-1] == ' ') { + openIndex--; + } + line = text.substring(0, openIndex); + } + } + } + + @override + String toString() { + return rawLine; + } +} diff --git a/lib/src/imap/imap_response_reader.dart b/lib/src/imap/imap_response_reader.dart new file mode 100644 index 00000000..eb078e63 --- /dev/null +++ b/lib/src/imap/imap_response_reader.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/src/util/uint8_list_reader.dart'; + +import 'imap_response.dart'; +import 'imap_response_line.dart'; + +class ImapResponseReader { + final Uint8ListReader _rawReader = Uint8ListReader(); + ImapResponse _currentResponse; + ImapResponseLine _currentLine; + final Function(ImapResponse) _onImapResponse; + + ImapResponseReader([this._onImapResponse]); + + + void onData(Uint8List data) { + _rawReader.add(data); + // var text = String.fromCharCodes(data); + // print("onData: $text"); + // print("onData: hasLineBreak=${_rawReader.hasLineBreak()} currentResponse != null: ${(_currentResponse != null)}"); + if (_currentResponse != null) { + _checkResponse(_currentResponse, _currentLine); + } + if (_currentResponse == null) { + // there is currently no response awaiting its finalization + var text = _rawReader.readLine(); + while (text != null) { + var response = ImapResponse(); + var line = ImapResponseLine(text); + response.add(line); + if (line.isWithLiteral) { + _currentLine = line; + _currentResponse = response; + _checkResponse(response, line); + } else { + // this is a simple response: + _onImapResponse(response); + } + if (_currentLine != null && _currentLine.isWithLiteral) { + break; + } + text = _rawReader.readLine(); + } + } + } + + void _checkResponse(ImapResponse response, ImapResponseLine line) { + if (line.isWithLiteral) { + if (_rawReader.isAvailable(line.literal)) { + var rawLine = ImapResponseLine.raw(_rawReader.readBytes(line.literal)); + response.add(rawLine); + _currentLine = rawLine; + _checkResponse(response, rawLine); + } + } else { + // current line has no literal + var text = _rawReader.readLine(); + if (text != null) { + var textLine = ImapResponseLine(text); + // handle special case: + // the remainder of this line may consists of only a literal, + // in this case the information should be added on the previous line + if (textLine.isWithLiteral && textLine.line.isEmpty) { + line.literal = textLine.literal; + line.rawLine += text; + } else { + if (textLine.line.isNotEmpty) { + response.add(textLine); + } + if (!textLine.isWithLiteral) { + // this is the last line of this server response: + _onImapResponse(response); + _currentResponse = null; + _currentLine = null; + } else { + _currentLine = textLine; + } + } + } + } + } +} diff --git a/lib/src/imap/list_parser.dart b/lib/src/imap/list_parser.dart new file mode 100644 index 00000000..b9373f7c --- /dev/null +++ b/lib/src/imap/list_parser.dart @@ -0,0 +1,139 @@ +import 'package:enough_mail/imap/imap_client.dart'; +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +/// Pareses LIST and LSUB respones +class ListParser extends ResponseParser> { + final ImapServerInfo info; + final List boxes = []; + String startSequence; + + ListParser(this.info, {bool isLsubParser = false}) { + startSequence = isLsubParser ? 'LSUB ' : 'LIST '; + } + + @override + List parse(ImapResponse details, Response> response) { + return response.isOkStatus ? boxes : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response> response) { + var details = imapResponse.parseText; + if (details.startsWith(startSequence)) { + var box = Mailbox(); + var listDetails = details.substring(startSequence.length); + var flagsStartIndex = listDetails.indexOf('('); + var flagsEndIndex = listDetails.indexOf(')'); + if (flagsStartIndex != -1 && flagsStartIndex < flagsEndIndex) { + if (flagsStartIndex < flagsEndIndex - 1) { + // there are actually flags, not an empty () + var flagsText = listDetails + .substring(flagsStartIndex + 1, flagsEndIndex) + .toLowerCase(); + var flagNames = flagsText.split(' '); + for (var flagName in flagNames) { + switch (flagName) { + case r'\hasnochildren': + box.flags.add(MailboxFlag.hasNoChildren); + break; + case r'\haschildren': + box.flags.add(MailboxFlag.hasChildren); + box.hasChildren = true; + break; + case r'\unmarked': + box.flags.add(MailboxFlag.unMarked); + break; + case r'\marked': + box.flags.add(MailboxFlag.marked); + box.isMarked = true; + break; + case r'\noselect': + box.flags.add(MailboxFlag.noSelect); + box.isUnselectable = true; + break; + case r'\select': + box.flags.add(MailboxFlag.select); + box.isSelected = true; + break; + case r'\noinferiors': + box.flags.add(MailboxFlag.noInferior); + break; + case r'\nonexistent': + box.flags.add(MailboxFlag.nonExistent); + break; + case r'\subscribed': + box.flags.add(MailboxFlag.subscribed); + break; + case r'\remote': + box.flags.add(MailboxFlag.remote); + break; + case r'\all': + box.flags.add(MailboxFlag.all); + break; + case r'\inbox': + box.flags.add(MailboxFlag.inbox); + break; + case r'\sent': + box.flags.add(MailboxFlag.sent); + break; + case r'\drafts': + box.flags.add(MailboxFlag.drafts); + break; + case r'\junk': + box.flags.add(MailboxFlag.junk); + break; + case r'\trash': + box.flags.add(MailboxFlag.trash); + break; + case r'\archive': + box.flags.add(MailboxFlag.archive); + break; + case r'\flagged': + box.flags.add(MailboxFlag.flagged); + break; + // X-List flags: + case r'\allmail': + box.flags.add(MailboxFlag.all); + break; + case r'\important': + box.flags.add(MailboxFlag.flagged); + break; + case r'\spam': + box.flags.add(MailboxFlag.junk); + break; + case r'\starred': + box.flags.add(MailboxFlag.flagged); + break; + + default: + print('enountered unexpected flag: [$flagName]'); + } + } + } + listDetails = listDetails.substring(flagsEndIndex + 2); + } + if (listDetails.startsWith('"')) { + var endOfPathSeparatorIndex = listDetails.indexOf('"', 1); + if (endOfPathSeparatorIndex != -1) { + info.pathSeparator = + listDetails.substring(1, endOfPathSeparatorIndex); + //print("path-separator: " + info.pathSeparator); + listDetails = listDetails.substring(endOfPathSeparatorIndex + 2); + } + } + box.path = listDetails; + var lastPathSeparatorIndex = + listDetails.lastIndexOf(info.pathSeparator, listDetails.length - 2); + if (lastPathSeparatorIndex != -1) { + listDetails = listDetails.substring(lastPathSeparatorIndex + 1); + } + box.name = listDetails; + boxes.add(box); + return true; + } + return super.parseUntagged(imapResponse, response); + } +} diff --git a/lib/src/imap/logout_parser.dart b/lib/src/imap/logout_parser.dart new file mode 100644 index 00000000..7d90efd2 --- /dev/null +++ b/lib/src/imap/logout_parser.dart @@ -0,0 +1,23 @@ +import 'package:enough_mail/src/imap/imap_response.dart'; +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; + +class LogoutParser extends ResponseParser { + String _bye; + + @override + String parse(ImapResponse details, Response response) { + return response.isOkStatus ? _bye : null; + } + + @override + bool parseUntagged(ImapResponse details, Response response) { + if (details.parseText.startsWith('BYE')) { + _bye = details.parseText; + return true; + } + return super.parseUntagged(details, response); + } + +} \ No newline at end of file diff --git a/lib/src/imap/noop_parser.dart b/lib/src/imap/noop_parser.dart new file mode 100644 index 00000000..5084bd72 --- /dev/null +++ b/lib/src/imap/noop_parser.dart @@ -0,0 +1,51 @@ +import 'package:enough_mail/src/imap/select_parser.dart'; + +import '../../enough_mail.dart'; +import 'package:event_bus/event_bus.dart'; + +import 'imap_response.dart'; + +class NoopParser extends SelectParser { + EventBus eventBus; + + NoopParser(this.eventBus, Mailbox box) : super(box); + + + @override + bool parseUntagged(ImapResponse imapResponse, Response response) { + var details = imapResponse.parseText; + if (details.endsWith(' EXPUNGE')) { + // example: 1234 EXPUNGE + var id = parseInt(details, 0, ' '); + eventBus.fire(ImapExpungeEvent(id)); + } else if (details.contains(' FETCH ')) { + // example: 14 FETCH (FLAGS (\Seen \Deleted)) + var id = parseInt(details, 0, ' '); + var startIndex = details.indexOf('FLAGS'); + if (startIndex == -1) { + print('Unexpected/invalid FETCH response: ' + details); + return super.parseUntagged(imapResponse, response); + } + startIndex = details.indexOf("(", startIndex + "FLAGS".length); + if (startIndex == -1) { + print('Unexpected/invalid FETCH response: ' + details); + return super.parseUntagged(imapResponse, response); + } + var flags = parseListEntries(details, startIndex + 1, ")"); + eventBus.fire(ImapFetchEvent(id, flags)); + } else { + var messagesExists = box.messagesExists; + var messagesRecent = box.messagesRecent; + var handled = super.parseUntagged(imapResponse, response); + if (handled) { + if (box.messagesExists != messagesExists) { + eventBus.fire(ImapMessagesExistEvent( box.messagesExists, messagesExists)); + } else if (box.messagesRecent != messagesRecent) { + eventBus.fire(ImapMessagesRecentEvent( box.messagesRecent, messagesRecent)); + } + } + return handled; + } + return true; + } +} diff --git a/lib/src/imap/parser_helper.dart b/lib/src/imap/parser_helper.dart new file mode 100644 index 00000000..e8b11a10 --- /dev/null +++ b/lib/src/imap/parser_helper.dart @@ -0,0 +1,159 @@ +/// Abstracts a word such as a template name +class Word { + String text; + int startIndex; + int get endIndex => startIndex + text.length; + + Word(this.text, this.startIndex); +} + +class ParserHelper { + /// Helper method for parsing integer values within a line [details]. + static int parseInt(String details, int startIndex, String endCharacter) { + int endIndex = details.indexOf(endCharacter, startIndex); + if (endIndex == -1) { + return -1; + } + String numericText = details.substring(startIndex, endIndex); + return int.tryParse(numericText); + } + + /// Helper method for parsing integer values within a line [details]. + static int parseIntByIndex(String details, int startIndex, int endIndex) { + String numericText = details.substring(startIndex, endIndex); + return int.tryParse(numericText); + } + + /// Helper method to parse list entries in a line [details]. + static List parseListEntries( + String details, int startIndex, String endCharacter, + [String separator = ' ']) { + if (endCharacter != null) { + int endIndex = details.indexOf(endCharacter, startIndex); + if (endIndex == -1) { + return null; + } + details = details.substring(startIndex, endIndex); + } else { + details = details.substring(startIndex); + } + return details.split(separator); + } + + /// Helper method to parse list entries in a line [details]. + static List parseListEntriesByIndex( + String details, int startIndex, int endIndex, + [String separator = ' ']) { + if (endIndex == -1) { + return null; + } + details = details.substring(startIndex, endIndex); + return details.split(separator); + } + + /// Helper method to parse a list of integer values in a line [details]. + static List parseListIntEntries( + String details, int startIndex, String endCharacter, + [String separator = ' ']) { + var texts = parseListEntries(details, startIndex, endCharacter, separator); + var integers = [texts.length]; + texts.forEach((t) => integers.add(int.tryParse(t))); + return integers; + } + + /// Helper method to read the next word within a string + static Word readNextWord(String details, int startIndex, + [String separator = " "]) { + int endIndex = details.indexOf(separator, startIndex); + while (endIndex == startIndex) { + startIndex++; + endIndex = details.indexOf(separator, startIndex); + } + if (endIndex == -1) { + return null; + } + return Word(details.substring(startIndex, endIndex), startIndex); + } + + static HeaderParseResult parseHeader(String header) { + var result = HeaderParseResult(); + var headerLines = header.split("\r\n"); + int bodyStartIndex = 0; + StringBuffer buffer = StringBuffer(); + for (var line in headerLines) { + if (line.isEmpty) { + if (buffer.isNotEmpty) { + _addHeader(result, buffer); + buffer = StringBuffer(); + } + bodyStartIndex += 2; + result.bodyStartIndex = bodyStartIndex; + break; + } + bodyStartIndex += line.length + 2; + if (line.startsWith(' ')) { + buffer.write(' '); + buffer.write(line.trimLeft()); + } else { + if (buffer.isNotEmpty) { + // got a complete line + _addHeader(result, buffer); + buffer = StringBuffer(); + } + buffer.write(line); + } + } + if (buffer.isNotEmpty) { + // got a complete line + _addHeader(result, buffer); + } + return result; + } + + static void _addHeader(HeaderParseResult result, StringBuffer buffer) { + var headerText = buffer.toString(); + int colonIndex = headerText.indexOf(':'); + if (colonIndex != -1) { + var name = headerText.substring(0, colonIndex); + var value = headerText.substring(colonIndex + 2); + result.add(name, value); + } + } + + static String parseEmail(String value) { + if (value.length < 3) { + return null; + } + // check for a value like '"name" ' + int startIndex = value.indexOf('<'); + if (startIndex != -1) { + int endIndex = value.indexOf('>'); + if (endIndex > startIndex + 1) { + return value.substring(startIndex + 1, endIndex - 1); + } + } + // maybe this is just '"name" address@domain.com'? + if (value.startsWith('"')) { + int endIndex = value.indexOf('"', 1); + if (endIndex != -1) { + return value.substring(endIndex + 1).trim(); + } + } + return value; + } +} + +class HeaderParseResult { + List> headers = >[]; + int bodyStartIndex; + + void add(String name, String value) { + headers.add(Tuple(name, value)); + } +} + +class Tuple { + T name; + S value; + Tuple(this.name, this.value); +} diff --git a/lib/src/imap/response_parser.dart b/lib/src/imap/response_parser.dart new file mode 100644 index 00000000..4d2c5950 --- /dev/null +++ b/lib/src/imap/response_parser.dart @@ -0,0 +1,34 @@ +import 'package:enough_mail/src/imap/parser_helper.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +/// Responsible for parsing server responses in form of a single line. +abstract class ResponseParser { + /// Parses the final response line, either starting with OK, NO or BAD. + T parse(ImapResponse details, Response response); + + /// Parses intermediate untagged response lines. + bool parseUntagged(ImapResponse details, Response response) { + return false; + } + + /// Helper method for parsing integer values within a line [details]. + int parseInt(String details, int startIndex, String endCharacter) { + return ParserHelper.parseInt(details, startIndex, endCharacter); + } + + /// Helper method to parse list entries in a line [details]. + List parseListEntries( + String details, int startIndex, String endCharacter, + [String separator = ' ']) { + return ParserHelper.parseListEntries(details, startIndex, endCharacter); + } + + /// Helper method to parse a list of integer values in a line [details]. + List parseListIntEntries( + String details, int startIndex, String endCharacter, + [String separator = ' ']) { + return ParserHelper.parseListIntEntries(details, startIndex, endCharacter); + } +} diff --git a/lib/src/imap/search_parser.dart b/lib/src/imap/search_parser.dart new file mode 100644 index 00000000..10baac3f --- /dev/null +++ b/lib/src/imap/search_parser.dart @@ -0,0 +1,31 @@ +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +/// Parses search responses +class SearchParser extends ResponseParser> { + List ids = []; + + @override + List parse(ImapResponse details, Response> response) { + //await Future.delayed(Duration(milliseconds: 200)); + return response.isOkStatus ? ids : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response> response) { + var details = imapResponse.parseText; + if (details.startsWith('SEARCH ')) { + var listEntries = parseListEntries(details, 'SEARCH '.length, null); + for (var entry in listEntries) { + var id = int.parse(entry); + ids.add(id); + } + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + +} \ No newline at end of file diff --git a/lib/src/imap/select_parser.dart b/lib/src/imap/select_parser.dart new file mode 100644 index 00000000..a03a7755 --- /dev/null +++ b/lib/src/imap/select_parser.dart @@ -0,0 +1,49 @@ +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +class SelectParser extends ResponseParser { + Mailbox box; + + SelectParser(this.box); + + @override + Mailbox parse(ImapResponse details, Response response) { + if (box != null) { + box.isReadWrite = details.parseText.startsWith('OK [READ-WRITE]'); + } + return response.isOkStatus ? box : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response response) { + if (box == null) { + return super.parseUntagged(imapResponse, response); + } + var details = imapResponse.parseText; + if (details.startsWith('OK [UNSEEN ')) { + box.firstUnseenMessageSequenceId = + parseInt(details, 'OK [UNSEEN '.length, ']'); + } else if (details.startsWith('OK [UIDVALIDITY ')) { + box.uidValidity = parseInt(details, 'OK [UIDVALIDITY '.length, ']'); + } else if (details.startsWith('OK [UIDNEXT ')) { + box.uidNext = parseInt(details, 'OK [UIDNEXT '.length, ']'); + } else if (details.startsWith('OK [HIGHESTMODSEQ ')) { + box.highestModSequence = + parseInt(details, 'OK [HIGHESTMODSEQ '.length, ']'); + } else if (details.startsWith('FLAGS (')) { + box.messageFlags = parseListEntries(details, 'FLAGS ('.length, ')'); + } else if (details.startsWith('OK [PERMANENTFLAGS (')) { + box.permanentMessageFlags = + parseListEntries(details, 'OK [PERMANENTFLAGS ('.length, ')'); + } else if (details.endsWith(' EXISTS')) { + box.messagesExists = parseInt(details, 0, ' '); + } else if (details.endsWith(' RECENT')) { + box.messagesRecent = parseInt(details, 0, ' '); + } else { + return super.parseUntagged(imapResponse, response); + } + return true; + } +} diff --git a/lib/src/imap/status_parser.dart b/lib/src/imap/status_parser.dart new file mode 100644 index 00000000..10061e9b --- /dev/null +++ b/lib/src/imap/status_parser.dart @@ -0,0 +1,40 @@ +import 'package:enough_mail/src/imap/response_parser.dart'; + +import '../../enough_mail.dart'; +import 'imap_response.dart'; + +/// Parses status responses +class StatusParser extends ResponseParser { + Mailbox box; + + StatusParser(this.box); + + @override + Mailbox parse(ImapResponse details, Response response) { + return response.isOkStatus ? box : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response response) { + var details = imapResponse.parseText; + if (details.startsWith('STATUS ')) { + var listEntries = parseListEntries(details, details.indexOf('('), ")"); + for (var i=0; i < listEntries.length; i += 2) { + var entry = listEntries[i]; + var value = int.parse(listEntries[i+1]); + switch (entry) { + case 'MESSAGES': box.messagesExists = value; break; + case 'RECENT': box.messagesRecent = value; break; + case 'UIDNEXT': box.uidNext = value; break; + case 'UIDVALIDITY': box.uidValidity = value; break; + case 'UNSEEN': box.firstUnseenMessageSequenceId = value; break; + default: print('unexpected STATUS: ' + entry + '=' + listEntries[i+1] + '\nin ' + details); + } + } + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + +} \ No newline at end of file diff --git a/lib/src/smtp/commands/all_commands.dart b/lib/src/smtp/commands/all_commands.dart new file mode 100644 index 00000000..f40c038a --- /dev/null +++ b/lib/src/smtp/commands/all_commands.dart @@ -0,0 +1,6 @@ +export 'smtp_auth_command.dart'; +export 'smtp_ehlo_command.dart'; +export 'smtp_quit_command.dart'; +export 'smtp_sendmail_command.dart'; +export 'smtp_starttls_command.dart'; + diff --git a/lib/src/smtp/commands/smtp_auth_command.dart b/lib/src/smtp/commands/smtp_auth_command.dart new file mode 100644 index 00000000..51ee75d7 --- /dev/null +++ b/lib/src/smtp/commands/smtp_auth_command.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; +import '../smtp_command.dart'; + +class SmtpAuthCommand extends SmtpCommand { + + final String _userName; + final String _password; + + SmtpAuthCommand(this._userName, this._password) : super('AUTH PLAIN'); + + @override + String getCommand() { + var combined = _userName + '\u{0000}' + _userName + '\u{0000}' + _password; + var codec = Base64Codec(); + var encoded = codec.encode(combined.codeUnits); + return 'AUTH PLAIN ' + encoded; + } + +} \ No newline at end of file diff --git a/lib/src/smtp/commands/smtp_ehlo_command.dart b/lib/src/smtp/commands/smtp_ehlo_command.dart new file mode 100644 index 00000000..a94b016e --- /dev/null +++ b/lib/src/smtp/commands/smtp_ehlo_command.dart @@ -0,0 +1,24 @@ +import 'package:enough_mail/smtp/smtp_response.dart'; + +import '../smtp_command.dart'; + +class SmtpEhloCommand extends SmtpCommand { + + final String _clientName; + + SmtpEhloCommand([this._clientName]) : super('EHLO'); + + @override + String getCommand() { + if (_clientName != null) { + return '${super.getCommand()} $_clientName'; + } + return super.getCommand(); + } + + @override + bool isCommandDone(SmtpResponse response) { + return (response.type != SmtpResponseType.success) || (response.responseLines.length > 1); + } + +} \ No newline at end of file diff --git a/lib/src/smtp/commands/smtp_quit_command.dart b/lib/src/smtp/commands/smtp_quit_command.dart new file mode 100644 index 00000000..3c7c752a --- /dev/null +++ b/lib/src/smtp/commands/smtp_quit_command.dart @@ -0,0 +1,14 @@ +import 'package:enough_mail/smtp/smtp_client.dart'; +import 'package:enough_mail/smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +class SmtpQuitCommand extends SmtpCommand { + final SmtpClient _client; + SmtpQuitCommand(this._client) : super('QUIT'); + + @override + String nextCommand(SmtpResponse response) { + _client.close(); + return null; + } +} \ No newline at end of file diff --git a/lib/src/smtp/commands/smtp_sendmail_command.dart b/lib/src/smtp/commands/smtp_sendmail_command.dart new file mode 100644 index 00000000..e9ba1749 --- /dev/null +++ b/lib/src/smtp/commands/smtp_sendmail_command.dart @@ -0,0 +1,69 @@ +import 'package:enough_mail/smtp/smtp_response.dart'; + +import '../../../enough_mail.dart'; +import 'package:enough_mail/src/smtp/smtp_command.dart'; + +enum SmtpSendCommandSequence { mailFrom, rcptTo, data, done } + +class SmtpSendMailCommand extends SmtpCommand { + final Message _message; + final bool _use8BitEncoding; + SmtpSendCommandSequence _currentStep = SmtpSendCommandSequence.mailFrom; + int _recipientIndex = 0; + + SmtpSendMailCommand(this._message, this._use8BitEncoding) + : super('MAIL FROM'); + + @override + String getCommand() { + if (_use8BitEncoding) { + return 'MAIL FROM:<${_message.fromEmail}> BODY=8BITMIME'; + } + return 'MAIL FROM:<${_message.fromEmail}>'; + } + + @override + String nextCommand(SmtpResponse response) { + var step = _currentStep; + switch (step) { + case SmtpSendCommandSequence.mailFrom: + if (_message.recipients.isEmpty) { + return null; + } + _currentStep = SmtpSendCommandSequence.rcptTo; + _recipientIndex++; + return _getRecipientToCommand(_message.recipients[0]); + break; + case SmtpSendCommandSequence.rcptTo: + var index = _recipientIndex; + if (index < _message.recipients.length) { + _recipientIndex++; + return _getRecipientToCommand(_message.recipients[index]); + } else if (response.type == SmtpResponseType.success) { + _currentStep = SmtpSendCommandSequence.data; + return 'DATA'; + } else { + return null; + } + break; + case SmtpSendCommandSequence.data: + _currentStep = SmtpSendCommandSequence.done; + return _message.headerRaw + '\r\n' + _message.bodyRaw + '\r\n.'; + default: + return null; + } + } + + String _getRecipientToCommand(String email) { + return 'RCPT TO:<$email>'; + } + + @override + bool isCommandDone(SmtpResponse response) { + if (_currentStep == SmtpSendCommandSequence.data) { + return (response.code == 354); + } + return (response.type != SmtpResponseType.success) || + (_currentStep == SmtpSendCommandSequence.done); + } +} diff --git a/lib/src/smtp/commands/smtp_starttls_command.dart b/lib/src/smtp/commands/smtp_starttls_command.dart new file mode 100644 index 00000000..364656aa --- /dev/null +++ b/lib/src/smtp/commands/smtp_starttls_command.dart @@ -0,0 +1,6 @@ +import '../smtp_command.dart'; + +class SmtpStartTlsCommand extends SmtpCommand { + + SmtpStartTlsCommand() : super('STARTTLS'); +} diff --git a/lib/src/smtp/smtp_command.dart b/lib/src/smtp/smtp_command.dart new file mode 100644 index 00000000..49ac3d22 --- /dev/null +++ b/lib/src/smtp/smtp_command.dart @@ -0,0 +1,23 @@ +import 'dart:async'; +import 'package:enough_mail/smtp/smtp_response.dart'; + +class SmtpCommand { + final String _command; + String get command => getCommand(); + + final Completer completer = Completer(); + + SmtpCommand(this._command); + + String getCommand() { + return _command; + } + + String nextCommand(SmtpResponse response) { + return null; + } + + bool isCommandDone(SmtpResponse response) { + return true; + } +} diff --git a/lib/src/util/uint8_list_reader.dart b/lib/src/util/uint8_list_reader.dart new file mode 100644 index 00000000..86ed4ab5 --- /dev/null +++ b/lib/src/util/uint8_list_reader.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +/// Combines several Uin8Lists to read from them sequentially +class Uint8ListReader { + Uint8List _data = Uint8List(0); + + void add(Uint8List list) { + //idea: consider BytesBuilder + if (_data.isEmpty) { + _data = list; + } else { + _data = Uint8List.fromList(_data + list); + } + } + + void addText(String text) { + add(Uint8List.fromList(text.codeUnits)); + } + + int findLineBreak() { + var data = _data; + for (var charIndex = 0; charIndex < data.length - 1; charIndex++) { + if (data[charIndex] == 13 && data[charIndex + 1] == 10) { + // ok found CR + LF sequence: + return charIndex + 1; + } + } + + return null; + } + + int findLastLineBreak() { + var data = _data; + for (var charIndex = data.length; --charIndex > 1;) { + if (data[charIndex] == 10 && data[charIndex - 1] == 13) { + // ok found CR + LF sequence: + return charIndex; + } + } + return null; + } + + bool hasLineBreak() { + return (findLineBreak() != null); + } + + String readLine() { + var pos = findLineBreak(); + if (pos == null) { + return null; + } + var line = String.fromCharCodes(_data, 0, pos - 1); + _data = _data.sublist(pos + 1); + return line; + } + + List readLines() { + var pos = findLastLineBreak(); + if (pos == null) { + return null; + } + String text; + if (pos == _data.length - 1) { + text = String.fromCharCodes(_data); + _data = Uint8List(0); + } else { + text = String.fromCharCodes(_data, 0, pos); + _data = _data.sublist(pos + 1); + } + return text.split('\r\n'); + } + + Uint8List readBytes(int length) { + if (!isAvailable(length)) { + return null; + } + var result = _data.sublist(0, length); + _data = _data.sublist(length); + return result; + } + + bool isAvailable(int length) { + return (length <= _data.length); + } +} diff --git a/lib/util/stack_list.dart b/lib/util/stack_list.dart new file mode 100644 index 00000000..4e84198c --- /dev/null +++ b/lib/util/stack_list.dart @@ -0,0 +1,31 @@ +class StackList { + final List _elements = []; + + void put(T value) { + _elements.add(value); + } + + T peek() { + if (_elements.isEmpty) { + return null; + } + return _elements.last; + } + + T pop() { + if (_elements.isEmpty) { + return null; + } + return _elements.removeLast(); + } + + bool get isNotEmpty => _elements.isNotEmpty; + + bool get isEmpty => _elements.isEmpty; + + @override + String toString() { + return _elements.toString(); + } + +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..e7a2e2e4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,14 @@ +name: enough_mail +description: IMAP and SMTP clients in pure Dart. +version: 0.0.1 +# homepage: https://www.example.com + +environment: + sdk: '>=2.7.0 <3.0.0' + +dependencies: + event_bus: ^1.1.0 + +dev_dependencies: + pedantic: ^1.8.0 + test: ^1.6.0 diff --git a/test/encodings_test.dart b/test/encodings_test.dart new file mode 100644 index 00000000..ae51babf --- /dev/null +++ b/test/encodings_test.dart @@ -0,0 +1,19 @@ +import 'package:test/test.dart'; +import 'package:enough_mail/encodings.dart'; + +void main() { + test('encodings.quoted-printable header', () { + var input = + '=?utf-8?Q?Chat=3A?==?utf-8?Q?_?=oh=?utf-8?Q?_?==?utf-8?Q?hi=2C?=' + '=?utf-8?Q?__?=how=?utf-8?Q?_?=do=?utf-8?Q?_?=you=?utf-8?Q?_?==?utf-8?Q?do=3F?==?utf-8?Q?_?==?utf-8?Q?=3A-)?='; + expect(EncodingsHelper.decodeAny(input), 'Chat: oh hi, how do you do? :-)'); + }); + + test('encodings.quoted-printable header no direct start', () { + var input = + ' =?utf-8?Q?Chat=3A?==?utf-8?Q?_?=oh=?utf-8?Q?_?==?utf-8?Q?hi=2C?=' + '=?utf-8?Q?__?=how=?utf-8?Q?_?=do=?utf-8?Q?_?=you=?utf-8?Q?_?==?utf-8?Q?do=3F?==?utf-8?Q?_?==?utf-8?Q?=3A-)?='; + expect(EncodingsHelper.decodeAny(input), ' Chat: oh hi, how do you do? :-)'); + }); + +} diff --git a/test/imap/imap_client_test.dart b/test/imap/imap_client_test.dart new file mode 100644 index 00000000..97f1dfe8 --- /dev/null +++ b/test/imap/imap_client_test.dart @@ -0,0 +1,690 @@ +import 'dart:async'; + +import 'package:test/test.dart'; +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:event_bus/event_bus.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'mock_imap_server.dart'; +import '../mock_socket.dart'; + +bool _isLogEnabled = false; +String imapHost, imapUser, imapPassword; +ImapClient client; +MockImapServer mockServer; +Response> capResponse; +List fetchEvents = []; +List expungedMessages = []; +const String supportedMessageFlags = + r'\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social $promotion $HasAttachment $HasNoAttachment $HasChat $MDNSent'; +const String supportedPermanentMessageFlags = supportedMessageFlags + r' \*'; +ServerMailbox mockInbox; +Mailbox inbox; + +void main() { + setUp(() async { + if (client != null) { + return; + } + _log('setting up ImapClient tests'); + var envVars = Platform.environment; + + var imapPort = 993; + var useRealConnection = + (!envVars.containsKey('IMAP_USE') || envVars['IMAP_USE'] == 'true') && + envVars.containsKey('IMAP_HOST') && + envVars.containsKey('IMAP_USER') && + envVars.containsKey('IMAP_PASSWORD'); + if (useRealConnection) { + if (envVars.containsKey('IMAP_LOG')) { + _isLogEnabled = (envVars['IMAP_LOG'] == 'true'); + } else { + _isLogEnabled = true; + } + imapHost = envVars['IMAP_HOST']; + imapUser = envVars['IMAP_USER']; + imapPassword = envVars['IMAP_PASSWORD']; + if (envVars.containsKey('IMAP_PORT')) { + imapPort = int.parse(envVars['IMAP_PORT']); + } + } else if (envVars.containsKey('IMAP_LOG')) { + _isLogEnabled = (envVars['IMAP_LOG'] == 'true'); + //print("log-enabled: $_isLogEnabled [IMAP_LOG=${envVars['IMAP_LOG']}]"); + } + client = ImapClient(bus: EventBus(sync: true), isLogEnabled: _isLogEnabled); + + client.eventBus + .on() + .listen((e) => expungedMessages.add(e.messageSequenceId)); + client.eventBus.on().listen((e) => fetchEvents.add(e)); + + if (useRealConnection) { + await client.connectToServer(imapHost, imapPort); + capResponse = await client.login(imapUser, imapPassword); + } else { + var connection = MockConnection(); + client.connect(connection.socketClient); + mockServer = MockImapServer.connect(connection.socketServer); + client.serverInfo = ImapServerInfo(); + capResponse = await client.login('testuser', 'testpassword'); + } + mockInbox = ServerMailbox( + 'INBOX', + List.from([MailboxFlag.hasChildren]), + supportedMessageFlags, + supportedPermanentMessageFlags); + _log('ImapClient test setup complete'); + }); + + test('ImapClient login', () async { + _log('login result: ${capResponse.status}'); + expect(capResponse.status, ResponseStatus.OK); + expect(capResponse.result != null, true, + reason: 'capability response does not contain a result'); + expect(capResponse.result.isNotEmpty, true, + reason: 'capability response does not contain a single capability'); + _log(''); + _log('Capabilities=${capResponse.result}'); + if (mockServer != null) { + expect(capResponse.result.length, 2); + expect(capResponse.result[0].name, 'IMAP4rev1'); + expect(capResponse.result[1].name, 'IDLE'); + } + }); + + test('ImapClient listMailboxes', () async { + _log(''); + if (mockServer != null) { + mockInbox.messagesExists = 256; + mockInbox.messagesRecent = 23; + mockInbox.firstUnseenMessageSequenceId = 21419; + mockInbox.uidValidity = 1466002015; + mockInbox.uidNext = 37323; + mockInbox.highestModSequence = 110414; + mockServer.mailboxes.clear(); + mockServer.mailboxes.add(mockInbox); + mockServer.mailboxes.add(ServerMailbox( + 'Public', + List.from( + [MailboxFlag.noSelect, MailboxFlag.hasChildren]), + supportedMessageFlags, + supportedPermanentMessageFlags)); + mockServer.mailboxes.add(ServerMailbox( + 'Shared', + List.from( + [MailboxFlag.noSelect, MailboxFlag.hasChildren]), + supportedMessageFlags, + supportedPermanentMessageFlags)); + } + var listResponse = await client.listMailboxes(); + _log('list result: ${listResponse.status}'); + expect(listResponse.status, ResponseStatus.OK, + reason: 'expecting OK list result'); + expect(listResponse.result != null, true, + reason: 'list response does not conatin a result'); + expect(listResponse.result.isNotEmpty, true, + reason: 'list response does not contain a single mailbox'); + for (var box in listResponse.result) { + _log('list mailbox: ' + + box.name + + (box.hasChildren ? ' with children' : ' without children') + + (box.isUnselectable ? ' not selectable' : ' selectable')); + } + if (mockServer != null) { + expect(client.serverInfo.pathSeparator, mockServer.pathSeparator, + reason: 'different path separator than in server'); + expect(3, listResponse.result.length, + reason: 'Set up 3 mailboxes in root'); + var box = listResponse.result[0]; + expect('INBOX', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(false, box.isUnselectable); + box = listResponse.result[1]; + expect('Public', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(true, box.isUnselectable); + box = listResponse.result[2]; + expect('Shared', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(true, box.isUnselectable); + } + }); + test('ImapClient LSUB', () async { + _log(''); + if (mockServer != null) { + mockServer.mailboxesSubscribed.clear(); + mockServer.mailboxesSubscribed.add(mockInbox); + mockServer.mailboxesSubscribed.add(ServerMailbox( + 'Public', + List.from( + [MailboxFlag.noSelect, MailboxFlag.hasChildren]), + supportedMessageFlags, + supportedPermanentMessageFlags)); + } + var listResponse = await client.listSubscribedMailboxes(); + _log('lsub result: ' + listResponse.status.toString()); + expect(listResponse.status, ResponseStatus.OK, + reason: 'expecting OK lsub result'); + expect(listResponse.result != null, true, + reason: 'lsub response does not contain a result'); + expect(listResponse.result.isNotEmpty, true, + reason: 'lsub response does not contain a single mailbox'); + for (var box in listResponse.result) { + _log('lsub mailbox: ' + + box.name + + (box.hasChildren ? ' with children' : ' without children') + + (box.isUnselectable ? ' not selectable' : ' selectable')); + } + if (mockServer != null) { + expect(client.serverInfo.pathSeparator, mockServer.pathSeparator, + reason: 'different path separator than in server'); + expect(2, listResponse.result.length, + reason: 'Set up 2 mailboxes as subscribed'); + var box = listResponse.result[0]; + expect('INBOX', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(false, box.isUnselectable); + box = listResponse.result[1]; + expect('Public', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(true, box.isUnselectable); + } + }); + test('ImapClient LIST Inbox', () async { + _log(''); + var listResponse = await client.listMailboxes(path: 'INBOX'); + _log('INBOX result: ' + listResponse.status.toString()); + expect(listResponse.status, ResponseStatus.OK, + reason: 'expecting OK LIST INBOX result'); + expect(listResponse.result != null, true, + reason: 'list response does not contain a result '); + expect(listResponse.result.length == 1, true, + reason: 'list response does not contain exactly one result'); + for (var box in listResponse.result) { + _log('INBOX mailbox: ' + + box.path + + (box.hasChildren ? ' with children' : ' without children') + + (box.isSelected ? ' select' : ' no select')); + } + if (mockServer != null) { + expect(client.serverInfo.pathSeparator, mockServer.pathSeparator, + reason: 'different path separator than in server'); + expect(1, listResponse.result.length, + reason: 'There can be only one INBOX'); + var box = listResponse.result[0]; + expect('INBOX', box.name); + expect(true, box.hasChildren); + expect(false, box.isSelected); + expect(false, box.isUnselectable); + } + + _log(''); + inbox = listResponse.result[0]; + var selectResponse = await client.selectMailbox(inbox); + expect(selectResponse.status, ResponseStatus.OK, + reason: 'expecting OK SELECT INBOX response'); + expect(selectResponse.result != null, true, + reason: 'select response does not contain a result '); + expect(selectResponse.result.isReadWrite, true, + reason: 'SELECT should open INBOX in READ-WRITE '); + expect( + selectResponse.result.messagesExists != null && + selectResponse.result.messagesExists > 0, + true, + reason: 'expecting at least 1 mail in INBOX'); + _log(inbox.name + + ' exist=' + + inbox.messagesExists.toString() + + ' recent=' + + inbox.messagesRecent.toString() + + ', uidValidity=' + + inbox.uidValidity.toString()); + if (mockServer != null) { + expect(inbox.messagesExists, 256); + expect(inbox.messagesRecent, 23); + expect(inbox.firstUnseenMessageSequenceId, 21419); + expect(inbox.uidValidity, 1466002015); + expect(inbox.uidNext, 37323); + expect(inbox.highestModSequence, 110414); + expect(inbox.messageFlags != null, true, + reason: 'message flags expected'); + expect(_toString(inbox.messageFlags), + r'\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social $promotion $HasAttachment $HasNoAttachment $HasChat $MDNSent'); + expect(inbox.permanentMessageFlags != null, true, + reason: 'permanent message flags expected'); + expect(_toString(inbox.permanentMessageFlags), + r'\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social $promotion $HasAttachment $HasNoAttachment $HasChat $MDNSent \*'); + } + }); + + test('ImapClient search', () async { + _log(''); + if (mockServer != null) { + mockInbox.messageSequenceIdsUnseen = + List.from([mockInbox.firstUnseenMessageSequenceId, 3423, 17, 3]); + } + var searchResponse = await client.searchMessages('UNSEEN'); + expect(searchResponse.status, ResponseStatus.OK); + expect(searchResponse.result != null, true); + expect(searchResponse.result.isNotEmpty, true); + _log('searched messages: ' + searchResponse.result.toString()); + if (mockServer != null) { + expect(searchResponse.result.length, + mockInbox.messageSequenceIdsUnseen.length); + expect(searchResponse.result[0], mockInbox.firstUnseenMessageSequenceId); + expect(searchResponse.result[1], 3423); + expect(searchResponse.result[2], 17); + expect(searchResponse.result[3], 3); + } + }); + + test('ImapClient fetch FULL', () async { + _log(''); + var lowerIndex = math.max(inbox.messagesExists - 1, 0); + if (mockServer != null) { + mockServer.fetchResponses.clear(); + mockServer.fetchResponses.add(inbox.messagesExists.toString() + + r' FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200" ' + 'RFC822.SIZE 15320 ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200 (CEST)" {61}\r\n' + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps' + '(("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_' + 'Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" ' + '"domain.com")) (("Alice Dev" NIL "alice.dev" "domain.com")) NIL NIL "" "<130499090.797.1572014128349@product-gw2.domain.com>") BODY (("text" "plain" ' + '("charset" "UTF-8") NIL NIL "quoted-printable" 1289 53)("text" "html" ("charset" "UTF-8") NIL NIL "quoted-printable" ' + '7496 302) "alternative"))'); + mockServer.fetchResponses.add(lowerIndex.toString() + + r' FETCH (FLAGS (new seen) INTERNALDATE "25-Oct-2019 17:03:12 +0200" ' + 'RFC822.SIZE 20630 ENVELOPE ("Fri, 25 Oct 2019 11:02:30 -0400 (EDT)" "New appointment: Discussion and ' + 'Q&A" (("Tester, Theresa" NIL "t.tester" "domain.com")) (("Tester, Theresa" NIL "t.tester" "domain.com"))' + ' (("Tester, Theresa" NIL "t.tester" "domain.com")) (("Alice Dev" NIL "alice.dev" "domain.com"))' + ' NIL NIL "" "<1814674343.1008.1572015750561@appsuite-g' + 'w2.domain.com>") BODY (("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"))'); + } + var fetchResponse = + await client.fetchMessages(lowerIndex, inbox.messagesExists, 'FULL'); + expect(fetchResponse.status, ResponseStatus.OK, + reason: 'support for FETCH FULL expected'); + if (mockServer != null) { + expect(fetchResponse.result != null, true, + reason: 'fetch result expected'); + expect(fetchResponse.result.length, 2); + var message = fetchResponse.result[0]; + expect(message.sequenceId, lowerIndex + 1); + expect(message.flags != null, true); + expect(message.flags.length, 0); + expect(message.internalDate, '25-Oct-2019 16:35:31 +0200'); + expect(message.size, 15320); + expect(message.date, 'Fri, 25 Oct 2019 16:35:28 +0200 (CEST)'); + expect(message.subject, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps'); + expect(message.inReplyTo, + ''); + expect(message.messageId, + '<130499090.797.1572014128349@product-gw2.domain.com>'); + expect(message.cc, null); + expect(message.bcc, null); + expect(message.from != null, true); + expect(message.from.personalName, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(message.from.sourceRoute, null); + expect(message.from.mailboxName, 'rob.schoen'); + expect(message.from.hostName, 'domain.com'); + expect(message.sender != null, true); + expect(message.sender.personalName, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(message.sender.sourceRoute, null); + expect(message.sender.mailboxName, 'rob.schoen'); + expect(message.sender.hostName, 'domain.com'); + expect(message.replyTo != null, true); + expect(message.replyTo.personalName, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(message.replyTo.sourceRoute, null); + expect(message.replyTo.mailboxName, 'rob.schoen'); + expect(message.replyTo.hostName, 'domain.com'); + expect(message.to != null, true); + expect(message.to.personalName, 'Alice Dev'); + expect(message.to.sourceRoute, null); + expect(message.to.mailboxName, 'alice.dev'); + expect(message.to.hostName, 'domain.com'); + expect(message.body != null, true); + expect(message.body.type, 'alternative'); + expect(message.body.structures != null, true); + expect(message.body.structures.length, 2); + expect(message.body.structures[0].type, 'text'); + expect(message.body.structures[0].subtype, 'plain'); + expect(message.body.structures[0].description, null); + expect(message.body.structures[0].id, null); + expect(message.body.structures[0].encoding, 'quoted-printable'); + expect(message.body.structures[0].size, 1289); + expect(message.body.structures[0].numberOfLines, 53); + expect(message.body.structures[0].attributes != null, true); + expect(message.body.structures[0].attributes.length, 1); + expect(message.body.structures[0].attributes[0].name, 'charset'); + expect(message.body.structures[0].attributes[0].value, 'UTF-8'); + expect(message.body.structures[1].type, 'text'); + expect(message.body.structures[1].subtype, 'html'); + expect(message.body.structures[1].description, null); + expect(message.body.structures[1].id, null); + expect(message.body.structures[1].encoding, 'quoted-printable'); + expect(message.body.structures[1].size, 7496); + expect(message.body.structures[1].numberOfLines, 302); + expect(message.body.structures[1].attributes != null, true); + expect(message.body.structures[1].attributes.length, 1); + expect(message.body.structures[1].attributes[0].name, 'charset'); + expect(message.body.structures[1].attributes[0].value, 'UTF-8'); + + message = fetchResponse.result[1]; + expect(message.sequenceId, lowerIndex); + expect(message.flags != null, true); + expect(message.flags.length, 2); + expect(message.flags[0], 'new'); + expect(message.flags[1], 'seen'); + expect(message.internalDate, '25-Oct-2019 17:03:12 +0200'); + expect(message.size, 20630); + expect(message.date, 'Fri, 25 Oct 2019 11:02:30 -0400 (EDT)'); + expect(message.subject, 'New appointment: Discussion and Q&A'); + expect(message.inReplyTo, + ''); + expect(message.messageId, + '<1814674343.1008.1572015750561@appsuite-gw2.domain.com>'); + expect(message.cc, null); + expect(message.bcc, null); + expect(message.from != null, true); + expect(message.from.personalName, 'Tester, Theresa'); + expect(message.from.sourceRoute, null); + expect(message.from.mailboxName, 't.tester'); + expect(message.from.hostName, 'domain.com'); + expect(message.sender != null, true); + expect(message.sender.personalName, 'Tester, Theresa'); + expect(message.sender.sourceRoute, null); + expect(message.sender.mailboxName, 't.tester'); + expect(message.sender.hostName, 'domain.com'); + expect(message.replyTo != null, true); + expect(message.replyTo.personalName, 'Tester, Theresa'); + expect(message.replyTo.sourceRoute, null); + expect(message.replyTo.mailboxName, 't.tester'); + expect(message.replyTo.hostName, 'domain.com'); + expect(message.to != null, true); + expect(message.to.personalName, 'Alice Dev'); + expect(message.to.sourceRoute, null); + expect(message.to.mailboxName, 'alice.dev'); + expect(message.to.hostName, 'domain.com'); + expect(message.body != null, true); + expect(message.body.type, 'MIXED'); + expect(message.body.structures != null, true); + expect(message.body.structures.length, 2); + expect(message.body.structures[0].type, 'TEXT'); + expect(message.body.structures[0].subtype, 'PLAIN'); + expect(message.body.structures[0].description, null); + expect(message.body.structures[0].id, null); + expect(message.body.structures[0].encoding, '7BIT'); + expect(message.body.structures[0].size, 1152); + expect(message.body.structures[0].numberOfLines, 23); + expect(message.body.structures[0].attributes != null, true); + expect(message.body.structures[0].attributes.length, 1); + expect(message.body.structures[0].attributes[0].name, 'CHARSET'); + expect(message.body.structures[0].attributes[0].value, 'US-ASCII'); + expect(message.body.structures[1].type, 'TEXT'); + expect(message.body.structures[1].subtype, 'PLAIN'); + expect(message.body.structures[1].description, 'Compiler diff'); + expect(message.body.structures[1].id, + '<960723163407.20117h@cac.washington.edu>'); + expect(message.body.structures[1].encoding, 'BASE64'); + expect(message.body.structures[1].size, 4554); + expect(message.body.structures[1].numberOfLines, 73); + expect(message.body.structures[1].attributes != null, true); + expect(message.body.structures[1].attributes.length, 2); + expect(message.body.structures[1].attributes[0].name, 'CHARSET'); + expect(message.body.structures[1].attributes[0].value, 'US-ASCII'); + expect(message.body.structures[1].attributes[1].name, 'NAME'); + expect(message.body.structures[1].attributes[1].value, 'cc.diff'); + } + }); + + test('ImapClient fetch BODY[HEADER]', () async { + _log(''); + var lowerIndex = math.max(inbox.messagesExists - 1, 0); + if (mockServer != null) { + mockServer.fetchResponses.clear(); + mockServer.fetchResponses.add(inbox.messagesExists.toString() + + ' FETCH (BODY[HEADER] {345}\r\n' + 'Date: Wed, 17 Jul 1996 02:23:25 -0700 (PDT)\r\n' + 'From: Terry Gray \r\n' + 'Subject: IMAP4rev1 WG mtg summary and minutes\r\n' + 'To: imap@cac.washington.edu\r\n' + 'cc: minutes@CNRI.Reston.VA.US, \r\n' + ' John Klensin \r\n' + 'Message-Id: \r\n' + 'MIME-Version: 1.0\r\n' + 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n' + ')\r\n'); + mockServer.fetchResponses.add(lowerIndex.toString() + + ' FETCH (BODY[HEADER] {319}\r\n' + 'Date: Wed, 17 Jul 2020 02:23:25 -0700 (PDT)\r\n' + 'From: COI JOY \r\n' + 'Subject: COI\r\n' + 'To: imap@cac.washington.edu\r\n' + 'cc: minutes@CNRI.Reston.VA.US, \r\n' + ' John Klensin \r\n' + 'Message-Id: \r\n' + 'MIME-Version: 1.0\r\n' + 'Chat-Version: 1.0\r\n' + 'Content-Type: text/plan; charset="UTF-8"\r\n' + ')\r\n'); + } + var fetchResponse = await client.fetchMessages( + lowerIndex, inbox.messagesExists, 'BODY[HEADER]'); + expect(fetchResponse.status, ResponseStatus.OK, + reason: 'support for FETCH BODY[] expected'); + if (mockServer != null) { + expect(fetchResponse.result != null, true, + reason: 'fetch result expected'); + // for (int i=0; i'); + + message = fetchResponse.result[1]; + expect(message.sequenceId, lowerIndex); + expect(message.headers != null, true); + expect(message.headers.length, 9); + expect(message.getHeaderValue('Chat-Version'), '1.0'); + expect(message.getHeaderValue('Content-Type'), 'text/plan; charset="UTF-8"'); + } + }); + + test('ImapClient fetch BODY[]', () async { + _log(''); + var lowerIndex = math.max(inbox.messagesExists - 1, 0); + if (mockServer != null) { + mockServer.fetchResponses.clear(); + mockServer.fetchResponses.add(inbox.messagesExists.toString() + + ' FETCH (BODY[] {359}\r\n' + 'Date: Wed, 17 Jul 1996 02:23:25 -0700 (PDT)\r\n' + 'From: Terry Gray \r\n' + 'Subject: IMAP4rev1 WG mtg summary and minutes\r\n' + 'To: imap@cac.washington.edu\r\n' + 'cc: minutes@CNRI.Reston.VA.US, \r\n' + ' John Klensin \r\n' + 'Message-Id: \r\n' + 'MIME-Version: 1.0\r\n' + 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r\n' + '\r\n' + 'Hello Word\r\n' + ')\r\n'); + mockServer.fetchResponses.add(lowerIndex.toString() + + ' FETCH (BODY[] {374}\r\n' + 'Date: Wed, 17 Jul 1996 02:23:25 -0700 (PDT)\r\n' + 'From: Terry Gray \r\n' + 'Subject: IMAP4rev1 WG mtg summary and minutes\r\n' + 'To: imap@cac.washington.edu\r\n' + 'cc: minutes@CNRI.Reston.VA.US, \r\n' + ' John Klensin \r\n' + 'Message-Id: \r\n' + 'MIME-Version: 1.0\r\n' + 'Content-Type: text/plain; charset="utf-8"\r\n' + '\r\n' + 'Welcome to Enough MailKit.\r\n' + ')\r\n'); + } + var fetchResponse = await client.fetchMessages( + lowerIndex, inbox.messagesExists, 'BODY[]'); + expect(fetchResponse.status, ResponseStatus.OK, + reason: 'support for FETCH BODY[] expected'); + if (mockServer != null) { + expect(fetchResponse.result != null, true, + reason: 'fetch result expected'); + expect(fetchResponse.result.length, 2); + var message = fetchResponse.result[0]; + expect(message.sequenceId, lowerIndex + 1); + expect(message.bodyRaw, 'Hello Word\r\n'); + + message = fetchResponse.result[1]; + expect(message.sequenceId, lowerIndex); + expect(message.bodyRaw, 'Welcome to Enough MailKit.\r\n'); + expect(message.getHeaderValue('MIME-Version'), '1.0'); + expect(message.getHeaderValue('Content-Type'), 'text/plain; charset="utf-8"'); + //expect(message.getHeader('Content-Type').first.value, 'text/plain; charset="utf-8"'); + } + }); + + + + test('ImapClient fetch BODY[0]', () async { + _log(''); + var lowerIndex = math.max(inbox.messagesExists - 1, 0); + if (mockServer != null) { + mockServer.fetchResponses.clear(); + mockServer.fetchResponses.add(inbox.messagesExists.toString() + + ' FETCH (BODY[0] {12}\r\n' + 'Hello Word\r\n' + ')\r\n'); + mockServer.fetchResponses.add(lowerIndex.toString() + + ' FETCH (BODY[0] {28}\r\n' + 'Welcome to Enough MailKit.\r\n' + ')\r\n'); + } + var fetchResponse = await client.fetchMessages( + lowerIndex, inbox.messagesExists, 'BODY[0]'); + expect(fetchResponse.status, ResponseStatus.OK, + reason: 'support for FETCH BODY[0] expected'); + if (mockServer != null) { + expect(fetchResponse.result != null, true, + reason: 'fetch result expected'); + expect(fetchResponse.result.length, 2); + var message = fetchResponse.result[0]; + expect(message.sequenceId, lowerIndex + 1); + expect(message.getBodyPart(0), 'Hello Word\r\n'); + + message = fetchResponse.result[1]; + expect(message.sequenceId, lowerIndex); + expect(message.getBodyPart(0), 'Welcome to Enough MailKit.\r\n'); + } + }); + + test('ImapClient noop', () async { + _log(''); + await Future.delayed(Duration(seconds: 1)); + var noopResponse = await client.noop(); + expect(noopResponse.status, ResponseStatus.OK); + + if (mockServer != null) { + expungedMessages.clear(); + mockInbox.noopChanges = List.from([ + '2232 EXPUNGE', + '1234 EXPUNGE', + '23 EXISTS', + '3 RECENT', + r'14 FETCH (FLAGS (\Seen \Deleted))', + r'2322 FETCH (FLAGS (\Seen $Chat))', + ]); + noopResponse = await client.noop(); + await Future.delayed(Duration(milliseconds: 50)); + expect(noopResponse.status, ResponseStatus.OK); + expect(expungedMessages, List.from([2232, 1234]), + reason: 'Expunged messages should fit'); + expect(inbox.messagesExists, 23); + expect(inbox.messagesRecent, 3); + expect(fetchEvents.length, 2, reason: 'Expecting 2 fetch events'); + var event = fetchEvents[0]; + expect(event.messageSequenceId, 14); + expect(event.flags, List.from([r'\Seen', r'\Deleted'])); + event = fetchEvents[1]; + expect(event.messageSequenceId, 2322); + expect(event.flags, List.from([r'\Seen', r'$Chat'])); + } + }); + + test('ImapClient idle', () async { + _log(''); + expungedMessages.clear(); + var idleResponseFuture = client.idleStart(); + + if (mockServer != null) { + mockInbox.messagesExists += 4; + mockServer.fire(Duration(milliseconds: 100), '* 2 EXPUNGE\r\n* 17 EXPUNGE\r\n* ${mockInbox.messagesExists} EXISTS\r\n'); + } + await Future.delayed(Duration(milliseconds: 200)); + await client.idleDone(); + var idleResponse = await idleResponseFuture; + expect(idleResponse.status, ResponseStatus.OK); + if (mockServer != null) { + expect(expungedMessages.length, 2); + expect(expungedMessages[0], 2); + expect(expungedMessages[1], 17); + expect(inbox.messagesExists, mockInbox.messagesExists); + } + + //expect(doneResponse.status, ResponseStatus.OK); + }); + + + test('ImapClient close', () async { + _log(''); + var closeResponse = await client.closeMailbox(); + expect(closeResponse.status, ResponseStatus.OK); + }); + + test('ImapClient logout', () async { + _log(''); + var logoutResponse = await client.logout(); + expect(logoutResponse.status, ResponseStatus.OK); + + //await Future.delayed(Duration(seconds: 1)); + client.close(); + _log('done connecting'); + client = null; + }); +} + +void _log(String text) { + if (_isLogEnabled) { + print(text); + } +} + +String _toString(List elements, [String separator = ' ']) { + var buffer = StringBuffer(); + var addSeparator = false; + for (var element in elements) { + if (addSeparator) { + buffer.write(separator); + } + buffer.write(element); + addSeparator = true; + } + return buffer.toString(); +} diff --git a/test/imap/mock_imap_server.dart b/test/imap/mock_imap_server.dart new file mode 100644 index 00000000..1bc4c07b --- /dev/null +++ b/test/imap/mock_imap_server.dart @@ -0,0 +1,357 @@ +import 'dart:io'; + +import 'dart:typed_data'; + +import 'package:enough_mail/imap/Mailbox.dart'; + +enum ServerState { notAuthenticated, authenticated, selected } + +class ServerMailbox extends Mailbox { + List children = []; + List messageSequenceIdsUnseen; + List noopChanges; + + ServerMailbox(String name, List flags, String messageFlags, + String permanentMessageFlags) + : super.setup(name, flags) { + super.messageFlags = messageFlags.split(' '); + super.permanentMessageFlags = permanentMessageFlags.split(' '); + } +} + +/// Simple IMAP mock server for testing purposes +class MockImapServer { + final Socket _socket; + ServerState state = ServerState.notAuthenticated; + static const String _CRLF = '\r\n'; + static const String PathSeparatorSlash = '/'; + static const String PathSeperatorDot = '.'; + String pathSeparator = PathSeparatorSlash; + List mailboxes = []; + List mailboxesSubscribed = []; + List fetchResponses = []; + ServerMailbox _selectedMailbox; + + String _idleTag; + + static MockImapServer connect(Socket socket) { + return MockImapServer(socket); + } + + MockImapServer(this._socket) { + _socket.listen((data) { + parseRequest(data); + }, onDone: () { + print('server connection done'); + }, onError: (error) { + print('server error: $error'); + }); + } + + void parseRequest(Uint8List data) { + var line = String.fromCharCodes(data); + //print("SERVER RECEIVED: " + line); + var firstSpaceIndex = line.indexOf(' '); + if (firstSpaceIndex == -1) { + // this could still be valid after a continuation request from this server + if (line.startsWith('DONE') && _idleTag != null) { + writeln(_idleTag + ' OK IDLE finished.'); + _idleTag = null; + return; + } + processInvalidRequest('', line); + } + var tag = line.substring(0, firstSpaceIndex); + var request = line.substring(firstSpaceIndex + 1); + String Function(String) function; + if (request.startsWith('CAPABILITY ')) { + function = respondCapability; + } else if (request.startsWith('LOGIN ')) { + function = respondLogin; + } else if (request.startsWith('LIST ')) { + function = respondList; + } else if (request.startsWith('LSUB ')) { + function = respondLsub; + } else if (request.startsWith('SELECT ')) { + function = respondSelect; + } else if (request.startsWith('SEARCH ')) { + function = respondSearch; + } else if (request.startsWith('NOOP')) { + function = respondNoop; + } else if (request.startsWith('CLOSE')) { + function = respondClose; + } else if (request.startsWith('LOGOUT')) { + function = respondLogout; + } else if (request.startsWith('FETCH ')) { + function = respondFetch; + } else if (request.startsWith('IDLE')) { + _idleTag = tag; + function = respondIdle; + } + + if (function != null) { + var response = function(request); + if (response != null) { + writeln(tag + ' ' + response); + } + } else { + processInvalidRequest(tag, line); + } + } + + void writeUntagged(String response) { + writeln('* ' + response); + } + + void writeln(String data) { + //print("SERVER ANSWERS: " + data); + _socket.writeln(data); + } + + void write(String data) { + _socket.write(data); + } + + void processInvalidRequest(String tag, String line) { + print('encountered unsupported request: ' + line); + writeln(tag + ' BAD unsupported request: ' + line); + } + + String respondCapability(String line) { + _writeCapabilities(); + return 'OK CAPABILITY completed'; + } + + String respondLogin(String line) { + if (line == 'LOGIN testuser testpassword' + _CRLF) { + state = ServerState.authenticated; + _writeCapabilities(); + return 'OK LOGIN completed'; + } + return 'NO user unknown or password wrong'; + } + + String respondList(String line) { + return _respondListLike(line, 'LIST', mailboxes); + } + + String respondLsub(String line) { + //print('LSUB request: $line\nsubscribed boxes: ${mailboxesSubscribed.length}'); + return _respondListLike(line, 'LSUB', mailboxesSubscribed); + } + + String respondSelect(String line) { + var boxName = line.substring( + 'SELECT '.length, line.indexOf('\r', 'SELECT '.length + 1)); + var box = mailboxes.firstWhere((box) => box.name.startsWith(boxName)); + if (box == null) { + return 'BAD unknown mailbox in ' + line; + } + state = ServerState.selected; + _selectedMailbox = box; + var response = '* FLAGS (${_toString(box.messageFlags)})\r\n' + '* OK [PERMANENTFLAGS (${_toString(box.permanentMessageFlags)})] Flags permitted\r\n' + '* ${box.messagesExists} EXISTS\r\n' + '* OK [UNSEEN ${box.firstUnseenMessageSequenceId}] First unseen.\r\n' + '* OK [UIDVALIDITY ${box.uidValidity}] UIDs valid\r\n' + '* ${box.messagesRecent} RECENT\r\n' + '* OK [UIDNEXT ${box.uidNext}] Predicted next UID\r\n' + '* OK [HIGHESTMODSEQ ${box.highestModSequence}] Highest\r\n'; + write(response); + return 'OK [READ-WRITE] Select completed (0.088 + 0.000 + 0.087 secs).'; + } + + String respondIdle(String line) { + write('+ idling\r\n'); + write('* OK Still here\r\n'); + return null; //'OK IDLE MODE started...'; + } + + String respondSearch(String line) { + var box = _selectedMailbox; + if ((state != ServerState.authenticated && state != ServerState.selected) || + (box == null)) { + return 'NO not authenticated or no mailbox selected'; + } + var searchQuery = line.substring('SEARCH '.length, line.length - 2); + List sequenceIds; + if (searchQuery == 'UNSEEN') { + sequenceIds = box.messageSequenceIdsUnseen; + } + if (sequenceIds == null) { + return 'BAD search not supported: ' + + line + + ' query=[' + + searchQuery + + ']'; + } + writeUntagged('SEARCH ' + _toString(sequenceIds)); + return 'OK SEARCH completed (0.019 + 0.000 + 0.018 secs).'; + } + + String respondNoop(String line) { + var box = _selectedMailbox; + if (box != null) { + if (box.noopChanges != null) { + for (var change in box.noopChanges) { + writeUntagged(change); + } + } + } + return 'OK NOOP completed (0.001 + 0.077 secs).'; + } + + String respondClose(String line) { + var box = _selectedMailbox; + if (box != null) { + _selectedMailbox = null; + state = ServerState.authenticated; + return 'OK CLOSE completed (0.001 + 0.037 secs).'; + } + return 'NO you need to SELECT a mailbox first'; + } + + String respondLogout(String line) { + if (state == ServerState.authenticated || state == ServerState.selected) { + _selectedMailbox = null; + state = ServerState.notAuthenticated; + writeUntagged('BYE'); + return 'OK LOGOUT completed (0.000 + 0.017 secs).'; + } + return 'NO you have to LOGIN first'; + } + + String respondFetch(String line) { + var box = _selectedMailbox; + if ((state != ServerState.authenticated && state != ServerState.selected) || + (box == null)) { + return 'NO not authenticated or no mailbox selected'; + } + var isLastMessageEndingWithLiteral = false; + for (var fetch in fetchResponses) { + if (isLastMessageEndingWithLiteral) { + write(fetch); + } else { + writeUntagged(fetch); + isLastMessageEndingWithLiteral = fetch[fetch.length - 1] == '}'; + } + } + return 'OK Fetch completed (0.001 + 0.000 secs).'; + } + + String _toString(List elements, [String separator = ' ']) { + var buffer = StringBuffer(); + var addSeparator = false; + for (var element in elements) { + if (addSeparator) { + buffer.write(separator); + } + buffer.write(element); + addSeparator = true; + } + return buffer.toString(); + } + + String _respondListLike(String line, String command, List boxes) { + if (state != ServerState.authenticated && state != ServerState.selected) { + return 'BAD not authenticated'; + } else if (line.startsWith(command + ' "" %')) { + return _respondListMailboxes(command, boxes); + } else if (line.startsWith(command) && line.endsWith(' %' + _CRLF)) { + var boxName = line.substring(command.length + 1, line.lastIndexOf(' %')); + var isListChildren = false; + if (boxName.endsWith(pathSeparator)) { + boxName = boxName.substring(0, boxName.length - 2); + isListChildren = true; + } + var matches = List.from(boxes); + matches.retainWhere((box) => box.name.startsWith(boxName)); + if (isListChildren) { + // TODO allow to list children + } + return _respondListMailboxes(command, matches); + } else { + return 'NO mockimplementation does not support ' + line; + } + } + + String _respondListMailboxes(String command, List boxes) { + for (var box in boxes) { + var boxText = command + ' ('; + var addSpace = false; + for (var flag in box.flags) { + if (addSpace) { + boxText += ' '; + } + switch (flag) { + case MailboxFlag.hasNoChildren: + addSpace = true; + boxText += '\\HasNoChildren'; + break; + case MailboxFlag.hasChildren: + addSpace = true; + boxText += '\\HasChildren'; + break; + case MailboxFlag.marked: + addSpace = true; + boxText += '\\marked'; + break; + case MailboxFlag.unMarked: + addSpace = true; + boxText += '\\unmarked'; + break; + case MailboxFlag.select: + addSpace = true; + boxText += '\\select'; + break; + case MailboxFlag.noSelect: + addSpace = true; + boxText += '\\Noselect'; + break; + case MailboxFlag.drafts: + addSpace = true; + boxText += '\\Drafts'; + break; + case MailboxFlag.inbox: + addSpace = true; + boxText += '\\Inbox'; + break; + case MailboxFlag.junk: + addSpace = true; + boxText += '\\Junk'; + break; + case MailboxFlag.sent: + addSpace = true; + boxText += '\\Sent'; + break; + case MailboxFlag.trash: + addSpace = true; + boxText += '\\Trash'; + break; + case MailboxFlag.archive: + addSpace = true; + boxText += '\\Archive'; + break; + default: + return 'BAD ' + + command + + ': UNEXPECTED MailboxFlag ' + + flag.toString() + + ' encountered'; + } + } + boxText += ') "' + pathSeparator + '" ' + box.name; + writeUntagged(boxText); + } + return 'OK ' + command + ' completed (0.166 + 0.000 + 0.165 secs).'; + } + + void _writeCapabilities() { + writeUntagged('CAPABILITY IMAP4rev1 IDLE'); + } + + void fire(Duration duration, String s) async { + await Future.delayed(duration); + write(s); + } +} diff --git a/test/mock_socket.dart b/test/mock_socket.dart new file mode 100644 index 00000000..fd6c413e --- /dev/null +++ b/test/mock_socket.dart @@ -0,0 +1,404 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'dart:typed_data'; + +class MockConnection { + MockSocket socketClient; + MockSocket socketServer; + + MockConnection() { + socketClient = MockSocket(); + socketServer = MockSocket(); + socketClient._other = socketServer; + socketServer._other = socketClient; + } +} + +class MockSocket implements Socket { + MockSocket _other; + MockStreamSubscription _subscription; + Utf8Encoder _encoder = Utf8Encoder(); + static const String _CRLF = "\r\n"; + + @override + Encoding encoding; + + @override + void add(List data) { + // TODO: implement add + } + + @override + void addError(Object error, [StackTrace stackTrace]) { + // TODO: implement addError + } + + @override + Future addStream(Stream> stream) { + // TODO: implement addStream + return null; + } + + @override + // TODO: implement address + InternetAddress get address => null; + + @override + Future any(bool Function(Uint8List element) test) { + // TODO: implement any + return null; + } + + @override + Stream asBroadcastStream( + {void Function(StreamSubscription subscription) onListen, + void Function(StreamSubscription subscription) onCancel}) { + // TODO: implement asBroadcastStream + return null; + } + + @override + Stream asyncExpand(Stream Function(Uint8List event) convert) { + // TODO: implement asyncExpand + return null; + } + + @override + Stream asyncMap(FutureOr Function(Uint8List event) convert) { + // TODO: implement asyncMap + return null; + } + + @override + Stream cast() { + // TODO: implement cast + return null; + } + + @override + Future close() { + _subscription.handleDone(); + return null; + } + + @override + Future contains(Object needle) { + // TODO: implement contains + return null; + } + + @override + void destroy() { + // TODO: implement destroy + } + + @override + Stream distinct( + [bool Function(Uint8List previous, Uint8List next) equals]) { + // TODO: implement distinct + return null; + } + + @override + // TODO: implement done + Future get done => null; + + @override + Future drain([E futureValue]) { + // TODO: implement drain + return null; + } + + @override + Future elementAt(int index) { + // TODO: implement elementAt + return null; + } + + @override + Future every(bool Function(Uint8List element) test) { + // TODO: implement every + return null; + } + + @override + Stream expand(Iterable Function(Uint8List element) convert) { + // TODO: implement expand + return null; + } + + @override + // TODO: implement first + Future get first => null; + + @override + Future firstWhere(bool Function(Uint8List element) test, + {Uint8List Function() orElse}) { + // TODO: implement firstWhere + return null; + } + + @override + Future flush() { + // TODO: implement flush + return null; + } + + @override + Future fold( + S initialValue, S Function(S previous, Uint8List element) combine) { + // TODO: implement fold + return null; + } + + @override + Future forEach(void Function(Uint8List element) action) { + // TODO: implement forEach + return null; + } + + @override + Uint8List getRawOption(RawSocketOption option) { + // TODO: implement getRawOption + return null; + } + + @override + Stream handleError(Function onError, {bool test(error)}) { + // TODO: implement handleError + return null; + } + + @override + // TODO: implement isBroadcast + bool get isBroadcast => null; + + @override + // TODO: implement isEmpty + Future get isEmpty => null; + + @override + Future join([String separator = ""]) { + // TODO: implement join + return null; + } + + @override + // TODO: implement last + Future get last => null; + + @override + Future lastWhere(bool Function(Uint8List element) test, + {Uint8List Function() orElse}) { + // TODO: implement lastWhere + return null; + } + + @override + // TODO: implement length + Future get length => null; + + onErrorImpl(dynamic error) { + print("ON SOCKET ERROR"); + } + + onDoneImpl() { + print("ON SOCKET DONE"); + } + + @override + StreamSubscription listen(void Function(Uint8List event) onData, + {Function onError, void Function() onDone, bool cancelOnError}) { + if (onError == null) { + onError = onErrorImpl; + } + if (onDone == null) { + onDone = onDoneImpl; + } + var subscription = MockStreamSubscription(onData, onError, onDone); + _subscription = subscription; + return subscription; + } + + @override + Stream map(S Function(Uint8List event) convert) { + // TODO: implement map + return null; + } + + @override + Future pipe(StreamConsumer streamConsumer) { + // TODO: implement pipe + return null; + } + + @override + // TODO: implement port + int get port => null; + + @override + Future reduce( + Uint8List Function(Uint8List previous, Uint8List element) combine) { + // TODO: implement reduce + return null; + } + + @override + // TODO: implement remoteAddress + InternetAddress get remoteAddress => null; + + @override + // TODO: implement remotePort + int get remotePort => null; + + @override + bool setOption(SocketOption option, bool enabled) { + // TODO: implement setOption + return null; + } + + @override + void setRawOption(RawSocketOption option) { + // TODO: implement setRawOption + } + + @override + // TODO: implement single + Future get single => null; + + @override + Future singleWhere(bool Function(Uint8List element) test, + {Uint8List Function() orElse}) { + // TODO: implement singleWhere + return null; + } + + @override + Stream skip(int count) { + // TODO: implement skip + return null; + } + + @override + Stream skipWhile(bool Function(Uint8List element) test) { + // TODO: implement skipWhile + return null; + } + + @override + Stream take(int count) { + // TODO: implement take + return null; + } + + @override + Stream takeWhile(bool Function(Uint8List element) test) { + // TODO: implement takeWhile + return null; + } + + @override + Stream timeout(Duration timeLimit, + {void Function(EventSink sink) onTimeout}) { + // TODO: implement timeout + return null; + } + + @override + Future> toList() { + // TODO: implement toList + return null; + } + + @override + Future> toSet() { + // TODO: implement toSet + return null; + } + + @override + Stream transform(StreamTransformer streamTransformer) { + // TODO: implement transform + return null; + } + + @override + Stream where(bool Function(Uint8List event) test) { + // TODO: implement where + return null; + } + + @override + void write(Object obj) { + var text = obj.toString(); + var data = _encoder.convert(text); + //print("socket writing " + text + ", handler: " + _other._subscription.handleData.toString()); + _other._subscription.handleData(data); + } + + @override + void writeAll(Iterable objects, [String separator = ""]) { + // TODO: implement writeAll + } + + @override + void writeCharCode(int charCode) { + // TODO: implement writeCharCode + } + + @override + void writeln([Object obj = ""]) { + write(obj.toString() + _CRLF); + } +} + +class MockStreamSubscription extends StreamSubscription { + void Function(Uint8List data) handleData; + Function handleError; + void Function() handleDone; + + MockStreamSubscription(this.handleData, this.handleError, this.handleDone); + + @override + Future asFuture([E futureValue]) { + // TODO: implement asFuture + return null; + } + + @override + Future cancel() { + // TODO: implement cancel + return null; + } + + @override + // TODO: implement isPaused + bool get isPaused => null; + + @override + void onData(void Function(Uint8List data) handleData) { + this.handleData = handleData; + } + + @override + void onDone(void Function() handleDone) { + this.handleDone = handleDone; + } + + @override + void onError(Function handleError) { + this.handleError = handleError; + } + + @override + void pause([Future resumeSignal]) { + // TODO: implement pause + } + + @override + void resume() { + // TODO: implement resume + } +} diff --git a/test/smtp/mock_smtp_server.dart b/test/smtp/mock_smtp_server.dart new file mode 100644 index 00000000..f2c0cae4 --- /dev/null +++ b/test/smtp/mock_smtp_server.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +enum MailSendState { notStarted, rcptTo, data } + +class MockSmtpServer { + String nextResponse; + final Socket _socket; + final String _userName; + final String _userPassword; + MailSendState _sendState = MailSendState.notStarted; + + static MockSmtpServer connect( + Socket socket, String userName, String userPassword) { + return MockSmtpServer(socket, userName, userPassword); + } + + MockSmtpServer(this._socket, this._userName, this._userPassword) { + _socket.listen((data) { + onRequest(String.fromCharCodes(data)); + }, onDone: () { + print('server connection done'); + }, onError: (error) { + print('server error: ' + error); + }); + } + + void onRequest(String request) { + // check for supported request: + if (_sendState != MailSendState.notStarted || + request.startsWith('MAIL FROM:')) { + onMailSendRequest(request); + return; + } else if (request == 'QUIT\r\n') { + writeln('221 2.0.0 Bye'); + } else if (nextResponse == null || nextResponse.isEmpty) { // // no supported request found, answer with the pre-defined response: + writeln('500 Invalid state - define nextResponse for MockSmtpServer'); + } else { + writeln(nextResponse); + nextResponse = null; + } + } + + void onMailSendRequest(String request) { + if (_sendState == MailSendState.notStarted) { + _sendState = MailSendState.rcptTo; + writeln('250 2.1.0 Ok'); + } else if (_sendState == MailSendState.rcptTo) { + if (request.startsWith('DATA')) { + _sendState = MailSendState.data; + writeln('354 End data with .'); + } else { + writeln('250 2.1.5 Ok'); + } + } else if (request.endsWith('\r\n.\r\n')) { + _sendState = MailSendState.notStarted; + writeln('250 2.0.0 Ok: queued as 66BF93C0360'); + } + } + + void writeln(String response) { + _socket.writeln(response); + } +} diff --git a/test/smtp/smtp_client_test.dart b/test/smtp/smtp_client_test.dart new file mode 100644 index 00000000..3c410e3e --- /dev/null +++ b/test/smtp/smtp_client_test.dart @@ -0,0 +1,130 @@ +import 'package:test/test.dart'; +import 'dart:io'; +import 'package:event_bus/event_bus.dart'; +import 'package:enough_mail/enough_mail.dart'; + +import '../mock_socket.dart'; +import 'mock_smtp_server.dart'; + +SmtpClient client; +bool _isLogEnabled = false; +String _smtpUser; +String _smtpPassword; +MockSmtpServer _mockServer; + +void main() { + setUp(() async { + if (client != null) { + return; + } + _log('setting up ImapClient tests'); + var envVars = Platform.environment; + + var smtpPort = 587; // 25; + String smtpHost; + var useRealConnection = + (!envVars.containsKey('SMTP_USE') || envVars['SMTP_USE'] == 'true') && + envVars.containsKey('SMTP_HOST') && + envVars.containsKey('SMTP_USER') && + envVars.containsKey('SMTP_PASSWORD'); + if (useRealConnection) { + if (envVars.containsKey('SMTP_LOG')) { + _isLogEnabled = (envVars['SMTP_LOG'] == 'true'); + } else { + _isLogEnabled = true; + } + smtpHost = envVars['SMTP_HOST']; + _smtpUser = envVars['SMTP_USER']; + _smtpPassword = envVars['SMTP_PASSWORD']; + if (envVars.containsKey('SMTP_PORT')) { + smtpPort = int.parse(envVars['SMTP_PORT']); + } + } else if (envVars.containsKey('SMTP_LOG')) { + _isLogEnabled = (envVars['SMTP_LOG'] == 'true'); + } + client = SmtpClient('coi-dev.org', bus: EventBus(sync: true), isLogEnabled: _isLogEnabled); + + // client.eventBus + // .on() + // .listen((e) => expungedMessages.add(e.messageSequenceId)); + // client.eventBus.on().listen((e) => fetchEvents.add(e)); + + if (useRealConnection) { + await client.connectToServer(smtpHost, smtpPort, + isSecure: (smtpPort != 25)); + //capResponse = await client.login(imapUser, imapPassword); + } else { + _smtpUser = 'testuser'; + _smtpPassword = 'testpassword'; + var connection = MockConnection(); + client.connect(connection.socketClient); + _mockServer = MockSmtpServer.connect( + connection.socketServer, _smtpUser, _smtpPassword); + client.serverInfo = SmtpServerInfo(); + // capResponse = await client.login("testuser", "testpassword"); + } + // mockInbox = ServerMailbox( + // "INBOX", + // List.from([MailboxFlag.hasChildren]), + // supportedMessageFlags, + // supportedPermanentMessageFlags); + _log('SmtpClient test setup complete'); + }); + + test('SmtpClient EHLO', () async { + if (_mockServer != null) { + _mockServer.nextResponse = '220 domain.com ESMTP Postfix\r\n' + '250-domain.com\r\n' + '250-PIPELINING\r\n' + '250-SIZE 200000000\r\n' + '250-ETRN\r\n' + '250-AUTH PLAIN LOGIN OAUTHBEARER\r\n' + '250-AUTH=PLAIN LOGIN OAUTHBEARER\r\n' + '250-ENHANCEDSTATUSCODES\r\n' + '250-8BITMIME\r\n' + '250 DSN'; + } + var response = await client.ehlo(); + expect(response.type, SmtpResponseType.success); + expect(response.code, 250); + }); + + test('SmtpClient login', () async { + if (_mockServer != null) { + _mockServer.nextResponse = '235 2.7.0 Authentication successful'; + } + var response = await client.login(_smtpUser, _smtpPassword); + expect(response.type, SmtpResponseType.success); + expect(response.code, 235); + }); + + test('SmtpClient sendMessage', () async { + var message = Message(); + message.from = Address.fromEnvelope('Rita Levi-Montalcini', null, 'Rita.Levi-Montalcini', 'domain.com'); + message.recipients.add('Rosalind.Franklin@domain.com'); + message.headerRaw = 'From: Rita.Levi-Montalcini@domain.com\r\n' + 'To: Rosalind.Franklin@domain.com\r\n' + 'Subject: Enough MailKit Hello 2\r\n' + 'Message-ID: chat\$232.123o29892232.domain.com\r\n' + 'Date: Mon, 13 Jan 2020 15:47:37 +0100\r\n' + 'Conten-Type: text/plain; charset=utf-8; format=flowed\r\n' + 'Content-Transfer-Encoding: 8bit\r\n'; + message.bodyRaw = + 'Today as well.\r\nOne more time:\r\nHello from Enough MailKit!'; + var response = await client.sendMessage(message); + expect(response.type, SmtpResponseType.success); + expect(response.code, 250); + }); + + test('SmtpClient quit', () async { + var response = await client.quit(); + expect(response.type, SmtpResponseType.success); + expect(response.code, 221); + }); +} + +void _log(String text) { + if (_isLogEnabled) { + print(text); + } +} diff --git a/test/src/imap/imap_response_line_test.dart b/test/src/imap/imap_response_line_test.dart new file mode 100644 index 00000000..475372fb --- /dev/null +++ b/test/src/imap/imap_response_line_test.dart @@ -0,0 +1,34 @@ +import 'package:enough_mail/src/imap/imap_response_line.dart'; +import 'package:test/test.dart'; + + +void main() { + test('ImapResponseLine.init() with simple response',() { + var input = 'HELLO ()'; + var line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, input); + expect(line.isWithLiteral, false); + }); // test end + + test('ImapResponseLine.init() with complex response',() { + var input = 'HELLO {12}'; + var line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, 'HELLO'); + expect(line.isWithLiteral, true); + expect(line.literal, 12); + + }); // test end + + test('ImapResponseLine.init() with complex response and plus after the numeric literal',() { + var input = 'HELLO {12+}'; + var line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, 'HELLO'); + expect(line.isWithLiteral, true); + expect(line.literal, 12); + + }); // test end + +} \ No newline at end of file diff --git a/test/src/imap/imap_response_reader_test.dart b/test/src/imap/imap_response_reader_test.dart new file mode 100644 index 00000000..655375ba --- /dev/null +++ b/test/src/imap/imap_response_reader_test.dart @@ -0,0 +1,197 @@ +import 'package:enough_mail/src/imap/imap_response.dart'; +import 'package:enough_mail/src/imap/imap_response_reader.dart'; +import 'package:test/test.dart'; +import 'dart:typed_data'; + +ImapResponse _lastResponse; +void _onImapResponse(ImapResponse response) { + _lastResponse = response; +} + +List _lastResponses = []; +void _onMultipleImapResponse(ImapResponse response) { + _lastResponses.add(response); +} + +Uint8List _toUint8List(String text) { + return Uint8List.fromList(text.codeUnits); +} + +// String _toString(Uint8List bytes) { +// return String.fromCharCodes(bytes); +// } + +void main() { + test('ImapResponseReader.oneOnDataCall()', () { + var reader = ImapResponseReader(_onImapResponse); + var text = + r'1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200" ' + 'RFC822.SIZE 15320 ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200 (CEST)" {61}\r\n' + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps' + ' (("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_' + 'Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" ' + '"domain.com")) (("Alice Dev" NIL "alice.dev" "domain.com")) NIL NIL "" "<130499090.797.1572014128349@product-gw2.domain.com>") BODY (("text" "plain" ' + '("charset" "UTF-8") NIL NIL "quoted-printable" 1289 53)("text" "html" ("charset" "UTF-8") NIL NIL "quoted-printable" ' + '7496 302) "alternative"))\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse != null, true, reason: 'response expected'); + expect(_lastResponse.isSimple, false); + expect(_lastResponse.lines != null, true); + expect(_lastResponse.lines.length, 3); + expect(_lastResponse.lines[0] != null, true); + expect(_lastResponse.lines[0].isWithLiteral, true); + expect(_lastResponse.lines[0].literal, 61); + expect(_lastResponse.lines[0].rawLine, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200" RFC822.SIZE 15320 ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200 (CEST)" {61}'); + expect(_lastResponse.lines[0].line, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200" RFC822.SIZE 15320 ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200 (CEST)"'); + expect(_lastResponse.lines[1] != null, true); + expect(_lastResponse.lines[1].isWithLiteral, false); + expect(_lastResponse.lines[1].line, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps'); + expect(_lastResponse.lines[1].rawLine, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps'); + expect(_lastResponse.lines[2] != null, true); + expect(_lastResponse.lines[2].isWithLiteral, false); + _lastResponse = null; + }); // test end + + test('ImapResponseReader - simple response', () { + var reader = ImapResponseReader(_onImapResponse); + var text = + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200")\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse != null, true, reason: 'response expected'); + expect(_lastResponse.lines != null, true); + expect(_lastResponse.isSimple, true); + expect(_lastResponse.lines.length, 1); + expect(_lastResponse.first, _lastResponse.lines[0] ); + expect(_lastResponse.lines[0] != null, true); + expect(_lastResponse.lines[0].isWithLiteral, false); + expect(_lastResponse.lines[0].rawLine, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200")'); + expect(_lastResponse.lines[0].line, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200")'); + _lastResponse = null; + }); // test end + + test('ImapResponseReader - test simple response delivered in 2 packages', () { + var reader = ImapResponseReader(_onImapResponse); + var text = + '1232 FETCH (FLAGS () INTERNALDATE'; + reader.onData(_toUint8List(text)); + expect(_lastResponse, null); + text = ' "25-Oct-2019 16:35:31 +0200")\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse != null, true, reason: 'response expected'); + expect(_lastResponse.lines != null, true); + expect(_lastResponse.isSimple, true); + expect(_lastResponse.lines.length, 1); + expect(_lastResponse.first, _lastResponse.lines[0] ); + expect(_lastResponse.lines[0] != null, true); + expect(_lastResponse.lines[0].isWithLiteral, false); + expect(_lastResponse.lines[0].rawLine, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200")'); + expect(_lastResponse.lines[0].line, + '1232 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200")'); + _lastResponse = null; + }); + + test('ImapResponseReader - response in several parts', () { + var reader = ImapResponseReader(_onImapResponse); + var text = 'A001 LOGIN {11+}\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse, null); + text = 'FRED FOOBAR {7+}\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse, null); + text = 'fat man\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponse != null, true); + expect(_lastResponse.isSimple, false); + expect(_lastResponse.lines.length, 3); + expect(_lastResponse.lines[0] != null, true); + expect(_lastResponse.lines[0].isWithLiteral, true); + expect(_lastResponse.lines[0].literal, 11); + expect(_lastResponse.lines[0].rawLine, 'A001 LOGIN {11+}'); + expect(_lastResponse.lines[0].line, 'A001 LOGIN'); + expect(_lastResponse.lines[1] != null, true); + expect(_lastResponse.lines[1].isWithLiteral, true); + expect(_lastResponse.lines[1].line, 'FRED FOOBAR'); + expect(_lastResponse.lines[1].rawLine, 'FRED FOOBAR {7+}'); + expect(_lastResponse.lines[1].literal, 7); + expect(_lastResponse.lines[2] != null, true); + expect(_lastResponse.lines[2].isWithLiteral, false); + expect(_lastResponse.lines[2].line, 'fat man'); + expect(_lastResponse.lines[2].rawLine, 'fat man'); + _lastResponse = null; + }); // test end + + + test('ImapResponseReader - response in one go', () { + _lastResponses.clear(); + var reader = ImapResponseReader(_onMultipleImapResponse); + var text = r'* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social $promotion $HasAttachment $HasNoAttachment $HasChat $MDNSent)' + '\r\n' + r'* OK [PERMANENTFLAGS (\Seen \Flagged)] Flags permitted' + '\r\n' + '* 512 EXISTS\r\n' + '* OK [UNSEEN 12] First unseen.\r\n' + '* OK [UIDVALIDITY 292] UIDs valid\r\n' + '* 10 RECENT\r\n' + '* OK [UIDNEXT 513] Predicted next UID\r\n' + '* OK [HIGHESTMODSEQ 1299] Highest\r\n' + 'a4 OK [READ-WRITE] Select completed (0.088 + 0.000 + 0.087 secs).\r\n'; + reader.onData(_toUint8List(text)); + for (var response in _lastResponses) { + expect(response.isSimple, true); + expect(response.lines[0].isWithLiteral, false); + expect(response.lines[0].literal, null); + expect(response.first.line.isNotEmpty, true); + } + var last = _lastResponses.last; + expect(last.lines[0] != null, true); + expect(last.lines[0].line, 'a4 OK [READ-WRITE] Select completed (0.088 + 0.000 + 0.087 secs).'); + // expect(_lastResponse.lines[0].line, r'* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social $promotion $HasAttachment $HasNoAttachment $HasChat $MDNSent)'); + // expect(_lastResponse.lines[1] != null, true); + // expect(_lastResponse.lines[1].line, r"* OK [PERMANENTFLAGS (\Seen \Flagged)] Flags permitted"); + // expect(_lastResponse.lines[2] != null, true); + // expect(_lastResponse.lines[2].isWithLiteral, false); + // expect(_lastResponse.lines[2].line, '* 512 EXISTS'); + + }); // test end + + test('ImapResponseReader - 2 responses in one delivery', () { + _lastResponses.clear(); + var reader = ImapResponseReader(_onMultipleImapResponse); + var text = '* 123 FETCH (FLAGS (){10}\r\n' + '0123456789' + ')\r\na002 OK Fetch completed\r\n'; + reader.onData(_toUint8List(text)); + expect(_lastResponses.length, 2); + expect(_lastResponses[0].lines.length, 3); + expect(_lastResponses[0].lines[1].rawLine, '0123456789'); + expect(_lastResponses[1].isSimple, true); + expect(_lastResponses[1].parseText, 'a002 OK Fetch completed' ); + }); // test end + + test('ImapResponseReader - 2 responses in 3 deliveries', () { + _lastResponses.clear(); + var reader = ImapResponseReader(_onMultipleImapResponse); + var text = '* 123 FETCH (FLAGS (){10}\r\n' + '012345'; + reader.onData(_toUint8List(text)); + expect(_lastResponses.length, 0); + text = '6789 INTERNALDATE "2020-12-23 14:23")\r\na002 OK F'; + reader.onData(_toUint8List(text)); + reader.onData(_toUint8List('etch completed\r\n')); + expect(_lastResponses.isNotEmpty, true); + expect(_lastResponses[0].lines.length, 3); + expect(_lastResponses[0].lines[1].rawLine, '0123456789'); + expect(_lastResponses.length, 2); + expect(_lastResponses[1].isSimple, true); + expect(_lastResponses[1].parseText, 'a002 OK Fetch completed' ); + }); // test end + +} diff --git a/test/src/imap/imap_response_test.dart b/test/src/imap/imap_response_test.dart new file mode 100644 index 00000000..059e831e --- /dev/null +++ b/test/src/imap/imap_response_test.dart @@ -0,0 +1,283 @@ +import 'package:enough_mail/src/imap/imap_response.dart'; +import 'package:enough_mail/src/imap/imap_response_line.dart'; +import 'package:test/test.dart'; + +void main() { + test('ImapResponse.iterate() with simple response', () { + var input = 'A001 OK FLAGS "seen" "new flag" DONE'; + var response = ImapResponse(); + var line = ImapResponseLine(input); + response.add(line); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 6); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[3].value, 'seen'); + expect(parsed.values[4].value, 'new flag'); + expect(parsed.values[5].value, 'DONE'); + }); // test end + + test('ImapResponse.iterate() with complex response', () { + var response = ImapResponse(); + response.add(ImapResponseLine('A001 OK FLAGS {10}')); + response.add(ImapResponseLine('1"2 3 \r\n90')); + response.add(ImapResponseLine('"DONE"')); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 5); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[3].value, '1"2 3 \r\n90'); + expect(parsed.values[4].value, 'DONE'); + }); // test end + + test('ImapResponse.iterate() with simple response and parentheses', () { + var input = 'A001 OK FLAGS ("seen" "new flag")'; + var response = ImapResponse(); + var line = ImapResponseLine(input); + response.add(line); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 3); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[2].children != null, true); + expect(parsed.values[2].children.length, 2); + expect(parsed.values[2].children[0].value, 'seen'); + expect(parsed.values[2].children[1].value, 'new flag'); + + input = 'A001 OK FLAGS (seen new)'; + response = ImapResponse(); + line = ImapResponseLine(input); + response.add(line); + parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 3); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[2].children != null, true); + expect(parsed.values[2].children.length, 2); + expect(parsed.values[2].children[0].value, 'seen'); + expect(parsed.values[2].children[1].value, 'new'); + }); // test end + + test('ImapResponse.iterate() with simple response and empty parentheses', () { + var input = 'A001 OK FLAGS () INTERNALDATE'; + var response = ImapResponse(); + var line = ImapResponseLine(input); + response.add(line); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 4); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[2].children != null, true); + expect(parsed.values[2].children.length, 0); + expect(parsed.values[3].value, 'INTERNALDATE'); + + input = 'A001 OK FLAGS ()'; + response = ImapResponse(); + line = ImapResponseLine(input); + response.add(line); + parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 3); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[2].children != null, true); + expect(parsed.values[2].children.length, 0); + }); // test end + + test('ImapResponse.iterate() with complex response and parentheses', () { + var response = ImapResponse(); + response.add(ImapResponseLine('A001 OK FLAGS ({10}')); + response.add(ImapResponseLine('1"2 3 \r\n90')); + response.add(ImapResponseLine('seen)')); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 3); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[2].children != null, true); + expect(parsed.values[2].children.length, 2); + expect(parsed.values[2].children[0].value, '1"2 3 \r\n90'); + expect(parsed.values[2].children[1].value, 'seen'); + }); + + test('ImapResponse.iterate() with simple response and double parentheses [1]', + () { + var input = 'A001 OK FLAGS (("seen" "new flag"))'; + var response = ImapResponse(); + var line = ImapResponseLine(input); + response.add(line); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 4); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[3].children != null, true); + expect(parsed.values[3].children.length, 2); + expect(parsed.values[3].children[0].value, 'seen'); + expect(parsed.values[3].children[1].value, 'new flag'); + }); + test('ImapResponse.iterate() with simple response and double parentheses [2]', + () { + var input = 'A001 OK FLAGS ((seen new))'; + var response = ImapResponse(); + var line = ImapResponseLine(input); + response.add(line); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 4); + expect(parsed.values[0].value, 'A001'); + expect(parsed.values[1].value, 'OK'); + expect(parsed.values[2].value, 'FLAGS'); + expect(parsed.values[3].children != null, true); + expect(parsed.values[3].children.length, 2); + expect(parsed.values[3].children[0].value, 'seen'); + expect(parsed.values[3].children[1].value, 'new'); + }); // test end + + test('ImapResponse.iterate() with complex real world response', () { + var response = ImapResponse(); + response.add(ImapResponseLine( + '* 123 FETCH (FLAGS () INTERNALDATE "25-Oct-2019 16:35:31 +0200" ' + 'RFC822.SIZE 15320 ENVELOPE ("Fri, 25 Oct 2019 16:35:28 +0200 (CEST)" {61}')); + expect(response.first.literal, 61); + response.add(ImapResponseLine( + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps')); + response.add(ImapResponseLine( + '(("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_' + 'Rob?=" NIL "rob.schoen" "domain.com")) (("=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=" NIL "rob.schoen" ' + '"domain.com")) (("Alice Dev" NIL "alice.dev" "domain.com")) NIL NIL "" "<130499090.797.1572014128349@product-gw2.domain.com>") BODY (("text" "plain" ' + '("charset" "UTF-8") NIL NIL "quoted-printable" 1289 53)("text" "html" ("charset" "UTF-8") NIL NIL "quoted-printable" ' + '7496 302) "alternative"))')); + var parsed = response.iterate(); + expect(parsed != null, true); + expect(parsed.values != null, true); + //print(parsed.values); + expect(parsed.values.length, 3); + expect(parsed.values[0].value, '*'); + expect(parsed.values[1].value, '123'); + expect(parsed.values[2].value, 'FETCH'); + var values = parsed.values[2].children; + expect(values != null, true); + expect(values[0].value, 'FLAGS'); + expect(values[0].children != null, true); + expect(values[0].children.length, 0); + expect(values[1].value, 'INTERNALDATE'); + expect(values[2].value, '25-Oct-2019 16:35:31 +0200'); + expect(values[3].value, 'RFC822.SIZE'); + expect(values[4].value, '15320'); + expect(values[5].value, 'ENVELOPE'); + values = values[5].children; + expect(values != null, true); + expect(values[0].value, 'Fri, 25 Oct 2019 16:35:28 +0200 (CEST)'); + expect(values[1].value, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps'); + expect(values[2].value, null); + expect(values[2].children != null, true); + expect(values[2].children.length, 4); + expect(values[2].children[0].value, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(values[2].children[1].value, 'NIL'); + expect(values[2].children[2].value, 'rob.schoen'); + expect(values[2].children[3].value, 'domain.com'); + + expect(values[3].value, null); + expect(values[3].children != null, true); + expect(values[3].children.length, 4); + expect(values[3].children[0].value, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(values[3].children[1].value, 'NIL'); + expect(values[3].children[2].value, 'rob.schoen'); + expect(values[3].children[3].value, 'domain.com'); + + expect(values[4].value, null); + expect(values[4].children != null, true); + expect(values[4].children.length, 4); + expect(values[4].children[0].value, '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?='); + expect(values[4].children[1].value, 'NIL'); + expect(values[4].children[2].value, 'rob.schoen'); + expect(values[4].children[3].value, 'domain.com'); + + expect(values[5].value, null); + expect(values[5].children != null, true); + expect(values[5].children.length, 4); + expect(values[5].children[0].value, 'Alice Dev'); + expect(values[5].children[1].value, 'NIL'); + expect(values[5].children[2].value, 'alice.dev'); + expect(values[5].children[3].value, 'domain.com'); + + expect(values[6].value, 'NIL'); + expect(values[7].value, 'NIL'); + + expect(values[8].value, + ''); + expect(values[9].value, + '<130499090.797.1572014128349@product-gw2.domain.com>'); + + values = parsed.values[2].children; + expect(values[6].value, 'BODY'); + expect(values[6].children != null, true); + expect(values[6].children.length, 3); + var value = values[6].children[0]; + expect(value.value, null); + expect(value.children != null, true); + expect(value.children.length, 7); + expect(value.children[0].value, 'text'); + expect(value.children[1].value, 'plain'); + expect(value.children[1].children != null, true); + expect(value.children[1].children[0].value, 'charset'); + expect(value.children[1].children[1].value, 'UTF-8'); + expect(value.children[2].value, 'NIL'); + expect(value.children[3].value, 'NIL'); + expect(value.children[4].value, 'quoted-printable'); + expect(value.children[5].value, '1289'); + expect(value.children[6].value, '53'); + + value = values[6].children[1]; + expect(value.value, null); + expect(value.children != null, true); + expect(value.children.length, 7); + expect(value.children[0].value, 'text'); + expect(value.children[1].value, 'html'); + expect(value.children[1].children != null, true); + expect(value.children[1].children[0].value, 'charset'); + expect(value.children[1].children[1].value, 'UTF-8'); + expect(value.children[2].value, 'NIL'); + expect(value.children[3].value, 'NIL'); + expect(value.children[4].value, 'quoted-printable'); + expect(value.children[5].value, '7496'); + expect(value.children[6].value, '302'); + + expect(values[6].children[2].value, 'alternative'); + + }); // test end +} diff --git a/test/src/imap/parser_helper_test.dart b/test/src/imap/parser_helper_test.dart new file mode 100644 index 00000000..e3e024f4 --- /dev/null +++ b/test/src/imap/parser_helper_test.dart @@ -0,0 +1,75 @@ +import 'package:enough_mail/src/imap/parser_helper.dart'; +import 'package:test/test.dart'; + +void main() { + test('ParserHelper.readNextWord',() { + var input = 'HELLO ()'; + expect(ParserHelper.readNextWord(input, 0).text, 'HELLO'); + input = ' HELLO ()'; + expect(ParserHelper.readNextWord(input, 0).text, 'HELLO'); + expect(ParserHelper.readNextWord(input, 1).text, 'HELLO'); + input = ' HELLO () ENVELOPE (...)'; + expect(ParserHelper.readNextWord(input, 9).text, 'ENVELOPE'); + expect(ParserHelper.readNextWord(input, 10).text, 'ENVELOPE'); + input = ' HELLO () ENVELOPE'; + expect(ParserHelper.readNextWord(input, 9), null); + expect(ParserHelper.readNextWord(input, 10), null); + input = ' '; + expect(ParserHelper.readNextWord(input, 0), null); + input = ' '; + expect(ParserHelper.readNextWord(input, 0), null); + input = ''; + expect(ParserHelper.readNextWord(input, 0), null); + }); // test end + + test('ParserHelper.parseHeader', () { + var header = 'Return-Path: \r\n' +'Delivered-To: jane.goodall@domain.com\r\n' +'Received: from mx2.domain.com ([10.20.30.2])\r\n' +' by imap.domain.com with LMTP\r\n' +' id QOW0G8YmFl5tPAAA3c6Kzw\r\n' +' (envelope-from )\r\n' +' for ; Wed, 08 Jan 2020 20:00:22 +0100\r\n' +'Received: from localhost (localhost.localdomain [127.0.0.1])\r\n' +' by mx2.domain.com (Postfix) with ESMTP id 5803D6A254\r\n' +' for ; Wed, 8 Jan 2020 20:00:22 +0100 (CET)\r\n'; + var result = ParserHelper.parseHeader(header); + var headers = result.headers; + expect(result != null, true); + expect(headers.length, 4); + expect(headers[0].name, 'Return-Path'); + expect(headers[1].name, 'Delivered-To'); + expect(headers[2].name, 'Received'); + expect(headers[2].value, 'from mx2.domain.com ([10.20.30.2]) by imap.domain.com with LMTP id QOW0G8YmFl5tPAAA3c6Kzw (envelope-from ) for ; Wed, 08 Jan 2020 20:00:22 +0100'); + expect(headers[3].name, 'Received'); + }); + + test('ParserHelper.parseHeader with body', () { + var header = 'Return-Path: \r\n' +'Delivered-To: jane.goodall@domain.com\r\n' +'Received: from mx2.domain.com ([10.20.30.2])\r\n' +' by imap.domain.com with LMTP\r\n' +' id QOW0G8YmFl5tPAAA3c6Kzw\r\n' +' (envelope-from )\r\n' +' for ; Wed, 08 Jan 2020 20:00:22 +0100\r\n' +'Received: from localhost (localhost.localdomain [127.0.0.1])\r\n' +' by mx2.domain.com (Postfix) with ESMTP id 5803D6A254\r\n' +' for ; Wed, 8 Jan 2020 20:00:22 +0100 (CET)\r\n' +'Content-Type: text/plain\r\n' +'\r\n' +'Hello world.\r\n'; + var result = ParserHelper.parseHeader(header); + var headers = result.headers; + expect(result != null, true); + expect(headers.length, 5); + expect(headers[0].name, 'Return-Path'); + expect(headers[1].name, 'Delivered-To'); + expect(headers[2].name, 'Received'); + expect(headers[2].value, 'from mx2.domain.com ([10.20.30.2]) by imap.domain.com with LMTP id QOW0G8YmFl5tPAAA3c6Kzw (envelope-from ) for ; Wed, 08 Jan 2020 20:00:22 +0100'); + expect(headers[3].name, 'Received'); + expect(headers[4].name, 'Content-Type'); + expect(headers[4].value, 'text/plain'); + expect(result.bodyStartIndex != null, true); + expect(header.substring(result.bodyStartIndex), 'Hello world.\r\n'); + }); +} \ No newline at end of file diff --git a/test/src/util/uint8_list_reader_test.dart b/test/src/util/uint8_list_reader_test.dart new file mode 100644 index 00000000..bb532884 --- /dev/null +++ b/test/src/util/uint8_list_reader_test.dart @@ -0,0 +1,145 @@ +import 'package:test/test.dart'; +import 'dart:typed_data'; +import 'package:enough_mail/src/util/uint8_list_reader.dart'; + +// Uint8List _toUint8List(String text) { +// return Uint8List.fromList(text.codeUnits); +// } + +String _toString(Uint8List bytes) { + return String.fromCharCodes(bytes); +} + +void main() { + test('Uint8ListReader.readLine() with simple input', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + expect(reader.readLine(), 'HELLO ()'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in one', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\nHI\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 2 lines', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 2+ lines', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\nOHMY'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 3 lines', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\n'); + reader.addText('YEAH\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + expect(reader.readLine(), 'YEAH'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 3+ lines', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\nYEAH'); + reader.addText('\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + expect(reader.readLine(), 'YEAH'); + }); // test end + + test('Uint8ListReader.readBytes() with 1 line [1]', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + expect(_toString(reader.readBytes(5)), 'HELLO'); + }); // test end + + test('Uint8ListReader.readBytes() with 1 line [2]', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + expect(_toString(reader.readBytes(10)), 'HELLO ()\r\n'); + }); // test end + + test('Uint8ListReader.readBytes() [3]', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\nYEAH'); + reader.addText('\r\n'); + expect(_toString(reader.readBytes(12)), 'HELLO ()\r\nHI'); + }); // test end + + test('Uint8ListReader.readBytes() [4]', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + reader.addText('HI\r\nYEAH'); + reader.addText('\r\n'); + expect(_toString(reader.readBytes(12)), 'HELLO ()\r\nHI'); + expect(_toString(reader.readBytes(5)), '\r\nYEA'); + }); // test end + + test('Uint8ListReader.readBytes() with text in parts read [5]', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\nHI\r\nYEAH\r\n'); + expect(_toString(reader.readBytes(2)), 'HE'); + expect(_toString(reader.readBytes(5)), 'LLO ('); + expect(_toString(reader.readBytes(5)), ')\r\nHI'); + expect(_toString(reader.readBytes(7)), '\r\nYEAH\r'); + expect(_toString(reader.readBytes(1)), '\n'); + }); // test end + + test('Uint8ListReader.readLine() and readBytes()', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\nHI\r\nYEAH\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(_toString(reader.readBytes(4)), 'HI\r\n'); + expect(reader.readLine(), 'YEAH'); + }); // test end + + test('Uint8ListReader.readLine() without newline', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()'); + expect(reader.hasLineBreak(), false); + expect(reader.readLine(), null); + reader.addText('\r\n'); + expect(reader.hasLineBreak(), true); + expect(reader.readLine(), 'HELLO ()'); + }); // test end + + test('Uint8ListReader.readLine() with break in newline', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r'); + expect(reader.hasLineBreak(), false); + expect(reader.readLine(), null); + reader.addText('\n'); + expect(reader.hasLineBreak(), true); + expect(reader.readLine(), 'HELLO ()'); + }); // test end + + test('Uint8ListReader.findLineBreak() simple case', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r\n'); + var pos = reader.findLineBreak(); + expect(pos, 9); + }); // test end + + test('Uint8ListReader.findLineBreak() with break in newline', () { + var reader = Uint8ListReader(); + reader.addText('HELLO ()\r'); + expect(reader.findLineBreak(), null); + reader.addText('\n'); + var pos = reader.findLineBreak(); + expect(pos, 9); + }); // test end +}