diff --git a/CHANGELOG.md b/CHANGELOG.md index 98751f9..0ea3afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 5.0.0 +* minor interface changes. Some are now const / final objects. +* added a lot of tests +* names of addresses may contain unicode characters now + still no punycode support! + ## 4.0.0 * null safety and cleanups Thanks: https://github.com/bsutton diff --git a/README.md b/README.md index 0ddb558..75090da 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,9 @@ # mailer - **mailer** is an easy to use library for composing and sending emails in Dart. Mailer supports file attachments and HTML emails. - -## mailer2 and mailer3 - -`mailer2` and `mailer3` on pub.dart are forks of this project. - -`mailer` was not well maintained and `mailer2` and `mailer3` had some important fixes. - -Currently `mailer` should include all known bug-fixes and AFAIK there is -no reason to use `mailer2` or `mailer3`. - - -## Dart2 support - -Support for dart2 has been added in version ^1.2.0 - -Version ^2.0.0 is a rewrite (it too supports dart1.x and dart2). - -Even though the API for ^2.0.0 has slightly changed, *most* programs will probably -continue to work with deprecation warnings. - ## SMTP definitions Mailer provides configurations for a few common SMTP servers. @@ -36,7 +15,8 @@ Please create merge requests for missing configurations. * Export the newly created SMTP server in `lib/smtp_server.dart` * Create a pull request. -In a lot of cases you will find a configuration in [legacy.dart](https://github.com/kaisellgren/mailer/blob/v2/lib/legacy.dart) +In a lot of cases you will find a configuration +in [legacy.dart](https://github.com/kaisellgren/mailer/blob/v2/lib/legacy.dart) ## Features @@ -53,8 +33,8 @@ In a lot of cases you will find a configuration in [legacy.dart](https://github. * Correct encoding of non ASCII mail addresses. * Reintegrate address validation from version 1.* * Improve Header types. (see [ir_header.dart](lib/src/smtp/internal_representation/ir_header.dart)) -We should choose the correct header based on the header name. -Known headers (`list-unsubscribe`,...) should have their own subclass. + We should choose the correct header based on the header name. + Known headers (`list-unsubscribe`,...) should have their own subclass. * Improve documentation. ## Examples @@ -76,7 +56,7 @@ main() async { // final smtpServer = SmtpServer('smtp.domain.com'); // See the named arguments of SmtpServer for further configuration // options. - + // Create our message. final message = Message() ..from = Address(username, 'Your name') @@ -97,8 +77,8 @@ main() async { } } // DONE - - + + // Let's send another message using a slightly different syntax: // // Addresses without a name part can be set directly. @@ -109,32 +89,37 @@ main() async { // `new Address('destination@example.com')` is equivalent to // adding the mail address as `String`. final equivalentMessage = Message() - ..from = Address(username, 'Your name') - ..recipients.add(Address('destination@example.com')) - ..ccRecipients.addAll([Address('destCc1@example.com'), 'destCc2@example.com']) - ..bccRecipients.add('bccAddress@example.com') - ..subject = 'Test Dart Mailer library :: ๐Ÿ˜€ :: ${DateTime.now()}' - ..text = 'This is the plain text.\nThis is line 2 of the text part.' - ..html = "

Test

\n

Hey! Here's some HTML content

"; - + ..from = Address(username, 'Your name ๐Ÿ˜€') + ..recipients.add(Address('destination@example.com')) + ..ccRecipients.addAll([Address('destCc1@example.com'), 'destCc2@example.com']) + ..bccRecipients.add('bccAddress@example.com') + ..subject = 'Test Dart Mailer library :: ๐Ÿ˜€ :: ${DateTime.now()}' + ..text = 'This is the plain text.\nThis is line 2 of the text part.' + ..html = '

Test

\n

Hey! Here is some HTML content

' + ..attachments = [ + FileAttachment(File('exploits_of_a_mom.png')) + ..location = Location.inline + ..cid = '' + ]; + final sendReport2 = await send(equivalentMessage, smtpServer); - + // Sending multiple messages with the same connection // // Create a smtp client that will persist the connection var connection = PersistentConnection(smtpServer); - + // Send the first message await connection.send(message); - + // send the equivalent message await connection.send(equivalentMessage); - + // close the connection await connection.close(); - } ``` ## License + This library is licensed under MIT. diff --git a/lib/src/entities/address.dart b/lib/src/entities/address.dart index 806c5e9..b5e1f8d 100644 --- a/lib/src/entities/address.dart +++ b/lib/src/entities/address.dart @@ -1,16 +1,17 @@ class Address { - String? name; - String? mailAddress; + final String? name; + final String mailAddress; - Address([this.mailAddress, this.name]); + const Address(this.mailAddress, [this.name]); + + /// The name used to output to SMTP server. + /// Implementation can override it to pre-process the name before sending. + /// For example, providing a default name for certain address, or quoting it. + String? get sanitizedName => name; + /// The address used to output to SMTP server. + /// Implementation can override it to pre-process the address before sending + String get sanitizedAddress => mailAddress; - /// Generates an address that must conform to RFC 5322. - /// For example, `name `, `` - /// and `foo.domain.com`. @override - String toString() { - var fromName = name ?? ''; - // ToDo base64 fromName (add _IRMetaInformation as argument) - return '$fromName <$mailAddress>'; - } + String toString() => "${name ?? ''} <$mailAddress>"; } diff --git a/lib/src/entities/attachment.dart b/lib/src/entities/attachment.dart index 7b8b675..d376ae6 100644 --- a/lib/src/entities/attachment.dart +++ b/lib/src/entities/attachment.dart @@ -16,11 +16,15 @@ enum Location { /// Represents a single email attachment. /// /// You may specify a [File], a [Stream] or just a [String] of [data]. -/// [cid] allows you to specify the content id. +/// [cid] allows you to specify the content id for html inlining. /// /// When [location] is set to [Location.inline] The attachment (usually image) /// can be referenced using: /// `cid:yourCid`. For instance: `` +/// +/// [cid] must contain an `@` and be inside `<` and `>`. +/// The cid: `` can then be referenced inside your html as: +/// `` abstract class Attachment { String? cid; Location location = Location.attachment; diff --git a/lib/src/smtp/capabilities.dart b/lib/src/smtp/capabilities.dart index a4aad55..d0323b6 100644 --- a/lib/src/smtp/capabilities.dart +++ b/lib/src/smtp/capabilities.dart @@ -1,3 +1,17 @@ +import 'package:meta/meta.dart'; + +@visibleForTesting +Capabilities capabilitiesForTesting( + {bool startTls = false, + bool smtpUtf8 = false, + bool authPlain = true, + bool authLogin = false, + bool authXoauth2 = false, + List all = const []}) { + return Capabilities._values( + startTls, smtpUtf8, authPlain, authLogin, authXoauth2, all); +} + class Capabilities { final bool startTls; final bool smtpUtf8; diff --git a/lib/src/smtp/internal_representation/conversion.dart b/lib/src/smtp/internal_representation/conversion.dart index aed0ecb..5697741 100644 --- a/lib/src/smtp/internal_representation/conversion.dart +++ b/lib/src/smtp/internal_representation/conversion.dart @@ -1,6 +1,10 @@ import 'dart:async'; import 'dart:convert' as convert; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('conversion'); + const String eol = '\r\n'; List to8(String s) => convert.utf8.encode(s); @@ -71,22 +75,31 @@ Iterable> split(List data, int maxLength, Stream> _splitS( Stream> dataS, int splitOver, int maxLength) { var currentLineLength = 0; + var insertEol = false; var sc = StreamController>(); void processData(List data) { + _logger.finest('_splitS: <- ${data.length} bytes currentLineLength: $currentLineLength'); if (data.length + currentLineLength > maxLength) { var targetLength = maxLength ~/ 2; if (targetLength + currentLineLength > maxLength) { targetLength = maxLength - currentLineLength; } + _logger.finest('_splitS: > maxLength ($maxLength) Splitting into $targetLength parts'); split(data, targetLength, avoidUtf8Cut: false).forEach(processData); } else if (data.length + currentLineLength > splitOver) { + _logger.finest('_splitS: inside splitOver ($splitOver) and maxLength ($maxLength) window.'); // We are now over splitOver but not too long. Perfect. + if (insertEol) sc.add(eol8); sc.add(data); - sc.add(eol8); currentLineLength = 0; + insertEol = true; } else { + _logger.finest('_splitS: below splitOver ($splitOver).'); // We are still below splitOver + if (insertEol) sc.add(eol8); + insertEol = false; + sc.add(data); currentLineLength += data.length; } diff --git a/lib/src/smtp/internal_representation/internal_representation.dart b/lib/src/smtp/internal_representation/internal_representation.dart index 3779652..c242a6d 100644 --- a/lib/src/smtp/internal_representation/internal_representation.dart +++ b/lib/src/smtp/internal_representation/internal_representation.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert' as convert; import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; import 'package:mailer/src/utils.dart'; import '../../entities/address.dart'; diff --git a/lib/src/smtp/internal_representation/ir_content.dart b/lib/src/smtp/internal_representation/ir_content.dart index 23b3c90..da710ea 100644 --- a/lib/src/smtp/internal_representation/ir_content.dart +++ b/lib/src/smtp/internal_representation/ir_content.dart @@ -41,12 +41,14 @@ abstract class _IRContentPart extends _IRContent { late Iterable<_IRContent> _content; List _boundaryStart(String boundary) => to8('--$boundary$eol'); + List _boundaryEnd(String boundary) => to8('--$boundary--$eol'); // We don't want to expose the number of sent emails. // Only use the counter, if milliseconds hasn't changed. static int _counter = 0; static int? _prevTimestamp; + static String _buildBoundary() { var now = DateTime.now().millisecondsSinceEpoch; if (now != _prevTimestamp) _counter = 0; @@ -84,7 +86,7 @@ Iterable _follow(T t, Iterable ts) sync* { class _IRContentPartMixed extends _IRContentPart { _IRContentPartMixed(Message message, Iterable<_IRHeader> header) { - var attachments = message.attachments ; + var attachments = message.attachments; var attached = attachments.where((a) => a.location == Location.attachment); _active = attached.isNotEmpty; @@ -155,7 +157,7 @@ class _IRContentAttachment extends _IRContent { _header.add(_IRHeaderText('content-transfer-encoding', 'base64')); if ((_attachment.cid ?? '').isNotEmpty) { - _header.add(_IRHeaderText('content-id', _attachment.cid)); + _header.add(_IRHeaderText('content-id', _attachment.cid!)); } var fnSuffix = ''; @@ -173,7 +175,7 @@ class _IRContentAttachment extends _IRContent { enum _IRTextType { plain, html } class _IRContentText extends _IRContent { - String? _text; + String _text = ''; _IRContentText( String? text, _IRTextType textType, Iterable<_IRHeader> header) { diff --git a/lib/src/smtp/internal_representation/ir_header.dart b/lib/src/smtp/internal_representation/ir_header.dart index fe41890..fd4b8eb 100644 --- a/lib/src/smtp/internal_representation/ir_header.dart +++ b/lib/src/smtp/internal_representation/ir_header.dart @@ -3,17 +3,81 @@ part of 'internal_representation.dart'; abstract class _IRHeader extends _IROutput { final String _name; - static final List _b64prefix = convert.utf8.encode(' =?utf-8?B?'); - static final List _b64postfix = convert.utf8.encode('?=$eol'); + static final _b64prefix = convert.utf8.encode('=?utf-8?B?'), + _b64postfix = convert.utf8.encode('?='), + _$eol = convert.utf8.encode(eol), + _$eolSpace = convert.utf8.encode('$eol '), + _$spaceLt = convert.utf8.encode(' <'), + _$gt = convert.utf8.encode('>'), + _$commaSpace = convert.utf8.encode(', '), + _$colonSpace = convert.utf8.encode(': '); static final int _b64Length = _b64prefix.length + _b64postfix.length; - Stream> _outValue(String? value) => Stream.fromIterable( - [_name, ': ', value ?? '', eol].map(convert.utf8.encode)); + Stream> _outValue(String? value) async* { + yield convert.utf8.encode(_name); + yield _$colonSpace; + if (value != null) yield convert.utf8.encode(value); + yield _$eol; + } - // Outputs value encoded as base64. + // Outputs this header's name and the given [value] encoded as base64. // Every chunk starts with ' ' and ends with eol. // Call _outValueB64 after an eol. Stream> _outValueB64(String value) async* { + yield convert.utf8.encode(_name); + yield _$colonSpace; + yield* _outB64(value); + yield _$eol; + } + + /// Outputs the given [addresses]. + Stream> _outAddressesValue(Iterable
addresses, + _IRMetaInformation irMetaInformation) async* { + yield convert.utf8.encode(_name); + yield _$colonSpace; + + var len = 2, //2 = _$commaSpace + second = false; + for (final address in addresses) { + final name = address.sanitizedName, maddr = address.sanitizedAddress; + var adrlen = maddr.length; + if (name != null) { + adrlen += name.length + 3; + } //not accurate but good enough + + if (second) { + yield _$commaSpace; + + if (len + adrlen > maxEncodedLength) { + len = 2; + yield _$eolSpace; + } + } else { + second = true; + } + + if (name == null) { + yield convert.utf8.encode(maddr); + } else { + if (_shallB64(name, irMetaInformation)) { + yield* _outB64(name); + } else { + yield convert.utf8.encode(name); + } + + yield _$spaceLt; + yield convert.utf8.encode(maddr); + yield _$gt; + } + + len += adrlen; + } + + yield _$eol; + } + + // Outputs the given [value] encoded as base64. + static Stream> _outB64(String value) async* { // Encode with base64. var availableLengthForBase64 = maxEncodedLength - _b64Length; @@ -24,16 +88,33 @@ abstract class _IRHeader extends _IROutput { // At least 10 chars (random length). if (availableLength < 10) availableLength = 10; - var splitData = split(convert.utf8.encode(value), availableLength); + var second = false; + for (var d in split(convert.utf8.encode(value), availableLength)) { + if (second) { + yield _$eolSpace; + } else { + second = true; + } - yield convert.utf8.encode('$_name: $eol'); - for (var d in splitData) { yield _b64prefix; yield convert.utf8.encode(convert.base64.encode(d)); yield _b64postfix; } } + static bool _shallB64(String value, _IRMetaInformation irMetaInformation) { + // If we have a maxLineLength is it the length of utf8 characters or + // the length of utf8 bytes? + // Just to be safe we'll count the bytes. + var byteLength = convert.utf8.encode(value).length; + return (byteLength > maxLineLength || + !isPrintableRegExp.hasMatch(value) || + // Make sure that text which looks like an encoded text is encoded. + value.contains('=?') || + (!irMetaInformation.capabilities.smtpUtf8 && + value.contains(RegExp(r'[^\x20-\x7E]')))); + } + /* Stream> _outValue8(List value) => Stream.fromIterable( [_name, ': '].map(utf8.encode).followedBy([value, _eol8])); @@ -43,28 +124,17 @@ abstract class _IRHeader extends _IROutput { } class _IRHeaderText extends _IRHeader { - final String? _value; + final String _value; _IRHeaderText(String name, this._value) : super(name); @override - Stream> out(_IRMetaInformation irMetaInformation) { - var utf8Allowed = irMetaInformation.capabilities.smtpUtf8; - - if ((_value?.length ?? 0) > maxLineLength || - !isPrintableRegExp.hasMatch(_value!) || - // Make sure that text which looks like an encoded text is encoded. - _value!.contains('=?') || - (!utf8Allowed && _value!.contains(RegExp(r'[^\x20-\x7E]')))) { - return _outValueB64(_value!); - } - return _outValue(_value); - } + Stream> out(_IRMetaInformation irMetaInformation) => + _IRHeader._shallB64(_value, irMetaInformation) + ? _outValueB64(_value) + : _outValue(_value); } -Iterable _addressToString(Iterable
addresses) => - addresses.map((a) => a.toString()); - class _IRHeaderAddress extends _IRHeader { final Address _address; @@ -72,7 +142,7 @@ class _IRHeaderAddress extends _IRHeader { @override Stream> out(_IRMetaInformation irMetaInformation) => - _outValue(_addressToString([_address]).first); + _outAddressesValue([_address], irMetaInformation); } class _IRHeaderAddresses extends _IRHeader { @@ -82,7 +152,7 @@ class _IRHeaderAddresses extends _IRHeader { @override Stream> out(_IRMetaInformation irMetaInformation) => - _outValue(_addressToString(_addresses).join(', ')); + _outAddressesValue(_addresses, irMetaInformation); } class _IRHeaderContentType extends _IRHeader { @@ -142,7 +212,7 @@ Iterable<_IRHeader> _buildHeaders(Message message) { }); if (!msgHeader.containsKey('subject') && message.subject != null) { - headers.add(_IRHeaderText('subject', message.subject)); + headers.add(_IRHeaderText('subject', message.subject!)); } if (!msgHeader.containsKey('from')) { diff --git a/lib/src/smtp/internal_representation/ir_message.dart b/lib/src/smtp/internal_representation/ir_message.dart index 05d04ce..21f159a 100644 --- a/lib/src/smtp/internal_representation/ir_message.dart +++ b/lib/src/smtp/internal_representation/ir_message.dart @@ -1,6 +1,7 @@ part of 'internal_representation.dart'; class IRMessage { + final Logger _logger = Logger('IRMessage'); final Message? _message; late _IRContent _content; @@ -19,19 +20,23 @@ class IRMessage { ..._message!.recipientsAsAddresses, ..._message!.ccsAsAddresses, ..._message!.bccsAsAddresses - ].where((a) => a.mailAddress != null).map((a) => a.mailAddress); + ].map((a) => a.mailAddress); } return envelopeTos; } String get envelopeFrom => - _message!.envelopeFrom ?? _message!.fromAsAddress.mailAddress ?? ''; + _message!.envelopeFrom ?? _message!.fromAsAddress.mailAddress; Stream> data(Capabilities capabilities) => - _content.out(_IRMetaInformation(capabilities)); + _content.out(_IRMetaInformation(capabilities)).map((s) { + _logger.finest('ยซ${convert.utf8.decoder.convert(s)}ยป'); + return s; + }); } class InvalidHeaderException implements Exception { String message; + InvalidHeaderException(this.message); } diff --git a/lib/src/smtp/validator.dart b/lib/src/smtp/validator.dart index 3736978..d743442 100644 --- a/lib/src/smtp/validator.dart +++ b/lib/src/smtp/validator.dart @@ -15,9 +15,8 @@ bool _validAddress(dynamic addressIn) { String? address; if (addressIn is Address) { - //We can't validate [Address.name] directly, since the implementation - //of [Address.toString] might sanitize it. - if (!_printableCharsOnly(addressIn.toString())) return false; + //Don't validate [Address.name] here since it will be encoded with base64 + //if necessary address = addressIn.mailAddress; } else { address = addressIn as String; @@ -25,10 +24,7 @@ bool _validAddress(dynamic addressIn) { return _validMailAddress(address); } -bool _validMailAddress(String? ma) { - if (ma == null) { - return false; - } +bool _validMailAddress(String ma) { var split = ma.split('@'); return split.length == 2 && split.every((part) => part.isNotEmpty && _printableCharsOnly(part)); @@ -67,7 +63,7 @@ List validate(Message message) { a = aIn is String ? Address(aIn) : aIn as Address?; validate( - a != null && (a.mailAddress ?? '').isNotEmpty, + a != null && (a.mailAddress).isNotEmpty, 'FROM_ADDRESS_EMPTY', 'A recipient address is null or empty. (pos: $counter).'); if (a != null) { diff --git a/pubspec.yaml b/pubspec.yaml index 2cf0c8e..e5ee553 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: mailer -version: 4.0.0 +version: 5.0.0 description: > Compose and send emails from Dart. Supports file attachments and HTML emails @@ -7,7 +7,7 @@ homepage: https://github.com/kaisellgren/mailer environment: sdk: '>=2.12.0 <3.0.0' dependencies: - async: '^2.0.0' + async: '^2.5.0' logging: '^1.0.0' intl: '^0.17.0' mime: '^1.0.0' diff --git a/test/message_out_test.dart b/test/message_out_test.dart new file mode 100644 index 0000000..531ca8c --- /dev/null +++ b/test/message_out_test.dart @@ -0,0 +1,94 @@ +library message_out_test; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:mailer/mailer.dart'; +import 'package:mailer/src/smtp/capabilities.dart'; +import 'package:mailer/src/smtp/internal_representation/internal_representation.dart'; +import 'package:test/test.dart'; + +part 'messages/message_helpers.dart'; + +part 'messages/message_simple_utf8.dart'; + +part 'messages/message_utf8_from_header.dart'; + +part 'messages/message_utf8_long_subject_long_body.dart'; + +part 'messages/message_text_html_only.dart'; + +part 'messages/message_all.dart'; + +class MessageTest { + final String name; + final Message message; + final String messageRegExpWithUtf8; + final String messageRegExpWithoutUtf8; + final Map stringReplacements; + + MessageTest(this.name, this.message, this.messageRegExpWithUtf8, + this.messageRegExpWithoutUtf8, + {this.stringReplacements = const {}}); +} + +final testCases = [ + messageSimpleUtf8, + messageUtf8FromHeader, + messageUtf8LongSubjectLongBodyBelowLineLength, + messageUtf8LongSubjectLongBodyAboveLineLength, + messageHtmlOnly, + messageTextOnly, + messageAll +]; + +Future testMessage(Message message, String expectedRegExp, + {bool smtpUtf8 = true, + Map stringReplacements = const {}}) async { + var irContent = IRMessage(message); + var capabilities = capabilitiesForTesting(smtpUtf8: smtpUtf8); + var data = irContent.data(capabilities); + var m = await data.fold>([], (previous, element) { + previous.addAll(element); + return previous; + }); + var mUtf8 = utf8.decoder.convert(m); + stringReplacements.forEach((replaceThis, withThis) { + mUtf8 = mUtf8.replaceAll(replaceThis, withThis); + }); + //print('Testing: $mUtf8 against $expectedRegExp'); + return RegExp(expectedRegExp, multiLine: true).hasMatch(mUtf8); +} + +void main() async { + Logger.root.level = Level.ALL; + // Logger.root.onRecord.listen((LogRecord rec) => + // print('${rec.level.name}: ${rec.time}: ${rec.message}')); + + testCases.forEach((testCase) { + // If we have a StreamAttachment we can't send the same message twice. + // In this case the testCase is a function which generates a `MessageTest` + var tcUtf8 = (testCase is Function ? testCase() : testCase) as MessageTest; + test( + 'message is correctly converted ${tcUtf8.name} (utf8)', + () async => expect( + testMessage(tcUtf8.message, tcUtf8.messageRegExpWithUtf8, + smtpUtf8: true, stringReplacements: tcUtf8.stringReplacements), + completion(equals(true)), + reason: '${tcUtf8.name} (smtpUtf8)'), + ); + + // Recreate the testCase (for StreamAttachments) + final tcWithoutUtf8 = + (testCase is Function ? testCase() : testCase) as MessageTest; + test( + 'message is correctly converted ${tcWithoutUtf8.name} (without utf8)', + () async => expect( + testMessage( + tcWithoutUtf8.message, tcWithoutUtf8.messageRegExpWithoutUtf8, + smtpUtf8: false, stringReplacements: tcUtf8.stringReplacements), + completion(equals(true)), + reason: '${tcWithoutUtf8.name} (smtpUtf8 false)')); + }); +} diff --git a/test/send_test.dart b/test/send_test.dart index 04c8267..3ec418f 100644 --- a/test/send_test.dart +++ b/test/send_test.dart @@ -41,7 +41,7 @@ Future configureCorrectSmtpServer() async { Message createMessage(SmtpServer smtpServer) { // Message to myself return Message() - ..from = Address(smtpServer.username) + ..from = Address(smtpServer.username!) ..recipients.add(smtpServer.username) ..subject = 'Test Dart Mailer library :: ๐Ÿ˜€ :: ${DateTime.now()}' ..text = 'This is the plain text.\nThis is line 2 of the text part.';