From 71952ed36be4d5a97a49347b9dcad51c6afa452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Apr 2026 08:52:01 +0200 Subject: [PATCH] Fix: vendor enough_mail as regular files instead of gitlink The directory was tracked as a mode-160000 gitlink (bare submodule reference) without a .gitmodules entry, causing 'has no commit checked out' errors on commit. Re-added as ordinary tracked files so the vendored copy is fully part of this repo. Co-Authored-By: Claude Sonnet 4.6 --- LATER.md | 5 + packages/enough_mail | 1 - .../enough_mail/.github/workflows/dart.yml | 45 + packages/enough_mail/.gitignore | 15 + packages/enough_mail/CHANGELOG.md | 441 ++ packages/enough_mail/LICENSE | 373 ++ packages/enough_mail/README.md | 350 ++ packages/enough_mail/analysis_options.yaml | 147 + packages/enough_mail/build.yaml | 20 + packages/enough_mail/example/discover.dart | 75 + .../example/enough_mail_example.dart | 225 + packages/enough_mail/lib/codecs.dart | 6 + packages/enough_mail/lib/discover.dart | 4 + packages/enough_mail/lib/enough_mail.dart | 18 + packages/enough_mail/lib/highlevel.dart | 16 + packages/enough_mail/lib/imap.dart | 17 + packages/enough_mail/lib/mime.dart | 10 + packages/enough_mail/lib/pop.dart | 9 + packages/enough_mail/lib/smtp.dart | 8 + .../lib/src/codecs/base64_mail_codec.dart | 187 + .../lib/src/codecs/date_codec.dart | 549 +++ .../lib/src/codecs/mail_codec.dart | 461 ++ .../lib/src/codecs/modified_utf7_codec.dart | 258 ++ .../codecs/quoted_printable_mail_codec.dart | 267 ++ .../lib/src/discover/client_config.dart | 418 ++ .../lib/src/discover/discover.dart | 197 + packages/enough_mail/lib/src/exception.dart | 17 + .../lib/src/imap/extended_data.dart | 5 + packages/enough_mail/lib/src/imap/id.dart | 170 + .../enough_mail/lib/src/imap/imap_client.dart | 2791 +++++++++++++ .../enough_mail/lib/src/imap/imap_events.dart | 125 + .../lib/src/imap/imap_exception.dart | 36 + .../enough_mail/lib/src/imap/imap_search.dart | 480 +++ .../enough_mail/lib/src/imap/mailbox.dart | 397 ++ .../lib/src/imap/message_sequence.dart | 799 ++++ .../enough_mail/lib/src/imap/metadata.dart | 69 + .../enough_mail/lib/src/imap/qresync.dart | 73 + .../lib/src/imap/resource_limit.dart | 14 + .../enough_mail/lib/src/imap/response.dart | 277 ++ .../lib/src/imap/return_option.dart | 111 + .../lib/src/imap/selection_options.dart | 36 + .../lib/src/mail/mail_account.dart | 439 ++ .../lib/src/mail/mail_authentication.dart | 328 ++ .../enough_mail/lib/src/mail/mail_client.dart | 3699 +++++++++++++++++ .../enough_mail/lib/src/mail/mail_events.dart | 94 + .../lib/src/mail/mail_exception.dart | 71 + .../enough_mail/lib/src/mail/mail_search.dart | 171 + .../enough_mail/lib/src/mail/results.dart | 641 +++ packages/enough_mail/lib/src/mail/tree.dart | 166 + .../enough_mail/lib/src/mail_address.dart | 214 + .../enough_mail/lib/src/mail_conventions.dart | 161 + packages/enough_mail/lib/src/media_type.dart | 547 +++ .../enough_mail/lib/src/message_builder.dart | 1813 ++++++++ .../enough_mail/lib/src/message_flags.dart | 35 + packages/enough_mail/lib/src/mime_data.dart | 400 ++ .../enough_mail/lib/src/mime_message.dart | 2170 ++++++++++ .../enough_mail/lib/src/pop/pop_client.dart | 223 + .../enough_mail/lib/src/pop/pop_events.dart | 29 + .../lib/src/pop/pop_exception.dart | 46 + .../enough_mail/lib/src/pop/pop_response.dart | 54 + .../lib/src/private/imap/all_parsers.dart | 15 + .../src/private/imap/capability_parser.dart | 74 + .../lib/src/private/imap/command.dart | 114 + .../lib/src/private/imap/enable_parser.dart | 48 + .../lib/src/private/imap/fetch_parser.dart | 656 +++ .../lib/src/private/imap/generic_parser.dart | 114 + .../lib/src/private/imap/id_parser.dart | 34 + .../lib/src/private/imap/imap_response.dart | 265 ++ .../src/private/imap/imap_response_line.dart | 73 + .../private/imap/imap_response_reader.dart | 87 + .../lib/src/private/imap/list_parser.dart | 239 ++ .../lib/src/private/imap/logout_parser.dart | 23 + .../src/private/imap/meta_data_parser.dart | 53 + .../src/private/imap/no_response_parser.dart | 16 + .../lib/src/private/imap/noop_parser.dart | 127 + .../lib/src/private/imap/parser_helper.dart | 236 ++ .../lib/src/private/imap/quota_parser.dart | 125 + .../lib/src/private/imap/response_parser.dart | 44 + .../lib/src/private/imap/search_parser.dart | 151 + .../lib/src/private/imap/select_parser.dart | 117 + .../lib/src/private/imap/sort_parser.dart | 149 + .../lib/src/private/imap/status_parser.dart | 72 + .../lib/src/private/imap/thread_parser.dart | 58 + .../private/pop/commands/all_commands.dart | 13 + .../pop/commands/pop_apop_command.dart | 26 + .../pop/commands/pop_delete_command.dart | 7 + .../pop/commands/pop_list_command.dart | 14 + .../pop/commands/pop_noop_command.dart | 7 + .../pop/commands/pop_pass_command.dart | 10 + .../pop/commands/pop_quit_command.dart | 17 + .../pop/commands/pop_reset_command.dart | 7 + .../pop/commands/pop_retrieve_command.dart | 14 + .../pop/commands/pop_starttls_command.dart | 9 + .../pop/commands/pop_status_command.dart | 9 + .../private/pop/commands/pop_top_command.dart | 14 + .../pop/commands/pop_uidl_command.dart | 14 + .../pop/commands/pop_user_command.dart | 7 + .../src/private/pop/parsers/all_parsers.dart | 5 + .../private/pop/parsers/pop_list_parser.dart | 45 + .../pop/parsers/pop_retrieve_parser.dart | 33 + .../pop/parsers/pop_standard_parser.dart | 14 + .../pop/parsers/pop_status_parser.dart | 24 + .../private/pop/parsers/pop_uidl_parser.dart | 47 + .../lib/src/private/pop/pop_command.dart | 36 + .../src/private/pop/pop_response_parser.dart | 13 + .../private/smtp/commands/all_commands.dart | 9 + .../commands/smtp_auth_cram_md5_command.dart | 70 + .../commands/smtp_auth_login_command.dart | 47 + .../commands/smtp_auth_plain_command.dart | 27 + .../commands/smtp_auth_xoauth2_command.dart | 56 + .../smtp/commands/smtp_ehlo_command.dart | 23 + .../smtp/commands/smtp_quit_command.dart | 17 + .../smtp/commands/smtp_send_bdat_command.dart | 193 + .../smtp/commands/smtp_sendmail_command.dart | 135 + .../smtp/commands/smtp_starttls_command.dart | 7 + .../lib/src/private/smtp/smtp_command.dart | 55 + .../lib/src/private/util/ascii_runes.dart | 86 + .../lib/src/private/util/byte_utils.dart | 27 + .../lib/src/private/util/client_base.dart | 341 ++ .../lib/src/private/util/discover_helper.dart | 532 +++ .../lib/src/private/util/http_helper.dart | 79 + .../src/private/util/mail_address_parser.dart | 169 + .../lib/src/private/util/mail_signature.dart | 114 + .../lib/src/private/util/non_nullable.dart | 22 + .../lib/src/private/util/stack_list.dart | 36 + .../src/private/util/uint8_list_reader.dart | 285 ++ .../lib/src/private/util/word.dart | 14 + .../enough_mail/lib/src/smtp/smtp_client.dart | 488 +++ .../enough_mail/lib/src/smtp/smtp_events.dart | 29 + .../lib/src/smtp/smtp_exception.dart | 53 + .../lib/src/smtp/smtp_response.dart | 121 + packages/enough_mail/migration.md | 60 + packages/enough_mail/pubspec.yaml | 43 + .../test/codecs/base64_mail_codec_test.dart | 50 + .../test/codecs/date_codec_test.dart | 124 + .../enough_mail/test/codecs/folding_test.dart | 77 + .../test/codecs/mail_codec_test.dart | 124 + .../test/codecs/modified_utf7_codec_test.dart | 66 + .../quoted_printable_mail_codec_test.dart | 181 + .../test/imap/imap_client_test.dart | 1488 +++++++ .../enough_mail/test/imap/mailbox_test.dart | 48 + .../test/imap/message_sequence_test.dart | 393 ++ .../test/imap/mock_imap_server.dart | 66 + .../enough_mail/test/imap/qresync_test.dart | 31 + .../enough_mail/test/imap/response_test.dart | 36 + .../test/mail/mail_account_test.dart | 236 ++ .../enough_mail/test/mail/results_test.dart | 75 + .../test/message_builder_test.dart | 1811 ++++++++ .../enough_mail/test/mime_message_test.dart | 1799 ++++++++ packages/enough_mail/test/mock_socket.dart | 370 ++ .../enough_mail/test/pop/mock_pop_server.dart | 36 + .../enough_mail/test/pop/pop_client_test.dart | 194 + .../test/smtp/mock_smtp_server.dart | 86 + .../test/smtp/smtp_client_test.dart | 144 + .../enough_mail/test/smtp/testimage-large.jpg | Bin 0 -> 872619 bytes packages/enough_mail/test/smtp/testimage.jpg | Bin 0 -> 13390 bytes .../test/src/imap/fetch_parser_test.dart | 1569 +++++++ .../test/src/imap/id_parser_test.dart | 57 + .../src/imap/imap_response_line_test.dart | 43 + .../src/imap/imap_response_reader_test.dart | 295 ++ .../test/src/imap/imap_response_test.dart | 562 +++ .../test/src/imap/list_parser_test.dart | 154 + .../test/src/imap/parser_helper_test.dart | 147 + .../test/src/imap/search_parser_test.dart | 109 + .../test/src/imap/sort_parser_test.dart | 111 + .../test/src/imap/status_parser_test.dart | 61 + .../test/src/imap/thread_parser_test.dart | 128 + .../smtp_auth_cram_md5_command_test.dart | 59 + .../test/src/util/discover_helper_test.dart | 479 +++ .../test/src/util/uint8_list_reader_test.dart | 182 + 170 files changed, 38626 insertions(+), 1 deletion(-) create mode 100644 LATER.md delete mode 160000 packages/enough_mail create mode 100644 packages/enough_mail/.github/workflows/dart.yml create mode 100644 packages/enough_mail/.gitignore create mode 100644 packages/enough_mail/CHANGELOG.md create mode 100644 packages/enough_mail/LICENSE create mode 100644 packages/enough_mail/README.md create mode 100644 packages/enough_mail/analysis_options.yaml create mode 100644 packages/enough_mail/build.yaml create mode 100644 packages/enough_mail/example/discover.dart create mode 100644 packages/enough_mail/example/enough_mail_example.dart create mode 100644 packages/enough_mail/lib/codecs.dart create mode 100644 packages/enough_mail/lib/discover.dart create mode 100644 packages/enough_mail/lib/enough_mail.dart create mode 100644 packages/enough_mail/lib/highlevel.dart create mode 100644 packages/enough_mail/lib/imap.dart create mode 100644 packages/enough_mail/lib/mime.dart create mode 100644 packages/enough_mail/lib/pop.dart create mode 100644 packages/enough_mail/lib/smtp.dart create mode 100644 packages/enough_mail/lib/src/codecs/base64_mail_codec.dart create mode 100644 packages/enough_mail/lib/src/codecs/date_codec.dart create mode 100644 packages/enough_mail/lib/src/codecs/mail_codec.dart create mode 100644 packages/enough_mail/lib/src/codecs/modified_utf7_codec.dart create mode 100644 packages/enough_mail/lib/src/codecs/quoted_printable_mail_codec.dart create mode 100644 packages/enough_mail/lib/src/discover/client_config.dart create mode 100644 packages/enough_mail/lib/src/discover/discover.dart create mode 100644 packages/enough_mail/lib/src/exception.dart create mode 100644 packages/enough_mail/lib/src/imap/extended_data.dart create mode 100644 packages/enough_mail/lib/src/imap/id.dart create mode 100644 packages/enough_mail/lib/src/imap/imap_client.dart create mode 100644 packages/enough_mail/lib/src/imap/imap_events.dart create mode 100644 packages/enough_mail/lib/src/imap/imap_exception.dart create mode 100644 packages/enough_mail/lib/src/imap/imap_search.dart create mode 100644 packages/enough_mail/lib/src/imap/mailbox.dart create mode 100644 packages/enough_mail/lib/src/imap/message_sequence.dart create mode 100644 packages/enough_mail/lib/src/imap/metadata.dart create mode 100644 packages/enough_mail/lib/src/imap/qresync.dart create mode 100644 packages/enough_mail/lib/src/imap/resource_limit.dart create mode 100644 packages/enough_mail/lib/src/imap/response.dart create mode 100644 packages/enough_mail/lib/src/imap/return_option.dart create mode 100644 packages/enough_mail/lib/src/imap/selection_options.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_account.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_authentication.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_client.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_events.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_exception.dart create mode 100644 packages/enough_mail/lib/src/mail/mail_search.dart create mode 100644 packages/enough_mail/lib/src/mail/results.dart create mode 100644 packages/enough_mail/lib/src/mail/tree.dart create mode 100644 packages/enough_mail/lib/src/mail_address.dart create mode 100644 packages/enough_mail/lib/src/mail_conventions.dart create mode 100644 packages/enough_mail/lib/src/media_type.dart create mode 100644 packages/enough_mail/lib/src/message_builder.dart create mode 100644 packages/enough_mail/lib/src/message_flags.dart create mode 100644 packages/enough_mail/lib/src/mime_data.dart create mode 100644 packages/enough_mail/lib/src/mime_message.dart create mode 100644 packages/enough_mail/lib/src/pop/pop_client.dart create mode 100644 packages/enough_mail/lib/src/pop/pop_events.dart create mode 100644 packages/enough_mail/lib/src/pop/pop_exception.dart create mode 100644 packages/enough_mail/lib/src/pop/pop_response.dart create mode 100644 packages/enough_mail/lib/src/private/imap/all_parsers.dart create mode 100644 packages/enough_mail/lib/src/private/imap/capability_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/command.dart create mode 100644 packages/enough_mail/lib/src/private/imap/enable_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/fetch_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/generic_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/id_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/imap_response.dart create mode 100644 packages/enough_mail/lib/src/private/imap/imap_response_line.dart create mode 100644 packages/enough_mail/lib/src/private/imap/imap_response_reader.dart create mode 100644 packages/enough_mail/lib/src/private/imap/list_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/logout_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/meta_data_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/no_response_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/noop_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/parser_helper.dart create mode 100644 packages/enough_mail/lib/src/private/imap/quota_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/response_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/search_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/select_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/sort_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/status_parser.dart create mode 100644 packages/enough_mail/lib/src/private/imap/thread_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/all_commands.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_apop_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_delete_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_list_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_noop_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_pass_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_quit_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_reset_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_retrieve_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_starttls_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_status_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_top_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_uidl_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/commands/pop_user_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/all_parsers.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/pop_list_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/pop_retrieve_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/pop_standard_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/pop_status_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/parsers/pop_uidl_parser.dart create mode 100644 packages/enough_mail/lib/src/private/pop/pop_command.dart create mode 100644 packages/enough_mail/lib/src/private/pop/pop_response_parser.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/all_commands.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_login_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_plain_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_xoauth2_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_ehlo_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_quit_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_send_bdat_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_sendmail_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/commands/smtp_starttls_command.dart create mode 100644 packages/enough_mail/lib/src/private/smtp/smtp_command.dart create mode 100644 packages/enough_mail/lib/src/private/util/ascii_runes.dart create mode 100644 packages/enough_mail/lib/src/private/util/byte_utils.dart create mode 100644 packages/enough_mail/lib/src/private/util/client_base.dart create mode 100644 packages/enough_mail/lib/src/private/util/discover_helper.dart create mode 100644 packages/enough_mail/lib/src/private/util/http_helper.dart create mode 100644 packages/enough_mail/lib/src/private/util/mail_address_parser.dart create mode 100644 packages/enough_mail/lib/src/private/util/mail_signature.dart create mode 100644 packages/enough_mail/lib/src/private/util/non_nullable.dart create mode 100644 packages/enough_mail/lib/src/private/util/stack_list.dart create mode 100644 packages/enough_mail/lib/src/private/util/uint8_list_reader.dart create mode 100644 packages/enough_mail/lib/src/private/util/word.dart create mode 100644 packages/enough_mail/lib/src/smtp/smtp_client.dart create mode 100644 packages/enough_mail/lib/src/smtp/smtp_events.dart create mode 100644 packages/enough_mail/lib/src/smtp/smtp_exception.dart create mode 100644 packages/enough_mail/lib/src/smtp/smtp_response.dart create mode 100644 packages/enough_mail/migration.md create mode 100644 packages/enough_mail/pubspec.yaml create mode 100644 packages/enough_mail/test/codecs/base64_mail_codec_test.dart create mode 100644 packages/enough_mail/test/codecs/date_codec_test.dart create mode 100644 packages/enough_mail/test/codecs/folding_test.dart create mode 100644 packages/enough_mail/test/codecs/mail_codec_test.dart create mode 100644 packages/enough_mail/test/codecs/modified_utf7_codec_test.dart create mode 100644 packages/enough_mail/test/codecs/quoted_printable_mail_codec_test.dart create mode 100644 packages/enough_mail/test/imap/imap_client_test.dart create mode 100644 packages/enough_mail/test/imap/mailbox_test.dart create mode 100644 packages/enough_mail/test/imap/message_sequence_test.dart create mode 100644 packages/enough_mail/test/imap/mock_imap_server.dart create mode 100644 packages/enough_mail/test/imap/qresync_test.dart create mode 100644 packages/enough_mail/test/imap/response_test.dart create mode 100644 packages/enough_mail/test/mail/mail_account_test.dart create mode 100644 packages/enough_mail/test/mail/results_test.dart create mode 100644 packages/enough_mail/test/message_builder_test.dart create mode 100644 packages/enough_mail/test/mime_message_test.dart create mode 100644 packages/enough_mail/test/mock_socket.dart create mode 100644 packages/enough_mail/test/pop/mock_pop_server.dart create mode 100644 packages/enough_mail/test/pop/pop_client_test.dart create mode 100644 packages/enough_mail/test/smtp/mock_smtp_server.dart create mode 100644 packages/enough_mail/test/smtp/smtp_client_test.dart create mode 100644 packages/enough_mail/test/smtp/testimage-large.jpg create mode 100644 packages/enough_mail/test/smtp/testimage.jpg create mode 100644 packages/enough_mail/test/src/imap/fetch_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/id_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/imap_response_line_test.dart create mode 100644 packages/enough_mail/test/src/imap/imap_response_reader_test.dart create mode 100644 packages/enough_mail/test/src/imap/imap_response_test.dart create mode 100644 packages/enough_mail/test/src/imap/list_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/parser_helper_test.dart create mode 100644 packages/enough_mail/test/src/imap/search_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/sort_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/status_parser_test.dart create mode 100644 packages/enough_mail/test/src/imap/thread_parser_test.dart create mode 100644 packages/enough_mail/test/src/smtp/commands/smtp_auth_cram_md5_command_test.dart create mode 100644 packages/enough_mail/test/src/util/discover_helper_test.dart create mode 100644 packages/enough_mail/test/src/util/uint8_list_reader_test.dart diff --git a/LATER.md b/LATER.md new file mode 100644 index 0000000..99aa719 --- /dev/null +++ b/LATER.md @@ -0,0 +1,5 @@ +Can I publish my enough mail changes somehow. Maybe do a repo fork ? + +Add pre-commit + +Add GH CI diff --git a/packages/enough_mail b/packages/enough_mail deleted file mode 160000 index 25320ad..0000000 --- a/packages/enough_mail +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 25320adab0d9c1d98c3602ebf53fb15e28e3a0e9 diff --git a/packages/enough_mail/.github/workflows/dart.yml b/packages/enough_mail/.github/workflows/dart.yml new file mode 100644 index 0000000..47cc43b --- /dev/null +++ b/packages/enough_mail/.github/workflows/dart.yml @@ -0,0 +1,45 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Dart + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + # Note: This workflow uses the latest stable version of the Dart SDK. + # You can specify other versions if desired, see documentation here: + # https://github.com/dart-lang/setup-dart/blob/main/README.md + # - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + + - name: Output dart version + run: dart --version + + - name: Install dependencies + run: dart pub get + + # Uncomment this step to verify the use of 'dart format' on each commit. + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + # Consider passing '--fatal-infos' for slightly stricter analysis. + - name: Analyze project source + run: dart analyze + + # Your project will need to have tests in test/ and a dependency on + # package:test for this step to succeed. Note that Flutter projects will + # want to change this to 'flutter test'. + - name: Run tests + run: dart test diff --git a/packages/enough_mail/.gitignore b/packages/enough_mail/.gitignore new file mode 100644 index 0000000..7e65bb7 --- /dev/null +++ b/packages/enough_mail/.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/packages/enough_mail/CHANGELOG.md b/packages/enough_mail/CHANGELOG.md new file mode 100644 index 0000000..f39d039 --- /dev/null +++ b/packages/enough_mail/CHANGELOG.md @@ -0,0 +1,441 @@ +# 2.1.7 +* chore: Update plugin and dependencies versions - thanks to [Dr-Usman](https://github.com/Dr-Usman)! +* chore: pub upgrade to bring in intl 0.20.2 - thanks to [jpohhhh](https://github.com/jpohhhh)! +* Fix: FetchImapResult.replaceMatchingMessages - thanks to [scribetw](https://github.com/scribetw)! +* Fix: trim lines read after decoding - thanks to [vware](https://github.com/vware)! +* Fix: Remove extra double quotes for search query dates - thanks to [ig-garcia](https://github.com/ig-garcia)! + +# 2.1.6 +* Fix: Fix serialization of ServerConfig - thanks to [RobinJespersen](https://github.com/RobinJespersen)! +* Feat: allow to specify connection timeout in high level API and increase default timeout + +# 2.1.5 +* Fix: Ensure compatibility with Flutter 3.16 - thanks to [Tzanou123](https://github.com/Tzanou123)! + +# 2.1.4 +* Fix: use refreshed OAUTH tokens when using the high level MailClient API. +* Fix: handle edge cases in IMAP `FETCH` responses. +* Feat: Add details for low level IMAP errors when using the high level MailCLient API. +* Feat: Refresh OAUTH tokens 15 minutes in advance before they expire to reduce the risk of a token expiring during a long running operation. +* Feat: show error details when SMTP XOAuth2 authentication fails. +* Feat: synchronize access to low level clients when using the high level MailClient API. + +# 2.1.3 +* Fix: Apply correct mailbox path separator - thanks [nruzzu](https://github.com/nruzzu)! +* Feat: add firstWhereOrNull search method for a Tree +* Feat: add identityFlag getter to Mailbox + +# 2.1.2 +* Fix: RangeError when a Mailbox name contains a parentheses - thanks [nruzzu](https://github.com/nruzzu) +* Fix: base64 decoding of headers with a lowercase b +* Feat: support more name variations for ISO codecs +* Feat: update dependencies - thanks [hatch01](https://github.com/hatch01) +* Feat: use standard serialization based on json_serializable +* Feat: Improve high level API fetch message support + +# 2.1.1 +* Loosened dependency restrictions a bit upon suggestion from [hpoul](https://github.com/Enough-Software/enough_mail/issues/194) +* Added support for Big5, KOI8-r and KOI8-u character encodings +* Load encodings only when required + +# 2.1.0 +* The `MailClient.deleteMessages()` / `undoDeleteMessages()` as well as the `moveMessages()` and `undoMoveMessages()` calls + will now update the given `messages` UIDs automatically, when they have been specified. +* Simplify building a `multipart/alternative` message or message part by adding the option `plainText` and `htmlText` parameters + in `MessageBuilder.prepareMultipartAlternativeMessage()` and `addMultipartAlternative()`. +* Fixed documentation for generating a mime message with an attachment (thanks [lqmminh](https://github.com/lqmminh)!). + +# 2.0.1 +* Thanks to [yarsort](https://github.com/yarsort) resolved various POP3 bugs. +* Interpret mime messages with an (invalid) 2-digit year as coming from the current millennium. + + +# 2.0.0 +Improvements and fixes: +* Thanks to [matthiasn](https://github.com/matthiasn) the date parsing/generation on west of greenwich timezones now works properly. +* Improve automatic re-connecting when using the high-level MailClient API. +* Support timeouts for IMAP, SMTP and POP calls. +* `MimeMessage`: + - Get an alternative mime part easier with `MimePart? getAlternativePart(MediaSubtype subtype)`. + - Retrieve all recipients via the `List get recipients` getter. + - Support decoding `binary` transfer-encoding for text message parts. + - Introduce `guid` / global unique IDs which are set automatically when using the high-level `MailClient`. + - Correctly unwrap header values before decoding them. + - Accept headers that have no space after the colon-separator. +* Improve high level API support for OAUTH: + - You can now define `refresh` and `onConfigChanged` callback methods when connecting to a mail service using `MailClient`. +* Support expunging messages when deleting them in `MailClient` with `Future deleteMessages( MessageSequence sequence, {bool expunge = false})`. +OauthAuthentication now contains a complete OauthToken. + main +* `MessageBuilder`: Access also text-attachments in the `attachments` getter. +* Only use `STARTTLS` when the IMAP service supports it. +* Simplify search API. + +Breaking changes: +* Package structure is simplified, so that imports of specific classes are not possible anymore. Instead either `import 'package:enough_mail/enough_mail.dart';` or one of the specializes sub-packages `codecs.dart`,`discover.dart`, `highlevel.dart`, `imap.dart`, `mime.dart`, `pop.dart` or `smtp.dart`. +* `Authentication.passwordCleartext` is renamed to `Authentication.passwordClearText` +* `Mailbox` API has changed specifically when creating mailboxes yourself. + +Other: +* Improved code style, enforcing linting rules. +* Improve [API documentation](https://pub.dev/documentation/enough_mail/latest/). +* Improve package structure +* Many further small-scale improvements. + +# 1.3.6 +- Fix generating messages with several recipients in `MessageBuilder`. Previously semicolons were used that were not accepted by all mail providers. + +# 1.3.5 +- Add `bool Function(X509Certificate)? onBadCertificate` callback to handle invalid certificates #167 +- Stop polling when disconnecting high level `MailClient` +- Ignore subsequent `IDLE` requests when already in idle mode in `ImapClient` +- Improve documentation + +# 1.3.4 +- Fix some IMAP mailbox commands when there is no mailbox selected: #160 #164 #165 + +# 1.3.3 +- Add easier method to setup a `MailAccount` with manual settings by calling `MailAccount.fromManualSettings()` + or `MailAccount.fromManualSettingsWithAuth()`. This is useful when settings cannot or should not be auto-discovered, for example. + +# 1.3.2 +- Fix login for IMAP servers that do not define capabilities in their `AUTH`/`LOGIN` response #159 + +# 1.3.1 +- Always quote user name and password in IMAP login, #158 +- Thanks to [fttx2020](https://github.com/fttx2020) we have these great improvements: + - Fix for POP3 UID LIST command + - Fix parsing of POP3 responses + - Handle more Chinese character encodings + - Handle some base64 text variations better +- `SmtpException`s now contain the full error description + +# 1.3.0 +- Support read receipts #149 + - Check if a message contains a read receipt request with `MimeMessage.isReadReceiptRequested` + - Generate a read request response with `MessageBuilder.buildReadReceipt()` +- Support Windows-1256 encoding +- Add another message as an attachment with `MessageBuilder.addMessagePart()` #153 +- Easily retrieve all leaf parts after loading `BODYSTRUCTURE` with `MimeMessage.body.allLeafParts` +- Fix for responses with a line break spread around 2 chunks #140 +- Improve identification of message parts with their `fetchId` #141 #143 - Thanks to [A.Zulli](https://github.com/azulli) again! +- Messages are now send with `utf-8` rather than `utf8` to reduce problems #144 - Thanks to [gmalakov](https://github.com/gmalakov) +- Fix for responses with a literal `{0}` response #145 +- Better detection of plain text messages thanks to [castaway](https://github.com/castaway) + + +# 1.2.2 +- Assume `8bit` encoding when no `content-transfer-encoding` is specified in a MIME message. +- Exclude empty address-lists when building a message with `MessageBuilder`. +- Retrieve a MIME part wit the fetchId `1` correctly. +- `ImapClient.idleStart()` throws an error when no mailbox is selected. +- `MailClient.fetchMessageContents()` allows you to specify which media types you want to include with the `includedInlineTypes` parameter, e.g. `final mime = await mailClient.fetchMessageContents(envelopeMime, includedInlineTypes: [MediaToptype.image]);`. +- Convenience improvements: + * Select a mailbox just by it's flag like `MailboxFlag.sent` with `MailClient.selectMailboxByFlag(MailboxFlag)` method. + * Check if an email address contains a personal name with `MailAddress.hasPersonalName` getter. + +# 1.2.1 +- Handle raw data in parameter values of IMAP `FETCH` responses. + +# 1.2.0 +- Thanks to [KevinBLT](https://github.com/KevinBLT) mime messages will now always have a valid date header. +- The high level search API has been extended and access simplified +- The high level thread API has been simplified + +# 1.1.0 +- Thanks to [A.Zulli](https://github.com/azulli) the `UNSELECT` IMAP command of [rfc3691](https://tools.ietf.org/html/rfc3691) is now supported with `ImapClient.unselectMailbox()`. +- Support [THREAD](https://tools.ietf.org/html/rfc5256) IMAP Extension with `ImapClient.threadMessages()` and `uidThreadMessage()` as well as the high level API `MailClient.fetchThreads()` and `fetchThreadData()`, the latter can set the `MimeMessage.threadSequence` automatically. #44 +- Access embedded `message/rfc822` messages using `mimePart.decodeContentMessage()`. #138 +- Added `SearchQueryType.toOrFrom` to easily search for recipients or senders of a message. +- All Mailbox commands now return the mailbox in question, not the currently selected mailbox. +- Improve automatic reconnects in high level `MailClient` API. +- Added high level OAuth login option and `MailAccount.fromDiscoveredSettingsWithAuth()` for easy setup. #137 +- Appending a message will now return the new UID of that message. +- Continue editing a draft easily by calling `MessageBuilder prepareFromDraft(MimeMessage draft)`. +- You now easier load the next page of of search using `MailClient.searchMessagesNextPage(MailSearchResult)`. +- Improve null-safety. +- Breaking API changes: + - To align with Dart APIs, `MessageSequence.isEmpty` and `isNotEmpty` are now getters and not methods anymore. So instead of `if (sequence.isEmpty())` please now use `if (sequence.isEmpty)`, etc. + - Date headers are always decoded to local time. Instead of `mimeMessage.decodeDate().toLocal()` now just call `mimeMessage.decodeDate()`. + - High level API `MailSearchResult` has been refactored to use `PagedMessageSequence`. + +# 1.0.0 +- `enough_mail` is now [null safe](https://dart.dev/null-safety/tour) #127 +- Support `zulu` timezone in date decoding #132 +- When the `MailClient` loses a connection or reconnects, it will now fire corresponding `MailConnectionLost` and `MailConnectionReEstablished` events. +- When the `MailClient` reconnects, it will fetch new messages automatically and notify about them using `MailLoadEvent`. +- Breaking changes to `v0.3`: + * `MessageBuilder.encoding` is renamed to `MessageBuilder.transferEncoding` and the `enum` previously called `MessageEncoding` is now called `TransferEncoding`. All optional parameters previously called `encoding` are now also named `transferEncoding`. + * `MetaDataEntry.entry` has been renamed to `MetaDataEntry.name`. + * `ImapClient.setQuota()` and `getQuota()` methods use named parameters. + * Due to null safety, a lots of functions that previously (wrongly) accepted `null` parameters do not accept `null` as input anymore. + * Some fields changed to `final` to ensure consistency. + + +## 0.3.1 +* Fix for handling `PARTIAL` IMAP responses - thanks to [A.Zulli](https://github.com/azulli) +* Fix for handling `FETCH` IMAP responses that are spread across several response lines for a single message - #131 + +## 0.3.0 +- [KevinBLT](https://github.com/KevinBLT) contributed the following improvements and features: + * Check out the experimental [DKIM](https://tools.ietf.org/html/rfc6376) signing of messages. + * Enjoy the improved the performance of `QuotedPrintable` encoding. + * BCC header is now stripped from messages before sending them via SMTP +- [A.Zulli](https://github.com/azulli) contributed major IMAP features in this release: + * Sort messages with `ImapClient.sortMessages(...)` [SORT](https://tools.ietf.org/html/rfc5256) - and also use the extended sort mechanism with specifying `returnOptions` on servers with [ESORT](https://tools.ietf.org/html/rfc5267). + * `ImapClient.searchMessages(...)` now accepts `List` parameter for extending the search according to the [ESEARCH](https://tools.ietf.org/html/rfc4731) standard. + * Support `PARTIAL` responses according to the [CONTEXT](https://tools.ietf.org/html/rfc5267) IMAP extension. + * Use the LIST extensions: + * [rfc5258](https://tools.ietf.org/html/rfc5258): `LIST` command extensions + * [rfc5819](https://tools.ietf.org/html/rfc5819): return `STATUS` in extended lists + * [rfc6154](https://tools.ietf.org/html/rfc6154): `SPECIAL-USE` mailboxes +- [Alexander Sotnikov](https://github.com/SotnikAP) fixed `POP3` so that you can now use the `PopClient` as intended. +- SMTP improvements: + * You can now send messages via the SMTP `BDAT` command using `SmtpClient.sendChunkedMessage()` / `sendChunkedMessageData()` / `sendChunkedMessageText()`. + * You don't require a `MimeMessage` to send any more when you send messages either via `SmtpClient.sendMessageData()` or `SmtpClient.sendMessageText()`. +- MessageBuilder / MIME generation improvements: + * Attachments are now also added when forrwarding a message without quoting in `MessageBuilder.prepareForwardMessage()`. + * You can now also prepend parts by setting `insert` to `true` when calling `addPart()`. +- Other improvements and bugfixes: + * Remove some dependencies and relax constraints on some so that we all get quicker through the `null-safety` challenge. + * Fixed decoding of 8bit messages that use a different charset than UTF8 + * Fixed header decoding in some edge cases + * Some fixes in parsing personal names in email addresses + * Support Chinese encodings `GBK` and `GB-2312` + * Improve reconnecting when using the high level API + * Only download the `ENVELOPE` information when a new mail is detected in high level API +- Breaking changes: + * `MessageBuilder.replyToMessage` is renamed to `MessageBuilder.originalMessage` + +## 0.2.1 +- Allow to specify `connectionTimeout` for all low level clients +- Support non-ASCII IMAP searches when supported by server +- Fix reconnection issue for `ImapClient` +- Fix decoding of sequentiell encoded words in edge cases +- Do a `noop` when resuming `MailClient` when server does not support `IDLE` + +## 0.2.0 +- ImapClient now processes tasks sequentially, removing the dreaded `StreamSink is bound to a stream` exception when accessing ImapClient from several threads. +- Highlevel API for adding mail messages with `MailClient.appendMessage(...)` / `.appendMessageToFlag(...)` and `MailClient.saveDraftMessage(...)` +- Searching for messages is now easier than ever with `MailClient.search(MailSearch)` and `SearchQueryBuilder`- #109 +- Sent messages are now appended automatically when using the high level `MailClient.sentMessage(...)` call unless setting the `appendToSent` parameter to `false`. +- Create IMAP search criteria with `SearchQueryBuilder` and conduct common searches with `MailClient.search(MailSearch)` +- Fixed detection of audio media types +- Added `CRAM-MD5` authentication support for SMTP - #108 +- Added `XOAUTH2` authentication support for SMTP - #107 +- Create MessageSequence from list of mime messages with `MessageSequence.fromMessages(List)` +- You can now check with the highlevel API if you can send 8bit messages with `MailClient.supports8BitEncoding()` and set the preferred encoding with `MailClient.buildMimeMessageWithRecommendedTextEncoding(MessageBuilder)`. +- `MessageBuilder` now can recommend text encodings with `MessageBuilder.setRecommendedTextEncoding(bool supports8Bit)` and sets content types automatically depending on attachments. +- Access attachment information easier using the `MessageBuilder.attachments` field and the `AttachmentInfo` class. +- You can send a `MessageBuilder` instance instead of a `MimeMessage` with `MailClient.sendMessageBuilder(...)`. +- Breaking API changes: + * `SmtpClient.login()` is deprecated, please use the better named `SmtpClient.authenticate()` instead, e.g.: + `await smtpClient.authenticate(userName, password, AuthMechanism.login)` + * `BodyPart.id` is renamed to `BodyPart.cid` to make the meaning clearer. + +## 0.1.0 +- Moving from response based to exceptions, compare the migration guide for details compare the migration guide in [Readme.md](https://github.com/Enough-Software/enough_mail/blob/main/README.md#Migrating) and #101 for details - specicial thanks to [Tienisto](https://github.com/Tienisto) +- Improved performance when downloading large data significantly +- High Level API now checks for SMTP START TLS support before switching to a secure connection when connected via plan sockets +- Low level SMTP API now exposes all found server capabilities +- Fix decoding bug for UTF8 8 bit encoded text +- `ImapClient.search(...)` now returns a `MessageSequence` instead just a list of integers +- High level API now supports moving messages with `MailClient.moveMessages(...)` and `MailClient.undoMoveMessages()` methods +- High level API now supports deleting messages with `MailClient.deleteMessages(...)` and `MailClient.undoDeleteMessages()` methods + +## 0.0.36 +- Remove spaces between two encoded words in headers +- High level API support for deleting messages and undoing it: + - `Future> deleteMessages(` + ` MessageSequence sequence, Mailbox trashMailbox)` + - `Future> deleteAllMessages(Mailbox mailbox,` + ` {bool expunge})` +- Deleted messages are now preferably moved to `\Trash` folder, when possible. +- Optionally mark a message as seen by setting `markAsSeen` parameter to `true` when fetching messages or message contents + using the high level API, e.g. `MailClient.fetchMessageContents(message, markAsSeen: true)`; + +## 0.0.35 +- Ignoring malformed UT8 when logging thanks to [Tienisto](https://github.com/Tienisto). +- Use `enough_convert` package for previously missing character encodings. +- Add ` MimeMessage.parseFromText(String text)` helper method. +- Add Open PGP mime types like `MediaSubtype.applicationPgpSignature` to known media types. + +## 0.0.34 +- Fix handling of `VANISHED (EARLIER)` responses in edge cases thanks to [Andrea](https://github.com/andreademasi). +- Find a mime message part by its content-ID with the `MimeMessage.getPartWithContentId(String cid)` helper method. +- List all parts of a mime message sequentially using the `MimeMessage.allPartsFlat` getter. +- Fix problems with `UTF8` 8-bit decoded answers. +- Use the [enough_serialization](https://pub.dev/packages/enough_serialization) for JSON (de)serialization support. +- Improve discovery of mail settings. +- Allow to limit the download size of messages: `MailClient.fetchMessageContents(MimeMessage message, {int maxSize})` fetches all parts apart from attachments when the message size is bigger than the one specified in bytes in `maxSize`. +- Improve documentation, also thanks to [TheOneWithTheBraid](https://github.com/theonewiththebraid). + + +## 0.0.33 +- Support IMAP [QUOTA Extension](https://tools.ietf.org/html/rfc2087) thanks to [azulli](https://github.com/azulli). +- Throw exceptions that might occur while sending a message thanks to [hpoul](https://github.com/hpoul). +- Retrieve currently selected mailbox in highlevel API with `MailClient.selectedMailbox`. +- Specify `fetchPreference` in highlevel API when fetching messages, for example to only fetch `ENVELOPE`s first. +- Create a message builder based on a mailto link with `MessageBuilder.prepareMailtoBasedMessage()`. +- Mail events now contain the originating ImapClient, SmtpClient or MailClient instance to match the event when having several active accounts at the same time. +- Support the SMTP `AUTH LOGIN` authentication by specying the `authMechanism` parameter in `SmtpClient.login()`. +- Ease flagging of messages with `MailClient.flagMessage()`. +- Highlevel API now udates flags of a message correctly when they have changed remotely. + +## 0.0.32 +- easier to retrieve and set common message flags such as `\Seen`, `\Answered` and `$Forwarded` +- use `MimeMessage.isSeen`, `.isAnswered`, `.isForwarded` to query the corresponding flags +- use `MimeMessage.hasAttachments()` or `MimeMessage.hasAttachmentsOrInlineNonTextualParts()` to determine if the message contains attachment parts. +- [Q-Encoding](https://tools.ietf.org/html/rfc2047#section-4.2) is used for encoding/decoding corresponding MIME message headers now, compare #77 for details + +## 0.0.31 +- Mime: List all message parts with a specfic Content-Disposition with `MimeMessage.findContentInfo(ContenDisposition disposition)`. +- Mime: Retrieve an individual message part with `MimeMessage.getPart(String fetchId)` +- Bugfix: fetch individual message parts via IMAP with `BODY[1.2]` now works. +- MailClient: Download individual message parts with `MailClient.fetchMessagePart(MimeMessage message, String fetchId)`. +- MailClient: events now provide reference to used `MailClient` instance, so that apps can differentiate between accounts. +- MessageBuilder: allow to specify user aliases and to handle + aliases and to differentiate between reply and reply-all in `MessageBuilder.prepareReplyToMessage()` +- ImapClient: Ensure that every Inbox has a `MailboxFlag.inbox`. + +## 0.0.30 +- Thanks to [hpoul](https://github.com/hpoul) the XML library now works with both beta and stable flutter channels. +- Thanks to [hydeparkk](https://github.com/hydeparkk) encoded mailbox paths are now used in copy, move, status and append/ +- Fix decoding message date headers +- Fix handling mailboxes with a space in their path +- Allow to easly serialize and deserialize [MailAccount](https://pub.dev/documentation/enough_mail/latest/mail_mail_account/MailAccount-class.html) to/from JSON. +- Extended high level [MailClient API](https://pub.dev/documentation/enough_mail/latest/mail_mail_client/MailClient-class.html): + - Allow to select mailbox by path + - Disconnect to close connections + - Include fetching message flags when fetching messages + - Allow to store message flags, e.g. mark as read + - Provide access to low level API from within the high level API + +## 0.0.29 +- Add `discconect()` method to high level `MailClient` API +- Encode and decode mailbox names using Modified UTF7 encoding +- Add [IMAP support for UTF-8](https://tools.ietf.org/html/rfc6855) + +## 0.0.28 +- High level `MailClient` API supports IMAP IDLE, POP and SMTP. + +## 0.0.27 +- Downgraded crypto dependency to be compatible with flutter_test ons stable flutter channel again + +## 0.0.26 +- Added high level `MailClient` API +- Downgraded XML dependency to be compatible with flutter_test again +- Fixed `ImapClient`'s `eventBus` registration when this is specified outside of ImapClient. + +## 0.0.25 +- Add support to discover email settings using the `Discover` class. + +## 0.0.24 +- Improve parsing of IMAP `BODYSTRUCTURE` responses to FETCH commands. +- Add message media types. + +## 0.0.23 +- Provide [POP3](https://tools.ietf.org/html/rfc1939) support + +## 0.0.22 +- Breaking API change: use FETCH IMAP methods now return `FetchImapResult` instead of `List` +- Breaking API change: `ImapFetchEvent` now contains a full `MimeMessage` instead of just the sequence ID and flags +- Added `ImapVanishedEvent` that is called instead of `ImapExpungeEvent` when QRESYNC has been enabled +- Added support for [QRESYNC extension](https://tools.ietf.org/html/rfc7162) +- Added support for [ENABLE extension](https://tools.ietf.org/html/rfc5161) +- Fix handling STATUS responses (issue #56) + +## 0.0.21 +- Added support for ISO 8859-15 / latin9 encoding - based on UTF-8 + +## 0.0.20 +- Breaking change: use `MessageSequence` for defining message ID or UID ranges instead of integer-based IDs + +## 0.0.19 +- Fix for fetching recent messages when the chunksize is larger than the existing messages - thanks to studiozocaro! + +## 0.0.18 +- Breaking API changes: `MimeMessage.body` API, get and set text/plain and text/html parts in MimeMessage +- Support nested BODY and BODYSTRUCTURE responeses when fetching message data +- Support [CONDSTORE IMAP extension](https://tools.ietf.org/html/rfc5161) +- Support [MOVE IMAP extension](https://tools.ietf.org/html/rfc6851) +- Support [UIDPLUS IMAP extension](https://tools.ietf.org/html/rfc6851) + +## 0.0.17 +- Supports parsing BODYSTRUCTURE responses when fetching message data +- Also eased API for accessing BODY and BODYSTRUCTURE response data + +## 0.0.16 +- Adding 'name' parameter with quotes to 'Content-Type' header when adding a file + +## 0.0.15 +- Adding 'name' parameter to 'Content-Type' header when adding a file + +## 0.0.14 + +- Save messages to the server with `ImapClient.appendMessage()`. +- Store message flags using the `ImapClient.store()` method or use one of the mark-methods like `markFlagged()` or `markSeen()`. +- Copy message(s) using `ImapClient.copy()`. +- Copy, fetch, store or search message with UIDs using `ImapClient.uidCopy()`, `uidStore()`, etc. +- Remove messages marked with the \Deleted flag using `ImapClient.expunge()` +- Authenticate via OAUTH 2.0 using `ImapClient.authenticateWithOAuth2()` (AUTH=XOAUTH2) or `authenticateWithOAuthBearer()` (AUTH=OAUTHBEARER). +- You can now switch to TLS using `ImapClient.startTls()`. +- Query the capabilities using the `ImapClient.capability()` call. +- Let the server do some housekeeping using the `ImapClient.check()` method. + +## 0.0.13 + +- Forward complex messages with `MessageBuilder.prepareForwardMessage()`, too (issue #24) + +## 0.0.12 + +- Forward messages with `MessageBuilder.prepareForwardMessage()` + +## 0.0.11 + +- Adding simple reply generation with `MessageBuilder.prepareReplyToMessage()` (issue #25) +- Improvement for adding larger files (issue #28) + + +## 0.0.10 + +- Fix for message sending via SMTP (issue #27) + +## 0.0.9 + +- Introducing MessageBuilder for easy mime message creation +- Adapted example + +## 0.0.8 + +- Ease access to text contents of a mime message +- Adapted example + +## 0.0.7 + +- Parse MIME messages using MimeMessage.parse() +- Handle content encodings more reliably + + +## 0.0.6 + +- Supporting ASCII character character encodings and padding BASE64 headers if required + +## 0.0.5 + +- Addressed health and syntax recommendations + +## 0.0.4 + +- Support [IMAP METADATA Extension](https://tools.ietf.org/html/rfc5464) + +## 0.0.3 + +- Always end lines with `\r\n` when communicating either with SMTP or IMAP server, parse iso-8859-1 encoded headers + +## 0.0.2 + +- Cleaning architecture, adding support for `BODY[HEADER.FIELDS]` messages + +## 0.0.1 + +- Initial alpha version diff --git a/packages/enough_mail/LICENSE b/packages/enough_mail/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/packages/enough_mail/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/enough_mail/README.md b/packages/enough_mail/README.md new file mode 100644 index 0000000..e1b5661 --- /dev/null +++ b/packages/enough_mail/README.md @@ -0,0 +1,350 @@ +IMAP, POP3 and SMTP clients for Dart and Flutter email developers. + +Available under the commercial friendly +[MPL Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/). + + +## Installation +Add this dependency your pubspec.yaml file: + +``` +dependencies: + enough_mail: ^2.1.7 +``` +The latest version or `enough_mail` is [![enough_mail version](https://img.shields.io/pub/v/enough_mail.svg)](https://pub.dartlang.org/packages/enough_mail). + + +## API Documentation +Check out the full API documentation at https://pub.dev/documentation/enough_mail/latest/ + +## High Level API Usage + +The high level API abstracts away from IMAP and POP3 details, reconnects automatically and allows to easily watch a mailbox for new messages. +A simple usage example for using the high level API: + +```dart +import 'dart:io'; +import 'package:enough_mail/enough_mail.dart'; + +String userName = 'user.name'; +String password = 'password'; + +void main() async { + await mailExample(); +} + + +/// Builds a simple example message +MimeMessage buildMessage() { + final builder = MessageBuilder.prepareMultipartAlternativeMessage( + plainText: 'Hello world!', + htmlText: '

Hello world!

', + ) + ..from = [MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + MailAddress('Recipient Personal Name', 'recipient@domain.com'), + MailAddress('Other Recipient', 'other@domain.com') + ]; + return builder.buildMimeMessage(); +} + +/// Builds an example message with attachment +Future buildMessageWithAttachment() async { + final builder = MessageBuilder() + ..from = [MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + MailAddress('Recipient Personal Name', 'recipient@domain.com'), + MailAddress('Other Recipient', 'other@domain.com') + ] + ..addMultipartAlternative( + plainText: 'Hello world!', + htmlText: '

Hello world!

', + ); + final file = File.fromUri(Uri.parse('file://./document.pdf')); + await builder.addFile(file, MediaSubtype.applicationPdf.mediaType); + return builder.buildMimeMessage(); +} + +/// High level mail API example +Future mailExample() async { + final email = '$userName@$domain'; + print('discovering settings for $email...'); + final config = await Discover.discover(email); + if (config == null) { + // note that you can also directly create an account when + // you cannot auto-discover the settings: + // Compare the [MailAccount.fromManualSettings] + // and [MailAccount.fromManualSettingsWithAuth] + // methods for details. + print('Unable to auto-discover settings for $email'); + return; + } + print('connecting to ${config.displayName}.'); + final account = + MailAccount.fromDiscoveredSettings('my account', email, password, config); + final mailClient = MailClient(account, isLogEnabled: true); + try { + await mailClient.connect(); + print('connected'); + final mailboxes = + await mailClient.listMailboxesAsTree(createIntermediate: false); + print(mailboxes); + await mailClient.selectInbox(); + final messages = await mailClient.fetchMessages(count: 20); + messages.forEach(printMessage); + mailClient.eventBus.on().listen((event) { + print('New message at ${DateTime.now()}:'); + printMessage(event.message); + }); + await mailClient.startPolling(); + // generate and send email: + final mimeMessage = buildMessage(); + await mailClient.sendMessage(mimeMessage); + } on MailException catch (e) { + print('High level API failed with $e'); + } +} +``` + +## Low Level Usage + +A simple usage example for using the low level API: + +```dart +import 'dart:io'; +import 'package:enough_mail/enough_mail.dart'; + +String userName = 'user.name'; +String password = 'password'; +String imapServerHost = 'imap.domain.com'; +int imapServerPort = 993; +bool isImapServerSecure = true; +String popServerHost = 'pop.domain.com'; +int popServerPort = 995; +bool isPopServerSecure = true; +String smtpServerHost = 'smtp.domain.com'; +int smtpServerPort = 465; +bool isSmtpServerSecure = true; + +void main() async { + await discoverExample(); + await imapExample(); + await smtpExample(); + await popExample(); + exit(0); +} + +Future discoverExample() async { + var email = 'someone@enough.de'; + var config = await Discover.discover(email, isLogEnabled: false); + if (config == null) { + print('Unable to discover settings for $email'); + } else { + print('Settings for $email:'); + for (var provider in config.emailProviders) { + print('provider: ${provider.displayName}'); + print('provider-domains: ${provider.domains}'); + print('documentation-url: ${provider.documentationUrl}'); + print('Incoming:'); + print(provider.preferredIncomingServer); + print('Outgoing:'); + print(provider.preferredOutgoingServer); + } + } +} + +/// Low level IMAP API usage example +Future imapExample() async { + final client = ImapClient(isLogEnabled: false); + try { + await client.connectToServer(imapServerHost, imapServerPort, + isSecure: isImapServerSecure); + await client.login(userName, password); + final mailboxes = await client.listMailboxes(); + print('mailboxes: $mailboxes'); + await client.selectInbox(); + // fetch 10 most recent messages: + final fetchResult = await client.fetchRecentMessages( + messageCount: 10, criteria: 'BODY.PEEK[]'); + for (final message in fetchResult.messages) { + printMessage(message); + } + await client.logout(); + } on ImapException catch (e) { + print('IMAP failed with $e'); + } +} + +/// Low level SMTP API example +Future smtpExample() async { + final client = SmtpClient('enough.de', isLogEnabled: true); + try { + await client.connectToServer(smtpServerHost, smtpServerPort, + isSecure: isSmtpServerSecure); + await client.ehlo(); + if (client.serverInfo.supportsAuth(AuthMechanism.plain)) { + await client.authenticate('user.name', 'password', AuthMechanism.plain); + } else if (client.serverInfo.supportsAuth(AuthMechanism.login)) { + await client.authenticate('user.name', 'password', AuthMechanism.login); + } else { + return; + } + final builder = MessageBuilder.prepareMultipartAlternativeMessage( + plainText: 'hello world.', + htmlText: '

hello world

', + ) + ..from = [MailAddress('My name', 'sender@domain.com')] + ..to = [MailAddress('Your name', 'recipient@domain.com')] + ..subject = 'My first message'; + final mimeMessage = builder.buildMimeMessage(); + final sendResponse = await client.sendMessage(mimeMessage); + print('message sent: ${sendResponse.isOkStatus}'); + } on SmtpException catch (e) { + print('SMTP failed with $e'); + } +} + +/// Low level POP3 API example +Future popExample() async { + final client = PopClient(isLogEnabled: false); + try { + await client.connectToServer(popServerHost, popServerPort, + isSecure: isPopServerSecure); + await client.login(userName, password); + // alternative login: + // await client.loginWithApop(userName, password); // optional different login mechanism + final status = await client.status(); + print( + 'status: messages count=${status.numberOfMessages}, messages size=${status.totalSizeInBytes}'); + final messageList = await client.list(status.numberOfMessages); + print( + 'last message: id=${messageList?.first?.id} size=${messageList?.first?.sizeInBytes}'); + var message = await client.retrieve(status.numberOfMessages); + printMessage(message); + message = await client.retrieve(status.numberOfMessages + 1); + print('trying to retrieve newer message succeeded'); + await client.quit(); + } on PopException catch (e) { + print('POP failed with $e'); + } +} + +void printMessage(MimeMessage message) { + print('from: ${message.from} with subject "${message.decodeSubject()}"'); + if (!message.isTextPlainMessage()) { + print(' content-type: ${message.mediaType}'); + } else { + final plainText = message.decodeTextPlainPart(); + if (plainText != null) { + final lines = plainText.split('\r\n'); + for (final line in lines) { + if (line.startsWith('>')) { + // break when quoted text starts + break; + } + print(line); + } + } + } +} +``` + +## Related Projects +Check out these related projects: +* [enough_mail_html](https://github.com/Enough-Software/enough_mail_html) generates HTML out of a `MimeMessage`. +* [enough_mail_flutter](https://github.com/Enough-Software/enough_mail_flutter) provides some common Flutter widgets for any mail app. +* [enough_mail_icalendar](https://github.com/Enough-Software/enough_mail_icalendar) for handling calendar invites in emails. +* [enough_mail_app](https://github.com/Enough-Software/enough_mail_app) aims to become a full mail app. +* [enough_convert](https://github.com/Enough-Software/enough_convert) provides the encodings missing from `dart:convert`. + +## Miss a feature or found a bug? + +Please file feature requests and bugs at the [issue tracker](https://github.com/Enough-Software/enough_mail/issues). + +## Contribute + +Want to contribute? Please check out [contribute](https://github.com/Enough-Software/enough_mail/contribute). +This is an open-source community project. Anyone, even beginners, can contribute. + +This is how you contribute: + +* Fork the [enough_mail](https://github.com/enough-software/enough_mail/) project by pressing the fork button. +* Clone your fork to your computer: `git clone github.com/$your_username/enough_mail` +* Do your changes. When you are done, commit changes with `git add -A` and `git commit`. +* Push changes to your personal repository: `git push origin` +* Go to [enough_mail](https://github.com/enough-software/enough_mail/) and create a pull request. + +Thank you in advance! + +## Thanks to all Contributors!! + + + + + +## Features +### Base standards +* ✅ [IMAP4 rev1](https://tools.ietf.org/html/rfc3501) support +* ✅ [SMTP](https://tools.ietf.org/html/rfc5321) support +* ✅ [POP3](https://tools.ietf.org/html/rfc1939) support +* ✅ [MIME](https://tools.ietf.org/html/rfc2045) parsing and generation support + +### IMAP extensions +The following IMAP extensions are supported: +* ✅ [IMAP IDLE](https://tools.ietf.org/html/rfc2177) +* ✅ [IMAP METADATA](https://tools.ietf.org/html/rfc5464) +* ✅ [UIDPLUS](https://tools.ietf.org/html/rfc2359) +* ✅ [MOVE](https://tools.ietf.org/html/rfc6851) +* ✅ [CONDSTORE](https://tools.ietf.org/html/rfc7162) +* ✅ [QRESYNC](https://tools.ietf.org/html/rfc7162) +* ✅ [ENABLE](https://tools.ietf.org/html/rfc5161) +* ✅ [QUOTA](https://tools.ietf.org/html/rfc2087) +* ✅ [IMAP Support for UTF-8](https://tools.ietf.org/html/rfc6855) +* ✅ [ESEARCH](https://tools.ietf.org/html/rfc4731) +* ✅ [SORT and THREAD](https://tools.ietf.org/html/rfc5256) +* ✅ [UNSELECT](https://tools.ietf.org/html/rfc3691)) +* ✅ ESORT and PARTIAL from [Contexts](https://tools.ietf.org/html/rfc5267) +* ✅ List extensions ([rfc5258](https://tools.ietf.org/html/rfc5258), [rfc5819](https://tools.ietf.org/html/rfc5819), [rfc6154](https://tools.ietf.org/html/rfc6154)) + +### SMTP Extensions +The following SMTP extensions are supported: +* ✅ [8-bit MIME](https://tools.ietf.org/html/rfc6152) + +### Security +The following security extensions are supported: +* ✅ Partial signing of messages using [DKIM](https://tools.ietf.org/html/rfc6376) + +### Other +* ✅ [Mailto](https://tools.ietf.org/html/rfc6068) parsing mailto links +* ✅ [Email provider auto-discovery](https://tools.ietf.org/html/rfc6186) Discover settings for an email address + +### Supported encodings +Character encodings: +* ASCII (7bit) +* UTF-8 (uft8, 8bit) +* ISO-8859-1 (latin-1) +* ISO-8859-2 - 16 (latin-2 - 16) +* Windows-1250, 1251, 1252, 1253, 1254 and 1256 +* GB-2312, GBK, GB-18030, Chinese, CSGB-2312, CSGB-231280, CSISO-58-GB-231280, ISO-IR-58, X-Mac-ChineseSimp +* Big5 +* KOI8-r and KOI8-u + +Transfer encodings: +* [Quoted-Printable (Q)](https://tools.ietf.org/html/rfc2045#section-6.7) +* [Base-64 (base64)](https://tools.ietf.org/html/rfc2045#section-6.8) + +### To do +* Compare [issues](https://github.com/Enough-Software/enough_mail/issues) + +### Develop and Contribute +* To start check out the package and then run `dart run test` to run all tests. +* Public facing library classes are in *lib*, *lib/imap* and *lib/smtp*. +* Private classes are in *lib/src*. +* Test cases are in *test*. +* Please file a pull request for each improvement/fix that you are create - your contributions are welcome. +* Check out https://github.com/enough-Software/enough_mail/contribute for good first issues. +* When changing model files, re-run the code generation by calling `dart run build_runner build --delete-conflicting-outputs`. + + +## License +`enough_mail` is licensed under the commercial friendly [Mozilla Public License 2.0](LICENSE). \ No newline at end of file diff --git a/packages/enough_mail/analysis_options.yaml b/packages/enough_mail/analysis_options.yaml new file mode 100644 index 0000000..9e876a0 --- /dev/null +++ b/packages/enough_mail/analysis_options.yaml @@ -0,0 +1,147 @@ +# cSpell:disable +analyzer: + errors: + todo: ignore + exclude: + - '**/*.g.*' + + +linter: + rules: + - always_declare_return_types + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + #- always_specify_types # This would enforce to write types literally everywhere. + - annotate_overrides + #- avoid_annotating_with_dynamic # Explicit annotation of dynamic as type is preferable. Also exclusive with type_annotate_public_apis + #- avoid_as - deprecated, breaks lint + - avoid_bool_literals_in_conditional_expressions + #- avoid_catches_without_on_clauses # Do not enable this as enough_mail needs to handle several underlying exceptions and errors + - avoid_catching_errors + #- avoid_classes_with_only_static_members # Useful for some non-global helper cases + - avoid_double_and_int_checks + - avoid_empty_else + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + - avoid_implementing_value_types + - avoid_init_to_null + - avoid_js_rounded_ints + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_private_typedef_functions + - avoid_renaming_method_parameters + - avoid_relative_lib_imports + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - close_sinks + - collection_methods_unrelated_type + - comment_references + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + #- diagnostic_describe_all_properties # We do not use diagnostics atm + - directives_ordering + - empty_constructor_bodies + - empty_statements + - empty_catches + - file_names + #- flutter_style_todos # Flutter todos are to verbose for our requirements. + - hash_and_equals + - implementation_imports + # - join_return_with_assignment # leads to less readable code IMHO + - library_names + - library_prefixes + - lines_longer_than_80_chars + - literal_only_boolean_expressions + - no_adjacent_strings_in_list + - no_duplicate_case_values + - non_constant_identifier_names + - null_closures + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_expression_function_bodies + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_iterable_whereType + - prefer_mixin + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + - slash_for_doc_comments + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_to_and_as_if_applicable + - valid_regexps + - void_checks + diff --git a/packages/enough_mail/build.yaml b/packages/enough_mail/build.yaml new file mode 100644 index 0000000..4ae7304 --- /dev/null +++ b/packages/enough_mail/build.yaml @@ -0,0 +1,20 @@ +targets: + $default: + builders: + json_serializable: + options: + # Options configure how source code is generated for every + # `@JsonSerializable`-annotated class in the package. + # any_map: false + # checked: false + # constructor: "" + # create_factory: true + # create_field_map: false + # create_per_field_to_json: false + # create_to_json: true + # disallow_unrecognized_keys: false + explicit_to_json: true + # field_rename: none + # generic_argument_factories: false + # ignore_unannotated: false + # include_if_null: true \ No newline at end of file diff --git a/packages/enough_mail/example/discover.dart b/packages/enough_mail/example/discover.dart new file mode 100644 index 0000000..d1b618a --- /dev/null +++ b/packages/enough_mail/example/discover.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:enough_mail/discover.dart'; + +// ignore: avoid_void_async +void main(List args) async { + if (args.isEmpty) { + _usage(); + } + var forceSsl = false; + var log = false; + var onlyPreferred = false; + var email = args.first; + if (args.length > 1) { + final arguments = [...args]; + forceSsl = arguments.remove('--ssl'); + log = arguments.remove('--log'); + onlyPreferred = arguments.remove('--preferred'); + email = arguments.last; + if (arguments.length != 1) { + email = args.firstWhere( + (arguments) => arguments.contains('@'), + orElse: () => '', + ); + arguments.remove(email); + print('Invalid arguments: $arguments'); + _usage(); + } + } + if (!email.contains('@')) { + _usage(); + } + print('Resolving for email $email...'); + final config = await Discover.discover( + email, + forceSslConnection: forceSsl, + isLogEnabled: log, + ); + if (config == null) { + print('Unable to discover settings for $email'); + } else { + print('Settings for $email:'); + for (final provider in config.emailProviders ?? []) { + print('provider: ${provider.displayName}'); + print('provider-domains: ${provider.domains}'); + print('documentation-url: ${provider.documentationUrl}'); + if (!onlyPreferred) { + print('Incoming:'); + provider.incomingServers?.forEach(print); + } + print('Preferred incoming:'); + print(provider.preferredIncomingServer); + if (!onlyPreferred) { + print('Outgoing:'); + provider.outgoingServers?.forEach(print); + } + print('Preferred outgoing:'); + print(provider.preferredOutgoingServer); + } + } + exit(0); +} + +void _usage() { + print('Tries to discover email settings.'); + print('Usage: dart example/discover.dart [options] email'); + print('Options:'); + print('--ssl: enforce SSL usage'); + print('--log: log details during discovery'); + print('--preferred: only print the preferred incoming and outgoing servers'); + print(''); + print('Example:'); + print('dart example/discover.dart --log your-email@domain.com'); + exit(1); +} diff --git a/packages/enough_mail/example/enough_mail_example.dart b/packages/enough_mail/example/enough_mail_example.dart new file mode 100644 index 0000000..334dd6f --- /dev/null +++ b/packages/enough_mail/example/enough_mail_example.dart @@ -0,0 +1,225 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; + +String userName = 'user.name'; +String password = 'password'; +String domain = 'domain.com'; +String imapServerHost = 'imap.$domain'; +int imapServerPort = 993; +bool isImapServerSecure = true; +String popServerHost = 'pop.$domain'; +int popServerPort = 995; +bool isPopServerSecure = true; +String smtpServerHost = 'smtp.$domain'; +int smtpServerPort = 465; +bool isSmtpServerSecure = true; + +// ignore: avoid_void_async +void main() async { + //await mailExample(); + await discoverExample(); + await imapExample(); + await smtpExample(); + await popExample(); + exit(0); +} + +/// Auto discover settings from email address example +Future discoverExample() async { + const email = 'someone@enough.de'; + final config = await Discover.discover(email, isLogEnabled: false); + if (config == null) { + print('Unable to discover settings for $email'); + } else { + print('Settings for $email:'); + for (final provider in config.emailProviders ?? []) { + print('provider: ${provider.displayName}'); + print('provider-domains: ${provider.domains}'); + print('documentation-url: ${provider.documentationUrl}'); + print('Incoming:'); + provider.incomingServers?.forEach(print); + print(provider.preferredIncomingServer); + print('Outgoing:'); + provider.outgoingServers?.forEach(print); + print(provider.preferredOutgoingServer); + } + } +} + +/// Builds a simple example message +MimeMessage buildMessage() { + final builder = MessageBuilder.prepareMultipartAlternativeMessage( + plainText: 'Hello world!', + htmlText: '

Hello world!

', + ) + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ]; + + return builder.buildMimeMessage(); +} + +/// Builds an example message with attachment +Future buildMessageWithAttachment() async { + final builder = MessageBuilder() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addMultipartAlternative( + plainText: 'Hello world!', + htmlText: '

Hello world!

', + ); + final file = File.fromUri(Uri.parse('file://./document.pdf')); + await builder.addFile(file, MediaSubtype.applicationPdf.mediaType); + + return builder.buildMimeMessage(); +} + +/// High level mail API example +Future mailExample() async { + final email = '$userName@$domain'; + print('discovering settings for $email...'); + final config = await Discover.discover(email); + if (config == null) { + // note that you can also directly create an account when + // you cannot auto-discover the settings: + // Compare the [MailAccount.fromManualSettings] + // and [MailAccount.fromManualSettingsWithAuth] + // factory constructors for details. + print('Unable to auto-discover settings for $email'); + + return; + } + print('connecting to ${config.displayName}.'); + final account = MailAccount.fromDiscoveredSettings( + name: 'my account', + userName: 'First Last', + email: email, + password: password, + config: config, + ); + final mailClient = MailClient(account, isLogEnabled: true); + try { + await mailClient.connect(); + print('connected'); + final mailboxes = + await mailClient.listMailboxesAsTree(createIntermediate: false); + print(mailboxes); + await mailClient.selectInbox(); + final messages = await mailClient.fetchMessages(count: 20); + messages.forEach(printMessage); + mailClient.eventBus.on().listen((event) { + print('New message at ${DateTime.now()}:'); + printMessage(event.message); + }); + await mailClient.startPolling(); + // generate and send email: + final mimeMessage = buildMessage(); + await mailClient.sendMessage(mimeMessage); + } on MailException catch (e) { + print('High level API failed with $e'); + } +} + +/// Low level IMAP API usage example +Future imapExample() async { + final client = ImapClient(isLogEnabled: false); + try { + await client.connectToServer( + imapServerHost, + imapServerPort, + isSecure: isImapServerSecure, + ); + await client.login(userName, password); + final mailboxes = await client.listMailboxes(); + print('mailboxes: $mailboxes'); + await client.selectInbox(); + // fetch 10 most recent messages: + final fetchResult = await client.fetchRecentMessages( + messageCount: 10, + criteria: 'BODY.PEEK[]', + ); + fetchResult.messages.forEach(printMessage); + await client.logout(); + } on ImapException catch (e) { + print('IMAP failed with $e'); + } +} + +/// Low level SMTP API example +Future smtpExample() async { + final client = SmtpClient('enough.de', isLogEnabled: true); + try { + await client.connectToServer( + smtpServerHost, + smtpServerPort, + isSecure: isSmtpServerSecure, + ); + await client.ehlo(); + if (client.serverInfo.supportsAuth(AuthMechanism.plain)) { + await client.authenticate('user.name', 'password', AuthMechanism.plain); + } else if (client.serverInfo.supportsAuth(AuthMechanism.login)) { + await client.authenticate('user.name', 'password', AuthMechanism.login); + } else { + return; + } + // generate and send email: + final mimeMessage = await buildMessageWithAttachment(); + final sendResponse = await client.sendMessage(mimeMessage); + print('message sent: ${sendResponse.isOkStatus}'); + } on SmtpException catch (e) { + print('SMTP failed with $e'); + } +} + +/// Low level POP3 API example +Future popExample() async { + final client = PopClient(isLogEnabled: false); + try { + await client.connectToServer( + popServerHost, + popServerPort, + isSecure: isPopServerSecure, + ); + await client.login(userName, password); + // alternative login: + // await client.loginWithApop(userName, password); + final status = await client.status(); + print('status: messages count=${status.numberOfMessages}, ' + 'messages size=${status.totalSizeInBytes}'); + final messageList = await client.list(status.numberOfMessages); + print('last message: id=${messageList.first.id} ' + 'size=${messageList.first.sizeInBytes}'); + var message = await client.retrieve(status.numberOfMessages); + printMessage(message); + message = await client.retrieve(status.numberOfMessages + 1); + print('trying to retrieve newer message succeeded'); + await client.quit(); + } on PopException catch (e) { + print('POP failed with $e'); + } +} + +void printMessage(MimeMessage message) { + print('from: ${message.from} with subject "${message.decodeSubject()}"'); + if (!message.isTextPlainMessage()) { + print(' content-type: ${message.mediaType}'); + } else { + final plainText = message.decodeTextPlainPart(); + if (plainText != null) { + final lines = plainText.split('\r\n'); + for (final line in lines) { + if (line.startsWith('>')) { + // break when quoted text starts + break; + } + print(line); + } + } + } +} diff --git a/packages/enough_mail/lib/codecs.dart b/packages/enough_mail/lib/codecs.dart new file mode 100644 index 0000000..b7016e8 --- /dev/null +++ b/packages/enough_mail/lib/codecs.dart @@ -0,0 +1,6 @@ +/// Email codec classes +export 'mime.dart'; +export 'src/codecs/base64_mail_codec.dart'; +export 'src/codecs/date_codec.dart'; +export 'src/codecs/mail_codec.dart'; +export 'src/codecs/quoted_printable_mail_codec.dart'; diff --git a/packages/enough_mail/lib/discover.dart b/packages/enough_mail/lib/discover.dart new file mode 100644 index 0000000..7b5ebb2 --- /dev/null +++ b/packages/enough_mail/lib/discover.dart @@ -0,0 +1,4 @@ +/// Discovers email settings based on an email address. + +export 'src/discover/client_config.dart'; +export 'src/discover/discover.dart'; diff --git a/packages/enough_mail/lib/enough_mail.dart b/packages/enough_mail/lib/enough_mail.dart new file mode 100644 index 0000000..5d4a585 --- /dev/null +++ b/packages/enough_mail/lib/enough_mail.dart @@ -0,0 +1,18 @@ +/// With enough_mail you can connect to any mail service via IMAP, POP3 and SMTP +/// +/// You can choose between a high-level API starting with `MailClient` and the +/// low-level APIs `ImapClient`, `PopClient` and `SmtpClient`. +/// +/// Generate a new `MimeMessage` with `MessageBuilder`. +/// +/// Discover connection settings with `Discover`. +library enough_mail; + +export 'codecs.dart'; +export 'discover.dart'; +export 'highlevel.dart'; +export 'imap.dart'; +export 'mime.dart'; +export 'pop.dart'; +export 'smtp.dart'; +export 'src/exception.dart'; diff --git a/packages/enough_mail/lib/highlevel.dart b/packages/enough_mail/lib/highlevel.dart new file mode 100644 index 0000000..814ccc6 --- /dev/null +++ b/packages/enough_mail/lib/highlevel.dart @@ -0,0 +1,16 @@ +/// Highlevel email API +/// +/// Start with `MailClient` to connect to any mail service. +export 'mime.dart'; +export 'src/imap/imap_search.dart'; +export 'src/imap/mailbox.dart'; +export 'src/imap/qresync.dart'; +export 'src/imap/response.dart'; +export 'src/mail/mail_account.dart'; +export 'src/mail/mail_authentication.dart'; +export 'src/mail/mail_client.dart'; +export 'src/mail/mail_events.dart'; +export 'src/mail/mail_exception.dart'; +export 'src/mail/mail_search.dart'; +export 'src/mail/results.dart'; +export 'src/mail/tree.dart'; diff --git a/packages/enough_mail/lib/imap.dart b/packages/enough_mail/lib/imap.dart new file mode 100644 index 0000000..a5869ac --- /dev/null +++ b/packages/enough_mail/lib/imap.dart @@ -0,0 +1,17 @@ +/// Anything you need to fetch and process messages using the IMAP protocol. +/// +/// Use the `ImapClient` to connect to any IMAP compliant service. +export 'mime.dart'; +export 'src/imap/id.dart'; +export 'src/imap/imap_client.dart'; +export 'src/imap/imap_events.dart'; +export 'src/imap/imap_exception.dart'; +export 'src/imap/imap_search.dart'; +export 'src/imap/mailbox.dart'; +export 'src/imap/message_sequence.dart'; +export 'src/imap/metadata.dart'; +export 'src/imap/qresync.dart'; +export 'src/imap/resource_limit.dart'; +export 'src/imap/response.dart'; +export 'src/imap/return_option.dart'; +export 'src/imap/selection_options.dart'; diff --git a/packages/enough_mail/lib/mime.dart b/packages/enough_mail/lib/mime.dart new file mode 100644 index 0000000..1c09851 --- /dev/null +++ b/packages/enough_mail/lib/mime.dart @@ -0,0 +1,10 @@ +/// Base email classes +export 'src/exception.dart'; +export 'src/imap/message_sequence.dart'; +export 'src/mail_address.dart'; +export 'src/mail_conventions.dart'; +export 'src/media_type.dart'; +export 'src/message_builder.dart'; +export 'src/message_flags.dart'; +export 'src/mime_data.dart'; +export 'src/mime_message.dart'; diff --git a/packages/enough_mail/lib/pop.dart b/packages/enough_mail/lib/pop.dart new file mode 100644 index 0000000..4c7e040 --- /dev/null +++ b/packages/enough_mail/lib/pop.dart @@ -0,0 +1,9 @@ +/// Fetch messages using the POP3 protocol +/// +/// Start with the `PopClient` to connect to a POP3 enabled service. + +export 'mime.dart'; +export 'src/pop/pop_client.dart'; +export 'src/pop/pop_events.dart'; +export 'src/pop/pop_exception.dart'; +export 'src/pop/pop_response.dart'; diff --git a/packages/enough_mail/lib/smtp.dart b/packages/enough_mail/lib/smtp.dart new file mode 100644 index 0000000..059eaa2 --- /dev/null +++ b/packages/enough_mail/lib/smtp.dart @@ -0,0 +1,8 @@ +/// Everything you need to send messages using the SMTP protocol. +/// +/// With the `SmtpClient` you can connect to any SMTP service. +export 'mime.dart'; +export 'src/smtp/smtp_client.dart'; +export 'src/smtp/smtp_events.dart'; +export 'src/smtp/smtp_exception.dart'; +export 'src/smtp/smtp_response.dart'; diff --git a/packages/enough_mail/lib/src/codecs/base64_mail_codec.dart b/packages/enough_mail/lib/src/codecs/base64_mail_codec.dart new file mode 100644 index 0000000..83fd676 --- /dev/null +++ b/packages/enough_mail/lib/src/codecs/base64_mail_codec.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../mail_conventions.dart'; +import '../private/util/ascii_runes.dart'; +import 'mail_codec.dart'; + +/// Provides base64 encoder and decoder. +/// +/// Compare https://tools.ietf.org/html/rfc2045#page-23 for details. +class Base64MailCodec extends MailCodec { + /// Creates a new base64 mail codec + const Base64MailCodec(); + + /// Encodes the specified text in base64 format. + /// + /// [text] specifies the text to be encoded. + /// [codec] the optional codec, defaults to utf8 [MailCodec.encodingUtf8]. + /// Set [wrap] to `false` in case you do not want to wrap lines. + @override + String encodeText( + String text, { + Codec codec = MailCodec.encodingUtf8, + bool wrap = true, + }) { + final charCodes = codec.encode(text); + + return encodeData(charCodes, wrap: wrap); + } + + /// Encodes the header text in base64 only if required. + /// + /// [text] specifies the text to be encoded. + /// Set the optional [fromStart] to true in case the encoding should + /// start at the beginning of the text and not in the middle. + /// Set the [nameLength] for ensuring there is enough place for the + /// name of the encoding. + @override + String encodeHeader( + String text, { + int nameLength = 0, + bool fromStart = false, + }) { + final runes = List.from(text.runes, growable: false); + var numberOfRunesAbove7Bit = 0; + var startIndex = -1; + var endIndex = -1; + for (var runeIndex = 0; runeIndex < runes.length; runeIndex++) { + final rune = runes[runeIndex]; + if (rune > 128) { + numberOfRunesAbove7Bit++; + if (startIndex == -1) { + startIndex = runeIndex; + endIndex = runeIndex; + } else { + endIndex = runeIndex; + } + } + } + if (numberOfRunesAbove7Bit == 0) { + return text; + } else { + const qpWordHead = '=?utf8?B?'; + const qpWordTail = '?='; + const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; + if (fromStart) { + startIndex = 0; + endIndex = text.length - 1; + } + // Available space for the current encoded word + var qpWordSize = MailConventions.encodedWordMaxLength - + qpWordDelimiterSize - + startIndex - + (nameLength + 2); + final buffer = StringBuffer(); + if (startIndex > 0) { + buffer.write(text.substring(0, startIndex)); + } + final textToEncode = + fromStart ? text : text.substring(startIndex, endIndex + 1); + final encoded = encodeText(textToEncode, wrap: false); + buffer.write(qpWordHead); + if (encoded.length < qpWordSize) { + buffer.write(encoded); + } else { + // Reuses startIndex for folding + startIndex = 0; + while (startIndex < encoded.length) { + final chunk = startIndex + qpWordSize > encoded.length + ? encoded.substring(startIndex) + : encoded.substring(startIndex, startIndex + qpWordSize); + buffer.write(chunk); + startIndex += qpWordSize; + if (startIndex < encoded.length) { + buffer + ..write(qpWordTail) + // NOTE Per specification, a CRLF should be inserted here, + // but the folding occurs on the rendering function. + // Here we leave only the WSP marker + // to separate each q-encoded word. + // ..writeCharCode(AsciiRunes.runeCarriageReturn) + // ..writeCharCode(AsciiRunes.runeLineFeed) + // Assumes per default a single leading space for header folding + ..writeCharCode(AsciiRunes.runeSpace) + ..write(qpWordHead); + qpWordSize = + MailConventions.encodedWordMaxLength - qpWordDelimiterSize - 1; + } + } + } + buffer.write(qpWordTail); + if (endIndex < text.length - 1) { + buffer.write(text.substring(endIndex + 1)); + } + + return buffer.toString(); + } + } + + @override + Uint8List decodeData(final String part) { + var cleaned = part.replaceAll('\r\n', ''); + var numberOfRequiredPadding = + cleaned.length % 4 == 0 ? 0 : 4 - cleaned.length % 4; + if (numberOfRequiredPadding > 0 && cleaned.endsWith('=')) { + cleaned = cleaned.substring(0, cleaned.length - 1); + numberOfRequiredPadding = + cleaned.length % 4 == 0 ? 0 : 4 - cleaned.length % 4; + } + if (numberOfRequiredPadding > 0) { + final buffer = StringBuffer(cleaned); + var paddingRequired = true; + while (paddingRequired) { + buffer.write('='); + numberOfRequiredPadding--; + paddingRequired = numberOfRequiredPadding > 0; + } + cleaned = buffer.toString(); + } + + return base64.decode(cleaned); + } + + @override + String decodeText(String part, Encoding codec, {bool isHeader = false}) { + final outputList = decodeData(part); + + return codec.decode(outputList); + } + + /// Encodes the specified [data] in base64 format. + /// Set [wrap] to false in case you do not want to wrap lines. + String encodeData(List data, {bool wrap = true}) { + var base64Text = base64.encode(data); + if (wrap) { + base64Text = _wrapText(base64Text); + } + + return base64Text; + } + + String _wrapText(String text) { + const chunkLength = MailConventions.textLineMaxLength; + var length = text.length; + if (length <= chunkLength) { + return text; + } + var chunkIndex = 0; + final buffer = StringBuffer(); + // ignore: invariant_booleans + while (length > chunkLength) { + final startPos = chunkIndex * chunkLength; + final endPos = startPos + chunkLength; + buffer + ..write(text.substring(startPos, endPos)) + ..write('\r\n'); + chunkIndex++; + length -= chunkLength; + } + if (length > 0) { + final startPos = chunkIndex * chunkLength; + buffer.write(text.substring(startPos)); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/codecs/date_codec.dart b/packages/enough_mail/lib/src/codecs/date_codec.dart new file mode 100644 index 0000000..2351cd0 --- /dev/null +++ b/packages/enough_mail/lib/src/codecs/date_codec.dart @@ -0,0 +1,549 @@ +/// Encodes and decodes dates according to MIME requirements. +class DateCodec { + // do not allow instantiation + DateCodec._(); + + static const _weekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + static const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + static const _monthsByName = { + 'jan': 1, + 'feb': 2, + 'mar': 3, + 'apr': 4, + 'may': 5, + 'jun': 6, + 'jul': 7, + 'aug': 8, + 'sep': 9, + 'oct': 10, + 'nov': 11, + 'dec': 12, + }; + + // cSpell:disable + // source: https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations + static const _timeZonesByName = { + 'GMT': '+0000', // Greenwich Mean Time - most often this will be used + // by non-compliant implementations + 'Z': '+0000', // Zulu time zone - artificial timezone, equivalent to UTC + 'ACDT': '+1030', // Australian Central Daylight Savings Time + 'ACST': '+0930', // Australian Central Standard Time + 'ACT': '-0500', // Acre Time + 'ACWST': '+0845', // Australian Central Western Standard Time (unofficial) + 'ADT': '-0300', // Atlantic Daylight Time + 'AEDT': '+1100', // Australian Eastern Daylight Savings Time + 'AEST': '+1000', // Australian Eastern Standard Time + 'AET': '+1000', // Australian Eastern Time - can also apparently be +1100 + 'AFT': '+0430', // Afghanistan Time + 'AKDT': '-0800', // Alaska Daylight Time + 'AKST': '-0900', // Alaska Standard Time + 'ALMT': '+0600', // Alma-Ata Time + 'AMST': '-0300', // Amazon Summer Time (Brazil) + 'AMT': '+0400', // can be Amazon Time or Armenia Time. Since Brasil + // has other time zones we assume Armenia Time + 'ANAT': '+1200', // Anadyr Time + 'AQTT': '+0500', // Aqtobe Time + 'ART': '-0300', // Argentina Time + 'AST': + '+0300', // Arabia Standard Time, could also be Atlantic Standard Time + 'AWST': '+0800', // Australian Western Standard Time + 'AZOST': '+0000', // Azores Summer Time + 'AZOT': '+0100', // Azores Standard Time + 'AZT': '+0400', // Azerbaijan Time + 'BDT': '+0800', // Brunei Time + 'BIOT': '+0600', // British Indian Ocean Time + 'BIT': '-1200', // Baker Island Time + 'BOT': '-0400', // Bolivia Time + 'BRST': '-0200', // Brasília Summer Time + 'BRT': '-0300', // Brasília Time + 'BST': '+0600', // Bangladesh Standard Time, + // but could also be Bougainville Standard Time +1100 + 'BTT': '+0600', // Bhutan Time + 'CAT': '+0200', // Central Africa Time + 'CCT': '+0630', // Cocos Islands Time + 'CDT': '-0500', // Central Daylight Time (North America) + // - could also be Cuba Daylight Time -0400 + 'CEST': '+0200', // Central European Summer Time (Cf. HAEC) + 'CET': '+0100', // Central European Time + 'CHADT': '+1345', // Chatham Daylight Time + 'CHAST': '+1245', // Chatham Standard Time + 'CHOT': '+0800', // Choibalsan Standard Time + 'CHOST': '+0900', // Choibalsan Summer Time + 'CHST': '+1000', // Chuuk Time + 'CIST': '-0800', // Clipperton Island Standard Time + 'CIT': '+0800', // Central Indonesia Time + 'CKT': '-1000', // Cook Island Time + 'CLST': '-0300', // Chile Summer Time + 'CLT': '-0400', // Chile Standard Time + 'COST': '-0400', // Colombia Summer Time + 'COT': '-0500', // Colombia Time + 'CST': '-0600', // Central Standard Time (North America), + // could also be China Standard Time +0800 or Cuba Standard Time -0500 + 'CT': '+0800', // China Time + 'CVT': '-0100', // Cape Verde Time + 'CWST': '+0845', // Central Western Standard Time (Australia) unofficial + 'CXT': '+0700', // Christmas Island Time + 'DAVT': '+0700', // Davis Time + 'DDUT': '+1000', // Dumont d'Urville Time + 'DFT': '+0100', // AIX-specific equivalent of Central European Time + 'EASST': '-0500', // Easter Island Summer Time + 'EAST': '-0600', // Easter Island Standard Time + 'EAT': '+0300', // East Africa Time + 'ECT': '-0500', // Ecuador Time, could also be Eastern Caribbean Time -0400 + 'EDT': '-0400', // Eastern Daylight Time (North America) + 'EEST': '+0300', // Eastern European Summer Time + 'EET': '+0200', // Eastern European Time + 'EGST': '+0000', // Eastern Greenland Summer Time + 'EGT': '-0100', // Eastern Greenland Time + 'EIT': '+0900', // Eastern Indonesian Time + 'EST': '-0500', // Eastern Standard Time (North America) + 'FET': '+0300', // Further-eastern European Time + 'FJT': '+1200', // Fiji Time + 'FKST': '-0300', // Falkland Islands Summer Time + 'FKT': '-0400', // Falkland Islands Time + 'FNT': '-0200', // Fernando de Noronha Time + 'GALT': '-0600', // Galápagos Time + 'GAMT': '-0900', // Gambier Islands Time + 'GET': '+0400', // Georgia Standard Time + 'GFT': '-0300', // French Guiana Time + 'GILT': '+1200', // Gilbert Island Time + 'GIT': '-0900', // Gambier Island Time + 'GST': '+0400', // Gulf Standard Time, + // could also be South Georgia and the South Sandwich Islands Time -0200 + 'GYT': '-0400', // Guyana Time + 'HDT': '-0900', // Hawaii–Aleutian Daylight Time + 'HAEC': '+0200', // Heure Avancée d'Europe Centrale + // French-language name for CEST + 'HST': '-1000', // Hawaii–Aleutian Standard Time + 'HKT': '+0800', // Hong Kong Time + 'HMT': '+0500', // Heard and McDonald Islands Time + 'HOVST': '+0800', // Hovd Summer Time + 'HOVT': '+0700', // Hovd Time + 'ICT': '+0700', // Indochina Time + 'IDLW': '-1200', // International Day Line West time zone + 'IDT': '+0300', // Israel Daylight Time + 'IOT': '+0300', // Indian Ocean Time + 'IRDT': '+0430', // Iran Daylight Time + 'IRKT': '+0800', // Irkutsk Time + 'IRST': '+0330', // Iran Standard Time + 'IST': '+0530', // Indian Standard Time, could also be Irish Standard Time + // +0100 or Israel Standard Time +0200 + 'JST': '+0900', // Japan Standard Time + 'KALT': '+0200', // Kaliningrad Time + 'KGT': '+0600', // Kyrgyzstan Time + 'KOST': '+1100', // Kosrae Time + 'KRAT': '+0700', // Krasnoyarsk Time + 'KST': '+0900', // Korea Standard Time + 'LHST': '+1030', // Lord Howe Standard Time, + // could also be Lord Howe Summer Time +1100 + 'LINT': '+1400', // Line Islands Time + 'MAGT': '+1200', // Magadan Time + 'MART': '-0930', // Marquesas Islands Time + 'MAWT': '+0500', // Mawson Station Time + 'MDT': '-0600', // Mountain Daylight Time (North America) + 'MET': '+0100', // Middle European Time Same zone as CET + 'MEST': '+0200', // Middle European Summer Time Same zone as CEST + 'MHT': '+1200', // Marshall Islands Time + 'MIST': '+1100', // Macquarie Island Station Time + 'MIT': '-0930', // Marquesas Islands Time + 'MMT': '+0630', // Myanmar Standard Time + 'MSK': '+0300', // Moscow Time + 'MST': '-0700', // Mountain Standard Time (North America), + // could also be Malaysia Standard Time +0800 + 'MUT': '+0400', // Mauritius Time + 'MVT': '+0500', // Maldives Time + 'MYT': '+0800', // Malaysia Time + 'NCT': '+1100', // New Caledonia Time + 'NDT': '-0230', // Newfoundland Daylight Time + 'NFT': '+1100', // Norfolk Island Time + 'NOVT': '+0700', // Novosibirsk Time + 'NPT': '+0545', // Nepal Time + 'NST': '-0330', // Newfoundland Standard Time + 'NT': '-0330', // Newfoundland Time + 'NUT': '-1100', // Niue Time + 'NZDT': '+1300', // New Zealand Daylight Time + 'NZST': '+1200', // New Zealand Standard Time + 'OMST': '+0600', // Omsk Time + 'ORAT': '+0500', // Oral Time + 'PDT': '-0700', // Pacific Daylight Time (North America) + 'PET': '-0500', // Peru Time + 'PETT': '+1200', // Kamchatka Time + 'PGT': '+1000', // Papua New Guinea Time + 'PHOT': '+1300', // Phoenix Island Time + 'PHT': '+0800', // Philippine Time + 'PKT': '+0500', // Pakistan Standard Time + 'PMDT': '-0200', // Saint Pierre and Miquelon Daylight Time + 'PMST': '-0300', // Saint Pierre and Miquelon Standard Time + 'PONT': '+1100', // Pohnpei Standard Time + 'PST': '-0800', // Pacific Standard Time (North America), + // could also be Philippine Standard Time +0800 + 'PYST': '-0300', // Paraguay Summer Time + 'PYT': '-0400', // Paraguay Time + 'RET': '+0400', // Réunion Time + 'ROTT': '-0300', // Rothera Research Station Time + 'SAKT': '+1100', // Sakhalin Island Time + 'SAMT': '+0400', // Samara Time + 'SAST': '+0200', // South African Standard Time + 'SBT': '+1100', // Solomon Islands Time + 'SCT': '+0400', // Seychelles Time + 'SDT': '-1000', // Samoa Daylight Time + 'SGT': '+0800', // Singapore Time + 'SLST': '+0530', // Sri Lanka Standard Time + 'SRET': '+1100', // Srednekolymsk Time + 'SRT': '-0300', // Suriname Time + 'SST': '+0800', // Singapore Standard Time, + // could also be Samoa Standard Time (-1100) + 'SYOT': '+0300', // Showa Station Time + 'TAHT': '-1000', // Tahiti Time + 'THA': '+0700', // Thailand Standard Time + 'TFT': '+0500', // French Southern and Antarctic Time + 'TJT': '+0500', // Tajikistan Time + 'TKT': '+1300', // Tokelau Time + 'TLT': '+0900', // Timor Leste Time + 'TMT': '+0500', // Turkmenistan Time + 'TRT': '+0300', // Turkey Time + 'TOT': '+1300', // Tonga Time + 'TVT': '+1200', // Tuvalu Time + 'ULAST': '+0900', // Ulaanbaatar Summer Time + 'ULAT': '+0800', // Ulaanbaatar Standard Time + 'UTC': '+0000', // Coordinated Universal Time + 'UYST': '-0200', // Uruguay Summer Time + 'UYT': '-0300', // Uruguay Standard Time + 'UZT': '+0500', // Uzbekistan Time + 'VET': '-0400', // Venezuelan Standard Time + 'VLAT': '+1000', // Vladivostok Time + 'VOLT': '+0400', // Volgograd Time + 'VOST': '+0600', // Vostok Station Time + 'VUT': '+1100', // Vanuatu Time + 'WAKT': '+1200', // Wake Island Time + 'WAST': '+0200', // West Africa Summer Time + 'WAT': '+0100', // West Africa Time + 'WEST': '+0100', // Western European Summer Time + 'WET': '+0000', // Western European Time + 'WIT': '+0700', // Western Indonesian Time + 'WGST': '-0200', // West Greenland Summer Time + 'WGT': '-0300', // West Greenland Time + 'WST': '+0800', // Western Standard Time (North America) + 'YAKT': '+0900', // Yakutsk Time + 'YEKT': '+0500', // Yekaterinburg Time + }; + + /// Encodes the given [dateTime] to a valid MIME date representation + 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. + */ + final buffer = StringBuffer() + ..write(_weekdays[dateTime.weekday - 1]) + ..write(', ') + ..write(dateTime.day.toString().padLeft(2, '0')) + ..write(' ') + ..write(_months[dateTime.month - 1]) + ..write(' ') + ..write(dateTime.year) + ..write(' ') + ..write(dateTime.hour.toString().padLeft(2, '0')) + ..write(':') + ..write(dateTime.minute.toString().padLeft(2, '0')) + ..write(':') + ..write(dateTime.second.toString().padLeft(2, '0')) + ..write(' '); + if (dateTime.timeZoneOffset.inMinutes > 0) { + buffer.write('+'); + } else { + buffer.write('-'); + } + final hours = dateTime.timeZoneOffset.inHours; + if (hours < 10 && hours > -10) { + buffer.write('0'); + } + buffer.write(hours.abs()); + final 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(); + } + + /// Encodes only day-month-year of the given dateTime, e.g. `"1-MAR-2021"` + static String encodeSearchDate(DateTime dateTime) { + final buffer = StringBuffer() + ..write('"') + ..write(dateTime.day) + ..write('-') + ..write(_months[dateTime.month - 1]) + ..write('-') + ..write(dateTime.year) + ..write('"'); + + return buffer.toString(); + } + + /// Decodes the given MIME [dateText] to the local DateTime + static DateTime? decodeDate(final String? dateText) { + /* +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. + */ + if (dateText == null || dateText.isEmpty) { + return null; + } + var reminder = dateText; + final splitIndex = reminder.indexOf(','); + if (splitIndex != -1) { + // remove weekday + reminder = reminder.substring(splitIndex + 1).trim(); + } + var spaceIndex = reminder.indexOf(' '); + if (spaceIndex == -1) { + return null; + } + final dayText = reminder.substring(0, spaceIndex); + reminder = reminder.substring(spaceIndex + 1).trimLeft(); + spaceIndex = reminder.indexOf(' '); + if (spaceIndex == -1) { + return null; + } + final monthText = reminder.substring(0, spaceIndex); + reminder = reminder.substring(spaceIndex + 1).trimLeft(); + spaceIndex = reminder.indexOf(' '); + // ignore: invariant_booleans + if (spaceIndex == -1) { + return null; + } + final yearText = reminder.substring(0, spaceIndex); + reminder = reminder.substring(spaceIndex + 1).trimLeft(); + spaceIndex = reminder.indexOf(' '); + var timeText = reminder; + var zoneText = '+0000'; + if (spaceIndex != -1) { + timeText = reminder.substring(0, spaceIndex); + if (reminder.length > spaceIndex) { + reminder = reminder.substring(spaceIndex + 1).trim(); + spaceIndex = reminder.indexOf(' '); + zoneText = + spaceIndex == -1 ? reminder : reminder.substring(0, spaceIndex); + } + } + final dayOfMonth = int.tryParse(dayText); + if (dayOfMonth == null || dayOfMonth < 1 || dayOfMonth > 31) { + print('Invalid day $dayText in date $dateText'); + + return null; + } + final month = _monthsByName[monthText.toLowerCase()]; + if (month == null) { + print('Invalid month $monthText in date $dateText'); + + return null; + } + final year = int.tryParse(yearText.length == 2 ? '20$yearText' : yearText); + if (year == null) { + print('Invalid year $yearText in date $dateText'); + + return null; + } + final timeParts = timeText.split(':'); + if (timeParts.length < 2) { + print('Invalid time $timeText in date $dateText'); + + return null; + } + int? second = 0; + final hour = int.tryParse(timeParts[0]); + final minute = int.tryParse(timeParts[1]); + if (timeParts.length > 2) { + second = int.tryParse(timeParts[2]); + } + if (hour == null || minute == null || second == null) { + print('Invalid time $timeText in date $dateText'); + + return null; + } + if (zoneText.length != 5) { + if (zoneText.length == 4 && + !(zoneText.startsWith('+') || zoneText.startsWith('-'))) { + zoneText = '+$zoneText'; + } else { + // source: https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations + final zoneOffset = _timeZonesByName[zoneText]; + if (zoneOffset == null) { + print('warning: invalid time zone [$zoneText] in $dateText'); + } + zoneText = zoneOffset ?? '+0000'; + } + } + final timeZoneHours = int.tryParse(zoneText.substring(1, 3)); + final timeZoneMinutes = int.tryParse(zoneText.substring(3)); + if (timeZoneHours == null || timeZoneMinutes == null) { + print('invalid time zone $zoneText in $dateText'); + + return null; + } + var dateTime = DateTime.utc(year, month, dayOfMonth, hour, minute, second); + final isWesternTimeZone = zoneText.startsWith('+'); + final timeZoneDuration = + Duration(hours: timeZoneHours, minutes: timeZoneMinutes); + dateTime = isWesternTimeZone + ? dateTime.subtract(timeZoneDuration) + : dateTime.add(timeZoneDuration); + + return dateTime.toLocal(); + } + // cSpell:enable +} diff --git a/packages/enough_mail/lib/src/codecs/mail_codec.dart b/packages/enough_mail/lib/src/codecs/mail_codec.dart new file mode 100644 index 0000000..4a6edc6 --- /dev/null +++ b/packages/enough_mail/lib/src/codecs/mail_codec.dart @@ -0,0 +1,461 @@ +import 'dart:convert' as convert; +import 'dart:typed_data'; + +import 'package:enough_convert/enough_convert.dart'; + +import '../mail_conventions.dart'; +import '../private/util/ascii_runes.dart'; +import 'base64_mail_codec.dart'; +import 'quoted_printable_mail_codec.dart'; + +/// The used header encoding mechanism +enum HeaderEncoding { + /// Q encoding similar to QuotedPrintable + Q, + + /// Base64 encoding + B, + + /// No encoding + none +} + +/// Encodes and decodes base-64 and quoted printable encoded texts +/// +/// Compare https://tools.ietf.org/html/rfc2045#page-19 +/// and https://tools.ietf.org/html/rfc2045#page-23 for details +abstract class MailCodec { + /// Creates a new mail codec + const MailCodec(); + + /// No transfer encoding + static const String contentTransferEncodingNone = 'none'; + + /// Typical maximum length of a single text line + static const String _encodingEndSequence = '?='; + static final _headerEncodingExpression = RegExp( + r'\=\?.+?\?.+?\?.+?\?\=', + ); // the question marks after plus make this regular expression non-greedy + static final _emptyHeaderEncodingExpression = RegExp(r'\=\?.+?\?.+?\?\?\='); + + /// UTF8 encoding + static const encodingUtf8 = convert.Utf8Codec(allowMalformed: true); + + /// ISO-8859-1 encoding + static const encodingLatin1 = convert.Latin1Codec(allowInvalid: true); + + /// ASCII encoding + static const encodingAscii = convert.AsciiCodec(allowInvalid: true); + static final _charsetCodecsByName = { + 'utf-8': () => encodingUtf8, + 'utf8': () => encodingUtf8, + 'latin-1': () => encodingLatin1, + 'iso-8859-1': () => encodingLatin1, + 'iso8859-1': () => encodingLatin1, + 'iso-8859-2': () => const Latin2Codec(allowInvalid: true), + 'iso8859-2': () => const Latin2Codec(allowInvalid: true), + 'iso-8859-3': () => const Latin3Codec(allowInvalid: true), + 'iso8859-3': () => const Latin3Codec(allowInvalid: true), + 'iso-8859-4': () => const Latin4Codec(allowInvalid: true), + 'iso8859-4': () => const Latin4Codec(allowInvalid: true), + 'iso-8859-5': () => const Latin5Codec(allowInvalid: true), + 'iso8859-5': () => const Latin5Codec(allowInvalid: true), + 'iso-8859-6': () => const Latin6Codec(allowInvalid: true), + 'iso8859-6': () => const Latin6Codec(allowInvalid: true), + 'iso-8859-7': () => const Latin7Codec(allowInvalid: true), + 'iso8859-7': () => const Latin7Codec(allowInvalid: true), + 'iso-8859-8': () => const Latin8Codec(allowInvalid: true), + 'iso8859-8': () => const Latin8Codec(allowInvalid: true), + 'iso-8859-9': () => const Latin9Codec(allowInvalid: true), + 'iso8859-9': () => const Latin9Codec(allowInvalid: true), + 'iso-8859-10': () => const Latin10Codec(allowInvalid: true), + 'iso8859-10': () => const Latin10Codec(allowInvalid: true), + 'iso-8859-11': () => const Latin11Codec(allowInvalid: true), + 'iso8859-11': () => const Latin11Codec(allowInvalid: true), + // iso-8859-12 does not exist... + 'iso-8859-13': () => const Latin13Codec(allowInvalid: true), + 'iso8859-13': () => const Latin13Codec(allowInvalid: true), + 'iso-8859-14': () => const Latin14Codec(allowInvalid: true), + 'iso8859-14': () => const Latin14Codec(allowInvalid: true), + 'iso-8859-15': () => const Latin15Codec(allowInvalid: true), + 'iso8859-15': () => const Latin15Codec(allowInvalid: true), + 'iso-8859-16': () => const Latin16Codec(allowInvalid: true), + 'iso8859-16': () => const Latin16Codec(allowInvalid: true), + 'windows-1250': () => const Windows1250Codec(allowInvalid: true), + 'cp1250': () => const Windows1250Codec(allowInvalid: true), + 'cp-1250': () => const Windows1250Codec(allowInvalid: true), + 'windows-1251': () => const Windows1251Codec(allowInvalid: true), + 'cp1251': () => const Windows1251Codec(allowInvalid: true), + 'windows-1252': () => const Windows1252Codec(allowInvalid: true), + 'cp1252': () => const Windows1252Codec(allowInvalid: true), + 'cp-1252': () => const Windows1252Codec(allowInvalid: true), + 'windows-1253': () => const Windows1253Codec(allowInvalid: true), + 'cp1253': () => const Windows1253Codec(allowInvalid: true), + 'cp-1253': () => const Windows1253Codec(allowInvalid: true), + 'windows-1254': () => const Windows1254Codec(allowInvalid: true), + 'cp1254': () => const Windows1254Codec(allowInvalid: true), + 'cp-1254': () => const Windows1254Codec(allowInvalid: true), + 'windows-1256': () => const Windows1256Codec(allowInvalid: true), + 'cp1256': () => const Windows1256Codec(allowInvalid: true), + 'cp-1256': () => const Windows1256Codec(allowInvalid: true), + 'gbk': () => const GbkCodec(allowInvalid: true), + 'gb2312': () => const GbkCodec(allowInvalid: true), + 'gb-2312': () => const GbkCodec(allowInvalid: true), + 'cp-936': () => const GbkCodec(allowInvalid: true), + 'windows-936': () => const GbkCodec(allowInvalid: true), + 'gb18030': () => const GbkCodec(allowInvalid: true), + 'chinese': () => const GbkCodec(allowInvalid: true), + 'csgb2312': () => const GbkCodec(allowInvalid: true), + 'csgb231280': () => const GbkCodec(allowInvalid: true), + 'csiso58gb231280': () => const GbkCodec(allowInvalid: true), + 'iso-ir-58': () => const GbkCodec(allowInvalid: true), + 'x-mac-chinesesimp': () => const GbkCodec(allowInvalid: true), + 'big5': () => const Big5Codec(allowInvalid: true), + 'big-5': () => const Big5Codec(allowInvalid: true), + 'koi8': () => const Koi8rCodec(allowInvalid: true), + 'koi8-r': () => const Koi8rCodec(allowInvalid: true), + 'koi8-u': () => const Koi8uCodec(allowInvalid: true), + 'us-ascii': () => encodingAscii, + 'ascii': () => encodingAscii, + }; + static final _textDecodersByName = { + 'q': quotedPrintable.decodeText, + 'quoted-printable': quotedPrintable.decodeText, + 'b': base64.decodeText, + 'base64': base64.decodeText, + 'base-64': base64.decodeText, + '7bit': decodeOnlyCodec, + '8bit': decodeOnlyCodec, + contentTransferEncodingNone: decodeOnlyCodec, + }; + + static final _binaryDecodersByName = { + 'b': base64.decodeData, + 'base64': base64.decodeData, + 'base-64': base64.decodeData, + 'binary': decodeBinaryTextData, + '8bit': decode8BitTextData, + contentTransferEncodingNone: decode8BitTextData, + }; + + /// bas64 mail codec + static const base64 = Base64MailCodec(); + + /// quoted printable mail codec + static const quotedPrintable = QuotedPrintableMailCodec(); + + /// Encodes the specified text in the chosen codec's format. + /// + /// [text] specifies the text to be encoded. + /// [codec] the optional codec, which defaults to utf8. + /// Set [wrap] to false in case you do not want to wrap lines. + String encodeText( + String text, { + convert.Codec codec = encodingUtf8, + bool wrap = true, + }); + + /// Encodes the header text in the chosen codec's only if required. + /// + /// [text] specifies the text to be encoded. + /// Set the optional [fromStart] to true in case the encoding should + /// start at the beginning of the text and not in the middle. + String encodeHeader( + String text, { + bool fromStart = false, + }); + + /// Encodes the given [part] text. + Uint8List decodeData(String part); + + /// Decodes the given [part] text with the given [codec]. + /// + /// [isHeader] is set to the `true` when this text originates from a header + String decodeText( + String part, + convert.Encoding codec, { + bool isHeader = false, + }); + + /// Decodes the given header [input] value. + static String? decodeHeader(final String? input) { + if (input == null || input.isEmpty) { + return input; + } + // unwrap any lines: + var cleaned = input.replaceAll('\r\n ', ''); + // remove any spaces between 2 encoded words: + final containsEncodedWordsWithSpace = cleaned.contains('?= =?'); + final containsEncodedWordsWithTab = cleaned.contains('?=\t=?'); + final containsEncodedWordsWithoutSpace = + !containsEncodedWordsWithSpace && cleaned.contains('?==?'); + if (containsEncodedWordsWithSpace || + containsEncodedWordsWithTab || + containsEncodedWordsWithoutSpace) { + final match = _headerEncodingExpression.firstMatch(cleaned); + if (match != null) { + final sequence = match.group(0) ?? ''; + final separatorIndex = sequence.indexOf('?', 3); + final endIndex = separatorIndex + 3; + final startSequence = sequence.substring(0, endIndex); + final searchText = containsEncodedWordsWithSpace + ? '?= $startSequence' + : containsEncodedWordsWithTab + ? '?=\t$startSequence' + : '?=$startSequence'; + if (startSequence.endsWith('?B?') || startSequence.endsWith('?b?')) { + // in base64 encoding there are 2 cases: + // 1. individual parts can end with the padding character "=": + // - in that case we just remove the + // space between the encoded words + // 2. individual words do not end with a padding character: + // - in that case we combine the words + if (cleaned.contains('=$searchText')) { + if (containsEncodedWordsWithSpace) { + cleaned = cleaned.replaceAll('?= =?', '?==?'); + } else if (containsEncodedWordsWithTab) { + cleaned = cleaned.replaceAll('?=\t=?', '?==?'); + } + } else { + cleaned = cleaned.replaceAll(searchText, ''); + } + } else { + // "standard case" - just fuse the sequences together + cleaned = cleaned.replaceAll(searchText, ''); + } + } + } + final buffer = StringBuffer(); + _decodeHeaderImpl(cleaned, buffer); + + return buffer.toString(); + } + + static void _decodeHeaderImpl(final String input, StringBuffer buffer) { + RegExpMatch? match; + var reminder = input; + while ((match = _headerEncodingExpression.firstMatch(reminder)) != null) { + final sequence = match?.group(0) ?? ''; + final separatorIndex = sequence.indexOf('?', 3); + final characterEncodingName = + sequence.substring('=?'.length, separatorIndex).toLowerCase(); + final decoderName = sequence + .substring(separatorIndex + 1, separatorIndex + 2) + .toLowerCase(); + + final codec = _charsetCodecsByName[characterEncodingName]?.call(); + if (codec == null) { + print('Error: no encoding found for [$characterEncodingName].'); + buffer.write(reminder); + + return; + } + final decoder = _textDecodersByName[decoderName]; + if (decoder == null) { + print('Error: no decoder found for [$decoderName].'); + buffer.write(reminder); + + return; + } + if (match != null && match.start > 0) { + buffer.write(reminder.substring(0, match.start)); + } + final contentStartIndex = separatorIndex + 3; + final part = sequence.substring( + contentStartIndex, + sequence.length - _encodingEndSequence.length, + ); + final decoded = decoder(part, codec, isHeader: true); + buffer.write(decoded); + reminder = reminder.substring(match?.end ?? 0); + } + if (buffer.isEmpty && + reminder.startsWith('=?') && + _emptyHeaderEncodingExpression.hasMatch(reminder)) { + return; + } + buffer.write(reminder); + } + + /// Detects the encoding used in the given header [value]. + static HeaderEncoding detectHeaderEncoding(String value) { + final match = _headerEncodingExpression.firstMatch(value); + if (match == null) { + return HeaderEncoding.none; + } + final group = match.group(0); + + return group?.contains('?B?') ?? group?.contains('?b?') ?? false + ? HeaderEncoding.B + : HeaderEncoding.Q; + } + + /// Decodes the given binary [text] + static Uint8List decodeBinary( + final String text, + final String? transferEncoding, + ) { + final tEncoding = transferEncoding ?? contentTransferEncodingNone; + final decoder = _binaryDecodersByName[tEncoding.toLowerCase()]; + if (decoder == null) { + print('Error: no binary decoder found for [$tEncoding].'); + + return Uint8List.fromList(text.codeUnits); + } + + return decoder(text); + } + + /// Decodes the given [data] + static String decodeAsText( + final Uint8List data, + final String? transferEncoding, + final String? charset, + ) { + if (transferEncoding == null && charset == null) { + // this could be a) UTF-8 or b) UTF-16 most likely: + final utf8Decoded = encodingUtf8.decode(data, allowMalformed: true); + if (utf8Decoded.contains('�')) { + final comparison = String.fromCharCodes(data); + if (!comparison.contains('�')) { + return comparison; + } + } + + return utf8Decoded; + } + // there is actually just one interesting case: + // when the transfer encoding is 8bit, the text needs to be decoded with + // the specified charset. + // Note that some mail senders also declare 7bit message encoding even when + // UTF8 or other 8bit encodings are used. + // In other cases the text is ASCII and the 'normal' decodeAnyText method + // can be used. + final transferEncodingLC = transferEncoding?.toLowerCase() ?? '8bit'; + if (transferEncodingLC == '8bit' || + transferEncodingLC == '7bit' || + transferEncodingLC == 'binary') { + final cs = charset ?? 'utf8'; + final codec = _charsetCodecsByName[cs.toLowerCase()]?.call(); + if (codec == null) { + print('Error: no encoding found for charset [$cs].'); + + return encodingUtf8.decode(data, allowMalformed: true); + } + final decodedText = codec.decode(data); + + return decodedText; + } + final text = String.fromCharCodes(data); + + return decodeAnyText(text, transferEncoding, charset); + } + + /// Decodes the given [text] + static String decodeAnyText( + final String text, + final String? transferEncoding, + final String? charset, + ) { + final transferEnc = transferEncoding ?? contentTransferEncodingNone; + final decoder = _textDecodersByName[transferEnc.toLowerCase()]; + if (decoder == null) { + print('Error: no decoder found for ' + 'content-transfer-encoding [$transferEnc].'); + + return text; + } + final cs = charset ?? 'utf8'; + final codec = _charsetCodecsByName[cs.toLowerCase()]?.call(); + if (codec == null) { + print('Error: no encoding found for charset [$cs].'); + + return text; + } + + return decoder(text, codec, isHeader: false); + } + + /// Decodes binary from the given text [part]. + static Uint8List decodeBinaryTextData(String part) => + Uint8List.fromList(part.codeUnits); + + /// Decodes the data from the given 8bit text [part] + static Uint8List decode8BitTextData(final String part) => + Uint8List.fromList(part.replaceAll('\r\n', '').codeUnits); + + /// Is a noop + static String decodeOnlyCodec( + String part, + convert.Encoding codec, { + bool isHeader = false, + }) => + part; + + /// Wraps the text so that it stays within email's 76 characters + /// per line convention. + /// + /// [text] the text that should be wrapped. + /// Set [wrapAtWordBoundary] to true in case the text should be wrapped + /// at word boundaries / spaces. + static String wrapText(String text, {bool wrapAtWordBoundary = false}) { + if (text.length <= MailConventions.textLineMaxLength) { + return text; + } + final buffer = StringBuffer(); + final runes = text.runes; + int? lastRune; + int? lastSpaceIndex; + var currentLineLength = 0; + var currentLineStartIndex = 0; + for (var runeIndex = 0; runeIndex < runes.length; runeIndex++) { + final rune = runes.elementAt(runeIndex); + if (rune == AsciiRunes.runeLineFeed && + lastRune == AsciiRunes.runeCarriageReturn) { + buffer.write(text.substring(currentLineStartIndex, runeIndex + 1)); + currentLineLength = 0; + currentLineStartIndex = runeIndex + 1; + lastSpaceIndex = null; + } else { + if (wrapAtWordBoundary && + (rune == AsciiRunes.runeSpace || rune == AsciiRunes.runeTab)) { + lastSpaceIndex = runeIndex; + } + currentLineLength++; + if (currentLineLength >= MailConventions.textLineMaxLength) { + // edge case: this could be in the middle of a \r\n sequence: + if (rune == AsciiRunes.runeCarriageReturn && + runeIndex < runes.length - 1 && + runes.elementAt(runeIndex + 1) == AsciiRunes.runeLineFeed) { + lastRune = rune; + continue; // the break will be handled in the next loop iteration + } + var endIndex = (wrapAtWordBoundary && lastSpaceIndex != null) + ? lastSpaceIndex + : runeIndex; + if (endIndex < runes.length - 1) { + endIndex++; + } + buffer + ..write(text.substring(currentLineStartIndex, endIndex)) + ..write('\r\n'); + currentLineLength = 0; + currentLineStartIndex = endIndex; + lastSpaceIndex = null; + } + } + lastRune = rune; + } + + if (currentLineStartIndex < text.length) { + buffer.write(text.substring(currentLineStartIndex)); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/codecs/modified_utf7_codec.dart b/packages/enough_mail/lib/src/codecs/modified_utf7_codec.dart new file mode 100644 index 0000000..387b5cc --- /dev/null +++ b/packages/enough_mail/lib/src/codecs/modified_utf7_codec.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; + +/// Provides Modified UTF7 encoder and decoder. +/// Compare https://tools.ietf.org/html/rfc3501#section-5.1.3 and https://tools.ietf.org/html/rfc2152 for details. +/// Inspired by https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs +class ModifiedUtf7Codec { + /// Creates a new modified UTF7 codec + const ModifiedUtf7Codec(); + + static const String _utf7Alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,'; + + static const List _utf7Rank = [ + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 62, + 63, + 255, + 255, + 255, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 255, + 255, + 255, + 255, + 255, + 255, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 255, + 255, + 255, + 255, + 255, + ]; + + void _utf7ShiftOut(StringBuffer output, int u, int bits) { + if (bits > 0) { + final x = (u << (6 - bits)) & 0x3f; + output.write(_utf7Alphabet[x]); + } + + output.write('-'); + } + + /// Encodes the specified text in Modified UTF7 format. + /// [text] specifies the text to be encoded. + String encodeText(String text) { + final encoded = StringBuffer(); + var shifted = false; + var bits = 0, u = 0; + + for (var index = 0; index < text.length; index++) { + final character = text[index]; + final codeUnit = character.codeUnitAt(0); + if (codeUnit >= 0x20 && codeUnit < 0x7f) { + // characters with octet values 0x20-0x25 and 0x27-0x7e + // represent themselves while 0x26 ("&") is represented + // by the two-octet sequence "&-" + + if (shifted) { + _utf7ShiftOut(encoded, u, bits); + shifted = false; + bits = 0; + } + + if (codeUnit == 0x26) { + encoded.write('&-'); + } else { + encoded.write(character); + } + } else { + // base64 encode + if (!shifted) { + encoded.write('&'); + shifted = true; + } + + u = (u << 16) | (codeUnit & 0xffff); + bits += 16; + + while (bits >= 6) { + final x = (u >> (bits - 6)) & 0x3f; + encoded.write(_utf7Alphabet[x]); + bits -= 6; + } + } + } + + if (shifted) { + _utf7ShiftOut(encoded, u, bits); + } + + return encoded.toString(); + } + + /// Decodes the specified [text] + /// + /// [codec] the optional character encoding (charset, defaults to utf-8) + String decodeText(String text, [Encoding codec = utf8]) { + final decoded = StringBuffer(); + var shifted = false; + var bits = 0, v = 0; + var index = 0; + String c; + + while (index < text.length) { + c = text[index++]; + + if (shifted) { + final codeUnit = c.codeUnitAt(0); + if (c == '-') { + // shifted back out of modified UTF-7 + shifted = false; + bits = v = 0; + } else if (codeUnit > 127) { + // invalid UTF-7 + return text; + } else { + final rank = _utf7Rank[codeUnit]; + + if (rank == 0xff) { + // invalid UTF-7 + return text; + } + + v = (v << 6) | rank; + bits += 6; + + if (bits >= 16) { + final u = (v >> (bits - 16)) & 0xffff; + decoded.write(String.fromCharCode(u)); + bits -= 16; + } + } + } else if (c == '&' && index < text.length) { + if (text[index] == '-') { + decoded.write('&'); + index++; + } else { + // shifted into modified UTF-7 + shifted = true; + } + } else { + decoded.write(c); + } + } + + return decoded.toString(); + } +} diff --git a/packages/enough_mail/lib/src/codecs/quoted_printable_mail_codec.dart b/packages/enough_mail/lib/src/codecs/quoted_printable_mail_codec.dart new file mode 100644 index 0000000..c3e93c6 --- /dev/null +++ b/packages/enough_mail/lib/src/codecs/quoted_printable_mail_codec.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../mail_conventions.dart'; +import '../private/util/ascii_runes.dart'; +import 'mail_codec.dart'; + +/// Provides quoted printable encoder and decoder. +/// +/// Compare https://tools.ietf.org/html/rfc2045#page-19 for details. +class QuotedPrintableMailCodec extends MailCodec { + /// Creates a new quoted printable codec + const QuotedPrintableMailCodec(); + + /// Encodes the specified text in quoted printable format. + /// + /// [text] specifies the text to be encoded. + /// [codec] the optional codec, which defaults to utf8. + /// Set [wrap] to false in case you do not want to wrap lines. + @override + String encodeText( + final String text, { + Codec codec = MailCodec.encodingUtf8, + bool wrap = true, + }) { + final buffer = StringBuffer(); + final runes = List.from(text.runes); + final runeCount = runes.length; + + var lineCharacterCount = 0; + + for (var i = 0; i < runeCount; i++) { + final rune = runes[i]; + if ((rune >= 32 && rune <= 60) || + (rune >= 62 && rune <= 126) || + rune == 9) { + buffer.writeCharCode(rune); + lineCharacterCount++; + } else { + if (i < runeCount - 1 && + rune == AsciiRunes.runeCarriageReturn && + runes[i + 1] == AsciiRunes.runeLineFeed) { + buffer.write('\r\n'); + i++; + lineCharacterCount = 0; + } else if (rune == AsciiRunes.runeLineFeed) { + buffer.write('\r\n'); + lineCharacterCount = 0; + } else { + //TODO some characters consist of more than a single rune + lineCharacterCount += _writeQuotedPrintable(rune, buffer, codec); + } + } + if (wrap && lineCharacterCount >= MailConventions.textLineMaxLength - 1) { + buffer.write('=\r\n'); // soft line break + lineCharacterCount = 0; + } + } + + return buffer.toString(); + } + + /// Encodes the header text in Q encoding only if required. + /// + /// Compare https://tools.ietf.org/html/rfc2047#section-4.2 for details. + /// [text] specifies the text to be encoded. + /// [nameLength] the length of the header name, for calculating the wrapping + /// point. + /// [codec] the optional codec, which defaults to utf8. + /// Set the optional [fromStart] to true in case the encoding should start + /// at the beginning of the text and not in the middle. + @override + String encodeHeader( + final String text, { + int nameLength = 0, + Codec codec = utf8, + bool fromStart = false, + }) { + final runes = List.from(text.runes, growable: false); + var numberOfRunesAbove7Bit = 0; + var startIndex = -1; + var endIndex = -1; + final runeCount = runes.length; + + for (var runeIndex = 0; runeIndex < runeCount; runeIndex++) { + final rune = runes[runeIndex]; + if (rune > 128) { + numberOfRunesAbove7Bit++; + if (startIndex == -1) { + startIndex = runeIndex; + endIndex = runeIndex; + } else { + endIndex = runeIndex; + } + } + } + if (numberOfRunesAbove7Bit == 0) { + return text; + } else { + // TODO Set the correct encoding + const qpWordHead = '=?utf8?Q?'; + const qpWordTail = '?='; + const qpWordDelimiterSize = qpWordHead.length + qpWordTail.length; + if (fromStart) { + startIndex = 0; + endIndex = text.length - 1; + } + // Available space for the current encoded word + var qpWordSize = MailConventions.encodedWordMaxLength - + qpWordDelimiterSize - + startIndex - + (nameLength + 2); + // Counts the characters of the current encoded word + var wordCounter = 0; + // True when reached the end of the current word available space + var isWordSplit = false; + final buffer = StringBuffer(); + for (var runeIndex = 0; runeIndex < runeCount; runeIndex++) { + final rune = runes[runeIndex]; + if (runeIndex < startIndex || runeIndex > endIndex) { + buffer.writeCharCode(rune); + continue; + } + if (runeIndex == startIndex || isWordSplit) { + // Adds the line terminator + if (isWordSplit) { + buffer + ..write(qpWordTail) + // NOTE Per specification, a CRLF should be inserted here, + // but the folding occurs on the rendering function. + // Here we leave only the WSP marker to separate each q-encode + // word. + // ..writeCharCode(AsciiRunes.runeCarriageReturn) + // ..writeCharCode(AsciiRunes.runeLineFeed) + // Assumes per default a single leading space for header folding + ..writeCharCode(AsciiRunes.runeSpace); + // Resets the split flag + isWordSplit = false; + // Calculates the new encoded word size + qpWordSize = + MailConventions.encodedWordMaxLength - qpWordDelimiterSize - 1; + } + buffer.write(qpWordHead); + } + if ((rune > AsciiRunes.runeSpace && rune <= 60) || + (rune == 62) || + (rune > 63 && rune <= 126 && rune != AsciiRunes.runeUnderline)) { + wordCounter++; + isWordSplit = wordCounter > qpWordSize; + if (!isWordSplit) { + buffer.writeCharCode(rune); + } + } else if (rune == AsciiRunes.runeSpace) { + wordCounter++; + isWordSplit = wordCounter > qpWordSize; + if (!isWordSplit) { + buffer.write('_'); + } + } else { + // _writeQuotedPrintable(rune, buffer, codec); + final quoted = _encodeQuotedPrintableChar(rune, codec); + wordCounter += quoted.length; + isWordSplit = wordCounter > qpWordSize; + if (!isWordSplit) { + buffer.write(quoted); + } + } + if (isWordSplit) { + wordCounter = 0; + runeIndex--; + } + if (runeIndex == endIndex) { + buffer.write(qpWordTail); + } + } + + return buffer.toString(); + } + } + + /// Decodes the specified text + /// + /// [part] the text part that should be decoded + /// [codec] the character encoding (charset) + /// Set [isHeader] to true to decode header text using the Q-Encoding scheme, + /// compare https://tools.ietf.org/html/rfc2047#section-4.2 + @override + String decodeText( + final String part, + final Encoding codec, { + bool isHeader = false, + }) { + final buffer = StringBuffer(); + // remove all soft-breaks: + final cleaned = part.replaceAll('=\r\n', ''); + for (var i = 0; i < cleaned.length; i++) { + final char = cleaned[i]; + if (char == '=') { + final hexText = cleaned.substring(i + 1, i + 3); + var charCode = int.tryParse(hexText, radix: 16); + if (charCode == null) { + print('unable to decode quotedPrintable [$cleaned]: ' + 'invalid hex code [$hexText] at $i.'); + buffer.write(hexText); + } else { + final charCodes = [charCode]; + while (cleaned.length > (i + 4) && cleaned[i + 3] == '=') { + i += 3; + final hexText = cleaned.substring(i + 1, i + 3); + charCode = int.parse(hexText, radix: 16); + charCodes.add(charCode); + } + + try { + final decoded = codec.decode(charCodes); + buffer.write(decoded); + } on FormatException catch (err) { + print('unable to decode quotedPrintable buffer: ${err.message}'); + buffer.write(String.fromCharCodes(charCodes)); + } + } + i += 2; + } else if (isHeader && char == '_') { + buffer.write(' '); + } else { + buffer.write(char); + } + } + + return buffer.toString(); + } + + int _writeQuotedPrintable(int rune, StringBuffer buffer, Codec codec) { + List encoded; + if (rune < 128) { + // this is 7 bit ASCII + encoded = [rune]; + } else { + final runeText = String.fromCharCode(rune); + encoded = codec.encode(runeText); + } + final lengthBefore = buffer.length; + for (final charCode in encoded) { + final paddedHexValue = charCode.toRadixString(16).toUpperCase(); + buffer.write('='); + if (paddedHexValue.length == 1) { + buffer.write('0'); + } + buffer.write(paddedHexValue); + } + + return buffer.length - lengthBefore; + } + + /// Encodes a single rune of a quoted printable word. + /// + /// Uses [_writeQuotedPrintable] internally. + String _encodeQuotedPrintableChar(int rune, Codec codec) { + final buffer = StringBuffer(); + _writeQuotedPrintable(rune, buffer, codec); + + return buffer.toString(); + } + + @override + Uint8List decodeData(String part) => Uint8List.fromList(part.codeUnits); +} diff --git a/packages/enough_mail/lib/src/discover/client_config.dart b/packages/enough_mail/lib/src/discover/client_config.dart new file mode 100644 index 0000000..e62dce8 --- /dev/null +++ b/packages/enough_mail/lib/src/discover/client_config.dart @@ -0,0 +1,418 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'client_config.g.dart'; + +/// Provides discovery information +class ClientConfig { + /// Creates a new client config + ClientConfig({this.version, this.emailProviders}); + + /// The version of this document + String? version; + + /// The list of email providers + List? emailProviders; + + /// Checks if the client configuration is not valid + bool get isNotValid { + final emailProviders = this.emailProviders; + + return emailProviders == null || + emailProviders.isEmpty || + emailProviders.first.preferredIncomingServer == null || + emailProviders.first.preferredOutgoingServer == null; + } + + /// Checks if the client configuration is valid + bool get isValid => !isNotValid; + + /// Adds the specified email [provider] + void addEmailProvider(ConfigEmailProvider provider) { + emailProviders ??= []; + emailProviders?.add(provider); + } + + /// Gets the preferred incoming mail server + ServerConfig? get preferredIncomingServer => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingServer; + + /// The preferred incoming IMAP-compatible mail server + ServerConfig? get preferredIncomingImapServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingImapServer; + set preferredIncomingImapServer(ServerConfig? server) { + emailProviders?.first.preferredIncomingImapServer = server; + } + + /// The preferred incoming POP-compatible mail server + ServerConfig? get preferredIncomingPopServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredIncomingPopServer; + set preferredIncomingPopServer(ServerConfig? server) { + emailProviders?.first.preferredIncomingPopServer = server; + } + + /// The preferred outgoing mail server + ServerConfig? get preferredOutgoingServer => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredOutgoingServer; + set preferredOutgoingServer(ServerConfig? server) { + emailProviders?.first.preferredOutgoingServer = server; + } + + /// The preferred outgoing SMTP-compatible mail server + ServerConfig? get preferredOutgoingSmtpServer => + emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.preferredOutgoingSmtpServer; + set preferredOutgoingSmtpServer(ServerConfig? server) { + emailProviders?.first.preferredOutgoingSmtpServer = server; + } + + /// Retrieves the first display name + String? get displayName => emailProviders?.isEmpty ?? true + ? null + : emailProviders?.first.displayName; +} + +/// Contains configuration settings for a single email service +class ConfigEmailProvider { + /// Creates a new mail provider + ConfigEmailProvider({ + this.id, + this.domains, + this.displayName, + this.displayShortName, + this.incomingServers, + this.outgoingServers, + }) { + preferredIncomingServer = + (incomingServers?.isEmpty ?? true) ? null : incomingServers?.first; + preferredOutgoingServer = + (outgoingServers?.isEmpty ?? true) ? null : outgoingServers?.first; + } + + /// ID of the provider + String? id; + + /// Domains associated with the provider + List? domains; + + /// The name used for display purposes + String? displayName; + + /// The short name + String? displayShortName; + + /// All incoming servers + List? incomingServers; + + /// All outgoing servers + List? outgoingServers; + + /// The URL for further documentation + String? documentationUrl; + + /// The preferred incoming server + ServerConfig? preferredIncomingServer; + + /// The preferred incoming IMAP server + ServerConfig? preferredIncomingImapServer; + + /// The preferred incoming POP server + ServerConfig? preferredIncomingPopServer; + + /// The preferred outgoing server + ServerConfig? preferredOutgoingServer; + + /// The preferred outgoing SMTP server + ServerConfig? preferredOutgoingSmtpServer; + + /// Adds the domain with the [name] to the list of associated domains + void addDomain(String name) { + domains ??= []; + domains?.add(name); + } + + /// Adds the incoming [server]. + void addIncomingServer(ServerConfig server) { + incomingServers ??= []; + incomingServers?.add(server); + preferredIncomingServer ??= server; + if (server.type == ServerType.imap && preferredIncomingImapServer == null) { + preferredIncomingImapServer = server; + } + if (server.type == ServerType.pop && preferredIncomingPopServer == null) { + preferredIncomingPopServer = server; + } + } + + /// Adds the outgoing [server]. + void addOutgoingServer(ServerConfig server) { + outgoingServers ??= []; + outgoingServers?.add(server); + preferredOutgoingServer ??= server; + if (server.type == ServerType.smtp && preferredOutgoingSmtpServer == null) { + preferredOutgoingSmtpServer = server; + } + } +} + +/// The type of the server +enum ServerType { + /// IMAP compatible incoming server + imap, + + /// POP3 compatible incoming server + pop, + + /// SMTP compatible outgoing server + smtp, + + /// Unknown server type + unknown, +} + +/// The socket type +enum SocketType { + /// No encryption. + /// + /// Typically this is switched to SSL using start TLS before authentication. + plain, + + /// Secured connection + ssl, + + /// No encryption for the first connection, then switch to SSL using start TLS + starttls, + + /// Unknown encryption status + unknown, + + /// No encryption is used, even not for authentication. + plainNoStartTls, +} + +/// The type of authentication +enum Authentication { + /// OAuth 2 authentication + oauth2, + + /// same as plain + passwordClearText, + + /// plain text authentication + plain, + + /// The password is encrypted before transmission + passwordEncrypted, + + /// The password is secured before transmission + secure, + + /// Family of authentication protocols + // cSpell: disable-next-line + ntlm, + + /// Generic Security Services Application Program Interface + // cSpell: disable-next-line + gsapi, + + /// The IP address of the client is used (very insecure) + clientIpAddress, + + /// A client certificate is used + tlsClientCert, + + /// SMTP can be used after authenticating via POP3 + smtpAfterPop, + + /// No authentication is used + none, + + /// The authentication is not known in advance + unknown, +} + +/// The user name configuration +enum UsernameType { + /// Full email address is used + emailAddress, + + /// The start of the email address until the `@` is used + emailLocalPart, + + /// The real name of the user + realName, + + /// Unknown user name configuration + unknown, +} + +/// The configuration for a single server +@JsonSerializable() +class ServerConfig { + /// Creates a new server configuration + const ServerConfig({ + required this.type, + required this.hostname, + required this.port, + required this.socketType, + required this.authentication, + required this.usernameType, + this.authenticationAlternative, + }); + + /// Creates a new server configuration with the default values + const ServerConfig.empty() + : type = ServerType.unknown, + hostname = '', + port = 0, + socketType = SocketType.unknown, + authentication = Authentication.unknown, + usernameType = UsernameType.unknown, + authenticationAlternative = null; + + /// Creates a new [ServerConfig] from the given [json] + factory ServerConfig.fromJson(Map json) => + _$ServerConfigFromJson(json); + + /// Generates json from this [ServerConfig] + Map toJson() => _$ServerConfigToJson(this); + + /// The name of the server type + @JsonKey(includeFromJson: false, includeToJson: false) + String get typeName => type.toString().substring('serverType.'.length); + + /// The server type + final ServerType type; + + /// The host + final String hostname; + + /// The port + final int port; + + /// The connection security + final SocketType socketType; + + /// The name of the connection security + @JsonKey(includeFromJson: false, includeToJson: false) + String get socketTypeName => + socketType.toString().substring('socketType.'.length); + + /// The used main authentication mechanism + final Authentication authentication; + + /// The used secondary authentication mechanism + final Authentication? authenticationAlternative; + + /// The name of the main authentication + @JsonKey(includeFromJson: false, includeToJson: false) + String get authenticationName => + authentication.toString().substring('authentication.'.length); + + /// The name of the secondary authentication + @JsonKey(includeFromJson: false, includeToJson: false) + String? get authenticationAlternativeName => + authenticationAlternative?.toString().substring('authentication.'.length); + + /// The name of the username configuration + @JsonKey(includeFromJson: false, includeToJson: false) + String get username => _usernameTypeToText(usernameType); + + /// The username configuration + final UsernameType usernameType; + + /// Retrieves true when this server uses a secure connection + bool get isSecureSocket => socketType == SocketType.ssl; + + @override + String toString() => '$typeName:\n host: $hostname\n port: $port\n socket: ' + '$socketTypeName\n authentication: $authenticationName\n' + 'username: $username'; + + /// Retrieves the user name based on the specified [email] address. + /// Returns `null` in case usernameType is + /// [UsernameType.realName] or [UsernameType.unknown]. + String? getUserName(String email) { + switch (usernameType) { + case UsernameType.emailAddress: + return email; + case UsernameType.emailLocalPart: + final lastAtIndex = email.lastIndexOf('@'); + if (lastAtIndex == -1) { + return email; + } + return email.substring(lastAtIndex + 1); + case UsernameType.realName: + case UsernameType.unknown: + default: + return null; + } + } + + @override + bool operator ==(Object other) => + other is ServerConfig && + other.type == type && + other.hostname == hostname && + other.port == port && + other.usernameType == usernameType && + other.socketType == socketType && + other.authentication == authentication && + other.authenticationAlternative == authenticationAlternative; + + @override + int get hashCode => + type.hashCode | + hostname.hashCode | + port | + usernameType.hashCode | + socketType.hashCode | + authentication.hashCode | + (authenticationAlternative?.hashCode ?? 0); + + /// Creates a copy of this [ServerConfig] with the specified values + ServerConfig copyWith({ + ServerType? type, + String? hostname, + int? port, + SocketType? socketType, + Authentication? authentication, + Authentication? authenticationAlternative, + UsernameType? usernameType, + }) => + ServerConfig( + type: type ?? this.type, + hostname: hostname ?? this.hostname, + port: port ?? this.port, + socketType: socketType ?? this.socketType, + authentication: authentication ?? this.authentication, + authenticationAlternative: + authenticationAlternative ?? this.authenticationAlternative, + usernameType: usernameType ?? this.usernameType, + ); + + static String _usernameTypeToText(UsernameType? type) { + String text; + switch (type) { + case UsernameType.emailAddress: + text = '%EMAILADDRESS%'; + break; + case UsernameType.emailLocalPart: + text = '%EMAILLOCALPART%'; + break; + case UsernameType.realName: + text = '%REALNAME%'; + break; + default: + text = 'UNKNOWN'; + } + + return text; + } +} diff --git a/packages/enough_mail/lib/src/discover/discover.dart b/packages/enough_mail/lib/src/discover/discover.dart new file mode 100644 index 0000000..9669d7b --- /dev/null +++ b/packages/enough_mail/lib/src/discover/discover.dart @@ -0,0 +1,197 @@ +import '../mail/mail_account.dart'; +import '../private/util/discover_helper.dart'; +import 'client_config.dart'; + +/// Helps discovering email connection settings based on an email address. +/// +/// Use [discover] to initiate the discovery process. +class Discover { + Discover._(); + + /// Tries to discover mail settings for the specified [emailAddress]. + /// + /// Optionally set [forceSslConnection] to `true` when not encrypted + /// connections should not be allowed. + /// + /// Set [isLogEnabled] to `true` to output debugging information during + /// the discovery process. + /// + /// You can use the discovered client settings directly or by converting + /// them to a [MailAccount] first with calling + /// [MailAccount.fromDiscoveredSettings]. + static Future discover( + String emailAddress, { + bool forceSslConnection = false, + bool isLogEnabled = false, + }) async { + final config = await _discover(emailAddress, isLogEnabled); + if (forceSslConnection && config != null) { + final preferredIncomingImapServer = config.preferredIncomingImapServer; + if (preferredIncomingImapServer != null && + !preferredIncomingImapServer.isSecureSocket) { + config.preferredIncomingImapServer = + preferredIncomingImapServer.copyWith( + port: 993, + socketType: SocketType.ssl, + ); + } + final preferredIncomingPopServer = config.preferredIncomingPopServer; + if (preferredIncomingPopServer != null && + !preferredIncomingPopServer.isSecureSocket) { + config.preferredIncomingPopServer = preferredIncomingPopServer.copyWith( + port: 995, + socketType: SocketType.ssl, + ); + } + final preferredOutgoingSmtpServer = config.preferredOutgoingSmtpServer; + if (preferredOutgoingSmtpServer != null && + !preferredOutgoingSmtpServer.isSecureSocket) { + config.preferredOutgoingSmtpServer = + preferredOutgoingSmtpServer.copyWith( + port: 465, + socketType: SocketType.ssl, + ); + } + } + + return config; + } + + /// Tries to complete the specified [partialAccount] information. + /// + /// This is useful when mail configuration settings cannot be discovered + /// automatically and the user + /// only provides some information such as the host domains of the incoming + /// and outgoing servers. + /// Warning: this method assumes that the host domain has been specified by + /// the user and contains a corresponding assert statement. + static Future complete( + MailAccount partialAccount, { + bool isLogEnabled = false, + }) async { + final incoming = partialAccount.incoming.serverConfig; + assert( + partialAccount.email.isNotEmpty, 'MailAccount requires email address'); + assert(incoming.hostname.isNotEmpty, + 'MailAccount required incoming server host to be specified'); + final outgoing = partialAccount.outgoing.serverConfig; + assert(outgoing.hostname.isNotEmpty, + 'MailAccount required outgoing server host to be specified'); + final infos = []; + if (incoming.port == 0 || + incoming.socketType == SocketType.unknown || + incoming.type == ServerType.unknown) { + DiscoverHelper.addIncomingVariations(incoming.hostname, infos); + } + if (outgoing.port == 0 || + outgoing.socketType == SocketType.unknown || + outgoing.type == ServerType.unknown) { + DiscoverHelper.addOutgoingVariations(outgoing.hostname, infos); + } + if (infos.isNotEmpty) { + final baseDomain = + DiscoverHelper.getDomainFromEmail(partialAccount.email); + final clientConfig = await DiscoverHelper.discoverFromConnections( + baseDomain, + infos, + isLogEnabled: isLogEnabled, + ); + if (clientConfig == null) { + _log( + 'Unable to discover remaining settings from $partialAccount', + isLogEnabled, + ); + + return null; + } + + return partialAccount.copyWith( + incoming: partialAccount.incoming.copyWith( + serverConfig: clientConfig.preferredIncomingServer, + ), + outgoing: partialAccount.outgoing.copyWith( + serverConfig: clientConfig.preferredOutgoingServer, + ), + ); + } + + return null; + } + + static Future _discover( + String emailAddress, + bool isLogEnabled, + ) async { + // [1] auto-discover from sub-domain, + // compare: https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration + final emailDomain = DiscoverHelper.getDomainFromEmail(emailAddress); + var config = await DiscoverHelper.discoverFromAutoConfigSubdomain( + emailAddress, + domain: emailDomain, + isLogEnabled: isLogEnabled, + ); + if (config == null) { + final mxDomain = await DiscoverHelper.discoverMxDomain(emailDomain); + _log('mxDomain for [$emailDomain] is [$mxDomain]', isLogEnabled); + if (mxDomain != null && mxDomain != emailDomain) { + config = await DiscoverHelper.discoverFromAutoConfigSubdomain( + emailAddress, + domain: mxDomain, + isLogEnabled: isLogEnabled, + ); + } + //print('querying ISP DB for $mxDomain'); + + // [5] auto-discover from Mozilla ISP DB: + // https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration + final hasMxDomain = mxDomain != null && mxDomain != emailDomain; + config ??= await DiscoverHelper.discoverFromIspDb( + emailDomain, + isLogEnabled: isLogEnabled, + ); + if (hasMxDomain) { + config ??= await DiscoverHelper.discoverFromIspDb( + mxDomain, + isLogEnabled: isLogEnabled, + ); + } + + // try to guess incoming and outgoing server names based on the domain + final domains = hasMxDomain ? [emailDomain, mxDomain] : [emailDomain]; + config ??= await DiscoverHelper.discoverFromCommonDomains( + domains, + isLogEnabled: isLogEnabled, + ); + } + //print('got config $config for $mxDomain.'); + + return _updateDisplayNames(config, emailDomain); + } + + static ClientConfig? _updateDisplayNames( + ClientConfig? config, + String mailDomain, + ) { + final emailProviders = config?.emailProviders; + if (emailProviders != null && emailProviders.isNotEmpty) { + for (final provider in emailProviders) { + if (provider.displayName != null) { + provider.displayName = + provider.displayName?.replaceFirst('%EMAILDOMAIN%', mailDomain); + } + if (provider.displayShortName != null) { + provider.displayShortName = provider.displayShortName + ?.replaceFirst('%EMAILDOMAIN%', mailDomain); + } + } + } + + return config; + } + + static void _log(String text, bool isLogEnabled) { + if (isLogEnabled) { + print(text); + } + } +} diff --git a/packages/enough_mail/lib/src/exception.dart b/packages/enough_mail/lib/src/exception.dart new file mode 100644 index 0000000..8140ace --- /dev/null +++ b/packages/enough_mail/lib/src/exception.dart @@ -0,0 +1,17 @@ +/// Base exception for any IMAP, POP, SMTP or highlevel API exceptions +class BaseMailException implements Exception { + /// Creates a new exception + const BaseMailException(this.message); + + /// The error message + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// Notifies about an invalid argument +class InvalidArgumentException extends BaseMailException { + /// Creates a new invalid argument exception + InvalidArgumentException(super.message); +} diff --git a/packages/enough_mail/lib/src/imap/extended_data.dart b/packages/enough_mail/lib/src/imap/extended_data.dart new file mode 100644 index 0000000..2a8a540 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/extended_data.dart @@ -0,0 +1,5 @@ +/// Extended data results for LIST commands. +class ExtendedData { + /// Child information as result of "RECURSIVEMATCH" extended selection option + static const String childinfo = 'CHILDINFO'; +} diff --git a/packages/enough_mail/lib/src/imap/id.dart b/packages/enough_mail/lib/src/imap/id.dart new file mode 100644 index 0000000..a9a6f18 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/id.dart @@ -0,0 +1,170 @@ +import '../../codecs.dart'; +import '../private/imap/parser_helper.dart'; + +/// Contains classes to support [RFC 2971](https://datatracker.ietf.org/doc/html/rfc2971) + +class Id { + /// Creates a new ID + const Id({ + this.name, + this.version, + this.os, + this.osVersion, + this.vendor, + this.supportUrl, + this.address, + this.date, + this.command, + this.arguments, + this.environment, + this.nonStandardFields = const {}, + }); + + /// Name of the program + final String? name; + + /// Version number of the program + final String? version; + + /// Name of the operating system + final String? os; + + /// Version of the operating system + final String? osVersion; + + /// Vendor of the client/server + final String? vendor; + + /// URL to contact for support + final String? supportUrl; + + /// Postal address of contact/vendor + final String? address; + + /// Date program was released, specified as a date-time in IMAP4rev1 + final DateTime? date; + + /// Command used to start the program + final String? command; + + /// Arguments supplied on the command line, if any + final String? arguments; + + /// Description of environment, + /// i.e., UNIX environment variables or Windows registry settings + final String? environment; + + /// Any other, non-standard properties + final Map nonStandardFields; + + /// Checks if this ID is empty ie it contains no values + bool get isEmpty => + name == null && + version == null && + os == null && + osVersion == null && + vendor == null && + supportUrl == null && + address == null && + date == null && + command == null && + arguments == null && + environment == null && + nonStandardFields.isEmpty; + + static const _standardFieldNames = [ + 'name', + 'version', + 'os', + 'os-version', + 'vendor', + 'support-url', + 'address', + 'date', + 'command', + 'arguments', + 'environment', + ]; + + /// Creates an ID from the given [text] + static Id? fromText(String text) { + if (text == 'NIL' || !text.startsWith('(')) { + return null; + } + final entries = ParserHelper.parseListEntries(text, 1, ')', ' ') ?? []; + final map = {}; + for (var i = 0; i < entries.length - 1; i += 2) { + final name = _stripQuotes(entries[i]).toLowerCase(); + final value = _stripQuotes(entries[i + 1]); + map[name] = value; + } + + return Id( + name: map.remove('name'), + version: map.remove('version'), + os: map.remove('os'), + osVersion: map.remove('os-version'), + vendor: map.remove('vendor'), + supportUrl: map.remove('support-url'), + address: map.remove('address'), + date: _parseDate(map.remove('date')), + command: map.remove('command'), + arguments: map.remove('arguments'), + environment: map.remove('environment'), + nonStandardFields: map, + ); + } + + static String _stripQuotes(String input) { + if (input.startsWith('"')) { + return input.substring(1, input.length - 1); + } + + return input; + } + + static DateTime? _parseDate(String? input) => DateCodec.decodeDate(input); + + @override + String toString() { + if (isEmpty) { + return 'NIL'; + } + final standardValues = [ + name, + version, + os, + osVersion, + vendor, + supportUrl, + address, + date, + command, + arguments, + environment, + ]; + final buffer = StringBuffer()..write('('); + var addSpace = false; + for (var i = 0; i < standardValues.length; i++) { + final value = standardValues[i]; + if (value != null) { + if (addSpace) { + buffer.write(' '); + } else { + addSpace = true; + } + final name = _standardFieldNames[i]; + buffer + ..write('"') + ..write(name) + ..write('" ') + ..write('"') + ..write(value) + ..write('"'); + } + } + buffer.write(')'); + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/imap/imap_client.dart b/packages/enough_mail/lib/src/imap/imap_client.dart new file mode 100644 index 0000000..7c6b36b --- /dev/null +++ b/packages/enough_mail/lib/src/imap/imap_client.dart @@ -0,0 +1,2791 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:event_bus/event_bus.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../codecs/date_codec.dart'; +import '../exception.dart'; +import '../message_flags.dart'; +import '../mime_message.dart'; +import '../private/imap/all_parsers.dart'; +import '../private/imap/capability_parser.dart'; +import '../private/imap/command.dart'; +import '../private/imap/imap_response.dart'; +import '../private/imap/imap_response_reader.dart'; +import '../private/imap/response_parser.dart'; +import '../private/util/client_base.dart'; +import 'id.dart'; +import 'imap_events.dart'; +import 'imap_exception.dart'; +import 'imap_search.dart'; +import 'mailbox.dart'; +import 'message_sequence.dart'; +import 'metadata.dart'; +import 'qresync.dart'; +import 'response.dart'; +import 'return_option.dart'; + +part 'imap_client.g.dart'; + +/// Describes a capability +@JsonSerializable() +class Capability { + /// Creates a new capability with the given [name] + const Capability(this.name); + + /// Creates a new [Capability] from the given [json] + factory Capability.fromJson(Map json) => + _$CapabilityFromJson(json); + + /// Generates JSON from this [Capability] + Map toJson() => _$CapabilityToJson(this); + + /// The name of the capability + final String name; + + @override + String toString() => name; + + @override + bool operator ==(Object other) => other is Capability && other.name == name; + + @override + int get hashCode => name.hashCode; +} + +/// Keeps information about the remote IMAP server +/// +/// Persist this information to improve initialization times. +class ImapServerInfo { + /// Creates a new server info instance + ImapServerInfo(ConnectionInfo info) + : host = info.host, + port = info.port, + isSecure = info.isSecure; + + /// [ID](https://tools.ietf.org/html/rfc2971) capability with the value `ID` + static const String capabilityId = 'ID'; + + /// [IDLE](https://tools.ietf.org/html/rfc2177) capability with the value `IDLE` + static const String capabilityIdle = 'IDLE'; + + /// [MOVE](https://tools.ietf.org/html/rfc6851) capability with the value `MOVE` + static const String capabilityMove = 'MOVE'; + + /// capability with the value `QRESYNC` + static const String capabilityQresync = 'QRESYNC'; + + /// [UID PLUS](https://tools.ietf.org/html/rfc2359) capability with the value `UIDPLUS` + static const String capabilityUidPlus = 'UIDPLUS'; + + /// [UTF-8](https://tools.ietf.org/html/rfc6855) capability with the value `UTF8=ACCEPT` + static const String capabilityUtf8Accept = 'UTF8=ACCEPT'; + + /// [UTF-8](https://tools.ietf.org/html/rfc6855) capability with the value `UTF8=ONLY` + static const String capabilityUtf8Only = 'UTF8=ONLY'; + + /// [THREAD](https://tools.ietf.org/html/rfc5256) capability with the value `THREAD=ORDEREDSUBJECT` + static const String capabilityThreadOrderedSubject = 'THREAD=ORDEREDSUBJECT'; + + /// [THREAD](https://tools.ietf.org/html/rfc5256) capability with the value `THREAD=REFERENCES` + static const String capabilityThreadReferences = 'THREAD=REFERENCES'; + + /// [STARTTLS](https://tools.ietf.org/html/rfc2595) capability with the value `STARTTLS` + static const String capabilityStartTls = 'STARTTLS'; + + /// The used host of the service + final String host; + + /// `true` when a secure connection is used + final bool isSecure; + + /// The port of the server + final int port; + + /// The separator for paths, only set after listing the mailboxes + String? pathSeparator; + + /// The known capabilities as text + String? capabilitiesText; + + /// The known capabilities + List? capabilities; + + /// The enabled capabilities + final List enabledCapabilities = []; + + /// Checks if the capability with the specified [capabilityName] is supported. + bool supports(String capabilityName) => + capabilities?.firstWhereOrNull((c) => c.name == capabilityName) != null; + + /// Does the server support [STARTTLS](https://tools.ietf.org/html/rfc2595)? + bool get supportsStartTls => supports(capabilityStartTls); + + /// Does the server support [UID PLUS](https://tools.ietf.org/html/rfc2359)? + bool get supportsUidPlus => supports(capabilityUidPlus); + + /// Does the server support [IDLE](https://tools.ietf.org/html/rfc2177)? + bool get supportsIdle => supports(capabilityIdle); + + /// Does the server support [MOVE](https://tools.ietf.org/html/rfc6851)? + bool get supportsMove => supports(capabilityMove); + + /// Does the server support [QRESYNC](https://tools.ietf.org/html/rfc5162)? + bool get supportsQresync => supports(capabilityQresync); + + /// Does the server support [UTF-8](https://tools.ietf.org/html/rfc6855)? + bool get supportsUtf8 => + supports(capabilityUtf8Accept) || supports(capabilityUtf8Only); + + /// Does the server support [ID](https://tools.ietf.org/html/rfc2971)? + bool get supportsId => supports(capabilityId); + + /// Does the server support [THREAD](https://tools.ietf.org/html/rfc5256)? + bool get supportsThreading => + supports(capabilityThreadOrderedSubject) || + supports(capabilityThreadReferences); + List? _supportedThreadingMethods; + + /// Retrieves the supported threading methods, e.g. `[]`, + /// `['ORDEREDSUBJECT']` or `['ORDEREDSUBJECT', 'REFERENCES']` + List get supportedThreadingMethods { + var methods = _supportedThreadingMethods; + if (methods == null) { + methods = []; + _supportedThreadingMethods = methods; + final caps = capabilities; + if (caps != null) { + for (final cap in caps) { + if (cap.name.startsWith('THREAD=')) { + methods.add(cap.name.substring('THREAD='.length)); + } + } + } + } + + return methods; + } + + /// Checks if the capability with the specified [capabilityName] + /// has been enabled. + bool isEnabled(String capabilityName) => + enabledCapabilities.firstWhereOrNull((c) => c.name == capabilityName) != + null; +} + +/// Possible flag store actions +enum StoreAction { + /// Add the specified flag(s) + add, + + /// Remove the specified flag(s) + remove, + + /// Replace the flags of the message with the specified ones. + replace, +} + +/// Options for querying status updates +enum StatusFlags { + /// The number of messages in the mailbox. + messages, + + /// The number of messages with the \Recent flag set. + recent, + + /// The next unique identifier value of the mailbox. + uidNext, + + /// The unique identifier validity value of the mailbox. + uidValidity, + + /// The number of messages which do not have the \Seen flag set. + unseen, + + /// The highest mod-sequence value of all messages in the mailbox. + /// + /// Only available when the CONDSTORE or QRESYNC capability is supported. + highestModSequence, +} + +/// Low-level IMAP library. +/// +/// Compliant to IMAP4rev1 standard [RFC 3501](https://tools.ietf.org/html/rfc3501). +/// Also compare recommendations at [RFC 2683](https://tools.ietf.org/html/rfc2683) +class ImapClient extends ClientBase { + /// Creates a new ImapClient instance. + /// + /// Set the [bus] to add your specific `EventBus` to listen to + /// IMAP events. + /// + /// Set [isLogEnabled] to `true` for getting log outputs on the standard + /// output. + /// + /// Optionally specify a [logName] that is given out at logs to differentiate + /// between different imap clients. + /// + /// Set the [defaultWriteTimeout] in case the connection connection should + /// timeout automatically after the given time. + /// + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return `true` to continue the [SecureSocket] + /// connection. + ImapClient({ + EventBus? bus, + bool isLogEnabled = false, + String? logName, + this.defaultWriteTimeout, + this.defaultResponseTimeout, + bool Function(X509Certificate)? onBadCertificate, + }) : _eventBus = bus ?? EventBus(), + super( + isLogEnabled: isLogEnabled, + logName: logName, + onBadCertificate: onBadCertificate, + ) { + _imapResponseReader = ImapResponseReader(onServerResponse); + } + + late ImapServerInfo _serverInfo; + + /// Information about the IMAP service + ImapServerInfo get serverInfo => _serverInfo; + + /// Allows to listens for events + /// + /// If no event bus is specified in the constructor, + /// an asynchronous 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 get eventBus => _eventBus; + final EventBus _eventBus; + + int _lastUsedCommandId = 0; + CommandTask? _currentCommandTask; + final Map _tasks = {}; + Mailbox? _selectedMailbox; + late ImapResponseReader _imapResponseReader; + + bool _isInIdleMode = false; + CommandTask? _idleCommandTask; + final _queue = []; + List? _stashedQueue; + + /// The default timeout for getting a response + final Duration? defaultResponseTimeout; + + /// The default timeout for sending a command + final Duration? defaultWriteTimeout; + + @override + void onDataReceived(Uint8List data) { + _imapResponseReader.onData(data); + } + + @override + FutureOr onConnectionEstablished( + ConnectionInfo connectionInfo, + String serverGreeting, + ) async { + _isInIdleMode = false; + _serverInfo = ImapServerInfo(connectionInfo); + final startIndex = serverGreeting.indexOf('[CAPABILITY '); + if (startIndex != -1) { + CapabilityParser.parseCapabilities( + serverGreeting, + startIndex + '[CAPABILITY '.length, + _serverInfo, + ); + } + if (_queue.isNotEmpty) { + // this can happen when a connection was re-established, + // e.g. when trying to complete an IDLE connection + for (final task in _queue) { + try { + task.completer.completeError('reconnect'); + } catch (e, s) { + print('unable to completeError for task $task $e $s'); + } + } + _queue.clear(); + } + } + + @override + void onConnectionError(dynamic error) { + logApp('onConnectionError: $error'); + _isInIdleMode = false; + _selectedMailbox = null; + eventBus.fire(ImapConnectionLostEvent(this)); + } + + /// Logs in the user with the given [name] and [password]. + /// + /// Requires the IMAP service to support `AUTH=PLAIN` capability. + Future> login(String name, String password) async { + final cmd = Command( + 'LOGIN "$name" "$password"', + logText: 'LOGIN "$name" "(password scrambled)"', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = CapabilityParser(serverInfo); + final response = await sendCommand>(cmd, parser); + isLoggedIn = true; + + return response; + } + + /// Logs in the user with the given [user] and [accessToken] via Oauth 2.0. + /// + /// Note that the capability 'AUTH=XOAUTH2' needs to be present. + Future> authenticateWithOAuth2( + String user, + String accessToken, + ) async { + final authText = + 'user=$user\u{0001}auth=Bearer $accessToken\u{0001}\u{0001}'; + final authBase64Text = base64.encode(utf8.encode(authText)); + // the empty client response to a challenge yields the actual server + // error message response + final cmd = Command.withContinuation( + ['AUTHENTICATE XOAUTH2 $authBase64Text', ''], + logText: 'AUTHENTICATE XOAUTH2 (base64 code scrambled)', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final response = + await sendCommand>(cmd, CapabilityParser(serverInfo)); + isLoggedIn = true; + + return response; + } + + /// Logs in the user with the given [user] and [accessToken] + /// via Oauth Bearer mechanism. + /// + /// Optionally specify the [host] and [port] of the service, per default the + /// current connection is used. + /// Note that the capability 'AUTH=OAUTHBEARER' needs to be present. + /// Compare https://tools.ietf.org/html/rfc7628 for details + Future> authenticateWithOAuthBearer( + String user, + String accessToken, { + String? host, + int? port, + }) async { + host ??= serverInfo.host; + port ??= serverInfo.port; + final authText = 'n,u=$user,\u{0001}' + 'host=$host\u{0001}' + 'port=$port\u{0001}' + 'auth=Bearer $accessToken\u{0001}\u{0001}'; + final authBase64Text = base64.encode(utf8.encode(authText)); + final cmd = Command( + 'AUTHENTICATE OAUTHBEARER $authBase64Text', + logText: 'AUTHENTICATE OAUTHBEARER (base64 code scrambled)', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final response = + await sendCommand>(cmd, CapabilityParser(serverInfo)); + isLoggedIn = true; + + return response; + } + + /// Logs the current user out. + Future logout() async { + final cmd = Command( + 'LOGOUT', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final response = await sendCommand(cmd, LogoutParser()); + isLoggedIn = false; + _isInIdleMode = false; + + return response; + } + + /// 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 { + final cmd = Command( + 'STARTTLS', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final response = await sendCommand( + cmd, + GenericParser(this, _selectedMailbox), + ); + log('STARTTLS: upgrading socket to secure one...', initial: 'A'); + await upgradeToSslSocket(); + + return response; + } + + /// Reports the optional [clientId] to the server and returns the server ID. + /// + /// This requires the server to the support the + /// [IMAP4 ID extension](https://datatracker.ietf.org/doc/html/rfc2971). + /// Check [ImapServerInfo.supportsId] to see if the ID extension is supported. + Future id({Id? clientId}) { + final cmd = Command( + 'ID ${clientId ?? 'NIL'}', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + + return sendCommand(cmd, IdParser()); + } + + /// Checks the capabilities of this server directly + Future> capability() { + final cmd = Command( + 'CAPABILITY', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = CapabilityParser(serverInfo); + + return sendCommand>(cmd, parser); + } + + /// Copies the specified message(s) from the specified [sequence] + /// from the currently selected mailbox to the target mailbox. + /// + /// You can either specify the [targetMailbox] or the [targetMailboxPath], + /// if none is given, the messages will be copied to the currently + /// selected mailbox. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare [uidCopy] for the copying files based on their sequence IDs + Future copy( + MessageSequence sequence, { + Mailbox? targetMailbox, + String? targetMailboxPath, + }) => + _copyOrMove( + 'COPY', + sequence, + targetMailbox: targetMailbox, + targetMailboxPath: targetMailboxPath, + ); + + /// Copies the specified message(s) from the specified [sequence] + /// from the currently selected mailbox to the target mailbox. + /// + /// You can either specify the [targetMailbox] or the [targetMailboxPath], + /// if none is given, the messages will be copied to the currently + /// selected mailbox. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare [copy] for the version with message sequence IDs + Future uidCopy( + MessageSequence sequence, { + Mailbox? targetMailbox, + String? targetMailboxPath, + }) => + _copyOrMove( + 'UID COPY', + sequence, + targetMailbox: targetMailbox, + targetMailboxPath: targetMailboxPath, + ); + + /// Moves the specified message(s) from the specified [sequence] + /// from the currently selected mailbox to the target mailbox. + /// + /// You must either specify the [targetMailbox] or the [targetMailboxPath], + /// if none is given, move will fail. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare [uidMove] for moving messages based on their UID + /// The move command is only available for servers that advertise the + /// `MOVE` capability. + Future move( + MessageSequence sequence, { + Mailbox? targetMailbox, + String? targetMailboxPath, + }) { + if (targetMailbox == null && targetMailboxPath == null) { + throw InvalidArgumentException( + 'move() error: Neither targetMailbox nor targetMailboxPath defined.', + ); + } + + return _copyOrMove( + 'MOVE', + sequence, + targetMailbox: targetMailbox, + targetMailboxPath: targetMailboxPath, + ); + } + + /// Copies the specified message(s) from the specified [sequence] + /// from the currently selected mailbox to the target mailbox. + /// + /// You must either specify the [targetMailbox] or the [targetMailboxPath], + /// if none is given, move will fail. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare [copy] for the version with message sequence IDs + Future uidMove( + MessageSequence sequence, { + Mailbox? targetMailbox, + String? targetMailboxPath, + }) { + if (targetMailbox == null && targetMailboxPath == null) { + throw InvalidArgumentException('uidMove() error: Neither targetMailbox ' + 'nor targetMailboxPath defined.'); + } + + return _copyOrMove( + 'UID MOVE', + sequence, + targetMailbox: targetMailbox, + targetMailboxPath: targetMailboxPath, + ); + } + + /// Implementation for both COPY or MOVE + Future _copyOrMove( + String command, + MessageSequence sequence, { + Mailbox? targetMailbox, + String? targetMailboxPath, + }) { + final selectedMailbox = _selectedMailbox; + if (selectedMailbox == null) { + throw InvalidArgumentException('No mailbox selected.'); + } + final buffer = StringBuffer() + ..write(command) + ..write(' '); + sequence.render(buffer); + final path = _encodeFirstMailboxPath( + targetMailbox, + targetMailboxPath, + selectedMailbox, + ); + buffer + ..write(' ') + ..write(path); + final cmd = Command( + buffer.toString(), writeTimeout: defaultWriteTimeout, + // Use response timeout here? This could be a long running operation... + ); + + return sendCommand( + cmd, + GenericParser(this, selectedMailbox), + ); + } + + /// Updates the [flags] of the message(s) from the specified [sequence] + /// in the currently selected mailbox. + /// + /// Set [silent] to true, if the updated flags should not be returned. + /// Specify if flags should be replaced, added or removed with the [action] + /// parameter, this defaults to adding flags. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the `CONDSTORE` or + /// `QRESYNC` capability + /// When there are modified elements that have not passed the + /// [unchangedSinceModSequence] test, then the `modifiedMessageSequence` + /// field of the contains the sequence of messages that have NOT been + /// updated by this store command. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare the methods [markSeen], [markFlagged], etc for typical store + /// operations. + Future store( + MessageSequence sequence, + List flags, { + StoreAction? action, + bool? silent, + int? unchangedSinceModSequence, + }) => + _store( + false, + 'STORE', + sequence, + flags, + action: action, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Updates the [flags] of the message(s) from the specified [sequence] + /// in the currently selected mailbox. + /// + /// Set [silent] to true, if the updated flags should not be returned. + /// Specify if flags should be replaced, added or removed with the [action] + /// parameter, this defaults to adding flags. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the `CONDSTORE` or + /// `QRESYNC` capability + /// When there are modified elements that have not passed the + /// [unchangedSinceModSequence] test, then the `modifiedMessageSequence` + /// field of the contains the sequence of messages that have NOT been + /// updated by this store command. + /// Compare [selectMailbox], [selectMailboxByPath] or [selectInbox] for + /// selecting a mailbox first. + /// Compare the methods [uidMarkSeen], [uidMarkFlagged], etc for typical + /// store operations. + Future uidStore( + MessageSequence sequence, + List flags, { + StoreAction? action, + bool? silent, + int? unchangedSinceModSequence, + }) => + _store( + true, + 'UID STORE', + sequence, + flags, + action: action, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// STORE and UID STORE implementation + Future _store( + bool isUidStore, + String command, + MessageSequence sequence, + List flags, { + StoreAction? action, + bool? silent, + int? unchangedSinceModSequence, + }) async { + if (_selectedMailbox == null) { + throw InvalidArgumentException('No mailbox selected.'); + } + action ??= StoreAction.add; + silent ??= false; + final buffer = StringBuffer() + ..write(command) + ..write(' '); + if (unchangedSinceModSequence != null) { + buffer + ..write('(UNCHANGEDSINCE ') + ..write(unchangedSinceModSequence) + ..write(') '); + } + sequence.render(buffer); + switch (action) { + case StoreAction.add: + buffer.write(' +FLAGS'); + break; + case StoreAction.remove: + buffer.write(' -FLAGS'); + break; + default: + buffer.write(' FLAGS'); + } + if (silent) { + buffer.write('.SILENT'); + } + buffer.write(' ('); + var addSpace = false; + for (final flag in flags) { + if (addSpace) { + buffer.write(' '); + } + buffer.write(flag); + addSpace = true; + } + buffer.write(')'); + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = FetchParser(isUidFetch: isUidStore); + final messagesResponse = await sendCommand(cmd, parser); + final result = StoreImapResult() + ..changedMessages = messagesResponse.messages + ..modifiedMessageSequence = messagesResponse.modifiedSequence; + + return result; + } + + /// Mark the messages from the specified [sequence] as seen/read. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the `CONDSTORE` or + /// `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markSeen( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.seen], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unseen/unread. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the `CONDSTORE` or + /// `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnseen( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.seen], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as flagged. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the `CONDSTORE` or + /// `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markFlagged( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.flagged], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unflagged. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnflagged( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.flagged], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as deleted. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markDeleted( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.deleted], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not deleted. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUndeleted( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.deleted], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as answered. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markAnswered( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.answered], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not answered. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnanswered( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.answered], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markForwarded( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.keywordForwarded], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnforwarded( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.keywordForwarded], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as seen/read. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkSeen( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.seen], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unseen/unread. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkUnseen( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.seen], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as flagged. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkFlagged( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.flagged], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unflagged. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkUnflagged( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.flagged], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as deleted. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkDeleted( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.deleted], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not deleted. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkUndeleted( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.deleted], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as answered. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkAnswered( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.answered], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not answered. + /// + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkUnanswered( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.answered], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkForwarded( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.keywordForwarded], + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Set [silent] to true in case the updated flags are of no interest. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability + /// Compare the [uidStore] method in case you need more control or want to + /// change several flags. + Future uidMarkUnforwarded( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + uidStore( + sequence, + [MessageFlags.keywordForwarded], + action: StoreAction.remove, + silent: silent, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Trigger a noop (no operation). + /// + /// A noop can update the info about the currently selected mailbox + /// and can be used as a keep alive. + /// Also compare [idleStart] for starting the IMAP IDLE mode on + /// compatible servers. + Future noop() { + final cmd = Command( + 'NOOP', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + + return sendCommand(cmd, NoopParser(this, _selectedMailbox)); + } + + /// Trigger a check operation for the server's housekeeping. + /// + /// The CHECK command requests a checkpoint of the currently selected + /// mailbox. A checkpoint refers to any implementation-dependent + /// housekeeping associated with the mailbox (e.g., resolving the + /// server's in-memory state of the mailbox with the state on its + /// disk) that is not normally executed as part of each command. A + /// checkpoint MAY take a non-instantaneous amount of real time to + /// complete. If a server implementation has no such housekeeping + /// considerations, CHECK is equivalent to NOOP. + /// + /// There is no guarantee that an EXISTS untagged response will happen + /// as a result of CHECK. NOOP, not CHECK, SHOULD be used for new + /// message polling. + /// Compare [noop], [idleStart] + Future check() { + final cmd = Command( + 'CHECK', + writeTimeout: defaultWriteTimeout, + ); + + return sendCommand(cmd, NoopParser(this, _selectedMailbox)); + } + + /// Expunges (deletes) any messages that are marked as deleted. + /// + /// The EXPUNGE command permanently removes all messages that have the + /// `\Deleted` flag set from the currently selected mailbox. Before + /// returning an OK to the client, an untagged EXPUNGE response is + /// sent for each message that is removed. + Future expunge() { + final cmd = Command( + 'EXPUNGE', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + + return sendCommand(cmd, NoopParser(this, _selectedMailbox)); + } + + /// Expunges (deletes) any messages that are in the specified [sequence] + /// AND marked as deleted. + /// + /// The UID EXPUNGE command permanently removes all messages that have the + /// `\Deleted` flag set AND that in the the defined UID-range from the + /// currently selected mailbox. Before + /// returning an OK to the client, an untagged EXPUNGE response is + /// sent for each message that is removed. + /// + /// The `UID EXPUNGE` command is only available for servers that expose the + /// `UIDPLUS` capability. + Future uidExpunge(MessageSequence sequence) { + final buffer = StringBuffer()..write('UID EXPUNGE '); + sequence.render(buffer); + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + + return sendCommand(cmd, NoopParser(this, _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 sub-mailboxes are also listed. + /// + /// When specified, [mailboxPatterns] overrides the [recursive] options + /// and provides a list of mailbox patterns to include. + /// + /// The [selectionOptions] allows extended options to be supplied + /// to the command. + /// + /// The [returnOptions] lists the extra results that should be returned + /// by the extended list enabled servers. + /// + /// The LIST command will set the [serverInfo]`.pathSeparator` + /// as a side-effect. + Future> listMailboxes({ + String path = '""', + bool recursive = false, + List? mailboxPatterns, + List? selectionOptions, + List? returnOptions, + }) => + listMailboxesByReferenceAndName( + path, + recursive ? '*' : '%', + mailboxPatterns, + selectionOptions, + returnOptions, + ); + + String _encodeFirstMailboxPath( + Mailbox? preferred, + String? path, + Mailbox? third, + ) { + if (preferred == null && path == null && third == null) { + throw ImapException(this, 'Invalid mailbox null'); + } + + return _encodeMailboxPath( + preferred?.encodedPath ?? path ?? third?.encodedPath ?? '', + ); + } + + String _encodeMailboxPath(String path, [bool alwaysQuote = false]) { + if (_serverInfo.supportsUtf8) { + if (path.startsWith('\"')) { + return path; + } + + return '"$path"'; + } + final pathSeparator = serverInfo.pathSeparator ?? '/'; + var encodedPath = Mailbox.encode(path, pathSeparator); + if (encodedPath.contains(' ') || + (alwaysQuote && !encodedPath.startsWith('"'))) { + encodedPath = '"$encodedPath"'; + } + + return encodedPath; + } + + /// Lists all mailboxes in the path [referenceName] that match + /// the given [mailboxName] that can contain wildcards. + /// + /// If the server exposes the LIST-STATUS capability, a list of attributes + /// can be provided with [returnOptions]. + /// The LIST command will set the `serverInfo.pathSeparator` as a side-effect + Future> listMailboxesByReferenceAndName( + String referenceName, + String mailboxName, [ + List? mailboxPatterns, + List? selectionOptions, + List? returnOptions, + ]) { + final buffer = StringBuffer('LIST'); + final bool hasSelectionOptions; + if (selectionOptions != null && selectionOptions.isNotEmpty) { + hasSelectionOptions = true; + buffer + ..write(' (') + ..write(selectionOptions.join(' ')) + ..write(')'); + } else { + hasSelectionOptions = false; + } + buffer + ..write(' ') + ..write(_encodeMailboxPath(referenceName, true)); + final bool hasMailboxPatterns; + if (mailboxPatterns != null && mailboxPatterns.isNotEmpty) { + hasMailboxPatterns = true; + buffer + ..write(' (') + ..write( + mailboxPatterns.map((e) => _encodeMailboxPath(e, true)).join(' '), + ) + ..write(')'); + } else { + hasMailboxPatterns = false; + buffer + ..write(' ') + ..write(_encodeMailboxPath(mailboxName, true)); + } + final bool hasReturnOptions; + if (returnOptions != null && returnOptions.isNotEmpty) { + hasReturnOptions = true; + buffer + ..write(' RETURN (') + ..write(returnOptions.join(' ')) + ..write(')'); + } else { + hasReturnOptions = false; + } + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = ListParser( + serverInfo, + isExtended: hasSelectionOptions || hasMailboxPatterns || hasReturnOptions, + hasReturnOptions: hasReturnOptions, + ); + + return sendCommand>(cmd, parser); + } + + /// Lists all subscribed mailboxes + /// + /// 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 sub-mailboxes are also listed. + /// The LIST command will set the `serverInfo.pathSeparator` as a side-effect + Future> listSubscribedMailboxes({ + String path = '""', + bool recursive = false, + }) { + // list all folders in that path + final cmd = Command( + 'LSUB ${_encodeMailboxPath(path)} ${recursive ? '*' : '%'}', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = ListParser(serverInfo, isLsubParser: true); + + return sendCommand>(cmd, parser); + } + + /// Enables the specified [capabilities]. + /// + /// Example: `await imapClient.enable(['QRESYNC']);` + /// + /// The ENABLE command is only valid in the authenticated state, + /// before any mailbox is selected. + /// + /// The server must support the `ENABLE` capability before this call + /// can be used. + /// + /// Compare https://tools.ietf.org/html/rfc5161 for details. + Future> enable(List capabilities) { + final cmd = Command( + 'ENABLE ${capabilities.join(' ')}', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = EnableParser(serverInfo); + + return sendCommand>(cmd, parser); + } + + /// Selects the specified mailbox. + /// + /// This allows future search and fetch calls. + /// [path] the path or name of the mailbox that should be selected. + /// Set [enableCondStore] to true if you want to force-enable `CONDSTORE`. + /// This is only possible when the `CONDSTORE` or `QRESYNC` capability + /// is supported. + /// Specify [qresync] parameter in case the server supports the `QRESYNC` + /// capability and you have known values from the last session. + /// Note that you need to `ENABLE QRESYNC` first. + /// Compare [enable] + Future selectMailboxByPath( + String path, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + if (serverInfo.pathSeparator == null) { + await listMailboxes(); + } + final pathSeparator = serverInfo.pathSeparator ?? '/'; + final nameSplitIndex = path.lastIndexOf(pathSeparator); + final name = + nameSplitIndex == -1 ? path : path.substring(nameSplitIndex + 1); + final box = Mailbox( + encodedName: name, + encodedPath: path, + pathSeparator: pathSeparator, + flags: [], + ); + + return selectMailbox( + box, + enableCondStore: enableCondStore, + qresync: qresync, + ); + } + + /// Selects the inbox. + /// + /// This allows future search and fetch calls. + /// Set [enableCondStore] to true if you want to force-enable `CONDSTORE`. + /// This is only possible when the `CONDSTORE` or + /// `QRESYNC` capability is supported. + /// Specify [qresync] parameter in case the server supports the `QRESYNC` + /// capability and you have known values from the last session. + /// Note that you need to `ENABLE QRESYNC` first. + /// Compare [enable] + Future selectInbox({ + bool enableCondStore = false, + QResyncParameters? qresync, + }) => + selectMailboxByPath( + 'INBOX', + enableCondStore: enableCondStore, + qresync: qresync, + ); + + /// Selects the specified mailbox. + /// + /// This allows future search and fetch calls. + /// [box] the mailbox that should be selected. + /// Set [enableCondStore] to true if you want to force-enable `CONDSTORE`. + /// This is only possible when the `CONDSTORE` or `QRESYNC` capability + /// is supported. + /// Specify [qresync] parameter in case the server supports the `QRESYNC` + /// capability and you have known values from the last session. + /// Note that you need to `ENABLE QRESYNC` first. + /// Compare [enable] + Future selectMailbox( + Mailbox box, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) => + _selectOrExamine( + 'SELECT', + box, + enableCondStore: enableCondStore, + qresync: qresync, + ); + + /// Examines the [box] without selecting it. + /// + /// Set [enableCondStore] to true if you want to force-enable `CONDSTORE`. + /// This is only possible when the `CONDSTORE` or `QRESYNC` capability + /// is supported. + /// Specify [qresync] parameter in case the server supports the `QRESYNC` + /// capability and you have known values from the last session. + /// Note that you need to `ENABLE QRESYNC` first. + /// 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. + /// Compare [enable] + Future examineMailbox( + Mailbox box, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) => + _selectOrExamine( + 'EXAMINE', + box, + enableCondStore: enableCondStore, + qresync: qresync, + ); + + /// implementation for both SELECT as well as EXAMINE + Future _selectOrExamine( + String command, + Mailbox box, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) { + final path = '"${box.encodedPath}"'; + final buffer = StringBuffer() + ..write(command) + ..write(' ') + ..write(path); + if (enableCondStore || qresync != null) { + buffer.write(' ('); + if (enableCondStore) { + buffer.write('CONDSTORE'); + } + if (qresync != null) { + if (enableCondStore) { + buffer.write(' '); + } + qresync.render(buffer); + } + buffer.write(')'); + } + final parser = SelectParser(box, this); + _selectedMailbox = box; + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + + return sendCommand(cmd, parser); + } + + /// Closes the currently selected mailbox and triggers an implicit EXPUNGE. + /// + /// Compare [selectMailbox] + /// Compare [unselectMailbox] + /// Compare [expunge] + Future closeMailbox() { + if (_selectedMailbox == null) { + return Future.value(); + } + final cmd = Command( + 'CLOSE', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = NoResponseParser(_selectedMailbox); + _selectedMailbox = null; + + return sendCommand(cmd, parser); + } + + /// Closes the currently selected mailbox + /// without triggering the expunge events. + /// + /// Compare [selectMailbox] + Future unselectMailbox() { + if (_selectedMailbox == null) { + return Future.value(); + } + final cmd = Command( + 'UNSELECT', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = NoResponseParser(_selectedMailbox); + _selectedMailbox = null; + + return sendCommand(cmd, parser); + } + + /// Searches messages by the given [searchCriteria] + /// like `'UNSEEN'` or `'RECENT'` or `'FROM sender@domain.com'`. + /// + /// When augmented with zero or more [returnOptions], requests an + /// extended search. Note that the IMAP server needs to support + /// [ESEARCH](https://tools.ietf.org/html/rfc4731) capability for this. + /// This request times out after the specified [responseTimeout] + Future searchMessages({ + String searchCriteria = 'UNSEEN', + List? returnOptions, + Duration? responseTimeout, + }) { + final parser = + SearchParser(isUidSearch: false, isExtended: returnOptions != null); + final buffer = StringBuffer('SEARCH '); + if (returnOptions != null) { + buffer + ..write('RETURN (') + ..write(returnOptions.join(' ')) + ..write(') '); + } + buffer.write(searchCriteria); + final cmdText = buffer.toString(); + buffer.clear(); + final searchLines = cmdText.split('\n'); + final cmd = searchLines.length == 1 + ? Command( + cmdText, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ) + : Command.withContinuation( + searchLines, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + + return sendCommand(cmd, parser); + } + + /// Searches messages with the given [query]. + /// + /// Specify a [responseTimeout] when a response is expected + /// within the given time. + Future searchMessagesWithQuery( + SearchQueryBuilder query, { + Duration? responseTimeout, + }) => + searchMessages( + searchCriteria: query.toString(), + responseTimeout: responseTimeout, + ); + + /// Searches messages by the given [searchCriteria] + /// like `'UNSEEN'` or `'RECENT'` or `'FROM sender@domain.com'`. + /// + /// Is only supported by servers that expose the `UID` capability. + /// When augmented with zero or more [returnOptions], requests an + /// extended search. + /// This request times out after the specified [responseTimeout] + Future uidSearchMessages({ + String searchCriteria = 'UNSEEN', + List? returnOptions, + Duration? responseTimeout, + }) { + final parser = + SearchParser(isUidSearch: true, isExtended: returnOptions != null); + final buffer = StringBuffer('UID SEARCH '); + if (returnOptions != null) { + buffer + ..write('RETURN (') + ..write(returnOptions.join(' ')) + ..write(') '); + } + buffer.write(searchCriteria); + final cmdText = buffer.toString(); + buffer.clear(); + final searchLines = cmdText.split('\n'); + final cmd = searchLines.length == 1 + ? Command( + cmdText, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ) + : Command.withContinuation( + searchLines, + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + + return sendCommand(cmd, parser); + } + + /// Searches messages with the given [query]. + /// + /// Is only supported by servers that expose the `UID` capability. + /// Specify a [responseTimeout] when a response is expected within + /// the given time. + Future uidSearchMessagesWithQuery( + SearchQueryBuilder query, { + List? returnOptions, + Duration? responseTimeout, + }) => + uidSearchMessages( + searchCriteria: query.toString(), + returnOptions: returnOptions, + responseTimeout: responseTimeout, + ); + + /// Fetches a single message by the given definition. + /// + /// [messageSequenceId] the message sequence ID of the desired message + /// [fetchContentDefinition] the definition of what should be fetched from + /// the message, for example `(UID ENVELOPE HEADER[])`, `BODY[]` or + /// `ENVELOPE`, etc + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + Future fetchMessage( + int messageSequenceId, + String fetchContentDefinition, { + Duration? responseTimeout, + }) => + fetchMessages( + MessageSequence.fromId(messageSequenceId), + fetchContentDefinition, + responseTimeout: responseTimeout, + ); + + /// Fetches messages by the given definition. + /// + /// [sequence] the sequence IDs of the messages that should be fetched + /// [fetchContentDefinition] the definition of what should be fetched from + /// the message, e.g. `(UID ENVELOPE HEADER[])`, `BODY[]` or `ENVELOPE`, etc + /// Specify the [changedSinceModSequence] in case only messages that have + /// been changed since the specified modification sequence should be fetched. + /// Note that this requires the CONDSTORE or QRESYNC server capability. + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + Future fetchMessages( + MessageSequence sequence, + String? fetchContentDefinition, { + int? changedSinceModSequence, + Duration? responseTimeout, + }) => + _fetchMessages( + false, + 'FETCH', + sequence, + fetchContentDefinition, + changedSinceModSequence: changedSinceModSequence, + responseTimeout: responseTimeout, + ); + + /// FETCH and UID FETCH implementation + Future _fetchMessages( + bool isUidFetch, + String command, + MessageSequence sequence, + String? fetchContentDefinition, { + int? changedSinceModSequence, + Duration? responseTimeout, + }) { + final cmdText = StringBuffer() + ..write(command) + ..write(' '); + sequence.render(cmdText); + cmdText + ..write(' ') + ..write(fetchContentDefinition); + if (changedSinceModSequence != null) { + cmdText + ..write(' (CHANGEDSINCE ') + ..write(changedSinceModSequence) + ..write(')'); + } + final cmd = Command( + cmdText.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + final parser = FetchParser(isUidFetch: isUidFetch); + + return sendCommand(cmd, parser); + } + + /// Fetches messages by the specified criteria. + /// + /// This call is more flexible than [fetchMessages]. + /// [fetchIdsAndCriteria] the requested message IDs and specification of the + /// requested elements, e.g. '1:* (ENVELOPE)' or + /// '1:* (FLAGS ENVELOPE) (CHANGEDSINCE 1232232)'. + /// Specify a [responseTimeout] when a response is expected within + /// the given time. + Future fetchMessagesByCriteria( + String fetchIdsAndCriteria, { + Duration? responseTimeout, + }) { + final cmd = Command( + 'FETCH $fetchIdsAndCriteria', + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + final parser = FetchParser(isUidFetch: false); + + return sendCommand(cmd, parser); + } + + /// Fetches the specified number of recent messages by the specified criteria. + /// + /// [messageCount] optional number of messages that should be fetched, + /// defaults to 30. + /// + /// [criteria] optional fetch criteria of the requested elements, e.g. + /// '(ENVELOPE BODY.PEEK[])'. Defaults to '(FLAGS BODY[])'. + /// + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + Future fetchRecentMessages({ + int messageCount = 30, + String criteria = '(FLAGS BODY[])', + Duration? responseTimeout, + }) { + final box = _selectedMailbox; + if (box == null) { + throw InvalidArgumentException( + 'No mailbox selected - call select() first.', + ); + } + final upperMessageSequenceId = box.messagesExists; + var lowerMessageSequenceId = upperMessageSequenceId - messageCount; + if (lowerMessageSequenceId < 1) { + lowerMessageSequenceId = 1; + } + + return fetchMessages( + MessageSequence.fromRange( + lowerMessageSequenceId, + upperMessageSequenceId, + ), + criteria, + responseTimeout: responseTimeout, + ); + } + + /// Fetches a single messages identified by the [messageUid] + /// + /// [fetchContentDefinition] the definition of what should be fetched from + /// the message, e.g. 'BODY[]' or 'ENVELOPE', etc. + /// + /// Also compare [uidFetchMessagesByCriteria]. + /// + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + Future uidFetchMessage( + int messageUid, + String fetchContentDefinition, { + Duration? responseTimeout, + }) => + _fetchMessages( + true, + 'UID FETCH', + MessageSequence.fromId(messageUid), + fetchContentDefinition, + responseTimeout: responseTimeout, + ); + + /// Fetches messages by the given definition. + /// + /// [sequence] the sequence of message UIDs for which messages should + /// be fetched + /// [fetchContentDefinition] the definition of what should be fetched from + /// the message, e.g. 'BODY[]' or 'ENVELOPE', etc + /// Specify the [changedSinceModSequence] in case only messages that have + /// been changed since the specified modification sequence should be fetched. + /// Note that this requires the `CONDSTORE` or `QRESYNC` server capability. + /// Specify a [responseTimeout] when you expect a response within a the + /// specified duration. + /// Also compare [uidFetchMessagesByCriteria]. + Future uidFetchMessages( + MessageSequence sequence, + String? fetchContentDefinition, { + int? changedSinceModSequence, + Duration? responseTimeout, + }) => + _fetchMessages( + true, + 'UID FETCH', + sequence, + fetchContentDefinition, + changedSinceModSequence: changedSinceModSequence, + responseTimeout: responseTimeout, + ); + + /// Fetches messages by the specified criteria. + /// + /// This call is more flexible than [uidFetchMessages]. + /// [fetchIdsAndCriteria] the requested message UIDs and specification of + /// the requested elements, e.g. '1232:1234 (ENVELOPE)'. + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + Future uidFetchMessagesByCriteria( + String fetchIdsAndCriteria, { + Duration? responseTimeout, + }) { + final cmd = Command( + 'UID FETCH $fetchIdsAndCriteria', + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ); + final parser = FetchParser(isUidFetch: true); + + return sendCommand(cmd, parser); + } + + /// Appends the specified MIME [message]. + /// + /// When no [targetMailbox] or [targetMailboxPath] is specified, then the + /// message will be appended to the currently selected mailbox. + /// You can specify flags such as `\Seen` or `\Draft` in the [flags] parameter. + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + /// Compare also the [appendMessageText] method. + Future appendMessage( + MimeMessage message, { + List? flags, + Mailbox? targetMailbox, + String? targetMailboxPath, + Duration? responseTimeout, + }) => + appendMessageText( + message.renderMessage(), + flags: flags, + targetMailbox: targetMailbox, + targetMailboxPath: targetMailboxPath, + responseTimeout: responseTimeout, + ); + + /// Appends the specified MIME [messageText]. + /// + /// When no [targetMailbox] or [targetMailboxPath] is specified, then the + /// message will be appended to the currently selected mailbox. + /// You can specify flags such as `\Seen` or `\Draft` in the [flags] parameter. + /// Specify a [responseTimeout] when a response is expected within the + /// given time. + /// Compare also the [appendMessage] method. + Future appendMessageText( + String messageText, { + List? flags, + Mailbox? targetMailbox, + String? targetMailboxPath, + Duration? responseTimeout, + }) { + final path = _encodeFirstMailboxPath( + targetMailbox, + targetMailboxPath, + _selectedMailbox, + ); + final buffer = StringBuffer() + ..write('APPEND ') + ..write(path); + if (flags != null && flags.isNotEmpty) { + buffer + ..write(' (') + ..write(flags.join(' ')) + ..write(')'); + } + final numberOfBytes = utf8.encode(messageText).length; + buffer + ..write(' {') + ..write(numberOfBytes) + ..write('}'); + final cmdText = buffer.toString(); + final cmd = Command.withContinuation( + [cmdText, messageText], + responseTimeout: responseTimeout, + ); + + return sendCommand( + cmd, + GenericParser(this, _selectedMailbox), + ); + } + + /// Retrieves the specified meta data entry. + /// + /// [entry] defines the path of the meta data + /// Optionally specify [mailboxName], the [maxSize] in bytes or the [depth]. + /// + /// Compare https://tools.ietf.org/html/rfc5464 for details. + /// Note that errata of the RFC exist. + Future> getMetaData( + String entry, { + String? mailboxName, + int? maxSize, + MetaDataDepth? depth, + }) { + var cmd = 'GETMETADATA '; + if (maxSize != null || depth != null) { + cmd += '('; + } + if (maxSize != null) { + cmd += 'MAXSIZE $maxSize'; + } + if (depth != null) { + if (maxSize != null) { + cmd += ' '; + } + cmd += 'DEPTH '; + switch (depth) { + case MetaDataDepth.none: + cmd += '0'; + break; + case MetaDataDepth.directChildren: + cmd += '1'; + break; + case MetaDataDepth.allChildren: + cmd += 'infinity'; + break; + } + } + if (maxSize != null || depth != null) { + cmd += ') '; + } + cmd += '"${mailboxName ?? ''}" ($entry)'; + final parser = MetaDataParser(); + + return sendCommand>(Command(cmd), parser); + } + + /// Checks if the specified value can be safely send to the IMAP server + /// just in double-quotes. + bool _isSafeForQuotedTransmission(String value) => + value.length < 80 && !value.contains('"') && !value.contains('\n'); + + /// Saves the specified meta data [entry]. + /// + /// Set [MetaDataEntry.value] to null to delete the specified meta data entry + /// Compare https://tools.ietf.org/html/rfc5464 for details. + Future setMetaData(MetaDataEntry entry) { + final valueText = entry.valueText; + final Command cmd; + final value = entry.value; + if (value == null || _isSafeForQuotedTransmission(valueText ?? '')) { + final cmdText = 'SETMETADATA "${entry.mailboxName}" ' + '(${entry.name} ' + '${value == null ? 'NIL' : '"$valueText"'})'; + cmd = Command(cmdText); + } else { + // this is a complex command that requires continuation responses + final setPart = 'SETMETADATA "${entry.mailboxName}" ' + '(${entry.name} {${value.length}}'; + final parts = [setPart, '$valueText)']; + cmd = Command.withContinuation(parts); + } + final parser = NoResponseParser(_selectedMailbox); + + return sendCommand(cmd, parser); + } + + /// Saves the given meta data [entries]. + /// + /// Note that each [MetaDataEntry.mailboxName] is expected to be the same. + /// Set [MetaDataEntry.value] to null to delete the specified meta data entry + /// Compare https://tools.ietf.org/html/rfc5464 for details. + Future setMetaDataEntries(List entries) { + final parts = []; + var cmd = StringBuffer()..write('SETMETADATA '); + var entry = entries.first; + cmd.write('"${entry.mailboxName}" ('); + for (entry in entries) { + cmd + ..write(' ') + ..write(entry.name) + ..write(' '); + final value = entry.value; + if (value == null) { + cmd.write('NIL'); + } else if (_isSafeForQuotedTransmission(entry.valueText ?? '')) { + cmd.write('"${entry.valueText}"'); + } else { + cmd.write('{${value.length}}'); + parts.add(cmd.toString()); + cmd = StringBuffer()..write(entry.valueText); + } + } + cmd.write(')'); + parts.add(cmd.toString()); + final parser = NoopParser(this, _selectedMailbox); + Command command; + command = parts.length == 1 + ? Command(parts.first) + : Command.withContinuation(parts); + + return sendCommand(command, parser); + } + + /// Checks the status of the currently not selected [box]. + /// + /// 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) { + final path = '"${box.encodedPath}"'; + final buffer = StringBuffer() + ..write('STATUS ') + ..write(path) + ..write(' ('); + var addSpace = false; + for (final flag in flags) { + if (addSpace) { + buffer.write(' '); + } + switch (flag) { + case StatusFlags.messages: + buffer.write('MESSAGES'); + break; + case StatusFlags.recent: + buffer.write('RECENT'); + break; + case StatusFlags.uidNext: + buffer.write('UIDNEXT'); + break; + case StatusFlags.uidValidity: + buffer.write('UIDVALIDITY'); + break; + case StatusFlags.unseen: + buffer.write('UNSEEN'); + break; + case StatusFlags.highestModSequence: + buffer.write('HIGHESTMODSEQ'); + break; + } + addSpace = true; + } + buffer.write(')'); + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = StatusParser(box); + + return sendCommand(cmd, parser); + } + + /// Creates a new mailbox with the specified [path] + Future createMailbox(String path) async { + final encodedPath = _encodeMailboxPath(path); + final cmd = Command( + 'CREATE $encodedPath', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = NoopParser( + this, + _selectedMailbox ?? + Mailbox( + encodedName: path, + encodedPath: path, + flags: [MailboxFlag.noSelect], + pathSeparator: serverInfo.pathSeparator ?? '/', + ), + ); + await sendCommand(cmd, parser); + final matchingBoxes = await listMailboxes(path: path); + if (matchingBoxes.isNotEmpty) { + return matchingBoxes.first; + } + throw ImapException( + this, + 'Unable to find just created mailbox with the path [$path]. ' + 'Please report this problem.', + ); + } + + /// Removes the specified mailbox + /// + /// [box] the mailbox to be deleted + Future deleteMailbox(Mailbox box) => + _sendMailboxCommand('DELETE', box); + + /// Renames the specified mailbox + /// + /// [box] the mailbox that should be renamed + /// [newName] the desired future name of the mailbox + Future renameMailbox(Mailbox box, String newName) async { + final path = '"${box.encodedPath}"'; + + final cmd = Command( + 'RENAME $path ${_encodeMailboxPath(newName)}', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final response = await sendCommand( + cmd, + NoopParser(this, _selectedMailbox ?? box), + ); + // if (box.name.toUpperCase() == '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? + // } + + return response ?? box; + } + + /// Subscribes the specified mailbox. + /// + /// The mailbox is listed in future LSUB commands, + /// compare [listSubscribedMailboxes]. + /// [box] the mailbox that is subscribed + Future subscribeMailbox(Mailbox box) => + _sendMailboxCommand('SUBSCRIBE', box); + + /// Unsubscribes the specified mailbox. + /// + /// [box] the mailbox that is unsubscribed + Future unsubscribeMailbox(Mailbox box) => + _sendMailboxCommand('UNSUBSCRIBE', box); + + Future _sendMailboxCommand(String command, Mailbox box) async { + final path = '"${box.encodedPath}"'; + final cmd = Command( + '$command $path', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final result = await sendCommand(cmd, NoopParser(this, box)); + + return result ?? box; + } + + /// Switches to IDLE mode. + /// + /// Requires a mailbox to be selected and the mail service to support IDLE. + /// + /// Compare [idleDone] + Future idleStart() { + if (!isConnected) { + throw ImapException(this, 'idleStart failed: client is not connected'); + } + if (!isLoggedIn) { + throw ImapException(this, 'idleStart failed: user not logged in'); + } + if (_selectedMailbox == null) { + print('$logName: idleStart(): ERROR: no mailbox selected'); + + return Future.value(); + } + if (_isInIdleMode) { + logApp('Warning: idleStart() called but client is already in IDLE mode.'); + + return Future.value(); + } + final cmd = Command( + 'IDLE', + writeTimeout: defaultWriteTimeout, + ); + final task = CommandTask(cmd, nextId(), NoopParser(this, _selectedMailbox)); + _tasks[task.id] = task; + _idleCommandTask = task; + final result = sendCommandTask(task, returnCompleter: false); + _isInIdleMode = true; + + return result; + } + + /// Stops the IDLE mode. + /// + /// For example after receiving information about a new message to download + /// the message. + /// Requires a mailbox to be selected and the mail service to support IDLE. + /// + /// Compare [idleStart] + Future idleDone() async { + if (!isConnected || !isLoggedIn) { + throw ImapException(this, 'idleDone(): not connected or logged in!'); + } + if (!_isInIdleMode) { + print('$logName: warning: ignore idleDone(): not in IDLE mode'); + + return; + } + _isInIdleMode = false; + // as this is a potential breaking point, give it a timeout: + await writeText('DONE'); + final completer = _idleCommandTask?.completer; + if (isLogEnabled && completer == null) { + logApp( + 'There is no current idleCommandTask or ' + 'completer future $_idleCommandTask', + ); + } + if (completer != null) { + completer.timeout( + defaultResponseTimeout ?? const Duration(seconds: 4), + this, + ); + await completer.future; + } else { + await Future.delayed(const Duration(milliseconds: 200)); + } + _idleCommandTask = null; + } + + /// Sets the quota [resourceLimits] for the the user / [quotaRoot]. + /// + /// Optionally define the [quotaRoot] which defaults to `""`. + /// Note that the server needs to support the [QUOTA](https://tools.ietf.org/html/rfc2087) capability. + Future setQuota({ + required Map resourceLimits, + String quotaRoot = '""', + }) { + final quotaRootParameter = + quotaRoot.contains(' ') ? '"$quotaRoot"' : quotaRoot; + final buffer = StringBuffer() + ..write('SETQUOTA ') + ..write(quotaRootParameter) + ..write(' (') + ..write(resourceLimits.entries + .map((entry) => '${entry.key} ${entry.value}') + .join(' ')) + ..write(')'); + final cmd = Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = QuotaParser(); + + return sendCommand(cmd, parser); + } + + /// Retrieves the quota for the user/[quotaRoot]. + /// + /// Optionally define the [quotaRoot] which defaults to `""`. + /// Note that the server needs to support the + /// [QUOTA](https://tools.ietf.org/html/rfc2087) capability. + Future getQuota({String quotaRoot = '""'}) { + final quotaRootParameter = + quotaRoot.contains(' ') ? '"$quotaRoot"' : quotaRoot; + final cmd = Command( + 'GETQUOTA $quotaRootParameter', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = QuotaParser(); + + return sendCommand(cmd, parser); + } + + /// Retrieves the quota root for the specified [mailboxName] + /// which defaults to the root `""`. + /// + /// Note that the server needs to support the + /// [QUOTA](https://tools.ietf.org/html/rfc2087) capability. + Future getQuotaRoot({String mailboxName = '""'}) { + final cmd = Command( + 'GETQUOTAROOT ${_encodeMailboxPath(mailboxName)}', + writeTimeout: defaultWriteTimeout, + responseTimeout: defaultResponseTimeout, + ); + final parser = QuotaRootParser(); + + return sendCommand(cmd, parser); + } + + /// Sorts messages by the given criteria. + /// + /// [sortCriteria] the criteria used for sorting the results + /// like 'ARRIVAL' or 'SUBJECT'. + /// + /// [searchCriteria] the criteria like 'UNSEEN' or 'RECENT'. + /// + /// [charset] the charset used for the searching criteria. + /// + /// When augmented with zero or more [returnOptions], requests an extended + /// search, in this case the server must support the + /// [ESORT](https://tools.ietf.org/html/rfc5267) capability. + /// The server needs to expose the + /// [SORT](https://tools.ietf.org/html/rfc5256) capability for this + /// command to work. + Future sortMessages( + String sortCriteria, [ + String searchCriteria = 'ALL', + String charset = 'UTF-8', + List? returnOptions, + ]) { + final parser = + SortParser(isUidSort: false, isExtended: returnOptions != null); + final buffer = StringBuffer('SORT '); + if (returnOptions != null) { + buffer + ..write('RETURN (') + ..write(returnOptions.join(' ')) + ..write(') '); + } + buffer + ..write('(') + ..write(sortCriteria) + ..write(') ') + ..write(charset) + ..write(' ') + ..write(searchCriteria); + final cmdText = buffer.toString(); + buffer.clear(); + final sortLines = cmdText.split('\n'); + final cmd = sortLines.length == 1 + ? Command(cmdText, writeTimeout: defaultWriteTimeout) + : Command.withContinuation( + sortLines, + writeTimeout: defaultWriteTimeout, + ); + + return sendCommand(cmd, parser); + } + + /// Sorts messages by the given criteria + /// + /// [sortCriteria] the criteria used for sorting the results + /// like 'ARRIVAL' or 'SUBJECT' + /// [searchCriteria] the criteria like 'UNSEEN' or 'RECENT' + /// [charset] the charset used for the searching criteria + /// When augmented with zero or more [returnOptions], requests + /// an extended search. + /// The server needs to expose the + /// [SORT](https://tools.ietf.org/html/rfc5256) capability for this + /// command to work. + Future uidSortMessages( + String sortCriteria, [ + String searchCriteria = 'ALL', + String charset = 'UTF-8', + List? returnOptions, + ]) { + final parser = + SortParser(isUidSort: true, isExtended: returnOptions != null); + final buffer = StringBuffer('UID SORT '); + if (returnOptions != null) { + buffer + ..write('RETURN (') + ..write(returnOptions.join(' ')) + ..write(') '); + } + buffer + ..write('(') + ..write(sortCriteria) + ..write(') ') + ..write(charset) + ..write(' ') + ..write(searchCriteria); + final cmdText = buffer.toString(); + buffer.clear(); + final sortLines = cmdText.split('\n'); + final cmd = sortLines.length == 1 + ? Command(cmdText, writeTimeout: defaultWriteTimeout) + : Command.withContinuation( + sortLines, + writeTimeout: defaultWriteTimeout, + ); + + return sendCommand(cmd, parser); + } + + /// Requests the IDs of message threads starting on [since] + /// using the given [method] (defaults to `ORDEREDSUBJECT`) + /// and [charset] (defaults to `UTF-8`). + /// + /// Optionally set [threadUids] to `true` when you want to receive UIDs + /// rather than sequence IDs. + /// You can use this method when the server announces the `THREAD` + /// capability, in which it also announces the supported methods, e.g. + /// `THREAD=ORDEREDSUBJECT THREAD=REFERENCES`. + /// Specify a [responseTimeout] when a response is expected within + /// the given time. + /// Compare `ServerInfo.supportsThreading` and + /// `ServerInfo.supportedThreadingMethods`. + Future threadMessages({ + required DateTime since, + String method = 'ORDEREDSUBJECT', + String charset = 'UTF-8', + bool threadUids = false, + Duration? responseTimeout, + }) { + final buffer = StringBuffer(); + if (threadUids) { + buffer.write('UID '); + } + buffer + ..write('THREAD ') + ..write(method) + ..write(' ') + ..write(charset) + ..write(' SINCE ') + ..write(DateCodec.encodeSearchDate(since)); + + return sendCommand( + Command( + buffer.toString(), + writeTimeout: defaultWriteTimeout, + responseTimeout: responseTimeout, + ), + ThreadParser(isUidSequence: threadUids), + ); + } + + /// Requests the UIDs of message threads starting on [since] + /// using the given [method] (defaults to `ORDEREDSUBJECT`) + /// and [charset] (defaults to `UTF-8`). + /// + /// You can use this method when the server announces the `THREAD` + /// capability, in which it also announces the supported methods, e.g. + /// `THREAD=ORDEREDSUBJECT THREAD=REFERENCES`. + /// Specify a [responseTimeout] when a response is expected + /// within the given time. + /// Compare `ServerInfo.supportsThreading` and + /// `ServerInfo.supportedThreadingMethods`. + Future uidThreadMessages({ + required DateTime since, + String method = 'ORDEREDSUBJECT', + String charset = 'UTF-8', + Duration? responseTimeout, + }) => + threadMessages( + method: method, + charset: charset, + since: since, + threadUids: true, + responseTimeout: responseTimeout, + ); + + /// Retrieves the next session-unique command ID + String nextId() { + final id = _lastUsedCommandId++; + + return 'a$id'; + } + + /// Queues the specified [command] for sending to the server. + /// + /// The response is parsed using [parser], by default the + /// completer's future is returned unless you set + /// [returnCompleter] to `false`. + Future sendCommand( + Command command, + ResponseParser parser, { + bool returnCompleter = true, + }) { + final task = CommandTask(command, nextId(), parser); + _tasks[task.id] = task; + queueTask(task); + + return returnCompleter ? task.completer.future : Future.value(); + } + + /// Queues the given [task] for sending to the server. + /// + /// By default the + /// completer's future is returned unless you set + /// [returnCompleter] to `false`. + Future sendCommandTask( + CommandTask task, { + bool returnCompleter = true, + }) { + queueTask(task); + + return returnCompleter ? task.completer.future : Future.value(); + } + + /// Queues the given [task]. + /// + /// Starts processing the queue automatically when necessary. + void queueTask(CommandTask task) { + if (_isInIdleMode && task.command.commandText == 'IDLE') { + logApp('Ignore duplicate IDLE: $task'); + task.completer.complete(); + + return; + } + final stashedQueue = _stashedQueue; + if (!isConnected && stashedQueue != null) { + logApp('Stashing task $task'); + stashedQueue.add(task); + + return; + } + _queue.add(task); + if (_queue.length == 1) { + _processQueue(); + } + } + + Future _processQueue() async { + // print('$logName: process queue'); + while (_queue.isNotEmpty) { + final task = _queue[0]; + // print('enough: $logName: process queue task $task'); + await _processTask(task); + if (_queue.isNotEmpty) { + _queue.removeAt(0); + } + } + } + + Future _processTask(CommandTask task) async { + _currentCommandTask = task; + try { + await writeText(task.imapRequest, task, task.command.writeTimeout); + } catch (e, s) { + log('unable to process task $task: $e $s'); + if (!task.completer.isCompleted) { + task.completer.completeError(e, s); + } + + return; + } + try { + final timeout = task.command.responseTimeout; + task.completer.timeout(timeout, this); + await task.completer.future; + } catch (e, s) { + if (!task.completer.isCompleted) { + // caller needs to handle any errors: + logApp('ImapClient._processTask: forward error to completer: $e'); + task.completer.completeError(e, s); + } + } + } + + /// Handles the specified [imapResponse] from the server. + /// + /// The response is parsed and processed. + void onServerResponse(ImapResponse imapResponse) { + if (isLogEnabled) { + log(imapResponse, isClient: false); + } + final line = imapResponse.parseText; + //final log = imapResponse.toString().replaceAll("\r\n", "\n"); + //log("S: $log"); + + //log("sub-line: " + 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); + } + } + + /// Processes the command result response from the server. + void onCommandResult(ImapResponse imapResponse) { + final line = imapResponse.parseText; + final spaceIndex = line.indexOf(' '); + if (spaceIndex != -1) { + final commandId = line.substring(0, spaceIndex); + final task = _tasks[commandId]; + if (task != null) { + if (task == _currentCommandTask) { + _currentCommandTask = null; + } + imapResponse.parseText = line.substring(spaceIndex + 1); + final response = task.parse(imapResponse); + try { + if (!task.completer.isCompleted) { + if (response.isOkStatus) { + task.completer.complete(response.result); + } else { + task.completer + .completeError(ImapException(this, response.details)); + } + } + } catch (e, s) { + print('Unable to complete task ${task.command.logText}: $e $s'); + print('response: ${imapResponse.parseText}'); + print('result: ${response.result}'); + try { + task.completer.completeError(ImapException(this, response.details)); + } on Exception { + // ignore + } + } + } else { + log('ERROR: no task found for command [$commandId]'); + } + } else { + log('unexpected SERVER response: [$imapResponse]'); + } + } + + /// Handles an untagged response from the server + void onUntaggedResponse(ImapResponse imapResponse) { + final task = _currentCommandTask; + if (task == null || !task.parseUntaggedResponse(imapResponse)) { + log('untagged not handled: [$imapResponse] by task $task'); + } + } + + /// Handles an continuation response from the server + Future onContinuationResponse(ImapResponse imapResponse) async { + final cmd = _currentCommandTask?.command; + if (cmd != null) { + final response = cmd.getContinuationResponse(imapResponse); + if (response != null) { + await writeText(response); + + return; + } + } + if (!_isInIdleMode) { + logApp('continuation not handled: [$imapResponse], current cmd: $cmd'); + } + } + + /// Closes the connection. Deprecated: use `disconnect()` instead. + @Deprecated('Use disconnect() instead.') + Future closeConnection() { + logApp('Closing socket for host ${serverInfo.host}'); + + return disconnect(); + } + + /// Remembers the queued tasks until [applyStashedTasks] is called. + /// + /// Compare [applyStashedTasks] + void stashQueuedTasks() { + _stashedQueue = [..._queue]; + _queue.clear(); + } + + /// Applies the stashed tasks + /// + /// Compare [stashQueuedTasks] + Future applyStashedTasks() async { + final stash = _stashedQueue; + _stashedQueue = null; + if (stash != null) { + for (final task in stash) { + final text = task.command.commandText; + try { + if (text == 'IDLE') { + if (!task.completer.isCompleted) { + task.completer.complete(); + } + } else if (text == 'DONE') { + final completer = _idleCommandTask?.completer; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + if (!task.completer.isCompleted) { + task.completer.complete(); + } + } else if (text == 'NOOP') { + if (!task.completer.isCompleted) { + task.completer.complete(_selectedMailbox); + } + } else { + await _processTask(task); + } + } catch (e, s) { + print('Unable to apply stashed command $text: $e $s'); + } + } + } + } + + @override + Object createClientError(String message) => ImapException(this, message); +} diff --git a/packages/enough_mail/lib/src/imap/imap_events.dart b/packages/enough_mail/lib/src/imap/imap_events.dart new file mode 100644 index 0000000..28cb0eb --- /dev/null +++ b/packages/enough_mail/lib/src/imap/imap_events.dart @@ -0,0 +1,125 @@ +import '../../enough_mail.dart'; + +/// Classification of IMAP events +/// +/// Compare [ImapEvent] +enum ImapEventType { + /// The connection to the server has been lost. Try to reconnect. + /// Compare [ImapConnectionLostEvent]. + connectionLost, + + /// A message has been removed. Also see the vanished event. + /// Compare [ImapExpungeEvent]. + expunge, + + /// The status flags of a message have been updated. + /// Compare [ImapFetchEvent]. + fetch, + + /// The currently selected mailbox has a new number of messages. + /// Compare [ImapMessagesExistEvent]. + exists, + + /// Similar to the exists event, + /// the number of messages deemed as recent have changed. + /// Compare [ImapMessagesRecentEvent]. + recent, + + /// A number of messages have been deleted. + /// This event can only be triggered if the server is `QRESYNC` compliant + /// and after the client has enabled `QRESYNC`. + /// Compare [ImapVanishedEvent]. + vanished, +} + +/// Base class for any event that can be fired by the `IMAP` client at any time. +/// Compare [ImapClient.eventBus] +class ImapEvent { + /// Creates a new instance + ImapEvent(this.eventType, this.imapClient); + + /// The type of the event. + final ImapEventType eventType; + + /// The associated ImapClient. + final ImapClient imapClient; +} + +/// Notifies about a message that has been deleted +class ImapExpungeEvent extends ImapEvent { + /// Creates a new IMAP event + ImapExpungeEvent(this.messageSequenceId, ImapClient imapClient) + : super(ImapEventType.expunge, imapClient); + + /// The message sequence id (index) of the message that has been removed. + final int messageSequenceId; +} + +/// Notifies about a sequence of messages that have been deleted. +/// This event can only be triggered if the server is `QRESYNC` compliant and +/// after the client has enabled `QRESYNC`. +class ImapVanishedEvent extends ImapEvent { + /// Creates a new IMAP event + ImapVanishedEvent( + this.vanishedMessages, + ImapClient imapClient, { + required this.isEarlier, + }) : super(ImapEventType.vanished, imapClient); + + /// Message sequence of messages that have been expunged + /// Check `vanishedMessages.isUid` to see if the message sequence + /// contains IDs or UIDs. + final MessageSequence? vanishedMessages; + + /// true when the vanished messages do not lead to updated sequence IDs + final bool isEarlier; +} + +/// Notifies about a message that has changed its status / flags +class ImapFetchEvent extends ImapEvent { + /// Creates a new IMAP event + ImapFetchEvent(this.message, ImapClient imapClient) + : super(ImapEventType.fetch, imapClient); + + /// The message with the updated flags. + final MimeMessage message; +} + +/// Notifies about new messages +class ImapMessagesExistEvent extends ImapEvent { + /// Creates a new IMAP event + ImapMessagesExistEvent( + this.newMessagesExists, + this.oldMessagesExists, + ImapClient imapClient, + ) : super(ImapEventType.exists, imapClient); + + /// The current number of existing messages + final int newMessagesExists; + + /// The previous number of existing messages + final int oldMessagesExists; +} + +/// Notifies about new messages +class ImapMessagesRecentEvent extends ImapEvent { + /// Creates a new IMAP event + ImapMessagesRecentEvent( + this.newMessagesRecent, + this.oldMessagesRecent, + ImapClient imapClient, + ) : super(ImapEventType.recent, imapClient); + + /// The current number of recent messages + final int newMessagesRecent; + + /// The previous number of recent messages + final int oldMessagesRecent; +} + +/// Notifies about a connection lost +class ImapConnectionLostEvent extends ImapEvent { + /// Creates a new IMAP event + ImapConnectionLostEvent(ImapClient imapClient) + : super(ImapEventType.connectionLost, imapClient); +} diff --git a/packages/enough_mail/lib/src/imap/imap_exception.dart b/packages/enough_mail/lib/src/imap/imap_exception.dart new file mode 100644 index 0000000..4e54bcc --- /dev/null +++ b/packages/enough_mail/lib/src/imap/imap_exception.dart @@ -0,0 +1,36 @@ +import 'imap_client.dart'; + +/// Provides information about an exception +class ImapException implements Exception { + /// Creates a new exception + ImapException(this.imapClient, this.message, {this.stackTrace, this.details}); + + /// The corresponding IMAP client + final ImapClient imapClient; + + /// The message if known + final String? message; + + /// The stacktrace if known + final StackTrace? stackTrace; + + /// Any exception-specific details if known + final dynamic details; + + @override + String toString() { + final buffer = StringBuffer()..write(message); + if (details != null) { + buffer + ..write('\n') + ..write(details); + } + if (stackTrace != null) { + buffer + ..write('\n') + ..write(stackTrace); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/imap/imap_search.dart b/packages/enough_mail/lib/src/imap/imap_search.dart new file mode 100644 index 0000000..dcc8271 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/imap_search.dart @@ -0,0 +1,480 @@ +import 'dart:convert'; + +import '../codecs/date_codec.dart'; +import '../exception.dart'; +import 'message_sequence.dart'; + +/// Which part of the message should be searched +enum SearchQueryType { + /// Search for matching `Subject` header + subject, + + /// Search for matching `From` header + from, + + /// Search for matching `To` header + to, + + /// Search for matches in the body of the message + /// (a very resource intensive search, not every mail provider supports this) + body, + + /// Search in all common headers (not every mail provider supports this) + allTextHeaders, + + /// Search in either `FROM` or in `SUBJECT`. + /// + /// Specifically useful in cases where the mail provider + /// does not support `allTextHeaders` + fromOrSubject, + + /// Search in either `TO` or in `SUBJECT`. + /// + /// Specifically useful in cases where the mail provider + /// does not support `allTextHeaders` + toOrSubject, + + /// Search for matching `TO` or `FROM` headers + fromOrTo, +} + +/// Defines what kind of messages should be searched +enum SearchMessageType { + /// any message + all, + + /// any flagged messages + flagged, + + /// any messages that are not flagged + unflagged, + + /// any seen (read) messages + seen, + + /// any messages that have not been seen + unseen, + + /// any messages marked as deleted + deleted, + + /// any messages that are not marked as deleted + undeleted, + + /// any messages marked as draft + draft, + + /// any messages not marked as draft + undraft +} + +/// Creates a new search query. +/// +/// In IMAP any search query is combined with AND meaning all conditions +/// must be met by matching messages. +class SearchQueryBuilder { + /// Creates a common search query. + /// + /// [query] contains the search text, define where to search + /// with the [queryType]. + /// + /// Optionally you can also define what kind of messages to search + /// with the [messageType], + /// + /// the internal date since a message has been received with [since], + /// + /// the internal date before a message has been received with [before], + /// + /// the internal date since a message has been sent with [sentSince], + /// + /// the internal date before a message has been sent with [sentBefore], + SearchQueryBuilder.from( + String query, + SearchQueryType queryType, { + SearchMessageType? messageType, + DateTime? since, + DateTime? before, + DateTime? sentSince, + DateTime? sentBefore, + }) { + if (query.isNotEmpty) { + if (_TextSearchTerm.containsNonAsciiCharacters(query)) { + add(const SearchTermCharsetUf8()); + } + switch (queryType) { + case SearchQueryType.subject: + add(SearchTermSubject(query)); + break; + case SearchQueryType.from: + add(SearchTermFrom(query)); + break; + case SearchQueryType.to: + add(SearchTermTo(query)); + break; + case SearchQueryType.allTextHeaders: + add(SearchTermText(query)); + break; + case SearchQueryType.body: + add(SearchTermBody(query)); + break; + case SearchQueryType.fromOrSubject: + add(SearchTermOr(SearchTermFrom(query), SearchTermSubject(query))); + break; + case SearchQueryType.toOrSubject: + add(SearchTermOr(SearchTermTo(query), SearchTermSubject(query))); + break; + case SearchQueryType.fromOrTo: + add(SearchTermOr(SearchTermFrom(query), SearchTermTo(query))); + break; + } + } + + if (messageType != null) { + switch (messageType) { + case SearchMessageType.all: + // ignore + break; + case SearchMessageType.flagged: + add(const SearchTermFlagged()); + break; + case SearchMessageType.unflagged: + add(const SearchTermUnflagged()); + break; + case SearchMessageType.seen: + add(const SearchTermSeen()); + break; + case SearchMessageType.unseen: + add(const SearchTermUnseen()); + break; + case SearchMessageType.deleted: + add(const SearchTermDeleted()); + break; + case SearchMessageType.undeleted: + add(const SearchTermUndeleted()); + break; + case SearchMessageType.draft: + add(const SearchTermDraft()); + break; + case SearchMessageType.undraft: + add(const SearchTermUndraft()); + break; + } + } + if (before != null) { + add(SearchTermBefore(before)); + } + if (since != null) { + add(SearchTermSince(since)); + } + if (sentBefore != null) { + add(SearchTermSentBefore(sentBefore)); + } + if (sentSince != null) { + add(SearchTermSentSince(sentSince)); + } + } + + /// The terms for this search query + final searchTerms = []; + + /// Adds a new search term + void add(SearchTerm term) { + searchTerms.add(term); + } + + /// Renders this search query to the given [buffer]. + void render(StringBuffer buffer) { + var addSpace = false; + for (final term in searchTerms) { + if (addSpace) { + buffer.write(' '); + } + buffer.write(term.term); + addSpace = !term.term.endsWith('\n'); + } + } + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } +} + +/// Base class for all search terms +abstract class SearchTerm { + /// Creates a new search term + const SearchTerm(this.term); + + /// The search + final String term; + + /// Renders this term to the given [buffer]. + void render(StringBuffer buffer) { + buffer.write(term); + } +} + +class _TextSearchTerm extends SearchTerm { + _TextSearchTerm(String name, String? value) : super(merge(name, value)); + + static String merge(String name, String? value) { + if (value == null) { + return name; + } + // check if there are UTF-8 characters: + if (containsNonAsciiCharacters(value)) { + final encoded = utf8.encode(value); + + return '$name {${encoded.length}}\n$value'; + } + final escaped = value.replaceAll('"', r'\"'); + + return '$name "$escaped"'; + } + + static bool containsNonAsciiCharacters(String value) { + final runes = value.runes; + for (final rune in runes) { + if (rune >= 127) { + return true; + } + } + + return false; + } +} + +class _DateSearchTerm extends SearchTerm { + _DateSearchTerm(String name, DateTime value) + : super('$name ${DateCodec.encodeSearchDate(value)}'); +} + +/// Set the charset to UTF8 +class SearchTermCharsetUf8 extends SearchTerm { + /// Creates a new search term + const SearchTermCharsetUf8() : super('CHARSET "UTF-8"'); +} + +/// Searches all messages +class SearchTermAll extends SearchTerm { + /// Creates a new search term + const SearchTermAll() : super('ALL'); +} + +/// Searches for answered/replied messages +class SearchTermAnswered extends SearchTerm { + /// Creates a new search term + const SearchTermAnswered() : super('ANSWERED'); +} + +/// Searches for messages with a BCC recipient that matches +class SearchTermBcc extends _TextSearchTerm { + /// Creates a new search term + SearchTermBcc(String recipientPart) : super('BCC', recipientPart); +} + +/// Searches for messages stored before the given date. +class SearchTermBefore extends _DateSearchTerm { + /// Creates a new search term + SearchTermBefore(DateTime dateTime) : super('BEFORE', dateTime); +} + +/// Searches in the body of messages. +/// This is usually a long lasting operation. +class SearchTermBody extends _TextSearchTerm { + /// Creates a new search term + SearchTermBody(String match) : super('BODY', match); +} + +/// Searches for messages with a matching recipient on CC +class SearchTermCc extends _TextSearchTerm { + /// Creates a new search term + SearchTermCc(String recipientPart) : super('CC', recipientPart); +} + +/// Searches for deleted messages +class SearchTermDeleted extends SearchTerm { + /// Creates a new search term + const SearchTermDeleted() : super('DELETED'); +} + +/// Searches for draft messages +class SearchTermDraft extends SearchTerm { + /// Creates a new search term + const SearchTermDraft() : super('DRAFT'); +} + +/// Searches for flagged messages +class SearchTermFlagged extends SearchTerm { + /// Creates a new search term + const SearchTermFlagged() : super('FLAGGED'); +} + +/// Searches for messages where the sender matches the senderPart +class SearchTermFrom extends _TextSearchTerm { + /// Creates a new search term + SearchTermFrom(String senderPart) : super('FROM', senderPart); +} + +/// Searches for messages with the given header +class SearchTermHeader extends _TextSearchTerm { + /// Creates a new search term + SearchTermHeader(String headerName, {String? headerValue}) + : super('HEADER $headerName', headerValue); +} + +/// Searches for messages flagged with the given keyword +class SearchTermKeyword extends SearchTerm { + /// Creates a new search term + const SearchTermKeyword(String keyword) : super('KEYWORD $keyword'); +} + +/// Searches for messages that are bigger than the given size +class SearchTermLarger extends SearchTerm { + /// Creates a new search term + const SearchTermLarger(int bytes) : super('LARGER $bytes'); +} + +/// Searches for new messages +class SearchTermNew extends SearchTerm { + /// Creates a new search term + const SearchTermNew() : super('NEW'); +} + +/// Negates the given search term +class SearchTermNot extends SearchTerm { + /// Creates a new search term + SearchTermNot(SearchTerm term) : super('NOT ${term.term}'); +} + +/// Searches for old messages +class SearchTermOld extends SearchTerm { + /// Creates a new search term + const SearchTermOld() : super('OLD'); +} + +/// Searches for message stored at the given day +class SearchTermOn extends _DateSearchTerm { + /// Creates a new search term + SearchTermOn(DateTime dateTime) : super('ON', dateTime); +} + +/// Combines two atomic search terms in an OR way +/// Note that you cannot nest an OR term into another OR term +class SearchTermOr extends SearchTerm { + /// Creates a new search term + SearchTermOr(SearchTerm term1, SearchTerm term2) + : super(_merge(term1, term2)); + static String _merge(SearchTerm term1, SearchTerm term2) { + if (term1 is SearchTermOr || term2 is SearchTermOr) { + throw InvalidArgumentException('You cannot nest several OR search terms'); + } + + return 'OR ${term1.term} ${term2.term}'; + } +} + +/// Searches for recent messages +class SearchTermRecent extends SearchTerm { + /// Creates a new search term + const SearchTermRecent() : super('RECENT'); +} + +/// Searches for seen / read messages +class SearchTermSeen extends SearchTerm { + /// Creates a new search term + const SearchTermSeen() : super('SEEN'); +} + +/// Searches for messages sent before the given date +class SearchTermSentBefore extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentBefore(DateTime dateTime) : super('SENTBEFORE', dateTime); +} + +/// Searches for message sent at the given day +class SearchTermSentOn extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentOn(DateTime dateTime) : super('SENTON', dateTime); +} + +/// Searches message sent after the given time +class SearchTermSentSince extends _DateSearchTerm { + /// Creates a new search term + SearchTermSentSince(DateTime dateTime) : super('SENTSINCE', dateTime); +} + +/// Searches for messages stored after the given time +class SearchTermSince extends _DateSearchTerm { + /// Creates a new search term + SearchTermSince(DateTime dateTime) : super('SINCE', dateTime); +} + +/// Searches messages with a size less than given +class SearchTermSmaller extends SearchTerm { + /// Creates a new search term + const SearchTermSmaller(int bytes) : super('SMALLER $bytes'); +} + +/// Searches for messages with a matching subject +class SearchTermSubject extends _TextSearchTerm { + /// Creates a new search term + SearchTermSubject(String subjectPart) : super('SUBJECT', subjectPart); +} + +/// Searches any text header +class SearchTermText extends _TextSearchTerm { + /// Creates a new search term + SearchTermText(String textPart) : super('TEXT', textPart); +} + +/// Searches for recipients +class SearchTermTo extends _TextSearchTerm { + /// Creates a new search term + SearchTermTo(String recipientPart) : super('TO', recipientPart); +} + +/// Searches for the given UID messages +class UidSearchTerm extends SearchTerm { + /// Creates a new search term + UidSearchTerm(MessageSequence sequence) : super('UID $sequence'); +} + +/// Searches messages without the replied flag +class SearchTermUnanswered extends SearchTerm { + /// Creates a new search term + const SearchTermUnanswered() : super('UNANSWERED'); +} + +/// Searches messages that are not deleted +class SearchTermUndeleted extends SearchTerm { + /// Creates a new search term + const SearchTermUndeleted() : super('UNDELETED'); +} + +/// Searches for messages that carry no draft flag +class SearchTermUndraft extends SearchTerm { + /// Creates a new search term + const SearchTermUndraft() : super('UNDRAFT'); +} + +/// Search for not flagged messages +class SearchTermUnflagged extends SearchTerm { + /// Creates a new search term + const SearchTermUnflagged() : super('UNFLAGGED'); +} + +/// Searches for messages without the keyword +class SearchTermUnkeyword extends SearchTerm { + /// Creates a new search term + const SearchTermUnkeyword(String keyword) : super('UNKEYWORD $keyword'); +} + +/// Searches for unseen messages +class SearchTermUnseen extends SearchTerm { + /// Creates a new search term + const SearchTermUnseen() : super('UNSEEN'); +} diff --git a/packages/enough_mail/lib/src/imap/mailbox.dart b/packages/enough_mail/lib/src/imap/mailbox.dart new file mode 100644 index 0000000..e55d3b2 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/mailbox.dart @@ -0,0 +1,397 @@ +import 'package:collection/collection.dart' show IterableExtension; + +import '../codecs/modified_utf7_codec.dart'; +import 'qresync.dart'; + +/// Contains common flags for mailboxes +enum MailboxFlag { + /// a marked mailbox + marked, + + /// a not marked mailbox + unMarked, + + /// a mailbox with other mailboxes inside + hasChildren, + + /// a mailbox leaf + hasNoChildren, + + /// a mailbox that cannot be selected + noSelect, + + /// a mailbox that can be selected + select, + + /// a mailbox without inferiors boxes + noInferior, + + /// the user has subscribed this mailbox + subscribed, + + /// this mailbox is at a remote service + remote, + + /// this mailbox does not exist + nonExistent, + + /// this mailbox contains all messages + all, + + /// this mailbox is the inbox + inbox, + + /// this mailbox contains sent messages + sent, + + /// this mailbox contains draft messages + drafts, + + /// this mailbox contains junk messages + junk, + + /// this mailbox contains deleted messages + trash, + + /// this mailbox contains archived messages + archive, + + /// this mailbox contains flagged messages + flagged, + + /// a virtual, not existing mailbox + /// + /// Compare [Mailbox.virtual] + virtual, +} + +/// Stores meta data about a folder aka Mailbox +class Mailbox { + /// Creates a new Mailbox + Mailbox({ + required this.encodedName, + required this.encodedPath, + required this.flags, + required this.pathSeparator, + this.isReadWrite = false, + this.messagesRecent = 0, + this.messagesExists = 0, + this.messagesUnseen = 0, + this.highestModSequence, + this.firstUnseenMessageSequenceId, + this.uidNext, + this.uidValidity, + this.messageFlags = const [], + this.permanentMessageFlags = const [], + this.extendedData = const {}, + }) : name = _modifiedUtf7Codec.decodeText(encodedName), + path = _modifiedUtf7Codec.decodeText(encodedPath) { + if (!isInbox && name.toLowerCase() == 'inbox') { + flags.add(MailboxFlag.inbox); + } + } + + /// Creates a new mailbox with the specified [name], [path] and [flags]. + /// + /// Optionally specify the path separator with [pathSeparator] + @Deprecated('Use Mailbox() constructor directly') + Mailbox.setup( + String name, + String path, + List flags, { + String? pathSeparator, + }) : this( + encodedName: name, + encodedPath: path, + flags: flags, + pathSeparator: pathSeparator ?? '/', + ); + + /// Creates a new virtual mailbox + /// + /// A virtual mailbox has the flag [MailboxFlag.virtual] and is not + /// a mailbox that exists for real. + Mailbox.virtual(String name, List flags) + : this( + encodedName: name, + encodedPath: name, + flags: flags.addIfNotPresent(MailboxFlag.virtual), + pathSeparator: '/', + ); + + /// Copies this mailbox with the given parameters + Mailbox copyWith({ + int? messagesRecent, + int? messagesExists, + int? messagesUnseen, + int? highestModSequence, + int? uidNext, + List? messageFlags, + List? permanentMessageFlags, + Map>? extendedData, + }) => + Mailbox( + encodedName: encodedName, + encodedPath: encodedPath, + flags: flags, + pathSeparator: pathSeparator, + isReadWrite: isReadWrite, + messagesRecent: messagesRecent ?? this.messagesRecent, + messagesExists: messagesExists ?? this.messagesExists, + highestModSequence: highestModSequence ?? this.highestModSequence, + uidNext: uidNext ?? this.uidNext, + uidValidity: uidValidity, + firstUnseenMessageSequenceId: firstUnseenMessageSequenceId, + messageFlags: messageFlags ?? this.messageFlags, + permanentMessageFlags: + permanentMessageFlags ?? this.permanentMessageFlags, + extendedData: extendedData ?? this.extendedData, + ); + + static const ModifiedUtf7Codec _modifiedUtf7Codec = ModifiedUtf7Codec(); + + /// The encoded name of the mailbox + final String encodedName; + + /// The encoded path + final String encodedPath; + + /// The human readable path + final String path; + + /// The separator between path elements, usually `/` or `:`. + final String pathSeparator; + + /// The human readable name of this box + String name; + + /// Number of messages deemed by the server as recent + int messagesRecent; + + /// The number of messages in this mailbox + int messagesExists; + + /// The number of unseen messages - only reported through STATUS calls + int messagesUnseen; + + /// The sequence ID of the first unseen message + int? firstUnseenMessageSequenceId; + + /// The UID validity of this mailbox + int? uidValidity; + + /// The expected UID of the next incoming message + int? uidNext; + + /// Can the user both read and write this mailbox? + bool isReadWrite; + + /// The last modification sequence in case the server supports the + /// `CONDSTORE` or `QRESYNC` capability. Useful for message synchronization. + int? highestModSequence; + + /// The flags of this mailbox + final List flags; + + /// Supported flags for messages in this mailbox + List messageFlags; + + /// Supported permanent flags for messages in this mailbox + List permanentMessageFlags; + + /// Map of extended results + final Map> extendedData; + + /// Retrieves the quick resync settings of this mailbox + /// + /// Note that this is only supported when the server supports the + /// `QRESYNC` extension. + QResyncParameters? get qresync => + (highestModSequence == null || uidValidity == null) + ? null + : QResyncParameters(uidValidity, highestModSequence); + + /// Is this mailbox marked? + bool get isMarked => hasFlag(MailboxFlag.marked); + + /// Does this mailbox have children? + bool get hasChildren => hasFlag(MailboxFlag.hasChildren); + + /// Is this mailbox selected? + bool get isSelected => hasFlag(MailboxFlag.select); + + /// Can this mailbox not be selected? + @Deprecated('Use isNotSelectable instead') + bool get isUnselectable => hasFlag(MailboxFlag.noSelect); + + /// Can this mailbox not be selected? + bool get isNotSelectable => hasFlag(MailboxFlag.noSelect); + + /// This is set to false in case the server supports CONDSTORE but no + /// mod sequence for this mailbox + bool get hasModSequence => highestModSequence != null; + + /// Tries to retrieve the identity flag of this mailbox + /// + /// Compare [isSpecialUse], [isInbox], [isDrafts], [isSent], [isJunk], + /// [isTrash], [isArchive]. + MailboxFlag? get identityFlag => flags.firstWhereOrNull((flag) => + flag == MailboxFlag.inbox || + flag == MailboxFlag.drafts || + flag == MailboxFlag.sent || + flag == MailboxFlag.junk || + flag == MailboxFlag.trash || + flag == MailboxFlag.archive); + + /// Is this the inbox? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isInbox => hasFlag(MailboxFlag.inbox); + + /// Is this the drafts folder? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isDrafts => hasFlag(MailboxFlag.drafts); + + /// Is this the sent folder? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isSent => hasFlag(MailboxFlag.sent); + + /// Is this the junk folder? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isJunk => hasFlag(MailboxFlag.junk); + + /// Is this the trash folder? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isTrash => hasFlag(MailboxFlag.trash); + + /// Is this the archive folder? + /// + /// Compare [isSpecialUse] and [identityFlag] + bool get isArchive => hasFlag(MailboxFlag.archive); + + /// Is this a virtual mailbox? + /// + /// A virtual mailbox does not exist in reality. + /// Compare [Mailbox.virtual] + bool get isVirtual => hasFlag(MailboxFlag.virtual); + + /// Does this mailbox have a known specific purpose? + /// + /// Compare [identityFlag], [isInbox], [isDrafts], [isSent], [isJunk], + /// [isTrash], [isArchive]. + bool get isSpecialUse => identityFlag != null; + + /// Checks of the mailbox has the given flag + bool hasFlag(MailboxFlag flag) => flags.contains(flag); + + /// Sets the name from the original path + /// + /// This can be useful when the mailbox name was localized + /// for viewing purposes. + /// + /// Compare [name] + void setNameFromPath() { + name = _modifiedUtf7Codec.decodeText(encodedName); + } + + /// Tries to determine the parent mailbox + /// from the given [knownMailboxes] and [separator]. + /// + /// Set [create] to `false` in case the parent should only be determined + /// from the known mailboxes (defaults to `true`). + /// Set [createIntermediate] to `false` and [create] to `true` to return + /// the first known existing parent, when the direct parent is not known + Mailbox? getParent( + List knownMailboxes, + String separator, { + bool create = true, + bool createIntermediate = true, + }) { + var lastSplitIndex = encodedPath.lastIndexOf(separator); + if (lastSplitIndex == -1) { + // this is a root mailbox, eg 'Inbox' + return null; + } + final parentPath = encodedPath.substring(0, lastSplitIndex); + var parent = + knownMailboxes.firstWhereOrNull((box) => box.path == parentPath); + if (parent == null && create) { + lastSplitIndex = parentPath.lastIndexOf(separator); + final parentName = (lastSplitIndex == -1) + ? parentPath + : parentPath.substring(lastSplitIndex + 1); + parent = Mailbox( + encodedName: parentName, + encodedPath: parentPath, + flags: [MailboxFlag.noSelect], + pathSeparator: separator, + ); + if ((lastSplitIndex != -1) && (!createIntermediate)) { + parent = parent.getParent( + knownMailboxes, + separator, + create: true, + createIntermediate: false, + ); + } + } + + return parent; + } + + @override + String toString() { + final buffer = StringBuffer() + ..write('"') + ..write(path) + ..write('"') + ..write(' exists: ') + ..write(messagesExists) + ..write(', highestModeSequence: ') + ..write(highestModSequence) + ..write(', flags: ') + ..write(flags); + + return buffer.toString(); + } + + /// Helper method to encode the specified [path] in Modified UTF7 encoding. + /// + /// Note that any path separators will be encoded as well, so + /// you might have to separate and reassemble path element manually + static String encode(String path, String pathSeparator) { + final pathSeparatorIndex = path.lastIndexOf(pathSeparator); + if (pathSeparatorIndex == -1) { + return _modifiedUtf7Codec.encodeText(path); + } else { + final start = path.substring(0, pathSeparatorIndex); + final end = _modifiedUtf7Codec.encodeText( + path.substring(pathSeparatorIndex + pathSeparator.length), + ); + + return '$start$pathSeparator$end'; + } + } + + @override + int get hashCode => encodedPath.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Mailbox && encodedPath == other.encodedPath; +} + +extension _ListExtension on List { + List addIfNotPresent(T element) { + if (!contains(element)) { + add(element); + } + + return this; + } +} diff --git a/packages/enough_mail/lib/src/imap/message_sequence.dart b/packages/enough_mail/lib/src/imap/message_sequence.dart new file mode 100644 index 0000000..a84cf85 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/message_sequence.dart @@ -0,0 +1,799 @@ +// ignore_for_file: avoid_returning_this + +import 'dart:collection'; + +import 'package:json_annotation/json_annotation.dart'; + +import '../exception.dart'; +import '../mime_message.dart'; + +part 'message_sequence.g.dart'; + +/// Defines a list of message IDs. +/// +/// IDs can be either be based on sequence IDs or on UIDs. +@JsonSerializable() +class MessageSequence { + /// Creates a new message sequence. + /// + /// Optionally set [isUidSequence] to `true` in case this is a sequence + /// based on UIDs. This defaults to `false`. + MessageSequence({this.isUidSequence = false}); + + /// Convenience method for getting the sequence for a single [id]. + /// + /// Optionally specify the if the ID is a UID with [isUid], defaults to false. + MessageSequence.fromId(int id, {bool isUid = false}) : isUidSequence = isUid { + add(id); + } + + /// Convenience method to creating a sequence from a list of [ids]. + /// + /// Optionally specify the if the ID is a UID with [isUid], defaults to false. + MessageSequence.fromIds(List ids, {bool isUid = false}) + : isUidSequence = isUid { + addList(ids); + } + + /// Convenience method for getting the sequence for a single [message]. + MessageSequence.fromSequenceId(MimeMessage message) : isUidSequence = false { + addSequenceId(message); + } + + /// Convenience method for getting the sequence for a single [message]'s UID. + MessageSequence.fromUid(MimeMessage message) : isUidSequence = true { + addUid(message); + } + + /// Convenience method for getting the sequence for a single [message]'s + /// UID or sequence ID. + MessageSequence.fromMessage(MimeMessage message) + : isUidSequence = message.uid != null { + if (isUidSequence) { + addUid(message); + } else { + addSequenceId(message); + } + } + + /// Convenience method for getting the sequence for the given [messages]'s + /// UIDs or sequence IDs. + MessageSequence.fromMessages(List messages) + : isUidSequence = messages.isNotEmpty && messages.first.uid != null { + if (isUidSequence) { + messages.forEach(addUid); + } else { + messages.forEach(addSequenceId); + } + } + + /// Convenience method for getting the sequence for a single range from + /// [start] to [end] inclusive. + MessageSequence.fromRange(int start, int end, {this.isUidSequence = false}) { + addRange(start, end); + } + + /// Convenience method for getting the sequence for a single range from + /// [start] to the last message inclusive. + /// + /// Note that the last message will always be returned, even when + /// the sequence ID / UID of the last message is smaller than [start]. + MessageSequence.fromRangeToLast(int start, {this.isUidSequence = false}) { + addRangeToLast(start); + } + + /// Convenience method for getting the sequence for the last message. + MessageSequence.fromLast() : isUidSequence = false { + addLast(); + } + + /// Convenience method for getting the sequence for all messages. + MessageSequence.fromAll() : isUidSequence = false { + addAll(); + } + + /// Generates a sequence based on the specified input [text] + /// like `1:10,21,73:79`. + /// + /// Set [isUidSequence] to `true` in case this sequence consists of UIDs. + MessageSequence.parse(String text, {this.isUidSequence = false}) { + final chunks = text.split(','); + if (chunks[0] == 'NIL') { + _isNilSequence = true; + _text = null; + } else { + for (final chunk in chunks) { + final id = int.tryParse(chunk); + if (id != null) { + add(id); + } else if (chunk == '*') { + addLast(); + } else if (chunk.endsWith(':*')) { + final idText = chunk.substring(0, chunk.length - ':*'.length); + final id = int.tryParse(idText); + if (id != null) { + addRangeToLast(id); + } else { + throw InvalidArgumentException( + 'expect id in $idText for <$chunk> in $text', + ); + } + } else { + final colonIndex = chunk.indexOf(':'); + if (colonIndex == -1) { + throw InvalidArgumentException('expect colon in <$chunk> / $text'); + } + final start = int.tryParse(chunk.substring(0, colonIndex)); + final end = int.tryParse(chunk.substring(colonIndex + 1)); + if (start == null || end == null) { + throw InvalidArgumentException('expect range in <$chunk> / $text'); + } + addRange(start, end); + } + } + } + } + + /// Convenience method for getting the sequence for a range defined by the + /// [page] starting with `1`, the [pageSize] and the number + /// of messages [messagesExist]. + factory MessageSequence.fromPage( + int page, + int pageSize, + int messagesExist, { + bool isUidSequence = false, + }) { + final rangeStart = messagesExist - page * pageSize + 1; + + if (page == 1) { + // ensure that also get any new messages: + return MessageSequence.fromRangeToLast( + rangeStart < 1 ? 1 : rangeStart, + isUidSequence: isUidSequence, + ); + } + final rangeEnd = rangeStart + pageSize - 1; + + return MessageSequence.fromRange( + rangeStart < 1 ? 1 : rangeStart, + rangeEnd, + isUidSequence: isUidSequence, + ); + } + + /// Creates a [MessageSequence] from the given [json] + factory MessageSequence.fromJson(Map json) => + _$MessageSequenceFromJson(json); + + /// Converts this [MessageSequence] to JSON + Map toJson() => _$MessageSequenceToJson(this); + + /// True when this sequence is consisting of UIDs + final bool isUidSequence; + + /// The length of this sequence. + /// + /// Only valid when there is no range to last involved. + int get length => toList().length; + + /// Checks is this sequence has at no elements. + bool get isEmpty => !_isLastAdded && !_isAllAdded && _ids.isEmpty; + + /// Checks is this sequence has at least one element. + bool get isNotEmpty => _isLastAdded || _isAllAdded || _ids.isNotEmpty; + + final List _ids = []; + bool _isLastAdded = false; + bool _isAllAdded = false; + String? _text; + + bool _isNilSequence = false; + + /// Is this a null sequence? + bool get isNil => _isNilSequence; + + static const int _elementStar = 0; + static const int _elementRangeStar = -1; + + /// Adds the UID or sequence ID of the [message] to this sequence. + void addMessage(MimeMessage message) { + if (isUidSequence) { + final uid = message.uid; + if (uid == null) { + throw InvalidArgumentException('no UID found in message'); + } + add(uid); + } else { + final sequenceId = message.sequenceId; + if (sequenceId == null) { + throw InvalidArgumentException('no sequence ID found in message'); + } + add(sequenceId); + } + } + + /// Removes the UID or sequence ID of the [message] to this sequence. + void removeMessage(MimeMessage message) { + if (isUidSequence) { + final uid = message.uid; + if (uid == null) { + throw InvalidArgumentException('no UID found in message'); + } + remove(uid); + } else { + final sequenceId = message.sequenceId; + if (sequenceId == null) { + throw InvalidArgumentException('no sequence ID found in message'); + } + remove(sequenceId); + } + } + + /// Adds the sequence ID of the specified [message]. + void addSequenceId(MimeMessage message) { + final id = message.sequenceId; + if (id == null) { + throw InvalidArgumentException('no sequence ID found in message'); + } + add(id); + } + + /// Removes the sequence ID of the specified [message]. + void removeSequenceId(MimeMessage message) { + final id = message.sequenceId; + if (id == null) { + throw InvalidArgumentException('no sequence ID found in message'); + } + remove(id); + } + + /// Adds the UID of the specified [message]. + void addUid(MimeMessage message) { + final uid = message.uid; + if (uid == null) { + throw InvalidArgumentException('no UID found in message'); + } + add(uid); + } + + /// Removes the UID of the specified [message]. + void removeUid(MimeMessage message) { + final uid = message.uid; + if (uid == null) { + throw InvalidArgumentException('no UID found in message'); + } + remove(uid); + } + + /// Adds the specified [id] + void add(int id) { + _ids.add(id); + _text = null; + } + + /// Removes the given [id] + void remove(int id) { + _ids.remove(id); + _text = null; + } + + /// Adds all messages between [start] and [end] inclusive. + void addRange(int start, int end) { + // start:end + if (start == end) { + add(start); + + return; + } + final wasEmpty = isEmpty; + if (start < end) { + _ids.addAll([for (int i = start; i <= end; i++) i]); + } else { + _ids.addAll([for (int i = end; i <= start; i++) i]); + } + _text = wasEmpty ? '$start:$end' : null; + } + + /// Adds a range from the specified [start] ID to + /// to the last `*` element. + void addRangeToLast(int start) { + if (start == 0) { + throw InvalidArgumentException('sequence ID must not be 0'); + } + // start:* + final wasEmpty = isEmpty; + _isLastAdded = true; + _ids.addAll([start, _elementRangeStar]); + _text = wasEmpty ? '$start:*' : null; + } + + /// Adds the last element, which is alway `*`. + void addLast() { + // * + final wasEmpty = isEmpty; + _isLastAdded = true; + _ids.add(_elementStar); + _text = wasEmpty ? '*' : null; + } + + /// Adds all messages + /// + /// This results into `1:*`. + void addAll() { + // 1:* + final wasEmpty = isEmpty; + _isAllAdded = true; + _text = wasEmpty ? '1:*' : null; + } + + /// Adds a user defined sequence of IDs + void addList(List ids) { + _ids.addAll(ids); + _text = null; + } + + /// Creates a new sequence containing the message IDs/UIDs between [start] (inclusive) and [end] (exclusive) + MessageSequence subsequence(int start, [int? end]) { + final sublist = _ids.sublist(start, end); + final subsequence = MessageSequence(isUidSequence: isUidSequence); + subsequence._ids.addAll(sublist); + + return subsequence; + } + + /// Retrieves sequence containing the message IDs/UIDs from the page + /// with the given [pageNumber] which starts at 1 and the given [pageSize]. + /// + /// This pages start from the end of this sequence, + /// optionally skipping the first [skip] entries. + /// When the [pageNumber] is 1 and the [pageSize] is equals or bigger + /// than the [length] of this sequence, this sequence is returned. + MessageSequence subsequenceFromPage( + int pageNumber, + int pageSize, { + int skip = 0, + }) { + if (pageNumber == 1 && pageSize >= length) { + return this; + } + final pageIndex = pageNumber - 1; + final end = length - skip - (pageIndex * pageSize); + if (end <= 0) { + return MessageSequence(); + } + var start = end - pageSize; + if (start < 0) { + start = 0; + } + + return subsequence(start, end); + } + + /// Retrieves the ID at the specified zero-based [index]. + int elementAt(int index) => _ids.elementAt(index); + + /// Retrieves the ID at the specified zero-based [index]. + int operator [](int index) => _ids.elementAt(index); + + /// Checks if this sequence contains the last indicator in some form - '*' + bool containsLast() => _isLastAdded || _isAllAdded; + + /// Lists all entries of this sequence. + /// + /// You must specify the number of existing messages with the [exists] + /// parameter, in case this sequence contains the last element '*' + /// in some form. + /// + /// Use the [containsLast] method to determine if this sequence contains + /// the last element '*'. + List toList([int? exists]) { + if (exists == null && containsLast()) { + throw InvalidArgumentException( + 'Unable to list sequence when * is part of the list and the ' + '\'exists\' parameter is not specified.', + ); + } + if (_isNilSequence) { + throw InvalidArgumentException('Unable to list non existent sequence.'); + } + final idSet = LinkedHashSet.identity(); + if (_isAllAdded) { + if (exists == null) { + throw InvalidArgumentException( + 'Unable to list sequence when * is part of the list and the ' + '\'exists\' parameter is not specified.', + ); + } + for (var i = 1; i <= exists; i++) { + idSet.add(i); + } + } else { + var index = 0; + var zeroLoc = _ids.indexOf(_elementRangeStar, index); + while (zeroLoc > 0) { + idSet.addAll(_ids.sublist(index, zeroLoc)); + + // Using a for-loop because we must generate a sequence when + //reaching the `STAR` value + if (exists != null) { + idSet.addAll([for (var x = idSet.last + 1; x <= exists; x++) x]); + } + index = zeroLoc + 1; + zeroLoc = _ids.indexOf(_elementRangeStar, index); + } + if (index >= 0 && zeroLoc == -1) { + idSet.addAll(_ids.sublist(index)); + } + } + if (idSet.remove(_elementStar) && exists != null) { + idSet.add(exists); + } + + return idSet.toList(); + } + + @override + String toString() { + final text = _text; + if (text != null) { + return text; + } + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } + + /// Renders this message sequence into the specified StringBuffer [buffer]. + void render(StringBuffer buffer) { + if (_isNilSequence) { + buffer.write('NIL'); + + return; + } + if (_text != null) { + buffer.write(_text); + + return; + } + if (isEmpty) { + throw InvalidArgumentException('no ID added to sequence'); + } + if (_ids.length == 1) { + buffer.write(_ids[0]); + } else { + var cache = 0; + for (var i = 0; i < _ids.length; i++) { + if (i == 0) { + buffer.write(_ids[i] == _elementStar ? '*' : _ids[i]); + } else if (_ids[i] == _ids[i - 1] + 1) { + // Saves the current id of the range + cache = _ids[i]; + } else { + // Writes out the current range + if (cache > 0) { + buffer + ..write(':') + ..write(cache); + cache = 0; + } + if (_ids[i] == _elementRangeStar) { + buffer + ..write(':') + ..write('*'); + } else { + buffer + ..write(',') + ..write(_ids[i] == _elementStar ? '*' : _ids[i]); + } + } + } + // Writes out the range at the end of the sequence, if any + if (cache > 0) { + buffer + ..write(':') + ..write(cache); + cache = 0; + } + } + if (_isAllAdded) { + if (buffer.length > 0) { + buffer.write(','); + } + buffer.write('1:*'); + } + } + + /// Sorts the sequence set. + /// + /// Use when the request assumes an ordered sequence of IDs or UIDs + void sort() { + _ids.sort(); + // Moves the `*` placeholder to the bottom + if (_isLastAdded) { + if (_ids.remove(_elementStar)) { + _ids.add(_elementStar); + } + if (_ids.remove(_elementRangeStar)) { + _ids.add(_elementRangeStar); + } + } + } + + /// Iterates through the sequence + Iterable every() sync* { + for (final id in _ids) { + yield id; + } + } +} + +/// Selection mode for retrieving a `MessageSequence` from a nested +/// `SequenceNode` structure. +enum SequenceNodeSelectionMode { + /// All message IDs are retrieved + all, + + /// Only the first / root / oldest leaf of each nested 'thread' is retrieved + firstLeaf, + + /// Only the last / newest leaf of each nested 'thread' is retrieved + lastLeaf, +} + +/// A message sequence to handle nested IDs like in the IMAP THREAD extension. +class SequenceNode { + /// Creates a sequence node with the given [id] and `true` in [isUid] + /// if this belongs to a UID sequence. + SequenceNode(this.id, {required this.isUid}); + + /// Creates a root node with `true` in [isUid] if this belongs to a + /// UID sequence. + /// + /// Root nodes can occur anywhere in a nested sequence node unless it has + /// been flattened. + /// + /// Compare [flatten] + SequenceNode.root({required this.isUid}) : id = -1; + + /// Children of this node + final children = []; + + /// The ID, the root node has an ID of -1 + final int id; + + /// Checks if this node has an ID, otherwise it is a root node + bool get hasId => id != -1; + + /// Defines if this is a UID (when `true`) or a sequenceId (when `false`). + final bool isUid; + + /// Checks if this node has no children + bool get isEmpty => children.isEmpty; + + /// Checks if this node has children + bool get isNotEmpty => children.isNotEmpty; + + /// Retrieves the number of children of this node + int get length => children.length; + + /// Retrieves the ID of the latest message node + int get latestId => isEmpty ? id : children[length - 1].latestId; + + /// Adds a child with the given ID. + SequenceNode addChild(int childId) { + final child = SequenceNode(childId, isUid: isUid); + children.add(child); + + return child; + } + + /// Renders this node into the given [buffer]. + void render(StringBuffer buffer) { + if (id != -1) { + buffer.write(id); + } + if (isNotEmpty) { + buffer.write('('); + var addSpace = false; + for (final child in children) { + if (addSpace) { + buffer.write(' '); + } + child.render(buffer); + addSpace = true; + } + buffer.write(')'); + } + } + + @override + String toString() { + final buffer = StringBuffer()..write('SequenceNode '); + if (isUid) { + buffer.write('(UID) '); + } + if (isEmpty) { + buffer.write(''); + } else { + render(buffer); + } + + return buffer.toString(); + } + + /// Retrieves the child node at the given index + SequenceNode operator [](int index) => children[index]; + + /// Flattens the structure with the given [depth] so that only the returned + /// node is actually a root node. + /// + /// When the [depth] is `1`, then only the direct children are allowed, + /// if it has higher, there can be additional + /// descendants. [depth] must not be lower than `1`. [depth] defaults to `2`. + SequenceNode flatten({int depth = 2}) { + assert(depth >= 1, 'depth must be at least 1 ($depth is invalid)'); + final root = SequenceNode.root(isUid: isUid); + _flatten(depth, root); + + return root; + } + + void _flatten(int depth, SequenceNode parent) { + if (hasId) { + // this is a leaf + parent.addChild(id); + } + if (depth == 1) { + for (final child in children) { + if (child.hasId) { + parent.children.add(child); + } else { + child._flatten(depth, parent); + } + } + } else { + for (final child in children) { + final parentChild = SequenceNode.root(isUid: isUid); + parent.children.add(parentChild); + child._flatten(depth - 1, parentChild); + } + } + } + + /// Converts this node to a message sequence in the specified [mode]. + /// + /// The [mode] defaults to all message IDs. + MessageSequence toMessageSequence({ + SequenceNodeSelectionMode mode = SequenceNodeSelectionMode.all, + }) { + final sequence = MessageSequence(isUidSequence: isUid); + _addToSequence(sequence, mode, 0); + + return sequence; + } + + void _addToSequence( + MessageSequence sequence, + SequenceNodeSelectionMode mode, + int depth, + ) { + if (mode == SequenceNodeSelectionMode.all || depth == 0) { + if (hasId) { + sequence.add(id); + } + for (final child in children) { + child._addToSequence(sequence, mode, depth + 1); + } + } else if (mode == SequenceNodeSelectionMode.firstLeaf) { + if (hasId) { + sequence.add(id); + } else if (length > 0) { + children[0]._addToSequence(sequence, mode, depth + 1); + } + } else { + // mode is last leaf: + if (length == 0 && hasId) { + sequence.add(id); + } else if (length > 0) { + children[length - 1]._addToSequence(sequence, mode, depth + 1); + } + } + } +} + +/// A paginated list of message IDs +class PagedMessageSequence { + /// Creates a new paged sequence from the given [sequence] + /// with the optional [pageSize]. + PagedMessageSequence(this.sequence, {this.pageSize = 30}) + : _messageSequenceIds = sequence.toList(); + + /// Creates a new empty paged sequence with the optional [pageSize]. + PagedMessageSequence.empty({int pageSize = 30}) + : this(MessageSequence(), pageSize: pageSize); + + /// The original sequence + final MessageSequence sequence; + final List _messageSequenceIds; + + /// The page size + final int pageSize; + + /// Determines if this is a UID sequence + bool get isUidSequence => sequence.isUidSequence; + int _currentPage = 0; + int _addedIds = 0; + + /// Retrieves the 0-based index of the current page + int get currentPageIndex => _currentPage; + + /// Retrieves the length of the sequence + int get length => _messageSequenceIds.length; + + /// Checks if this paged list has a next page + bool get hasNext => _currentPage * pageSize < length; + + /// Retrieves the ID at the given [index] + int operator [](int index) => _messageSequenceIds[index]; + + /// Retrieves the sequence for the current page. + /// + /// You have to call `next()` before you can access the first page. + MessageSequence getCurrentPage() { + assert(_currentPage > 0, + 'You have to call next() before you can access the first page.'); + + return sequence.subsequenceFromPage( + _currentPage, + pageSize, + skip: _addedIds, + ); + } + + /// Advances this sequence to the next page and then returns + /// `getCurrentPage()`. + /// + /// You have to check the `hasNext` property first before you can call + /// `next()`. + MessageSequence next() { + assert(hasNext, + 'This paged sequence has no next page. Check hasNext property.'); + _currentPage++; + + return getCurrentPage(); + } + + /// Adds the given [id] to this paged sequence + void add(int id) { + _addedIds++; + sequence.add(id); + _messageSequenceIds.add(id); + } + + /// Inserts the given [id] to this paged sequence + void insert(int id) { + _addedIds++; + sequence.add(id); + _messageSequenceIds.insert(0, id); + } + + /// Removes the given [id] from this paged sequence + void remove(int id) { + _messageSequenceIds.remove(id); + sequence.remove(id); + } + + /// Retrieves the page index for the given ID + int pageIndexOf(int index) => index ~/ pageSize; + + /// Retrieves the sequence for the specified page index + MessageSequence getSequence(int pageIndex) => + sequence.subsequenceFromPage(pageIndex + 1, pageSize, skip: _addedIds); +} + +/// Allows to get a sequence for a list of [MimeMessage]s easily +extension SequenceExtension on List { + /// Retrieves a message sequence from this list of [MimeMessage]s + MessageSequence toSequence() => MessageSequence.fromMessages(this); +} diff --git a/packages/enough_mail/lib/src/imap/metadata.dart b/packages/enough_mail/lib/src/imap/metadata.dart new file mode 100644 index 0000000..d8950de --- /dev/null +++ b/packages/enough_mail/lib/src/imap/metadata.dart @@ -0,0 +1,69 @@ +// METADATA supporting classes, compare https://tools.ietf.org/html/rfc5464 + +import 'dart:typed_data'; + +/// Contains meta data entries +class MetaDataEntries { + /// Defines a comment or note that is associated with the server + /// and that is shared with authorized users of the server. + static const String sharedCommend = '/shared/comment'; + + /// Indicates a method for contacting the server administrator. + /// + /// The value MUST be a URI (e.g., a mailto: or tel: URL). This entry is + /// always read-only -- clients cannot change it. It is visible to + /// authorized users of the system. + static const String sharedAdmin = '/shared/admin'; + + /// Defines the top level of shared entries associated with the server, + /// as created by a particular product of some vendor. + /// + /// This entry can be used by vendors to provide server- or client-specific + /// annotations. The vendor-token MUST be registered with IANA, using + /// the Application Configuration Access Protocol (ACAP) RFC2244 + /// vendor subtree registry. + static const String sharedVendor = '/shared/vendor/'; + + /// Defines the top level of private entries associated with the server, + /// as created by a particular product of some vendor. + /// + /// This entry can be used by vendors to provide server- or client-specific + /// annotations. The vendor-token MUST be registered with IANA, using + /// the ACAP RFC2244 vendor subtree registry. + static const String privateVendor = '/private/vendor/'; +} + +/// The depth of a meta data request +enum MetaDataDepth { + /// only direct value is returned, no children (0) + none, + + /// the direct value plus its immediate children are returned (1) + directChildren, + + /// the direct value and any children and children's children etc are + /// returned (infinity) + allChildren +} + +/// A meta data element +class MetaDataEntry { + /// Creates a new meta data entry + MetaDataEntry({required this.name, this.mailboxName = '', this.value}); + + /// name of the associated mailbox + final String mailboxName; + + /// name of this entry + final String name; + + /// Optional binary value + final Uint8List? value; + + /// Optional textual value + String? get valueText { + final value = this.value; + + return value == null ? null : String.fromCharCodes(value); + } +} diff --git a/packages/enough_mail/lib/src/imap/qresync.dart b/packages/enough_mail/lib/src/imap/qresync.dart new file mode 100644 index 0000000..6b4ead9 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/qresync.dart @@ -0,0 +1,73 @@ +import '../exception.dart'; +import 'message_sequence.dart'; + +/// Classes for implementing QRESYNC https://tools.ietf.org/html/rfc7162 + +/// QRESYNC parameters when doing a SELECT or EXAMINE. +class QResyncParameters { + /// Creates new quick resync parameters + QResyncParameters(this.lastKnownValidity, this.lastKnownModificationSequence); + + /// the last known UIDVALIDITY of the mailbox / folder + int? lastKnownValidity; + + /// the last known modification sequence of the mailbox / folder + int? lastKnownModificationSequence; + + /// the optional set of known UIDs + MessageSequence? knownUids; + + /// an optional parenthesized list of known sequence ranges and their + /// corresponding UIDs + MessageSequence? _knownSequenceIds; + + /// corresponding UIDs to the known sequence IDs + MessageSequence? _knownSequenceIdsUids; + + /// Specifies the optional known message sequence IDs with [knownSequenceIds] + /// along with their corresponding UIds [correspondingKnownUids]. + void setKnownSequenceIdsWithTheirUids( + MessageSequence knownSequenceIds, + MessageSequence correspondingKnownUids, + ) { + if (knownSequenceIds == correspondingKnownUids) { + throw InvalidArgumentException( + 'Invalid known and sequence ids are the same $knownSequenceIds', + ); + } + _knownSequenceIds = knownSequenceIds; + _knownSequenceIdsUids = correspondingKnownUids; + } + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } + + /// Renders this parameter for an IMAP SELECT or EXAMINE command. + void render(StringBuffer buffer) { + buffer + ..write('QRESYNC (') + ..write(lastKnownValidity) + ..write(' ') + ..write(lastKnownModificationSequence); + final knownUids = this.knownUids; + if (knownUids != null) { + buffer.write(' '); + knownUids.render(buffer); + final _knownSequenceIds = this._knownSequenceIds; + final _knownSequenceIdsUids = this._knownSequenceIdsUids; + if (_knownSequenceIds != null && _knownSequenceIdsUids != null) { + buffer.write(' ('); + _knownSequenceIds.render(buffer); + buffer.write(' '); + _knownSequenceIdsUids.render(buffer); + buffer.write(')'); + } + } + buffer.write(')'); + } +} diff --git a/packages/enough_mail/lib/src/imap/resource_limit.dart b/packages/enough_mail/lib/src/imap/resource_limit.dart new file mode 100644 index 0000000..d44d1fa --- /dev/null +++ b/packages/enough_mail/lib/src/imap/resource_limit.dart @@ -0,0 +1,14 @@ +/// QUOTA resource limit +class ResourceLimit { + /// Creates a new resource limit + ResourceLimit(this.name, this.currentUsage, this.usageLimit); + + /// The quota resource name. + final String name; + + /// Current resource usage in kilobytes. + final int? currentUsage; + + /// Usage limit of the resource as kilobytes. + final int? usageLimit; +} diff --git a/packages/enough_mail/lib/src/imap/response.dart b/packages/enough_mail/lib/src/imap/response.dart new file mode 100644 index 0000000..4b8ee92 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/response.dart @@ -0,0 +1,277 @@ +import '../../enough_mail.dart'; + +/// Status for command responses. +enum ResponseStatus { + /// The response completed successfully + ok, + + /// The command is not supported + no, + + /// The command is supported but the client send a wrong request + /// or is a wrong state + bad, +} + +/// Base class for command responses. +class Response { + /// The status, either OK or Failed + ResponseStatus? status; + + /// The textual response details + String? details; + + /// The result of the operation + T? result; + + /// Returns `true` when the response status is OK + bool get isOkStatus => status == ResponseStatus.ok; + + /// Returns `true` when the response status is not ok + bool get isFailedStatus => !isOkStatus; +} + +/// A generic result that provide details about the success or failure +/// of the command. +class GenericImapResult { + /// A list of possible warnings + final List warnings = []; + + /// Optional response code as text + String? responseCode; + + /// Optional details as text + String? details; + + /// Retrieves the APPENDUID details after an APPEND call, + /// compare https://tools.ietf.org/html/rfc4315 + UidResponseCode? get responseCodeAppendUid => + _parseUidResponseCode('APPENDUID'); + + /// Retrieves the COPYUID details after an COPY call, + /// compare https://tools.ietf.org/html/rfc4315 + UidResponseCode? get responseCodeCopyUid => _parseUidResponseCode('COPYUID'); + + UidResponseCode? _parseUidResponseCode(String name) { + final responseCode = this.responseCode; + if (responseCode != null && responseCode.startsWith(name)) { + final uidParts = responseCode.substring(name.length + 1).split(' '); + if (uidParts.length == 3) { + if (uidParts[1].isEmpty || uidParts[2].isEmpty) { + return null; + } + + return UidResponseCode( + int.parse(uidParts[0]), + MessageSequence.parse(uidParts[1], isUidSequence: true), + MessageSequence.parse(uidParts[2], isUidSequence: true), + ); + } else if (uidParts.length == 2) { + if (uidParts[1].isEmpty) { + return null; + } + + return UidResponseCode( + int.parse(uidParts[0]), + null, + MessageSequence.parse(uidParts[1], isUidSequence: true), + ); + } + } + + return null; + } +} + +/// Result for FETCH operations +class FetchImapResult { + /// Creates a new fetch result + const FetchImapResult( + this.messages, + this.vanishedMessagesUidSequence, { + this.modifiedSequence, + }); + + /// Any messages that have been removed by other clients. + /// This is only given from QRESYNC compliant servers after having enabled + /// `QRESYNC` by the client. + /// Clients must NOT use these vanished sequence to update their + /// internal sequence IDs, because + /// they have happened earlier. + /// Compare https://tools.ietf.org/html/rfc7162 for details. + final MessageSequence? vanishedMessagesUidSequence; + + /// The sequence of messages that have been modified + final MessageSequence? modifiedSequence; + + /// The requested messages + final List messages; + + /// Replaces matching messages + void replaceMatchingMessages(List newMessages) { + for (final mime in newMessages) { + final uid = mime.uid; + final sequenceId = mime.sequenceId; + if (uid != null) { + final index = messages.indexWhere((msg) => msg.uid == uid); + if (index != -1) { + messages[index] = mime; + } + } else if (sequenceId != null) { + final index = + messages.indexWhere((msg) => msg.sequenceId == sequenceId); + if (index != -1) { + messages[index] = mime; + } + } + } + } +} + +/// Result for STORE and UID STORE operations +class StoreImapResult { + /// A list of messages that have been updated + List? changedMessages; + + /// A list of IDs of messages that have been modified on the server side. + /// The IDs are sequence IDs for STORE and UIDs for UID STORE commands. + /// The modified IDs can only be returned when the unchangedSinceModSequence + /// parameter has been specified. + MessageSequence? modifiedMessageSequence; +} + +/// Result for SEARCH and UID SEARCH operations +class SearchImapResult { + /// A list of message IDs + MessageSequence? matchingSequence; + + /// The highest modification sequence in the searched messages + /// The modification sequence can only be returned when the `MODSEQ` search + /// criteria has been used and when the server supports the + /// `CONDSTORE` capability. + int? highestModSequence; + + /// Identifies an extended search result + bool? isExtended; + + /// Result tag + String? tag; + + /// Minimum found message ID or UID + int? min; + + /// Maximum found message ID or UID + int? max; + + /// Matches count + int? count; + + /// Range of the partial result returned + String? partialRange; + + /// Is this a partial search response? + bool get isPartial { + final partialRange = this.partialRange; + + return partialRange != null && partialRange.isNotEmpty; + } +} + +/// Contains a UID response code +class UidResponseCode { + /// Creates a new response code + const UidResponseCode( + this.uidValidity, + this.originalSequence, + this.targetSequence, + ); + + /// The UID validity + final int uidValidity; + + /// The optional original sequence + final MessageSequence? originalSequence; + + /// The optional target sequence + final MessageSequence targetSequence; +} + +/// Warnings can often be ignored but provide more insights in case of problems +/// They are given in untagged responses of the server. +class ImapWarning { + /// Creates a new warning instance + const ImapWarning(this.type, this.details); + + /// Either 'BAD' or 'NO' + final String type; + + /// The human readable error + final String details; +} + +/// Result for QUOTA operations +class QuotaResult { + /// Creates a new quota result + const QuotaResult(this.rootName, this.resourceLimits); + + /// The optional name of the root + final String? rootName; + + /// The resource limits + final List resourceLimits; +} + +/// Result for QUOTAROOT operations +class QuotaRootResult { + /// Creates a new quota root result + QuotaRootResult(this.mailboxName, this.rootNames); + + /// The name of the associated mailbox + final String mailboxName; + + /// All names in this root + final List rootNames; + + /// The quota results + Map quotaRoots = {}; +} + +/// Result for SORT and UID SORT operations +/// +/// Copy of [SearchImapResult] class because SEARCH and SORT are equivalents +class SortImapResult { + /// A list of message IDs + MessageSequence? matchingSequence; + + /// The highest modification sequence in the searched messages + /// + /// The modification sequence can only be returned when the `MODSEQ` search + /// criteria has been used and when the server supports the + /// `CONDSTORE` capability. + int? highestModSequence; + + /// Signals an extended sort result + bool? isExtended; + + /// Result tag + String? tag; + + /// Minimum found message ID or UID + int? min; + + /// Maximum found message ID or UID + int? max; + + /// Matches count + int? count; + + /// Range of the partial result returned + String? partialRange; + + /// Is this a partial response? + bool get isPartial { + final partialRange = this.partialRange; + + return partialRange != null && partialRange.isNotEmpty; + } +} diff --git a/packages/enough_mail/lib/src/imap/return_option.dart b/packages/enough_mail/lib/src/imap/return_option.dart new file mode 100644 index 0000000..2fcdfe1 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/return_option.dart @@ -0,0 +1,111 @@ +import '../exception.dart'; + +/// Return option definition for extended commands. +class ReturnOption { + /// Creates a new return option + ReturnOption(this.name, {this.parameters, this.isSingleParam = false}); + + /// Creates a new return option + ReturnOption.specialUse() : this('SPECIAL-USE'); + + /// Returns subscription state of all matching mailbox names. + ReturnOption.subscribed() : this('SUBSCRIBED'); + + /// Returns mailbox child information as flags "\HasChildren", + /// "\HasNoChildren". + ReturnOption.children() : this('CHILDREN'); + + /// Returns given STATUS information of all matching mailbox names. + /// + /// A number of [parameters] must be provided for returning their status. + ReturnOption.status([List? parameters]) + : this( + 'STATUS', + parameters: parameters, + ); + + /// Returns the minimum message id or UID satisfying the search parameters. + ReturnOption.min() : this('MIN'); + + /// Return the maximum message id or UID that satisfies the search parameters. + ReturnOption.max() : this('MAX'); + + /// Returns all the message ids or UIDs that satisfies the search parameters. + ReturnOption.all() : this('ALL'); + + /// Returns the match count of the search request. + ReturnOption.count() : this('COUNT'); + + /// Defines a partial range of the found results. + ReturnOption.partial(String rangeSet) + : this( + 'PARTIAL', + parameters: [rangeSet], + isSingleParam: true, + ); + + /// The name of this option + final String name; + + /// Optional list of return option parameters. + final List? parameters; + + /// If set, the option allows only one parameter not enclosed by "()". + final bool isSingleParam; + + /// Adds the given [parameter] + void add(String parameter) { + final parameters = this.parameters; + if (parameters == null) { + throw InvalidArgumentException( + '$name return option doesn\'t allow any parameter', + ); + } + if (isSingleParam && parameters.isNotEmpty) { + parameters.replaceRange(0, 0, [parameter]); + } else { + parameters.add(parameter); + } + } + + /// Adds all parameters + void addAll(List parameters) { + final parameters = this.parameters; + + if (parameters == null) { + throw InvalidArgumentException( + '$name return option doesn\'t allow any parameter', + ); + } + if (isSingleParam && parameters.length > 1) { + throw InvalidArgumentException( + '$name return options allows only one parameter', + ); + } + parameters.addAll(parameters); + } + + /// Checks of this return options has the specified [parameter] + bool hasParameter(String parameter) => + parameters?.contains(parameter) ?? false; + + @override + String toString() { + final result = StringBuffer(name); + final parameters = this.parameters; + if (parameters != null) { + if (isSingleParam && parameters.isNotEmpty) { + result + ..write(' ') + ..write(parameters[0]); + } else if (parameters.isNotEmpty) { + result + ..write(' (') + ..write(parameters.join(' ')) + ..write(')'); + } + } + + return result.toString(); + } +} diff --git a/packages/enough_mail/lib/src/imap/selection_options.dart b/packages/enough_mail/lib/src/imap/selection_options.dart new file mode 100644 index 0000000..71a6e09 --- /dev/null +++ b/packages/enough_mail/lib/src/imap/selection_options.dart @@ -0,0 +1,36 @@ +/// LIST-EXTENDED selection options +enum SelectionOptions { + /// Includes flags for special-use mailboxes, + /// such as those used to hold draft messages or sent messages. + specialUse, + + /// List only subscribed names. + /// Supplements the `LSUB` command with accurate and complete information. + subscribed, + + /// Shows also remote mailboxes, marked with "\Remote" attribute. + remote, + + /// Forces the return of information about non matched mailboxes + /// whose children matches the selection options. + /// + /// Cannot be uses alone or in combination with only the REMOTE option + recursiveMatch, +} + +/// Extension on [SelectionOptions] +extension Stringify on SelectionOptions { + /// The value as text + String value() { + switch (this) { + case SelectionOptions.specialUse: + return 'SPECIAL-USE'; + case SelectionOptions.subscribed: + return 'SUBSCRIBED'; + case SelectionOptions.remote: + return 'REMOTE'; + case SelectionOptions.recursiveMatch: + return 'RECURSIVEMATCH'; + } + } +} diff --git a/packages/enough_mail/lib/src/mail/mail_account.dart b/packages/enough_mail/lib/src/mail/mail_account.dart new file mode 100644 index 0000000..2e70053 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_account.dart @@ -0,0 +1,439 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:json_annotation/json_annotation.dart'; + +import '../discover/client_config.dart'; +import '../imap/imap_client.dart'; +import '../mail_address.dart'; +import '../private/util/non_nullable.dart'; +import 'mail_authentication.dart'; + +part 'mail_account.g.dart'; + +/// Contains information about a single mail account +@JsonSerializable() +class MailAccount { + /// Creates a new empty mail account + const MailAccount({ + required this.name, + required this.email, + required this.incoming, + required this.outgoing, + this.userName = '', + this.outgoingClientDomain = 'enough.de', + this.supportsPlusAliases = false, + this.aliases = const [], + this.attributes = const {}, + }); + + /// Creates a mail account with the given [name] from the discovered [config] + /// with the given [auth] for the preferred incoming and + /// preferred outgoing server. + /// + /// Optionally specify a different [outgoingAuth] if needed. + /// For SMTP usage you also should define the [outgoingClientDomain], + /// which defaults to `enough.de`. + factory MailAccount.fromDiscoveredSettingsWithAuth({ + required String name, + required String email, + required MailAuthentication auth, + required ClientConfig config, + String userName = '', + String outgoingClientDomain = 'enough.de', + MailAuthentication? outgoingAuth, + bool supportsPlusAliases = false, + List aliases = const [], + }) { + final incoming = MailServerConfig( + authentication: auth, + serverConfig: toValueOrThrow( + config.preferredIncomingImapServer ?? config.preferredIncomingServer, + 'No incoming server found', + ), + ); + final outgoing = MailServerConfig( + authentication: outgoingAuth ?? auth, + serverConfig: config.preferredOutgoingServer.toValueOrThrow( + 'No outgoing server found', + ), + ); + + return MailAccount( + name: name, + email: email, + incoming: incoming, + outgoing: outgoing, + userName: userName, + outgoingClientDomain: outgoingClientDomain, + supportsPlusAliases: supportsPlusAliases, + aliases: aliases, + ); + } + + /// Creates a mail account from manual settings + /// with a simple user-name/password authentication. + /// + /// You need to specify the account [name], the associated [email], + /// the [incomingHost], [outgoingHost] and [password]. + /// + /// When the [userName] is different from the email, + /// it must also be specified. + /// + /// You should specify the [outgoingClientDomain] for sending messages, + /// this defaults to `enough.de`. + /// + /// The [incomingType] defaults to [ServerType.imap], the [incomingPort] + /// to `993` and the [incomingSocketType] to [SocketType.ssl]. + /// + /// The [outgoingType] defaults to [ServerType.smtp], the [outgoingPort] + /// to `465` and the [outgoingSocketType] to [SocketType.ssl]. + factory MailAccount.fromManualSettings({ + required String name, + required String email, + required String incomingHost, + required String outgoingHost, + required String password, + String userName = '', + ServerType incomingType = ServerType.imap, + ServerType outgoingType = ServerType.smtp, + String? loginName, + String outgoingClientDomain = 'enough.de', + int incomingPort = 993, + int outgoingPort = 465, + SocketType incomingSocketType = SocketType.ssl, + SocketType outgoingSocketType = SocketType.ssl, + bool supportsPlusAliases = false, + List aliases = const [], + }) => + MailAccount.fromManualSettingsWithAuth( + name: name, + email: email, + userName: userName, + incomingHost: incomingHost, + outgoingHost: outgoingHost, + auth: PlainAuthentication(loginName ?? email, password), + incomingType: incomingType, + outgoingType: outgoingType, + outgoingClientDomain: outgoingClientDomain, + incomingPort: incomingPort, + outgoingPort: outgoingPort, + incomingSocketType: incomingSocketType, + outgoingSocketType: outgoingSocketType, + supportsPlusAliases: supportsPlusAliases, + aliases: aliases, + ); + + /// Creates a mail account from manual settings with the specified [auth]. + /// + /// You need to specify the account [name], the associated [email], + /// the [incomingHost], [outgoingHost] and [auth]. + /// + /// You can specify a different authentication for the outgoing server using + /// the [outgoingAuth] parameter. + /// + /// You should specify the [outgoingClientDomain] for sending messages, + /// this defaults to `enough.de`. + /// + /// The [incomingType] defaults to [ServerType.imap], the [incomingPort] to + /// `993` and the [incomingSocketType] to [SocketType.ssl]. + /// + /// The [outgoingType] defaults to [ServerType.smtp], the [outgoingPort] to + /// `465` and the [outgoingSocketType] to [SocketType.ssl]. + factory MailAccount.fromManualSettingsWithAuth({ + required String name, + required String email, + required String incomingHost, + required String outgoingHost, + required MailAuthentication auth, + String userName = '', + ServerType incomingType = ServerType.imap, + ServerType outgoingType = ServerType.smtp, + MailAuthentication? outgoingAuth, + String outgoingClientDomain = 'enough.de', + incomingPort = 993, + outgoingPort = 465, + SocketType incomingSocketType = SocketType.ssl, + SocketType outgoingSocketType = SocketType.ssl, + bool supportsPlusAliases = false, + List aliases = const [], + }) { + final incoming = MailServerConfig( + authentication: auth, + serverConfig: ServerConfig( + type: incomingType, + hostname: incomingHost, + port: incomingPort, + socketType: incomingSocketType, + authentication: auth.authentication, + usernameType: UsernameType.unknown, + ), + ); + final outgoing = MailServerConfig( + authentication: outgoingAuth ?? auth, + serverConfig: ServerConfig( + type: outgoingType, + hostname: outgoingHost, + port: outgoingPort, + socketType: outgoingSocketType, + authentication: auth.authentication, + usernameType: UsernameType.unknown, + ), + ); + + return MailAccount( + name: name, + email: email, + incoming: incoming, + outgoing: outgoing, + userName: userName, + outgoingClientDomain: outgoingClientDomain, + supportsPlusAliases: supportsPlusAliases, + aliases: aliases, + ); + } + + /// Creates a new [MailAccount] from the given [json] + factory MailAccount.fromJson(Map json) => + _$MailAccountFromJson(json); + + /// Creates a mail account with the given [name] for the specified [email] + /// from the discovered [config] with a a plain authentication for the + /// preferred incoming and preferred outgoing server. + /// + /// You nee to specify the [password]. + /// + /// Specify the [userName] if it cannot be deducted from the email + /// or the discovery config. + /// + /// For SMTP usage you also should define the [outgoingClientDomain], + /// which defaults to `enough.de`. + factory MailAccount.fromDiscoveredSettings({ + required String name, + required String email, + required String password, + required ClientConfig config, + required String userName, + String outgoingClientDomain = 'enough.de', + String? loginName, + bool supportsPlusAliases = false, + List aliases = const [], + }) => + MailAccount.fromDiscoveredSettingsWithAuth( + name: name, + email: email, + userName: userName, + auth: PlainAuthentication( + loginName ?? + getLoginName( + email, + config.preferredIncomingServer.toValueOrThrow( + 'no preferred incoming server found', + ), + ), + password, + ), + config: config, + outgoingClientDomain: outgoingClientDomain, + supportsPlusAliases: supportsPlusAliases, + aliases: aliases, + ); + + /// Generates JSON from this [MailAccount] + Map toJson() => _$MailAccountToJson(this); + + /// The name of the account + final String name; + + // cSpell: ignore Ghez + /// The associated name of the user such as `First Last`, e.g. `Andrea Ghez` + final String userName; + + /// The email address of the user + final String email; + + /// Incoming mail settings + final MailServerConfig incoming; + + /// Outgoing mail settings + final MailServerConfig outgoing; + + /// The domain that is reported to the outgoing SMTP service + final String outgoingClientDomain; + + /// Convenience getter for the from MailAddress + MailAddress get fromAddress => MailAddress(userName, email); + + /// Optional list of associated aliases + final List aliases; + + /// Optional indicator if the mail service supports + based aliases + /// + /// E.g. `user+alias@domain.com`. + final bool supportsPlusAliases; + + /// Further attributes + /// + /// Note that you need to specify these attributes in case you want them, + /// by default an unmodifiable `const {}` is used. + final Map attributes; + + /// Checks if this account has an attribute with the specified [name] + bool hasAttribute(String name) => attributes.containsKey(name); + + /// Retrieves the user name from the given [email] and + /// the discovered [serverConfig]. + /// + /// Defaults to the email when the serverConfig does not contain any rules. + static String getLoginName(String email, ServerConfig serverConfig) => + serverConfig.getUserName(email) ?? email; + + @override + bool operator ==(Object other) => + other is MailAccount && + other.name == name && + other.userName == userName && + other.email == email && + other.outgoingClientDomain == outgoingClientDomain && + other.incoming == incoming && + other.outgoing == outgoing && + other.supportsPlusAliases == supportsPlusAliases && + other.aliases.length == aliases.length && + other.attributes.length == attributes.length; + + @override + int get hashCode => name.hashCode | email.hashCode; + + @override + String toString() => jsonEncode(toJson()); + + /// Creates a new [MailAccount] with the given settings or by copying + /// the current settings. + /// + /// Compare [copyWithAttribute], [copyWithAlias] + MailAccount copyWith({ + String? name, + String? email, + String? userName, + MailServerConfig? incoming, + MailServerConfig? outgoing, + List? aliases, + Map? attributes, + String? outgoingClientDomain, + bool? supportsPlusAliases, + }) => + MailAccount( + name: name ?? this.name, + email: email ?? this.email, + userName: userName ?? this.userName, + incoming: incoming ?? this.incoming, + outgoing: outgoing ?? this.outgoing, + aliases: aliases ?? this.aliases, + outgoingClientDomain: outgoingClientDomain ?? this.outgoingClientDomain, + supportsPlusAliases: supportsPlusAliases ?? this.supportsPlusAliases, + attributes: attributes ?? this.attributes, + ); + + /// Copies this account with the attribute [name] and [value] + /// + /// Compare [copyWith], [copyWithAlias] + MailAccount copyWithAttribute(String name, dynamic value) { + final attributes = + this.attributes.isEmpty ? {} : this.attributes; + attributes[name] = value; + + return copyWith(attributes: attributes); + } + + /// Copies this account with the additional [alias] + /// + /// Compare [copyWith], [copyWithAttribute] + MailAccount copyWithAlias(MailAddress alias) { + final aliases = this.aliases.isEmpty ? [] : this.aliases + ..add(alias); + + return copyWith(aliases: aliases); + } + + /// Convenience method to update the incoming and outgoing authentication + /// user name for identifying the user towards the mail service. + MailAccount copyWithAuthenticationUserName(String authenticationUserName) { + var incomingAuth = incoming.authentication; + if (incomingAuth is UserNameBasedAuthentication) { + incomingAuth = incomingAuth.copyWithUserName(authenticationUserName); + } + var outgoingAuth = outgoing.authentication; + if (outgoingAuth is UserNameBasedAuthentication) { + outgoingAuth = outgoingAuth.copyWithUserName(authenticationUserName); + } + + return copyWith( + incoming: incoming.copyWith(authentication: incomingAuth), + outgoing: outgoing.copyWith(authentication: outgoingAuth), + ); + } +} + +/// Configuration of a specific mail service like IMAP, POP3 or SMTP +@JsonSerializable() +class MailServerConfig { + /// Creates a new mail server configuration + const MailServerConfig({ + required this.serverConfig, + required this.authentication, + this.serverCapabilities = const [], + this.pathSeparator = '/', + }); + + /// Creates a new [MailServerConfig] from the given [json] + factory MailServerConfig.fromJson(Map json) => + _$MailServerConfigFromJson(json); + + /// Converts this [MailServerConfig] to JSON + Map toJson() => _$MailServerConfigToJson(this); + + /// The server configuration like host, port and socket type + final ServerConfig serverConfig; + + /// The authentication like [PlainAuthentication] or [OauthAuthentication] + final MailAuthentication authentication; + + /// Capabilities of the server + final List serverCapabilities; + + /// Path separator of the server, e.g. `/` + final String pathSeparator; + + /// Checks of the given capability is supported + bool supports(String capabilityName) => + serverCapabilities.firstWhereOrNull((c) => c.name == capabilityName) != + null; + + @override + bool operator ==(Object other) => + other is MailServerConfig && + other.pathSeparator == pathSeparator && + other.serverCapabilities.length == serverCapabilities.length && + other.authentication == authentication && + other.serverConfig == serverConfig; + + @override + int get hashCode => serverConfig.hashCode | authentication.hashCode; + + @override + String toString() => jsonEncode(toJson()); + + /// Copies this [MailServerConfig] with the given values + MailServerConfig copyWith({ + ServerConfig? serverConfig, + MailAuthentication? authentication, + String? pathSeparator, + List? serverCapabilities, + }) => + MailServerConfig( + serverConfig: serverConfig ?? this.serverConfig, + authentication: authentication ?? this.authentication, + pathSeparator: pathSeparator ?? this.pathSeparator, + serverCapabilities: serverCapabilities ?? this.serverCapabilities, + ); +} diff --git a/packages/enough_mail/lib/src/mail/mail_authentication.dart b/packages/enough_mail/lib/src/mail/mail_authentication.dart new file mode 100644 index 0000000..c622ca5 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_authentication.dart @@ -0,0 +1,328 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +import '../discover/client_config.dart'; +import '../exception.dart'; +import '../imap/imap_client.dart'; +import '../pop/pop_client.dart'; +import '../private/util/non_nullable.dart'; +import '../smtp/smtp_client.dart'; + +part 'mail_authentication.g.dart'; + +/// Contains an authentication for a mail service +/// Compare [PlainAuthentication] and [OauthAuthentication] for implementations. +abstract class MailAuthentication { + /// Creates a new authentication with the given [typeName] + const MailAuthentication(this.authentication); + + /// Creates a new [MailAuthentication] from the given [json] + factory MailAuthentication.fromJson(Map json) { + final authentication = json['authentication'] ?? json['typeName']; + switch (authentication) { + case 'plain': + return PlainAuthentication.fromJson(json); + case 'oauth': + case 'oauth2': + return OauthAuthentication.fromJson(json); + } + throw InvalidArgumentException( + 'unsupported MailAuthentication type [$authentication]', + ); + } + + /// Converts this [MailAuthentication] to JSON + Map toJson(); + + /// The type of this authentication + final Authentication authentication; + + /// The name of this authentication type, e.g. `plain` or `oauth2` + String get typeName => authentication.name; + + /// Authenticates with the specified mail service + Future authenticate( + ServerConfig serverConfig, { + ImapClient? imap, + PopClient? pop, + SmtpClient? smtp, + }); +} + +/// Base class for authentications with user-names +abstract class UserNameBasedAuthentication extends MailAuthentication { + /// Creates a new user name based auth + const UserNameBasedAuthentication(this.userName, super.authentication); + + /// The user name + final String userName; + + /// Copies this authentication with the new [userName] + UserNameBasedAuthentication copyWithUserName(String userName); +} + +/// Provides a simple username-password authentication +@JsonSerializable() +class PlainAuthentication extends UserNameBasedAuthentication { + /// Creates a new plain authentication + /// with the given [userName] and [password]. + const PlainAuthentication(String userName, this.password) + : super(userName, Authentication.plain); + + /// Creates a new [PlainAuthentication] from the given [json] + factory PlainAuthentication.fromJson(Map json) => + _$PlainAuthenticationFromJson(json); + + /// Converts this [PlainAuthentication] to JSON + @override + Map toJson() => + _$PlainAuthenticationToJson(this)..['typeName'] = typeName; + + /// The password + final String password; + + @override + Future authenticate( + ServerConfig serverConfig, { + ImapClient? imap, + PopClient? pop, + SmtpClient? smtp, + }) async { + final name = userName; + final pwd = password; + switch (serverConfig.type) { + case ServerType.imap: + await imap.toValueOrThrow('no [ImapClient] found').login(name, pwd); + break; + case ServerType.pop: + await pop.toValueOrThrow('no [PopClient] found').login(name, pwd); + break; + case ServerType.smtp: + if (smtp == null) { + throw ArgumentError('no [SmtpClient] found'); + } + final authMechanism = smtp.serverInfo.supportsAuth(AuthMechanism.plain) + ? AuthMechanism.plain + : smtp.serverInfo.supportsAuth(AuthMechanism.login) + ? AuthMechanism.login + : AuthMechanism.cramMd5; + await smtp.authenticate(name, pwd, authMechanism); + break; + default: + throw InvalidArgumentException( + 'Unknown server type ${serverConfig.typeName}', + ); + } + } + + @override + bool operator ==(Object other) => + other is PlainAuthentication && + other.userName == userName && + other.password == password; + + @override + int get hashCode => userName.hashCode | password.hashCode; + + @override + UserNameBasedAuthentication copyWithUserName(String userName) => + PlainAuthentication(userName, password); + + /// Copies this authentication with the given values + PlainAuthentication copyWith({String? userName, String? password}) => + PlainAuthentication(userName ?? this.userName, password ?? this.password); +} + +/// Contains an OAuth compliant token +@JsonSerializable() +class OauthToken { + /// Creates a new token + const OauthToken({ + required this.accessToken, + required this.expiresIn, + required this.refreshToken, + required this.scope, + required this.tokenType, + required this.created, + this.provider, + }); + + /// Creates a new [OauthToken] from the given [json] + factory OauthToken.fromJson(Map json) => + _$OauthTokenFromJson(json); + + /// Parses a new token from the given [text]. + factory OauthToken.fromText( + String text, { + String? provider, + String? refreshToken, + }) { + final json = jsonDecode(text); + if (provider != null) { + json['provider'] = provider; + } + if (refreshToken != null && json['refresh_token'] == null) { + json['refresh_token'] = refreshToken; + } + if (json['created'] == null) { + json['created'] = DateTime.now().toUtc().toIso8601String(); + } + + return OauthToken.fromJson(json); + } + + /// Converts this [OauthToken] to JSON. + Map toJson() => _$OauthTokenToJson(this); + + /// Token for API access + @JsonKey(name: 'access_token') + final String accessToken; + + /// Expiration in seconds from [created] time + /// + /// Compare [expiresDateTime], [willExpireIn] + /// and [isExpired] + @JsonKey(name: 'expires_in') + final int expiresIn; + + /// Token for refreshing the [accessToken] + @JsonKey(name: 'refresh_token') + final String refreshToken; + + /// Granted scope(s) for access + final String scope; + + /// Type of the token + @JsonKey(name: 'token_type') + final String tokenType; + + /// UTC time of creation of this token + /// + /// Typically `DateTime.now().toUtc()` + final DateTime created; + + /// Optional, implementation-specific provider + final String? provider; + + /// Checks if this token is expired + /// + /// Compare [willExpireIn] and [isValid] + bool get isExpired => expiresDateTime.isBefore(DateTime.now().toUtc()); + + /// Checks if the token is already expired or will expire + /// within the given (positive) [duration], e.g. + /// `const Duration(minutes: 15)`. + bool willExpireIn(Duration duration) => + expiresDateTime.isBefore(DateTime.now().toUtc().add(duration)); + + /// Retrieves the expiry date time + /// + /// Compare [willExpireIn] + DateTime get expiresDateTime => created.add(Duration(seconds: expiresIn)); + + /// Checks if this token is still valid, ie not expired. + /// + /// Compare [isExpired] + bool get isValid => !isExpired; + + /// Refreshes this token with the new [accessToken] and [expiresIn]. + OauthToken copyWith(String accessToken, int expiresIn) => OauthToken( + accessToken: accessToken, + expiresIn: expiresIn, + refreshToken: refreshToken, + scope: scope, + tokenType: tokenType, + provider: provider, + created: DateTime.now().toUtc(), + ); + + @override + String toString() => jsonEncode(toJson()); +} + +/// Provides an OAuth-compliant authentication +@JsonSerializable() +class OauthAuthentication extends UserNameBasedAuthentication { + /// Creates a new authentication + const OauthAuthentication(String userName, this.token) + : super(userName, Authentication.oauth2); + + /// Creates a new [OauthAuthentication] from the given [json] + factory OauthAuthentication.fromJson(Map json) => + _$OauthAuthenticationFromJson(json); + + /// Creates an OauthAuthentication from the given [userName] + /// and [oauthTokenText] in JSON. + /// + /// Optionally specify the [provider] for identifying tokens later. + factory OauthAuthentication.from( + String userName, + String oauthTokenText, { + String? provider, + }) { + final token = OauthToken.fromText(oauthTokenText, provider: provider); + + return OauthAuthentication(userName, token); + } + + /// Converts this [OauthAuthentication] to JSON. + @override + Map toJson() => + _$OauthAuthenticationToJson(this)..['typeName'] = typeName; + + /// Token for the access + final OauthToken token; + + @override + Future authenticate( + ServerConfig serverConfig, { + ImapClient? imap, + PopClient? pop, + SmtpClient? smtp, + }) async { + final userName = this.userName; + final accessToken = token.accessToken; + switch (serverConfig.type) { + case ServerType.imap: + await imap + .toValueOrThrow('no [ImapClient] found') + .authenticateWithOAuth2(userName, accessToken); + break; + case ServerType.pop: + await pop + .toValueOrThrow('no [PopClient] found') + .login(userName, accessToken); + break; + case ServerType.smtp: + await smtp + .toValueOrThrow('no [SmtpClient] found') + .authenticate(userName, accessToken, AuthMechanism.xoauth2); + break; + default: + throw InvalidArgumentException( + 'Unknown server type ${serverConfig.typeName}', + ); + } + } + + @override + bool operator ==(Object other) => + other is OauthAuthentication && + other.userName == userName && + other.token.accessToken == token.accessToken; + + @override + int get hashCode => userName.hashCode | token.hashCode; + + /// Copies this [OauthAuthentication] with the given [token] + OauthAuthentication copyWith({String? userName, OauthToken? token}) => + OauthAuthentication( + userName ?? this.userName, + token ?? this.token, + ); + + @override + UserNameBasedAuthentication copyWithUserName(String userName) => + OauthAuthentication(userName, token); +} diff --git a/packages/enough_mail/lib/src/mail/mail_client.dart b/packages/enough_mail/lib/src/mail/mail_client.dart new file mode 100644 index 0000000..e97e139 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_client.dart @@ -0,0 +1,3699 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:event_bus/event_bus.dart'; +import 'package:synchronized/synchronized.dart'; + +import '../../enough_mail.dart'; +import '../private/util/client_base.dart'; +import '../private/util/non_nullable.dart'; + +/// Definition for optional event filters, compare [MailClient.addEventFilter]. +typedef MailEventFilter = bool Function(MailEvent event); + +/// The client's preference when fetching messages +enum FetchPreference { + /// Only envelope data is preferred - this is the fasted option + envelope, + + /// Only the structural information is preferred + bodystructure, + + /// The full message details are preferred + full, + + /// The full message when the size is within the limits, otherwise envelope + fullWhenWithinSize, +} + +/// Highlevel online API to access mail. +class MailClient { + /// Creates a new highlevel online mail client for the given [account]. + /// + /// Specify the account settings with [account]. + /// + /// Set [isLogEnabled] to `true` to debug connection issues and the [logName] + /// to differentiate between mail clients. + /// + /// Set a [defaultWriteTimeout] if you do not want to use the default + /// timeout of 2 seconds. + /// + /// Set a [defaultResponseTimeout] if you do not want to use the default + /// timeout for waiting for responses to simple commands of 5 seconds. + /// + /// Specify the optional [downloadSizeLimit] in bytes to only download + /// messages automatically that are this size or lower. + /// + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + /// + /// Set a [clientId] when the ID should be send automatically after logging + /// in for IMAP servers that supports the + /// [IMAP4 ID extension](https://datatracker.ietf.org/doc/html/rfc2971). + /// + /// Specify the [refresh] callback in case you support OAuth-based tokens + /// that might expire. + /// + /// Specify the optional [onConfigChanged] callback for persisting a changed + /// token in the account, after it has been refreshed. + MailClient( + MailAccount account, { + bool isLogEnabled = false, + int? downloadSizeLimit, + EventBus? eventBus, + String? logName, + this.defaultWriteTimeout = const Duration(seconds: 2), + this.defaultResponseTimeout = const Duration(seconds: 5), + bool Function(X509Certificate)? onBadCertificate, + this.clientId, + Future Function(MailClient client, OauthToken expiredToken)? + refresh, + Future Function(MailAccount account)? onConfigChanged, + }) : _eventBus = eventBus ?? EventBus(), + _account = account, + _isLogEnabled = isLogEnabled, + _downloadSizeLimit = downloadSizeLimit, + _refreshOAuthToken = refresh, + _onConfigChanged = onConfigChanged { + final config = _account.incoming; + if (config.serverConfig.type == ServerType.imap) { + _incomingMailClient = _IncomingImapClient( + _downloadSizeLimit, + _eventBus, + logName, + defaultWriteTimeout, + defaultResponseTimeout, + config, + this, + isLogEnabled: _isLogEnabled, + onBadCertificate: onBadCertificate, + ); + } else if (config.serverConfig.type == ServerType.pop) { + _incomingMailClient = _IncomingPopClient( + _downloadSizeLimit, + _eventBus, + logName, + config, + this, + isLogEnabled: _isLogEnabled, + onBadCertificate: onBadCertificate, + ); + } else { + throw InvalidArgumentException( + 'Unsupported incoming' + 'server type [${config.serverConfig.typeName}].', + ); + } + final outgoingConfig = _account.outgoing; + if (outgoingConfig.serverConfig.type != ServerType.smtp) { + print( + 'Warning: unknown outgoing server ' + 'type ${outgoingConfig.serverConfig.typeName}.', + ); + } + _outgoingMailClient = _OutgoingSmtpClient( + this, + _account.outgoingClientDomain, + _eventBus, + 'SMTP-$logName', + outgoingConfig, + isLogEnabled: _isLogEnabled, + onBadCertificate: onBadCertificate, + ); + } + + /// Default polling duration (every 2 minutes) + static const Duration defaultPollingDuration = Duration(minutes: 2); + + /// Default ordering for mailboxes + static const List defaultMailboxOrder = [ + MailboxFlag.inbox, + MailboxFlag.drafts, + MailboxFlag.sent, + MailboxFlag.trash, + MailboxFlag.archive, + MailboxFlag.junk, + ]; + + /// The default limit in bytes for downloading messages fully + final int? _downloadSizeLimit; + MailAccount _account; + + /// The mail account associated used by this client + MailAccount get account => _account; + + /// Callback for refreshing tokens + final Future Function( + MailClient client, + OauthToken expiredToken, + )? _refreshOAuthToken; + + /// Callback for getting notified when the config has changed, + /// ie after an OAuth login token has been refreshed + final Future Function(MailAccount account)? _onConfigChanged; + + /// Checks if the connected service supports threading + /// + /// Compare [fetchThreads] + bool get supportsThreading => _incomingMailClient.supportsThreading; + + bool _isConnected = false; + + /// Checks if this mail client is connected + /// + /// Compare [connect] + bool get isConnected => _isConnected; + + /// event bus for firing and listening to events + EventBus get eventBus => _eventBus; + final EventBus _eventBus; + + /// Filter for mail events. + /// + /// Allows to suppress events being forwarded to the [eventBus]. + List? _eventFilters; + + final bool _isLogEnabled; + + Mailbox? _selectedMailbox; + + /// Retrieves the currently selected mailbox, if any. + /// + /// Compare [selectMailbox]. + Mailbox? get selectedMailbox => _selectedMailbox; + + List? _mailboxes; + + /// Retrieves the previously caches mailboxes + List? get mailboxes => _mailboxes; + + /// Retrieves the low level mail client for reading mails + /// + /// Example: + /// ``` + /// final lowlevelClient = mailClient.lowLevelIncomingMailClient; + /// if (lowlevelClient is ImapClient) { + /// final response = await lowlevelClient. + /// uidFetchMessage(1232, '(ENVELOPE HEADER[])'); + /// } + /// ``` + ClientBase get lowLevelIncomingMailClient => _incomingMailClient.client; + + /// Retrieves the type of the low level incoming client. + /// + /// Currently either [ServerType.imap] or [ServerType.pop] + ServerType get lowLevelIncomingMailClientType => + _incomingMailClient.clientType; + + /// Retrieves the low level mail client for sending mails + /// + /// Example: + /// ``` + /// final smtpClient = mailClient.lowLevelOutgoingMailClient as SmtpClient; + /// final response = await smtpClient.ehlo(); + /// ``` + ClientBase get lowLevelOutgoingMailClient => _outgoingMailClient.client; + + /// Retrieves the type pof the low level mail client. + /// + /// Currently always [ServerType.smtp] + ServerType get lowLevelOutgoingMailClientType => + _outgoingMailClient.clientType; + + /// The ID of the client app using this MailClient. + /// + /// Compare [serverId] + final Id? clientId; + + /// The ID of the IMAP server this mail client is connected to. + /// + /// Compare [clientId] + Id? get serverId => _incomingMailClient.serverId; + + /// The default timeout for write operations + final Duration? defaultWriteTimeout; + + /// The default timeout for server responses, + /// currently only used on IMAP for selected commands. + final Duration? defaultResponseTimeout; + + late _IncomingMailClient _incomingMailClient; + late _OutgoingMailClient _outgoingMailClient; + final _incomingLock = Lock(); + final _outgoingLock = Lock(); + + /// Adds the specified mail event [filter]. + /// + /// You can use a filter to suppress matching `MailEvent`. + /// Compare [eventBus]. + void addEventFilter(MailEventFilter filter) { + _eventFilters ??= []; + _eventFilters?.add(filter); + } + + /// Removes the specified mail event [filter]. + /// + /// Compare `addEventFilter()`. + void removeEventFilter(MailEventFilter filter) { + final filters = _eventFilters; + if (filters != null) { + filters.remove(filter); + if (filters.isEmpty) { + _eventFilters = null; + } + } + } + + void _fireEvent(MailEvent event) { + final filters = _eventFilters; + if (filters != null) { + for (final filter in filters) { + if (filter(event)) { + return; + } + } + } + eventBus.fire(event); + } + + //Future> poll(Mailbox mailbox) {} + + /// Connects and authenticates with the specified incoming mail server. + /// + /// Also compare [disconnect]. + /// + /// Specify a [timeout] for the connection, defaults to 20 seconds. + Future connect({Duration timeout = const Duration(seconds: 20)}) async { + await _prepareConnect(); + await _incomingMailClient.connect(timeout: timeout); + _isConnected = true; + } + + Future _prepareConnect() async { + final refresh = _refreshOAuthToken; + if (refresh != null) { + final auth = account.incoming.authentication; + if (auth is OauthAuthentication && + auth.token.willExpireIn(const Duration(minutes: 15))) { + OauthToken? refreshed; + try { + _incomingMailClient.log('Refreshing token...'); + refreshed = await refresh(this, auth.token); + } catch (e, s) { + final message = 'Unable to refresh token: $e $s'; + throw MailException(this, message, stackTrace: s, details: e); + } + if (refreshed == null) { + throw MailException(this, 'Unable to refresh token'); + } + final newToken = + auth.token.copyWith(refreshed.accessToken, refreshed.expiresIn); + final incoming = account.incoming.copyWith( + authentication: auth.copyWith(token: newToken), + ); + var outgoing = account.outgoing; + final outAuth = outgoing.authentication; + if (outAuth is OauthAuthentication) { + outgoing = outgoing.copyWith( + authentication: outAuth.copyWith(token: newToken), + ); + } + _account = _account.copyWith( + incoming: incoming, + outgoing: outgoing, + ); + _incomingMailClient._config = _account.incoming; + _outgoingMailClient._mailConfig = _account.outgoing; + final onConfigChanged = _onConfigChanged; + if (onConfigChanged != null) { + try { + await onConfigChanged(account); + } catch (e, s) { + _incomingMailClient.log( + 'Unable to handle onConfigChanged $onConfigChanged: $e $s', + ); + } + } + } + } + } + + /// Disconnects from the mail service. + /// + /// Also compare [connect]. + Future disconnect() async { + final futures = [ + stopPollingIfNeeded(), + _incomingLock.synchronized( + () => _incomingMailClient.disconnect(), + ), + _outgoingLock.synchronized( + () => _outgoingMailClient.disconnect(), + ), + ]; + _isConnected = false; + await Future.wait(futures); + } + + /// Enforces to reconnect with the incoming service. + /// + /// Also compare [disconnect]. + /// Also compare [connect]. + Future reconnect() async { + await _incomingLock.synchronized( + () async { + await _incomingMailClient.disconnect(); + await _incomingMailClient.reconnect(); + _isConnected = true; + }, + ); + } + + // Future tryAuthenticate( + // ServerConfig serverConfig, MailAuthentication authentication) { + // return authentication.authenticate(this, serverConfig); + // } + + /// Lists all mailboxes/folders of the incoming mail server. + /// + /// Optionally specify the [order] of the mailboxes, matching ones will be + /// served in the given order. + Future> listMailboxes({List? order}) async { + var boxes = await _incomingLock.synchronized( + () => _incomingMailClient.listMailboxes(), + ); + _mailboxes = boxes; + if (order != null) { + boxes = sortMailboxes(order, boxes); + } + if (boxes.isNotEmpty) { + final separator = boxes.first.pathSeparator; + if (separator != _account.incoming.pathSeparator) { + _account = _account.copyWith( + incoming: _account.incoming.copyWith(pathSeparator: separator), + ); + unawaited(_onConfigChanged?.call(_account)); + } + } + + return boxes; + } + + /// Lists all mailboxes/folders of the incoming mail server as a tree + /// in the specified [order]. + /// + /// Optionally set [createIntermediate] to false, in case not all intermediate + /// folders should be created, if not already present on the server. + Future> listMailboxesAsTree({ + bool createIntermediate = true, + List order = defaultMailboxOrder, + }) async { + final mailboxes = _mailboxes ?? await listMailboxes(); + List? firstBoxes; + firstBoxes = sortMailboxes(order, mailboxes, keepRemaining: false); + final boxes = [...mailboxes]..sort((b1, b2) => b1.path.compareTo(b2.path)); + final separator = (mailboxes.isNotEmpty) + ? mailboxes.first.pathSeparator + : _account.incoming.pathSeparator; + final tree = Tree(null) + ..populateFromList( + boxes, + (child) => child?.getParent( + boxes, + separator, + createIntermediate: createIntermediate, + ), + ); + final parent = tree.root; + final children = parent.children; + for (var i = firstBoxes.length; --i >= 0;) { + final box = firstBoxes[i]; + var element = _extractTreeElementWithoutChildren(parent, box); + if (element != null) { + if (element.children?.isEmpty ?? true) { + // this element has been removed: + element.parent = parent; + } else { + element = TreeElement(box, parent); + } + children?.insert(0, element); + } + } + + return tree; + } + + TreeElement? _extractTreeElementWithoutChildren( + TreeElement root, + Mailbox mailbox, + ) { + if (root.value == mailbox) { + if ((root.children?.isEmpty ?? true) && (root.parent != null)) { + root.parent?.children?.remove(root); + } + + return root as TreeElement?; + } + if (root.children != null) { + for (final child in root.children ?? []) { + final element = _extractTreeElementWithoutChildren(child, mailbox); + if (element != null) { + return element; + } + } + } + + return null; + } + + /// Retrieves the mailbox with the specified [flag] from the provided [boxes]. + /// + /// When no boxes are given, then the `MailClient.mailboxes` are used. + Mailbox? getMailbox(MailboxFlag flag, [List? boxes]) { + boxes ??= mailboxes; + + return boxes?.firstWhereOrNull((box) => box.hasFlag(flag)); + } + + /// Retrieves the mailbox with the specified [order] + /// from the provided [mailboxes]. The underlying mailboxes are not changed. + /// + /// Set [keepRemaining] to `false` (defaults to `true`) to only return the + /// mailboxes specified by the [order] [MailboxFlag]s. + /// + /// Set [sortRemainingAlphabetically] to `false` (defaults to `true`) to + /// sort the remaining boxes by name, + /// is only relevant when [keepRemaining] is `true`. + List sortMailboxes( + List order, + List mailboxes, { + bool keepRemaining = true, + bool sortRemainingAlphabetically = true, + }) { + final inputMailboxes = [...mailboxes]; + final outputMailboxes = []; + for (final flag in order) { + final box = getMailbox(flag, inputMailboxes); + if (box != null) { + outputMailboxes.add(box); + inputMailboxes.remove(box); + } + } + if (keepRemaining) { + if (sortRemainingAlphabetically) { + inputMailboxes.sort((b1, b2) => b1.path.compareTo(b2.path)); + } + outputMailboxes.addAll(inputMailboxes); + } + + return outputMailboxes; + } + + /// Selects the mailbox/folder with the specified [path]. + /// + /// Optionally specify if `CONDSTORE` support should be enabled + /// with [enableCondStore]. + /// + /// Optionally specify quick resync parameters with [qresync]. + Future selectMailboxByPath( + String path, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + var mailboxes = _mailboxes; + mailboxes ??= await listMailboxes(); + final mailbox = mailboxes.firstWhereOrNull((box) => box.path == path); + if (mailbox == null) { + throw MailException(this, 'Unknown mailbox with path <$path>'); + } + final box = await _incomingLock.synchronized( + () => _incomingMailClient.selectMailbox( + mailbox, + enableCondStore: enableCondStore, + qresync: qresync, + ), + ); + + _selectedMailbox = box; + + return box; + } + + /// Selects the mailbox/folder with the specified [flag]. + /// + /// Optionally specify if `CONDSTORE` support should be enabled + /// with [enableCondStore]. + /// + /// Optionally specify quick resync parameters with [qresync]. + Future selectMailboxByFlag( + MailboxFlag flag, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + var mailboxes = _mailboxes; + mailboxes ??= await listMailboxes(); + final mailbox = getMailbox(flag, mailboxes); + if (mailbox == null) { + throw MailException(this, 'Unknown mailbox with flag <$flag>'); + } + final box = await _incomingLock.synchronized( + () => _incomingMailClient.selectMailbox( + mailbox, + enableCondStore: enableCondStore, + qresync: qresync, + ), + ); + _selectedMailbox = box; + + return box; + } + + /// Shortcut to select the INBOX. + /// + /// Optionally specify if `CONDSTORE` support should be enabled + /// with [enableCondStore] - for IMAP servers that support CONDSTORE only. + /// + /// Optionally specify quick resync parameters with [qresync] - + /// for IMAP servers that support `QRESYNC` only. + Future selectInbox({ + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + var mailboxes = _mailboxes; + mailboxes ??= await listMailboxes(); + var inbox = mailboxes.firstWhereOrNull((box) => box.isInbox); + inbox ??= + mailboxes.firstWhereOrNull((box) => box.name.toLowerCase() == 'inbox'); + if (inbox == null) { + throw MailException(this, 'Unable to find inbox'); + } + + return selectMailbox( + inbox, + enableCondStore: enableCondStore, + qresync: qresync, + ); + } + + /// Selects the specified [mailbox]/folder. + /// + /// Optionally specify if CONDSTORE support should be + /// enabled with [enableCondStore]. + /// + /// Optionally specify quick resync parameters with [qresync]. + Future selectMailbox( + Mailbox mailbox, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + final box = await _incomingLock.synchronized( + () => _incomingMailClient.selectMailbox( + mailbox, + enableCondStore: enableCondStore, + qresync: qresync, + ), + ); + _selectedMailbox = box; + + return box; + } + + Future _selectMailboxIfNeeded(Mailbox? mailbox) { + final usedMailbox = mailbox ?? _selectedMailbox; + if (usedMailbox == null) { + throw MailException(this, 'No mailbox selected'); + } + if (usedMailbox != _selectedMailbox) { + return selectMailbox(usedMailbox); + } + + return Future.value(usedMailbox); + } + + /// Loads the specified [page] of messages starting at the latest message + /// and going down [count] messages. + /// + /// Specify [page] number - by default this is 1, so the first + /// page is downloaded. + /// + /// Optionally specify the [mailbox] in case none has been selected before + /// or if another mailbox/folder should be queried. + /// + /// Optionally specify the [fetchPreference] to define the preferred + /// downloaded scope, defaults to `FetchPreference.fullWhenWithinSize`. + /// By default messages that are within the size bounds as defined in the + /// `downloadSizeLimit` in the `MailClient`s constructor are downloaded fully. + /// + /// Note that the [fetchPreference] cannot be realized on some backends such + /// as POP3 mail servers. + /// + /// Compare [fetchMessagesNextPage] + Future> fetchMessages({ + Mailbox? mailbox, + int count = 20, + int page = 1, + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + }) async { + final usedMailbox = await _selectMailboxIfNeeded(mailbox); + final sequence = + MessageSequence.fromPage(page, count, usedMailbox.messagesExists); + + return _incomingLock.synchronized( + () => _incomingMailClient.fetchMessageSequence( + sequence, + fetchPreference: fetchPreference, + ), + ); + } + + /// Loads the specified [sequence] of messages. + /// + /// Optionally specify the [mailbox] in case none has been selected before + /// or if another mailbox/folder should be queried. + /// + /// Optionally specify the [fetchPreference] to define the preferred + /// downloaded scope, defaults to `FetchPreference.fullWhenWithinSize`. + /// + /// Set [markAsSeen] to `true` to automatically add the `\Seen` flag in case + /// it is not there yet when downloading the `fetchPreference.full`. + /// Note that the preference cannot be realized on some backends such as + /// POP3 mail servers. + /// + /// Compare [fetchMessagesNextPage] + Future> fetchMessageSequence( + MessageSequence sequence, { + Mailbox? mailbox, + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + bool markAsSeen = false, + }) async { + await _selectMailboxIfNeeded(mailbox); + + return _incomingLock.synchronized( + () => _incomingMailClient.fetchMessageSequence( + sequence, + fetchPreference: fetchPreference, + markAsSeen: markAsSeen, + ), + ); + } + + /// Loads the next page of messages in the given [pagedSequence]. + /// + /// Optionally specify the [mailbox] in case none has been selected before or + /// if another mailbox/folder should be queried. + /// + /// Optionally specify the [fetchPreference] to define the preferred + /// downloaded scope, defaults to `FetchPreference.fullWhenWithinSize`. + /// + /// Set [markAsSeen] to `true` to automatically add the `\Seen` flag in case + /// it is not there yet when downloading the `fetchPreference.full`. + /// + /// Note that the [fetchPreference] cannot be realized on some backends such + /// as POP3 mail servers. + Future> fetchMessagesNextPage( + PagedMessageSequence pagedSequence, { + Mailbox? mailbox, + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + bool markAsSeen = false, + }) async { + if (pagedSequence.hasNext) { + final sequence = pagedSequence.next(); + + return fetchMessageSequence( + sequence, + mailbox: mailbox, + fetchPreference: fetchPreference, + markAsSeen: markAsSeen, + ); + } + + return Future.value([]); + } + + /// Fetches the contents of the specified [message]. + /// + /// This can be useful when you have specified an automatic download + /// limit with `downloadSizeLimit` in the MailClient's constructor or when + /// you have specified a `fetchPreference` in `fetchMessages`. + /// + /// Optionally specify the [maxSize] in bytes to not download attachments of + /// the message. The [maxSize] parameter is ignored over POP. + /// + /// Optionally set [markAsSeen] to `true` in case the message should be + /// flagged as `\Seen` if not already done. + /// + /// Optionally specify [includedInlineTypes] to exclude parts with an inline + /// disposition and a different media type than specified. + /// + /// Optionally specify a specific [responseTimeout] until when the message + /// contents must have arrived + Future fetchMessageContents( + MimeMessage message, { + int? maxSize, + bool markAsSeen = false, + List? includedInlineTypes, + Duration? responseTimeout, + }) { + _incomingMailClient.log('fetch message contents of ${message.uid}'); + + return _incomingLock.synchronized( + () => _incomingMailClient.fetchMessageContents( + message, + maxSize: maxSize, + markAsSeen: markAsSeen, + includedInlineTypes: includedInlineTypes, + responseTimeout: responseTimeout, + ), + ); + } + + /// Fetches the part with the specified [fetchId] of the specified [message]. + /// + /// This can be useful when you have specified an automatic download + /// limit with `downloadSizeLimit` in the MailClient's constructor and want + /// to download an individual attachment, for example. + /// Note that this is only possible when the user is connected via IMAP and + /// not via POP. + /// + /// Compare [lowLevelIncomingMailClientType]. + Future fetchMessagePart( + MimeMessage message, + String fetchId, { + Duration? responseTimeout, + }) => + _incomingLock.synchronized( + () => _incomingMailClient.fetchMessagePart( + message, + fetchId, + responseTimeout: responseTimeout, + ), + ); + + /// Retrieves the threads starting at [since]. + /// + /// Optionally specify the [mailbox], in case not the currently selected + /// mailbox should be used. + /// + /// Choose with [threadPreference] if only the latest (default) or all + /// messages should be fetched. + /// + /// Choose what message data should be fetched using [fetchPreference], + /// which defaults to [FetchPreference.envelope]. + /// + /// Choose the number of downloaded messages with [pageSize], which + /// defaults to `30`. + /// + /// Note that you can download further pages using [fetchThreadsNextPage]. + /// Compare [supportsThreading]. + Future fetchThreads({ + required DateTime since, + Mailbox? mailbox, + ThreadPreference threadPreference = ThreadPreference.latest, + FetchPreference fetchPreference = FetchPreference.envelope, + int pageSize = 30, + Duration? responseTimeout, + }) { + final usedMailbox = mailbox ?? _selectedMailbox; + if (usedMailbox == null) { + throw InvalidArgumentException('no mailbox defined nor selected'); + } + + return _incomingLock.synchronized( + () => _incomingMailClient.fetchThreads( + usedMailbox, + since, + threadPreference, + fetchPreference, + pageSize, + responseTimeout: responseTimeout, + ), + ); + } + + /// Retrieves the next page for the given [threadResult] + /// and returns the loaded messages. + /// + /// The given [threadResult] will be updated to contain the loaded messages. + /// + /// Compare [fetchThreads]. + Future> fetchThreadsNextPage( + ThreadResult threadResult, + ) async { + final messages = await fetchMessagesNextPage( + threadResult.threadSequence, + fetchPreference: threadResult.fetchPreference, + ); + threadResult.addAll(messages); + + return messages; + } + + /// Retrieves thread information starting at [since]. + /// + /// When you set [setThreadSequences] to `true`, then the + /// [MimeMessage.threadSequence] will be populated automatically for future + /// fetched messages. + /// + /// Optionally specify the [mailbox], in case not the currently selected + /// mailbox should be used. + /// + /// Compare [supportsThreading]. + Future fetchThreadData({ + required DateTime since, + Mailbox? mailbox, + bool setThreadSequences = false, + }) { + final usedMailbox = mailbox ?? _selectedMailbox; + if (usedMailbox == null) { + throw InvalidArgumentException('no mailbox defined nor selected'); + } + + return _incomingLock.synchronized( + () => _incomingMailClient.fetchThreadData( + usedMailbox, + since, + setThreadSequences: setThreadSequences, + ), + ); + } + + /// Builds the mime message from the given [messageBuilder] + /// with the recommended text encodings. + Future buildMimeMessageWithRecommendedTextEncoding( + MessageBuilder messageBuilder, + ) async { + final supports8Bit = await supports8BitEncoding(); + messageBuilder.setRecommendedTextEncoding( + supports8BitMessages: supports8Bit, + ); + + return messageBuilder.buildMimeMessage(); + } + + /// Sends the message defined with the specified [messageBuilder] + /// with the recommended text encoding. + /// + /// Specify [from] as the originator in case it differs from the + /// `From` header of the message. + /// + /// Optionally set [appendToSent] to `false` in case the message should + /// NOT be appended to the SENT folder. + /// By default the message is appended. Note that some mail providers + /// automatically append sent messages to + /// the SENT folder, this is not detected by this API. + /// + /// Optionally specify the [recipients], in which case the recipients + /// defined in the message are ignored. + /// + /// Optionally specify the [sentMailbox] when the mail system does not + /// support mailbox flags. + Future sendMessageBuilder( + MessageBuilder messageBuilder, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + List? recipients, + }) async { + final supports8Bit = await supports8BitEncoding(); + final builderEncoding = messageBuilder.setRecommendedTextEncoding( + supports8BitMessages: supports8Bit, + ); + final message = messageBuilder.buildMimeMessage(); + final use8Bit = builderEncoding == TransferEncoding.eightBit; + + return sendMessage( + message, + from: from, + appendToSent: appendToSent, + supportUnicode: supports8Bit, + sentMailbox: sentMailbox, + use8BitEncoding: use8Bit, + recipients: recipients, + ); + } + + /// Sends the specified [message]. + /// + /// Use [MessageBuilder] to create new messages. + /// + /// Specify [from] as the originator in case it differs from the `From` + /// header of the message. + /// + /// Optionally set [appendToSent] to `false` in case the message should NOT + /// be appended to the SENT folder. + /// By default the message is appended. Note that some mail providers + /// automatically append sent messages to + /// the SENT folder, this is not detected by this API. + /// + /// You can also specify if the message should be sent using 8 bit encoding + /// with [use8BitEncoding], which default to `false`. + /// + /// Optionally specify the [recipients], in which case the recipients + /// defined in the message are ignored. + /// + /// Optionally specify the [sentMailbox] when the mail system does not + /// support mailbox flags. + /// first + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + bool supportUnicode = false, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }) async { + await _prepareConnect(); + final futures = [ + _outgoingLock.synchronized( + () => _sendMessageViaOutgoing( + message, + from, + use8BitEncoding, + recipients, + supportUnicode: supportUnicode, + ), + ), + ]; + if (appendToSent && _incomingMailClient.supportsAppendingMessages) { + sentMailbox ??= getMailbox(MailboxFlag.sent); + if (sentMailbox == null) { + _incomingMailClient + .log('Error: unable to append sent message: no no mailbox with ' + 'flag sent found in $mailboxes'); + } else { + futures.add( + appendMessage( + message, + sentMailbox, + flags: [MessageFlags.seen], + ), + ); + } + } + + await Future.wait(futures); + } + + Future _sendMessageViaOutgoing( + MimeMessage message, + MailAddress? from, + bool use8BitEncoding, + List? recipients, { + bool supportUnicode = false, + }) async { + await _outgoingMailClient.sendMessage( + message, + from: from, + use8BitEncoding: use8BitEncoding, + supportUnicode: supportUnicode, + recipients: recipients, + ); + + await _outgoingMailClient.disconnect(); + } + + /// Appends the [message] to the drafts mailbox + /// with the `\Draft` and `\Seen` message flags. + /// + /// Optionally specify the [draftsMailbox] when the mail system does not + /// support mailbox flags. + Future saveDraftMessage( + MimeMessage message, { + Mailbox? draftsMailbox, + }) => + draftsMailbox == null + ? appendMessageToFlag( + message, + MailboxFlag.drafts, + flags: [MessageFlags.draft, MessageFlags.seen], + ) + : appendMessage( + message, + draftsMailbox, + flags: [MessageFlags.draft, MessageFlags.seen], + ); + + /// Appends the [message] to the mailbox with the [targetMailboxFlag]. + /// + /// Optionally specify the message [flags]. + Future appendMessageToFlag( + MimeMessage message, + MailboxFlag targetMailboxFlag, { + List? flags, + }) { + final mailbox = getMailbox(targetMailboxFlag); + if (mailbox == null) { + throw MailException( + this, + 'No mailbox with flag $targetMailboxFlag found in $mailboxes.', + ); + } + + return appendMessage(message, mailbox, flags: flags); + } + + /// Appends the [message] to the [targetMailbox]. + /// + /// Optionally specify the message [flags]. + Future appendMessage( + MimeMessage message, + Mailbox targetMailbox, { + List? flags, + }) => + _incomingLock.synchronized( + () => _incomingMailClient.appendMessage(message, targetMailbox, flags), + ); + + /// Starts listening for new incoming messages. + /// + /// Listen for [MailLoadEvent] on the [eventBus] to get notified + /// about new messages. + Future startPolling([Duration duration = defaultPollingDuration]) => + _incomingLock.synchronized( + () => _incomingMailClient.startPolling(duration), + ); + + /// Stops listening for new messages. + Future stopPolling() => _incomingLock.synchronized( + () => _incomingMailClient.stopPolling(), + ); + + /// Stops listening for new messages if this client is currently polling. + Future stopPollingIfNeeded() { + if (_incomingMailClient.isPolling()) { + return stopPolling(); + } + + return Future.value(); + } + + /// Checks if this mail client is currently polling. + bool isPolling() => _incomingMailClient.isPolling(); + + /// Resumes the mail client after a some inactivity. + /// + /// Reconnects the mail client in the background, if necessary. + /// Set the [startPollingWhenError] to `false` in case polling should not + /// be started again when an error occurred. + Future resume({bool startPollingWhenError = true}) async { + await _incomingLock.synchronized( + () async { + _incomingMailClient.log('resume mail client'); + try { + await _incomingMailClient.stopPolling(); + await _incomingMailClient.startPolling(defaultPollingDuration); + } catch (e, s) { + _incomingMailClient.log('error while resuming: $e $s'); + // re-connect explicitly: + try { + await _incomingMailClient.reconnect(); + if (startPollingWhenError && !_incomingMailClient.isPolling()) { + await _incomingMailClient.startPolling(defaultPollingDuration); + } + } catch (e2, s2) { + _incomingMailClient.log( + 'error while trying to reconnect in resume: $e2 $s2', + ); + } + } + }, + ); + } + + /// Determines if message flags such as `\Seen` can be stored. + /// + /// POP3 servers do not support message flagging, for example. + /// Note that even on POP3 servers the \Deleted / [MessageFlags.deleted] + /// "flag" can be set. However, messages are really deleted + /// and cannot be retrieved after marking them as deleted after the current + /// POP3 session is closed. + bool supportsFlagging() => _incomingMailClient.supportsFlagging(); + + /// Mark the messages from the specified [sequence] as seen/read. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markSeen( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.seen], + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unseen/unread. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnseen( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.seen], + action: StoreAction.remove, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as flagged. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markFlagged( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.flagged], + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as unflagged. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnflagged( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.flagged], + action: StoreAction.remove, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as deleted. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markDeleted( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.deleted], + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not deleted. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUndeleted( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.deleted], + action: StoreAction.remove, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as answered. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markAnswered( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.answered], + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not answered. + /// + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnanswered( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.answered], + action: StoreAction.remove, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark from the specified [sequence] as forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markForwarded( + MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.keywordForwarded], + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Mark the messages from the specified [sequence] as not forwarded. + /// + /// Note this uses the common but not-standardized `$Forwarded` keyword flag. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports + /// the `CONDSTORE` or `QRESYNC` capability. + /// + /// Compare the [store] method in case you need more control or want to + /// change several flags. + Future markUnforwarded( + MessageSequence sequence, { + int? unchangedSinceModSequence, + }) => + store( + sequence, + [MessageFlags.keywordForwarded], + action: StoreAction.remove, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + + /// Flags the [message] with the specified flags. + /// + /// Set any bool parameter to either `true` or `false` + /// if you want to change the corresponding flag. + /// Keep a parameter `null` to not change the corresponding flag. + /// Compare [store] for gaining more control. + Future flagMessage( + MimeMessage message, { + bool? isSeen, + bool? isFlagged, + bool? isAnswered, + bool? isForwarded, + bool? isDeleted, + @Deprecated('use isReadReceiptSent instead') bool? isMdnSent, + bool? isReadReceiptSent, + }) { + if (isSeen != null) { + message.isSeen = isSeen; + } + if (isFlagged != null) { + message.isFlagged = isFlagged; + } + if (isAnswered != null) { + message.isAnswered = isAnswered; + } + if (isForwarded != null) { + message.isForwarded = isForwarded; + } + if (isDeleted != null) { + message.isDeleted = isDeleted; + } + if (isMdnSent != null) { + message.isReadReceiptSent = isMdnSent; + } + if (isReadReceiptSent != null) { + message.isReadReceiptSent = isReadReceiptSent; + } + final msgFlags = message.flags; + if (msgFlags != null) { + final sequence = MessageSequence.fromMessage(message); + final flags = [...msgFlags]..remove(MessageFlags.recent); + + return store(sequence, flags, action: StoreAction.replace); + } else { + throw MailException(this, 'No message flags defined'); + } + } + + /// Stores the specified message [flags] for the given message [sequence]. + /// + /// By default the flags are added, but you can specify a different + /// store [action]. + /// Specify the [unchangedSinceModSequence] to limit the store action to + /// elements that have not changed since the specified modification sequence. + /// This is only supported when the server supports the + /// `CONDSTORE` or `QRESYNC` capability. + /// + /// Call [supportsFlagging] first to determine if the mail server supports + /// flagging at all. + Future store( + MessageSequence sequence, + List flags, { + StoreAction action = StoreAction.add, + int? unchangedSinceModSequence, + }) => + _incomingLock.synchronized( + () => _incomingMailClient.store( + sequence, + flags, + action, + unchangedSinceModSequence, + ), + ); + + /// Deletes the given [message]. + /// + /// Depending on the service capabilities either the message is moved to the + /// trash, copied to the trash or just flagged as deleted. + /// + /// Optionally set [expunge] to `true` to clear the messages directly from + /// disk on IMAP servers. In that case, the delete operation cannot be undone. + /// + /// Returns a [DeleteResult] that can be used for an undo operation, + /// compare [undoDeleteMessages]. + /// + /// The UID of the [message] will be updated automatically. + Future deleteMessage( + MimeMessage message, { + bool expunge = false, + }) => + deleteMessages(MessageSequence.fromMessage(message), expunge: expunge); + + /// Deletes the given message [sequence]. + /// + /// Depending on the service capabilities either the sequence is moved to + /// the trash, copied to the trash or just flagged as deleted. + /// + /// Optionally set [expunge] to `true` to clear the messages directly from + /// disk on IMAP servers. In that case, the delete operation cannot be undone. + /// + /// Returns a `DeleteResult` that can be used for an undo operation, + /// compare [undoDeleteMessages]. + /// + /// The UIDs of the [messages] will be updated automatically, when they + /// are specified. + Future deleteMessages( + MessageSequence sequence, { + bool expunge = false, + List? messages, + }) { + final trashMailbox = getMailbox(MailboxFlag.trash); + + return _incomingLock.synchronized( + () => _incomingMailClient.deleteMessages( + sequence, + trashMailbox, + expunge: expunge, + messages: messages, + ), + ); + } + + /// Reverts the previous [deleteResult] + /// + /// Note that is only possible when `deleteResult.canUndo` is `true`. + /// + /// The UIDs of the associated messages will be updated automatically, + /// when the messages have been specified in the original delete operation. + /// + /// Compare [deleteMessages] + Future undoDeleteMessages(DeleteResult deleteResult) => + _incomingLock.synchronized( + () => _incomingMailClient.undoDeleteMessages(deleteResult), + ); + + /// Deletes all messages from the specified [mailbox]. + /// + /// Optionally set [expunge] to `true` to clear the messages + /// directly from disk on IMAP servers. In that case, the delete + /// operation cannot be undone. + Future deleteAllMessages( + Mailbox mailbox, { + bool expunge = false, + }) async { + final result = await _incomingLock.synchronized( + () => _incomingMailClient.deleteAllMessages(mailbox, expunge: expunge), + ); + mailbox + ..messagesExists = 0 + ..messagesRecent = 0 + ..messagesUnseen = 0; + + return result; + } + + /// Moves the specified [message] to the junk folder + /// + /// The message UID will be updated automatically. + Future junkMessage(MimeMessage message) => + moveMessageToFlag(message, MailboxFlag.junk); + + /// Moves the specified message [sequence] to the junk folder + /// + /// The message UID will be updated automatically. + Future junkMessages( + MessageSequence sequence, { + List? messages, + }) => + moveMessagesToFlag(sequence, MailboxFlag.junk, messages: messages); + + /// Moves the specified [message] to the inbox folder + /// + /// The message UID will be updated automatically. + Future moveMessageToInbox(MimeMessage message) => + moveMessageToFlag(message, MailboxFlag.inbox); + + /// Moves the specified message [sequence] to the inbox folder + /// + /// The message UID will be updated automatically. + Future moveMessagesToInbox( + MessageSequence sequence, { + List? messages, + }) => + moveMessagesToFlag(sequence, MailboxFlag.inbox, messages: messages); + + /// Moves the specified [message] to the folder flagged + /// with the specified mailbox [flag]. + /// + /// The message UID will be updated automatically. + Future moveMessageToFlag(MimeMessage message, MailboxFlag flag) => + moveMessagesToFlag( + MessageSequence.fromMessage(message), + flag, + messages: [message], + ); + + /// Moves the specified message [sequence] to the folder flagged + /// with the specified mailbox [flag]. + /// + /// The [messages] UIDs will be updated automatically when they are specified. + /// + /// Throws [InvalidArgumentException] when the target mailbox with the given + /// [flag] is not found. + Future moveMessagesToFlag( + MessageSequence sequence, + MailboxFlag flag, { + List? messages, + }) async { + var boxes = _mailboxes; + if (boxes == null || boxes.isEmpty) { + boxes = await listMailboxes(); + if (boxes.isEmpty) { + throw MailException(this, 'No mailboxes defined'); + } + } + final target = getMailbox(flag, boxes); + if (target == null) { + throw InvalidArgumentException( + 'Move target mailbox with flag $flag not found in $boxes', + ); + } + + return _incomingLock.synchronized( + () => _incomingMailClient.moveMessages( + sequence, + target, + messages: messages, + ), + ); + } + + /// Moves the specified [message] to the given [target] folder + /// + /// The message UID will be updated automatically. + Future moveMessage(MimeMessage message, Mailbox target) => + _incomingLock.synchronized( + () => _incomingMailClient.moveMessages( + MessageSequence.fromMessage(message), + target, + messages: [message], + ), + ); + + /// Moves the specified message [sequence] to the given [target] folder + /// + /// The [messages] UIDs will be updated automatically when they are specified. + Future moveMessages( + MessageSequence sequence, + Mailbox target, { + List? messages, + }) => + _incomingLock.synchronized( + () => _incomingMailClient.moveMessages( + sequence, + target, + messages: messages, + ), + ); + + /// Reverts the previous move operation, if possible. + /// + /// When messages have been specified for the original [moveMessages] + /// operation, then the UIDs of those messages will be adjusted + /// automatically. + Future undoMoveMessages(MoveResult moveResult) => + _incomingLock.synchronized( + () => _incomingMailClient.undoMove(moveResult), + ); + + /// Searches the messages with the criteria defined in [search]. + /// + /// Compare [searchMessagesNextPage] for retrieving the next page + /// of search results. + Future searchMessages(MailSearch search) => + _incomingLock.synchronized( + () => _incomingMailClient.searchMessages(search), + ); + + /// Retrieves the next page of messages for the specified [searchResult]. + Future> searchMessagesNextPage( + MailSearchResult searchResult, + ) => + fetchNextPage(searchResult); + + /// Retrieves the next page of messages for the specified [pagedResult]. + Future> fetchNextPage( + PagedMessageResult pagedResult, + ) async { + final messages = await fetchMessagesNextPage( + pagedResult.pagedSequence, + fetchPreference: pagedResult.fetchPreference, + ); + pagedResult.insertAll(messages); + + return messages; + } + + /// Checks if the mail provider supports 8 bit encoding for new messages. + Future supports8BitEncoding() => + _outgoingMailClient.supports8BitEncoding(); + + /// Checks if this mail client supports different mailboxes + bool get supportsMailboxes => _incomingMailClient.supportsMailboxes; + + /// Creates a new mailbox with the given [mailboxName]. + /// + /// Specify a [parentMailbox] in case the mailbox should + /// not be created in the root. + Future createMailbox( + String mailboxName, { + Mailbox? parentMailbox, + }) async { + if (!supportsMailboxes) { + throw MailException( + this, + 'Mailboxes are not supported, check "supportsMailboxes" first', + ); + } + + final box = await _incomingLock.synchronized( + () => _incomingMailClient.createMailbox( + mailboxName, + parentMailbox: parentMailbox, + ), + ); + _mailboxes?.add(box); + + return box; + } + + /// Deletes the specified [mailbox] + Future deleteMailbox(Mailbox mailbox) async { + if (!supportsMailboxes) { + throw MailException( + this, + 'Mailboxes are not supported, check "supportsMailboxes" first', + ); + } + await _incomingLock.synchronized( + () => _incomingMailClient.deleteMailbox(mailbox), + ); + _mailboxes?.remove(mailbox); + } +} + +/// Defines the thread fetching preference +enum ThreadPreference { + /// All messages of each thread are fetched + all, + + /// Only the newest message of each thread is fetched + latest +} + +abstract class _IncomingMailClient { + _IncomingMailClient(this.downloadSizeLimit, this._config, this.mailClient); + + final MailClient mailClient; + + ClientBase get client; + + ServerType get clientType; + + int? downloadSizeLimit; + MailServerConfig _config; + Mailbox? _selectedMailbox; + Future Function()? _pollImplementation; + Duration _pollDuration = MailClient.defaultPollingDuration; + Timer? _pollTimer; + + /// Checks if the incoming mail client supports 8 bit encoded messages + /// - is only correct after authorizing + bool get supports8BitEncoding; + + /// Checks if the incoming mail client supports appending messages + bool get supportsAppendingMessages; + + bool get supportsThreading; + + bool get supportsMailboxes; + + Id? get serverId => null; + + Future connect({Duration timeout = const Duration(seconds: 20)}); + + Future disconnect(); + + Future> listMailboxes(); + + Future selectMailbox( + Mailbox mailbox, { + bool enableCondStore = false, + QResyncParameters? qresync, + }); + + Future fetchThreads( + Mailbox mailbox, + DateTime since, + ThreadPreference threadPreference, + FetchPreference fetchPreference, + int pageSize, { + Duration? responseTimeout, + }); + + Future> fetchMessageSequence( + MessageSequence sequence, { + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + bool markAsSeen = false, + Duration? responseTimeout, + }); + + Future fetchMessageContents( + MimeMessage message, { + int? maxSize, + bool markAsSeen = false, + List? includedInlineTypes, + Duration? responseTimeout, + }); + + Future fetchMessagePart( + MimeMessage message, + String fetchId, { + Duration? responseTimeout, + }); + + Future> poll(); + + bool supportsFlagging(); + + Future store( + MessageSequence sequence, + List flags, + StoreAction action, + int? unchangedSinceModSequence, + ); + + Future deleteMessages( + MessageSequence sequence, + Mailbox? trashMailbox, { + bool expunge = false, + List? messages, + }); + + Future undoDeleteMessages(DeleteResult deleteResult); + + Future deleteAllMessages( + Mailbox mailbox, { + bool expunge = false, + }); + + Future startPolling( + Duration duration, { + Future Function()? pollImplementation, + }) { + _pollDuration = duration; + _pollImplementation = pollImplementation ?? poll; + _pollTimer = Timer.periodic(duration, _poll); + + return Future.value(); + } + + Future stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + + return Future.value(); + } + + bool isPolling() => _pollTimer?.isActive ?? false; + + Future _poll(Timer timer) async { + final callback = _pollImplementation; + if (callback != null) { + await callback(); + } + } + + Future moveMessages( + MessageSequence sequence, + Mailbox target, { + List? messages, + }); + + Future undoMove(MoveResult moveResult); + + Future searchMessages(MailSearch search); + + Future appendMessage( + MimeMessage message, + Mailbox targetMailbox, + List? flags, + ); + + Future noop(); + + Future fetchThreadData( + Mailbox mailbox, + DateTime since, { + required bool setThreadSequences, + }); + + Future createMailbox(String mailboxName, {Mailbox? parentMailbox}); + + Future deleteMailbox(Mailbox mailbox); + + Future reconnect(); + + void log(String text); +} + +class _IncomingImapClient extends _IncomingMailClient { + _IncomingImapClient( + int? downloadSizeLimit, + EventBus eventBus, + String? logName, + Duration? defaultWriteTimeout, + Duration? defaultResponseTimeout, + MailServerConfig config, + MailClient mailClient, { + required bool isLogEnabled, + bool Function(X509Certificate)? onBadCertificate, + }) : _imapClient = ImapClient( + bus: eventBus, + isLogEnabled: isLogEnabled, + logName: logName, + onBadCertificate: onBadCertificate, + defaultWriteTimeout: defaultWriteTimeout, + defaultResponseTimeout: defaultResponseTimeout, + ), + super(downloadSizeLimit, config, mailClient) { + eventBus.on().listen(_onImapEvent); + } + + @override + ClientBase get client => _imapClient; + + @override + ServerType get clientType => ServerType.imap; + final ImapClient _imapClient; + bool _isQResyncEnabled = false; + bool _supportsIdle = false; + bool _isInIdleMode = false; + final List _fetchMessages = []; + bool _isReconnecting = false; + final List _imapEventsDuringReconnecting = []; + int _reconnectCounter = 0; + bool _isIdlePaused = false; + ThreadDataResult? _threadData; + + @override + bool get supportsMailboxes => true; + Id? _serverId; + + @override + Id? get serverId => _serverId; + + Future _onImapEvent(ImapEvent event) async { + if (event.imapClient != _imapClient) { + return; // ignore events from other imap clients and in disconnected state + } + // print( + // 'imap event: ${event.eventType} - is currently currently ' + //'reconnecting: $_isReconnecting'); + if (_isReconnecting) { + if (event.eventType != ImapEventType.connectionLost) { + _imapEventsDuringReconnecting.add(event); + } + + return; + } + switch (event.eventType) { + case ImapEventType.fetch: + final message = (event as ImapFetchEvent).message; + final messageUid = message.uid; + final mailboxNextUid = _selectedMailbox?.uidNext; + if (mailboxNextUid != null && + messageUid != null && + mailboxNextUid <= messageUid) { + _selectedMailbox?.uidNext = messageUid + 1; + } + if (message.flags != null) { + mailClient._fireEvent(MailUpdateEvent(message, mailClient)); + } + break; + case ImapEventType.exists: + final evt = event as ImapMessagesExistEvent; + if (evt.newMessagesExists <= evt.oldMessagesExists) { + // this is just an update eg after an EXPUNGE event + // ignore: + break; + } + final sequence = MessageSequence(); + if (evt.newMessagesExists - evt.oldMessagesExists > 1) { + final oldMessagesExists = + evt.oldMessagesExists == 0 ? 1 : evt.oldMessagesExists; + final range = evt.newMessagesExists - oldMessagesExists; + if (range > 100) { + // this is very unlikely, limit the number of fetched messages: + sequence.addRange( + max(evt.newMessagesExists - 10, 1), + evt.newMessagesExists, + ); + } else { + sequence.addRange(oldMessagesExists, evt.newMessagesExists); + } + } else { + sequence.add(evt.newMessagesExists); + } + final messages = await fetchMessageSequence( + sequence, + fetchPreference: mailClient._downloadSizeLimit != null + ? FetchPreference.fullWhenWithinSize + : FetchPreference.envelope, + ); + if (messages.isNotEmpty) { + final last = messages.last; + final messageUid = last.uid; + final mailboxNextUid = _selectedMailbox?.uidNext; + if (mailboxNextUid != null && + messageUid != null && + mailboxNextUid <= messageUid) { + _selectedMailbox?.uidNext = messageUid + 1; + } + for (final message in messages) { + mailClient._fireEvent(MailLoadEvent(message, mailClient)); + _fetchMessages.add(message); + } + } + break; + case ImapEventType.vanished: + final evt = event as ImapVanishedEvent; + mailClient._fireEvent( + MailVanishedEvent( + evt.vanishedMessages, + mailClient, + isEarlier: evt.isEarlier, + ), + ); + break; + case ImapEventType.expunge: + final evt = event as ImapExpungeEvent; + mailClient._fireEvent( + MailVanishedEvent( + MessageSequence.fromId(evt.messageSequenceId), + mailClient, + isEarlier: false, + ), + ); + break; + case ImapEventType.connectionLost: + unawaited(reconnect()); + break; + case ImapEventType.recent: + // ignore the recent event for now + break; + } + } + + Future _pauseIdle() { + if (_isInIdleMode && !_isIdlePaused) { + _imapClient.log('pause idle...'); + _isIdlePaused = true; + + return stopPolling(); + } + + return Future.value(); + } + + Future _resumeIdle() async { + if (_isIdlePaused) { + _imapClient.log('resume idle...'); + await startPolling(_pollDuration); + _isIdlePaused = false; + } + } + + @override + Future reconnect() async { + _isReconnecting = true; + log('reconnecting....'); + try { + mailClient._fireEvent(MailConnectionLostEvent(mailClient)); + } catch (e, s) { + log('ERROR: handler crashed at MailConnectionLostEvent: $e $s'); + } + final restartPolling = _pollTimer != null; + if (restartPolling) { + // turn off idle mode as this is an error case in which the client + // cannot send 'DONE' to the server anyhow. + _isInIdleMode = false; + await stopPolling(); + } + _reconnectCounter++; + final counter = _reconnectCounter; + final box = _selectedMailbox; + final uidNext = box?.uidNext; + _imapClient.stashQueuedTasks(); + final qresync = + _imapClient.serverInfo.supportsQresync ? box?.qresync : null; + const minRetryDurationSeconds = 5; + const maxRetryDurationSeconds = 5 * 60; + var retryDurationSeconds = minRetryDurationSeconds; + while (counter == _reconnectCounter) { + // when another caller calls reconnect, _reconnectCounter will be + // increased and this loop will be aborted + + try { + _imapClient.logApp('trying to connect...'); + // refresh token if required: + await mailClient._prepareConnect(); + await connect(); + _imapClient.logApp('connected.'); + _isInIdleMode = false; + _imapClient.logApp( + 're-select mailbox "${box?.path ?? 'inbox'}".', + ); + + if (box != null) { + try { + _selectedMailbox = + await _imapClient.selectMailbox(box, qresync: qresync); + } catch (e, s) { + _imapClient.logApp('failed to re-select mailbox: $e $s'); + _selectedMailbox = qresync != null + ? await _imapClient.selectMailbox(box) + : await _imapClient.selectInbox(); + } + } else { + _selectedMailbox = await _imapClient.selectInbox(); + mailClient._selectedMailbox = _selectedMailbox; + if (mailClient.mailboxes == null) { + await mailClient.listMailboxes(); + } + } + _imapClient.logApp('done selecting mailbox $_selectedMailbox.'); + await _imapClient.applyStashedTasks(); + _imapClient.logApp('applied queued commands, if any.'); + final events = _imapEventsDuringReconnecting.toList(); + _imapEventsDuringReconnecting.clear(); + _isReconnecting = false; + if (events.isNotEmpty) { + events.forEach(_onImapEvent); + } + await _fetchMessagesAfterReconnecting(uidNext); + if (restartPolling) { + _imapClient.logApp('restart polling...'); + await startPolling( + _pollDuration, + pollImplementation: _pollImplementation, + ); + } + _imapClient.logApp('done reconnecting.'); + try { + final isManualSynchronizationRequired = qresync == null; + mailClient._fireEvent(MailConnectionReEstablishedEvent( + mailClient, + isManualSynchronizationRequired: isManualSynchronizationRequired, + )); + } catch (e, s) { + log('Error: receiver could not handle ' + 'MailConnectionReEstablishedEvent: $e $s'); + } + // exist reconnect loop: + + return; + } catch (e, s) { + _imapClient.logApp('Unable to reconnect: $e $s'); + } + await Future.delayed(Duration(seconds: retryDurationSeconds)); + retryDurationSeconds = + max(retryDurationSeconds * 2, maxRetryDurationSeconds); + } + } + + Future _fetchMessagesAfterReconnecting(int? uidNext) async { + final selectedMailboxUidNext = _selectedMailbox?.uidNext; + if (uidNext != null && + selectedMailboxUidNext != null && + selectedMailboxUidNext > uidNext) { + // there are new message in the meantime, download them: + final sequence = MessageSequence.fromRange( + uidNext, + selectedMailboxUidNext, + isUidSequence: true, + ); + final messages = await fetchMessageSequence( + sequence, + fetchPreference: FetchPreference.envelope, + ); + _imapClient.logApp('Reconnect: got ${messages.length} new messages.'); + try { + for (final message in messages) { + mailClient._fireEvent(MailLoadEvent(message, mailClient)); + } + } catch (e, s) { + log('Error: receiver could not handle MailLoadEvent after ' + 're-establishing connection: $e $s'); + } + } + } + + @override + Future connect({Duration timeout = const Duration(seconds: 20)}) async { + final serverConfig = _config.serverConfig; + final isSecure = serverConfig.socketType == SocketType.ssl; + await _imapClient.connectToServer( + serverConfig.hostname, + serverConfig.port, + isSecure: isSecure, + timeout: timeout, + ); + if (!isSecure) { + if (_imapClient.serverInfo.supportsStartTls && + (serverConfig.socketType != SocketType.plainNoStartTls)) { + await _imapClient.startTls(); + } else { + log('Warning: connecting without encryption, ' + 'your credentials are not secure.'); + } + } + try { + await _config.authentication + .authenticate(serverConfig, imap: _imapClient); + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } catch (e, s) { + throw MailException(mailClient, e.toString(), stackTrace: s, details: e); + } + final serverInfo = _imapClient.serverInfo; + if (serverInfo.capabilities?.isEmpty ?? true) { + await _imapClient.capability(); + } + if (serverInfo.supportsId) { + _serverId = await _imapClient.id(clientId: mailClient.clientId); + } + _config = _config.copyWith( + serverCapabilities: serverInfo.capabilities, + ); + final enableCaps = []; + if (serverInfo.supportsQresync) { + enableCaps.add(ImapServerInfo.capabilityQresync); + } + if (serverInfo.supportsUtf8) { + enableCaps.add(ImapServerInfo.capabilityUtf8Accept); + } + if (enableCaps.isNotEmpty) { + await _imapClient.enable(enableCaps); + _isQResyncEnabled = + _imapClient.serverInfo.isEnabled(ImapServerInfo.capabilityQresync); + } + _supportsIdle = serverInfo.supportsIdle; + } + + @override + Future disconnect() { + _reconnectCounter++; // this aborts the reconnect cycle + + return _imapClient.disconnect(); + } + + @override + Future> listMailboxes() async { + await _pauseIdle(); + try { + final mailboxes = await _imapClient.listMailboxes(recursive: true); + final separator = _imapClient.serverInfo.pathSeparator; + _config = _config.copyWith(pathSeparator: separator); + + return mailboxes; + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + Future selectMailbox( + Mailbox mailbox, { + bool enableCondStore = false, + final QResyncParameters? qresync, + }) async { + await _pauseIdle(); + try { + if (_selectedMailbox != null) { + await _imapClient.closeMailbox(); + } + var quickReSync = qresync; + if (qresync == null && + _isQResyncEnabled && + mailbox.highestModSequence != null) { + quickReSync = + QResyncParameters(mailbox.uidValidity, mailbox.highestModSequence); + } + final selectedMailbox = await _imapClient.selectMailbox( + mailbox, + enableCondStore: enableCondStore, + qresync: quickReSync, + ); + _selectedMailbox = selectedMailbox; + _threadData = null; + + return selectedMailbox; + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + Future> fetchMessageSequence( + MessageSequence sequence, { + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + bool markAsSeen = false, + Duration? responseTimeout, + }) async { + try { + await _pauseIdle(); + + return await _fetchMessageSequence( + sequence, + fetchPreference: fetchPreference, + markAsSeen: markAsSeen, + responseTimeout: responseTimeout, + ); + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } catch (e, s) { + throw MailException( + mailClient, + 'Error while fetching: $e', + details: e, + stackTrace: s, + ); + } finally { + await _resumeIdle(); + } + } + + /// fetches messages without pause or exception handling + Future> _fetchMessageSequence( + MessageSequence sequence, { + FetchPreference fetchPreference = FetchPreference.fullWhenWithinSize, + bool markAsSeen = false, + final Duration? responseTimeout, + }) async { + final downloadSizeLimit = this.downloadSizeLimit; + var timeout = responseTimeout; + String criteria; + switch (fetchPreference) { + case FetchPreference.envelope: + criteria = '(UID FLAGS RFC822.SIZE ENVELOPE)'; + timeout ??= const Duration(seconds: 20); + break; + case FetchPreference.bodystructure: + criteria = '(UID FLAGS RFC822.SIZE BODYSTRUCTURE)'; + timeout ??= const Duration(seconds: 60); + break; + case FetchPreference.full: + criteria = markAsSeen + ? '(UID FLAGS RFC822.SIZE BODY[])' + : '(UID FLAGS RFC822.SIZE BODY.PEEK[])'; + break; + case FetchPreference.fullWhenWithinSize: + criteria = downloadSizeLimit == null + ? markAsSeen + ? '(UID FLAGS RFC822.SIZE BODY[])' + : '(UID FLAGS RFC822.SIZE BODY.PEEK[])' + : '(UID FLAGS RFC822.SIZE ENVELOPE)'; + timeout = const Duration(seconds: 120); + break; + } + + final fetchImapResult = sequence.isUidSequence + ? await _imapClient.uidFetchMessages( + sequence, + criteria, + responseTimeout: timeout, + ) + : await _imapClient.fetchMessages( + sequence, + criteria, + responseTimeout: timeout, + ); + if (fetchImapResult.vanishedMessagesUidSequence?.isNotEmpty ?? false) { + mailClient._fireEvent( + MailVanishedEvent( + fetchImapResult.vanishedMessagesUidSequence, + mailClient, + isEarlier: false, + ), + ); + } + if (fetchPreference == FetchPreference.fullWhenWithinSize && + downloadSizeLimit != null) { + await _fetchSmallEnoughMessagesOnly( + fetchImapResult, + downloadSizeLimit, + markAsSeen, + timeout, + ); + } + final threadData = _threadData; + if (threadData != null) { + fetchImapResult.messages.forEach(threadData.setThreadSequence); + } + fetchImapResult.messages.sort( + (msg1, msg2) => (msg1.sequenceId ?? 0).compareTo(msg2.sequenceId ?? 0), + ); + final email = mailClient._account.email; + final encodedMailboxName = _selectedMailbox?.encodedName ?? ''; + final mailboxUidValidity = _selectedMailbox?.uidValidity ?? 0; + for (final message in fetchImapResult.messages) { + message.setGuid( + email: email, + encodedMailboxName: encodedMailboxName, + mailboxUidValidity: mailboxUidValidity, + ); + } + + return fetchImapResult.messages; + } + + Future _fetchSmallEnoughMessagesOnly( + FetchImapResult fetchImapResult, + int downloadSizeLimit, + bool markAsSeen, + Duration? timeout, + ) async { + final smallEnoughMessages = fetchImapResult.messages + .where((msg) => (msg.size ?? 0) < downloadSizeLimit); + final smallMessagesSequenceUids = MessageSequence(isUidSequence: true); + final smallMessagesSequenceSequenceIds = + MessageSequence(isUidSequence: false); + for (final msg in smallEnoughMessages) { + final uid = msg.uid; + if (uid != null) { + smallMessagesSequenceUids.add(uid); + } else { + smallMessagesSequenceSequenceIds.add( + msg.sequenceId.toValueOrThrow('no sequenceId found in msg'), + ); + } + } + if (smallMessagesSequenceUids.isNotEmpty) { + final smallMessagesFetchResult = await _imapClient.uidFetchMessages( + smallMessagesSequenceUids, + markAsSeen ? '(UID FLAGS BODY[])' : '(UID FLAGS BODY.PEEK[])', + responseTimeout: timeout, + ); + + fetchImapResult + .replaceMatchingMessages(smallMessagesFetchResult.messages); + } else if (smallMessagesSequenceSequenceIds.isNotEmpty) { + final smallMessagesFetchResult = await _imapClient.fetchMessages( + smallMessagesSequenceSequenceIds, + markAsSeen ? '(UID FLAGS BODY[])' : '(UID FLAGS BODY.PEEK[])', + responseTimeout: timeout, + ); + fetchImapResult + .replaceMatchingMessages(smallMessagesFetchResult.messages); + } + } + + @override + Future> poll() async { + _fetchMessages.clear(); + try { + if (_imapClient.isLoggedIn) { + await _imapClient.noop(); + } + if (_fetchMessages.isEmpty) { + return []; + } + + return _fetchMessages.toList(); + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } catch (e, s) { + _imapClient.logApp('Unexpected exception during polling $e $s'); + throw MailException(mailClient, e.toString(), stackTrace: s, details: e); + } + } + + @override + Future fetchMessagePart( + MimeMessage message, + String fetchId, { + Duration? responseTimeout, + }) async { + FetchImapResult fetchImapResult; + await _pauseIdle(); + try { + final uid = message.uid; + fetchImapResult = uid != null + ? await _imapClient.uidFetchMessage( + uid, + '(BODY[$fetchId])', + responseTimeout: responseTimeout, + ) + : await _imapClient.fetchMessage( + message.sequenceId.toValueOrThrow('no sequenceId found in msg'), + '(BODY[$fetchId])', + responseTimeout: responseTimeout, + ); + if (fetchImapResult.messages.length == 1) { + final part = fetchImapResult.messages.first.getPart(fetchId); + if (part == null) { + throw MailException( + mailClient, + 'Unable to fetch message part <$fetchId>', + ); + } + message.setPart(fetchId, part); + + return part; + } else { + throw MailException( + mailClient, + 'Unable to fetch message part <$fetchId>', + ); + } + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + Future startPolling( + Duration duration, { + Future Function()? pollImplementation, + }) async { + var pollDuration = duration; + if (_supportsIdle) { + // IMAP Idle timeout is 30 minutes, so official recommendation is to + // restart IDLE every 29 minutes. + // Here is a shorter duration chosen, so that connection problems are + // detected earlier. + if (duration == MailClient.defaultPollingDuration) { + pollDuration = const Duration(minutes: 5); + } + pollImplementation ??= _restartIdlePolling; + _isInIdleMode = true; + _imapClient.log('start polling...'); + try { + await _imapClient.idleStart(); + } catch (e, s) { + log('unable to call idleStart(): $e $s'); + unawaited(reconnect()); + // throw MailException.fromImap(mailClient, e); + } + } + + return super + .startPolling(pollDuration, pollImplementation: pollImplementation); + } + + @override + Future stopPolling() async { + if (_isInIdleMode) { + _imapClient.log('stop polling...'); + _isInIdleMode = false; + try { + await _imapClient.idleDone(); + } catch (e, s) { + log('idleDone() call failed: $e $s'); + unawaited(reconnect()); + // throw MailException(mailClient, 'idleDone() call failed', + // details: e, stackTrace: s); + } + } + + return super.stopPolling(); + } + + @override + bool isPolling() => _isInIdleMode || super.isPolling(); + + Future _restartIdlePolling() async { + try { + _imapClient.log('restart IDLE...'); + //print('restart IDLE...'); + await _imapClient.idleDone(); + await _imapClient.idleStart(); + //print('done restarting IDLE.'); + } catch (e, s) { + log('failure at idleDone or idleStart: $e $s'); + log('Unable to restart IDLE: $e'); + unawaited(reconnect()); + } + + return Future.value(); + } + + @override + Future store( + MessageSequence sequence, + List flags, + StoreAction action, + int? unchangedSinceModSequence, + ) async { + await _pauseIdle(); + try { + if (sequence.isUidSequence) { + await _imapClient.uidStore( + sequence, + flags, + action: action, + silent: true, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + } else { + await _imapClient.store( + sequence, + flags, + action: action, + silent: true, + unchangedSinceModSequence: unchangedSinceModSequence, + ); + } + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + bool supportsFlagging() => true; + + @override + Future fetchMessageContents( + final MimeMessage message, { + int? maxSize, + bool markAsSeen = false, + List? includedInlineTypes, + Duration? responseTimeout, + }) async { + BodyPart? body; + final sequence = MessageSequence.fromMessage(message); + if (maxSize != null && (message.size ?? 0) > maxSize) { + // download body structure first, so the media type becomes known: + body = await _fetchMessageStructure(sequence, responseTimeout, body); + } + if (body == null) { + final messages = await fetchMessageSequence( + sequence, + fetchPreference: FetchPreference.full, + markAsSeen: markAsSeen, + responseTimeout: const Duration(seconds: 60), + ); + if (messages.isNotEmpty) { + return messages.last; + } + } else { + try { + // download all non-attachment parts: + final matchingContents = []; + body.collectContentInfo( + ContentDisposition.attachment, + matchingContents, + reverse: true, + ); + if (includedInlineTypes != null && includedInlineTypes.isNotEmpty) { + // some messages set the inline disposition-header + // also for the message text parts + final included = includedInlineTypes.contains(MediaToptype.text) + ? includedInlineTypes + : [MediaToptype.text, ...includedInlineTypes]; + + matchingContents.removeWhere((info) => + (info.contentDisposition?.disposition == + ContentDisposition.inline) && + !included.contains(info.mediaType?.top)); + } + final buffer = StringBuffer()..write('(FLAGS BODY[HEADER] '); + if (message.envelope == null) { + buffer.write('ENVELOPE '); + } + var addSpace = false; + for (final contentInfo in matchingContents) { + if (addSpace) { + buffer.write(' '); + } + if (markAsSeen) { + buffer.write('BODY['); + } else { + buffer.write('BODY.PEEK['); + } + buffer + ..write(contentInfo.fetchId) + ..write(']'); + addSpace = true; + } + buffer.write(')'); + final criteria = buffer.toString(); + final fetchResult = sequence.isUidSequence + ? await _imapClient.uidFetchMessages(sequence, criteria) + : await _imapClient.fetchMessages(sequence, criteria); + if (fetchResult.messages.isNotEmpty) { + final result = fetchResult.messages.first; + // copy all data into original message, so that envelope and + // flags information etc is being kept: + message + ..body = body + ..envelope ??= result.envelope + ..headers = result.headers + ..copyIndividualParts(result) + ..flags = result.flags; + final threadData = _threadData; + if (threadData != null) { + threadData.setThreadSequence(message); + } + + return message; + } + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + throw MailException( + mailClient, + 'Unable to download message with UID ${message.uid} / ' + 'sequence ID ${message.sequenceId}', + ); + } + + Future _fetchMessageStructure( + MessageSequence sequence, + Duration? responseTimeout, + BodyPart? body, + ) async { + try { + await _pauseIdle(); + final fetchResult = sequence.isUidSequence + ? await _imapClient.uidFetchMessages( + sequence, + '(BODYSTRUCTURE)', + responseTimeout: + responseTimeout ?? _imapClient.defaultResponseTimeout, + ) + : await _imapClient.fetchMessages( + sequence, + '(BODYSTRUCTURE)', + responseTimeout: + responseTimeout ?? _imapClient.defaultResponseTimeout, + ); + if (fetchResult.messages.isNotEmpty) { + final lastMessage = fetchResult.messages.last; + if (lastMessage.mediaType.top == MediaToptype.multipart) { + // only for multipart messages it makes sense to + // download the inline parts: + return lastMessage.body; + } + } + } on ImapException catch (e, s) { + await _resumeIdle(); + throw MailException.fromImap(mailClient, e, s); + } + + return body; + } + + @override + Future deleteMessages( + MessageSequence sequence, + Mailbox? trashMailbox, { + bool expunge = false, + List? messages, + }) async { + final selectedMailbox = _selectedMailbox; + if (selectedMailbox == null) { + throw MailException( + mailClient, + 'Unable to delete messages: no mailbox selected', + ); + } + selectedMailbox.messagesExists -= sequence.length; + if (trashMailbox == null || trashMailbox == selectedMailbox || expunge) { + try { + await _pauseIdle(); + await _imapClient.store( + sequence, + [MessageFlags.deleted], + action: StoreAction.add, + silent: true, + ); + if (expunge) { + await _imapClient.expunge(); + } + final canUndo = !expunge; + + return DeleteResult( + DeleteAction.flag, + sequence, + selectedMailbox, + sequence, + selectedMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } else { + try { + await _pauseIdle(); + DeleteAction deleteAction; + GenericImapResult imapResult; + if (_imapClient.serverInfo.supportsMove) { + deleteAction = DeleteAction.move; + imapResult = sequence.isUidSequence + ? await _imapClient.uidMove(sequence, targetMailbox: trashMailbox) + : await _imapClient.move(sequence, targetMailbox: trashMailbox); + } else { + deleteAction = DeleteAction.copy; + + imapResult = sequence.isUidSequence + ? await _imapClient.uidCopy(sequence, targetMailbox: trashMailbox) + : await _imapClient.copy(sequence, targetMailbox: trashMailbox); + await _imapClient.store( + sequence, + [MessageFlags.deleted], + action: StoreAction.add, + silent: true, + ); + } + // note: explicitly do not EXPUNGE after delete, + // so that undo becomes easier + + final targetSequence = imapResult.responseCodeCopyUid?.targetSequence; + // copy and move commands result in a mapping sequence + // which is relevant for undo operations: + + return DeleteResult( + deleteAction, + sequence, + selectedMailbox, + targetSequence, + trashMailbox, + mailClient, + canUndo: targetSequence != null, + messages: messages, + ); + } on ImapException catch (e) { + selectedMailbox.messagesExists += sequence.length; + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + } + + @override + Future undoDeleteMessages(DeleteResult deleteResult) async { + switch (deleteResult.action) { + case DeleteAction.flag: + await store( + deleteResult.originalSequence, + [MessageFlags.deleted], + StoreAction.remove, + null, + ); + break; + case DeleteAction.move: + try { + await _pauseIdle(); + await _imapClient.closeMailbox(); + await _imapClient.selectMailbox( + deleteResult.targetMailbox.toValueOrThrow('no targetMailbox found'), + ); + + GenericImapResult? result; + final targetSequence = deleteResult.targetSequence; + if (targetSequence != null) { + result = targetSequence.isUidSequence + ? await _imapClient.uidMove( + targetSequence, + targetMailbox: deleteResult.originalMailbox, + ) + : await _imapClient.move( + targetSequence, + targetMailbox: deleteResult.originalMailbox, + ); + } + await _imapClient.closeMailbox(); + await _imapClient.selectMailbox(deleteResult.originalMailbox); + if (result == null) { + throw MailException( + mailClient, + 'Unable to undo delete messages ' + 'result without target sequence in $deleteResult', + ); + } + final undoResult = + deleteResult.reverseWith(result.responseCodeCopyUid); + + return undoResult; + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + case DeleteAction.copy: + try { + await _pauseIdle(); + if (deleteResult.originalSequence.isUidSequence) { + await _imapClient.uidStore( + deleteResult.originalSequence, + [MessageFlags.deleted], + action: StoreAction.remove, + ); + } else { + await _imapClient.store( + deleteResult.originalSequence, + [MessageFlags.deleted], + action: StoreAction.remove, + ); + } + final targetMailbox = deleteResult.targetMailbox; + final targetSequence = deleteResult.targetSequence; + if (targetMailbox != null && targetSequence != null) { + await _imapClient.closeMailbox(); + await _imapClient.selectMailbox(targetMailbox); + + if (targetSequence.isUidSequence) { + await _imapClient.uidStore( + targetSequence, + [MessageFlags.deleted], + action: StoreAction.add, + ); + } else { + await _imapClient.store( + targetSequence, + [MessageFlags.deleted], + action: StoreAction.add, + ); + } + + await _imapClient.closeMailbox(); + await _imapClient.selectMailbox(deleteResult.originalMailbox); + } + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + break; + case DeleteAction.pop: + throw InvalidArgumentException( + 'POP delete action not expected for IMAP connection.', + ); + } + + return deleteResult.reverse(); + } + + @override + Future deleteAllMessages( + Mailbox mailbox, { + bool expunge = false, + }) async { + var canUndo = true; + final sequence = MessageSequence.fromAll(); + final selectedMailbox = _selectedMailbox; + try { + await _pauseIdle(); + if (mailbox != selectedMailbox) { + await _imapClient.selectMailbox(mailbox); + } + await _imapClient.markDeleted(sequence, silent: true); + if (expunge) { + canUndo = false; + await _imapClient.expunge(); + } + if (selectedMailbox != null && selectedMailbox != mailbox) { + await _imapClient.selectMailbox(selectedMailbox); + } + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + + return DeleteResult( + DeleteAction.flag, + sequence, + mailbox, + null, + null, + mailClient, + canUndo: canUndo, + ); + } + + Future _moveMessages( + MessageSequence sequence, + Mailbox? target, { + List? messages, + }) async { + final sourceMailbox = _selectedMailbox; + if (sourceMailbox == null) { + throw MailException( + mailClient, + 'Unable to move messages without selected mailbox', + ); + } + MoveAction moveAction; + final GenericImapResult imapResult; + if (_imapClient.serverInfo.supports(ImapServerInfo.capabilityMove)) { + moveAction = MoveAction.move; + imapResult = sequence.isUidSequence + ? await _imapClient.uidMove(sequence, targetMailbox: target) + : await _imapClient.move(sequence, targetMailbox: target); + } else { + moveAction = MoveAction.copy; + + imapResult = sequence.isUidSequence + ? await _imapClient.uidCopy(sequence, targetMailbox: target) + : await _imapClient.copy(sequence, targetMailbox: target); + await _imapClient.store( + sequence, + [MessageFlags.deleted], + action: StoreAction.add, + ); + } + _selectedMailbox?.messagesExists -= sequence.length; + final targetSequence = imapResult.responseCodeCopyUid?.targetSequence; + // copy and move commands result in a mapping sequence + // which is relevant for undo operations: + + return MoveResult( + moveAction, + sequence, + sourceMailbox, + targetSequence, + target, + mailClient, + canUndo: targetSequence != null, + messages: messages, + ); + } + + @override + Future moveMessages( + MessageSequence sequence, + Mailbox target, { + List? messages, + }) async { + try { + await _pauseIdle(); + final response = await _moveMessages( + sequence, + target, + messages: messages, + ); + + return response; + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + Future undoMove(MoveResult moveResult) async { + try { + await _pauseIdle(); + await _imapClient.selectMailbox( + moveResult.targetMailbox.toValueOrThrow('no targetMailbox found'), + ); + final response = await _moveMessages( + moveResult.targetSequence.toValueOrThrow('no targetSequence found'), + moveResult.originalMailbox, + messages: moveResult.messages, + ); + await _imapClient.selectMailbox(moveResult.originalMailbox); + + return response; + } on ImapException catch (e) { + throw MailException.fromImap(mailClient, e); + } finally { + await _resumeIdle(); + } + } + + @override + Future searchMessages(MailSearch search) async { + final queryBuilder = SearchQueryBuilder.from( + search.query, + search.queryType, + messageType: search.messageType, + since: search.since, + before: search.before, + sentSince: search.sentSince, + sentBefore: search.sentBefore, + ); + var resumeIdleInFinally = true; + try { + await _pauseIdle(); + SearchImapResult result; + result = _imapClient.serverInfo.supportsUidPlus + ? await _imapClient.uidSearchMessagesWithQuery( + queryBuilder, + responseTimeout: const Duration(seconds: 60), + ) + : await _imapClient.searchMessagesWithQuery( + queryBuilder, + responseTimeout: const Duration(seconds: 60), + ); + + // TODO consider supported ESEARCH / IMAP Extension for Referencing the Last SEARCH Result / https://tools.ietf.org/html/rfc5182 + final sequence = result.matchingSequence; + if (sequence == null || sequence.isEmpty) { + return MailSearchResult.empty(search); + } + + final requestSequence = sequence.subsequenceFromPage(1, search.pageSize); + final messages = await _fetchMessageSequence( + requestSequence, + fetchPreference: search.fetchPreference, + markAsSeen: false, + ); + + return MailSearchResult( + search, + PagedMessageSequence(sequence, pageSize: search.pageSize), + messages, + search.fetchPreference, + ); + } on ImapException catch (e, s) { + if (search.queryType == SearchQueryType.allTextHeaders) { + resumeIdleInFinally = false; + final orSearch = _selectedMailbox?.isSent ?? false + ? SearchQueryType.toOrSubject + : SearchQueryType.fromOrSubject; + + return searchMessages(search.copyWith(queryType: orSearch)); + } + throw MailException.fromImap(mailClient, e, s); + } finally { + if (resumeIdleInFinally) { + await _resumeIdle(); + } + } + } + + @override + Future appendMessage( + MimeMessage message, + Mailbox targetMailbox, + List? flags, + ) async { + try { + await _pauseIdle(); + final result = await _imapClient.appendMessage( + message, + targetMailbox: targetMailbox, + flags: flags, + ); + + return result.responseCodeAppendUid; + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + bool get supports8BitEncoding => _imapClient.serverInfo.supportsUtf8; + + @override + bool get supportsAppendingMessages => true; + + @override + Future noop() async { + try { + await _pauseIdle(); + await _imapClient.noop(); + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + Future fetchThreads( + Mailbox mailbox, + DateTime since, + ThreadPreference threadPreference, + FetchPreference fetchPreference, + int pageSize, { + Duration? responseTimeout, + }) async { + try { + await _pauseIdle(); + if (mailbox != _selectedMailbox) { + await selectMailbox(mailbox); + } + if (_imapClient.serverInfo.supportedThreadingMethods.isEmpty) { + throw MailException(mailClient, 'Threading not supported by server'); + } + final method = _imapClient.serverInfo.supportedThreadingMethods.first; + responseTimeout ??= const Duration(seconds: 30); + final threadNodes = await _imapClient.uidThreadMessages( + method: method, + since: since, + responseTimeout: responseTimeout, + ); + final threadSequence = threadNodes.toMessageSequence( + mode: threadPreference == ThreadPreference.latest + ? SequenceNodeSelectionMode.lastLeaf + : SequenceNodeSelectionMode.all, + ); + final pagedThreadSequence = + PagedMessageSequence(threadSequence, pageSize: pageSize); + final result = ThreadResult( + threadNodes, + pagedThreadSequence, + threadPreference, + fetchPreference, + since, + [], + ); + if (pagedThreadSequence.hasNext) { + final sequence = pagedThreadSequence.next(); + final unthreadedMessages = await _fetchMessageSequence( + sequence, + fetchPreference: fetchPreference, + responseTimeout: responseTimeout, + ); + result.addAll(unthreadedMessages); + } + + return result; + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + bool get supportsThreading => _imapClient.serverInfo.supportsThreading; + + @override + Future fetchThreadData( + Mailbox mailbox, + DateTime since, { + required bool setThreadSequences, + }) async { + try { + await _pauseIdle(); + if (mailbox != _selectedMailbox) { + await selectMailbox(mailbox); + } + if (_imapClient.serverInfo.supportedThreadingMethods.isEmpty) { + throw MailException(mailClient, 'Threading not supported by server'); + } + final method = _imapClient.serverInfo.supportedThreadingMethods.first; + final threadNodes = await _imapClient.uidThreadMessages( + method: method, + since: since, + responseTimeout: const Duration(seconds: 60), + ); + final result = ThreadDataResult(threadNodes, since); + _threadData = setThreadSequences ? result : null; + + return result; + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + Future createMailbox( + String mailboxName, { + Mailbox? parentMailbox, + }) async { + final path = (parentMailbox != null) + ? parentMailbox.encodedPath + parentMailbox.pathSeparator + mailboxName + : mailboxName; + try { + await _pauseIdle(); + + return await _imapClient.createMailbox(path); + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + Future deleteMailbox(Mailbox mailbox) async { + try { + await _pauseIdle(); + await _imapClient.deleteMailbox(mailbox); + } on ImapException catch (e, s) { + throw MailException.fromImap(mailClient, e, s); + } finally { + await _resumeIdle(); + } + } + + @override + void log(String text) { + _imapClient.logApp(text); + } +} + +class _IncomingPopClient extends _IncomingMailClient { + _IncomingPopClient( + int? downloadSizeLimit, + EventBus eventBus, + String? logName, + MailServerConfig config, + MailClient mailClient, { + required bool isLogEnabled, + bool Function(X509Certificate)? onBadCertificate, + }) : _popClient = PopClient( + bus: eventBus, + isLogEnabled: isLogEnabled, + logName: logName, + onBadCertificate: onBadCertificate, + ), + super(downloadSizeLimit, config, mailClient); + + @override + ClientBase get client => _popClient; + + @override + ServerType get clientType => ServerType.pop; + + final Mailbox _popInbox = Mailbox( + encodedName: 'Inbox', + encodedPath: 'Inbox', + flags: [MailboxFlag.inbox], + pathSeparator: '/', + ); + + final PopClient _popClient; + + @override + Future connect({Duration timeout = const Duration(seconds: 20)}) async { + final serverConfig = _config.serverConfig; + final isSecure = serverConfig.socketType == SocketType.ssl; + await _popClient.connectToServer( + serverConfig.hostname, + serverConfig.port, + isSecure: isSecure, + timeout: timeout, + ); + if (!isSecure) { + //TODO check POP3 server capabilities first + if (serverConfig.socketType != SocketType.plainNoStartTls) { + await _popClient.startTls(); + } else { + log('Warning: not using secure connection, ' + 'your credentials are not secure.'); + } + } + try { + final authResponse = await _config.authentication + .authenticate(serverConfig, pop: _popClient); + + return authResponse; + } on PopException catch (e, s) { + throw MailException.fromPop(mailClient, e, s); + } catch (e, s) { + throw MailException(mailClient, e.toString(), stackTrace: s, details: e); + } + } + + @override + Future disconnect() => _popClient.disconnect(); + + @override + Future> listMailboxes() => Future.value([_popInbox]); + + @override + Future selectMailbox( + Mailbox mailbox, { + bool enableCondStore = false, + QResyncParameters? qresync, + }) async { + if (mailbox != _popInbox) { + throw MailException(mailClient, 'Unknown mailbox $mailbox'); + } + final status = await _popClient.status(); + mailbox.messagesExists = status.numberOfMessages; + _selectedMailbox = mailbox; + + return mailbox; + } + + @override + Future> poll() async { + final numberOfKNownMessages = + _selectedMailbox.toValueOrThrow('no mailbox selected').messagesExists; + // in POP3 a new session is required to get a new status + await connect(); + final status = await _popClient.status(); + final messages = []; + final numberOfMessages = status.numberOfMessages; + if (numberOfMessages < numberOfKNownMessages) { + //TODO compare list UIDs with known message UIDs + // instead of just checking the number of messages + final diff = numberOfMessages - numberOfKNownMessages; + for (var id = numberOfMessages; id > numberOfMessages - diff; id--) { + final message = await _popClient.retrieve(id); + messages.add(message); + mailClient._fireEvent(MailLoadEvent(message, mailClient)); + } + } + + return messages; + } + + @override + Future> fetchMessageSequence( + MessageSequence sequence, { + FetchPreference? fetchPreference, + bool? markAsSeen, + Duration? responseTimeout, + }) async { + final ids = sequence.toList(_selectedMailbox?.messagesExists); + final messages = []; + for (final id in ids) { + final message = await _popClient.retrieve(id); + messages.add(message); + } + + return messages; + } + + @override + Future store( + MessageSequence sequence, + List flags, + StoreAction action, + int? unchangedSinceModSequence, + ) async { + if (flags.length == 1 && flags.first == MessageFlags.deleted) { + if (action == StoreAction.remove) { + await _popClient.reset(); + } + final ids = sequence.toList(_selectedMailbox?.messagesExists); + for (final id in ids) { + await _popClient.delete(id); + } + } + throw InvalidArgumentException('POP does not support storing flags.'); + } + + @override + bool supportsFlagging() => false; + + @override + Future fetchMessagePart( + MimeMessage message, + String fetchId, { + Duration? responseTimeout, + }) { + throw InvalidArgumentException( + 'POP does not support fetching message parts.', + ); + } + + @override + Future fetchMessageContents( + MimeMessage message, { + int? maxSize, + bool? markAsSeen, + List? includedInlineTypes, + Duration? responseTimeout, + }) async { + final id = message.sequenceId.toValueOrThrow('no sequenceId found'); + final messageResponse = await _popClient.retrieve(id); + + return messageResponse; + } + + @override + Future deleteMessages( + MessageSequence sequence, + Mailbox? trashMailbox, { + bool expunge = false, + List? messages, + }) async { + final selectedMailbox = _selectedMailbox; + if (selectedMailbox == null) { + throw MailException( + mailClient, + 'Unable to deleteMessages: select inbox first', + ); + } + final ids = sequence.toList(_selectedMailbox?.messagesExists); + for (final id in ids) { + await _popClient.delete(id); + } + + return DeleteResult( + DeleteAction.pop, + sequence, + selectedMailbox, + null, + null, + mailClient, + canUndo: false, + messages: messages, + ); + } + + @override + Future deleteAllMessages( + Mailbox mailbox, { + bool expunge = false, + }) { + // TODO(RV): implement deleteAllMessages + throw UnimplementedError(); + } + + @override + Future undoDeleteMessages(DeleteResult deleteResult) { + // TODO(RV): implement undoDeleteMessages + throw UnimplementedError(); + } + + @override + Future moveMessages( + MessageSequence sequence, + Mailbox target, { + List? messages, + }) { + // TODO(RV): implement moveMessages + throw UnimplementedError(); + } + + @override + Future undoMove(MoveResult moveResult) { + // TODO(RV): implement undoMove + throw UnimplementedError(); + } + + @override + Future searchMessages(MailSearch search) { + // TODO(RV): implement searchMessages + throw UnimplementedError(); + } + + @override + Future appendMessage( + MimeMessage message, + Mailbox targetMailbox, + List? flags, + ) { + // TODO(RV): implement appendMessage + throw UnimplementedError(); + } + + @override + bool get supports8BitEncoding => false; // TODO implement + + @override + bool get supportsAppendingMessages => false; + + @override + Future noop() => _popClient.noop(); + + @override + Future fetchThreads( + Mailbox mailbox, + DateTime since, + ThreadPreference threadPreference, + FetchPreference fetchPreference, + int pageSize, { + Duration? responseTimeout, + }) { + // TODO(RV): implement fetchThreads + throw UnimplementedError(); + } + + @override + bool get supportsThreading => false; + + @override + Future fetchThreadData( + Mailbox mailbox, + DateTime since, { + required bool setThreadSequences, + }) { + // TODO(RV): implement fetchThreadData + throw UnimplementedError(); + } + + @override + Future createMailbox(String mailboxName, {Mailbox? parentMailbox}) { + // TODO(RV): implement createMailbox + throw UnimplementedError(); + } + + @override + bool get supportsMailboxes => false; + + @override + Future deleteMailbox(Mailbox mailbox) { + // TODO(RV): implement deleteMailbox + throw UnimplementedError(); + } + + @override + Future reconnect() => connect(); + + @override + void log(String text) { + _popClient.logApp(text); + } +} + +abstract class _OutgoingMailClient { + _OutgoingMailClient({required MailServerConfig mailConfig}) + : _mailConfig = mailConfig; + + ClientBase get client; + + ServerType get clientType; + MailServerConfig _mailConfig; + + /// Checks if the incoming mail client supports 8 bit encoded messages. + /// + /// Is only correct after authorizing. + Future supports8BitEncoding(); + + Future sendMessage( + MimeMessage message, { + required bool supportUnicode, + MailAddress? from, + bool use8BitEncoding = false, + List? recipients, + }); + + Future disconnect(); +} + +class _OutgoingSmtpClient extends _OutgoingMailClient { + _OutgoingSmtpClient( + this.mailClient, + outgoingClientDomain, + EventBus? eventBus, + String logName, + MailServerConfig mailConfig, { + required bool isLogEnabled, + bool Function(X509Certificate)? onBadCertificate, + }) : _smtpClient = SmtpClient( + outgoingClientDomain, + bus: eventBus, + isLogEnabled: isLogEnabled, + logName: logName, + // defaultWriteTimeout: connectionTimeout, + onBadCertificate: onBadCertificate, + ), + super(mailConfig: mailConfig); + + @override + ClientBase get client => _smtpClient; + + @override + ServerType get clientType => ServerType.smtp; + final MailClient mailClient; + final SmtpClient _smtpClient; + + Future _connectOutgoingIfRequired() async { + if (!_smtpClient.isLoggedIn) { + final config = _mailConfig.serverConfig; + final isSecure = config.socketType == SocketType.ssl; + try { + await _smtpClient.connectToServer( + config.hostname, + config.port, + isSecure: isSecure, + ); + await _smtpClient.ehlo(); + if (!isSecure) { + if (_smtpClient.serverInfo.supportsStartTls && + (config.socketType != SocketType.plainNoStartTls)) { + await _smtpClient.startTls(); + } else { + _smtpClient.logApp( + 'Warning: not using secure connection, ' + 'your credentials are not secure.', + ); + } + } + await _mailConfig.authentication + .authenticate(config, smtp: _smtpClient); + } on SmtpException catch (e, s) { + throw MailException.fromSmtp(mailClient, e, s); + } catch (e, s) { + throw MailException( + mailClient, + e.toString(), + stackTrace: s, + details: e, + ); + } + } + } + + @override + Future sendMessage( + MimeMessage message, { + required bool supportUnicode, + MailAddress? from, + bool use8BitEncoding = false, + List? recipients, + }) async { + await _connectOutgoingIfRequired(); + try { + if (_smtpClient.serverInfo.supportsChunking) { + await _smtpClient.sendChunkedMessage( + message, + from: from, + supportUnicode: supportUnicode, + use8BitEncoding: use8BitEncoding, + recipients: recipients, + ); + } else { + await _smtpClient.sendMessage( + message, + from: from, + use8BitEncoding: use8BitEncoding, + recipients: recipients, + ); + } + } on SmtpException catch (e) { + throw MailException.fromSmtp(mailClient, e); + } + } + + @override + Future disconnect() => _smtpClient.disconnect(); + + @override + Future supports8BitEncoding() async { + if (!_smtpClient.isLoggedIn) { + await _connectOutgoingIfRequired(); + } + + return _smtpClient.serverInfo.supports8BitMime; + } +} diff --git a/packages/enough_mail/lib/src/mail/mail_events.dart b/packages/enough_mail/lib/src/mail/mail_events.dart new file mode 100644 index 0000000..d621b36 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_events.dart @@ -0,0 +1,94 @@ +import '../imap/message_sequence.dart'; +import '../mime_message.dart'; +import 'mail_client.dart'; + +/// Classification of Mail events +/// +/// Compare [MailEvent] +enum MailEventType { + /// a new mail arrived + newMail, + + /// one or several mails have been deleted / moved to trash + vanished, + + /// one or several mail flags have been updated + updateMail, + + /// the connection to the server has been lost + connectionLost, + + /// the connection to the server has been regained + connectionReEstablished +} + +/// Base class for any event that can be fired by the MailClient at any time. +/// Compare [MailClient.eventBus] +class MailEvent { + /// Creates a new mail event + const MailEvent(this.eventType, this.mailClient); + + /// The type of the event + final MailEventType eventType; + + /// The mail client that fired this event + final MailClient mailClient; +} + +/// Notifies about a message that has been deleted +class MailLoadEvent extends MailEvent { + /// Creates a new mail event + const MailLoadEvent(this.message, MailClient mailClient) + : super(MailEventType.newMail, mailClient); + + /// The message that has been loaded + final MimeMessage message; +} + +/// Notifies about the removal of messages +class MailVanishedEvent extends MailEvent { + /// Creates a new mail event + const MailVanishedEvent( + this.sequence, + MailClient mailClient, { + required this.isEarlier, + }) : super(MailEventType.vanished, mailClient); + + /// Sequence of messages that have been expunged, + /// Use this code to check if the sequence consists of UIDs: + /// `if (sequence.isUidSequence) { ... }` + final MessageSequence? sequence; + + /// true when the vanished messages do not lead to updated sequence IDs + final bool isEarlier; +} + +/// Notifies about an mail flags update +class MailUpdateEvent extends MailEvent { + /// Creates a new mail event + const MailUpdateEvent(this.message, MailClient mailClient) + : super(MailEventType.updateMail, mailClient); + + /// The message for which the flags have been updated + final MimeMessage message; +} + +/// Notifies about a lost connection +class MailConnectionLostEvent extends MailEvent { + /// Creates a new mail event + const MailConnectionLostEvent(MailClient mailClient) + : super(MailEventType.connectionLost, mailClient); +} + +/// Notifies about a connection that has been re-established +class MailConnectionReEstablishedEvent extends MailEvent { + /// Creates a new mail event + const MailConnectionReEstablishedEvent( + MailClient mailClient, { + required this.isManualSynchronizationRequired, + }) : super(MailEventType.connectionReEstablished, mailClient); + + /// Is `true` when the server does not support quick resync (`QRSYNC`) + /// or a similar method. + final bool isManualSynchronizationRequired; +} diff --git a/packages/enough_mail/lib/src/mail/mail_exception.dart b/packages/enough_mail/lib/src/mail/mail_exception.dart new file mode 100644 index 0000000..8c835e4 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_exception.dart @@ -0,0 +1,71 @@ +import '../../enough_mail.dart'; + +/// Provides details about high level unexpected events +class MailException implements Exception { + /// Creates a new exception + MailException(this.mailClient, this.message, {this.stackTrace, this.details}); + + /// Creates a new exception from the low level one + MailException.fromImap( + MailClient mailClient, + ImapException e, [ + StackTrace? s, + ]) : this( + mailClient, + '${e.imapClient.logName}: ${e.message}', + stackTrace: s ?? e.stackTrace, + details: e.details, + ); + + /// Creates a new exception from the low level one + MailException.fromPop(MailClient mailClient, PopException e, [StackTrace? s]) + : this( + mailClient, + '${e.popClient.logName}: ${e.message}', + stackTrace: s ?? e.stackTrace, + details: e.response, + ); + + /// Creates a new exception from the low level one + MailException.fromSmtp( + MailClient mailClient, + SmtpException e, [ + StackTrace? s, + ]) : this( + mailClient, + '${e.smtpClient.logName}: ${e.message}', + stackTrace: s ?? e.stackTrace, + details: e.response, + ); + + /// The originating mail client + final MailClient mailClient; + + /// The error message + final String? message; + + /// The stacktrace + final StackTrace? stackTrace; + + /// Any details + final dynamic details; + + @override + String toString() { + final buffer = StringBuffer() + ..write('MailException: ') + ..write(message); + if (details != null) { + buffer + ..write('\n') + ..write(details); + } + if (stackTrace != null) { + buffer + ..write('\n') + ..write(stackTrace); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/mail/mail_search.dart b/packages/enough_mail/lib/src/mail/mail_search.dart new file mode 100644 index 0000000..b0717c4 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/mail_search.dart @@ -0,0 +1,171 @@ +import '../imap/imap_search.dart'; +import '../mail_address.dart'; +import '../mime_message.dart'; +import 'mail_client.dart'; + +/// Abstracts a typical mail search +class MailSearch { + /// Creates a new search for [query] in the fields defined by [queryType]. + /// + /// Optionally you can also define what kind of messages to search + /// with the [messageType], + /// + /// the internal date since a message has been received with [since], + /// + /// the internal date before a message has been received with [before], + /// + /// the internal date since a message has been sent with [sentSince], + /// + /// the internal date before a message has been sent with [sentBefore], + /// + /// the number of messages that are loaded initially with [pageSize] + /// which defaults to `20`. + /// + /// the [fetchPreference] for fetching the initial page of messages, + /// defaults to [FetchPreference.envelope]. + const MailSearch( + this.query, + this.queryType, { + this.messageType, + this.since, + this.before, + this.sentSince, + this.sentBefore, + this.pageSize = 20, + this.fetchPreference = FetchPreference.envelope, + }); + + /// The query text + final String query; + + /// Which message fields should be used for this query. + final SearchQueryType queryType; + + /// Which message types should be used for this query - defaults to any. + final SearchMessageType? messageType; + + /// From which internal date onward a message matches + final DateTime? since; + + /// Until which internal date a message matches + final DateTime? before; + + /// From which internal sent date a message matches + final DateTime? sentSince; + + /// Until which internal sent date a message matches + final DateTime? sentBefore; + + /// The number of messages that are loaded initially + final int pageSize; + + /// The fetch preference for loading the search results + final FetchPreference fetchPreference; + + /// Checks a new incoming [message] if it matches this query + bool matches(MimeMessage message) { + var matchesQuery = query.isEmpty; + if (!matchesQuery) { + // the query is not empty + final queryText = query.toLowerCase(); + switch (queryType) { + case SearchQueryType.subject: + matchesQuery = _matchesSubject(queryText, message); + break; + case SearchQueryType.from: + matchesQuery = _matchesFrom(queryText, message); + break; + case SearchQueryType.to: + matchesQuery = _matchesTo(queryText, message); + break; + case SearchQueryType.body: + matchesQuery = + _textContains(queryText, message.decodeTextPlainPart()); + break; + case SearchQueryType.allTextHeaders: + matchesQuery = _matchesSubject(queryText, message) || + _matchesFrom(queryText, message) || + _matchesTo(queryText, message); + break; + case SearchQueryType.fromOrSubject: + matchesQuery = _matchesSubject(queryText, message) || + _matchesFrom(queryText, message); + break; + case SearchQueryType.toOrSubject: + matchesQuery = _matchesSubject(queryText, message) || + _matchesTo(queryText, message); + break; + case SearchQueryType.fromOrTo: + matchesQuery = _matchesFrom(queryText, message) || + _matchesTo(queryText, message); + break; + } + if (!matchesQuery) { + return false; + } + } + final before = this.before; + if (before != null) { + final date = message.decodeDate() ?? DateTime.now(); + if (date.isAfter(before)) { + return false; + } + } + + return true; + } + + bool _matchesSubject(String queryText, MimeMessage message) => + message.decodeSubject()?.toLowerCase().contains(queryText) ?? false; + + bool _matchesFrom(String queryText, MimeMessage message) => + _matchesAddresses(queryText, message.from); + + bool _matchesTo(String queryText, MimeMessage message) => + _matchesAddresses(queryText, message.to) || + _matchesAddresses(queryText, message.cc); + + bool _matchesAddresses(String queryText, List? addresses) { + if (addresses == null || addresses.isEmpty) { + return false; + } + for (final address in addresses) { + if (_textContains(queryText, address.email) || + _textContains(queryText, address.personalName)) { + return true; + } + } + + return false; + } + + bool _textContains(String queryText, String? text) { + if (text == null) { + return false; + } + + return text.toLowerCase().contains(queryText); + } + + /// Copies this search with the specified different parameters. + MailSearch copyWith({ + String? query, + SearchQueryType? queryType, + SearchMessageType? messageType, + DateTime? before, + DateTime? since, + DateTime? sentBefore, + DateTime? sentSince, + int? pageSize, + }) => + MailSearch( + query ?? this.query, + queryType ?? this.queryType, + messageType: messageType ?? this.messageType, + before: before ?? this.before, + since: since ?? this.since, + sentBefore: sentBefore ?? this.sentBefore, + sentSince: sentSince ?? this.sentSince, + pageSize: pageSize ?? this.pageSize, + ); +} diff --git a/packages/enough_mail/lib/src/mail/results.dart b/packages/enough_mail/lib/src/mail/results.dart new file mode 100644 index 0000000..1ee5ca3 --- /dev/null +++ b/packages/enough_mail/lib/src/mail/results.dart @@ -0,0 +1,641 @@ +import 'dart:async'; + +import 'package:collection/collection.dart' show IterableExtension; + +import '../../enough_mail.dart'; + +/// Base class for operation results based on messages +class MessagesOperationResult { + /// Creates a new message operation result + MessagesOperationResult( + this.originalSequence, + this.originalMailbox, + this.targetSequence, + this.targetMailbox, + this.mailClient, { + required this.canUndo, + this.messages, + }) { + applyMessageIds(originalSequence, targetSequence, messages); + } + + /// Is this delete result undoable? + @Deprecated('Use canUndo instead') + bool get isUndoable => canUndo; + + /// Can the move operation be undone? + final bool canUndo; + + /// The originating mailbox + final Mailbox originalMailbox; + + /// The original message sequence used + final MessageSequence originalSequence; + + /// The resulting message sequence of the deleted messages + final MessageSequence? targetSequence; + + /// The target mailbox, can be null + final Mailbox? targetMailbox; + + /// The associated mail client + final MailClient mailClient; + + /// The deleted messages, if known + final List? messages; + + /// Apply the message IDs from the [targetSequence] to the [messages] + bool applyMessageIds( + MessageSequence originalSequence, + MessageSequence? targetSequence, + List? messages, + ) { + if (messages != null && targetSequence != null) { + final originalIds = originalSequence.toList(); + final targetIds = targetSequence.toList(); + if (originalIds.length != targetIds.length) { + print('Unable to apply new message IDs: Unexpected different length of ' + 'original and target sequence: ' + 'original=$originalSequence, target=$targetSequence'); + + return false; + } + final isUid = originalSequence.isUidSequence; + for (var i = 0; i < originalIds.length; i++) { + final originalId = originalIds[i]; + final message = messages.firstWhereOrNull( + (message) => isUid + ? message.uid == originalId + : message.sequenceId == originalId, + ); + if (message != null) { + if (isUid) { + message.uid = targetIds[i]; + } else { + message.sequenceId = targetIds[i]; + } + } + } + } + + return true; + } +} + +/// The internal action that was used for deletion. +/// This is useful for undoing and delete operation. +enum DeleteAction { + /// The message(s) were marked as deleted with a flag + flag, + + /// The message(s) were moved + move, + + /// The message(s) were copied and then flagged + copy, + + /// The message(s) were deleted via POP3 protocol + pop, +} + +/// Provides information about a delete action +class DeleteResult extends MessagesOperationResult { + /// Creates a new result for an delete call + DeleteResult( + this.action, + MessageSequence originalSequence, + Mailbox originalMailbox, + MessageSequence? targetSequence, + Mailbox? targetMailbox, + MailClient mailClient, { + required bool canUndo, + List? messages, + }) : super( + originalSequence, + originalMailbox, + targetSequence, + targetMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + + /// The internal action that was used to delete + final DeleteAction action; + + /// Reverses the result + /// so that the original sequence and mailbox becomes the target ones. + DeleteResult reverse() { + final targetSequence = this.targetSequence; + if (targetSequence == null) { + throw InvalidArgumentException( + 'Unable to reverse DeleteResult without target sequence', + ); + } + final targetMailbox = this.targetMailbox; + if (targetMailbox == null) { + throw InvalidArgumentException( + 'Unable to reverse DeleteResult without target mailbox', + ); + } + + return DeleteResult( + action, + targetSequence, + targetMailbox, + originalSequence, + originalMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + } + + /// Reverses the result + /// and includes the new sequence from the given [result]. + DeleteResult reverseWith(UidResponseCode? result) { + final resultTargetSequence = result?.targetSequence; + final targetMailbox = this.targetMailbox; + final targetSequence = this.targetSequence; + if (resultTargetSequence != null && + targetMailbox != null && + targetSequence != null) { + return DeleteResult( + action, + targetSequence, + targetMailbox, + resultTargetSequence, + originalMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + } + + return reverse(); + } +} + +/// Possible move implementations +enum MoveAction { + /// Messages were moved using the `MOVE` extension + move, + + /// Messages were copied to the target mailbox and then deleted + /// on the originating mailbox + copy +} + +/// Result for move operations +class MoveResult extends MessagesOperationResult { + /// Creates a new result for an move call + MoveResult( + this.action, + MessageSequence originalSequence, + Mailbox originalMailbox, + MessageSequence? targetSequence, + Mailbox? targetMailbox, + MailClient mailClient, { + required bool canUndo, + List? messages, + }) : super( + originalSequence, + originalMailbox, + targetSequence, + targetMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + + /// The internal action that was used to delete + final MoveAction action; + + /// Reverses the result + /// so that the original sequence and mailbox becomes the target ones. + /// + /// Throws [MailException] when either the [targetSequence] or the + /// [targetMailbox] are `null`. + MoveResult reverse() { + final targetSequence = this.targetSequence; + final targetMailbox = this.targetMailbox; + if (targetSequence == null || targetMailbox == null) { + throw MailException( + mailClient, + 'Unable to reverse move operation without target sequence', + ); + } + + return MoveResult( + action, + targetSequence, + targetMailbox, + originalSequence, + originalMailbox, + mailClient, + canUndo: canUndo, + messages: messages, + ); + } +} + +/// Encapsulates a thread result +class ThreadResult { + /// Creates a new result with the given [threadData], [threadSequence], + /// [threadPreference], [fetchPreference] and the pre-fetched [threads]. + const ThreadResult( + this.threadData, + this.threadSequence, + this.threadPreference, + this.fetchPreference, + this.since, + this.threads, + ); + + /// The source data + final SequenceNode threadData; + + /// The paged message sequence + final PagedMessageSequence threadSequence; + + /// The thread preference + final ThreadPreference threadPreference; + + /// The fetch preference + final FetchPreference fetchPreference; + + /// Since when the thread data is retrieved + final DateTime since; + + /// The threads that have been fetched so far + final List threads; + + /// Retrieves the total number of threads. + /// + /// This can be higher than `threads.length`. + int get length => threadData.length; + + /// Checks if the [threadSequence] has a next page + bool get hasMoreResults => threadSequence.hasNext; + + /// Shortcut to find out if this thread result is UID based + bool get isUidBased => threadSequence.isUidSequence; + + /// Eases access to the [MimeThread] at the specified [threadIndex] or `null` + /// when it is not yet loaded. + /// + /// Note that the [threadIndex] is expected to be based on full [threadData], + /// meaning 0 is the newest thread and length-1 is the oldest thread. + MimeThread? operator [](int threadIndex) { + final index = length - threadIndex - 1; + if (index < 0 || threadIndex < 0) { + return null; + } + + return threads[threadIndex]; + } + + /// Distributes the given [unthreadedMessages] to the [threads] + /// managed by this result. + void addAll(List unthreadedMessages) { + // the new messages could + // a) complement existing threads, but only when threadPreference is + // ThreadPreference.all, or + // b) create complete new threads + final isUid = threadData.isUid; + if (threadPreference == ThreadPreference.latest) { + for (final node in threadData.children.reversed) { + final id = node.latestId; + final message = isUid + ? unthreadedMessages.firstWhereOrNull((msg) => msg.uid == id) + : unthreadedMessages + .firstWhereOrNull((msg) => msg.sequenceId == id); + if (message != null) { + final thread = MimeThread(node.toMessageSequence(), [message]); + threads.insert(0, thread); + } + } + threads.sort((t1, t2) => isUid + ? (t1.latest.uid ?? 0).compareTo(t2.latest.uid ?? 0) + : (t1.latest.sequenceId ?? 0).compareTo(t2.latest.sequenceId ?? 0)); + } else { + // check if there are messages for already existing threads: + for (final thread in threads) { + if (thread.hasMoreMessages) { + final ids = thread.missingMessageSequence.toList().reversed; + for (final id in ids) { + final message = isUid + ? unthreadedMessages.firstWhereOrNull((msg) => msg.uid == id) + : unthreadedMessages + .firstWhereOrNull((msg) => msg.sequenceId == id); + if (message != null) { + unthreadedMessages.remove(message); + thread.messages.insert(0, message); + } + } + } + } + // now check if there are more threads: + if (unthreadedMessages.isNotEmpty) { + for (final node in threadData.children.reversed) { + final threadSequence = node.toMessageSequence(); + final threadedMessages = []; + final ids = threadSequence.toList(); + for (final id in ids) { + final message = isUid + ? unthreadedMessages.firstWhereOrNull((msg) => msg.uid == id) + : unthreadedMessages + .firstWhereOrNull((msg) => msg.sequenceId == id); + if (message != null) { + threadedMessages.add(message); + } + } + if (threadedMessages.isNotEmpty) { + final thread = MimeThread(threadSequence, threadedMessages); + threads.add(thread); + } + } + threads.sort((t1, t2) => isUid + ? (t1.latest.uid ?? 0).compareTo(t2.latest.uid ?? 0) + : (t1.latest.sequenceId ?? 0).compareTo(t2.latest.sequenceId ?? 0)); + } + } + } + + /// Checks if the page for the given thread [threadIndex] is already requested + /// in a [ThreadPreference.latest] based result. + /// + /// Note that the [threadIndex] is expected to be based on full [threadData], + /// meaning 0 is the newest thread and length-1 is the oldest thread. + bool isPageRequestedFor(int threadIndex) { + assert(threadPreference == ThreadPreference.latest, + 'This call is only valid for ThreadPreference.latest'); + final index = length - threadIndex - 1; + + return index > + length - (threadSequence.currentPageIndex * threadSequence.pageSize); + } +} + +/// Contains information about threads +/// +/// Retrieve the thread sequence for a given message UID +/// with `threadDataResult[uid]`. +/// Example: +/// ```dart +/// final sequence = threadDataResult[mimeMessage.uid]; +/// if (sequence != null) { +/// // the mimeMessage belongs to a thread +/// } +/// ``` +class ThreadDataResult { + /// Creates a new result with the given [data] and [since]. + ThreadDataResult(this.data, this.since) { + for (final node in data.children) { + if (node.isNotEmpty) { + final sequence = node.toMessageSequence(); + final ids = sequence.toList(); + if (ids.length > 1) { + for (final id in ids) { + _sequencesById[id] = sequence; + } + } + } + } + } + + /// The source data + final SequenceNode data; + + /// The day since when threads were requested + final DateTime since; + + final _sequencesById = {}; + + /// Checks if the given [id] belongs to a thread. + bool hasThread(int id) => _sequencesById[id] != null; + + /// Retrieves the thread sequence for the given message [id]. + MessageSequence? operator [](int id) => _sequencesById[id]; + + /// Sets the [MimeMessage.threadSequence] for the specified [mimeMessage] + void setThreadSequence(MimeMessage mimeMessage) { + final id = data.isUid ? mimeMessage.uid : mimeMessage.sequenceId; + final sequence = _sequencesById[id]; + mimeMessage.threadSequence = sequence; + } +} + +/// Base class for actions that result in a partial fetching of messages +class PagedMessageResult { + /// Creates a new paged result + PagedMessageResult(this.pagedSequence, this.messages, this.fetchPreference) + : _requestedPages = >>{}; + + /// Creates a new empty paged message result with the option + /// [fetchPreference] ([FetchPreference.envelope]) and [pageSize](`30`). + PagedMessageResult.empty({ + FetchPreference fetchPreference = FetchPreference.envelope, + int pageSize = 30, + }) : this( + PagedMessageSequence.empty(pageSize: pageSize), + [], + fetchPreference, + ); + + /// The message sequence containing all IDs or UIDs, may be null + /// for empty searches + final PagedMessageSequence pagedSequence; + + /// The number of all matching messages + int get length => pagedSequence.length; + + /// Checks if this result is empty + bool get isEmpty => length == 0; + + /// Checks if this result is not empty + bool get isNotEmpty => length > 0; + + /// The fetched messages, initially this contains only the first page + final List messages; + + /// The original fetch preference + final FetchPreference fetchPreference; + + /// Requested pages + final Map>> _requestedPages; + + /// Checks if the `messageSequence` has a next page + bool get hasMoreResults => pagedSequence.hasNext; + + /// Shortcut to find out if this search result is UID based + bool get isUidBased => pagedSequence.isUidSequence; + + /// Inserts the given [page] of messages to this result + void insertAll(List page) => messages.insertAll(0, page); + + /// Adds the specified message to this search result. + void addMessage(MimeMessage message) { + final id = isUidBased ? message.uid : message.sequenceId; + if (id == null) { + throw InvalidArgumentException('Unable to add message without ID'); + } + pagedSequence.add(id); + messages.add(message); + } + + /// Adds the specified message to this search result. + void removeMessage(MimeMessage message) { + final id = isUidBased ? message.uid : message.sequenceId; + if (id == null) { + throw InvalidArgumentException('Unable to remove message without ID'); + } + pagedSequence.remove(id); + messages.remove(message); + } + + /// Removes the specified [removeSequence] from this result + /// and returns all messages that have been loaded. + /// + /// Note that the [removeSequence] must be based on the same type of IDs + /// (UID or sequence-ID) as this result. + List removeMessageSequence(MessageSequence removeSequence) { + assert(removeSequence.isUidSequence == pagedSequence.isUidSequence, + 'Not the same sequence ID types'); + final isUid = pagedSequence.isUidSequence; + final ids = removeSequence.toList(); + final result = []; + for (final id in ids) { + pagedSequence.remove(id); + final match = messages.firstWhereOrNull( + (msg) => isUid ? msg.uid == id : msg.sequenceId == id, + ); + if (match != null) { + result.add(match); + messages.remove(match); + } + } + + return result; + } + + /// Retrieves the message for the given [messageIndex]. + /// + /// Note that the [messageIndex] is expected to be based on + /// full `messageSequence`, where index 0 is newest message and + /// `size-1` is the oldest message. + /// Compare [isAvailable] + MimeMessage operator [](int messageIndex) { + final index = messages.length - messageIndex - 1; + if (index < 0) { + throw RangeError( + 'for messageIndex $messageIndex in a result with the length $length ' + 'and currently loaded message count of ${messages.length}', + ); + } + + return messages[index]; + } + + /// Checks if the message for the given [messageIndex] is already loaded. + /// + /// Note that the [messageIndex] is expected to be based on + /// full `messageSequence`, where index 0 is newest message and + /// `size-1` is the oldest message. + bool isAvailable(int messageIndex) { + final index = messages.length - messageIndex - 1; + + return index >= 0 && messageIndex >= 0; + } + + /// Retrieves the message ID at the specified [messageIndex]. + /// + /// Note that the [messageIndex] is expected to be based on + /// full `messageSequence`, where index 0 is newest message and + /// `size-1` is the oldest message. + int messageIdAt(int messageIndex) { + final index = length - messageIndex - 1; + + return pagedSequence.sequence.elementAt(index); + } + + /// Checks if the page for the given [messageIndex] is already requested. + /// + /// Note that the [messageIndex] is expected to be based on + /// full `messageSequence`, where index 0 is newest message and + /// `size-1` is the oldest message. + bool isPageRequestedFor(int messageIndex) { + final index = length - messageIndex - 1; + + return index > + length - (pagedSequence.currentPageIndex * pagedSequence.pageSize); + } + + /// Retrieves the message at the given index. + /// + /// Note that the [messageIndex] is expected to be based on + /// full `messageSequence`, where index 0 is newest message and + /// `size-1` is the oldest message. + Future getMessage( + int messageIndex, + MailClient mailClient, { + Mailbox? mailbox, + FetchPreference fetchPreference = FetchPreference.envelope, + }) async { + Future> queue(int pageIndex) { + final sequence = pagedSequence.getSequence(pageIndex); + final future = mailClient.fetchMessageSequence( + sequence, + mailbox: mailbox, + fetchPreference: fetchPreference, + ); + _requestedPages[pageIndex] = future; + + return future; + } + + if (isAvailable(messageIndex)) { + return this[messageIndex]; + } + final pageIndex = pagedSequence.pageIndexOf(messageIndex); + if (pageIndex > 0) { + // ensure that previous pages are loaded first: + final previousRequest = _requestedPages[pageIndex - 1]; + if (previousRequest != null) { + await previousRequest; + } + } + final request = _requestedPages[pageIndex] ?? queue(pageIndex); + final messages = await request; + if (_requestedPages.containsKey(pageIndex)) { + unawaited(_requestedPages.remove(pageIndex)); + insertAll(messages); + } + final relativeIndex = + (pageIndex * pagedSequence.pageSize + messages.length) - + (messageIndex + 1); + + return messages[relativeIndex]; + } +} + +/// Contains the result of a search +class MailSearchResult extends PagedMessageResult { + /// Creates a new search result + MailSearchResult( + this.search, + PagedMessageSequence pagedSequence, + List messages, + FetchPreference fetchPreference, + ) : super(pagedSequence, messages, fetchPreference); + + /// Creates a new empty search result + MailSearchResult.empty(this.search) + : super.empty( + fetchPreference: search.fetchPreference, + pageSize: search.pageSize, + ); + + /// The original search + final MailSearch search; +} diff --git a/packages/enough_mail/lib/src/mail/tree.dart b/packages/enough_mail/lib/src/mail/tree.dart new file mode 100644 index 0000000..28a242c --- /dev/null +++ b/packages/enough_mail/lib/src/mail/tree.dart @@ -0,0 +1,166 @@ +/// Contains a tree like structure +class Tree { + /// Creates a new tree with the given root value + Tree(T rootValue) : root = TreeElement(rootValue, null); + + /// The root element + final TreeElement root; + + @override + String toString() => root.toString(); + + /// Lists all leafs of this tree + /// Specify how to detect the leafs with [isLeaf]. + List flatten(bool Function(T element) isLeaf) { + final leafs = []; + _addLeafs(root, isLeaf, leafs); + + return leafs; + } + + void _addLeafs( + TreeElement root, + bool Function(T element) isLeaf, + List leafs, + ) { + for (final child in root.children ?? []) { + if (isLeaf(child.value)) { + leafs.add(child.value); + } + if (child.children != null) { + _addLeafs(child, isLeaf, leafs); + } + } + } + + /// Populates this tree from the given list of [elements] + void populateFromList(List elements, T Function(T child) getParent) { + for (final element in elements) { + final parent = getParent(element); + if (parent == null) { + root.addChild(element); + } else { + _addChildToParent(element, parent, getParent); + } + } + } + + TreeElement _addChildToParent( + T child, + T parent, + T Function(T child) getParent, + ) { + var treeElement = locate(parent); + if (treeElement == null) { + final grandParent = getParent(parent); + treeElement = grandParent == null + ? root.addChild(parent) + : _addChildToParent(parent, grandParent, getParent); + } + + return treeElement.addChild(child); + } + + /// Finds the tree element for the given [value]. + TreeElement? locate(T value) => _locate(value, root); + + /// Locates a specific value in this tree + T? firstWhereOrNull(bool Function(T value) test) => + _firstWhereOrNullFor(test, root); + + T? _firstWhereOrNullFor(bool Function(T value) test, TreeElement element) { + if (test(element.value)) { + return element.value; + } + final children = element.children; + if (children != null) { + for (final child in children) { + final result = _firstWhereOrNullFor(test, child); + if (result != null) { + return result; + } + } + } + + return null; + } + + TreeElement? _locate(T value, TreeElement root) { + final children = root.children; + if (children == null) { + return null; + } + for (final child in children) { + if (child.value == value) { + return child; + } + if (child.hasChildren) { + final result = _locate(value, child); + if (result != null) { + return result; + } + } + } + + return null; + } +} + +/// An Element in a Tree +class TreeElement { + /// Creates a new tree element + TreeElement(this.value, this.parent); + + /// The value of the tree + final T value; + + /// Any sub nodes of this tree element + List>? children; + + /// Checks of this tree element has children + bool get hasChildren { + final children = this.children; + + return children != null && children.isNotEmpty; + } + + /// The parent of this element, if known + TreeElement? parent; + + /// Adds the [child] to this element + TreeElement addChild(T child) { + children ??= >[]; + final element = TreeElement(child, this); + children?.add(element); + + return element; + } + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } + + /// Renders this tree element into the given [buffer] + void render(StringBuffer buffer, [String padding = '']) { + buffer + ..write(padding) + ..write(value) + ..write('\n'); + if (children != null) { + buffer + ..write(padding) + ..write('[\n'); + final childPadding = '$padding '; + for (final child in children ?? []) { + child.render(buffer, childPadding); + } + buffer + ..write(padding) + ..write(']\n'); + } + } +} diff --git a/packages/enough_mail/lib/src/mail_address.dart b/packages/enough_mail/lib/src/mail_address.dart new file mode 100644 index 0000000..fe49cc2 --- /dev/null +++ b/packages/enough_mail/lib/src/mail_address.dart @@ -0,0 +1,214 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'codecs/mail_codec.dart'; +import 'private/util/mail_address_parser.dart'; + +part 'mail_address.g.dart'; + +/// An email address can consist of separate fields +@JsonSerializable() +class MailAddress { + /// Creates a new mail address + const MailAddress(this.personalName, this.email); + + /// Creates a new mail address + factory MailAddress.fromEnvelope({ + required String mailboxName, + required String hostName, + String? personalName, + }) { + if (mailboxName.isEmpty) { + return MailAddress(personalName, hostName); + } + if (hostName.isEmpty) { + return MailAddress(personalName, mailboxName); + } + + return MailAddress(personalName, '$mailboxName@$hostName'); + } + + /// Creates a new mail address by parsing the [input]. + /// + /// Compare [encode] + factory MailAddress.parse(String input) { + final parsed = MailAddressParser.parseEmailAddresses(input); + if (parsed.isEmpty) { + throw FormatException('for invalid email [$input]'); + } + + return parsed.first; + } + + /// Creates a new [MailAddress] form the given [json] + factory MailAddress.fromJson(Map json) => + _$MailAddressFromJson(json); + + /// Converts this [MailAddress] to JSON + Map toJson() => _$MailAddressToJson(this); + + /// personal name + final String? personalName; + + /// mailbox name + String get mailboxName { + final atIndex = email.lastIndexOf('@'); + if (atIndex != -1) { + return email.substring(0, atIndex); + } + + return email; + } + + /// host name + String get hostName { + final atIndex = email.lastIndexOf('@'); + if (atIndex != -1) { + return email.substring(atIndex + 1); + } + + return email; + } + + /// email address + final String email; + + /// Checks if this address has a personal name + bool get hasPersonalName => personalName?.trim().isNotEmpty ?? false; + + /// Checks if this address has not a personal name + bool get hasNoPersonalName => !hasPersonalName; + + @override + String toString() { + if (personalName == null) { + return email; + } + + final buffer = StringBuffer(); + writeToStringBuffer(buffer); + + return buffer.toString(); + } + + /// Encodes this mail address + /// + /// Compare [MailAddress.parse] to decode an address + String encode() { + final personalName = this.personalName; + if (personalName == null || hasNoPersonalName) { + return email; + } + final buffer = StringBuffer() + ..write('"') + ..write( + MailCodec.quotedPrintable.encodeHeader( + personalName.trim(), + fromStart: true, + ), + ) + ..write('" <') + ..write(email) + ..write('>'); + + return buffer.toString(); + } + + /// Encodes this mail address into the given [buffer] + void writeToStringBuffer(StringBuffer buffer) { + if (hasPersonalName) { + buffer + ..write('"') + ..write(personalName) + ..write('" '); + } + buffer + ..write('<') + ..write(email) + ..write('>'); + } + + /// Searches the [searchForList] addresses in the [searchInList] list. + /// + /// Set [handlePlusAliases] to `true` in case plus aliases should be checked. + /// Set [removeMatch] to `true` in case the matching address should be + /// removed from the [searchInList] list. + /// Set [useMatchPersonalName] to `true` to return the personal name from the + /// [searchInList] in the returned match. By default the personal name is + /// retrieved from the matching entry in [searchForList]. + static MailAddress? getMatch( + List searchForList, + List? searchInList, { + bool handlePlusAliases = false, + bool removeMatch = false, + bool useMatchPersonalName = false, + }) { + for (final searchFor in searchForList) { + final searchForEmailAddress = searchFor.email.toLowerCase(); + if (searchInList != null && searchInList.isNotEmpty) { + MailAddress match; + for (var i = 0; i < searchInList.length; i++) { + final potentialMatch = searchInList[i]; + final matchAddress = getMatchingEmail( + searchForEmailAddress, + potentialMatch.email.toLowerCase(), + allowPlusAlias: handlePlusAliases, + ); + if (matchAddress != null) { + match = useMatchPersonalName + ? potentialMatch + : MailAddress(searchFor.personalName, matchAddress); + if (removeMatch) { + searchInList.removeAt(i); + } + + return match; + } + } + } + } + + return null; + } + + /// Checks if both email addresses [original] and [check] match and + /// returns the match. + /// + /// Set [allowPlusAlias] if plus aliases should be checked, so that + /// `name+alias@domain` matches the original `name@domain`. + static String? getMatchingEmail( + String original, + String check, { + bool allowPlusAlias = false, + }) { + if (check == original) { + return check; + } else if (allowPlusAlias) { + final plusIndex = check.indexOf('+'); + if (plusIndex > 1) { + final start = check.substring(0, plusIndex); + if (original.startsWith(start)) { + final atIndex = check.lastIndexOf('@'); + if (atIndex > plusIndex && + original.endsWith(check.substring(atIndex))) { + return check; + } + } + } + } + + return null; + } + + /// Copies this mail address with the given values + MailAddress copyWith({String? personalName, String? email}) => + MailAddress(personalName ?? this.personalName, email ?? this.email); + + @override + int get hashCode => email.hashCode + (personalName?.hashCode ?? 0); + + @override + bool operator ==(Object other) => + other is MailAddress && + other.email == email && + other.personalName == personalName; +} diff --git a/packages/enough_mail/lib/src/mail_conventions.dart b/packages/enough_mail/lib/src/mail_conventions.dart new file mode 100644 index 0000000..f2a7537 --- /dev/null +++ b/packages/enough_mail/lib/src/mail_conventions.dart @@ -0,0 +1,161 @@ +/// Contains various mail specific conventions +class MailConventions { + /// The maximum length of a text email should not be longer + /// than 76 characters. + static const int textLineMaxLength = 76; + + /// The maximum length of an encoded word in header space should not be longer + /// than 75 characters. + /// + /// That includes the charset, encoding, delimiters and actual data, + /// compare https://tools.ietf.org/html/rfc2047#section-2 + static const int encodedWordMaxLength = 75; + + /// The maximum length of a line in an Internet Message Format, + /// compare https://tools.ietf.org/html/rfc5322#section-2.1.1 + static const int messageLineMaxLength = 998; + + /// Default English reply abbreviation `'Re'` + static const String defaultReplyAbbreviation = 'Re'; + + /// Default English reply header template `'On wrote:'` + static const String defaultReplyHeaderTemplate = 'On wrote:'; + + /// Default English forward abbreviation `'Fwd'` + static const String defaultForwardAbbreviation = 'Fwd'; + + /// Default English forward header template + static const String defaultForwardHeaderTemplate = + '---------- Original Message ----------\r\n' + 'From: \r\n' + '[[to To: \r\n]]' + '[[cc CC: \r\n]]' + 'Date: \r\n' + '[[subject Subject: \r\n]]'; + + /// Standard template for message disposition notification messages + /// aka read receipts. + /// + /// When you want to use your own template you can use the fields + /// ``, ``, `` and ``. + static const String defaultReadReceiptTemplate = + '''The message sent on to with ' + 'subject "" has been displayed.\r +This is no guarantee that the message has been read or understood.'''; + + // cSpell:disable + + /// Common abbreviations in subject header for replied messages, + /// compare https://en.wikipedia.org/wiki/List_of_email_subject_abbreviations + static const List subjectReplyAbbreviations = [ + 'Re', // English + 'RE', // English, Spanish, fr-CA + 'رد', // Arabic + '回复', // Simplified Chinese + '回覆', // Traditional Chinese + 'SV', // Danish + Icelandic + Norwegian + Swedish + 'Antw', // Dutch + 'VS', // Finish + 'REF', // French (also RE) + 'AW', // German + 'ΑΠ', // Greek + 'ΣΧΕΤ', // Greek + 'השב', // Hebrew + 'Vá', // Hungarian + 'R', // Italian + 'RIF', // Italian + 'BLS', // Indonesian + 'RES', // Portuguese + 'Odp', // Polnish + 'YNT', // Turkish + 'ATB', // Welsh + ]; + + /// Common abbreviations in subject header for forwarded messages, + /// compare https://en.wikipedia.org/wiki/List_of_email_subject_abbreviations + static const List subjectForwardAbbreviations = [ + 'Fwd', + 'FWD', + 'Fw', + 'FW', + 'إعادة توجيه', // Arabic + '转发', // Simplified Chinese + '轉寄', // Traditional Chinese + 'VS', // Danish + Norwegian + Swedish + 'Doorst', // Dutch + 'VL', // Finish + 'TR', // French + 'WG', // German + 'ΠΡΘ', // Greek + 'הועבר', // Hebrew + 'Továbbítás', // Hungarian + 'I', // Italian + 'FS', // Icelandic + 'TRS', // Indonesian + 'VB', // Swedish + 'RV', // Spanish + 'ENC', // Portuguese + 'PD', // Polnish + 'İLT', // Turkish + 'YML', // Welsh + ]; + // cSpell:enable + + /// The `To` recipients header + static const String headerTo = 'To'; + + /// The `CC` CarbonCopy recipients header + static const String headerCc = 'Cc'; + + /// The `BCC` BlindCarbonCopy recipients header + static const String headerBcc = 'Bcc'; + + /// The `Date` header + static const String headerDate = 'Date'; + + /// The `Subject` header + static const String headerSubject = 'Subject'; + + /// The `Message-Id` header + static const String headerMessageId = 'Message-Id'; + + /// The `References` header + static const String headerReferences = 'References'; + + /// The `In-Reply-To` header + static const String headerInReplyTo = 'In-Reply-To'; + + /// The `From` header + static const String headerFrom = 'From'; + + /// The `Sender` header + static const String headerSender = 'Sender'; + + /// The `Content-Type` header + static const String headerContentType = 'Content-Type'; + + /// The `Content-Transfer-Encoding` header + static const String headerContentTransferEncoding = + 'Content-Transfer-Encoding'; + + /// The `Content-Disposition` header + static const String headerContentDisposition = 'Content-Disposition'; + + /// The `Content-Description` header + static const String headerContentDescription = 'Content-Description'; + + /// The `MIME-Version` header + static const String headerMimeVersion = 'MIME-Version'; + + /// The `Disposition-Notification-To` header + static const String headerDispositionNotificationTo = + 'Disposition-Notification-To'; + + /// The `Disposition-Notification-Options` header + static const String headerDispositionNotificationOptions = + 'Disposition-Notification-Options'; + + /// The `Return-Path` header + static const String headerReturnPath = 'Return-Path'; +//static const String header = ''; +} diff --git a/packages/enough_mail/lib/src/media_type.dart b/packages/enough_mail/lib/src/media_type.dart new file mode 100644 index 0000000..62158aa --- /dev/null +++ b/packages/enough_mail/lib/src/media_type.dart @@ -0,0 +1,547 @@ +/// Top level media types +enum MediaToptype { + /// text media + text, + + /// image media, can be animated + image, + + /// audio media + audio, + + /// video media + video, + + /// application specific media, eg JSON + application, + + /// media consisting of several other media parts + multipart, + + /// media that contains a message + message, + + /// media containing a 3D model + model, + + /// media containing a text font + font, + + /// unrecognized media + other, +} + +// cSpell:disable +/// Detailed media types +/// Compare https://www.iana.org/assignments/media-types/media-types.xhtml +enum MediaSubtype { + /// `text/plain` just plain/normal text + textPlain, + + /// `text/html` text in HTML format + textHtml, + + /// `text/calendar` or `x-vcalendar` https://www.iana.org/go/rfc5545 + /// + /// as an attachment you can also use [MediaSubtype.applicationIcs] + textCalendar, + + /// `text/vcard` https://www.iana.org/go/rfc6350 + textVcard, + + /// `text/markdown` https://www.iana.org/go/rfc7763 + textMarkdown, + + /// `text/rfc822-headers` Headers of an email message + textRfc822Headers, + + /// `audio/basic` basic audio + audioBasic, + + /// `audio/mpeg` mpeg audio + audioMpeg, + + /// `audio/mp3` mp3 audio + audioMp3, + + /// `audio/mp4` mp4 audio + audioMp4, + + /// `audio/ogg` ogg audio + audioOgg, + + /// `audio/wav` wav audio + audioWav, + + /// `audio/midi` midi audio + audioMidi, + + /// `audio/mod` mod audio + audioMod, + + /// `audio/aiff` aiff audio + audioAiff, + + /// `audio/webm` webm audio + audioWebm, + + /// `audio/aac` aac audio + audioAac, + + /// `image/jpeg` jpeg/jpg image + imageJpeg, + + /// `image/png` png image + imagePng, + + /// `image/gif` gif image + imageGif, + + /// `image/webp` webp image + imageWebp, + + /// `image/bmp` bmp image + imageBmp, + + /// `image/svg+xml` svg image in xml format + imageSvgXml, + + /// `video/mpeg` mpeg video + videoMpeg, + + /// `video/mp4` mp4 video + videoMp4, + + /// `video/webm` webm video + videoWebm, + + /// `video/h264` h264 video + videoH264, + + /// `video/ogg` ogg video + videoOgg, + + /// `application/json` json data + applicationJson, + + /// `application/zip` compressed file + applicationZip, + + /// `application/xml` xml data + applicationXml, + + /// `application/octet-stream` binary data + applicationOctetStream, + + /// `application/calendar+json` calendar data https://www.iana.org/go/rfc7265 + applicationCalendarJson, + + /// `application/calendar+xml` calendar data https://www.iana.org/go/rfc6321 + applicationCalendarXml, + + /// `application/vcard+json` contact data + applicationVcardJson, + + /// `application/vcard+xml` contact data + applicationVcardXml, + + /// `application/pdf` https://www.iana.org/go/rfc8118 + applicationPdf, + + /// `application/ics` iCalendar attachment + /// + /// Within an alternative multipart you need to use + /// [MediaSubtype.textCalendar] instead + applicationIcs, + + /// `application/vnd.openxmlformats-officedocument.wordprocessingml.document` + applicationOfficeDocumentWordProcessingDocument, + + /// `application/vnd.openxmlformats-officedocument.wordprocessingml.template` + applicationOfficeDocumentWordProcessingTemplate, + + /// `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` + applicationOfficeDocumentSpreadsheetSheet, + + /// `application/vnd.openxmlformats-officedocument.spreadsheetml.template` + applicationOfficeDocumentSpreadsheetTemplate, + + /// `application/vnd.openxmlformats-officedocument.presentationml.presentation` + applicationOfficeDocumentPresentationPresentation, + + /// `application/vnd.openxmlformats-officedocument.presentationml.template` + applicationOfficeDocumentPresentationTemplate, + + /// `application/pgp-signature` part that contains the signature + /// + /// https://tools.ietf.org/html/rfc3156 + applicationPgpSignature, + + /// `application/pgp-encrypted` encrypted message part + /// + /// https://tools.ietf.org/html/rfc3156 + applicationPgpEncrypted, + + /// `applicationPgpKeys` part that contains PGP keys + /// + /// compare https://tools.ietf.org/html/rfc3156 + applicationPgpKeys, + + /// `model/mesh` 3D model + modelMesh, + + /// `model/vrml` 3D model + modelVrml, + + /// `model/x3d+xml` 3D model + modelX3dXml, + + /// `model/x3d+vrml` or `model/x3d-vrml` 3D model + modelX3dVrml, + + /// `model/x3d+binary` or `model/x3d+fastinfoset` 3D model + modelX3dBinary, + + /// `model/vnd.collada+xml` 3D model + modelVndColladaXml, + + /// `message/rfc822` embedded message, + /// + /// https://tools.ietf.org/html/rfc2045 https://tools.ietf.org/html/rfc2046 + messageRfc822, + + /// `message/partial` partial message, + /// + /// https://tools.ietf.org/html/rfc2045 https://tools.ietf.org/html/rfc2046 + messagePartial, + + /// delivery status of a message, + /// + /// https://tools.ietf.org/html/rfc1894 + messageDeliveryStatus, + + /// read receipt, + /// + /// https://tools.ietf.org/html/rfc8098 + messageDispositionNotification, + + /// `multipart/alternative` show on of the embedded parts + multipartAlternative, + + /// `multipart/mixed` show all embedded parts in the given sequence + multipartMixed, + + /// `multipart/parallel` show all embedded parts at once + multipartParallel, + + /// `multipart/partial` contains a single part of a bigger complete part. + multipartPartial, + + /// `multipart/related` contains parts that belong logically together + multipartRelated, + + /// `multipart/digest` contains several rcf822 messages + multipartDigest, + + /// `multipart/signed` signed message + /// + /// https://tools.ietf.org/html/rfc1847 + multipartSigned, + + /// `multipart/encrypted` encrypted message + /// + /// https://tools.ietf.org/html/rfc1847 + multipartEncrypted, + + /// `multipart/report` Report + /// + /// https://tools.ietf.org/html/rfc6522 + multipartReport, + + /// `font/otf` otf font + fontOtf, + + /// `font/ttf` ttf font + fontTtf, + + /// `font/woff` woff font + fontWoff, + + /// `font/woff2` woff2 font + fontWoff2, + + /// `font/collection` collection of several fonts + fontCollection, + + /// other media sub type + other +} +// cSpell:enable + +/// Extension on [MediaSubtype] +extension MediaSubtypeExtension on MediaSubtype { + /// Retrieves a new media type based on this subtype + MediaType get mediaType => MediaType.fromSubtype(this); +} + +/// Describes the media type of a MIME message part +/// +/// Compare https://www.iana.org/assignments/media-types/media-types.xhtml for a list of common media types. +class MediaType { + /// Creates a new media type + const MediaType(this.text, this.top, this.sub); + + /// Creates a media type from the specified text + /// + /// The [text] must use the top/sub structure, e.g. 'text/plain' + factory MediaType.fromText(String text) { + final lcText = text.toLowerCase(); + final splitPos = lcText.indexOf('/'); + if (splitPos != -1) { + final topText = lcText.substring(0, splitPos); + final top = _topLevelByMimeName[topText] ?? MediaToptype.other; + final sub = _subtypesByMimeType[lcText] ?? MediaSubtype.other; + + return MediaType(lcText, top, sub); + } else { + final top = _topLevelByMimeName[lcText] ?? MediaToptype.other; + + return MediaType(lcText, top, MediaSubtype.other); + } + } + + /// Creates a media type from the specified [subtype]. + factory MediaType.fromSubtype(MediaSubtype subtype) { + for (final key in _subtypesByMimeType.keys) { + final sub = _subtypesByMimeType[key]; + if (sub == subtype) { + final splitPos = key.indexOf('/'); + if (splitPos != -1) { + final topText = key.substring(0, splitPos); + final top = _topLevelByMimeName[topText] ?? MediaToptype.other; + + return MediaType(key, top, subtype); + } + break; + } + } + print('Error: unable to resolve media subtype $subtype'); + + return MediaType('example/example', MediaToptype.other, subtype); + } + + /// Tries to guess the media type from [fileNameOrPath]. + /// + /// If it encounters an unknown extension, the `application/octet-stream` + /// media type is returned. + /// Alternatively use [MediaType.guessFromFileExtension] + /// for the same results. + factory MediaType.guessFromFileName(String fileNameOrPath) { + final lastDotIndex = fileNameOrPath.lastIndexOf('.'); + if (lastDotIndex != -1 && lastDotIndex < fileNameOrPath.length - 1) { + final ext = fileNameOrPath.substring(lastDotIndex + 1).toLowerCase(); + + return MediaType.guessFromFileExtension(ext); + } + + return MediaSubtype.applicationOctetStream.mediaType; + } + + // cSpell:disable + /// Tries to guess the media type from the specified file extension [ext]. + /// + /// If it encounters an unknown extension, the `application/octet-stream` + /// media type is returned. + /// Alternatively use [MediaType.guessFromFileName] for the same results. + factory MediaType.guessFromFileExtension(final String ext) { + switch (ext.toLowerCase()) { + case 'txt': + return MediaType.textPlain; + case 'html': + return MediaSubtype.textHtml.mediaType; + case 'vcf': + return MediaSubtype.textVcard.mediaType; + case 'jpg': + case 'jpeg': + return MediaSubtype.imageJpeg.mediaType; + case 'png': + return MediaSubtype.imagePng.mediaType; + case 'webp': + return MediaSubtype.imageWebp.mediaType; + case 'pdf': + return MediaSubtype.applicationPdf.mediaType; + case 'doc': + case 'docx': + return MediaSubtype + .applicationOfficeDocumentWordProcessingDocument.mediaType; + case 'ppt': + case 'pptx': + return MediaSubtype + .applicationOfficeDocumentPresentationPresentation.mediaType; + case 'xls': + case 'xlsx': + return MediaSubtype.applicationOfficeDocumentSpreadsheetSheet.mediaType; + case 'mp3': + return MediaSubtype.audioMp3.mediaType; + case 'mp4': + return MediaSubtype.videoMp4.mediaType; + case 'zip': + return MediaSubtype.applicationZip.mediaType; + } + + return MediaSubtype.applicationOctetStream.mediaType; + } + // cSpell:enable + + /// `text/plain` media type + static const MediaType textPlain = + MediaType('text/plain', MediaToptype.text, MediaSubtype.textPlain); + + static const Map _topLevelByMimeName = + { + 'application': MediaToptype.application, + 'audio': MediaToptype.audio, + 'image': MediaToptype.image, + 'font': MediaToptype.font, + 'message': MediaToptype.message, + 'model': MediaToptype.model, + 'multipart': MediaToptype.multipart, + 'text': MediaToptype.text, + 'video': MediaToptype.video, + }; + + // cSpell:disable + static const Map _subtypesByMimeType = + { + 'text/plain': MediaSubtype.textPlain, + 'text/html': MediaSubtype.textHtml, + 'text/calendar': MediaSubtype.textCalendar, + 'text/x-vcalendar': MediaSubtype.textCalendar, + 'text/vcard': MediaSubtype.textVcard, + 'text/markdown': MediaSubtype.textMarkdown, + 'text/rfc822-headers': MediaSubtype.textRfc822Headers, + 'image/jpeg': MediaSubtype.imageJpeg, + 'image/jpg': MediaSubtype.imageJpeg, + 'image/png': MediaSubtype.imagePng, + 'image/bmp': MediaSubtype.imageBmp, + 'image/gif': MediaSubtype.imageGif, + 'image/webp': MediaSubtype.imageWebp, + 'image/svg+xml': MediaSubtype.imageSvgXml, + 'audio/basic': MediaSubtype.audioBasic, + 'audio/webm': MediaSubtype.audioWebm, + 'audio/aac': MediaSubtype.audioAac, + 'audio/aiff': MediaSubtype.audioAiff, + 'audio/mp4': MediaSubtype.audioMp4, + 'audio/mp3': MediaSubtype.audioMp3, + 'audio/midi': MediaSubtype.audioMidi, + 'audio/mod': MediaSubtype.audioMod, + 'audio/x-mod': MediaSubtype.audioMod, + 'audio/mpeg': MediaSubtype.audioMpeg, + 'audio/ogg': MediaSubtype.audioOgg, + 'audio/wav': MediaSubtype.audioWav, + 'audio/x-wav': MediaSubtype.audioWav, + 'video/ogg': MediaSubtype.videoOgg, + 'application/ogg': MediaSubtype.videoOgg, + 'video/h264': MediaSubtype.videoH264, + 'video/mp4': MediaSubtype.videoMp4, + 'application/mp4': MediaSubtype.videoMp4, + 'video/mpeg': MediaSubtype.videoMpeg, + 'video/webm': MediaSubtype.videoWebm, + 'model/mesh': MediaSubtype.modelMesh, + 'model/vnd.collada+xml': MediaSubtype.modelVndColladaXml, + 'model/vrml': MediaSubtype.modelVrml, + 'model/x3d+xml': MediaSubtype.modelX3dXml, + 'model/x3d+vrml': MediaSubtype.modelX3dVrml, + 'model/x3d-vrml': MediaSubtype.modelX3dVrml, + 'model/x3d+binary': MediaSubtype.modelX3dBinary, + 'model/x3d+fastinfoset': MediaSubtype.modelX3dBinary, + 'application/json': MediaSubtype.applicationJson, + 'application/octet-stream': MediaSubtype.applicationOctetStream, + 'application/xml': MediaSubtype.applicationXml, + 'application/zip': MediaSubtype.applicationZip, + 'application/x-zip': MediaSubtype.applicationZip, + 'application/vcard+json': MediaSubtype.applicationVcardJson, + 'application/vcard+xml': MediaSubtype.applicationVcardXml, + 'application/calendar+json': MediaSubtype.applicationCalendarJson, + 'application/calendar+xml': MediaSubtype.applicationCalendarXml, + 'application/pdf': MediaSubtype.applicationPdf, + 'application/ics': MediaSubtype.applicationIcs, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + MediaSubtype.applicationOfficeDocumentWordProcessingDocument, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': + MediaSubtype.applicationOfficeDocumentWordProcessingTemplate, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + MediaSubtype.applicationOfficeDocumentSpreadsheetSheet, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': + MediaSubtype.applicationOfficeDocumentSpreadsheetTemplate, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + MediaSubtype.applicationOfficeDocumentPresentationPresentation, + 'application/vnd.openxmlformats-officedocument.presentationml.template': + MediaSubtype.applicationOfficeDocumentPresentationTemplate, + 'application/pgp-signature': MediaSubtype.applicationPgpSignature, + 'application/pgp-encrypted': MediaSubtype.applicationPgpEncrypted, + 'application/pgp-keys': MediaSubtype.applicationPgpKeys, + 'message/delivery-status': MediaSubtype.messageDeliveryStatus, + 'message/disposition-notification': + MediaSubtype.messageDispositionNotification, + 'message/rfc822': MediaSubtype.messageRfc822, + 'message/partial': MediaSubtype.messagePartial, + 'multipart/alternative': MediaSubtype.multipartAlternative, + 'multipart/mixed': MediaSubtype.multipartMixed, + 'multipart/parallel': MediaSubtype.multipartParallel, + 'multipart/related': MediaSubtype.multipartRelated, + 'multipart/partial': MediaSubtype.multipartPartial, + 'multipart/digest': MediaSubtype.multipartDigest, + 'multipart/report': MediaSubtype.multipartReport, + 'multipart/signed': MediaSubtype.multipartSigned, + 'multipart/encrypted': MediaSubtype.multipartEncrypted, + 'font/otf': MediaSubtype.fontOtf, + 'font/ttf': MediaSubtype.fontTtf, + 'font/woff': MediaSubtype.fontWoff, + 'font/woff2': MediaSubtype.fontWoff2, + 'font/collection': MediaSubtype.fontCollection, + }; + // cSpell:enable + + /// The original text of the media type, e.g. 'text/plain' or 'image/png'. + final String text; + + /// The top level media type + /// + /// E.g. `text`, `image`, `video`, `audio`, `application`, `model`, + /// `multipart` or other + final MediaToptype top; + + /// The sub-type of the media, e.g. `text/plain` + final MediaSubtype sub; + + /// Convenience getter to check of the [top] MediaTopType is text + bool get isText => top == MediaToptype.text; + + /// Convenience getter to check of the [top] MediaTopType is image + bool get isImage => top == MediaToptype.image; + + /// Convenience getter to check of the [top] MediaTopType is video + bool get isVideo => top == MediaToptype.video; + + /// Convenience getter to check of the [top] MediaTopType is audio + bool get isAudio => top == MediaToptype.audio; + + /// Convenience getter to check of the [top] MediaTopType is application + bool get isApplication => top == MediaToptype.application; + + /// Convenience getter to check of the [top] MediaTopType is multipart + bool get isMultipart => top == MediaToptype.multipart; + + /// Convenience getter to check of the [top] MediaTopType is model + bool get isModel => top == MediaToptype.model; + + /// Convenience getter to check of the [top] MediaTopType is message + bool get isMessage => top == MediaToptype.message; + + /// Convenience getter to check of the [top] MediaTopType is font + bool get isFont => top == MediaToptype.font; + + @override + String toString() => text; +} diff --git a/packages/enough_mail/lib/src/message_builder.dart b/packages/enough_mail/lib/src/message_builder.dart new file mode 100644 index 0000000..a1beb0f --- /dev/null +++ b/packages/enough_mail/lib/src/message_builder.dart @@ -0,0 +1,1813 @@ +// ignore_for_file: avoid_returning_this + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:intl/intl.dart'; + +import 'codecs/date_codec.dart'; +import 'codecs/mail_codec.dart'; +import 'exception.dart'; +import 'mail_address.dart'; +import 'mail_conventions.dart'; +import 'media_type.dart'; +import 'mime_data.dart'; +import 'mime_message.dart'; +import 'private/util/ascii_runes.dart'; + +/// The `transfer-encoding` used for encoding 8bit data if necessary +enum TransferEncoding { + /// this mime message/part only consists of 7bit data, e.g. ASCII text + sevenBit, + + /// actually daring to transfer 8bit as it is, e.g. UTF8 + eightBit, + + /// Quoted-printable is somewhat human readable + quotedPrintable, + + /// base64 encoding is used to transfer binary data + base64, + + /// the automatic options tries to find the best solution + /// + /// ie `7bit` for ASCII texts, `quoted-printable` for 8bit texts + /// and `base64` for binaries. + automatic, +} + +/// The used character set +enum CharacterSet { + /// 7-bit ASCII text + ascii, + + /// UTF-8 text + utf8, + + /// latin-1 text + latin1, +} + +/// The recipient +enum RecipientGroup { + /// direct recipients + to, + + /// recipients on CC (carbon copy) + cc, + + /// recipients not visible for other recipients + bcc, +} + +/// Information about a file that is attached +class AttachmentInfo { + /// Creates a new attachment info + AttachmentInfo( + this.file, + this.mediaType, + this.name, + this.size, + this.contentDisposition, + this.data, + this.part, + ); + + /// The name of the attachment + final String? name; + + /// The size of the attachment in bytes + final int? size; + + /// The media type + final MediaType mediaType; + + /// The content disposition + final ContentDisposition? contentDisposition; + + /// The associated file + final File? file; + + /// The associated data + final Uint8List? data; + + /// The related builder + final PartBuilder part; +} + +/// Allows to configure a mime part +class PartBuilder { + /// Creates a new part builder + PartBuilder( + MimePart mimePart, { + String? text, + this.transferEncoding = TransferEncoding.automatic, + this.characterSet, + this.contentType, + }) : _part = mimePart { + this.text = text; + } + + String? _text; + + /// the text in this part builder + String? get text => _text; + set text(String? value) { + if (value == null) { + _text = value; + } else { + // replace single LF characters with CR LF in text: (sigh, SMTP) + final runes = value.runes.toList(growable: false); + List? copy; + var foundBareLineFeeds = 0; + var lastChar = 0; + for (var i = 0; i < runes.length; i++) { + final char = runes[i]; + if (char == AsciiRunes.runeLineFeed && + (i == 0 || lastChar != AsciiRunes.runeCarriageReturn)) { + // this is a single LF character + copy ??= [...runes]; + copy.insert(i + foundBareLineFeeds, AsciiRunes.runeCarriageReturn); + foundBareLineFeeds++; + } + lastChar = char; + } + _text = copy == null ? value : String.fromCharCodes(copy); + } + } + + /// The scheme used for encoding 8bit characters in the [text] + TransferEncoding transferEncoding; + + /// The char set like ASCII or UTF-8 used in the [text] + CharacterSet? characterSet; + + /// The media type represented by this part + ContentTypeHeader? contentType; + + final _attachments = []; + + /// The attachments in this builder + List get attachments => _attachments; + + /// Checks if there is at least 1 attachment + bool get hasAttachments => _attachments.isNotEmpty; + + final MimePart _part; + List? _children; + + /// The way that this part should be handled, e.g. inline or as attachment. + ContentDispositionHeader? contentDisposition; + + void _copy(MimePart originalPart) { + contentType = originalPart.getHeaderContentType(); + + if (originalPart.isTextMediaType()) { + text = originalPart.decodeContentText(); + } else if (originalPart.parts == null) { + _part.mimeData = originalPart.mimeData; + } + final parts = originalPart.parts; + if (parts != null) { + for (final part in parts) { + final childDisposition = part.getHeaderContentDisposition(); + final childBuilder = addPart( + disposition: childDisposition, + mediaSubtype: part.mediaType.sub, + ); + if (childDisposition?.disposition == ContentDisposition.attachment) { + final info = AttachmentInfo( + null, + part.mediaType, + part.decodeFileName(), + null, + ContentDisposition.attachment, + part.decodeContentBinary(), + this, + ); + _attachments.add(info); + } + childBuilder._copy(part); + } + } + } + + /// Creates the content-type based on the specified [mediaType]. + /// + /// Optionally you can specify the [characterSet], [multiPartBoundary], + /// [name] or other [parameters]. + void setContentType( + MediaType mediaType, { + CharacterSet? characterSet, + String? multiPartBoundary, + String? name, + Map? parameters, + }) { + if (mediaType.isMultipart && multiPartBoundary == null) { + // multiPartBoundary is null and this is a multipart -> + // define a default boundary: + // ignore: parameter_assignments + multiPartBoundary = MessageBuilder.createRandomId(); + } + final contentType = ContentTypeHeader.from( + mediaType, + charset: mediaType.top == MediaToptype.text + ? MessageBuilder.getCharacterSetName(characterSet) + : null, + boundary: multiPartBoundary, + ); + if (name != null) { + contentType.parameters['name'] = '"$name"'; + } + if (parameters != null && parameters.isNotEmpty) { + contentType.parameters.addAll(parameters); + } + this.contentType = contentType; + } + + /// Adds a text part to this message with the specified [text]. + /// + /// Specify the optional [mediaType], in case this is not a + /// `text/plain` message + /// and the [characterSet] in case it is not ASCII. + /// + /// Optionally specify the content disposition with [disposition]. + /// + /// Optionally set [insert] to true to prepend and not append the part. + /// + /// Optionally specify the [transferEncoding] which defaults to + /// [TransferEncoding.automatic]. + PartBuilder addText( + String text, { + MediaType? mediaType, + TransferEncoding transferEncoding = TransferEncoding.automatic, + CharacterSet characterSet = CharacterSet.utf8, + ContentDispositionHeader? disposition, + bool insert = false, + }) { + mediaType ??= MediaSubtype.textPlain.mediaType; + final child = addPart(insert: insert) + ..setContentType(mediaType, characterSet: characterSet) + ..transferEncoding = transferEncoding + ..contentDisposition = disposition + ..text = text; + if (disposition != null && + disposition.disposition == ContentDisposition.attachment) { + final info = AttachmentInfo( + null, + mediaType, + disposition.filename, + disposition.size, + disposition.disposition, + utf8.encode(text), + child, + ); + _attachments.add(info); + } + + return child; + } + + /// Adds a plain text part + /// + /// Compare `addText()` for details. + PartBuilder addTextPlain( + String text, { + TransferEncoding transferEncoding = TransferEncoding.automatic, + CharacterSet characterSet = CharacterSet.utf8, + ContentDispositionHeader? disposition, + bool insert = false, + }) => + addText( + text, + transferEncoding: transferEncoding, + characterSet: characterSet, + disposition: disposition, + insert: insert, + ); + + /// Adds a HTML text part + /// + /// Compare `addText()` for details. + PartBuilder addTextHtml( + String text, { + TransferEncoding transferEncoding = TransferEncoding.automatic, + CharacterSet characterSet = CharacterSet.utf8, + ContentDispositionHeader? disposition, + bool insert = false, + }) => + addText( + text, + mediaType: MediaSubtype.textHtml.mediaType, + transferEncoding: transferEncoding, + characterSet: characterSet, + disposition: disposition, + insert: insert, + ); + + /// Adds a new part + /// + /// Specify the optional [disposition] in case you want to specify + /// the content-disposition. + /// + /// Optionally specify the [mimePart], if it is already known. + /// + /// Optionally specify the [mediaSubtype], e.g. + /// `MediaSubtype.multipartAlternative`. + /// + /// Optionally set [insert] to `true` to prepend and not append the part. + /// + /// Compare [addText], [addFile] + PartBuilder addPart({ + ContentDispositionHeader? disposition, + MimePart? mimePart, + MediaSubtype? mediaSubtype, + bool insert = false, + }) { + final addAttachmentInfo = mimePart != null && + mimePart.getHeaderContentDisposition()?.disposition == + ContentDisposition.attachment; + mimePart ??= MimePart(); + final childBuilder = PartBuilder(mimePart); + if (mediaSubtype != null) { + childBuilder.setContentType(mediaSubtype.mediaType); + } else if (mimePart.getHeaderContentType() != null) { + childBuilder.contentType = mimePart.getHeaderContentType(); + } + final children = _children ?? []; + if (insert) { + _part.insertPart(mimePart); + children.insert(0, childBuilder); + } else { + _part.addPart(mimePart); + children.add(childBuilder); + } + _children = children; + final usedDisposition = + disposition ?? mimePart.getHeaderContentDisposition(); + childBuilder.contentDisposition = usedDisposition; + if (mimePart.isTextMediaType()) { + childBuilder.text = mimePart.decodeContentText(); + } + if (addAttachmentInfo && usedDisposition != null) { + final info = AttachmentInfo( + null, + mimePart.mediaType, + mimePart.decodeFileName(), + usedDisposition.size, + usedDisposition.disposition, + mimePart.decodeContentBinary(), + childBuilder, + ); + _attachments.add(info); + } + + return childBuilder; + } + + /// Retrieves the first builder with a text/plain part. + /// + /// Note that only this builder and direct children are queried. + PartBuilder? getTextPlainPart() => getPart(MediaSubtype.textPlain); + + /// Retrieves the first builder with a text/plain part. + /// + /// Note that only this builder and direct children are queried. + PartBuilder? getTextHtmlPart() => getPart(MediaSubtype.textHtml); + + /// Retrieves the first builder with the specified [mediaSubtype]. + /// + /// Unless [recursive] is set to `false`, the whole tree is searched + /// for the given [mediaSubtype]. + PartBuilder? getPart(MediaSubtype mediaSubtype, {bool recursive = true}) { + final isPlainText = mediaSubtype == MediaSubtype.textPlain; + if (_children?.isEmpty ?? true) { + if (contentType?.mediaType.sub == mediaSubtype || + (isPlainText && contentType == null)) { + return this; + } + + return null; + } + for (final child in _children ?? []) { + if (recursive) { + final matchingPart = child.getPart(mediaSubtype); + if (matchingPart != null) { + return matchingPart; + } + } else if ((child.contentType?.mediaType.sub == mediaSubtype) || + (isPlainText && child.contentType == null)) { + return child; + } + } + + return null; + } + + /// Removes the specified attachment [info] + void removeAttachment(AttachmentInfo info) { + _attachments.remove(info); + removePart(info.part); + } + + /// Removes the specified part [childBuilder] + void removePart(PartBuilder childBuilder) { + _part.parts?.remove(childBuilder._part); + _children?.remove(childBuilder); + } + + /// Adds the [file] part asynchronously. + /// + /// [file] The file that should be added. + /// + /// [mediaType] The media type of the file. + /// + /// Specify the optional content [disposition] element, + /// if it should not be populated automatically. + /// + /// This will add an `AttachmentInfo` element to the `attachments` + /// list of this builder. + Future addFile( + File file, + MediaType mediaType, { + ContentDispositionHeader? disposition, + }) async { + disposition ??= + ContentDispositionHeader.from(ContentDisposition.attachment); + disposition.filename ??= _getFileName(file); + disposition.size ??= await file.length(); + disposition.modificationDate ??= file.lastModifiedSync(); + final child = addPart(disposition: disposition); + final data = await file.readAsBytes(); + child.transferEncoding = TransferEncoding.base64; + final info = AttachmentInfo( + file, + mediaType, + disposition.filename, + disposition.size, + disposition.disposition, + data, + child, + ); + _attachments.add(info); + child.setContentType(mediaType, name: disposition.filename); + child._part.mimeData = + TextMimeData(MailCodec.base64.encodeData(data), containsHeader: false); + + return child; + } + + String _getFileName(File file) { + var name = file.path; + final lastPathSeparator = + math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (lastPathSeparator != -1 && lastPathSeparator != name.length - 1) { + name = name.substring(lastPathSeparator + 1); + } + + return name; + } + + /// Adds a binary [data] part with the given [mediaType]. + /// + /// Specify the optional content [disposition] header, + /// if it should not be populated automatically. + /// + /// Optionally specify the [filename] when the [disposition] header + /// is generated automatically. + PartBuilder addBinary( + Uint8List data, + MediaType mediaType, { + TransferEncoding transferEncoding = TransferEncoding.base64, + ContentDispositionHeader? disposition, + String? filename, + }) { + disposition ??= ContentDispositionHeader.from( + ContentDisposition.attachment, + filename: filename, + size: data.length, + ); + final child = addPart(disposition: disposition) + ..transferEncoding = TransferEncoding.base64 + ..setContentType(mediaType, name: filename); + final info = AttachmentInfo( + null, + mediaType, + filename, + data.length, + disposition.disposition, + data, + child, + ); + _attachments.add(info); + child._part.mimeData = TextMimeData( + MailCodec.base64.encodeData(data), + containsHeader: false, + ); + + return child; + } + + /// Adds the message [mimeMessage] as a `message/rfc822` content. + /// + /// Optionally specify the [disposition] which defaults to + /// [ContentDisposition.attachment]. + PartBuilder addMessagePart( + MimeMessage mimeMessage, { + ContentDisposition disposition = ContentDisposition.attachment, + }) { + // message data can be binary or textual + // even binary message data should not be base64 encoded, + // since it has itself encodings etc + final mediaType = MediaSubtype.messageRfc822.mediaType; + final subject = mimeMessage.decodeSubject()?.replaceAll('"', r'\"'); + final filename = '${subject ?? ''}.eml'; + final messageText = mimeMessage.renderMessage(); + final partBuilder = addPart( + mimePart: MimePart() + ..mimeData = TextMimeData(messageText, containsHeader: false), + mediaSubtype: MediaSubtype.messageRfc822, + disposition: + ContentDispositionHeader.from(disposition, filename: filename), + ); + if (disposition == ContentDisposition.attachment) { + _attachments.add( + AttachmentInfo( + null, + mediaType, + filename, + null, + disposition, + utf8.encode(messageText), + partBuilder, + ), + ); + } + + return partBuilder; + } + + /// Adds a part with the `multipart/alternative` subtype. + /// + /// Optionally specify the [plainText] and the [htmlText]. Note that + /// you need to specify either neither or both. + /// + /// Same as `addPart(mediaSubtype: MediaSubtype.multipartAlternative)` when + /// no texts are given. + PartBuilder addMultipartAlternative({String? plainText, String? htmlText}) { + final partBuilder = + addPart(mediaSubtype: MediaSubtype.multipartAlternative); + if (plainText != null && htmlText != null) { + partBuilder + ..addTextPlain(plainText) + ..addTextHtml(htmlText); + } + + return partBuilder; + } + + /// Adds a header with the specified [name] and [value]. + /// + /// Compare [MailConventions] for common header names. + /// + /// Set [encoding] to any of the [HeaderEncoding] formats + /// to encode the header. + void addHeader( + String name, + String value, { + HeaderEncoding encoding = HeaderEncoding.none, + }) { + _part.addHeader(name, value, encoding); + } + + /// Sets a header with the specified [name] and [value] + /// + /// This replaces any previous header with the same [name]. + /// + /// Compare [MailConventions] for common header names. + /// Set [encoding] to any of the [HeaderEncoding] formats to + /// encode the header. + void setHeader( + String name, + String? value, { + HeaderEncoding encoding = HeaderEncoding.none, + }) { + _part.setHeader(name, value, encoding); + } + + /// Removes the header with the specified [name]. + /// + /// Compare [MailConventions] for common header names. + void removeHeader(String name) { + _part.removeHeader(name); + } + + /// Adds another header with the specified [name] + /// + /// with the given mail [addresses] as its value + void addMailAddressHeader(String name, List addresses) { + addHeader(name, addresses.map((a) => a.encode()).join('; ')); + } + + /// Adds the header with the specified [name] + /// + /// with the given mail [addresses] as its value + void setMailAddressHeader(String name, List addresses) { + setHeader(name, addresses.map((a) => a.encode()).join(', ')); + } + + void _buildPart() { + final initialContentType = contentType; + final topMediaType = contentType?.mediaType.top; + final addContentTransferEncodingHeader = + topMediaType != MediaToptype.message && + topMediaType != MediaToptype.multipart; + var partTransferEncoding = transferEncoding; + if (addContentTransferEncodingHeader && + partTransferEncoding == TransferEncoding.automatic) { + final messageText = text; + partTransferEncoding = messageText != null && + (initialContentType == null || + initialContentType.mediaType.isText) + ? MessageBuilder._contains8BitCharacters(messageText) + ? TransferEncoding.quotedPrintable + : TransferEncoding.sevenBit + : TransferEncoding.base64; + transferEncoding = partTransferEncoding; + } + final children = _children; + if (initialContentType == null) { + if (_attachments.isNotEmpty) { + setContentType( + MediaSubtype.multipartMixed.mediaType, + multiPartBoundary: MessageBuilder.createRandomId(), + ); + } else if (children == null || children.isEmpty) { + setContentType(MediaSubtype.textPlain.mediaType); + } else { + setContentType( + MediaSubtype.multipartMixed.mediaType, + multiPartBoundary: MessageBuilder.createRandomId(), + ); + } + } + final usedContentType = contentType; + if (usedContentType != null) { + if (_attachments.isNotEmpty && usedContentType.boundary == null) { + usedContentType.boundary = MessageBuilder.createRandomId(); + } + setHeader(MailConventions.headerContentType, usedContentType.render()); + } + if (addContentTransferEncodingHeader) { + setHeader( + MailConventions.headerContentTransferEncoding, + MessageBuilder.getContentTransferEncodingName(partTransferEncoding), + ); + } + final contentDisposition = this.contentDisposition; + if (contentDisposition != null) { + setHeader( + MailConventions.headerContentDisposition, + contentDisposition.render(), + ); + } + // build body: + final bodyText = text; + if ((_part.mimeData == null) && + (bodyText != null) && + (_part.parts?.isEmpty ?? true)) { + _part.mimeData = TextMimeData( + MessageBuilder.encodeText( + bodyText, + transferEncoding, + characterSet ?? CharacterSet.utf8, + ), + containsHeader: false, + ); + if (contentType == null) { + setHeader( + MailConventions.headerContentType, + 'text/plain; charset="${MessageBuilder.getCharacterSetName(characterSet)}"', + ); + } + } + _children?.forEach((c) => c._buildPart()); + } +} + +/// Simplifies creating mime messages for sending or storing. +class MessageBuilder extends PartBuilder { + /// Creates a new message builder and populates it with the optional data. + /// + /// Set the plain text part with [text] encoded with [transferEncoding] + /// using the given [characterSet]. + /// + /// You can also set the complete [contentType]. + /// Finally you can set the [subjectEncoding], defaulting to quoted printable. + MessageBuilder({ + String? text, + TransferEncoding transferEncoding = TransferEncoding.automatic, + CharacterSet? characterSet, + ContentTypeHeader? contentType, + this.subjectEncoding = HeaderEncoding.Q, + }) : super( + MimeMessage(), + text: text, + transferEncoding: transferEncoding, + characterSet: characterSet, + contentType: contentType, + ) { + _message = _part as MimeMessage; + } + + /// Prepares to create a reply to the given [originalMessage] + /// to be send by the user specified in [from]. + /// + /// Set [replyAll] to false in case the reply should only be done to the + /// sender of the message and not to other recipients + /// + /// Set [quoteOriginalText] to true in case the original plain and html + /// texts should be added to the generated message. + /// + /// Set [preferPlainText] and [quoteOriginalText] to true in case only + /// plain text should be quoted. + /// + /// You can also specify a custom [replyHeaderTemplate], which is only used + /// when [quoteOriginalText] has been set to true. The default + /// replyHeaderTemplate is 'On wrote:'. + /// + /// Set [replyToSimplifyReferences] to true if the References field + /// should not contain the references of all messages in this thread. + /// + /// Specify the [defaultReplyAbbreviation] if not 'Re' should be used at the + /// beginning of the subject to indicate an reply. + /// + /// Specify the known [aliases] of the recipient, so that alias addresses are + /// not added as recipients and a detected alias is used instead of the + /// [from] address in that case. + /// + /// Set [handlePlusAliases] to true in case plus aliases like + /// `email+alias@domain.com` should be detected and used. + factory MessageBuilder.prepareReplyToMessage( + MimeMessage originalMessage, + MailAddress from, { + bool replyAll = true, + bool quoteOriginalText = false, + bool preferPlainText = false, + String replyHeaderTemplate = MailConventions.defaultReplyHeaderTemplate, + String defaultReplyAbbreviation = MailConventions.defaultReplyAbbreviation, + bool replyToSimplifyReferences = false, + List? aliases, + bool handlePlusAliases = false, + HeaderEncoding subjectEncoding = HeaderEncoding.Q, + }) { + String? subject; + final originalSubject = originalMessage.decodeSubject(); + if (originalSubject != null) { + subject = createReplySubject( + originalSubject, + defaultReplyAbbreviation: defaultReplyAbbreviation, + ); + } + var to = originalMessage.to ?? []; + var cc = originalMessage.cc; + final replyTo = originalMessage.decodeSender(); + List senders; + senders = + aliases != null && aliases.isNotEmpty ? [from, ...aliases] : [from]; + var newSender = MailAddress.getMatch( + senders, + replyTo, + handlePlusAliases: handlePlusAliases, + removeMatch: true, + useMatchPersonalName: true, + ); + newSender ??= MailAddress.getMatch( + senders, + to, + handlePlusAliases: handlePlusAliases, + removeMatch: true, + ); + newSender ??= MailAddress.getMatch( + senders, + cc, + handlePlusAliases: handlePlusAliases, + removeMatch: true, + ); + if (replyAll) { + to.insertAll(0, replyTo); + } else { + if (replyTo.isNotEmpty) { + to = [...replyTo]; + } + cc = null; + } + final builder = MessageBuilder() + ..subject = subject + ..subjectEncoding = subjectEncoding + ..originalMessage = originalMessage + ..from = [newSender ?? from] + ..to = to + ..cc = cc + ..replyToSimplifyReferences = replyToSimplifyReferences; + + if (quoteOriginalText) { + final replyHeader = fillTemplate(replyHeaderTemplate, originalMessage); + + final plainText = originalMessage.decodeTextPlainPart(); + final quotedPlainText = quotePlainText(replyHeader, plainText); + final decodedHtml = originalMessage.decodeTextHtmlPart(); + if (preferPlainText || decodedHtml == null) { + builder.text = quotedPlainText; + } else { + builder + ..setContentType(MediaSubtype.multipartAlternative.mediaType) + ..addTextPlain(quotedPlainText); + final quotedHtml = + '

$replyHeader
$decodedHtml
'; + builder.addTextHtml(quotedHtml); + } + } + + return builder; + } + + /// Convenience method for initiating a multipart/alternative message + /// + /// In case you want to use 7bit instead of the default 8bit content transfer + /// encoding, specify the optional [transferEncoding]. + /// + /// You can also create a new MessageBuilder and call + /// [setContentType] with the same effect when using the + /// `multipart/alternative` media subtype. + factory MessageBuilder.prepareMultipartAlternativeMessage({ + String? plainText, + String? htmlText, + TransferEncoding transferEncoding = TransferEncoding.eightBit, + }) { + final builder = MessageBuilder.prepareMessageWithMediaType( + MediaSubtype.multipartAlternative, + transferEncoding: transferEncoding, + ); + if (plainText != null && htmlText != null) { + builder + ..addTextPlain(plainText) + ..addTextHtml(htmlText); + } + + return builder; + } + + /// Convenience method for initiating a multipart/mixed message + /// + /// In case you want to use 7bit instead of the default 8bit content transfer + /// encoding, specify the optional [transferEncoding]. + /// + /// You can also create a new MessageBuilder and call [setContentType] + /// with the same effect when using the multipart/mixed media subtype. + factory MessageBuilder.prepareMultipartMixedMessage({ + TransferEncoding transferEncoding = TransferEncoding.eightBit, + }) => + MessageBuilder.prepareMessageWithMediaType( + MediaSubtype.multipartMixed, + transferEncoding: transferEncoding, + ); + + /// Convenience method to init a message with the specified media [subtype] + /// + /// In case you want to use 7bit instead of the default 8bit content transfer + /// encoding, specify the optional [transferEncoding]. + /// + /// You can also create a new MessageBuilder and call [setContentType] + /// with the same effect when using the identical media subtype. + factory MessageBuilder.prepareMessageWithMediaType( + MediaSubtype subtype, { + TransferEncoding transferEncoding = TransferEncoding.eightBit, + }) { + final mediaType = subtype.mediaType; + final builder = MessageBuilder() + ..setContentType(mediaType) + ..transferEncoding = transferEncoding; + + return builder; + } + + /// Convenience method for creating a message based on a + /// [mailto](https://tools.ietf.org/html/rfc6068) URI from + /// the sender specified in [from]. + /// + /// The following fields are supported: + /// ``` + /// * mailto `to` recipient address(es) + /// * `cc` - CC recipient address(es) + /// * `subject` - the subject header field + /// * `body` - the body header field + /// * `in-reply-to` - message ID to which the new message is a reply + /// ``` + factory MessageBuilder.prepareMailtoBasedMessage( + Uri mailto, + MailAddress from, + ) { + final builder = MessageBuilder() + ..from = [from] + ..setContentType(MediaType.textPlain, characterSet: CharacterSet.utf8) + ..transferEncoding = TransferEncoding.automatic; + final to = []; + for (final value in mailto.pathSegments) { + to.addAll(value.split(',').map((email) => MailAddress(null, email))); + } + final queryParameters = mailto.queryParameters; + for (final key in queryParameters.keys) { + final value = queryParameters[key]; + switch (key.toLowerCase()) { + case 'subject': + builder.subject = value; + // Defaults to QP-encoding + builder.subjectEncoding = HeaderEncoding.Q; + break; + case 'to': + if (value != null) { + to.addAll( + value.split(',').map((email) => MailAddress(null, email)), + ); + } + break; + case 'cc': + if (value != null) { + builder.cc = value + .split(',') + .map((email) => MailAddress(null, email)) + .toList(); + } + break; + case 'body': + builder.text = value; + break; + case 'in-reply-to': + builder.setHeader(key, value); + break; + default: + print('unsupported mailto parameter $key=$value'); + } + } + builder.to = to; + + return builder; + } + + /// Prepares a message builder from the specified [draft] mime message. + factory MessageBuilder.prepareFromDraft(MimeMessage draft) { + final builder = MessageBuilder() + ..originalMessage = draft + .._copy(draft); + + return builder; + } + + /// Prepares to forward the given [originalMessage]. + /// + /// Optionally specify the sending user with [from]. + /// + /// You can also specify a custom [forwardHeaderTemplate]. The default + /// `MailConventions.defaultForwardHeaderTemplate` contains the metadata + /// information about the original message including subject, to, cc, date. + /// + /// Specify the [defaultForwardAbbreviation] if not `Fwd` should be used at + /// the beginning of the subject to indicate an reply. + /// + /// Set [quoteMessage] to `false` when you plan to quote text yourself, + /// e.g. using the `enough_mail_html`'s package `quoteToHtml()` method. + /// + /// Set [forwardAttachments] to `false` when parts with a content-disposition + /// of attachment should not be forwarded. + factory MessageBuilder.prepareForwardMessage( + MimeMessage originalMessage, { + MailAddress? from, + String forwardHeaderTemplate = MailConventions.defaultForwardHeaderTemplate, + String defaultForwardAbbreviation = + MailConventions.defaultForwardAbbreviation, + bool quoteMessage = true, + HeaderEncoding subjectEncoding = HeaderEncoding.Q, + bool forwardAttachments = true, + }) { + String subject; + final originalSubject = originalMessage.decodeSubject(); + subject = originalSubject != null + ? createForwardSubject( + originalSubject, + defaultForwardAbbreviation: defaultForwardAbbreviation, + ) + : defaultForwardAbbreviation; + + final builder = MessageBuilder() + ..subject = subject + ..subjectEncoding = subjectEncoding + ..contentType = originalMessage.getHeaderContentType() + ..transferEncoding = _getTransferEncoding(originalMessage) + ..originalMessage = originalMessage; + if (from != null) { + builder.from = [from]; + } + if (quoteMessage) { + final forwardHeader = + fillTemplate(forwardHeaderTemplate, originalMessage); + final parts = originalMessage.parts; + if (parts != null && parts.isNotEmpty) { + var processedTextPlainPart = false; + var processedTextHtmlPart = false; + for (final part in parts) { + if (part.isTextMediaType()) { + if (!processedTextPlainPart && + part.mediaType.sub == MediaSubtype.textPlain) { + final plainText = part.decodeContentText(); + final quotedPlainText = quotePlainText(forwardHeader, plainText); + builder.addTextPlain(quotedPlainText); + processedTextPlainPart = true; + continue; + } + if (!processedTextHtmlPart && + part.mediaType.sub == MediaSubtype.textHtml) { + final decodedHtml = part.decodeContentText() ?? ''; + final quotedHtml = '
${forwardHeader.split( + '\r\n', + ).join( + '
\r\n', + )}
\r\n$decodedHtml
'; + builder.addTextHtml(quotedHtml); + processedTextHtmlPart = true; + continue; + } + } + if (forwardAttachments || + part.getHeaderContentDisposition()?.disposition != + ContentDisposition.attachment) { + builder.addPart(mimePart: part); + } + } + } else { + // no parts, this is most likely a plain text message: + if (originalMessage.isTextPlainMessage()) { + final plainText = originalMessage.decodeContentText(); + final quotedPlainText = quotePlainText(forwardHeader, plainText); + builder.text = quotedPlainText; + } else { + //TODO check if there is anything else to quote + } + } + } else if (forwardAttachments) { + // do not quote message but forward attachments + final infos = originalMessage.findContentInfo(); + for (final info in infos) { + final part = originalMessage.getPart(info.fetchId); + if (part != null) { + builder.addPart(mimePart: part); + } + } + } + + return builder; + } + + late MimeMessage _message; + + /// List of senders, typically this is only one sender + List? from; + + /// One sender in case there are different `from` senders + MailAddress? sender; + + /// `to` recipients + List? to; + + /// `cc` recipients + List? cc; + + /// `bcc` recipients + List? bcc; + + /// Message subject + String? subject; + + /// Header encoding type + HeaderEncoding subjectEncoding; + + /// Message date + DateTime? date; + + /// ID of the message + String? messageId; + + /// Reference to original message + MimeMessage? originalMessage; + + /// Set to `true` in case only the last replied to message should + /// be referenced. Useful for long threads. + bool replyToSimplifyReferences = false; + + /// Set to `true` to set chat headers + bool isChat = false; + + /// Specify in case this is a chat group discussion + String? chatGroupId; + + @override + void _copy(MimePart originalPart) { + final originalMessage = originalPart as MimeMessage; + characterSet = CharacterSet.utf8; + to = originalMessage.to; + cc = originalMessage.cc; + bcc = originalMessage.bcc; + subject = originalMessage.decodeSubject(); + super._copy(originalPart); + } + + /// Adds a [recipient]. + /// + /// Specify the [group] in case the recipient should not be added + /// to the 'To' group. + /// Compare [removeRecipient] and [clearRecipients]. + void addRecipient( + MailAddress recipient, { + RecipientGroup group = RecipientGroup.to, + }) { + switch (group) { + case RecipientGroup.to: + to ??= []; + to?.add(recipient); + break; + case RecipientGroup.cc: + cc ??= []; + cc?.add(recipient); + break; + case RecipientGroup.bcc: + bcc ??= []; + bcc?.add(recipient); + break; + } + } + + /// Removes the specified [recipient] from To/Cc/Bcc fields. + /// + /// Compare [addRecipient] and [clearRecipients]. + void removeRecipient(MailAddress recipient) { + if (to != null) { + to?.remove(recipient); + } + if (cc != null) { + cc?.remove(recipient); + } + if (bcc != null) { + bcc?.remove(recipient); + } + } + + /// Removes all recipients from this message. + /// + /// Compare [removeRecipient] and [addRecipient]. + void clearRecipients() { + to = null; + cc = null; + bcc = null; + } + + /// Sets the transfer encoding to the recommended one. + /// + /// Set [supports8BitMessages] to `true` in case 8-bit message transfer + /// is supported by the provider. + TransferEncoding setRecommendedTextEncoding({ + bool supports8BitMessages = false, + }) { + var recommendedEncoding = TransferEncoding.quotedPrintable; + final textHtml = getTextHtmlPart(); + final textPlain = getTextPlainPart(); + if (!supports8BitMessages) { + recommendedEncoding = _contains8BitCharacters(text) || + _contains8BitCharacters(textPlain?.text) || + _contains8BitCharacters(textHtml?.text) + ? TransferEncoding.quotedPrintable + : TransferEncoding.sevenBit; + } + transferEncoding = recommendedEncoding; + textHtml?.transferEncoding = recommendedEncoding; + textPlain?.transferEncoding = recommendedEncoding; + + return recommendedEncoding; + } + + static bool _contains8BitCharacters(String? text) { + if (text == null) { + return false; + } + + return text.runes.any((rune) => rune >= 127); + } + + /// Requests a read receipt + /// + /// This is done by setting the `Disposition-Notification-To` + /// header to from address. + /// + /// Optionally specify a [recipient] address when no message sender + /// is defined in the [from] field yet. + /// + /// Compare [removeReadReceiptRequest] + /// Compare [setHeader] + void requestReadReceipt({MailAddress? recipient}) { + final from = this.from; + recipient ??= (from != null && from.isNotEmpty) ? from.first : null; + if (recipient == null) { + throw InvalidArgumentException( + 'Either define a sender in from or specify the recipient parameter', + ); + } + setHeader(MailConventions.headerDispositionNotificationTo, recipient.email); + } + + /// Removes the read receipt request. + /// + /// Shortcut to + /// `removeHeader(MailConventions.headerDispositionNotificationTo)`. + /// Compare [requestReadReceipt] + /// Compare [removeHeader] + void removeReadReceiptRequest() { + removeHeader(MailConventions.headerDispositionNotificationTo); + } + + /// Creates the mime message based on the previous input. + MimeMessage buildMimeMessage() { + // there are not mandatory fields required in case only a Draft message + // should be stored, for example + + // set default values for standard headers: + final usedDate = date ?? DateTime.now(); + date ??= usedDate; + final from = this.from; + messageId ??= createMessageId( + (from == null || from.isEmpty) ? 'enough.de' : from.first.hostName, + isChat: isChat, + chatGroupId: chatGroupId, + ); + final originalMessage = this.originalMessage; + if (subject == null && originalMessage != null) { + final originalSubject = originalMessage.decodeSubject(); + if (originalSubject != null) { + subject = createReplySubject(originalSubject); + } + } + if (from != null) { + setMailAddressHeader('From', from); + } + final sender = this.sender; + if (sender != null) { + setMailAddressHeader('Sender', [sender]); + } + var addresses = to; + if (addresses != null && addresses.isNotEmpty) { + setMailAddressHeader('To', addresses); + } + addresses = cc; + if (addresses != null && addresses.isNotEmpty) { + setMailAddressHeader('Cc', addresses); + } + addresses = bcc; + if (addresses != null && addresses.isNotEmpty) { + setMailAddressHeader('Bcc', addresses); + } + setHeader('Date', DateCodec.encodeDate(usedDate)); + setHeader('Message-Id', messageId); + if (isChat) { + setHeader('Chat-Version', '1.0'); + } + if (subject != null) { + setHeader('Subject', subject, encoding: subjectEncoding); + } + setHeader(MailConventions.headerMimeVersion, '1.0'); + final original = originalMessage; + if (original != null) { + final originalMessageId = original.getHeaderValue('message-id'); + setHeader(MailConventions.headerInReplyTo, originalMessageId); + final originalReferences = original.getHeaderValue('references'); + final references = originalReferences == null + ? originalMessageId + : replyToSimplifyReferences + ? originalReferences + : '$originalReferences $originalMessageId'; + setHeader(MailConventions.headerReferences, references); + } + final text = this.text; + if (text != null && _attachments.isNotEmpty) { + addTextPlain(text, transferEncoding: transferEncoding, insert: true); + } + _buildPart(); + _message.parse(); + + return _message; + } + + /// Creates a text message. + /// + /// [from] the mandatory originator of the message + /// + /// [to] the mandatory list of recipients + /// + /// [text] the mandatory content of the message + /// + /// [cc] the optional "carbon copy" recipients that are informed + /// about this message + /// + /// [bcc] the optional "blind carbon copy" recipients that should receive + /// the message without others being able to see those recipients + /// + /// [subject] the optional subject of the message, if null and a + /// [replyToMessage] is specified, then the subject of that message is + /// being re-used. + /// + /// [subjectEncoding] the optional subject [HeaderEncoding] format + /// + /// [date] the optional date of the message, is set to DateTime.now() + /// by default + /// + /// [replyToMessage] is the message that this message is a reply to + /// + /// Set the optional [replyToSimplifyReferences] parameter to `true` in + /// case only the root message-ID should be repeated instead of all + /// references as calculated from the [replyToMessage] + /// + /// [messageId] the optional custom message ID + /// + /// Set the optional [isChat] to true in case a COI-compliant message ID + /// should be generated, in case of a group message also specify + /// the [chatGroupId]. + /// + /// [chatGroupId] the optional ID of the chat group in case the + /// message-ID should be generated. + /// + /// [characterSet] the optional character set, defaults to [CharacterSet.utf8] + /// + /// [transferEncoding] the optional message encoding, defaults to + /// [TransferEncoding.quotedPrintable] + static MimeMessage buildSimpleTextMessage( + MailAddress from, + List to, + String text, { + List? cc, + List? bcc, + String? subject, + HeaderEncoding subjectEncoding = HeaderEncoding.Q, + DateTime? date, + MimeMessage? replyToMessage, + bool replyToSimplifyReferences = false, + String? messageId, + CharacterSet characterSet = CharacterSet.utf8, + TransferEncoding transferEncoding = TransferEncoding.quotedPrintable, + }) { + final builder = MessageBuilder() + ..from = [from] + ..to = to + ..subject = subject + ..subjectEncoding = subjectEncoding + ..text = text + ..cc = cc + ..bcc = bcc + ..date = date + ..originalMessage = replyToMessage + ..replyToSimplifyReferences = replyToSimplifyReferences + ..messageId = messageId + ..characterSet = characterSet + ..transferEncoding = transferEncoding; + + return builder.buildMimeMessage(); + } + + static TransferEncoding _getTransferEncoding(MimeMessage originalMessage) { + final originalTransferEncoding = originalMessage + .getHeaderValue(MailConventions.headerContentTransferEncoding); + + return originalTransferEncoding == null + ? TransferEncoding.automatic + : fromContentTransferEncodingName(originalTransferEncoding); + } + + /// Builds a disposition notification report for the given [originalMessage] + /// + /// that has been received by the [finalRecipient]. + /// + /// Optionally specify the reporting user agent, ie your apps name with the + /// [reportingUa] parameter, e.g. `'My Mail App 1.0'`. + /// + /// Optionally specify that the report is generated automatically by setting + /// [isAutomaticReport] to `true` - this defaults to `false`. + /// + /// Optionally specify a [subject], this defaults to `'read receipt'`. + /// + /// Optionally specify your own [textTemplate] in which you can use the + /// fields ``, ``, `` and ``. + /// This defaults to [MailConventions.defaultReadReceiptTemplate]. + /// + /// Throws a [InvalidArgumentException] when the originalMessage has no valid + /// `Disposition-Notification-To` or `Return-Receipt-To` header. + /// + /// Use [requestReadReceipt] to request a read receipt when building a + /// message. + static MimeMessage buildReadReceipt( + MimeMessage originalMessage, + MailAddress finalRecipient, { + String reportingUa = 'enough_mail', + bool isAutomaticReport = false, + String subject = 'read receipt', + String textTemplate = MailConventions.defaultReadReceiptTemplate, + }) { + final builder = MessageBuilder(); + var recipient = originalMessage.decodeHeaderMailAddressValue( + MailConventions.headerDispositionNotificationTo, + ); + if (recipient == null || recipient.isEmpty) { + recipient = + originalMessage.decodeHeaderMailAddressValue('Return-Receipt-To'); + if (recipient == null || recipient.isEmpty) { + throw InvalidArgumentException( + 'Invalid header ${MailConventions.headerDispositionNotificationTo} ' + 'in message: ' + '${originalMessage.getHeaderValue( + MailConventions.headerDispositionNotificationTo, + )}', + ); + } + } + builder + ..subject = subject + ..to = recipient + ..setContentType(MediaSubtype.multipartReport.mediaType); + final parameters = { + 'recipient': finalRecipient.toString(), + 'sender': originalMessage.fromEmail ?? '', + }; + builder.setHeader(MailConventions.headerMimeVersion, '1.0'); + final plainText = + fillTemplate(textTemplate, originalMessage, parameters: parameters); + builder.addTextPlain(plainText); + final mdnPart = builder.addPart( + mediaSubtype: MediaSubtype.messageDispositionNotification, + ) + ..transferEncoding = TransferEncoding.sevenBit + ..contentDisposition = ContentDispositionHeader.inline(); + final buffer = StringBuffer() + ..write('Reporting-UA: ') + ..write(reportingUa) + ..write('\r\n'); + if (originalMessage.findRecipient(finalRecipient) != null) { + buffer + ..write('Original-Recipient: rfc822;') + ..write(finalRecipient.email) + ..write('\r\n'); + } + buffer + ..write('Final-Recipient: rfc822;') + ..write(finalRecipient.email) + ..write('\r\n') + ..write('Original-Message-ID: ') + ..write(originalMessage.getHeaderValue(MailConventions.headerMessageId)) + ..write('\r\n'); + if (isAutomaticReport) { + buffer.write( + 'Disposition: automatic-action/MDN-sent-automatically; displayed\r\n', + ); + } else { + buffer + .write('Disposition: manual-action/MDN-sent-manually; displayed\r\n'); + } + mdnPart.text = buffer.toString(); + builder.from = [finalRecipient]; + + return builder.buildMimeMessage(); + } + + /// Quotes the given plain text [header] and [text]. + static String quotePlainText(final String header, final String? text) { + if (text == null) { + return '>\r\n'; + } + + return '>${header.split( + '\r\n', + ).join( + '\r\n>', + )}\r\n>${text.split( + '\r\n', + ).join('\r\n>')}'; + } + + /// Generates a message ID + /// + /// [hostName] the domain like 'example.com' + /// + /// Set the optional [isChat] to true in case a COI-compliant message ID + /// should be generated, in case of a group message also specify the + /// [chatGroupId]. + /// + /// [chatGroupId] the optional ID of the chat group in case the message-ID + /// should be generated. + static String createMessageId( + String? hostName, { + bool isChat = false, + String? chatGroupId, + }) { + String id; + final random = createRandomId(); + id = isChat + ? chatGroupId != null && chatGroupId.isNotEmpty + ? '' + : '' + : '<$random@$hostName>'; + + return id; + } + + /// Encodes the specified [text] with given [transferEncoding]. + /// + /// Specify the [characterSet] when a different character set than `UTF-8` + /// should be used. + static String encodeText( + String text, + TransferEncoding transferEncoding, [ + CharacterSet characterSet = CharacterSet.utf8, + ]) { + switch (transferEncoding) { + case TransferEncoding.quotedPrintable: + return MailCodec.quotedPrintable + .encodeText(text, codec: getCodec(characterSet)); + case TransferEncoding.base64: + return MailCodec.base64.encodeText(text, codec: getCodec(characterSet)); + default: + return MailCodec.wrapText(text, wrapAtWordBoundary: true); + } + } + + /// Retrieves the codec for the specified [characterSet]. + static Codec getCodec(CharacterSet? characterSet) { + switch (characterSet) { + case null: + return utf8; + case CharacterSet.ascii: + return ascii; + case CharacterSet.utf8: + return utf8; + case CharacterSet.latin1: + return latin1; + } + } + + /// Encodes the specified header [value]. + /// + /// Specify the [transferEncoding] when not the default `quoted-printable` + /// transfer encoding should be used. + static String encodeHeaderValue( + String value, [ + TransferEncoding transferEncoding = TransferEncoding.quotedPrintable, + ]) { + switch (transferEncoding) { + case TransferEncoding.quotedPrintable: + return MailCodec.quotedPrintable.encodeHeader(value); + case TransferEncoding.base64: + return MailCodec.base64.encodeHeader(value); + default: + return value; + } + } + + /// Retrieves the name of the specified [characterSet]. + static String getCharacterSetName(CharacterSet? characterSet) { + switch (characterSet) { + case null: + return 'utf-8'; + case CharacterSet.utf8: + return 'utf-8'; + case CharacterSet.ascii: + return 'ascii'; + case CharacterSet.latin1: + return 'latin1'; + } + } + + /// Retrieves the name of the specified [encoding]. + /// + /// Throws an [InvalidArgumentException] when the encoding is not yet handled. + static String getContentTransferEncodingName(TransferEncoding encoding) { + switch (encoding) { + case TransferEncoding.sevenBit: + return '7bit'; + case TransferEncoding.eightBit: + return '8bit'; + case TransferEncoding.quotedPrintable: + return 'quoted-printable'; + case TransferEncoding.base64: + return 'base64'; + default: + throw InvalidArgumentException( + 'Unhandled transfer encoding: $encoding', + ); + } + } + + /// Detects the transfer encoding from the given [name]. + static TransferEncoding fromContentTransferEncodingName(String name) { + switch (name.toLowerCase()) { + case '7bit': + return TransferEncoding.sevenBit; + case '8bit': + return TransferEncoding.eightBit; + case 'quoted-printable': + return TransferEncoding.quotedPrintable; + case 'base64': + return TransferEncoding.base64; + } + + return TransferEncoding.automatic; + } + + /// Creates a subject based on the [originalSubject] + /// taking mail conventions into account. + /// + /// Optionally specify the reply-indicator abbreviation by specifying + /// [defaultReplyAbbreviation], which defaults to 'Re'. + static String createReplySubject( + String originalSubject, { + String defaultReplyAbbreviation = MailConventions.defaultReplyAbbreviation, + }) => + _createSubject( + originalSubject, + defaultReplyAbbreviation, + MailConventions.subjectReplyAbbreviations, + ); + + /// Creates a subject based on the [originalSubject] + /// taking mail conventions into account. + /// + /// Optionally specify the forward-indicator abbreviation by specifying + /// [defaultForwardAbbreviation], which defaults to 'Fwd'. + static String createForwardSubject( + String originalSubject, { + String defaultForwardAbbreviation = + MailConventions.defaultForwardAbbreviation, + }) => + _createSubject( + originalSubject, + defaultForwardAbbreviation, + MailConventions.subjectForwardAbbreviations, + ); + + /// Creates a subject based on the [originalSubject] + /// taking mail conventions into account. + /// + /// Optionally specify the reply-indicator abbreviation by specifying + /// [defaultAbbreviation], which defaults to 'Re'. + static String _createSubject( + String originalSubject, + String defaultAbbreviation, + List commonAbbreviations, + ) { + final colonIndex = originalSubject.indexOf(':'); + if (colonIndex != -1) { + var start = originalSubject.substring(0, colonIndex); + if (commonAbbreviations.contains(start)) { + // the original subject already contains a common reply abbreviation, + //e.g. 'Re: bla' + return originalSubject; + } + // some mail servers use rewrite rules to adapt the subject, + //e.g start each external messages with '[EXT]' + final prefixStartIndex = originalSubject.indexOf('['); + if (prefixStartIndex == 0) { + final prefixEndIndex = originalSubject.indexOf(']'); + if (prefixEndIndex < colonIndex) { + start = start.substring(prefixEndIndex + 1).trim(); + if (commonAbbreviations.contains(start)) { + // the original subject already contains a common reply + // abbreviation, e.g. 'Re: bla' + return originalSubject.substring(prefixEndIndex + 1).trim(); + } + } + } + } + + return '$defaultAbbreviation: $originalSubject'; + } + + /// Creates a new randomized ID text. + /// + /// Specify [length] when a different length than 18 characters + /// should be used. + /// + /// This can be used as a multipart boundary or a message-ID, for example. + static String createRandomId({int length = 18}) { + const characters = + '0123456789_abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + final characterRunes = characters.runes; + const max = characters.length; + final random = math.Random(); + final buffer = StringBuffer(); + for (var count = length; count > 0; count--) { + final charIndex = random.nextInt(max); + final rune = characterRunes.elementAt(charIndex); + buffer.writeCharCode(rune); + } + + return buffer.toString(); + } + + /// Fills the given [template] with values + /// extracted from the provided [message]. + /// + /// Optionally extends the template fields by defining them in the + /// [parameters] field. + /// + /// Currently the following templates are supported: + /// ``` + /// ``: specifies the message sender (name plus email) + /// ``: specifies the message date + /// ``: the `to` recipients + /// ``: the `cc` recipients + /// ``: the subject of the message + /// ``` + /// Note that for date formatting Dart's + /// [intl](https://pub.dev/packages/intl) library is used. + /// + /// You might want to specify the default locale by setting + /// [Intl.defaultLocale] first. + static String fillTemplate( + String template, + MimeMessage message, { + Map? parameters, + }) { + final definedVariables = []; + var result = template; + var from = message.decodeHeaderMailAddressValue('sender'); + if (from?.isEmpty ?? true) { + from = message.decodeHeaderMailAddressValue('from'); + } + if (from != null && from.isNotEmpty) { + definedVariables.add('from'); + result = result.replaceAll('', from.first.toString()); + } + final date = message.decodeHeaderDateValue('date'); + if (date != null) { + definedVariables.add('date'); + final dateStr = DateFormat.yMd().add_jm().format(date); + result = result.replaceAll('', dateStr); + } + final to = message.to; + if (to != null && to.isNotEmpty) { + definedVariables.add('to'); + result = result.replaceAll('', _renderAddresses(to)); + } + final cc = message.cc; + if (cc != null && cc.isNotEmpty) { + definedVariables.add('cc'); + result = result.replaceAll('', _renderAddresses(cc)); + } + final subject = message.decodeSubject(); + if (subject != null) { + definedVariables.add('subject'); + result = result.replaceAll('', subject); + } + if (parameters != null) { + for (final key in parameters.keys) { + definedVariables.add(key); + result = result.replaceAll('<$key>', parameters[key]!); + } + } + // remove any undefined variables from result: + final optionalInclusionsExpression = RegExp(r'\[\[\w+\s[\s\S]+?\]\]'); + RegExpMatch? match; + while ((match = optionalInclusionsExpression.firstMatch(result)) != null) { + final sequence = match?.group(0) ?? ''; + //print('sequence=$sequence'); + final separatorIndex = sequence.indexOf(' ', 2); + final name = sequence.substring(2, separatorIndex); + var replacement = ''; + if (definedVariables.contains(name)) { + replacement = + sequence.substring(separatorIndex + 1, sequence.length - 2); + } + result = result.replaceAll(sequence, replacement); + } + + return result; + } + + static String _renderAddresses(List addresses) { + final buffer = StringBuffer(); + var addDelimiter = false; + for (final address in addresses) { + if (addDelimiter) { + buffer.write('; '); + } + address.writeToStringBuffer(buffer); + addDelimiter = true; + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/message_flags.dart b/packages/enough_mail/lib/src/message_flags.dart new file mode 100644 index 0000000..1024bc4 --- /dev/null +++ b/packages/enough_mail/lib/src/message_flags.dart @@ -0,0 +1,35 @@ +/// Contains common message flags +class MessageFlags { + /// Do not allow instantiation + MessageFlags._(); + + /// The message has been read by the user + static const String seen = r'\Seen'; + + /// The message has been replied by the user + static const String answered = r'\Answered'; + + /// The message has been marked as important / favorite by the user + static const String flagged = r'\Flagged'; + + /// The message has been marked as deleted + static const String deleted = r'\Deleted'; + + /// The message is a draft and not yet complete. + static const String draft = r'\Draft'; + + /// The message has been forwarded + /// + /// - note this is a common but not standardized keyword. + static const String keywordForwarded = r'$Forwarded'; + + /// For this message a read notification has been sent + /// + /// - note this is a common but not standardized keyword. + static const String keywordMdnSent = r'$MDNSent'; + + /// Marks this message as being recent. + /// + /// This flag cannot be changed or set by clients. + static const String recent = r'\Recent'; +} diff --git a/packages/enough_mail/lib/src/mime_data.dart b/packages/enough_mail/lib/src/mime_data.dart new file mode 100644 index 0000000..dc5a3bd --- /dev/null +++ b/packages/enough_mail/lib/src/mime_data.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart' show IterableExtension; + +import 'codecs/mail_codec.dart'; +import 'mime_message.dart'; +import 'private/imap/parser_helper.dart'; +import 'private/util/ascii_runes.dart'; +import 'private/util/byte_utils.dart'; + +/// Abstracts textual or binary mime data +abstract class MimeData { + /// Creates a new mime data + /// + /// Specify if this data contains header information with [containsHeader]. + MimeData({required this.containsHeader}); + + /// Defines if this mime data includes header data + final bool containsHeader; + + /// All known headers of this mime data + List
? headersList; + + /// Returns `true` when there are children + bool get hasParts => parts?.isNotEmpty ?? false; + + /// The children of this mime data + List? parts; + + ContentTypeHeader? _contentType; + + /// The content type of this mime data + ContentTypeHeader? get contentType { + var value = _contentType; + if (value == null) { + final headerText = _getHeaderValue('content-type'); + if (headerText != null) { + value = ContentTypeHeader(headerText); + } + } + + return value; + } + + bool _isParsed = false; + ContentTypeHeader? _parsingContentTypeHeader; + + int _size = 0; + + /// Size of the entire MimePart + int get size => _size; + + int _bodySize = 0; + + /// Size of the MimePart body + int get bodySize => _bodySize; + + /// Decodes the text represented by the mime data + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ); + + /// Decodes the data represented by the mime data + Uint8List decodeBinary(String? contentTransferEncoding); + + /// Decodes message/rfc822 content + MimeData? decodeMessageData(); + + /// Parses this data + void parse(ContentTypeHeader? contentTypeHeader) { + if (_isParsed && (contentTypeHeader == _parsingContentTypeHeader)) { + return; + } + _isParsed = true; + _parsingContentTypeHeader = contentTypeHeader; + _parseContent(contentTypeHeader); + } + + void _parseContent(ContentTypeHeader? contentTypeHeader); + + /// Renders this mime data. + /// + /// Optionally set [renderHeader] to `false` in case the + /// message header should be skipped. + void render(StringBuffer buffer, {bool renderHeader = true}); + + Header? _getHeader(String lowerCaseName) => + headersList?.firstWhereOrNull((h) => h.lowerCaseName == lowerCaseName); + + String? _getHeaderValue(String lowerCaseName) => + _getHeader(lowerCaseName)?.value; + + @override + String toString() { + final buffer = StringBuffer(); + render(buffer); + + return buffer.toString(); + } +} + +/// Represents textual mime data +class TextMimeData extends MimeData { + /// Creates a new text based mime data + /// + /// with the specified [text] and the [containsHeader] information. + TextMimeData(this.text, {required bool containsHeader}) + : super(containsHeader: containsHeader) { + _size = text.length; + } + + /// The text representation of the full mime data + final String text; + + /// The body of the data + late String body; + + @override + void _parseContent(ContentTypeHeader? contentTypeHeader) { + var bodyText = text; + if (containsHeader) { + if (text.startsWith('\r\n')) { + // this part has no header + bodyText = text.substring(2); + } else { + final headerParseResult = ParserHelper.parseHeader(text); + final bodyStartIndex = headerParseResult.bodyStartIndex; + if (bodyStartIndex != null) { + bodyText = bodyStartIndex >= text.length + ? '' + : text.substring(bodyStartIndex); + } + headersList = headerParseResult.headersList; + } + // ignore: parameter_assignments + contentTypeHeader ??= contentType; + } else { + bodyText = text; + } + body = bodyText; + _bodySize = body.length; + String? partsBoundary; + if (contentTypeHeader?.mediaType.isMessage ?? false) { + final headStop = body.indexOf('\r\n\r\n'); + final boundaryMatcher = RegExp(r'boundary="(.+)"'); + partsBoundary = + boundaryMatcher.firstMatch(body.substring(0, headStop))?.group(1); + } else { + partsBoundary = contentTypeHeader?.boundary; + } + if (partsBoundary != null) { + parts = []; + final splitBoundary = '--$partsBoundary\r\n'; + final childParts = bodyText.split(splitBoundary); + if (!bodyText.startsWith(splitBoundary)) { + // mime-readers can ignore the preamble: + childParts.removeAt(0); + } + if (childParts.isNotEmpty) { + var lastPart = childParts.last; + final closingIndex = lastPart.lastIndexOf('--$partsBoundary--'); + if (closingIndex != -1) { + childParts.removeLast(); + lastPart = lastPart.substring(0, closingIndex); + childParts.add(lastPart); + } + for (final childPart in childParts) { + if (childPart.isNotEmpty) { + final part = TextMimeData(childPart, containsHeader: true) + ..parse(null); + parts?.add(part); + } + } + } + } + } + + @override + void render(StringBuffer buffer, {bool renderHeader = true}) { + if (!renderHeader && containsHeader) { + buffer.write(body); + } else { + buffer.write(text); + } + } + + @override + Uint8List decodeBinary(String? contentTransferEncoding) => + MailCodec.decodeBinary(body, contentTransferEncoding); + + @override + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ) => + MailCodec.decodeAnyText( + body, + contentTransferEncoding, + contentTypeHeader?.charset, + ); + + @override + MimeData? decodeMessageData() => TextMimeData(body, containsHeader: true); +} + +/// Represents binary mime data +class BinaryMimeData extends MimeData { + /// Creates a new binary mime data + /// + /// with the specified [data] and the [containsHeader] info. + BinaryMimeData(this.data, {required bool containsHeader}) + : super(containsHeader: containsHeader) { + _size = data.length; + } + + /// The binary data + final Uint8List data; + int? _bodyStartIndex; + late Uint8List _bodyData; + + @override + void _parseContent(ContentTypeHeader? contentTypeHeader) { + if (containsHeader) { + headersList = _parseHeader(); + } else { + _bodyStartIndex = 0; + } + final bodyStartIndex = _bodyStartIndex; + if (bodyStartIndex == null) { + _bodyData = Uint8List(0); + } else { + _bodyData = bodyStartIndex == 0 ? data : data.sublist(bodyStartIndex); + final usedContentType = contentTypeHeader ?? contentType; + String? partsBoundary; + if (usedContentType?.mediaType.isMessage ?? false) { + final headStop = '\r\n\r\n'.codeUnits; + final headStopIndex = ByteUtils.findSequence(_bodyData, headStop); + if (headStopIndex > 0) { + final matcher = 'boundary="'.codeUnits; + final boundaryPos = ByteUtils.findSequence( + Uint8List.sublistView(_bodyData, 0, headStopIndex), + matcher, + ); + if (boundaryPos > 0) { + partsBoundary = String.fromCharCodes( + _bodyData.sublist( + boundaryPos + matcher.length, + _bodyData.indexOf( + AsciiRunes.runeDoubleQuote, + boundaryPos + matcher.length + 1, + ), + ), + ); + } + // print('message/rfc822 boundary: $partsBoundary'); + } + } else { + // Generic multipart + partsBoundary = usedContentType?.boundary; + } + if (partsBoundary != null) { + // split into different parts: + parts = _splitAndParse(partsBoundary, _bodyData); + } + } + _bodySize = _bodyData.length; + } + + List _splitAndParse( + final String boundaryText, + final Uint8List bodyData, + ) { + final boundary = '--$boundaryText\r\n'.codeUnits; + final result = []; + // end is expected to be \r\n for all but the last one, where -- is expected, possibly followed by \r\n + int? startIndex; + final maxIndex = bodyData.length - (3 * boundary.length); + for (var i = 0; i < maxIndex; i++) { + var foundMatch = true; + for (var j = 0; j < boundary.length; j++) { + if (bodyData[i + j] != boundary[j]) { + foundMatch = false; + break; + } + } + if (foundMatch) { + if (startIndex == null) { + i += boundary.length; + startIndex = i; + } else { + final partData = bodyData.sublist(startIndex, i); + final part = BinaryMimeData(partData, containsHeader: true) + ..parse(null); + result.add(part); + i += boundary.length; + startIndex = i; + } + } + } + // check and add end: + if (startIndex != null) { + final endBoundary = '--$boundaryText--'.codeUnits; + for (var i = bodyData.length - endBoundary.length; i > startIndex; i--) { + var foundMatch = true; + for (var j = 0; j < endBoundary.length; j++) { + if (bodyData[i + j] != endBoundary[j]) { + foundMatch = false; + break; + } + } + if (foundMatch) { + final partData = bodyData.sublist(startIndex, i); + final part = BinaryMimeData(partData, containsHeader: true) + ..parse(null); + result.add(part); + break; + } + } + } + + return result; + } + + @override + String decodeText( + ContentTypeHeader? contentTypeHeader, + String? contentTransferEncoding, + ) => + _bodyStartIndex == null + ? '' + : MailCodec.decodeAsText( + _bodyData, + contentTransferEncoding, + contentTypeHeader?.charset, + ); + + @override + Uint8List decodeBinary(String? contentTransferEncoding) { + final contentTransferEncodingLC = contentTransferEncoding?.toLowerCase(); + if (_bodyStartIndex == null || + // do not try to decode textual content: + contentTransferEncodingLC == '7bit' || + contentTransferEncodingLC == '8bit' || + contentTransferEncodingLC == 'quoted-printable') { + return _bodyData; + } + // even with a 'binary' content transfer encoding there are \r\n + // characters that need to be handled, + // so translate to text first + final dataText = utf8.decode(_bodyData); + + return MailCodec.decodeBinary(dataText, contentTransferEncodingLC); + } + + List
_parseHeader() { + final headerData = data; + // shortcut for having an empty line at the start: + if (headerData.length > 1 && + headerData[0] == AsciiRunes.runeCarriageReturn && + headerData[1] == AsciiRunes.runeLineFeed) { + _bodyStartIndex = 2; + + return []; + } + // check for first CRLF-CRLF sequence: + for (var i = 0; i < headerData.length - 4; i++) { + if (headerData[i] == AsciiRunes.runeCarriageReturn && + headerData[i + 1] == AsciiRunes.runeLineFeed && + headerData[i + 2] == AsciiRunes.runeCarriageReturn && + headerData[i + 3] == AsciiRunes.runeLineFeed) { + final headerLines = + String.fromCharCodes(headerData, 0, i).split('\r\n'); + _bodyStartIndex = i + 4; + + return ParserHelper.parseHeaderLines(headerLines).headersList; + } + } + // the whole data is just headers: + final headerLines = String.fromCharCodes(headerData).split('\r\n'); + + return ParserHelper.parseHeaderLines(headerLines).headersList; + } + + @override + void render(StringBuffer buffer, {bool renderHeader = true}) { + if (!renderHeader && containsHeader) { + final text = String.fromCharCodes(_bodyData); + buffer.write(text); + } else { + final text = String.fromCharCodes(data); + buffer.write(text); + } + } + + @override + MimeData? decodeMessageData() => + BinaryMimeData(_bodyData, containsHeader: true); +} diff --git a/packages/enough_mail/lib/src/mime_message.dart b/packages/enough_mail/lib/src/mime_message.dart new file mode 100644 index 0000000..29296bb --- /dev/null +++ b/packages/enough_mail/lib/src/mime_message.dart @@ -0,0 +1,2170 @@ +// ignore_for_file: avoid_returning_this + +import 'dart:typed_data'; + +import 'package:collection/collection.dart' show IterableExtension; + +import 'codecs/date_codec.dart'; +import 'codecs/mail_codec.dart'; +import 'exception.dart'; +import 'imap/message_sequence.dart'; +import 'mail_address.dart'; +import 'mail_conventions.dart'; +import 'media_type.dart'; +import 'message_flags.dart'; +import 'mime_data.dart'; +import 'private/imap/parser_helper.dart'; +import 'private/util/ascii_runes.dart'; +import 'private/util/mail_address_parser.dart'; + +/// A MIME part +/// In a simple case a MIME message only has one MIME part. +class MimePart { + /// The `headers` field contains all message(part) headers + List
? headers; + + /// The raw message data of this part. + /// + /// May or may not include headers, depending on retrieval. + MimeData? mimeData; + + /// The children of this part, if any. + /// + List? parts; + + bool _isParsed = false; + String? _decodedText; + DateTime? _decodedDate; + ContentTypeHeader? _contentTypeHeader; + ContentDispositionHeader? _contentDispositionHeader; + + /// Simplified way to retrieve the media type + /// When no `content-type` header is defined, the media type `text/plain` is returned + MediaType get mediaType { + final header = getHeaderContentType(); + + return header?.mediaType ?? MediaType.textPlain; + } + + /// 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) => + _getLowerCaseHeaderValue(name.toLowerCase()); + + /// 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? _getLowerCaseHeaderValue(String name) { + final matchingHeaders = _getHeaderLowercase(name); + if (matchingHeaders != null && matchingHeaders.isNotEmpty) { + return matchingHeaders.first.value; + } + + return null; + } + + /// Checks if this MIME part has a header with the specified [name]. + bool hasHeader(String name) => _hasHeaderLowercase(name.toLowerCase()); + + bool _hasHeaderLowercase(String name) { + if (!_isParsed) { + parse(); + } + + return headers?.firstWhereOrNull((h) => h.lowerCaseName == name) != null; + } + + /// Retrieves all matching headers with the specified [name]. + Iterable
? getHeader(String name) => + _getHeaderLowercase(name.toLowerCase()); + + Iterable
? _getHeaderLowercase(String name) { + if (!_isParsed) { + parse(); + } + + return headers?.where((h) => h.lowerCaseName == name); + } + + /// Adds a header with the specified [name], [value] and optional [encoding]. + void addHeader( + String name, + String? value, [ + HeaderEncoding encoding = HeaderEncoding.none, + ]) { + headers ??=
[]; + var localValue = value; + if (value != null) { + if (encoding == HeaderEncoding.Q) { + localValue = MailCodec.quotedPrintable + .encodeHeader(value, nameLength: name.length); + } else if (encoding == HeaderEncoding.B) { + localValue = + MailCodec.base64.encodeHeader(value, nameLength: name.length); + } + } + final header = Header(name, localValue, encoding); + headers?.add(header); + } + + /// Sets a header with the specified [name], [value] and optional [encoding], + /// + /// replacing any existing header with the same [name]. + void setHeader( + String name, + String? value, [ + HeaderEncoding encoding = HeaderEncoding.none, + ]) { + headers ??=
[]; + final lowerCaseName = name.toLowerCase(); + headers?.removeWhere((h) => h.lowerCaseName == lowerCaseName); + var localValue = value; + if (value != null) { + if (encoding == HeaderEncoding.Q) { + localValue = MailCodec.quotedPrintable + .encodeHeader(value, nameLength: name.length); + } else if (encoding == HeaderEncoding.B) { + localValue = + MailCodec.base64.encodeHeader(value, nameLength: name.length); + } + } + headers?.add(Header(name, localValue, encoding)); + } + + /// Removes the header with the specified [name]. + void removeHeader(String name) { + headers ??=
[]; + final lowerCaseName = name.toLowerCase(); + headers?.removeWhere((h) => h.lowerCaseName == lowerCaseName); + } + + /// Inserts the [part] at the beginning of all parts. + void insertPart(MimePart part) { + parts ??= []; + parts?.insert(0, part); + } + + /// Adds the [part] at the end of all parts. + void addPart(MimePart part) { + parts ??= []; + parts?.add(part); + } + + /// Retrieves the first 'content-type' header. + ContentTypeHeader? getHeaderContentType() { + if (_contentTypeHeader == null) { + final value = _getLowerCaseHeaderValue('content-type'); + if (value == null) { + return null; + } + _contentTypeHeader = ContentTypeHeader(value); + } + + return _contentTypeHeader; + } + + /// Retrieves the first 'content-disposition' header. + ContentDispositionHeader? getHeaderContentDisposition() { + if (_contentDispositionHeader != null) { + return _contentDispositionHeader; + } + final value = _getLowerCaseHeaderValue('content-disposition'); + if (value == null) { + return null; + } + + return _contentDispositionHeader = ContentDispositionHeader(value); + } + + /// Adds the matching disposition header with the specified [disposition] + /// + /// + /// of this part and this children parts to the [result]. + /// + /// Optionally set [reverse] to `true` to add all parts that do not match + /// the specified `disposition`. + /// + /// Set [complete] to `false` to skip the included messages parts. + void collectContentInfo( + ContentDisposition disposition, + List result, + String? fetchId, { + bool? reverse, + bool? complete, + }) { + reverse ??= false; + complete ??= true; + final header = getHeaderContentDisposition(); + final isMessage = getHeaderContentType()?.mediaType.isMessage ?? false; + if ((!reverse && header?.disposition == disposition) || + (reverse && header?.disposition != disposition)) { + final info = ContentInfo(fetchId ?? '') + ..contentDisposition = header + ..contentType = getHeaderContentType() + ..cid = _getLowerCaseHeaderValue('content-id'); + result.add(info); + } + if (complete || !isMessage) { + final parts = this.parts; + if (parts != null && parts.isNotEmpty) { + for (var i = 0; i < parts.length; i++) { + final part = parts[i]; + final partFetchId = mediaType.sub == MediaSubtype.messageRfc822 + ? fetchId + : fetchId != null + ? '$fetchId.${i + 1}' + : '${i + 1}'; + part.collectContentInfo( + disposition, + result, + partFetchId, + reverse: reverse, + complete: complete, + ); + } + } + } + } + + /// Decodes the value of the first matching header + String? decodeHeaderValue(String name) { + final value = getHeaderValue(name); + try { + return MailCodec.decodeHeader(value); + } catch (e) { + print('Unable to decode header [$name: $value]: $e'); + + return value; + } + } + + /// Decodes the message 'date' header to local time. + DateTime? decodeDate() => _decodedDate ??= decodeHeaderDateValue('date'); + + /// Tries to find and decode the associated file name + String? decodeFileName() { + final fileName = MailCodec.decodeHeader( + getHeaderContentDisposition()?.filename ?? + getHeaderContentType()?.parameters['name'], + ); + + return fileName?.replaceAll('\\"', '"'); + } + + /// Decodes the a date value of the first matching header + DateTime? decodeHeaderDateValue(String name) => + DateCodec.decodeDate(getHeaderValue(name)); + + /// Decodes the email address value of first matching header + List? decodeHeaderMailAddressValue(String name) => + MailAddressParser.parseEmailAddresses(getHeaderValue(name)); + + /// Decodes the text of this part. + String? decodeContentText() => _decodedText ??= mimeData?.decodeText( + getHeaderContentType(), + _getLowerCaseHeaderValue('content-transfer-encoding'), + ); + + /// Decodes the binary data of this part. + Uint8List? decodeContentBinary() => mimeData?.decodeBinary( + _getLowerCaseHeaderValue('content-transfer-encoding'), + ); + + /// Decodes a message/rfc822 part + MimeMessage? decodeContentMessage() { + final data = mimeData; + if (data == null) { + return null; + } + final message = MimeMessage() + ..mimeData = data.decodeMessageData() + ..parse(); + + return message; + } + + /// Checks if this MIME part is textual. + bool isTextMediaType() => mediaType.isText; + + /// Checks if this MIME part or a child is textual. + /// + /// [depth] optional depth, use 1 if only direct children should be checked + bool hasTextPart({int? depth}) { + if (isTextMediaType()) { + return true; + } + final parts = this.parts; + if (parts != null) { + if (depth != null) { + if (--depth < 0) { + return false; + } + } + for (final part in parts) { + if (part.hasTextPart(depth: depth)) { + return true; + } + } + } + + return false; + } + + /// Checks if this MIME part or a child is of the specified media type + /// + /// [subtype] the desired media type + /// [depth] optional depth, use 1 if only direct children should be checked + bool hasPart(MediaSubtype subtype, {int? depth}) { + if (mediaType.sub == subtype) { + return true; + } + final mimeParts = parts; + if (mimeParts != null) { + if (depth != null) { + if (--depth < 0) { + return false; + } + } + for (final part in mimeParts) { + if (part.hasPart(subtype, depth: depth)) { + return true; + } + } + } + + return false; + } + + /// Searches the MimePart with the specified [subtype]. + MimePart? getPartWithMediaSubtype(MediaSubtype subtype) { + if (mediaType.sub == subtype) { + return this; + } + final mimeParts = parts; + if (mimeParts != null) { + for (final mimePart in mimeParts) { + final match = mimePart.getPartWithMediaSubtype(subtype); + if (match != null) { + return match; + } + } + } + + return null; + } + + /// Searches for this the given subtype + /// as a part of a `Multipart/Alternative` mime part. + /// + /// This is useful if you want to check for your preferred rendering + /// format present as an alternative. + MimePart? getAlternativePart(MediaSubtype subtype) { + if (mediaType.sub == MediaSubtype.multipartAlternative) { + return getPartWithMediaSubtype(subtype); + } + final mimeParts = parts; + if (mimeParts != null) { + for (final mimePart in mimeParts) { + final match = mimePart.getAlternativePart(subtype); + if (match != null) { + return match; + } + } + } + + return null; + } + + /// Tries to find a 'content-type: text/plain' part + /// + /// and decodes its contents when found. + String? decodeTextPlainPart() => + _decodeTextPart(this, MediaSubtype.textPlain); + + /// Tries to find a 'content-type: text/html' part + /// + /// and decodes its contents when found. + String? decodeTextHtmlPart() => _decodeTextPart(this, MediaSubtype.textHtml); + + static String? _decodeTextPart(MimePart part, MediaSubtype subtype) { + if (!part._isParsed) { + part.parse(); + } + final mediaType = part.mediaType; + if (mediaType.sub == subtype) { + return part.decodeContentText(); + } + final parts = part.parts; + if (parts != null) { + for (final childPart in parts) { + final decoded = _decodeTextPart(childPart, subtype); + if (decoded != null) { + return decoded; + } + } + } + + return null; + } + + /// Parses this and all children MIME parts. + void parse() { + _isParsed = true; + final mimeData = this.mimeData; + final parts = this.parts; + if (mimeData != null) { + mimeData.parse(null); + if (mimeData.containsHeader) { + headers = mimeData.headersList; + } + final mimeDataParts = mimeData.parts; + if (mimeDataParts != null && mimeDataParts.isNotEmpty) { + final usedParts = []; + for (final dataPart in mimeDataParts) { + final part = MimePart() + ..mimeData = dataPart + ..headers = dataPart.headersList; + usedParts.add(part); + part.parse(); + } + this.parts = usedParts; + } + } else if (parts != null) { + for (final part in parts) { + part.parse(); + } + } + } + + /// Renders this mime part with all children parts into the specified [buffer] + /// + /// You can set [renderHeader] to `false` when the message headers + /// should not be rendered. + /// + /// Throws a [InvalidArgumentException] when this message contains + /// parts but no multipart boundary. + void render(StringBuffer buffer, {bool renderHeader = true}) { + final mimeData = this.mimeData; + if (mimeData != null) { + if (!mimeData.containsHeader && renderHeader) { + _renderHeaders(buffer); + buffer.write('\r\n'); + } + mimeData.render(buffer); + } else { + if (renderHeader) { + _renderHeaders(buffer); + buffer.write('\r\n'); + } + final parts = this.parts; + if (parts != null && parts.isNotEmpty) { + final multiPartBoundary = getHeaderContentType()?.boundary; + if (multiPartBoundary == null) { + throw InvalidArgumentException('mime message rendering error: ' + 'parts present but no multiPartBoundary defined.'); + } + for (final part in parts) { + buffer + ..write('--') + ..write(multiPartBoundary) + ..write('\r\n'); + part.render(buffer); + buffer.write('\r\n'); + } + buffer + ..write('--') + ..write(multiPartBoundary) + ..write('--') + ..write('\r\n'); + } + } + } + + void _renderHeaders(StringBuffer buffer) { + if (headers != null) { + for (final header in headers ?? []) { + header.render(buffer); + } + } + } +} + +/// A MIME message +class MimeMessage extends MimePart { + /// Creates a new empty mime message + MimeMessage(); + + /// Deserializes a new message based on the specified rendered [text] form. + /// + /// Compare [renderMessage] method for converting a message to text. + MimeMessage.parseFromText(String text) { + mimeData = TextMimeData(text, containsHeader: true); + parse(); + } + + /// Creates a new message based on the specified binary [data]. + /// + /// Compare [renderMessage] method for converting a message to text. + MimeMessage.parseFromData(Uint8List data) { + mimeData = BinaryMimeData(data, containsHeader: true); + parse(); + } + + /// Creates a new message from the given [envelope]. + MimeMessage.fromEnvelope( + Envelope value, { + this.uid, + this.guid, + this.sequenceId, + this.flags, + }) { + envelope = value; + final subject = value.subject; + if (subject != null) { + _decodedSubject = subject; + addHeader(MailConventions.headerSubject, subject); + } + _decodedDate = value.date; + final inReplyTo = value.inReplyTo; + if (inReplyTo != null) { + addHeader(MailConventions.headerInReplyTo, inReplyTo); + } + final messageId = value.messageId; + if (messageId != null) { + addHeader(MailConventions.headerMessageId, messageId); + } + } + + /// The index of the message, if known + int? sequenceId; + + /// The uid of the message, if known + int? uid; + + /// The guid of the message. + /// + /// This field is populated automatically when using the high level API + /// (`MailClient`) and when the mail service delivers a [uid] for messages. + /// + /// Compare [setGuid] and [calculateGuid] + int? guid; + + /// Generates a global unique ID to identify a message reliably and robustly. + /// + /// The generated GUID can be used as a primary key, a notification ID + /// and so forth. + /// + /// When using the highlevel API, the `MimeMessage.guid` field is populated + /// automatically. + /// + /// Compare [guid] and [setGuid] + static int calculateGuid({ + required String email, + required String encodedMailboxName, + required int mailboxUidValidity, + required int messageUid, + }) => + email.hashCode ^ + encodedMailboxName.hashCode ^ + mailboxUidValidity ^ + messageUid; + + /// Calculates and sets the [guid] of this message. + /// + /// Compare [guid] and [calculateGuid] + void setGuid({ + required String email, + required String encodedMailboxName, + required int mailboxUidValidity, + }) { + final guid = calculateGuid( + email: email, + encodedMailboxName: encodedMailboxName, + mailboxUidValidity: mailboxUidValidity, + messageUid: uid ?? 0, + ); + this.guid = guid; + } + + /// The modifications sequence of this message. + /// + /// This is only returned by servers that support the CONDSTORE capability + /// and can be fetch explicitly with 'MODSEQ'. + int? modSequence; + + /// Message flags like \Seen, \Recent, etc + List? flags; + + /// The internal date of the message on the recipient's provider server + String? internalDate; + + /// The size of the message in bytes + int? size; + + /// The thread sequence, this can be populated manually + /// + /// or with `MailClient.fetchThreadData`. + MessageSequence? threadSequence; + + /// Checks if this message has been read + bool get isSeen => hasFlag(MessageFlags.seen); + + /// Sets the `\Seen` flag for this message + set isSeen(bool value) => setFlag(MessageFlags.seen, value); + + /// Checks if this message has been replied + bool get isAnswered => hasFlag(MessageFlags.answered); + + /// Sets the `\Answered` flag for this message + set isAnswered(bool value) => setFlag(MessageFlags.answered, value); + + /// Checks if this message has been forwarded + bool get isForwarded => hasFlag(MessageFlags.keywordForwarded); + + /// Sets the `$Forwarded` keyword flag for this message + set isForwarded(bool value) => setFlag(MessageFlags.keywordForwarded, value); + + /// Checks if this message has been marked as important / flagged + bool get isFlagged => hasFlag(MessageFlags.flagged); + + /// Sets the `\Flagged` flag for this message + set isFlagged(bool value) => setFlag(MessageFlags.flagged, value); + + /// Checks if this message has been marked as deleted + bool get isDeleted => hasFlag(MessageFlags.deleted); + + /// Sets the `\Deleted` flag for this message + set isDeleted(bool value) => setFlag(MessageFlags.deleted, value); + + /// Checks if a read receipt has been sent for this message + @Deprecated('Use isReadReceiptSent instead') + bool get isMdnSent => hasFlag(MessageFlags.keywordMdnSent); + + /// Sets the `$MDNSent` keyword flag for this message + @Deprecated('Use isReadReceiptSent instead') + set isMdnSent(bool value) => setFlag(MessageFlags.keywordMdnSent, value); + + /// Checks if a read receipt has been sent for this message + /// + /// Compare [isReadReceiptRequested] + bool get isReadReceiptSent => hasFlag(MessageFlags.keywordMdnSent); + + /// Sets if a read receipt has been sent for this message + /// + /// Compare [isReadReceiptRequested] + set isReadReceiptSent(bool value) => + setFlag(MessageFlags.keywordMdnSent, value); + + /// Checks if a disposition notification message is requested. + /// + /// This getter checks if there is already a [MessageFlags.keywordMdnSent] + /// flag, if that's the case, `false` is returned. + /// + /// Then it is checked if either the + /// [MailConventions.headerDispositionNotificationTo] + /// or a `Return-Receipt-To` header is present. + /// Compare [isReadReceiptSent] + bool get isReadReceiptRequested { + final mimeHeaders = headers; + + return !isReadReceiptSent && + (mimeHeaders != null && + mimeHeaders.any((h) => + h.lowerCaseName == 'disposition-notification-to' || + h.lowerCaseName == 'return-receipt-to')); + } + + /// Checks if this message contents has been downloaded + bool get isDownloaded => + (mimeData != null) || (_individualParts?.isNotEmpty ?? false); + + /// The email of the first from address of this message + String? get fromEmail { + final from = this.from; + if (from != null && from.isNotEmpty) { + return from.first.email; + } else if (headers != null) { + final fromHeaderValue = + headers?.firstWhereOrNull((h) => h.lowerCaseName == 'from')?.value; + if (fromHeaderValue != null) { + return ParserHelper.parseEmail(fromHeaderValue); + } + } + + return null; + } + + List? _from; + + /// according to RFC 2822 section 3.6.2. there can be more than one + /// FROM address, in that case the sender MUST be specified + List? get from { + var addresses = _from; + if (addresses == null) { + addresses = decodeHeaderMailAddressValue('from'); + _from = addresses; + } + + return addresses; + } + + set from(List? list) => _from = list; + + MailAddress? _sender; + + /// The sender of the message + MailAddress? get sender { + var address = _sender; + if (address == null) { + final addresses = decodeHeaderMailAddressValue('sender'); + if (addresses != null && addresses.isNotEmpty) { + address = addresses.first; + } + _sender = address; + } + + return address; + } + + set sender(MailAddress? address) => _sender = address; + + List? _replyTo; + + /// The address that should be used for replies + List? get replyTo { + var addresses = _replyTo; + if (addresses == null) { + addresses = decodeHeaderMailAddressValue('reply-to'); + _replyTo = addresses; + } + + return addresses; + } + + set replyTo(List? list) => _replyTo = list; + + List? _to; + + /// The recipients of the message + List? get to { + var addresses = _to; + if (addresses == null) { + addresses = decodeHeaderMailAddressValue('to'); + _to = addresses; + } + + return addresses; + } + + set to(List? list) => _to = list; + + List? _cc; + + /// The recipients on carbon-copy (CC) + List? get cc { + var addresses = _cc; + if (addresses == null) { + addresses = decodeHeaderMailAddressValue('cc'); + _cc = addresses; + } + + return addresses; + } + + set cc(List? list) => _cc = list; + List? _bcc; + + /// The recipients not visible to other recipients + /// + /// (blind carbon copy) + List? get bcc { + var addresses = _bcc; + if (addresses == null) { + addresses = decodeHeaderMailAddressValue('bcc'); + _bcc = addresses; + } + + return addresses; + } + + set bcc(List? list) => _bcc = list; + + Map? _individualParts; + + /// The body structure of the message. + /// + /// This field is only populated when fetching either `BODY`, + /// `BODYSTRUCTURE` elements. + BodyPart? body; + + Envelope? _envelope; + + /// The envelope of the message. + /// + /// This field is only populated when fetching `ENVELOPE`. + Envelope? get envelope => _envelope; + set envelope(Envelope? value) { + _envelope = value; + if (value != null) { + _from = value.from; + _to = value.to; + _cc = value.cc; + _bcc = value.bcc; + _replyTo = value.replyTo; + _sender = value.sender; + } + } + + /// Retrieves the mail addresses of all message recipients + List get recipientAddresses => + recipients.map((r) => r.email).toList(); + + /// Retrieves the mail addresses of all message recipients + List get recipients { + final recipients = []; + final t = to; + if (t != null) { + recipients.addAll(t); + } + final c = cc; + if (c != null) { + recipients.addAll(c); + } + final b = bcc; + if (b != null) { + recipients.addAll(b); + } + + return recipients; + } + + String? _decodedSubject; + + /// Decodes the subject of this message + String? decodeSubject() => _decodedSubject ??= decodeHeaderValue('subject'); + + /// Serializes the complete message into a String. + /// + /// Optionally exclude the rendering of the headers by setting + /// [renderHeader] to `false`. + /// + /// Internally calls [render] to render all mime parts. + /// + /// Compare [MimeMessage.parseFromText] for de-serializing. + String renderMessage({bool renderHeader = true}) { + final buffer = StringBuffer(); + render(buffer, renderHeader: renderHeader); + + return buffer.toString(); + } + + /// Checks if this is a typical text message + /// Compare [isTextPlainMessage] + /// Compare [decodeTextPlainPart] + /// Compare [decodeTextHtmlPart] + bool isTextMessage() => + mediaType.isText || (mediaType.isMultipart && hasTextPart(depth: 1)); + + /// Checks if this is a typical text message with a plain text part + /// Compare [decodeTextPlainPart] + /// Compare [isTextMessage] + bool isTextPlainMessage() => + mediaType.sub == MediaSubtype.textPlain || + (mediaType.isMultipart && hasPart(MediaSubtype.textPlain, depth: 1)); + + /// Retrieves the sender of the this message + /// + /// by checking the `reply-to`, `sender` and `from` + /// header values in this order. + /// + /// Set [combine] to `true` in case you want to combine the + /// addresses from these headers, by default the first non-empty + /// entry is returned. + List decodeSender({bool combine = false}) { + var replyTo = decodeHeaderMailAddressValue('reply-to') ?? []; + if (combine || (replyTo.isEmpty)) { + final senderValue = + decodeHeaderMailAddressValue('sender') ?? []; + if (combine) { + replyTo.addAll(senderValue); + } else { + replyTo = senderValue; + } + } + if (combine || replyTo.isEmpty) { + final fromValue = decodeHeaderMailAddressValue('from') ?? []; + if (combine) { + replyTo.addAll(fromValue); + } else { + replyTo = fromValue; + } + } + + return replyTo; + } + + /// Checks of this messaging is from the specified [sender] address. + /// + /// Optionally specify known [aliases] and set [allowPlusAliases] to + /// `true` to allow alias such as `me+alias@domain.com`. + /// + /// Set [allowPlusAliases] to `true` in case + aliases like + /// `me+alias@domain.com` are valid. + bool isFrom( + MailAddress sender, { + List? aliases, + bool allowPlusAliases = false, + }) => + findSender( + sender, + aliases: aliases, + allowPlusAliases: allowPlusAliases, + ) != + null; + + /// Finds the matching [sender] address. + /// + /// Optionally specify known [aliases] and set [allowPlusAliases] to `true` + /// to allow alias such as `me+alias@domain.com`. + MailAddress? findSender( + MailAddress sender, { + List? aliases, + bool allowPlusAliases = false, + }) { + final searchFor = [sender]; + if (aliases != null) { + searchFor.addAll(aliases); + } + final searchIn = decodeSender(combine: true); + + return MailAddress.getMatch( + searchFor, + searchIn, + handlePlusAliases: allowPlusAliases, + ); + } + + /// Finds the matching [recipient] address. + /// + /// Optionally specify known [aliases] and set [allowPlusAliases] to `true` + /// to allow alias such as `me+alias@domain.com`. + MailAddress? findRecipient( + MailAddress recipient, { + List? aliases, + bool allowPlusAliases = false, + }) { + final searchFor = [recipient]; + if (aliases != null) { + searchFor.addAll(aliases); + } + final searchIn = []; + final to = this.to; + if (to != null) { + searchIn.addAll(to); + } + final cc = this.cc; + if (cc != null) { + searchIn.addAll(cc); + } + + return MailAddress.getMatch( + searchFor, + searchIn, + handlePlusAliases: allowPlusAliases, + ); + } + + /// Retrieves all content info of parts + /// with the specified [disposition] `Content-Type` header. + /// + /// By default the content info with `ContentDisposition.attachment` + /// are retrieved. + /// + /// Typically this used to list all attachments of a message. + /// Note that either the message contents (`BODY[]`) or the `BODYSTRUCTURE` + /// is required to reliably list all matching content elements. + /// All fetchId parsed from the `BODYSTRUCTURE` are returned in a form + /// compatible with the body parts tree unless [withCleanParts] is false. + List findContentInfo({ + ContentDisposition disposition = ContentDisposition.attachment, + bool? withCleanParts, + bool? complete, + }) { + withCleanParts ??= true; + final result = []; + final body = this.body; + if (body != null) { + body.collectContentInfo( + disposition, + result, + withCleanParts: withCleanParts, + complete: complete, + ); + } else if (parts?.isNotEmpty ?? false || body == null) { + collectContentInfo(disposition, result, null, complete: complete); + } + + return result; + } + + /// Checks if this message has parts with the specified [disposition]. + /// + /// Note that either the full message or the body structure must have + /// been downloaded before. + bool hasContent(ContentDisposition disposition) => + findContentInfo(disposition: disposition).isNotEmpty; + + /// Checks if this message has parts with a `Content-Disposition: attachment` + /// header. + bool hasAttachments() => hasContent(ContentDisposition.attachment); + + /// Checks if this message contains either explicit attachments + /// or non-textual inline parts. + bool hasAttachmentsOrInlineNonTextualParts() { + if (hasAttachments()) { + return true; + } else { + final inlineParts = + findContentInfo(disposition: ContentDisposition.inline); + for (final info in inlineParts) { + if (!info.isText) { + return true; + } + } + } + + return false; + } + + /// Checks if this message any inline parts. + bool hasInlineParts() { + final inlineParts = findContentInfo(disposition: ContentDisposition.inline); + + return inlineParts.isNotEmpty; + } + + /// Retrieves the part with the specified [fetchId]. + /// + /// Returns null if the part has not been loaded (yet). + /// + /// Throws a [InvalidArgumentException] when the [fetchId] is empty. + MimePart? getPart(String fetchId) { + if (fetchId.isEmpty) { + throw InvalidArgumentException( + 'Invalid empty fetchId in MimeMessage.getPart(fetchId).', + ); + } + final partsByFetchId = _individualParts; + if (partsByFetchId != null) { + final part = partsByFetchId[fetchId]; + if (part != null) { + return part; + } + } + final idParts = fetchId.split('.').map(int.tryParse); + MimePart parent = this; + var warningGiven = false; + for (final id in idParts) { + if (id == null) { + if (!warningGiven) { + print('Warning: unable to retrieve individual parts from ' + 'fetchId [$fetchId] (in MimeMessage.getPart(fetchId)).'); + warningGiven = true; + } + continue; + } + final parts = parent.parts; + if (parts == null || parts.length < id) { + // this mime message is not fully loaded + return null; + } + parent = parts[id - 1]; + } + + return parent; + } + + @override + MimePart? getPartWithMediaSubtype(MediaSubtype subtype) { + var match = super.getPartWithMediaSubtype(subtype); + if (match == null) { + final partsByFetchId = _individualParts; + if (partsByFetchId != null) { + match = partsByFetchId.values + .firstWhereOrNull((p) => p.mediaType.sub == subtype); + } + } + + return match; + } + + @override + MimePart? getAlternativePart(MediaSubtype subtype) { + final match = super.getAlternativePart(subtype); + if (match == null) { + if (mediaType.sub == subtype) { + return this; + } + final partsByFetchId = _individualParts; + final structure = body; + if (partsByFetchId != null && structure != null) { + final alternativeBodyPart = + structure.findFirst(MediaSubtype.multipartAlternative); + if (alternativeBodyPart != null) { + final matchBodyPart = alternativeBodyPart.findFirst(subtype); + if (matchBodyPart != null) { + return partsByFetchId[matchBodyPart.fetchId]; + } + } + } + } + + return match; + } + + /// Sets the individually loaded [part] with the given [fetchId]. + /// + /// call [getPart(fetchId)] to retrieve a part. + void setPart(String fetchId, MimePart part) { + _individualParts ??= {}; + final existing = body?.getChildPart(fetchId); + if (existing != null) { + part + .._contentTypeHeader = existing.contentType + .._contentDispositionHeader = existing.contentDisposition + ..addHeader( + MailConventions.headerContentTransferEncoding, + existing.encoding, + ); + } + _individualParts?[fetchId] = part; + } + + /// Puts all parts of this message into a flat sequential list. + List get allPartsFlat { + final allParts = []; + final individualParts = _individualParts; + if (individualParts != null) { + allParts.addAll(individualParts.values); + } + _addPartsFlat(this, allParts); + + return allParts; + } + + void _addPartsFlat(MimePart part, List allParts) { + allParts.add(part); + final childParts = part.parts; + if (childParts != null) { + for (final child in childParts) { + _addPartsFlat(child, allParts); + } + } + } + + /// Retrieves the part with the specified Content-ID [cid]. + MimePart? getPartWithContentId(String cid) { + var contentId = cid; + if (!contentId.startsWith('<')) { + contentId = '<$contentId>'; + } + contentId = contentId.toLowerCase(); + final allParts = allPartsFlat; + for (final part in allParts) { + final partCid = part._getLowerCaseHeaderValue('content-id'); + if (partCid != null && partCid.toLowerCase() == cid) { + return part; + } + } + final body = this.body; + if (body != null) { + final bodyPart = body.findFirstWithContentId(cid); + final fetchId = bodyPart?.fetchId; + if (fetchId != null) { + return getPart(fetchId); + } + } + + return null; + } + + /// Copies all individually loaded parts from [other] to this message. + void copyIndividualParts(MimeMessage other) { + final otherIndividualParts = other._individualParts; + if (otherIndividualParts != null) { + for (final key in otherIndividualParts.keys) { + final value = otherIndividualParts[key]; + if (value != null) { + setPart(key, value); + } + } + } + } + + @override + String toString() => renderMessage(); + + /// Checks if the messages has the message flag with the specified [name]. + bool hasFlag(String name) { + final mimeFlags = flags; + + return (mimeFlags != null) && mimeFlags.contains(name); + } + + /// Adds the flag with the specified [name] to this message. + /// + /// Note that this only affects this message instance and is not persisted or + /// reported to the mail service automatically. + void addFlag(String name) { + final mimeFlags = flags; + if (mimeFlags == null) { + flags = [name]; + } else if (!mimeFlags.contains(name)) { + mimeFlags.add(name); + } + } + + /// Removes the flag with the specified [name] from this message. + /// + /// Note that this only affects this message instance and is not persisted or + /// reported to the mail service automatically. + void removeFlag(String name) { + final flags = this.flags; + if (flags == null) { + this.flags = []; + } else { + flags.remove(name); + } + } + + /// Adds or removes the flag with the specified [name] to/from this message + /// depending on [enable]. + /// + /// Note that this only affects this message instance and is not persisted or + /// reported to the mail service automatically. + // ignore: avoid_positional_boolean_parameters + void setFlag(String name, bool enable) { + if (enable) { + addFlag(name); + } else { + removeFlag(name); + } + } + + @override + String? decodeTextPlainPart() { + final decoded = super.decodeTextPlainPart(); + if (decoded == null) { + return _decodeTextPartFromBody(MediaSubtype.textPlain); + } + + return decoded; + } + + @override + String? decodeTextHtmlPart() { + final decoded = super.decodeTextHtmlPart(); + if (decoded == null) { + return _decodeTextPartFromBody(MediaSubtype.textHtml); + } + + return decoded; + } + + @override + ContentTypeHeader? getHeaderContentType() => + super.getHeaderContentType() ?? body?.contentType; + + String? _decodeTextPartFromBody(MediaSubtype subtype) { + final body = this.body; + if (body != null) { + final bodyPart = body.findFirst(subtype); + final fetchId = bodyPart?.fetchId; + if (bodyPart != null && fetchId != null) { + final part = getPart(fetchId); + if (part != null) { + if (!part._isParsed) { + part.parse(); + } + final partMimeData = part.mimeData; + if (partMimeData != null) { + return partMimeData.decodeText( + bodyPart.contentType, + bodyPart.encoding, + ); + } + } + } + } + + return null; + } + + @override + int get hashCode => guid ?? super.hashCode; + + @override + bool operator ==(Object other) => guid != null && other is MimeMessage + ? guid == other.guid + : super == other; +} + +/// Encapsulates a MIME header +class Header { + /// Creates a new header + Header(this.name, this.value, [this.encoding = HeaderEncoding.none]) + : lowerCaseName = name.toLowerCase(); + + /// The name of the header + final String name; + + /// The optional value + final String? value; + + /// The used header encoding + final HeaderEncoding encoding; + + /// The name in lower case + final String lowerCaseName; + + @override + String toString() => '$name: $value'; + + /// Renders this header into a the [buffer] wrapping it if necessary. + void render(StringBuffer buffer) { + final value = this.value; + var length = name.length + ': '.length + (value?.length ?? 0); + buffer + ..write(name) + ..write(': '); + if (value == null || length < MailConventions.textLineMaxLength) { + if (value != null) { + buffer.write(value); + } + buffer.write('\r\n'); + + return; + } + var currentLineLength = name.length + ': '.length; + length -= name.length + ': '.length; + final runes = value.runes.toList(); + var startIndex = 0; + while (length > 0) { + var chunkLength = MailConventions.textLineMaxLength - currentLineLength; + if (startIndex + chunkLength >= value.length) { + // write reminder: + buffer + ..write(value.substring(startIndex).trim()) + ..write('\r\n'); + break; + } + for (var runeIndex = startIndex + chunkLength; + runeIndex > startIndex; + runeIndex--) { + final rune = runes[runeIndex]; + if (rune == AsciiRunes.runeSemicolon || + rune == AsciiRunes.runeSpace || + rune == AsciiRunes.runeClosingParentheses || + rune == AsciiRunes.runeClosingBracket || + rune == AsciiRunes.runeGreaterThan) { + chunkLength = runeIndex - startIndex + 1; + break; + } + } + buffer + ..write(value.substring(startIndex, startIndex + chunkLength).trim()) + ..write('\r\n'); + length -= chunkLength; + startIndex += chunkLength; + if (length > 0) { + buffer.writeCharCode(AsciiRunes.runeTab); + currentLineLength = 1; + } + } + } +} + +/// A BODY or BODYSTRUCTURE information element +class BodyPart { + /// Children parts, if present + List? parts; + + /// A string giving the content id as defined in [MIME-IMB]. + String? cid; + + /// 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: base64, quoted-printable + 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; + + /// The content type information. + ContentTypeHeader? contentType; + + /// The content disposition information. + /// + /// This is constructed when querying BODYSTRUCTURE in a fetch. + ContentDispositionHeader? contentDisposition; + + /// The raw text of this body part. + /// + /// This is set when fetching the message contents e.g. with `BODY[]`. + String? bodyRaw; + + /// The envelope, only provided for message/rfc822 structures + Envelope? envelope; + + String? _fetchId; + + /// The ID for fetching this body part. + /// + /// e.g. `1.2` for a part that can then be fetched + /// with the criteria `BODY[1.2]`. + String? get fetchId => _fetchId ??= _getFetchId(); + + BodyPart? _parent; + + /// Adds the given [childPart] or a generated empty part at the end. + BodyPart addPart([BodyPart? childPart]) { + childPart ??= BodyPart(); + parts ??= []; + parts?.add(childPart); + childPart._parent = this; + + return childPart; + } + + @override + String toString() { + final buffer = StringBuffer(); + write(buffer); + + return buffer.toString(); + } + + /// Renders the message part into the given [buffer]. + void write(StringBuffer buffer, [String padding = '']) { + buffer + ..write(padding) + ..write('[') + ..write(fetchId) + ..write(']\n'); + final contentType = this.contentType; + if (contentType != null) { + buffer.write(padding); + contentType.render(buffer); + buffer.write('\n'); + } + final contentDisposition = this.contentDisposition; + if (contentDisposition != null) { + buffer.write(padding); + contentDisposition.render(buffer); + buffer.write('\n'); + } + final parts = this.parts; + if (parts != null && parts.isNotEmpty) { + buffer + ..write(padding) + ..write('[\n'); + var addComma = false; + for (final part in parts) { + if (addComma) { + buffer + ..write(padding) + ..write(',\n'); + } + part.write(buffer, '$padding '); + addComma = true; + } + buffer + ..write(padding) + ..write(']\n'); + } + } + + String? _getFetchId([String? tail]) { + final parent = _parent; + if (parent != null) { + final index = parent.parts?.indexOf(this) ?? 0; + var fetchIdPart = (index + 1).toString(); + // Rationale: if this part is a direct child of a message/rfc822 part and + // is also a multipart, the numeric fetchId will be overwritten + // with 'TEXT' + if (parent.contentType?.mediaType.sub == MediaSubtype.messageRfc822) { + if (contentType?.mediaType.top == MediaToptype.multipart) { + fetchIdPart = 'TEXT'; + } + } + + return parent + ._getFetchId(tail == null ? fetchIdPart : '$fetchIdPart.$tail'); + } else { + return tail; + } + } + + /// Adds the matching disposition header with the specified [disposition] + /// of this part and this children parts to the [result]. + /// + /// Optionally set [reverse] to `true` to add all parts that do not + /// match the specified `disposition`. + /// + /// All fetchId parsed from the `BODYSTRUCTURE` are returned in a form + /// compatible with the body parts tree unless [withCleanParts] is false. + /// + /// Set [complete] to `false` to skip the included rfc822 messages parts. + void collectContentInfo( + ContentDisposition disposition, + List result, { + bool? reverse, + bool? withCleanParts, + bool? complete, + }) { + reverse ??= false; + withCleanParts ??= true; + complete ??= true; + final isMessage = contentType?.mediaType.isMessage ?? false; + final fetchId = this.fetchId; + if (fetchId != null) { + if ((!reverse && contentDisposition?.disposition == disposition) || + (reverse && + contentDisposition?.disposition != disposition && + contentType?.mediaType.top != MediaToptype.multipart)) { + if (!withCleanParts || (withCleanParts && !fetchId.endsWith('.TEXT'))) { + final info = ContentInfo( + withCleanParts ? fetchId.replaceAll('.TEXT', '') : fetchId, + ) + ..contentDisposition = contentDisposition + ..contentType = contentType + ..cid = cid; + result.add(info); + } + } + } + if (!complete && + isMessage && + ((reverse && disposition == ContentDisposition.attachment) || + (!reverse && disposition == ContentDisposition.inline))) { + // abort to search for inline parts at messages, + // unless attachments are searched + return; + } + final parts = this.parts; + if (parts != null && parts.isNotEmpty) { + for (final part in parts) { + if ((disposition == ContentDisposition.attachment && + reverse && + part.contentDisposition?.disposition == + ContentDisposition.attachment) || + (disposition == ContentDisposition.inline && + !reverse && + part.contentDisposition?.disposition == + ContentDisposition.attachment)) { + // abort at attachments when inline parts are searched for + continue; + } + part.collectContentInfo( + disposition, + result, + reverse: reverse, + withCleanParts: withCleanParts, + complete: complete, + ); + } + } + } + + /// Finds the first body part with the given [subtype] media type. + BodyPart? findFirst(MediaSubtype subtype) { + if (contentType?.mediaType.sub == subtype) { + return this; + } + final parts = this.parts; + if (parts != null && parts.isNotEmpty) { + for (final part in parts) { + final first = part.findFirst(subtype); + if (first != null) { + return first; + } + } + } + + return null; + } + + /// Finds the child part matching the [partFetchId] + BodyPart? getChildPart(String partFetchId) { + final _fetchId = partFetchId.contains('.TEXT') + ? fetchId + : fetchId?.replaceAll('.TEXT', ''); + // Handle the searching for the .HEADER part of a nested rfc822 part + if (_fetchId == partFetchId || + _fetchId == partFetchId.replaceFirst('.HEADER', '')) { + return this; + } + final parts = this.parts; + if (parts != null) { + for (final part in parts) { + final match = part.getChildPart(partFetchId); + if (match != null) { + return match; + } + } + } + + return null; + } + + /// Finds the first part with the [partCid] content-ID + BodyPart? findFirstWithContentId(String partCid) { + if (cid == partCid) { + return this; + } + final parts = this.parts; + if (parts != null) { + for (final part in parts) { + final match = part.findFirstWithContentId(partCid); + if (match != null) { + return match; + } + } + } + + return null; + } + + /// Retrieves the number of nested parts + int get length => parts?.length ?? 0; + + /// Eases access to a nested part, same as accessing `parts[index]` + BodyPart operator [](int index) { + final parts = this.parts; + + return parts != null + ? parts.elementAt(index) + : throw RangeError('$index invalid for BodyPart with length of 0'); + } + + /// Retrieves all leaf parts, + /// ie all parts that have no children parts themselves. + /// + /// This can be useful to check all content parts of the message + List get allLeafParts { + final leafParts = []; + _addLeafParts(leafParts); + + return leafParts; + } + + void _addLeafParts(List leafParts) { + final myParts = parts; + if (myParts == null) { + leafParts.add(this); + + return; + } + for (final part in myParts) { + part._addLeafParts(leafParts); + } + } +} + +/// Contains the envelope information about a message. +class Envelope { + /// Creates a new Envelope + Envelope({ + this.date, + this.subject, + this.from, + this.sender, + this.replyTo, + this.to, + this.cc, + this.bcc, + this.inReplyTo, + this.messageId, + }); + + /// The receive date + DateTime? date; + + /// The message subject + String? subject; + + /// The from sender(s), usually only 1 entry + List? from; + + /// The sender, often the same as the first from + MailAddress? sender; + + /// The address for replying the associated message + List? replyTo; + + /// The to recipients + List? to; + + /// The cc recipients + List? cc; + + /// The bcc recipients + List? bcc; + + /// The ID of the message that the associated message is replied to + String? inReplyTo; + + /// The ID of the associated message + String? messageId; +} + +/// A parameter that may contain additional parameters +class ParameterizedHeader { + /// Creates a new header with the given [rawValue] + ParameterizedHeader(this.rawValue) { + final elements = rawValue.split(';'); + value = elements[0]; + for (var i = 1; i < elements.length; i++) { + final element = elements[i].trim(); + final splitPos = element.indexOf('='); + if (splitPos == -1) { + parameters[element.toLowerCase()] = ''; + } else { + final name = element.substring(0, splitPos).toLowerCase(); + final value = element.substring(splitPos + 1); + final valueWithoutQuotes = removeQuotes(value); + parameters[name] = valueWithoutQuotes; + } + } + } + + /// The raw value of the header + String rawValue; + + /// The value without parameters as specified in the header, + /// eg `text/plain` for a `Content-Type` header. + late String value; + + /// Any parameters, for example charset, boundary, filename, etc + final parameters = {}; + + /// Removes any double-quotes from the [value] when present. + String removeQuotes(String value) { + if (value.startsWith('"') && value.endsWith('"')) { + return value.substring(1, value.length - 1); + } + + return value; + } + + /// Renders the field with the given [name] and [value] to the [buffer]. + /// + /// Set [quote] to `true` to quote the value. + /// + /// When the [value] is `null`, nothing will be rendered. + void renderField( + String name, + String? value, + StringBuffer buffer, { + bool quote = false, + }) { + if (value == null) { + return; + } + buffer + ..write('; ') + ..write(name) + ..write('='); + if (quote) { + buffer.write('"'); + } + buffer.write(value); + if (quote) { + buffer.write('"'); + } + } + + /// Render the field with the given [name] and [date] value to the [buffer]. + void renderDateField(String name, DateTime? date, StringBuffer buffer) { + if (date == null) { + return; + } + renderField(name, DateCodec.encodeDate(date), buffer, quote: true); + } + + /// Renders all remaining fields + void renderRemainingFields(StringBuffer buffer, {List? exclude}) { + for (final key in parameters.keys) { + if (exclude == null || !exclude.contains(key.toLowerCase())) { + renderField(key, parameters[key], buffer, quote: false); + } + } + } + + /// Adds a new or replaces the existing parameter [name] + /// with the value [quotedValue]. + void setParameter(String name, String quotedValue) { + parameters[name] = quotedValue; + } +} + +/// Eases reading content-type header values +class ContentTypeHeader extends ParameterizedHeader { + /// Creates a new content type header + ContentTypeHeader(String rawValue) : super(rawValue) { + mediaType = MediaType.fromText(value); + charset = parameters['charset']?.toLowerCase(); + boundary = parameters['boundary']; + if (parameters.containsKey('format')) { + isFlowedFormat = parameters['format']!.toLowerCase() == 'flowed'; + } + } + + /// Creates a content type header from the given [mediaType]. + /// + /// Optionally specify the used [charset], [boundary] and + /// [isFlowedFormat] values. + ContentTypeHeader.from( + this.mediaType, { + String? charset, + this.boundary, + this.isFlowedFormat, + }) : super(mediaType.text) { + this.charset = charset?.toLowerCase(); + } + + /// The media type pf the content type header + late MediaType mediaType; + + /// the used charset like 'utf-8', + /// this is always converted to lowercase if present + String? charset; + + /// the boundary for content-type headers of `multipart`. + String? boundary; + + /// defines wether a `text/plain` content-header has a `flowed=true` + /// or semantically equivalent value. + bool? isFlowedFormat; + + /// Renders this field using the given [buffer] + String render([StringBuffer? buffer]) { + buffer ??= StringBuffer(); + buffer.write(value); + renderField('charset', charset, buffer, quote: true); + renderField('boundary', boundary, buffer, quote: true); + if (isFlowedFormat == true) { + renderField('format', 'flowed', buffer); + } + renderRemainingFields(buffer, exclude: ['charset', 'boundary', 'format']); + + return buffer.toString(); + } + + @override + void setParameter(String name, String quotedValue) { + final fieldName = name.toLowerCase(); + var value = quotedValue; + if (fieldName == 'charset') { + value = removeQuotes(quotedValue).toLowerCase(); + charset = value; + } else if (fieldName == 'boundary') { + value = removeQuotes(quotedValue); + boundary = value; + } else if (fieldName == 'format') { + value = removeQuotes(quotedValue).toLowerCase(); + isFlowedFormat = value == 'flowed'; + } + super.setParameter(fieldName, value); + } +} + +/// Specifies the content disposition of a mime part. +/// Compare https://tools.ietf.org/html/rfc2183 for details. +enum ContentDisposition { + /// The content should be shown inline within the message contents + inline, + + /// The content should be shown separately as an attachment + attachment, + + /// The disposition could not be recognized + other +} + +/// Specifies the content disposition header of a mime part. +/// Compare https://tools.ietf.org/html/rfc2183 for details. +class ContentDispositionHeader extends ParameterizedHeader { + /// Creates a new disposition header + ContentDispositionHeader(String rawValue) : super(rawValue) { + dispositionText = value; + switch (dispositionText.toLowerCase()) { + case 'inline': + disposition = ContentDisposition.inline; + break; + case 'attachment': + disposition = ContentDisposition.attachment; + break; + default: + disposition = ContentDisposition.other; + break; + } + + filename = MailCodec.decodeHeader(parameters['filename']); + creationDate = DateCodec.decodeDate(parameters['creation-date']); + modificationDate = DateCodec.decodeDate(parameters['modification-date']); + readDate = DateCodec.decodeDate(parameters['read-date']); + final sizeText = parameters['size']; + if (sizeText != null) { + size = int.tryParse(sizeText); + } + } + + /// Convenience method to create a `Content-Disposition` header + /// with the given [disposition]. + ContentDispositionHeader.from( + this.disposition, { + this.filename, + this.creationDate, + this.modificationDate, + this.readDate, + this.size, + }) : super(disposition == ContentDisposition.inline + ? 'inline' + : disposition == ContentDisposition.attachment + ? 'attachment' + : 'unsupported') { + dispositionText = disposition.name; + } + + /// Convenience method to create a `Content-Disposition: inline` header + ContentDispositionHeader.inline({ + String? filename, + DateTime? creationDate, + DateTime? modificationDate, + DateTime? readDate, + int? size, + }) : this.from( + ContentDisposition.inline, + filename: filename, + creationDate: creationDate, + modificationDate: modificationDate, + readDate: readDate, + size: size, + ); + + /// Convenience method to create a `Content-Disposition: attachment` header + ContentDispositionHeader.attachment({ + String? filename, + DateTime? creationDate, + DateTime? modificationDate, + DateTime? readDate, + int? size, + }) : this.from( + ContentDisposition.attachment, + filename: filename, + creationDate: creationDate, + modificationDate: modificationDate, + readDate: readDate, + size: size, + ); + + /// The disposition as text + late String dispositionText; + + /// The disposition + late ContentDisposition disposition; + + /// The optional file name parameter + String? filename; + + /// The optional creation date parameter + DateTime? creationDate; + + /// The optional modification date parameter + DateTime? modificationDate; + + /// The optional last accessed date parameter + DateTime? readDate; + + /// The optional size in bytes parameter + int? size; + + /// Renders this header into the given [buffer]. + String render([StringBuffer? buffer]) { + buffer ??= StringBuffer(); + buffer.write(dispositionText); + renderField('filename', filename, buffer, quote: true); + renderDateField('creation-date', creationDate, buffer); + renderDateField('modification-date', modificationDate, buffer); + renderDateField('read-date', readDate, buffer); + if (size != null) { + renderField('size', size.toString(), buffer); + } + renderRemainingFields(buffer, exclude: [ + 'filename', + 'creation-date', + 'modification-date', + 'read-date', + 'size', + ]); + + return buffer.toString(); + } + + @override + void setParameter(String name, String quotedValue) { + final fieldName = name.toLowerCase(); + var value = quotedValue; + if (fieldName == 'filename') { + value = removeQuotes(quotedValue); + filename = value; + } else if (fieldName == 'creation-date') { + value = removeQuotes(quotedValue); + creationDate = DateCodec.decodeDate(value); + } else if (fieldName == 'modification-date') { + value = removeQuotes(quotedValue); + modificationDate = DateCodec.decodeDate(value); + } else if (fieldName == 'read-date') { + value = removeQuotes(quotedValue); + readDate = DateCodec.decodeDate(value); + } else if (fieldName == 'size') { + size = int.tryParse(quotedValue); + } + super.setParameter(fieldName, value); + } +} + +/// Provides high level information about content parts. +/// +/// Compare `MimeMessage.listContentInfo()`. +class ContentInfo { + /// Creates a new content info + ContentInfo(this.fetchId); + + /// The fetch ID of the part associated with this content + final String fetchId; + + /// The disposition of this content (inline / attachment) + ContentDispositionHeader? contentDisposition; + + /// The type of this content, e.g. `text/plain` + ContentTypeHeader? contentType; + + /// The content-ID + String? cid; + String? _decodedFileName; + + /// The file name + String? get fileName => _decodedFileName ??= MailCodec.decodeHeader( + contentDisposition?.filename ?? contentType?.parameters['name'], + ); + + /// The size of the associated message part in bytes + int? get size => contentDisposition?.size; + + /// The media type of the associated message part + MediaType? get mediaType => contentType?.mediaType; + + /// Is the associated message part an image? + bool get isImage => mediaType?.top == MediaToptype.image; + + /// Is the associated message part a text? + bool get isText => mediaType?.top == MediaToptype.text; + + /// Is the associated message part a model? + bool get isModel => mediaType?.top == MediaToptype.model; + + /// Is the associated message part an audio recording? + bool get isAudio => mediaType?.top == MediaToptype.audio; + + /// Is the associated message part an application-specific part like json? + bool get isApplication => mediaType?.top == MediaToptype.application; + + /// Is the associated message part a font? + bool get isFont => mediaType?.top == MediaToptype.font; + + /// Is the associated message part a message itself? + bool get isMessage => mediaType?.top == MediaToptype.message; + + /// Is the associated message part a video? + bool get isVideo => mediaType?.top == MediaToptype.video; + + /// Is the associated message part a multipart ie contains further parts? + bool get isMultipart => mediaType?.top == MediaToptype.multipart; + + /// Is the associated message part of unknown media type? + bool get isOther => mediaType?.top == MediaToptype.other; +} + +/// Abstract a mime message thread +/// +/// Compare `MailClient.fetchThreadedMessages` for fetching message threads. +class MimeThread { + /// Creates a new thread from the given [sequence] + /// with the pre-fetched [messages]. + MimeThread(this.sequence, this.messages) + : ids = sequence.toList(), + assert( + messages.isNotEmpty, + 'each thread requires at least one message entry, check the ' + 'messages argument, which is empty'), + assert( + sequence.isNotEmpty, + 'each thread requires at least one sequence entry, check the ' + 'sequence argument, which is empty'); + + /// The full sequence for this thread + final MessageSequence sequence; + + /// The IDs of the message sequence + final List ids; + + /// The length of this thread + int get length => ids.length; + + /// The fetched messages of this thread + final List messages; + + /// The latest message in this thread + MimeMessage get latest => messages.last; + + /// Checks if this thread contains more messages than are already fetched + bool get hasMoreMessages => length > messages.length; + + /// Retrieves the sequence for any messages that have not yet been loaded. + /// + /// Use [hasMoreMessages] to check if there are indeed any messages missing. + MessageSequence get missingMessageSequence { + if (length == 0) { + return sequence; + } + final isUid = sequence.isUidSequence; + final missingIds = ids + .where((id) => messages.any( + (message) => isUid ? message.uid == id : message.sequenceId == id, + )) + .toList(); + final missing = MessageSequence.fromIds(missingIds, isUid: isUid); + + return missing; + } +} diff --git a/packages/enough_mail/lib/src/pop/pop_client.dart b/packages/enough_mail/lib/src/pop/pop_client.dart new file mode 100644 index 0000000..18bde01 --- /dev/null +++ b/packages/enough_mail/lib/src/pop/pop_client.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:event_bus/event_bus.dart'; + +import '../mime_message.dart'; +import '../private/pop/commands/all_commands.dart'; +import '../private/pop/parsers/pop_standard_parser.dart'; +import '../private/pop/pop_command.dart'; +import '../private/util/client_base.dart'; +import '../private/util/uint8_list_reader.dart'; +import 'pop_events.dart'; +import 'pop_exception.dart'; +import 'pop_response.dart'; + +/// Client to access POP3 compliant servers. +/// Compare https://tools.ietf.org/html/rfc1939 for details. +class PopClient extends ClientBase { + /// Creates a new PopClient + /// + /// Set the [eventBus] to add your specific `EventBus` to listen to POP events + /// + /// Set [isLogEnabled] to `true` to see log output. + /// + /// Set the [logName] for adding the name to each log entry. + /// + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + PopClient({ + EventBus? bus, + bool isLogEnabled = false, + String? logName, + bool Function(X509Certificate)? onBadCertificate, + }) : _eventBus = bus ?? EventBus(), + super( + isLogEnabled: isLogEnabled, + logName: logName, + onBadCertificate: onBadCertificate, + ); + + /// Allows to listens for events + /// + /// If no event bus is specified in the constructor, + /// an asynchronous bus is used. + /// Usage: + /// ``` + /// eventBus.on().listen((event) { + /// // All events are of type SmtpConnectionLostEvent (or subtypes of it). + /// _log(event.type); + /// }); + /// + /// eventBus.on().listen((event) { + /// // All events are of type SmtpEvent (or subtypes of it). + /// _log(event.type); + /// }); + /// ``` + EventBus get eventBus => _eventBus; + final EventBus _eventBus; + + final Uint8ListReader _uint8listReader = Uint8ListReader(); + PopCommand? _currentCommand; + String? _currentFirstResponseLine; + final PopStandardParser _standardParser = PopStandardParser(); + + /// Information about the remote POP server + late PopServerInfo serverInfo; + + @override + FutureOr onConnectionEstablished( + ConnectionInfo connectionInfo, + String serverGreeting, + ) { + if (serverGreeting.startsWith('+OK')) { + final chunks = serverGreeting.split(' '); + serverInfo = PopServerInfo(chunks.last.trimRight()); + } else { + serverInfo = PopServerInfo(''); + } + } + + @override + void onConnectionError(dynamic error) { + eventBus.fire(PopConnectionLostEvent(this)); + } + + @override + void onDataReceived(Uint8List data) { + _uint8listReader.add(data); + _currentFirstResponseLine ??= _uint8listReader.readLine(); + final currentLine = _currentFirstResponseLine; + if (currentLine != null && currentLine.startsWith('-ERR')) { + onServerResponse([currentLine]); + + return; + } + if (_currentCommand?.isMultiLine ?? false) { + final lines = _uint8listReader.readLinesToCrLfDotCrLfSequence(); + if (lines != null) { + if (currentLine != null) { + lines.insert(0, currentLine); + } + onServerResponse(lines); + } + } else if (currentLine != null) { + onServerResponse([currentLine]); + } + } + + /// 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 { + await sendCommand(PopStartTlsCommand()); + log('STTL: upgrading socket to secure one...', initial: 'A'); + await upgradeToSslSocket(); + } + + /// Logs the user in with the default `USER` and `PASS` commands. + Future login(String name, String password) async { + await sendCommand(PopUserCommand(name)); + await sendCommand(PopPassCommand(password)); + isLoggedIn = true; + } + + /// Logs the user in with the `APOP` command. + Future loginWithApop(String name, String password) async { + await sendCommand(PopApopCommand(name, password, serverInfo.timestamp)); + isLoggedIn = true; + } + + /// Ends the POP session. + /// + /// Also removes any messages that have been marked as deleted + Future quit() async { + await sendCommand(PopQuitCommand(this)); + isLoggedIn = false; + } + + /// Checks the status ie the total number of messages and their size + Future status() => sendCommand(PopStatusCommand()); + + /// Checks the ID and size of all messages + /// or of the message with the specified [messageId] + Future> list([int? messageId]) => + sendCommand(PopListCommand(messageId)); + + /// Checks the ID and UID of all messages + /// or of the message with the specified [messageId] + /// + /// This command is optional and may not be supported by all servers. + Future> uidList([int? messageId]) => + sendCommand(PopUidListCommand(messageId)); + + /// Downloads the message with the specified [messageId] + Future retrieve(int messageId) => + sendCommand(PopRetrieveCommand(messageId)); + + /// Downloads the first [numberOfLines] lines of the message + /// with the given [messageId] + Future retrieveTopLines(int messageId, int numberOfLines) => + sendCommand(PopTopCommand(messageId, numberOfLines)); + + /// Marks the message with the specified [messageId] as deleted + Future delete(int messageId) => + sendCommand(PopDeleteCommand(messageId)); + + /// Keeps any messages that are marked as deleted + Future reset() => sendCommand(PopResetCommand()); + + /// Keeps the connection alive + Future noop() => sendCommand(PopNoOpCommand()); + + /// Sends the specified command to the remote POP server + Future sendCommand(PopCommand command) { + _currentCommand = command; + _currentFirstResponseLine = null; + writeText(command.command, command); + + return command.completer.future; + } + + /// Processes server responses + void onServerResponse(List responseTexts) { + if (isLogEnabled) { + for (final responseText in responseTexts) { + log(responseText, isClient: false); + } + } + final command = _currentCommand; + if (command == null) { + print('ignoring response starting with [${responseTexts.first}] ' + 'with ${responseTexts.length} lines.'); + } + if (command != null) { + var parser = command.parser; + parser ??= _standardParser; + final response = parser.parse(responseTexts); + final commandText = command.nextCommand(response); + if (commandText != null) { + writeText(commandText); + } else if (command.isCommandDone(response)) { + if (response.isFailedStatus) { + command.completer.completeError(PopException(this, response)); + } else { + command.completer.complete(response.result); + } + //_log("Done with command ${_currentCommand.command}"); + _currentCommand = null; + } + } + } + + @override + Object createClientError(String message) => + PopException.message(this, message); +} diff --git a/packages/enough_mail/lib/src/pop/pop_events.dart b/packages/enough_mail/lib/src/pop/pop_events.dart new file mode 100644 index 0000000..9a3d1a5 --- /dev/null +++ b/packages/enough_mail/lib/src/pop/pop_events.dart @@ -0,0 +1,29 @@ +import 'pop_client.dart'; + +/// Common POP event types +enum PopEventType { + /// Connection to remote service is lost ie due to a network error + connectionLost, + + /// Unrecognized error + unknown +} + +/// Base event class +abstract class PopEvent { + /// Creates a new event + PopEvent(this.popClient, this.type); + + /// The type of the event + final PopEventType type; + + /// The client triggering the event + final PopClient popClient; +} + +/// Informs about a lost connection +class PopConnectionLostEvent extends PopEvent { + /// Creates a connection lost event + PopConnectionLostEvent(PopClient popClient) + : super(popClient, PopEventType.connectionLost); +} diff --git a/packages/enough_mail/lib/src/pop/pop_exception.dart b/packages/enough_mail/lib/src/pop/pop_exception.dart new file mode 100644 index 0000000..c3069ee --- /dev/null +++ b/packages/enough_mail/lib/src/pop/pop_exception.dart @@ -0,0 +1,46 @@ +import 'pop_client.dart'; +import 'pop_response.dart'; + +/// Informs about an exceptional case when dealing with a POP service +class PopException implements Exception { + /// Creates a new pop exception + PopException(this.popClient, this.response, {this.stackTrace}) + : _message = response.toString(); + + /// Creates a new POP exception with the given message + PopException.message(this.popClient, String message) + : response = PopResponse(isOkStatus: false, result: message), + stackTrace = null, + _message = message; + + /// The originating client + final PopClient popClient; + + /// The response from the POP server + final PopResponse response; + + final String _message; + + /// The message + String get message => _message; + + /// The stacktrace, if known + final StackTrace? stackTrace; + + @override + String toString() { + final buffer = StringBuffer()..write('PopException'); + if (response.result != null) { + buffer + ..write('\n') + ..write(response.result); + } + if (stackTrace != null) { + buffer + ..write('\n') + ..write(stackTrace); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/pop/pop_response.dart b/packages/enough_mail/lib/src/pop/pop_response.dart new file mode 100644 index 0000000..b27f6f3 --- /dev/null +++ b/packages/enough_mail/lib/src/pop/pop_response.dart @@ -0,0 +1,54 @@ +/// Provides access to a POP response coming from the POP service +class PopResponse { + /// Creates a new response + PopResponse({this.isOkStatus = false, this.result}); + + /// Is the response indicating success? + bool isOkStatus; + + /// Is this is failed response? + bool get isFailedStatus => !isOkStatus; + + /// The result of the response + T? result; +} + +/// Provides status information about a POP service +class PopStatus { + /// Creates a new status + PopStatus(this.numberOfMessages, this.totalSizeInBytes); + + /// The number of available messages + final int numberOfMessages; + + /// The total used size in bytes + final int totalSizeInBytes; +} + +/// Basic information about a message +class MessageListing { + /// Creates a new listing + MessageListing({ + required this.id, + required this.sizeInBytes, + this.uid, + }); + + /// The message ID + final int id; + + /// The message UID + final String? uid; + + /// The message size in bytes + final int sizeInBytes; +} + +/// The server information +class PopServerInfo { + /// Creates a new server info instance + PopServerInfo(this.timestamp); + + /// The timestamp value + final String timestamp; +} diff --git a/packages/enough_mail/lib/src/private/imap/all_parsers.dart b/packages/enough_mail/lib/src/private/imap/all_parsers.dart new file mode 100644 index 0000000..e6440ba --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/all_parsers.dart @@ -0,0 +1,15 @@ +export 'enable_parser.dart'; +export 'fetch_parser.dart'; +export 'generic_parser.dart'; +export 'id_parser.dart'; +export 'list_parser.dart'; +export 'logout_parser.dart'; +export 'meta_data_parser.dart'; +export 'no_response_parser.dart'; +export 'noop_parser.dart'; +export 'quota_parser.dart'; +export 'search_parser.dart'; +export 'select_parser.dart'; +export 'sort_parser.dart'; +export 'status_parser.dart'; +export 'thread_parser.dart'; diff --git a/packages/enough_mail/lib/src/private/imap/capability_parser.dart b/packages/enough_mail/lib/src/private/imap/capability_parser.dart new file mode 100644 index 0000000..8009391 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/capability_parser.dart @@ -0,0 +1,74 @@ +import '../../imap/imap_client.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses IMAP capability responses +class CapabilityParser extends ResponseParser> { + /// Creates a new parser + CapabilityParser(this.info); + + /// The server information + final ImapServerInfo info; + + List? _capabilities; + + @override + List? parse( + ImapResponse imapResponse, + Response> response, + ) { + if (response.isOkStatus) { + if (imapResponse.parseText.startsWith('OK [CAPABILITY ')) { + parseCapabilities( + imapResponse.first.line ?? '', + 'OK [CAPABILITY '.length, + info, + ); + _capabilities = info.capabilities; + } + + return _capabilities ?? []; + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response>? response, + ) { + final line = imapResponse.parseText; + if (line.startsWith('OK [CAPABILITY ')) { + parseCapabilities(line, 'OK [CAPABILITY '.length, info); + _capabilities = info.capabilities; + + return true; + } else if (line.startsWith('CAPABILITY ')) { + parseCapabilities(line, 'CAPABILITY '.length, info); + _capabilities = info.capabilities; + + return true; + } + + return super.parseUntagged(imapResponse, response); + } + + /// Parses capabilities from the given text + static void parseCapabilities( + String details, + int startIndex, + ImapServerInfo info, + ) { + final closeIndex = details.lastIndexOf(']'); + String capText; + capText = closeIndex == -1 + ? details.substring(startIndex) + : details.substring(startIndex, closeIndex); + info.capabilitiesText = capText; + final capNames = capText.split(' '); + final caps = capNames.map(Capability.new).toList(); + info.capabilities = caps; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/command.dart b/packages/enough_mail/lib/src/private/imap/command.dart new file mode 100644 index 0000000..4925803 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/command.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Contains an IMAP command +class Command { + /// Creates a new command + Command( + this.commandText, { + this.logText, + this.parts, + this.writeTimeout, + this.responseTimeout, + }); + + /// Creates a new multiline command + Command.withContinuation( + List parts, { + String? logText, + Duration? writeTimeout, + Duration? responseTimeout, + }) : this( + parts.first, + parts: parts, + logText: logText, + writeTimeout: writeTimeout, + responseTimeout: responseTimeout, + ); + + /// The command text + final String commandText; + + /// The optional log text without sensitive data + final String? logText; + + /// The optional command parts for multiline-requests + final List? parts; + + /// The current part index of multiline-requests + int _currentPartIndex = 1; + + /// The command specific write timeout + final Duration? writeTimeout; + + /// The command specific response timeout + final Duration? responseTimeout; + + @override + String toString() => logText ?? commandText; + + /// Some commands need to be send in chunks + String? getContinuationResponse(ImapResponse imapResponse) { + final parts = this.parts; + if (parts == null || _currentPartIndex >= parts.length) { + return null; + } + final nextPart = parts[_currentPartIndex]; + _currentPartIndex++; + + return nextPart; + } +} + +/// Contains an IMAP command task +class CommandTask { + /// Creates a new task + CommandTask(this.command, this.id, this.parser); + + /// The command + final Command command; + + /// The ID to identify the command in responses + final String id; + + /// The associated response parser + final ResponseParser parser; + + /// Contains the response + final Response response = Response(); + + /// Completer for this task + final Completer completer = Completer(); + @override + String toString() => '$id $command'; + + /// Retrieves the IMAP request to send + String get imapRequest => '$id ${command.commandText}'; + + /// Parses the response + Response parse(ImapResponse imapResponse) { + if (imapResponse.parseText.startsWith('OK ')) { + response.status = ResponseStatus.ok; + } else if (imapResponse.parseText.startsWith('NO ')) { + response + ..status = ResponseStatus.no + ..details = imapResponse.parseText.length > 3 + ? imapResponse.parseText.substring(3) + : imapResponse.parseText; + } else { + response + ..status = ResponseStatus.bad + ..details = imapResponse.parseText; + } + response.result = parser.parse(imapResponse, response); + + return response; + } + + /// Parses the untagged response + bool parseUntaggedResponse(ImapResponse details) => + parser.parseUntagged(details, response); +} diff --git a/packages/enough_mail/lib/src/private/imap/enable_parser.dart b/packages/enough_mail/lib/src/private/imap/enable_parser.dart new file mode 100644 index 0000000..ae3e58a --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/enable_parser.dart @@ -0,0 +1,48 @@ +import '../../imap/imap_client.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses responses to IMAP ENABLE command +class EnableParser extends ResponseParser> { + /// Creates a new parser + EnableParser(this.info); + + /// Information about the remote service + final ImapServerInfo info; + + @override + List? parse( + ImapResponse imapResponse, + Response> response, + ) { + if (response.isOkStatus) { + return info.enabledCapabilities; + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response>? response, + ) { + final line = imapResponse.parseText; + if (line.startsWith('ENABLED ')) { + parseCapabilities(line, 'ENABLED '.length); + + return true; + } + + return super.parseUntagged(imapResponse, response); + } + + /// Parses the capabilities from the given [details] + void parseCapabilities(String details, int startIndex) { + final capText = details.substring(startIndex); + final capNames = capText.split(' '); + final caps = capNames.map(Capability.new); + info.enabledCapabilities.addAll(caps); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/fetch_parser.dart b/packages/enough_mail/lib/src/private/imap/fetch_parser.dart new file mode 100644 index 0000000..046281b --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/fetch_parser.dart @@ -0,0 +1,656 @@ +import '../../codecs/date_codec.dart'; +import '../../codecs/mail_codec.dart'; +import '../../imap/message_sequence.dart'; +import '../../imap/response.dart'; +import '../../mail_address.dart'; +import '../../media_type.dart'; +import '../../mime_data.dart'; +import '../../mime_message.dart'; +import 'imap_response.dart'; +import 'parser_helper.dart'; +import 'response_parser.dart'; + +/// Parses FETCH IMAP responses +class FetchParser extends ResponseParser { + /// Creates a new parser + FetchParser({required this.isUidFetch}); + + final List _messages = []; + + /// The most recent message that has been parsed + MimeMessage? lastParsedMessage; + + /// The most recent VANISHED response + MessageSequence? vanishedMessages; + + /// The modified sequence if defined in the FETCH response + MessageSequence? modifiedSequence; + + /// Is the FETCH request based on UIDs instead of sequence-IDs? + final bool isUidFetch; + + @override + FetchImapResult? parse( + ImapResponse imapResponse, + Response response, + ) { + final text = imapResponse.parseText; + final modifiedIndex = text.indexOf('[MODIFIED '); + if (modifiedIndex != -1) { + final modifiedEntries = ParserHelper.parseListIntEntries( + text, + modifiedIndex + '[MODIFIED '.length, + ']', + ',', + ); + if (modifiedEntries != null) { + modifiedSequence = + MessageSequence.fromIds(modifiedEntries, isUid: isUidFetch); + } + } + final vanishedMessages = this.vanishedMessages; + if (response.isOkStatus || + _messages.isNotEmpty || + (vanishedMessages != null && vanishedMessages.isNotEmpty)) { + return FetchImapResult( + _messages, + vanishedMessages, + modifiedSequence: modifiedSequence, + ); + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final firstLine = imapResponse.first.line; + if (firstLine == null) { + return false; + } + final fetchIndex = firstLine.indexOf(' FETCH '); + lastParsedMessage = null; + if (fetchIndex != -1) { + // eg "* 2389 FETCH (...)" + final sequenceId = parseInt(firstLine, 2, ' '); + MimeMessage message; + if (_messages.isNotEmpty && _messages.last.sequenceId == sequenceId) { + message = _messages.last; + } else { + message = MimeMessage()..sequenceId = sequenceId; + _messages.add(message); + } + lastParsedMessage = message; + final iterator = imapResponse.iterate(); + for (final value in iterator.values) { + if (value.value == 'FETCH') { + _parseFetch(message, value, imapResponse); + } + } + + return true; + } else if (firstLine.startsWith('* VANISHED (EARLIER) ')) { + final parseText = imapResponse.parseText; + + final messageSequenceText = parseText.startsWith('*') + ? parseText.substring('* VANISHED (EARLIER) '.length) + : parseText.substring('VANISHED (EARLIER) '.length); + vanishedMessages = + MessageSequence.parse(messageSequenceText, isUidSequence: true); + + return true; + } + + return super.parseUntagged(imapResponse, response); + } + + void _parseFetch( + MimeMessage message, + ImapValue fetchValue, + ImapResponse imapResponse, + ) { + final children = fetchValue.children ?? []; + for (var i = 0; i < children.length; i++) { + final child = children[i]; + final hasNext = i < children.length - 1; + switch (child.value) { + case 'UID': + if (hasNext) { + message.uid = int.parse(children[i + 1].value ?? '-1'); + i++; + } + break; + case 'MODSEQ': + if (hasNext && (children[i + 1].children?.length == 1)) { + message.modSequence = + int.tryParse(children[i + 1].children?[0].value ?? ''); + i++; + } + break; + case 'FLAGS': + message.flags = List.from( + child.children?.map((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 ?? '-1'); + i++; + } + break; + case 'ENVELOPE': + _parseEnvelope(message, child); + break; + case 'BODY': + _parseBody(message, child); + break; + case 'BODYSTRUCTURE': + _parseBodyStructure(message, child); + break; + case 'BODY[HEADER]': + case 'RFC822.HEADER': + if (hasNext) { + i++; + _parseBodyHeader(message, children[i]); + } + break; + case 'BODY[TEXT]': + case 'RFC822.TEXT': + if (hasNext) { + i++; + _parseBodyText(message, children[i]); + } + break; + case 'BODY[]': + case 'RFC822': + if (hasNext) { + i++; + _parseBodyFull(message, children[i]); + } + break; + default: + final value = child.value; + if (hasNext && + value != null && + value.startsWith('BODY[') && + value.endsWith(']')) { + i++; + _parseBodyPart(message, value, children[i]); + } else { + print( + 'fetch: encountered unexpected/unsupported element ' + '${child.value} at $i in ${imapResponse.parseText}', + ); + } + } + } + } + + /// Parse a body part + /// + /// parses elements starting with `BODY[`, excluding `BODY[]` and + /// `BODY[HEADER]` which are handled separately + /// e.g. `BODY[0]` or `BODY[HEADER.FIELDS (REFERENCES)]` + void _parseBodyPart( + MimeMessage message, + String bodyPartDefinition, + ImapValue imapValue, + ) { + // this matches + // BODY[HEADER.FIELDS (name1,name2)], as well as + // BODY[HEADER.FIELDS.NOT (name1,name2)] + if (bodyPartDefinition.startsWith('BODY[HEADER.FIELDS')) { + _parseBodyHeader(message, imapValue); + } else { + const startIndex = 'BODY['.length; + final endIndex = bodyPartDefinition.length - 1; + final fetchId = bodyPartDefinition.substring(startIndex, endIndex); + final part = MimePart(); + final value = imapValue.value; + final data = imapValue.data; + if (value != null) { + part.mimeData = TextMimeData(value, containsHeader: false); + } else if (data != null) { + part.mimeData = BinaryMimeData(data, containsHeader: false); + } + part.parse(); + //print('$fetchId: results in [${imapValue.value}]'); + message.setPart(fetchId.replaceFirst('.HEADER', ''), part); + } + } + + void _parseBodyFull(MimeMessage message, ImapValue bodyValue) { + //print("Parsing BODY[]\n[${bodyValue.value}]"); + final data = bodyValue.data; + final value = bodyValue.value; + if (data != null) { + message.mimeData = BinaryMimeData(data, containsHeader: true); + } else if (value != null) { + message.mimeData = TextMimeData(value, containsHeader: true); + //print("Parsing BODY text \n$bodyText"); + } + // ensure all headers are set: + message.parse(); + } + + HeaderParseResult _parseBodyHeader( + MimeMessage message, + ImapValue headerValue, + ) { + //print('Parsing BODY[HEADER]\n[${headerValue.value}]'); + final headerParseResult = + ParserHelper.parseHeader(headerValue.valueOrDataText ?? ''); + message.headers = headerParseResult.headersList; + + return headerParseResult; + } + + void _parseBodyText(MimeMessage message, ImapValue textValue) { + //print('Parsing BODY[TEXT]\n[${textValue.value}]'); + final data = textValue.data; + message.mimeData = data != null + ? BinaryMimeData(data, containsHeader: false) + : TextMimeData(textValue.value ?? '', containsHeader: false); + } + + /// Also compare: + /// * http://sgerwk.altervista.org/imapbodystructure.html + /// * https://tools.ietf.org/html/rfc3501#section-7.4.2 + /// * http://hea-www.cfa.harvard.edu/~fine/opinions/IMAPsucks.html + void _parseBodyRecursive(BodyPart body, ImapValue bodyValue) { + // print('_parseBodyRecursive from $bodyValue'); + var isMultipartSubtypeSet = false; + var multipartChildIndex = -1; + final children = bodyValue.children ?? []; + if (children.length >= 7 && children[0].children == null) { + // this is a direct type: + final parsed = _parseBodyStructureFrom(children); + body + ..bodyRaw = parsed.bodyRaw + ..contentDisposition = parsed.contentDisposition + ..contentType = parsed.contentType + ..description = parsed.description + ..encoding = parsed.encoding + ..envelope = parsed.envelope + ..cid = parsed.cid + ..numberOfLines = parsed.numberOfLines + ..size = parsed.size; + + return; + } + for (var childIndex = 0; childIndex < children.length; childIndex++) { + final child = children[childIndex]; + final grandchildren = child.children; + if (child.value == null && + grandchildren != null && + grandchildren.isNotEmpty && + grandchildren.first.value == null) { + // this is a nested structure + final part = BodyPart(); + body.addPart(part); + _parseBodyRecursive(part, child); + } else if (!isMultipartSubtypeSet && + grandchildren != null && + grandchildren.length >= 7) { + // TODO just counting cannot be a big enough indicator, + // compare for example + // ""mixed" ("charset" "utf8" "boundary" "cs2da2ss7EsqRfMsG")" + // this is a structure value + final structures = grandchildren; + final part = _parseBodyStructureFrom(structures); + body.addPart(part); + } else if (!isMultipartSubtypeSet) { + // this is the type: + isMultipartSubtypeSet = true; + multipartChildIndex = childIndex; + body.contentType = + ContentTypeHeader('multipart/${child.value?.toLowerCase()}'); + } else if (childIndex == multipartChildIndex + 1 && + grandchildren != null && + grandchildren.length > 1) { + final parameters = grandchildren; + for (var i = 0; i < parameters.length; i += 2) { + body.contentType?.setParameter( + parameters[i].value ?? '', + parameters[i + 1].valueOrDataText ?? '', + ); + } + } + } + } + + BodyPart _parseBodyStructureFrom(List structures) { + final size = int.tryParse(structures[6].value ?? ''); + final mediaType = + MediaType.fromText('${structures[0].value}/${structures[1].value}'); + final part = BodyPart() + ..cid = _checkForNil(structures[3].value) + ..description = _checkForNil(structures[4].value) + ..encoding = _checkForNil(structures[5].value)?.toLowerCase() + ..size = size + ..contentType = ContentTypeHeader.from(mediaType); + final contentTypeParameters = structures[2].children; + if (contentTypeParameters != null && contentTypeParameters.length > 1) { + for (var i = 0; i < contentTypeParameters.length; i += 2) { + final name = contentTypeParameters[i].value; + final value = contentTypeParameters[i + 1].valueOrDataText; + // print('content-type: $name=$value'); + if (name != null && value != null) { + part.contentType?.setParameter(name, value); + } + } + } + var startIndex = 7; + if (mediaType.isText && + structures.length > 7 && + structures[7].value != null) { + part.numberOfLines = int.tryParse(structures[7].value ?? ''); + startIndex = 8; + } else if (mediaType.isMessage && + mediaType.sub == MediaSubtype.messageRfc822) { + // [7] + // A body type of type MESSAGE and subtype RFC822 contains, + // immediately after the basic fields, the envelope structure, + // body structure, and size in text lines of the encapsulated + // message. + if (structures.length > 9) { + part.envelope = _parseEnvelope(null, structures[7]); + final child = BodyPart(); + part.addPart(child); + _parseBodyRecursive(child, structures[8]); + part.numberOfLines = int.tryParse(structures[9].value ?? ''); + } + startIndex += 3; + } + if ((structures.length > startIndex + 1) && + (structures[startIndex + 1].children?.isNotEmpty ?? false)) { + // read content disposition + // example: [attachment, [filename, testImage.jpg, + // modification-date, Fri, 27 Jan 2017 16:34:4 +0100, size, 13390]] + final parts = structures[startIndex + 1].children ?? []; + if (parts[0].value != null) { + final contentDisposition = + ContentDispositionHeader(parts[0].value?.toLowerCase() ?? ''); + final parameters = parts[1].children; + if (parameters != null && parameters.length > 1) { + for (var i = 0; i < parameters.length; i += 2) { + final name = parameters[i].value; + final value = parameters[i + 1].valueOrDataText; + if (name != null && value != null) { + // print('content-disposition: $name=$value'); + contentDisposition.setParameter(name, value); + } + } + } + part.contentDisposition = contentDisposition; + } else { + print('Unable to parse content disposition from:'); + print(parts); + } + } + + return part; + } + + void _parseBody(MimeMessage message, ImapValue bodyValue) { + // A parenthesized list that describes the [MIME-IMB] body + // structure of a message. This is computed by the server by + // parsing the [MIME-IMB] header fields, defaulting various fields + // as necessary. + + // For example, a simple text message of 48 lines and 2279 octets + // can have a body structure of: ("TEXT" "PLAIN" ("CHARSET" + // "US-ASCII") NIL NIL "7BIT" 2279 48) + + // Multiple parts are indicated by parenthesis nesting. Instead + // of a body type as the first element of the parenthesized list, + // there is a sequence of one or more nested body structures. The + // second element of the parenthesized list is the multipart + // subtype (mixed, digest, parallel, alternative, etc.). + + // For example, a two part message consisting of a text and a + // BASE64-encoded text attachment can have a body structure of: + // (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 + // 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff") + // "<960723163407.20117h@cac.washington.edu>" "Compiler diff" + // "BASE64" 4554 73) "MIXED") + + // [0]body type + // A string giving the content media type name as defined in + // [MIME-IMB]. + + // [1]body subtype + // A string giving the content subtype name as defined in + // [MIME-IMB]. + + // [2] body parameter parenthesized list + // A parenthesized list of attribute/value pairs [e.g., ("foo" + // "bar" "baz" "rag") where "bar" is the value of "foo" and + // "rag" is the value of "baz"] as defined in [MIME-IMB]. + + // [3]body id + // A string giving the content id as defined in [MIME-IMB]. + + // [4]body description + // A string giving the content description as defined in + // [MIME-IMB]. + + // [5]body encoding + // A string giving the content transfer encoding as defined in + // [MIME-IMB]. + + // [6]body size + // A number giving the size of the body in octets. Note that + // this size is the size in its transfer encoding and not the + // resulting size after any decoding. + + // [7] + // A body type of type MESSAGE and subtype RFC822 contains, + // immediately after the basic fields, the envelope structure, + // body structure, and size in text lines of the encapsulated + // message. + + // A body type of type TEXT contains, immediately after the basic + // fields, the size of the body in text lines. Note that this + // size is the size in its content transfer encoding and not the + // resulting size after any decoding. + + // Extension data follows the multipart subtype. Extension data + // is never returned with the BODY fetch, but can be returned with + // a BODYSTRUCTURE fetch. Extension data, if present, MUST be in + // the defined order. The extension data of a multipart body part + // are in the following order: + + // [7 / 8] + // body parameter parenthesized list + // A parenthesized list of attribute/value pairs [e.g., ("foo" + // "bar" "baz" "rag") where "bar" is the value of "foo", and + // "rag" is the value of "baz"] as defined in [MIME-IMB]. + + // [8 / 9] + // body disposition + // A parenthesized list, consisting of a disposition type + // string, followed by a parenthesized list of disposition + // attribute/value pairs as defined in [DISPOSITION]. + + // [9 / 10] + // body language + // A string or parenthesized list giving the body language + // value as defined in [LANGUAGE-TAGS]. + + // [10 / 11] + // body location + // A string list giving the body content URI as defined in + // [LOCATION]. + // + // + // The extension data of a non-multipart body part are in the + // following order: + + // [7 / 8] + // body MD5 + // A string giving the body MD5 value as defined in [MD5]. + // + // [8 / 9] + // body disposition + // A parenthesized list with the same content and function as + // the body disposition for a multipart body part. + + // [9 / 10] + // body language + // A string or parenthesized list giving the body language + // value as defined in [LANGUAGE-TAGS]. + + // [10 / 11] + // body location + // A string list giving the body content URI as defined in + // [LOCATION]. + //print('body: $bodyValue'); + final body = BodyPart(); + _parseBodyRecursive(body, bodyValue); + message.body = body; + } + + void _parseBodyStructure(MimeMessage message, ImapValue bodyValue) { + //print('bodystructure: $bodyValue'); + _parseBody(message, bodyValue); + } + + /// parses the envelope structure of a message + Envelope? _parseEnvelope(MimeMessage? message, ImapValue envelopeValue) { + // The fields of the envelope structure are in the following + // order: [0] date, [1]subject, [2]from, [3]sender, [4]reply-to, [5]to, + // [6]cc, [7]bcc, [8]in-reply-to, and [9]message-id. + // + // The date, subject, in-reply-to, + // and message-id fields are strings. The from, sender, reply-to, + // to, cc, and bcc fields are parenthesized lists of address + // structures. + + // If the Date, Subject, In-Reply-To, and Message-ID header lines + // are absent in the [RFC-2822] header, the corresponding member + // of the envelope is NIL; if these header lines are present but + // empty the corresponding member of the envelope is the empty + // string. + Envelope? envelope; + final children = envelopeValue.children; + //print("envelope: $children"); + if (children != null && children.length >= 10) { + final rawDate = _checkForNil(children[0].value); + final rawSubject = _checkForNil(children[1].valueOrDataText); + envelope = Envelope() + ..date = rawDate != null ? DateCodec.decodeDate(rawDate) : null + ..subject = + rawSubject != null ? MailCodec.decodeHeader(rawSubject) : null + ..from = _parseAddressList(children[2]) + ..sender = _parseAddressListFirst(children[3]) + ..replyTo = _parseAddressList(children[4]) + ..to = _parseAddressList(children[5]) + ..cc = _parseAddressList(children[6]) + ..bcc = _parseAddressList(children[7]) + ..inReplyTo = _checkForNil(children[8].value) + ..messageId = _checkForNil(children[9].value); + if (message != null) { + message.envelope = envelope; + if (rawDate != null) { + message.addHeader('Date', rawDate); + } + if (rawSubject != null) { + message.addHeader('Subject', rawSubject); + } + message + ..addHeader('In-Reply-To', envelope.inReplyTo) + ..addHeader('Message-ID', envelope.messageId); + } + } + + return envelope; + } + + MailAddress? _parseAddressListFirst(ImapValue addressValue) { + final addresses = _parseAddressList(addressValue); + if (addresses == null || addresses.isEmpty) { + return null; + } + + return addresses.first; + } + + List? _parseAddressList(ImapValue addressValue) { + if (addressValue.value == 'NIL') { + return null; + } + final addresses = []; + final addressChildren = addressValue.children; + if (addressChildren != null) { + for (final child in addressChildren) { + final address = _parseAddress(child); + if (address != null) { + addresses.add(address); + } + } + } + + return addresses; + } + + MailAddress? _parseAddress(ImapValue addressValue) { + // An address structure is a parenthesized list that describes an + // electronic mail address. The fields of an address structure + // are in the following order: personal name, [SMTP] + // at-domain-list (source route), mailbox name, and host name. + + // [RFC-2822] group syntax is indicated by a special form of + // address structure in which the host name field is NIL. If the + // mailbox name field is also NIL, this is an end of group marker + // (semi-colon in RFC 822 syntax). If the mailbox name field is + // non-NIL, this is a start of group marker, and the mailbox name + // field holds the group name phrase. + final addressChildren = addressValue.children; + if (addressValue.value == 'NIL' || + addressChildren == null || + addressChildren.length < 4) { + return null; + } + final children = addressChildren; + final mailboxName = _checkForNil(children[2].value); + final hostName = _checkForNil(children[3].value); + if (mailboxName == null && hostName == null) { + print('Warning: invalid mail address in $addressValue: ' + 'both mailboxName and hostName are null'); + + return null; + } + String? personalName = ''; + try { + personalName = MailCodec.decodeHeader(_checkForNil(children[0].value)); + } catch (e) { + print('Warning: invalid mail address in $addressValue: ' + 'personalName is invalid: $e'); + } + + return MailAddress.fromEnvelope( + personalName: personalName, + //sourceRoute: _checkForNil(children[1].value), + mailboxName: mailboxName ?? '', + hostName: hostName ?? '', + ); + } + + String? _checkForNil(String? value) { + if (value == 'NIL') { + return null; + } + + return value; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/generic_parser.dart b/packages/enough_mail/lib/src/private/imap/generic_parser.dart new file mode 100644 index 0000000..ca84003 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/generic_parser.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import '../../imap/imap_client.dart'; +import '../../imap/imap_events.dart'; +import '../../imap/mailbox.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Retrieves the response code / prefix of a IMAP response, +/// +/// eg `TRYCREATE` in the response `NO [TRYCREATE]`. +class GenericParser extends ResponseParser { + /// Creates a new parser + GenericParser(this.imapClient, this.mailbox); + + /// The associated IMAP client + final ImapClient imapClient; + + /// The currently active mailbox if any + final Mailbox? mailbox; + + final GenericImapResult _result = GenericImapResult(); + @override + GenericImapResult parse( + ImapResponse imapResponse, + Response response, + ) { + final text = imapResponse.parseText; + final startIndex = text.indexOf('['); + if (startIndex != -1 && startIndex < text.length - 2) { + final endIndex = text.indexOf(']', startIndex + 2); + if (endIndex != -1) { + _result + ..responseCode = text.substring(startIndex + 1, endIndex) + ..details = text.substring(endIndex + 1).trim(); + } + } + _result.details ??= text; + + return _result; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final text = imapResponse.parseText; + if (text.startsWith('NO ')) { + _result.warnings.add(ImapWarning('NO', text.substring('NO '.length))); + + return true; + } else if (text.startsWith('BAD ')) { + _result.warnings.add(ImapWarning('BAD', text.substring('BAD '.length))); + + return true; + } else if (text.startsWith('OK [COPYUID')) { + final endIndex = text.lastIndexOf(']'); + if (endIndex != -1) { + _result.responseCode = text.substring('OK ['.length, endIndex); + } + + return true; + } else if (text.endsWith('EXPUNGE')) { + // this is the expunge response for a MOVE operation, ignore + //print('ignoring expunge: $text'); + return true; + } else if (text.endsWith('EXISTS')) { + // a message has been added to the current mailbox, + // e.g. by a MOVE or APPEND operation: + final box = mailbox; + if (box != null) { + final exists = parseInt(text, 0, ' ') ?? 0; + final previous = box.messagesExists; + box.messagesExists = exists; + unawaited( + _fireDelayed( + ImapMessagesExistEvent( + exists, + previous, + imapClient, + ), + ), + ); + } + + return true; + } else if (text.endsWith('RECENT')) { + // a message has been added to the current mailbox, + // e.g. by a MOVE or APPEND operation: + final box = mailbox; + if (box != null) { + final recent = parseInt(text, 0, ' ') ?? 0; + final previous = box.messagesRecent; + box.messagesRecent = recent; + unawaited( + _fireDelayed( + ImapMessagesRecentEvent(recent, previous, imapClient), + ), + ); + } + + return true; + } + + return super.parseUntagged(imapResponse, response); + } + + Future _fireDelayed(ImapEvent event) async { + await Future.delayed(const Duration(milliseconds: 100)); + imapClient.eventBus.fire(event); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/id_parser.dart b/packages/enough_mail/lib/src/private/imap/id_parser.dart new file mode 100644 index 0000000..a66fed8 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/id_parser.dart @@ -0,0 +1,34 @@ +import '../../imap/id.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses IMAP ID responses +class IdParser extends ResponseParser { + Id? _id; + + @override + Id? parse(ImapResponse imapResponse, Response response) { + if (response.isOkStatus) { + return _id; + } + + return null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response? response) { + final text = imapResponse.parseText; + if (text.startsWith('ID ')) { + _id = Id.fromText(text.substring('ID '.length)); + + return true; + } else if (text.startsWith('* ID ')) { + _id = Id.fromText(text.substring('* ID '.length)); + + return true; + } + + return super.parseUntagged(imapResponse, response); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/imap_response.dart b/packages/enough_mail/lib/src/private/imap/imap_response.dart new file mode 100644 index 0000000..16a216e --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/imap_response.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../util/ascii_runes.dart'; +import '../util/stack_list.dart'; +import 'imap_response_line.dart'; + +/// Contains an IMAP response in a generic form +class ImapResponse { + /// The lines in the response + List lines = []; + + /// Is this a simple response ie only containing a single response line? + bool get isSimple => lines.length == 1; + + /// Retrieves the first line + ImapResponseLine get first => lines.first; + String? _parseText; + + /// Retrieves the text of the response ready for parsing + String get parseText { + var text = _parseText; + if (text == null) { + if (isSimple) { + text = first.line ?? ''; + } else { + final buffer = StringBuffer(); + for (final line in lines) { + buffer.write(line.line); + } + text = buffer.toString(); + } + _parseText = text; + } + + return text; + } + + set parseText(String? text) => _parseText = text; + static const List _knownParenthesesDataItems = [ + 'BODY', + 'BODYSTRUCTURE', + 'ENVELOPE', + 'FETCH', + 'FLAGS', + ]; + + /// Adds a line to this response + void add(ImapResponseLine line) { + lines.add(line); + } + + /// Iterates through the value of this response + ImapValueIterator iterate() { + final root = ImapValue(null, hasChildren: true); + var current = root; + var nextLineIsValueOnly = false; + final parentheses = StackList(); + + for (final line in lines) { + if (nextLineIsValueOnly) { + final child = ImapValue(null)..data = line.rawData; + current.addChild(child); + } else { + // iterate through each value: + var isInValue = false; + int? separatorChar; + final text = line.line ?? ''; + late int startIndex; + int? lastChar; + final textCodeUnits = text.codeUnits; + + var detectedEscapeSequence = false; + for (var charIndex = 0; charIndex < textCodeUnits.length; charIndex++) { + final char = textCodeUnits[charIndex]; + if (isInValue) { + if (char == AsciiRunes.runeOpeningBracket && + separatorChar == AsciiRunes.runeSpace) { + // this can be for example: + // BODY[] + // BODY[HEADER] + // but also: + // BODY[HEADER.FIELDS (REFERENCES)] + // BODY[HEADER.FIELDS.NOT (REFERENCES)] + // --> read on until closing "]" + separatorChar = AsciiRunes.runeClosingBracket; + } else if (char == separatorChar) { + // end of current word: + if (separatorChar == AsciiRunes.runeClosingBracket) { + // also include the closing ']' into the value: + charIndex++; + } else if (separatorChar == AsciiRunes.runeDoubleQuote && + lastChar == AsciiRunes.runeBackslash) { + detectedEscapeSequence = true; + // this can happen e.g. in Subject fields within an ENVELOPE value: "hello \"sir\"" + lastChar = char; + continue; + } + var valueText = text.substring(startIndex, charIndex); + if (detectedEscapeSequence) { + valueText = valueText.replaceAll('\\"', '"'); + detectedEscapeSequence = false; + } + current.addChild(ImapValue(valueText)); + isInValue = false; + } else if (parentheses.isNotEmpty && + separatorChar == AsciiRunes.runeSpace && + char == AsciiRunes.runeClosingParentheses) { + final valueText = text.substring(startIndex, charIndex); + current.addChild(ImapValue(valueText)); + isInValue = false; + parentheses.pop(); + final currentParent = current.parent; + if (currentParent != null) { + current = currentParent; + } + } + } else if (char == AsciiRunes.runeDoubleQuote) { + separatorChar = char; + startIndex = charIndex + 1; + isInValue = true; + } else if (char == AsciiRunes.runeOpeningParentheses) { + final lastSibling = + current.hasChildren ? current.children?.last : null; + ImapValue next; + if (lastSibling != null && + _knownParenthesesDataItems.contains(lastSibling.value)) { + lastSibling.children ??= []; + next = lastSibling; + parentheses.put(ParenthesizedListType.sibling); + } else { + next = ImapValue(null, hasChildren: true); + current.addChild(next); + parentheses.put(ParenthesizedListType.child); + } + current = next; + } else if (char == AsciiRunes.runeClosingParentheses) { + final lastType = parentheses.pop(); + final currentParent = current.parent; + if (currentParent != null) { + current = currentParent; + } else { + print( + 'Warning: no parent for closing parentheses, ' + 'last parentheses type $lastType', + ); + } + } else if (char != AsciiRunes.runeSpace) { + isInValue = true; + separatorChar = AsciiRunes.runeSpace; + startIndex = charIndex; + } + lastChar = char; + } // for each char + if (isInValue) { + isInValue = false; + final 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 ?? []); + } + + @override + String toString() { + final buffer = StringBuffer(); + for (final line in lines) { + buffer + ..write(line.rawLine ?? '<${line.rawData?.length} bytes data>') + ..write('\n'); + } + + return buffer.toString(); + } +} + +/// Iterator through parenthesized values in an IMAP response +class ImapValueIterator { + /// Creates a new iterator + ImapValueIterator(this.values); + + /// All values + final List values; + int _currentIndex = 0; + + /// The current value + ImapValue get current => values[_currentIndex]; + + /// Moves to the next value + /// + /// Returns `true` if there is a next value + bool next() { + if (_currentIndex < values.length - 1) { + _currentIndex++; + + return true; + } + + return false; + } +} + +/// The type of a value list element +enum ParenthesizedListType { + /// A child of another element + child, + + /// A sibling of another element + sibling +} + +/// Contains a single IMAP value in a parenthesized list +class ImapValue { + /// Creates a new value + ImapValue(this.value, {bool hasChildren = false}) { + if (hasChildren) { + children = []; + } + } + + /// The parent of this value + ImapValue? parent; + + /// The text data + String? value; + + /// The binary data + Uint8List? data; + + /// The children, if any + List? children; + + /// Does this value have children? + bool get hasChildren => children?.isNotEmpty ?? false; + + /// Retrieves the value as text + String? get valueOrDataText { + final data = this.data; + + return value ?? + (data == null ? null : utf8.decode(data, allowMalformed: true)); + } + + /// Adds a child to this value + void addChild(ImapValue child) { + children ??= []; + child.parent = this; + children?.add(child); + } + + @override + String toString() { + final data = this.data; + + return (value ?? (data != null ? '<${data.length} bytes>' : '')) + + (children != null ? children.toString() : ''); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/imap_response_line.dart b/packages/enough_mail/lib/src/private/imap/imap_response_line.dart new file mode 100644 index 0000000..0771f4e --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/imap_response_line.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'parser_helper.dart'; + +/// Contains an IMAP response line +class ImapResponseLine { + /// Creates a textual response line + ImapResponseLine(final String text) + : rawData = null, + rawLine = text { + // 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); + } + } + } + + /// Creates a binary response line + ImapResponseLine.raw(this.rawData) : rawLine = null; + + static const Utf8Decoder _decoder = Utf8Decoder(allowMalformed: true); + + /// The original text line + final String? rawLine; + String? _line; + + /// The processed text line + String? get line { + if (_line == null) { + final rawData = this.rawData; + if (rawData != null) { + _line = _decoder.convert(rawData); + } + } + + return _line; + } + + /// The literal at the end of this line. + /// + /// Compare [isWithLiteral]. + int? literal; + + /// Does this line have a [literal] data indicator? + bool get isWithLiteral { + final literal = this.literal; + + return literal != null && literal >= 0; + } + + /// The raw data of this line + final Uint8List? rawData; + + @override + String toString() => rawLine ?? line ?? ''; +} diff --git a/packages/enough_mail/lib/src/private/imap/imap_response_reader.dart b/packages/enough_mail/lib/src/private/imap/imap_response_reader.dart new file mode 100644 index 0000000..4118065 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/imap_response_reader.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import '../util/uint8_list_reader.dart'; +import 'imap_response.dart'; +import 'imap_response_line.dart'; + +/// Reads IMAP responses +class ImapResponseReader { + /// Creates a new imap response reader + ImapResponseReader(this.onImapResponse); + + /// Callback for finished IMAP responses + final Function(ImapResponse) onImapResponse; + final Uint8ListReader _rawReader = Uint8ListReader(); + ImapResponse? _currentResponse; + ImapResponseLine? _currentLine; + + /// Processes the given [data] + void onData(Uint8List data) { + _rawReader.add(data); + // var text = String.fromCharCodes(data).replaceAll('\r\n', '\n'); + // print('onData: $text'); + final currentResponse = _currentResponse; + final currentLine = _currentLine; + if (currentResponse != null && currentLine != null) { + _checkResponse(currentResponse, currentLine); + } + if (_currentResponse == null) { + // there is currently no response awaiting its finalization + var text = _rawReader.readLine(); + while (text != null) { + final response = ImapResponse(); + final 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?.isWithLiteral ?? false) { + break; + } + text = _rawReader.readLine(); + } + } + } + + void _checkResponse(ImapResponse response, ImapResponseLine line) { + final literal = line.literal; + if (literal != null && literal > 0) { + if (_rawReader.isAvailable(literal)) { + final rawLine = ImapResponseLine.raw(_rawReader.readBytes(literal)); + response.add(rawLine); + _currentLine = rawLine; + _checkResponse(response, rawLine); + } + } else { + // current line has no literal + final text = _rawReader.readLine(); + if (text != null) { + final 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 ?? true)) { + line.literal = textLine.literal; + } else { + if (textLine.line?.isNotEmpty ?? false) { + response.add(textLine); + } + if (!textLine.isWithLiteral) { + // this is the last line of this server response: + onImapResponse(response); + _currentResponse = null; + _currentLine = null; + } else { + _currentLine = textLine; + _checkResponse(response, textLine); + } + } + } + } + } +} diff --git a/packages/enough_mail/lib/src/private/imap/list_parser.dart b/packages/enough_mail/lib/src/private/imap/list_parser.dart new file mode 100644 index 0000000..5a5f133 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/list_parser.dart @@ -0,0 +1,239 @@ +import '../../imap/extended_data.dart'; +import '../../imap/imap_client.dart'; +import '../../imap/mailbox.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; +import 'status_parser.dart'; + +/// Parses `LIST` and `LSUB` responses +class ListParser extends ResponseParser> { + /// Creates a new parser + ListParser( + this.info, { + bool isLsubParser = false, + this.isExtended = false, + bool hasReturnOptions = false, + }) : startSequence = isLsubParser ? 'LSUB ' : 'LIST ', + // Return options are available only for LIST responses. + _hasReturnOptions = !isLsubParser && hasReturnOptions; + + /// The remote service info + final ImapServerInfo info; + + /// The resulting mailboxes + final List boxes = []; + + /// The command's start sequence + final String startSequence; + + /// Is an extended response expected? + /// + /// e.g. when hasSelectionOptions || hasMailboxPatterns || hasReturnOptions + final bool isExtended; + final bool _hasReturnOptions; + + @override + List? parse( + ImapResponse? imapResponse, + Response> response, + ) => + response.isOkStatus ? boxes : null; + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response>? response, + ) { + final parseText = imapResponse.parseText; + if (parseText.startsWith(startSequence)) { + _parseBoxFlags(parseText); + + return true; + } else if (_hasReturnOptions) { + if (parseText.startsWith('NO')) { + // Swallows failed STATUS result + // This is a special case in which a STATUS result fails with 'NO' for a + // non existent folder. Nevertheless, the mailbox is added with a \Nonexistent flag. + return true; + } + if (parseText.startsWith('STATUS')) { + // Reuses the StatusParser class + final parser = StatusParser(boxes.last); + // ignore: cascade_invocations + parser.parseUntagged(imapResponse, null); + + return true; + } + } + + return super.parseUntagged(imapResponse, response); + } + + void _parseBoxFlags(String parseText) { + final boxFlags = []; + var listDetails = parseText.substring(startSequence.length); + final flagsStartIndex = listDetails.indexOf('('); + final flagsEndIndex = listDetails.indexOf(')'); + if (flagsStartIndex != -1 && flagsStartIndex < flagsEndIndex) { + _addFlags(flagsStartIndex, flagsEndIndex, listDetails, boxFlags); + listDetails = listDetails.substring(flagsEndIndex + 2); + } + // Parses extended data + final boxExtendedData = >{}; + if (isExtended) { + final extraInfoStartIndex = listDetails.indexOf('('); + final extraInfoEndIndex = listDetails.lastIndexOf(')'); + if (extraInfoEndIndex != -1 && extraInfoStartIndex < extraInfoEndIndex) { + final extraInfo = + listDetails.substring(extraInfoStartIndex + 1, extraInfoEndIndex); + listDetails = listDetails.substring(0, extraInfoStartIndex - 1); + // Convert to loop if more extended data results will be present + //todo Address when multiple extended data list are returned + // by non conforming servers while (extraInfo.isNotEmpty) + if (extraInfo.startsWith(ExtendedData.childinfo) || + extraInfo.startsWith('"${ExtendedData.childinfo}"')) { + final childInfo = boxExtendedData[ExtendedData.childinfo] ?? []; + if (!boxExtendedData.containsKey(ExtendedData.childinfo)) { + boxExtendedData[ExtendedData.childinfo] = childInfo; + } + final optsStartIndex = extraInfo.indexOf('('); + final optsEndIndex = extraInfo.indexOf(')'); + if (optsStartIndex != -1 && optsStartIndex < optsEndIndex) { + final opts = extraInfo + .substring(optsStartIndex + 1, optsEndIndex) + .split(' ') + .map((e) => e.substring(1, e.length - 1)); + childInfo.addAll(opts); + } + } + } + } + if (listDetails.startsWith('"')) { + final endOfPathSeparatorIndex = listDetails.indexOf('"', 1); + if (endOfPathSeparatorIndex != -1) { + final separator = listDetails.substring(1, endOfPathSeparatorIndex); + info.pathSeparator = separator; + listDetails = listDetails.substring(endOfPathSeparatorIndex + 2); + } + } + if (listDetails.startsWith('"')) { + listDetails = listDetails.substring(1, listDetails.length - 1); + } + final boxPath = listDetails; + // Maybe was requested only the hierarchy separator without reference name + if (listDetails.length > 2 && info.pathSeparator != null) { + final lastPathSeparatorIndex = listDetails.lastIndexOf( + info.pathSeparator ?? '/', + listDetails.length - 2, + ); + if (lastPathSeparatorIndex != -1) { + listDetails = listDetails.substring(lastPathSeparatorIndex + 1); + } + } + final boxName = listDetails; + final box = Mailbox( + encodedName: boxName, + encodedPath: boxPath, + flags: boxFlags, + pathSeparator: info.pathSeparator ?? '/', + extendedData: boxExtendedData, + ); + boxes.add(box); + } + + void _addFlags( + int flagsStartIndex, + int flagsEndIndex, + String listDetails, + List boxFlags, + ) { + if (flagsStartIndex < flagsEndIndex - 1) { + // there are actually flags, not an empty () + final flagsText = listDetails + .substring(flagsStartIndex + 1, flagsEndIndex) + .toLowerCase(); + final flagNames = flagsText.split(' '); + for (final flagName in flagNames) { + switch (flagName) { + case r'\hasnochildren': + boxFlags.add(MailboxFlag.hasNoChildren); + break; + case r'\haschildren': + boxFlags.add(MailboxFlag.hasChildren); + break; + case r'\unmarked': + boxFlags.add(MailboxFlag.unMarked); + break; + case r'\marked': + boxFlags.add(MailboxFlag.marked); + break; + case r'\noselect': + boxFlags.add(MailboxFlag.noSelect); + break; + case r'\select': + boxFlags.add(MailboxFlag.select); + break; + case r'\noinferiors': + boxFlags.add(MailboxFlag.noInferior); + if (isExtended) { + boxFlags.add(MailboxFlag.hasNoChildren); + } + break; + case r'\nonexistent': + boxFlags.add(MailboxFlag.nonExistent); + if (isExtended) { + boxFlags.add(MailboxFlag.noSelect); + } + break; + case r'\subscribed': + boxFlags.add(MailboxFlag.subscribed); + break; + case r'\remote': + boxFlags.add(MailboxFlag.remote); + break; + case r'\all': + boxFlags.add(MailboxFlag.all); + break; + case r'\inbox': + boxFlags.add(MailboxFlag.inbox); + break; + case r'\sent': + boxFlags.add(MailboxFlag.sent); + break; + case r'\drafts': + boxFlags.add(MailboxFlag.drafts); + break; + case r'\junk': + boxFlags.add(MailboxFlag.junk); + break; + case r'\trash': + boxFlags.add(MailboxFlag.trash); + break; + case r'\archive': + boxFlags.add(MailboxFlag.archive); + break; + case r'\flagged': + boxFlags.add(MailboxFlag.flagged); + break; + // X-List flags: + case r'\allmail': + boxFlags.add(MailboxFlag.all); + break; + case r'\important': + boxFlags.add(MailboxFlag.flagged); + break; + case r'\spam': + boxFlags.add(MailboxFlag.junk); + break; + case r'\starred': + boxFlags.add(MailboxFlag.flagged); + break; + + default: + print('encountered unexpected flag: [$flagName]'); + } + } + } + } +} diff --git a/packages/enough_mail/lib/src/private/imap/logout_parser.dart b/packages/enough_mail/lib/src/private/imap/logout_parser.dart new file mode 100644 index 0000000..123ab6a --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/logout_parser.dart @@ -0,0 +1,23 @@ +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses responses to logout requests +class LogoutParser extends ResponseParser { + String? _bye; + + @override + String? parse(ImapResponse imapResponse, Response response) => + _bye ?? ''; + + @override + bool parseUntagged(ImapResponse imapResponse, Response? response) { + if (imapResponse.parseText.startsWith('BYE')) { + _bye = imapResponse.parseText; + + return true; + } + + return super.parseUntagged(imapResponse, response); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/meta_data_parser.dart b/packages/enough_mail/lib/src/private/imap/meta_data_parser.dart new file mode 100644 index 0000000..2454cca --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/meta_data_parser.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import '../../imap/metadata.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses responses to meta data requests +class MetaDataParser extends ResponseParser> { + final List _entries = []; + + //TODO consider supporting [METADATA LONGENTRIES 2199] + @override + List? parse( + ImapResponse imapResponse, + Response> response, + ) => + response.isOkStatus ? _entries : null; + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response>? response, + ) { + if (imapResponse.parseText.startsWith('METADATA ')) { + final children = imapResponse.iterate().values; + if (children.length < 4 || + children[3].children == null || + (children[3].children?.length ?? 0) < 2) { + print('METADATA: unable to parse ${imapResponse.parseText}.'); + + return super.parseUntagged(imapResponse, response); + } + final mailboxName = children[2].value; + final keyValuePairs = children[3].children ?? []; + for (var i = 0; i < keyValuePairs.length - 1; i += 2) { + final name = keyValuePairs[i].value ?? ''; + final value = keyValuePairs[i + 1].data ?? + Uint8List.fromList(keyValuePairs[i + 1].value?.codeUnits ?? []); + final metaData = MetaDataEntry( + mailboxName: mailboxName ?? '', + name: name, + value: value, + ); + _entries.add(metaData); + } + + return true; + } + + return super.parseUntagged(imapResponse, response); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/no_response_parser.dart b/packages/enough_mail/lib/src/private/imap/no_response_parser.dart new file mode 100644 index 0000000..93a1a92 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/no_response_parser.dart @@ -0,0 +1,16 @@ +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Returns the given value when the command succeeded +class NoResponseParser extends ResponseParser { + /// Creates a new parser + NoResponseParser(this.value); + + /// The value to be returned for successful responses + final T value; + + @override + T? parse(ImapResponse imapResponse, Response response) => + response.isOkStatus ? value : null; +} diff --git a/packages/enough_mail/lib/src/private/imap/noop_parser.dart b/packages/enough_mail/lib/src/private/imap/noop_parser.dart new file mode 100644 index 0000000..960ba93 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/noop_parser.dart @@ -0,0 +1,127 @@ +import '../../imap/imap_client.dart'; +import '../../imap/imap_events.dart'; +import '../../imap/mailbox.dart'; +import '../../imap/message_sequence.dart'; +import '../../imap/response.dart'; +import 'all_parsers.dart'; +import 'imap_response.dart'; +import 'parser_helper.dart'; +import 'response_parser.dart'; + +/// Parses responses to a NOOP (no operation) IMAP request +class NoopParser extends ResponseParser { + /// Create a new parser + NoopParser(this.imapClient, this.mailbox); + + /// The imap client initiating the request + final ImapClient imapClient; + + /// The associated mailbox + final Mailbox? mailbox; + + final FetchParser _fetchParser = FetchParser(isUidFetch: false); + final Response _fetchResponse = Response(); + + @override + Mailbox? parse(ImapResponse imapResponse, Response response) { + final box = mailbox; + if (box != null) { + box.isReadWrite = imapResponse.parseText.startsWith('OK [READ-WRITE]'); + final highestModSequenceIndex = + imapResponse.parseText.indexOf('[HIGHESTMODSEQ '); + if (highestModSequenceIndex != -1) { + box.highestModSequence = ParserHelper.parseInt( + imapResponse.parseText, + highestModSequenceIndex + '[HIGHESTMODSEQ '.length, + ']', + ); + } + } + + return response.isOkStatus ? box : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response? response) { + final details = imapResponse.parseText; + if (details.endsWith(' EXPUNGE')) { + // example: 1234 EXPUNGE + final id = parseInt(details, 0, ' '); + if (id != null) { + imapClient.eventBus.fire(ImapExpungeEvent(id, imapClient)); + } + } else if (details.startsWith('VANISHED (EARLIER) ')) { + handledVanished(details, 'VANISHED (EARLIER) ', isEarlier: true); + } else if (details.startsWith('VANISHED ')) { + handledVanished(details, 'VANISHED '); + } else { + var handled = false; + final box = mailbox; + if (box == null) { + handled = super.parseUntagged(imapResponse, response); + } else { + final messagesExists = box.messagesExists; + final messagesRecent = box.messagesRecent; + handled = SelectParser.parseUntaggedResponse(box, imapResponse); + + if (handled) { + if (box.messagesExists != messagesExists) { + imapClient.eventBus.fire( + ImapMessagesExistEvent( + box.messagesExists, + messagesExists, + imapClient, + ), + ); + } else if (box.messagesRecent != messagesRecent) { + imapClient.eventBus.fire( + ImapMessagesRecentEvent( + box.messagesRecent, + messagesRecent, + imapClient, + ), + ); + } + + return true; + } else { + if (_fetchParser.parseUntagged(imapResponse, _fetchResponse)) { + final mimeMessage = _fetchParser.lastParsedMessage; + if (mimeMessage != null) { + imapClient.eventBus.fire(ImapFetchEvent(mimeMessage, imapClient)); + } else if (_fetchParser.vanishedMessages != null) { + imapClient.eventBus.fire( + ImapVanishedEvent( + _fetchParser.vanishedMessages, + imapClient, + isEarlier: true, + ), + ); + } + + return true; + } + } + } + if (!handled && details.startsWith('OK ')) { + // a common response in IDLE mode can be "* OK still here" or similar + handled = true; + } + + return handled; + } + + return true; + } + + /// Handles vanished response lines + void handledVanished(String details, String start, {bool isEarlier = false}) { + final vanishedText = details.substring(start.length); + final vanished = MessageSequence.parse(vanishedText, isUidSequence: true); + imapClient.eventBus.fire(ImapVanishedEvent( + vanished, + imapClient, + isEarlier: isEarlier, + )); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/parser_helper.dart b/packages/enough_mail/lib/src/private/imap/parser_helper.dart new file mode 100644 index 0000000..6c9f727 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/parser_helper.dart @@ -0,0 +1,236 @@ +import '../../codecs/mail_codec.dart'; +import '../../mime_message.dart'; +import '../util/ascii_runes.dart'; +import '../util/word.dart'; + +/// Abstracts a word such as a template name +class ParserHelper { + ParserHelper._(); + + /// Helper method for parsing integer values within a line [details]. + static int? parseInt(String details, int startIndex, String endCharacter) { + final endIndex = details.indexOf(endCharacter, startIndex); + if (endIndex == -1) { + return -1; + } + final 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) { + final 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 = ' ', + ]) { + final runes = details.runes.toList(); + final separatorRune = separator.runes.first; + final endRune = endCharacter?.runes.first; + final result = []; + var isInQuote = false; + var isLastEscaped = false; + var entryStartIndex = startIndex; + for (var i = startIndex; i < runes.length; i++) { + final rune = runes[i]; + if (isLastEscaped) { + isLastEscaped = false; + } else if (rune == AsciiRunes.runeDoubleQuote) { + isInQuote = !isInQuote; + } else if (rune == AsciiRunes.runeBackslash) { + isLastEscaped = true; + } else if (!isInQuote) { + if (rune == separatorRune || rune == endRune) { + result.add(details.substring(entryStartIndex, i)); + entryStartIndex = i + 1; + } + if (rune == endRune) { + return result; + } + } + } + if (endCharacter != null) { + return null; + } else if (entryStartIndex < runes.length) { + result.add(details.substring(entryStartIndex)); + } + + return result; + } + + /// 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; + } + + return details.substring(startIndex, endIndex).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 = ' ', + ]) { + final texts = + parseListEntries(details, startIndex, endCharacter, separator); + if (texts == null) { + return null; + } + final integers = []; + for (final text in texts) { + final number = int.tryParse(text.trim()); + if (number == null) { + print('Warning: unable to parse entry $text in "$details"'); + } else { + integers.add(number); + } + } + + return integers; + } + + /// Helper method to read the next word within a string + static Word? readNextWord( + String details, + final int startIndex, [ + String separator = ' ', + ]) { + var endIndex = details.indexOf(separator, startIndex); + var i = startIndex; + while (endIndex == i) { + i++; + endIndex = details.indexOf(separator, i); + } + if (endIndex == -1) { + return null; + } + + return Word(details.substring(i, endIndex), i); + } + + /// Parses the headers from the given [headerText] + static HeaderParseResult parseHeader(final String headerText) { + final headerLines = headerText.split('\r\n'); + + return parseHeaderLines(headerLines); + } + + /// Parses the headers from the given [headerLines] + static HeaderParseResult parseHeaderLines( + List headerLines, { + int startRow = 0, + }) { + final result = HeaderParseResult(); + var bodyStartIndex = 0; + var buffer = StringBuffer(); + String? lastLine; + for (var i = startRow; i < headerLines.length; i++) { + final line = headerLines[i]; + if (line.isEmpty) { + // end of header is marked with an empty line + if (buffer.isNotEmpty) { + _addHeader(result, buffer); + buffer = StringBuffer(); + } + bodyStartIndex += 2; + result.bodyStartIndex = bodyStartIndex; + break; + } + bodyStartIndex += line.length + 2; + if (line.startsWith(' ') || (line.startsWith('\t'))) { + final trimmed = line.trimLeft(); + if (lastLine == null || + !lastLine.endsWith('=') || + !trimmed.startsWith('=')) { + buffer.write(' '); + } + buffer.write(trimmed); + } else { + if (buffer.isNotEmpty) { + // got a complete line + _addHeader(result, buffer); + buffer = StringBuffer(); + } + buffer.write(line); + } + lastLine = line; + } + if (buffer.isNotEmpty) { + // got a complete line + _addHeader(result, buffer); + } + + return result; + } + + static void _addHeader(HeaderParseResult result, StringBuffer buffer) { + final headerText = buffer.toString(); + final colonIndex = headerText.indexOf(':'); + if (colonIndex != -1) { + final name = headerText.substring(0, colonIndex); + if (colonIndex + 2 < headerText.length) { + final value = headerText.substring(colonIndex + 1).trim(); + result.add(name, value); + } else { + //print('encountered empty header [$headerText]'); + result.add(name, ''); + } + } + } + + /// Parses an email from the given [value] text + /// like `"name" ` + static String? parseEmail(String value) { + if (value.length < 3) { + return null; + } + // check for a value like '"name" ' + final startIndex = value.indexOf('<'); + if (startIndex != -1) { + final 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('"')) { + final endIndex = value.indexOf('"', 1); + if (endIndex != -1) { + return value.substring(endIndex + 1).trim(); + } + } + + return value; + } +} + +/// Contains the result for a parsed header +class HeaderParseResult { + /// The parsed headers + final headersList =
[]; + + /// The position of the body + int? bodyStartIndex; + + /// Adds a header with the given [name] and [value] + void add(String name, String value) { + final header = Header(name, value, MailCodec.detectHeaderEncoding(value)); + headersList.add(header); + } +} diff --git a/packages/enough_mail/lib/src/private/imap/quota_parser.dart b/packages/enough_mail/lib/src/private/imap/quota_parser.dart new file mode 100644 index 0000000..c21b45a --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/quota_parser.dart @@ -0,0 +1,125 @@ +import '../../imap/resource_limit.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses responses to IMAP QUOTA commands +class QuotaParser extends ResponseParser { + QuotaResult? _quota; + + @override + QuotaResult? parse( + ImapResponse imapResponse, + Response response, + ) => + response.isOkStatus ? _quota : null; + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + var details = imapResponse.parseText; + String? rootName; + if (details.startsWith('QUOTA ')) { + details = details.substring('QUOTA '.length); + final startIndex = details.indexOf('('); + if (details.startsWith('"')) { + final endOfNameIndex = details.indexOf('"', 1); + if (endOfNameIndex != -1) { + rootName = details.substring(1, endOfNameIndex); + } + } else { + rootName = details.substring(0, startIndex - 1); + } + final listEntries = parseListEntries(details, startIndex + 1, ')'); + if (listEntries == null) { + return false; + } + final buffer = []; + for (var index = 0; index < listEntries.length; index += 3) { + buffer.add(ResourceLimit( + listEntries[index], + int.tryParse(listEntries[index + 1]), + int.tryParse(listEntries[index + 2]), + )); + } + _quota = QuotaResult(rootName, buffer); + + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } +} + +/// Pareses results to QUOTA ROOT requests +class QuotaRootParser extends ResponseParser { + QuotaRootResult? _quotaRoot; + + @override + QuotaRootResult? parse( + ImapResponse imapResponse, + Response response, + ) => + response.isOkStatus ? _quotaRoot : null; + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + var details = imapResponse.parseText; + String? rootName; + if (details.startsWith('QUOTA ')) { + details = details.substring('QUOTA '.length); + final startIndex = details.indexOf('('); + if (details.startsWith('"')) { + final endOfNameIndex = details.indexOf('"', 1); + if (endOfNameIndex != -1) { + rootName = details.substring(1, endOfNameIndex); + } + } else { + rootName = details.substring(0, startIndex - 1); + } + final listEntries = parseListEntries(details, startIndex + 1, ')'); + if (listEntries == null) { + return false; + } + final buffer = []; + for (var index = 0; index < listEntries.length; index += 3) { + buffer.add(ResourceLimit( + listEntries[index], + int.tryParse(listEntries[index + 1]), + int.tryParse(listEntries[index + 2]), + )); + } + _quotaRoot?.quotaRoots[rootName] = QuotaResult(rootName, buffer); + + return true; + } else if (details.startsWith('QUOTAROOT ')) { + details = details.substring('QUOTAROOT '.length); + final entries = _parseStringEntries(details); + _quotaRoot = QuotaRootResult(entries.first, entries.sublist(1)); + + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + + List _parseStringEntries(String details) { + final output = []; + for (final item in details.split(' ')) { + if (item.startsWith('"')) { + output.add('${item.replaceFirst('"', '')} '); + } else if (item.endsWith('"')) { + output.add(output.removeLast() + item.replaceFirst('"', '')); + } else { + output.add(item); + } + } + + return output; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/response_parser.dart b/packages/enough_mail/lib/src/private/imap/response_parser.dart new file mode 100644 index 0000000..6629495 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/response_parser.dart @@ -0,0 +1,44 @@ +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'parser_helper.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 imapResponse, Response response); + + /// Parses intermediate untagged response lines. + bool parseUntagged(ImapResponse imapResponse, Response? response) => false; + + /// Helper method for parsing integer values within a line [details]. + int? parseInt(String details, int startIndex, String endCharacter) => + 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 = ' ', + ]) => + ParserHelper.parseListEntries( + details, + startIndex, + endCharacter, + separator, + ); + + /// Helper method to parse a list of integer values in a line [details]. + List? parseListIntEntries( + String details, + int startIndex, + String endCharacter, [ + String separator = ' ', + ]) => + ParserHelper.parseListIntEntries( + details, + startIndex, + endCharacter, + separator, + ); +} diff --git a/packages/enough_mail/lib/src/private/imap/search_parser.dart b/packages/enough_mail/lib/src/private/imap/search_parser.dart new file mode 100644 index 0000000..e2bbc93 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/search_parser.dart @@ -0,0 +1,151 @@ +import '../../imap/message_sequence.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses search responses +class SearchParser extends ResponseParser { + /// Creates a new search parser + SearchParser({required this.isUidSearch, this.isExtended = false}); + + /// Is this a UID-based search? + final bool isUidSearch; + + /// The IDs + List ids = []; + + /// The highest modification sequence + int? highestModSequence; + + /// Is an extended response expected? + final bool isExtended; + + /// Reference tag for the current extended search untagged response + String? tag; + + /// minimum search ID + int? min; + + /// maximum search ID + int? max; + + /// number of search results + int? count; + + /// Partial range + String? partialRange; + + @override + SearchImapResult? parse( + ImapResponse imapResponse, + Response response, + ) { + if (response.isOkStatus) { + final result = SearchImapResult() + // Force the sorting of the resulting sequence set + ..matchingSequence = + (MessageSequence.fromIds(ids, isUid: isUidSearch)..sort()) + ..highestModSequence = highestModSequence + ..isExtended = isExtended + ..tag = tag + ..min = min + ..max = max + ..count = count + ..partialRange = partialRange; + + return result; + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final details = imapResponse.parseText; + if (details.startsWith('SEARCH ')) { + return _parseSimpleDetails(details); + } else if (details.startsWith('ESEARCH ')) { + return _parseExtendedDetails(details); + } else if (details == 'SEARCH' || details == 'ESEARCH') { + // this is an empty search result + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + + bool _parseSimpleDetails(String details) { + final listEntries = parseListEntries(details, 'SEARCH '.length, null); + if (listEntries == null) { + return false; + } + for (var i = 0; i < listEntries.length; i++) { + final entry = listEntries[i]; + if (entry == '(MODSEQ') { + i++; + final seqEntry = listEntries[i]; + final modSeqText = seqEntry.substring(0, seqEntry.length - 1); + highestModSequence = int.tryParse(modSeqText); + } else { + final id = int.tryParse(entry); + if (id != null) { + ids.add(id); + } + } + } + + return true; + } + + bool _parseExtendedDetails(String details) { + final listEntries = parseListEntries(details, 'ESEARCH '.length, null); + if (listEntries == null) { + return false; + } + for (var i = 0; i < listEntries.length; i++) { + final entry = listEntries[i]; + if (entry == '(TAG') { + i++; + tag = listEntries[i].substring(1, listEntries[i].length - 2); + // } else if (entry == 'UID') { + // Included for completeness. + } else if (entry == 'MIN') { + i++; + min = int.tryParse(listEntries[i]); + } else if (entry == 'MAX') { + i++; + max = int.tryParse(listEntries[i]); + } else if (entry == 'COUNT') { + i++; + count = int.tryParse(listEntries[i]); + } else if (entry == 'ALL') { + i++; + // The result is always sequence-set. + final seq = + MessageSequence.parse(listEntries[i], isUidSequence: isUidSearch); + if (!seq.isNil) { + ids = seq.toList(); + } + } else if (entry == 'MODSEQ') { + i++; + highestModSequence = int.tryParse(listEntries[i]); + } else if (entry == 'PARTIAL') { + i++; + partialRange = listEntries[i].substring(1); + i++; + final seq = MessageSequence.parse( + listEntries[i].substring(0, listEntries[i].length - 1), + isUidSequence: isUidSearch, + ); + if (!seq.isNil) { + ids = seq.toList(); + } + } + } + + return true; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/select_parser.dart b/packages/enough_mail/lib/src/private/imap/select_parser.dart new file mode 100644 index 0000000..10315a2 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/select_parser.dart @@ -0,0 +1,117 @@ +import '../../imap/imap_client.dart'; +import '../../imap/imap_events.dart'; +import '../../imap/mailbox.dart'; +import '../../imap/response.dart'; +import 'all_parsers.dart'; +import 'imap_response.dart'; +import 'parser_helper.dart'; +import 'response_parser.dart'; + +/// Parses responses to a mailbox selection command +class SelectParser extends ResponseParser { + /// Creates a new select parser + SelectParser(this.mailbox, this.imapClient); + + /// The mailbox that should be selected + final Mailbox mailbox; + + /// The originating imap client + final ImapClient imapClient; + final FetchParser _fetchParser = FetchParser(isUidFetch: false); + final Response _fetchResponse = Response(); + + @override + Mailbox? parse(ImapResponse imapResponse, Response response) { + mailbox.isReadWrite = imapResponse.parseText.startsWith('OK [READ-WRITE]'); + final highestModSequenceIndex = + imapResponse.parseText.indexOf('[HIGHESTMODSEQ '); + if (highestModSequenceIndex != -1) { + mailbox.highestModSequence = ParserHelper.parseInt( + imapResponse.parseText, + highestModSequenceIndex + '[HIGHESTMODSEQ '.length, + ']', + ); + } + + return response.isOkStatus ? mailbox : null; + } + + @override + bool parseUntagged(ImapResponse imapResponse, Response? response) { + if (parseUntaggedResponse(mailbox, imapResponse)) { + return true; + } else if (_fetchParser.parseUntagged(imapResponse, _fetchResponse)) { + final mimeMessage = _fetchParser.lastParsedMessage; + if (mimeMessage != null) { + imapClient.eventBus.fire(ImapFetchEvent(mimeMessage, imapClient)); + } else if (_fetchParser.vanishedMessages != null) { + imapClient.eventBus.fire(ImapVanishedEvent( + _fetchParser.vanishedMessages, + imapClient, + isEarlier: true, + )); + } + + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + + /// Helps with parsing untagged responses + static bool parseUntaggedResponse( + Mailbox mailbox, + ImapResponse imapResponse, + ) { + final box = mailbox; + final details = imapResponse.parseText; + if (details.startsWith('OK [UNSEEN ')) { + box.firstUnseenMessageSequenceId = + ParserHelper.parseInt(details, 'OK [UNSEEN '.length, ']'); + + return true; + } else if (details.startsWith('OK [UIDVALIDITY ')) { + box.uidValidity = + ParserHelper.parseInt(details, 'OK [UIDVALIDITY '.length, ']'); + + return true; + } else if (details.startsWith('OK [UIDNEXT ')) { + box.uidNext = ParserHelper.parseInt(details, 'OK [UIDNEXT '.length, ']'); + + return true; + } else if (details.startsWith('OK [HIGHESTMODSEQ ')) { + box.highestModSequence = + ParserHelper.parseInt(details, 'OK [HIGHESTMODSEQ '.length, ']'); + + return true; + } else if (details.startsWith('OK [NOMODSEQ]')) { + box.highestModSequence = null; + + return true; + } else if (details.endsWith(' EXISTS')) { + box.messagesExists = ParserHelper.parseInt(details, 0, ' ') ?? 0; + + return true; + } else if (details.endsWith(' RECENT')) { + box.messagesRecent = ParserHelper.parseInt(details, 0, ' ') ?? 0; + + return true; + } else if (details.startsWith('FLAGS (')) { + box.messageFlags = + ParserHelper.parseListEntries(details, 'FLAGS ('.length, ')') ?? []; + + return true; + } else if (details.startsWith('OK [PERMANENTFLAGS (')) { + box.permanentMessageFlags = ParserHelper.parseListEntries( + details, + 'OK [PERMANENTFLAGS ('.length, + ')', + ) ?? + []; + + return true; + } else { + return false; + } + } +} diff --git a/packages/enough_mail/lib/src/private/imap/sort_parser.dart b/packages/enough_mail/lib/src/private/imap/sort_parser.dart new file mode 100644 index 0000000..206827c --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/sort_parser.dart @@ -0,0 +1,149 @@ +import '../../imap/message_sequence.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses sort responses +class SortParser extends ResponseParser { + /// Creates a new sort parser + SortParser({this.isUidSort = false, this.isExtended = false}); + + /// Is this a UID-based sorting request? + final bool isUidSort; + + /// The list of IDs + List ids = []; + + /// The highest modification sequence + int? highestModSequence; + + /// Is an extended response expected? + bool isExtended; + + /// Reference tag for the current extended sort untagged response + String? tag; + + /// minimum ID + int? min; + + /// maximum ID + int? max; + + /// number of results + int? count; + + /// The partial range + String? partialRange; + + @override + SortImapResult? parse( + ImapResponse imapResponse, + Response response, + ) { + if (response.isOkStatus) { + final result = SortImapResult() + ..matchingSequence = MessageSequence.fromIds(ids, isUid: isUidSort) + ..highestModSequence = highestModSequence + ..isExtended = isExtended + ..tag = tag + ..min = min + ..max = max + ..count = count + ..partialRange = partialRange; + + return result; + } + + return null; + } + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final details = imapResponse.parseText; + if (details.startsWith('SORT ')) { + return _parseSimpleDetails(details); + } else if (details.startsWith('ESEARCH ')) { + return _parseExtendedDetails(details); + } else if (details == 'SORT' || details == 'ESEARCH') { + // this is an empty search result + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + + bool _parseSimpleDetails(String details) { + final listEntries = parseListEntries(details, 'SORT '.length, null); + if (listEntries == null) { + return false; + } + for (var i = 0; i < listEntries.length; i++) { + final entry = listEntries[i]; + // Maybe MODSEQ should not be supported by SORT (introduced by ESORT?) + if (entry == '(MODSEQ') { + i++; + final modSeqText = + listEntries[i].substring(0, listEntries[i].length - 1); + highestModSequence = int.tryParse(modSeqText); + } else { + final id = int.tryParse(entry); + if (id != null) { + ids.add(id); + } + } + } + + return true; + } + + bool _parseExtendedDetails(String details) { + final listEntries = parseListEntries(details, 'ESEARCH '.length, null); + if (listEntries == null) { + return false; + } + for (var i = 0; i < listEntries.length; i++) { + final entry = listEntries[i]; + if (entry == '(TAG') { + i++; + tag = listEntries[i].substring(1, listEntries[i].length - 2); + // } else if (entry == 'UID') { + // Included for completeness. + } else if (entry == 'MIN') { + i++; + min = int.tryParse(listEntries[i]); + } else if (entry == 'MAX') { + i++; + max = int.tryParse(listEntries[i]); + } else if (entry == 'COUNT') { + i++; + count = int.tryParse(listEntries[i]); + } else if (entry == 'ALL') { + i++; + final seq = + MessageSequence.parse(listEntries[i], isUidSequence: isUidSort); + if (!seq.isNil) { + ids = seq.toList(); + } + } else if (entry == 'MODSEQ') { + i++; + highestModSequence = int.tryParse(listEntries[i]); + } else if (entry == 'PARTIAL') { + i++; + partialRange = listEntries[i].substring(1); + i++; + final seq = MessageSequence.parse( + listEntries[i].substring(0, listEntries[i].length - 1), + isUidSequence: isUidSort, + ); + if (!seq.isNil) { + ids = seq.toList(); + } + } + } + + return true; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/status_parser.dart b/packages/enough_mail/lib/src/private/imap/status_parser.dart new file mode 100644 index 0000000..2199404 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/status_parser.dart @@ -0,0 +1,72 @@ +import '../../imap/mailbox.dart'; +import '../../imap/response.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses status responses +class StatusParser extends ResponseParser { + /// Creates a new parser + StatusParser(this.box) : _regex = RegExp(r'(STATUS "[^"]+?" )(.*)'); + + /// The current mailbox + Mailbox box; + + final RegExp _regex; + + @override + Mailbox? parse(ImapResponse imapResponse, Response response) => + response.isOkStatus ? box : null; + + @override + bool parseUntagged(ImapResponse imapResponse, Response? response) { + final details = imapResponse.parseText; + if (details.startsWith('STATUS ')) { + final startIndex = _findStartIndex(details); + if (startIndex == -1) { + return false; + } + final listEntries = parseListEntries(details, startIndex + 1, ')'); + if (listEntries == null) { + return false; + } + for (var i = 0; i < listEntries.length; i += 2) { + final entry = listEntries[i]; + final 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.messagesUnseen = value; + break; + default: + print( + 'unexpected STATUS: $entry=${listEntries[i + 1]}\nin $details', + ); + } + } + + return true; + } else { + return super.parseUntagged(imapResponse, response); + } + } + + int _findStartIndex(String details) { + final matches = _regex.allMatches(details); + if (matches.isNotEmpty && matches.first.groupCount == 2) { + return matches.first.group(1)?.length ?? -1; + } + + return -1; + } +} diff --git a/packages/enough_mail/lib/src/private/imap/thread_parser.dart b/packages/enough_mail/lib/src/private/imap/thread_parser.dart new file mode 100644 index 0000000..9917869 --- /dev/null +++ b/packages/enough_mail/lib/src/private/imap/thread_parser.dart @@ -0,0 +1,58 @@ +import '../../../enough_mail.dart'; +import 'imap_response.dart'; +import 'response_parser.dart'; + +/// Parses responses to THREAD commands +class ThreadParser extends ResponseParser { + /// Creates a new parser + ThreadParser({required bool isUidSequence}) + : result = SequenceNode.root(isUid: isUidSequence); + + /// The resulting tree structure + final SequenceNode result; + + @override + SequenceNode? parse( + ImapResponse imapResponse, + Response response, + ) => + response.isOkStatus ? result : null; + + @override + bool parseUntagged( + ImapResponse imapResponse, + Response? response, + ) { + final text = imapResponse.parseText; + if (text.startsWith('THREAD ')) { + final values = imapResponse.iterate().values; + //print(values); + if (values.length > 1) { + final start = values[1].value == 'THREAD' ? 2 : 1; + for (var i = start; i < values.length; i++) { + final value = values[i]; + addNode(result, value); + } + + return true; + } + } + + return super.parseUntagged(imapResponse, response); + } + + /// Adds the [value] to the [parent] + void addNode(SequenceNode parent, ImapValue value) { + // print('addNode $value'); + final text = value.value; + final SequenceNode added; + added = + text != null ? parent.addChild(int.parse(text)) : parent.addChild(-1); + final children = value.children; + if (children != null) { + for (final child in children) { + addNode(added, child); + } + } + } +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/all_commands.dart b/packages/enough_mail/lib/src/private/pop/commands/all_commands.dart new file mode 100644 index 0000000..991dc3c --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/all_commands.dart @@ -0,0 +1,13 @@ +export 'pop_apop_command.dart'; +export 'pop_delete_command.dart'; +export 'pop_list_command.dart'; +export 'pop_noop_command.dart'; +export 'pop_pass_command.dart'; +export 'pop_quit_command.dart'; +export 'pop_reset_command.dart'; +export 'pop_retrieve_command.dart'; +export 'pop_starttls_command.dart'; +export 'pop_status_command.dart'; +export 'pop_top_command.dart'; +export 'pop_uidl_command.dart'; +export 'pop_user_command.dart'; diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_apop_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_apop_command.dart new file mode 100644 index 0000000..1f9bcb4 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_apop_command.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +import '../pop_command.dart'; + +/// The `APOP` command signs in the user +class PopApopCommand extends PopCommand { + /// Creates a new `APOP` command + PopApopCommand(this.user, String pass, String serverTimestamp) + : super('APOP $user ${toMd5(serverTimestamp + pass)}'); + + /// The user ID + final String user; + + /// Generates the MD5 hash from the [input] + static String toMd5(String input) { + final inputBytes = utf8.encode(input); + final digest = md5.convert(inputBytes); + + return digest.toString(); + } + + @override + String toString() => 'APOP $user '; +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_delete_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_delete_command.dart new file mode 100644 index 0000000..235252f --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_delete_command.dart @@ -0,0 +1,7 @@ +import '../pop_command.dart'; + +/// Deletes a specific message +class PopDeleteCommand extends PopCommand { + /// Creates a new `DELE` request for [messageId] + PopDeleteCommand(int messageId) : super('DELE $messageId'); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_list_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_list_command.dart new file mode 100644 index 0000000..41804a6 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_list_command.dart @@ -0,0 +1,14 @@ +import '../../../pop/pop_response.dart'; +import '../parsers/pop_list_parser.dart'; +import '../pop_command.dart'; + +/// Lists messages or a given specific message +class PopListCommand extends PopCommand> { + /// Creates a new `LIST` command + PopListCommand([int? messageId]) + : super( + messageId == null ? 'LIST' : 'LIST $messageId', + parser: PopListParser(isMultiLine: messageId == null), + isMultiLine: messageId == null, + ); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_noop_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_noop_command.dart new file mode 100644 index 0000000..3fc19a1 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_noop_command.dart @@ -0,0 +1,7 @@ +import '../pop_command.dart'; + +/// Just tests the connection with a NO OP (no operation) +class PopNoOpCommand extends PopCommand { + /// Creates a new `NOOP` command + PopNoOpCommand() : super('NOOP'); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_pass_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_pass_command.dart new file mode 100644 index 0000000..104eed0 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_pass_command.dart @@ -0,0 +1,10 @@ +import '../pop_command.dart'; + +/// Signs in the user using a PASS command +class PopPassCommand extends PopCommand { + /// Creates a new `PASS` command + PopPassCommand(String pass) : super('PASS $pass'); + + @override + String toString() => 'PASS '; +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_quit_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_quit_command.dart new file mode 100644 index 0000000..14f6f6a --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_quit_command.dart @@ -0,0 +1,17 @@ +import '../../../pop/pop_client.dart'; +import '../../../pop/pop_response.dart'; +import '../pop_command.dart'; + +/// Signs out and disconnects from the server +class PopQuitCommand extends PopCommand { + /// Creates a new `QUIT` command + PopQuitCommand(this._client) : super('QUIT'); + final PopClient _client; + + @override + String? nextCommand(PopResponse response) { + _client.disconnect(); + + return null; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_reset_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_reset_command.dart new file mode 100644 index 0000000..da470fd --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_reset_command.dart @@ -0,0 +1,7 @@ +import '../pop_command.dart'; + +/// Resets the connection, un-deleting any messages previously marked as deleted +class PopResetCommand extends PopCommand { + /// Creates a new `RSET` command + PopResetCommand() : super('RSET'); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_retrieve_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_retrieve_command.dart new file mode 100644 index 0000000..266401c --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_retrieve_command.dart @@ -0,0 +1,14 @@ +import '../../../../enough_mail.dart'; +import '../parsers/all_parsers.dart'; +import '../pop_command.dart'; + +/// Retrieves a specific or all messages +class PopRetrieveCommand extends PopCommand { + /// Creates a new `RETR` command + PopRetrieveCommand(int messageId) + : super( + 'RETR $messageId', + parser: PopRetrieveParser(), + isMultiLine: true, + ); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_starttls_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_starttls_command.dart new file mode 100644 index 0000000..ba491a5 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_starttls_command.dart @@ -0,0 +1,9 @@ +import '../pop_command.dart'; + +/// Starts switching to a secure connection +/// +/// Compare https://tools.ietf.org/html/rfc2595 +class PopStartTlsCommand extends PopCommand { + /// Creates a `STLS` command + PopStartTlsCommand() : super('STLS'); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_status_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_status_command.dart new file mode 100644 index 0000000..944fb3a --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_status_command.dart @@ -0,0 +1,9 @@ +import '../../../pop/pop_response.dart'; +import '../parsers/pop_status_parser.dart'; +import '../pop_command.dart'; + +/// Checks the status of the service, ie the number of messages +class PopStatusCommand extends PopCommand { + /// Creates a new `STAT` command + PopStatusCommand() : super('STAT', parser: PopStatusParser()); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_top_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_top_command.dart new file mode 100644 index 0000000..cf37ac8 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_top_command.dart @@ -0,0 +1,14 @@ +import '../../../mime_message.dart'; +import '../parsers/all_parsers.dart'; +import '../pop_command.dart'; + +/// Retrieves a part of the message +class PopTopCommand extends PopCommand { + /// Creates a new `TOP` command + PopTopCommand(int messageId, int lines) + : super( + 'TOP $messageId $lines', + parser: PopRetrieveParser(), + isMultiLine: true, + ); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_uidl_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_uidl_command.dart new file mode 100644 index 0000000..2e58f33 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_uidl_command.dart @@ -0,0 +1,14 @@ +import '../../../pop/pop_response.dart'; +import '../parsers/all_parsers.dart'; +import '../pop_command.dart'; + +/// Lists UIDs of messages or of a specific message +class PopUidListCommand extends PopCommand> { + /// Creates a new `UIDL` command + PopUidListCommand([int? messageId]) + : super( + messageId == null ? 'UIDL' : 'UIDL $messageId', + parser: PopUidListParser(isMultiLine: messageId == null), + isMultiLine: messageId == null, + ); +} diff --git a/packages/enough_mail/lib/src/private/pop/commands/pop_user_command.dart b/packages/enough_mail/lib/src/private/pop/commands/pop_user_command.dart new file mode 100644 index 0000000..a21362d --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/commands/pop_user_command.dart @@ -0,0 +1,7 @@ +import '../pop_command.dart'; + +/// Authenticates the user +class PopUserCommand extends PopCommand { + /// Creates a new `USER` command + PopUserCommand(String user) : super('USER $user'); +} diff --git a/packages/enough_mail/lib/src/private/pop/parsers/all_parsers.dart b/packages/enough_mail/lib/src/private/pop/parsers/all_parsers.dart new file mode 100644 index 0000000..b585532 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/all_parsers.dart @@ -0,0 +1,5 @@ +export 'pop_list_parser.dart'; +export 'pop_retrieve_parser.dart'; +export 'pop_standard_parser.dart'; +export 'pop_status_parser.dart'; +export 'pop_uidl_parser.dart'; diff --git a/packages/enough_mail/lib/src/private/pop/parsers/pop_list_parser.dart b/packages/enough_mail/lib/src/private/pop/parsers/pop_list_parser.dart new file mode 100644 index 0000000..0cdf317 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/pop_list_parser.dart @@ -0,0 +1,45 @@ +import '../../../pop/pop_response.dart'; +import '../pop_response_parser.dart'; + +/// Parses list responses +class PopListParser extends PopResponseParser> { + /// Creates a new a LIST response parser + PopListParser({required this.isMultiLine}); + + /// Are multiple or just a single response line expected? + final bool isMultiLine; + + @override + PopResponse> parse(List responseLines) { + final response = PopResponse>(); + parseOkStatus(responseLines, response); + if (response.isOkStatus) { + final result = []; + response.result = result; + for (final line in responseLines) { + if (line.isEmpty || (isMultiLine && line.startsWith('+OK'))) { + continue; + } + final parts = line.split(' '); + final MessageListing listing; + if (parts.length == 2) { + listing = MessageListing( + id: int.parse(parts[0]), + sizeInBytes: int.parse(parts[1]), + ); + } else if (parts.length == 3) { + // eg '+OK 123 123231' + listing = MessageListing( + id: int.parse(parts[1]), + sizeInBytes: int.parse(parts[2]), + ); + } else { + throw FormatException('Unexpected LIST response line [$line]'); + } + result.add(listing); + } + } + + return response; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/parsers/pop_retrieve_parser.dart b/packages/enough_mail/lib/src/private/pop/parsers/pop_retrieve_parser.dart new file mode 100644 index 0000000..308885d --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/pop_retrieve_parser.dart @@ -0,0 +1,33 @@ +import '../../../mime_data.dart'; +import '../../../mime_message.dart'; +import '../../../pop/pop_response.dart'; +import '../pop_response_parser.dart'; + +/// Parses a message response +class PopRetrieveParser extends PopResponseParser { + @override + PopResponse parse(List responseLines) { + final response = PopResponse(); + parseOkStatus(responseLines, response); + if (response.isOkStatus) { + final message = MimeMessage(); + //lines that start with a dot need to remove the dot first: + final buffer = StringBuffer(); + for (var i = 1; i < responseLines.length; i++) { + var line = responseLines[i]; + if (line.startsWith('.') && line.length > 1) { + line = line.substring(1); + } + buffer + ..write(line) + ..write('\r\n'); + } + message + ..mimeData = TextMimeData(buffer.toString(), containsHeader: true) + ..parse(); + response.result = message; + } + + return response; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/parsers/pop_standard_parser.dart b/packages/enough_mail/lib/src/private/pop/parsers/pop_standard_parser.dart new file mode 100644 index 0000000..4b94b0b --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/pop_standard_parser.dart @@ -0,0 +1,14 @@ +import '../../../pop/pop_response.dart'; +import '../pop_response_parser.dart'; + +/// Parses generic responses +class PopStandardParser extends PopResponseParser { + @override + PopResponse parse(List responseLines) { + final response = PopResponse() + ..result = responseLines.isEmpty ? null : responseLines.first; + parseOkStatus(responseLines, response); + + return response; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/parsers/pop_status_parser.dart b/packages/enough_mail/lib/src/private/pop/parsers/pop_status_parser.dart new file mode 100644 index 0000000..6bdce65 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/pop_status_parser.dart @@ -0,0 +1,24 @@ +import '../../../pop/pop_response.dart'; +import '../pop_response_parser.dart'; + +/// Parses responses to STATUS command +class PopStatusParser extends PopResponseParser { + @override + PopResponse parse(List responseLines) { + final response = PopResponse(); + parseOkStatus(responseLines, response); + if (response.isOkStatus) { + final responseLine = responseLines.first; + if (responseLine.length > '+OK '.length) { + final parts = responseLine.substring('+OK '.length).split(' '); + final numberOfMessages = int.tryParse(parts[0]); + if (numberOfMessages != null) { + final totalSizeInBytes = int.tryParse(parts[1]) ?? 0; + response.result = PopStatus(numberOfMessages, totalSizeInBytes); + } + } + } + + return response; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/parsers/pop_uidl_parser.dart b/packages/enough_mail/lib/src/private/pop/parsers/pop_uidl_parser.dart new file mode 100644 index 0000000..a2f40b6 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/parsers/pop_uidl_parser.dart @@ -0,0 +1,47 @@ +import '../../../pop/pop_response.dart'; +import '../pop_response_parser.dart'; + +/// Parses responses to `UIDL` requests +class PopUidListParser extends PopResponseParser> { + /// Creates a new a UIDL response parser + PopUidListParser({required this.isMultiLine}); + + /// Are multiple or just a single response line expected? + final bool isMultiLine; + + @override + PopResponse> parse(List responseLines) { + final response = PopResponse>(); + parseOkStatus(responseLines, response); + if (response.isOkStatus) { + final result = []; + response.result = result; + for (final line in responseLines) { + if (line.isEmpty || (isMultiLine && line.startsWith('+OK'))) { + continue; + } + final parts = line.split(' '); + final MessageListing listing; + if (parts.length == 2) { + listing = MessageListing( + id: int.parse(parts[0]), + sizeInBytes: 0, + uid: parts[1], + ); + } else if (parts.length == 3) { + // eg '+OK 123 123231' + listing = MessageListing( + id: int.parse(parts[1]), + sizeInBytes: 0, + uid: parts[2], + ); + } else { + throw FormatException('Unexpected UIDL response line [$line]'); + } + result.add(listing); + } + } + + return response; + } +} diff --git a/packages/enough_mail/lib/src/private/pop/pop_command.dart b/packages/enough_mail/lib/src/private/pop/pop_command.dart new file mode 100644 index 0000000..8d04fc9 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/pop_command.dart @@ -0,0 +1,36 @@ +import 'dart:async'; +import '../../pop/pop_response.dart'; +import 'pop_response_parser.dart'; + +/// Encapsulates a POP command +class PopCommand { + /// Creates a new POP command + PopCommand(this._command, {this.parser, this.isMultiLine = false}); + + final String _command; + + /// The command specific parser, if any + PopResponseParser? parser; + + /// Are several response lines expected for this command? + final bool isMultiLine; + + /// Retrieves the command + String get command => _command; + + /// The completer for this command + final Completer completer = Completer(); + + /// Retrieves the next command + /// + /// Compare [isCommandDone] + String? nextCommand(PopResponse response) => null; + + /// Checks if there are more steps to this command + /// + /// Compare [nextCommand] + bool isCommandDone(PopResponse response) => true; + + @override + String toString() => command; +} diff --git a/packages/enough_mail/lib/src/private/pop/pop_response_parser.dart b/packages/enough_mail/lib/src/private/pop/pop_response_parser.dart new file mode 100644 index 0000000..2859379 --- /dev/null +++ b/packages/enough_mail/lib/src/private/pop/pop_response_parser.dart @@ -0,0 +1,13 @@ +import '../../pop/pop_response.dart'; + +/// Parses POP responses +abstract class PopResponseParser { + /// Parses the OK status of the response + void parseOkStatus(List responseLines, PopResponse response) { + response.isOkStatus = responseLines.isNotEmpty && + responseLines.first.trim().startsWith('+OK'); + } + + /// Parses the given response lines + PopResponse parse(List responseLines); +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/all_commands.dart b/packages/enough_mail/lib/src/private/smtp/commands/all_commands.dart new file mode 100644 index 0000000..1abf0fd --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/all_commands.dart @@ -0,0 +1,9 @@ +export 'smtp_auth_cram_md5_command.dart'; +export 'smtp_auth_login_command.dart'; +export 'smtp_auth_plain_command.dart'; +export 'smtp_auth_xoauth2_command.dart'; +export 'smtp_ehlo_command.dart'; +export 'smtp_quit_command.dart'; +export 'smtp_send_bdat_command.dart'; +export 'smtp_sendmail_command.dart'; +export 'smtp_starttls_command.dart'; diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart new file mode 100644 index 0000000..79d933e --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_cram_md5_command.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +/// CRAM-MD5 Authentication +/// +/// Compare https://tools.ietf.org/html/rfc2195 and https://tools.ietf.org/html/rfc4954 for details. +class SmtpAuthCramMd5Command extends SmtpCommand { + /// Creates a new AUTH CRAM-MD5 command + SmtpAuthCramMd5Command(this._userName, this._password) + : super('AUTH CRAM-MD5'); + + final String _userName; + final String _password; + bool _authSent = false; + + @override + String get command => 'AUTH CRAM-MD5'; + + @override + String? nextCommand(SmtpResponse response) { + /* Example flow: +C: AUTH CRAM-MD5 +S: 334 BASE64(NONCE) +C: BASE64(USERNAME, " ", MD5((SECRET XOR opad),MD5((SECRET XOR ipad), NONCE))) +S: 235 Authentication succeeded + */ + if (response.code != 334 && response.code != 235) { + print('Warning: Unexpected status code during AUTH XOAUTH2: ' + '${response.code}. Expected: 334 or 235. \nauthSent=$_authSent'); + } + if (!_authSent) { + _authSent = true; + final base64Nonce = response.message ?? ''; + + return getBase64EncodedData(base64Nonce); + } else { + return null; + } + } + + /// Converts the password using the [base64Nonce] to base64 + String getBase64EncodedData(String base64Nonce) { + // BASE64(USERNAME, " ", + // MD5((SECRET XOR opad),MD5((SECRET XOR ipad), NONCE))) + var password = utf8.encode(_password); + if (password.length > 64) { + final passwordDigest = md5.convert(password); + password = Uint8List.fromList(passwordDigest.bytes); + } + final nonce = base64.decode(base64Nonce); + final hmac = Hmac(md5, password); + final hmacNonce = hmac.convert(nonce); + final input = '$_userName $hmacNonce'; + final complete = utf8.encode(input); + final authBase64Text = base64.encode(complete); + + return authBase64Text; + } + + @override + bool isCommandDone(SmtpResponse response) => _authSent; + + @override + String toString() => 'AUTH XOAUTH2 '; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_login_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_login_command.dart new file mode 100644 index 0000000..08238ab --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_login_command.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +/// Signs in the SMTP user +class SmtpAuthLoginCommand extends SmtpCommand { + /// Creates a new AUTH LOGIN command + SmtpAuthLoginCommand(this._userName, this._password) : super('AUTH LOGIN'); + + final String _userName; + final String _password; + final Base64Codec _codec = const Base64Codec(); + bool _userNameSent = false; + bool _userPasswordSent = false; + + @override + String get command => 'AUTH LOGIN'; + + @override + String? nextCommand(SmtpResponse response) { + if (response.code != 334 && response.code != 235) { + print( + 'Warning: Unexpected status code during AUTH LOGIN: ${response.code}.' + 'Expected: 334 or 235. \nuserNameSent=$_userNameSent, ' + 'userPasswordSent=$_userPasswordSent', + ); + } + if (!_userNameSent) { + _userNameSent = true; + + return _codec.encode(_userName.codeUnits); + } else if (!_userPasswordSent) { + _userPasswordSent = true; + + return _codec.encode(_password.codeUnits); + } else { + return null; + } + } + + @override + bool isCommandDone(SmtpResponse response) => _userPasswordSent; + + @override + String toString() => 'AUTH LOGIN '; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_plain_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_plain_command.dart new file mode 100644 index 0000000..db46c75 --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_plain_command.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import '../smtp_command.dart'; + +/// Authenticates the SMTP user +class SmtpAuthPlainCommand extends SmtpCommand { + /// Creates a new AUTH PLAIN command + SmtpAuthPlainCommand(this.userName, this.password) : super('AUTH PLAIN'); + + /// The user name + final String userName; + + /// The password + final String password; + + @override + String get command { + final combined = '$userName\u{0000}$userName\u{0000}$password'; + const codec = Base64Codec(); + final encoded = codec.encode(combined.codeUnits); + + return 'AUTH PLAIN $encoded'; + } + + @override + String toString() => 'AUTH PLAIN '; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_xoauth2_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_xoauth2_command.dart new file mode 100644 index 0000000..020ed97 --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_auth_xoauth2_command.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +/// Signs in the user with OAUTH 2 +class SmtpAuthXOauth2Command extends SmtpCommand { + /// Creates a new AUTH XOAUTH2 command + SmtpAuthXOauth2Command(this._userName, this._accessToken) + : super('AUTH XOAUTH2'); + + final String? _userName; + final String? _accessToken; + var _authSentSentCounter = 0; + + @override + String get command => 'AUTH XOAUTH2'; + + @override + String? nextCommand(SmtpResponse response) { + if (response.code != 334 && response.code != 235) { + print( + 'Warning: Unexpected status code during AUTH XOAUTH2: ' + '${response.code}. Expected: 334 or 235.\n' + 'authSentCounter=$_authSentSentCounter ', + ); + } + if (_authSentSentCounter == 0) { + _authSentSentCounter = 1; + + return getBase64EncodedData(); + } else if (response.code == 334 && _authSentSentCounter == 1) { + _authSentSentCounter++; + + return ''; // send empty line to receive error details + } else { + return null; + } + } + + /// Retrieve the base64 data for the request + String getBase64EncodedData() { + final authText = + 'user=$_userName\u{0001}auth=Bearer $_accessToken\u{0001}\u{0001}'; + final authBase64Text = base64.encode(utf8.encode(authText)); + + return authBase64Text; + } + + @override + bool isCommandDone(SmtpResponse response) => + response.code != 334 && _authSentSentCounter > 0; + + @override + String toString() => 'AUTH XOAUTH2 '; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_ehlo_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_ehlo_command.dart new file mode 100644 index 0000000..860725f --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_ehlo_command.dart @@ -0,0 +1,23 @@ +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +/// Says hello to the remote service +class SmtpEhloCommand extends SmtpCommand { + /// Creates a new EHLO command + SmtpEhloCommand([this._clientName]) : super('EHLO'); + final String? _clientName; + + @override + String get command { + if (_clientName != null) { + return '${super.command} $_clientName'; + } + + return super.command; + } + + @override + bool isCommandDone(SmtpResponse response) => + (response.type != SmtpResponseType.success) || + (response.responseLines.length > 1); +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_quit_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_quit_command.dart new file mode 100644 index 0000000..f9e339d --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_quit_command.dart @@ -0,0 +1,17 @@ +import '../../../smtp/smtp_client.dart'; +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +/// Signs out of the service +class SmtpQuitCommand extends SmtpCommand { + /// Creates a new QUIT command + SmtpQuitCommand(this._client) : super('QUIT'); + final SmtpClient _client; + + @override + String? nextCommand(SmtpResponse response) { + _client.disconnect(); + + return null; + } +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_send_bdat_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_send_bdat_command.dart new file mode 100644 index 0000000..1c6bd86 --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_send_bdat_command.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../../../mail_address.dart'; +import '../../../mime_data.dart'; +import '../../../mime_message.dart'; +import '../../../smtp/smtp_response.dart'; +import '../smtp_command.dart'; + +enum _BdatSequence { mailFrom, rcptTo, bdat, done } + +class _SmtpSendBdatCommand extends SmtpCommand { + _SmtpSendBdatCommand( + this.getData, + this.fromEmail, + this.recipientEmails, { + required this.use8BitEncoding, + required this.supportUnicode, + }) : super('MAIL FROM') { + final binaryData = _codec.encode(getData()); + _chunks = chunkData(binaryData); + } + + final String Function() getData; + final String? fromEmail; + final List recipientEmails; + final bool use8BitEncoding; + final bool supportUnicode; + _BdatSequence _currentStep = _BdatSequence.mailFrom; + int _recipientIndex = 0; + late List _chunks; + int _chunkIndex = 0; + static const Utf8Codec _codec = Utf8Codec(allowMalformed: true); + + static List chunkData(List binaryData) { + const chunkSize = 512 * 1024; + final result = []; + var startIndex = 0; + final length = binaryData.length; + while (startIndex < length) { + final isLast = startIndex + chunkSize >= length; + final endIndex = isLast ? length : startIndex + chunkSize; + final sublist = binaryData.sublist(startIndex, endIndex); + final bdat = _codec.encode(isLast + ? 'BDAT ${sublist.length} LAST\r\n' + : 'BDAT ${sublist.length}\r\n'); + // combine both: + final chunkData = Uint8List(bdat.length + sublist.length) + ..setRange(0, bdat.length, bdat) + ..setRange(bdat.length, bdat.length + sublist.length, sublist); + result.add(chunkData); + startIndex += chunkSize; + } + + return result; + } + + @override + String get command { + if (supportUnicode) { + print('supportUnicode $supportUnicode'); + // cSpell:ignore SMTPUTF8 + + return 'MAIL FROM:<$fromEmail> SMTPUTF8'; + } + if (use8BitEncoding) { + return 'MAIL FROM:<$fromEmail> BODY=8BITMIME'; + } + + return 'MAIL FROM:<$fromEmail>'; + } + + @override + SmtpCommandData? next(SmtpResponse response) { + final step = _currentStep; + switch (step) { + case _BdatSequence.mailFrom: + _currentStep = _BdatSequence.rcptTo; + _recipientIndex++; + return SmtpCommandData( + _getRecipientToCommand(recipientEmails[0]), + null, + ); + case _BdatSequence.rcptTo: + final index = _recipientIndex; + if (index < recipientEmails.length) { + _recipientIndex++; + + return SmtpCommandData( + _getRecipientToCommand(recipientEmails[index]), + null, + ); + } else if (response.type == SmtpResponseType.success) { + return _getCurrentChunk(); + } else { + return null; + } + case _BdatSequence.bdat: + return _getCurrentChunk(); + default: + return null; + } + } + + SmtpCommandData _getCurrentChunk() { + final chunk = _chunks[_chunkIndex]; + _chunkIndex++; + if (_chunkIndex >= _chunks.length) { + _currentStep = _BdatSequence.done; + } + + return SmtpCommandData(null, chunk); + } + + String _getRecipientToCommand(String email) => 'RCPT TO:<$email>'; + + @override + bool isCommandDone(SmtpResponse response) { + if (_currentStep == _BdatSequence.bdat) { + return response.code == 354; + } + + return (response.type != SmtpResponseType.success) || + (_currentStep == _BdatSequence.done); + } +} + +/// Sends a message using BDAT +class SmtpSendBdatMailCommand extends _SmtpSendBdatCommand { + /// Creates a new BDAT command + SmtpSendBdatMailCommand( + this.message, + MailAddress? from, + List recipientEmails, { + required bool use8BitEncoding, + required bool supportUnicode, + }) : super( + () => message + .renderMessage() + .replaceAll(RegExp('^Bcc:.*\r\n', multiLine: true), ''), + from?.email ?? message.fromEmail, + recipientEmails, + use8BitEncoding: use8BitEncoding, + supportUnicode: supportUnicode, + ); + + /// The message to be sent + final MimeMessage message; +} + +/// Sends a MIME Data via BDAT +class SmtpSendBdatMailDataCommand extends _SmtpSendBdatCommand { + /// Creates a new BDAT command + SmtpSendBdatMailDataCommand( + this.data, + MailAddress from, + List recipientEmails, { + required bool use8BitEncoding, + required bool supportUnicode, + }) : super( + () => data + .toString() + .replaceAll(RegExp('^Bcc:.*\r\n', multiLine: true), ''), + from.email, + recipientEmails, + use8BitEncoding: use8BitEncoding, + supportUnicode: supportUnicode, + ); + + /// The message data to be sent + final MimeData data; +} + +/// Sends message text via BDAT +class SmtpSendBdatMailTextCommand extends _SmtpSendBdatCommand { + /// Creates a new BDAT command + SmtpSendBdatMailTextCommand( + this.data, + MailAddress from, + List recipientEmails, { + required bool use8BitEncoding, + required bool supportUnicode, + }) : super( + () => data, + from.email, + recipientEmails, + use8BitEncoding: use8BitEncoding, + supportUnicode: supportUnicode, + ); + + /// The message text data + final String data; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_sendmail_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_sendmail_command.dart new file mode 100644 index 0000000..d7f127c --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_sendmail_command.dart @@ -0,0 +1,135 @@ +import '../../../../enough_mail.dart'; +import '../smtp_command.dart'; + +enum _SmtpSendCommandSequence { mailFrom, rcptTo, data, done } + +class _SmtpSendCommand extends SmtpCommand { + _SmtpSendCommand( + this.getData, + this.fromEmail, + this.recipientEmails, { + required this.use8BitEncoding, + }) : super('MAIL FROM'); + + final String Function() getData; + final String? fromEmail; + final List recipientEmails; + final bool use8BitEncoding; + _SmtpSendCommandSequence _currentStep = _SmtpSendCommandSequence.mailFrom; + int _recipientIndex = 0; + + @override + String get command { + if (use8BitEncoding) { + return 'MAIL FROM:<$fromEmail> BODY=8BITMIME'; + } + + return 'MAIL FROM:<$fromEmail>'; + } + + @override + String? nextCommand(SmtpResponse response) { + final step = _currentStep; + switch (step) { + case _SmtpSendCommandSequence.mailFrom: + _currentStep = _SmtpSendCommandSequence.rcptTo; + _recipientIndex++; + return _getRecipientToCommand(recipientEmails[0]); + case _SmtpSendCommandSequence.rcptTo: + final index = _recipientIndex; + if (index < recipientEmails.length) { + _recipientIndex++; + + return _getRecipientToCommand(recipientEmails[index]); + } else if (response.type == SmtpResponseType.success) { + _currentStep = _SmtpSendCommandSequence.data; + + return 'DATA'; + } else { + return null; + } + case _SmtpSendCommandSequence.data: + _currentStep = _SmtpSendCommandSequence.done; + + final data = getData(); + + // \r\n.\r\n is the data stop sequence, so 'pad' this sequence in the message data + return '${data.replaceAll('\r\n.\r\n', '\r\n..\r\n')}\r\n.'; + default: + return null; + } + } + + String _getRecipientToCommand(String email) => 'RCPT TO:<$email>'; + + @override + bool isCommandDone(SmtpResponse response) { + if (_currentStep == _SmtpSendCommandSequence.data) { + return response.code == 354; + } + + return (response.type != SmtpResponseType.success) || + (_currentStep == _SmtpSendCommandSequence.done); + } +} + +/// Sends a MIME message +class SmtpSendMailCommand extends _SmtpSendCommand { + /// Creates a new DATA command + SmtpSendMailCommand( + this.message, + MailAddress? from, + List recipientEmails, { + required bool use8BitEncoding, + }) : super( + () => message + .renderMessage() + .replaceAll(RegExp('^Bcc:.*\r\n', multiLine: true), ''), + from?.email ?? message.fromEmail, + recipientEmails, + use8BitEncoding: use8BitEncoding, + ); + + /// The message to be sent + final MimeMessage message; +} + +/// Sends the message data +class SmtpSendMailDataCommand extends _SmtpSendCommand { + /// Creates a new DATA command + SmtpSendMailDataCommand( + this.data, + MailAddress from, + List recipientEmails, { + required bool use8BitEncoding, + }) : super( + () => data + .toString() + .replaceAll(RegExp('^Bcc:.*\r\n', multiLine: true), ''), + from.email, + recipientEmails, + use8BitEncoding: use8BitEncoding, + ); + + /// The message data to be sent + final MimeData data; +} + +/// Sends textual message data +class SmtpSendMailTextCommand extends _SmtpSendCommand { + /// Creates a new DATA command + SmtpSendMailTextCommand( + this.data, + MailAddress from, + List recipientEmails, { + required bool use8BitEncoding, + }) : super( + () => data, + from.email, + recipientEmails, + use8BitEncoding: use8BitEncoding, + ); + + /// The message text data to be sent + final String data; +} diff --git a/packages/enough_mail/lib/src/private/smtp/commands/smtp_starttls_command.dart b/packages/enough_mail/lib/src/private/smtp/commands/smtp_starttls_command.dart new file mode 100644 index 0000000..60f742a --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/commands/smtp_starttls_command.dart @@ -0,0 +1,7 @@ +import '../smtp_command.dart'; + +/// Triggers conversion to a secure connection +class SmtpStartTlsCommand extends SmtpCommand { + /// Creates a new STARTTLS command + SmtpStartTlsCommand() : super('STARTTLS'); +} diff --git a/packages/enough_mail/lib/src/private/smtp/smtp_command.dart b/packages/enough_mail/lib/src/private/smtp/smtp_command.dart new file mode 100644 index 0000000..200cca2 --- /dev/null +++ b/packages/enough_mail/lib/src/private/smtp/smtp_command.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import '../../smtp/smtp_response.dart'; + +/// Contains a SMTP command +class SmtpCommand { + /// Creates a new command + SmtpCommand(this._command); + + final String _command; + + /// Retrieves the command + String get command => _command; + + /// The completer of this command + final Completer completer = Completer(); + + /// Tries to retrieve the next command data + SmtpCommandData? next(SmtpResponse response) { + final text = nextCommand(response); + if (text != null) { + return SmtpCommandData(text, null); + } + final data = nextCommandData(response); + if (data != null) { + return SmtpCommandData(null, data); + } + + return null; + } + + /// Tries to retrieve the next command + String? nextCommand(SmtpResponse response) => null; + + /// Tries to return the next command data + List? nextCommandData(SmtpResponse response) => null; + + /// Checks if the current command is done + bool isCommandDone(SmtpResponse response) => true; + + @override + String toString() => command; +} + +/// Contains command-specific data +class SmtpCommandData { + /// Creates a new data + SmtpCommandData(this.text, this.data); + + /// The textual data + final String? text; + + /// The binary data + final List? data; +} diff --git a/packages/enough_mail/lib/src/private/util/ascii_runes.dart b/packages/enough_mail/lib/src/private/util/ascii_runes.dart new file mode 100644 index 0000000..a4d0f07 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/ascii_runes.dart @@ -0,0 +1,86 @@ +/// Common ASCII codes +class AsciiRunes { + /// tab + static const int runeTab = 9; + + /// LF, \n on Unix systems + static const int runeLineFeed = 10; + + /// CR, together with LF - so CRLF - a line break in IMAP, POP3 and SMTP + static const int runeCarriageReturn = 13; + + /// space + static const int runeSpace = 32; + + /// "double quote" + static const int runeDoubleQuote = 34; + + /// & + static const int runeAmpersand = 38; + + /// 'single quote' + static const int runeSingleQuote = 39; + + /// ( + static const int runeOpeningParentheses = 40; + + /// ) + static const int runeClosingParentheses = 41; + + /// , + static const int runeComma = 44; + + /// - + static const int runeMinus = 45; + + /// . + static const int runeDot = 46; + + /// / + static const int runeSlash = 47; + + /// 0 + static const int rune0 = 48; + + /// 9 + static const int rune9 = 57; + + /// ; + static const int runeSemicolon = 59; + + /// < + static const int runeSmallerThan = 60; + + /// = + static const int runeEquals = 61; + + /// > + static const int runeGreaterThan = 62; + + /// @ + static const int runeAt = 64; + + /// A + static const int runeAUpperCase = 65; + + /// Z + static const int runeZUpperCase = 90; + + /// [ + static const int runeOpeningBracket = 91; + + /// \ + static const int runeBackslash = 92; + + /// ] + static const int runeClosingBracket = 93; + + /// _ + static const int runeUnderline = 95; + + /// a + static const int runeALowerCase = 97; + + /// z + static const int runeZLowerCase = 122; +} diff --git a/packages/enough_mail/lib/src/private/util/byte_utils.dart b/packages/enough_mail/lib/src/private/util/byte_utils.dart new file mode 100644 index 0000000..1d943f4 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/byte_utils.dart @@ -0,0 +1,27 @@ +/// Helps with byte arrays +class ByteUtils { + /// Finds a [sequence] of bytes into a [pool], + /// returns the starting position or -1 if not found. + static int findSequence(final List pool, final List sequence) { + // The pool size is reduced by the sequence length to + // avoid the eventual overflow + final dataSize = pool.length - sequence.length; + final needleSize = sequence.length; + var result = -1; + for (var pos = 0; pos < dataSize; pos++) { + var matchFound = true; + for (var j = 0; j < needleSize; j++) { + if (pool[pos + j] != sequence[j]) { + matchFound = false; + break; + } + } + if (matchFound) { + result = pos; + break; + } + } + + return result; + } +} diff --git a/packages/enough_mail/lib/src/private/util/client_base.dart b/packages/enough_mail/lib/src/private/util/client_base.dart new file mode 100644 index 0000000..72c726e --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/client_base.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +/// Provides connection information +class ConnectionInfo { + /// Creates a new connection info + const ConnectionInfo(this.host, this.port, {required this.isSecure}); + + /// The host + final String host; + + /// The port + final int port; + + /// `true` when a secure socket is used + final bool isSecure; +} + +/// Base class for socket-based clients +abstract class ClientBase { + /// Creates a new base client + /// + /// Set [isLogEnabled] to `true` to see log output. + /// + /// Set the [logName] for adding the name to each log entry. + /// + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + ClientBase({ + this.isLogEnabled = false, + this.logName, + this.onBadCertificate, + }); + + /// Initial for a client log output + static const String initialClient = 'C'; + + /// Initial for a server log output + static const String initialServer = 'S'; + + /// Initial for an app log output + static const String initialApp = 'A'; + + /// The name shown in log entries to differentiate this server + String? logName; + + /// `true` when the log is enabled + bool isLogEnabled; + + late Socket _socket; + + /// `true` when it is expected that the socket is closed + bool isSocketClosingExpected = false; + + /// `true` after the user has authenticated + bool isLoggedIn = false; + + bool _isServerGreetingDone = false; + + /// Information about the connection + late ConnectionInfo connectionInfo; + late Completer _greetingsCompleter; + + bool _isConnected = false; + + /// Ist the client currently connected? + bool get isConnected => _isConnected; + + /// Handles unverifiable certificates. + /// + /// The handler receives the [X509Certificate], and can inspect it and decide + /// (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + final bool Function(X509Certificate)? onBadCertificate; + + /// Is called when data is received + void onDataReceived(Uint8List data); + + /// Is called after the initial connection has been established + FutureOr onConnectionEstablished( + ConnectionInfo connectionInfo, + String serverGreeting, + ); + + /// Is called when the connection encountered an error + void onConnectionError(dynamic error); + + late StreamSubscription _socketStreamSubscription; + + /// Connects to the specified server. + /// + /// Specify [isSecure] if you do not want to connect to a secure service. + /// + /// Specify [timeout] to specify a different timeout for the connection. + /// This defaults to 20 seconds. + Future connectToServer( + String host, + int port, { + bool isSecure = true, + Duration timeout = const Duration(seconds: 20), + }) async { + logApp( + 'connecting to server $host:$port - ' + 'secure: $isSecure, timeout: $timeout', + ); + connectionInfo = ConnectionInfo(host, port, isSecure: isSecure); + final socket = isSecure + ? await SecureSocket.connect( + host, + port, + onBadCertificate: onBadCertificate, + ).timeout(timeout) + : await Socket.connect(host, port).timeout(timeout); + _greetingsCompleter = Completer(); + _isServerGreetingDone = false; + connect(socket); + + return _greetingsCompleter.future; + } + + /// Starts to listen on the given [socket]. + /// + /// This is mainly useful for testing purposes, ensure to set + /// [connectionInformation] manually in this case, e.g. + /// ```dart + /// await client.connect(socket, connectionInformation: + /// ConnectionInfo(host, port, isSecure)); + /// ``` + void connect(Socket socket, {ConnectionInfo? connectionInformation}) { + if (connectionInformation != null) { + connectionInfo = connectionInformation; + _greetingsCompleter = Completer(); + } + _socket = socket; + _writeFuture = null; + // if (connectionTimeout != null) { + // final timeoutStream = socket.timeout(connectionTimeout!); + // _socketStreamSubscription = timeoutStream.listen( + // _onDataReceived, + // onDone: onConnectionDone, + // onError: _onConnectionError, + // ); + // } else { + _socketStreamSubscription = socket.listen( + _onDataReceived, + onDone: onConnectionDone, + onError: _onConnectionError, + ); + // } + _isConnected = true; + isSocketClosingExpected = false; + } + + Future _onConnectionError(Object e, StackTrace s) async { + logApp('Socket error: $e $s'); + isLoggedIn = false; + _isConnected = false; + _writeFuture = null; + if (!isSocketClosingExpected) { + isSocketClosingExpected = true; + try { + await _socketStreamSubscription.cancel(); + } catch (e, s) { + logApp('Unable to cancel stream subscription: $e $s'); + } + try { + onConnectionError(e); + } catch (e, s) { + logApp('Unable to call onConnectionError: $e, $s'); + } + } + } + + /// Upgrades the current connection to a secure socket + Future upgradeToSslSocket() async { + _socketStreamSubscription.pause(); + final secureSocket = await SecureSocket.secure(_socket); + logApp('now using secure connection.'); + await _socketStreamSubscription.cancel(); + isSocketClosingExpected = true; + _socket.destroy(); + isSocketClosingExpected = false; + connect(secureSocket); + } + + Future _onDataReceived(Uint8List data) async { + if (_isServerGreetingDone) { + onDataReceived(data); + } else { + _isServerGreetingDone = true; + final serverGreeting = String.fromCharCodes(data); + log(serverGreeting, isClient: false); + onConnectionEstablished(connectionInfo, serverGreeting); + _greetingsCompleter.complete(connectionInfo); + } + } + + /// Informs about a closed connection + void onConnectionDone() { + logApp('Done, connection closed'); + isLoggedIn = false; + _isConnected = false; + if (!isSocketClosingExpected) { + isSocketClosingExpected = true; + onConnectionError('onDone not expected'); + } + } + + /// Disconnects from the service + Future disconnect() async { + if (_isConnected) { + isLoggedIn = false; + _isConnected = false; + isSocketClosingExpected = true; + try { + await _socketStreamSubscription.cancel(); + } catch (e) { + print('unable to cancel subscription $e'); + } + try { + await _socket.close(); + } catch (e) { + print('unable to close socket $e'); + } + } + } + + Future? _writeFuture; + + /// Writes the specified [text]. + /// + /// When the log is enabled it will either log the specified [logObject] + /// or just the [text]. + /// + /// When a [timeout] is specified and occurs, it will + /// throw a [TimeoutException] after the specified time. + Future writeText(String text, [dynamic logObject, Duration? timeout]) async { + final previousWriteFuture = _writeFuture; + if (previousWriteFuture != null) { + try { + await previousWriteFuture; + } catch (e, s) { + print('Unable to await previous write ' + // ignore: unawaited_futures + 'future $previousWriteFuture: $e $s'); + _writeFuture = null; + rethrow; + } + } + if (isLogEnabled) { + logObject ??= text; + log(logObject); + } + _socket.write('$text\r\n'); + + final future = + timeout == null ? _socket.flush() : _socket.flush().timeout(timeout); + _writeFuture = future; + await future; + _writeFuture = null; + } + + /// Writes the specified [data]. + /// + /// When the log is enabled it will either log the specified + /// [logObject] or just the length of the data. + Future writeData(List data, [dynamic logObject]) async { + final previousWriteFuture = _writeFuture; + if (previousWriteFuture != null) { + try { + await previousWriteFuture; + } catch (e, s) { + print('Unable to await previous write future: $e $s'); + _writeFuture = null; + } + } + if (isLogEnabled) { + logObject ??= '<${data.length} bytes>'; + log(logObject); + } + _socket.add(data); + final future = _socket.flush(); + _writeFuture = future; + await future; + _writeFuture = null; + } + + /// Logs the data from the app-side + void logApp(dynamic logObject) => log(logObject, initial: initialApp); + + /// Logs the data from the client-side + void logClient(dynamic logObject) => log(logObject, initial: initialClient); + + /// Logs the data from the server-side + void logServer(dynamic logObject) => log(logObject, initial: initialServer); + + /// Logs the data + void log(dynamic logObject, {bool isClient = true, String? initial}) { + if (isLogEnabled) { + initial ??= isClient ? initialClient : initialServer; + if (logName != null) { + print('$logName $initial: $logObject'); + } else { + print('$initial: $logObject'); + } + } + } + + void _onTimeout(Completer completer, Duration duration) { + // print( + // '$completer triggers timeout after $duration on + // $this at ${DateTime.now()}'); + completer.completeError(createClientError('timeout')); + } + + /// Subclasses need to be able to create client specific exceptions + Object createClientError(String message); +} + +/// Extends Completer instances +extension ExtensionCompleter on Completer { + /// Adds a timeout to a completer + void timeout(Duration? duration, ClientBase client) { + if (duration != null) { + Future.delayed(duration).then((value) { + if (!isCompleted) { + client._onTimeout(this, duration); + } + }); + } + } +} + +// class _QueuedText { +// final String text; +// final dynamic logObject; +// _QueuedText(this.text, this.logObject); +// } diff --git a/packages/enough_mail/lib/src/private/util/discover_helper.dart b/packages/enough_mail/lib/src/private/util/discover_helper.dart new file mode 100644 index 0000000..7fa9ab7 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/discover_helper.dart @@ -0,0 +1,532 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:basic_utils/basic_utils.dart' as basic; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:xml/xml.dart' as xml; + +import '../../discover/client_config.dart'; +import 'http_helper.dart'; +import 'non_nullable.dart'; + +/// Lowlevel helper methods for mail scenarios +class DiscoverHelper { + static const _timeout = Duration(seconds: 20); + + /// Extracts the domain from the email address (the part after the @) + static String getDomainFromEmail(String emailAddress) => + emailAddress.substring(emailAddress.lastIndexOf('@') + 1); + + /// Extracts the local part from the email address (the part before the @) + static String getLocalPartFromEmail(String emailAddress) => + emailAddress.substring(0, emailAddress.lastIndexOf('@')); + + /// Determines the user name from the given [email] address and [config] + static String getUserName(ServerConfig config, String email) => + (config.usernameType == UsernameType.emailAddress) + ? email + : (config.usernameType == UsernameType.unknown) + ? config.username + : getLocalPartFromEmail(email); + + /// Automatically discovers mail configuration from sub-domain + /// + /// compare: https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration + static Future discoverFromAutoConfigSubdomain( + String emailAddress, { + String? domain, + bool isLogEnabled = false, + }) async { + domain ??= getDomainFromEmail(emailAddress); + var url = + 'https://autoconfig.$domain/mail/config-v1.1.xml?emailaddress=$emailAddress'; + if (isLogEnabled) { + print('Discover: trying $url'); + } + var response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + if (_isInvalidAutoConfigResponse(response)) { + url = // try insecure lookup: + 'http://autoconfig.$domain/mail/config-v1.1.xml?emailaddress=$emailAddress'; + if (isLogEnabled) { + print('Discover: trying $url'); + } + response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + if (_isInvalidAutoConfigResponse(response)) { + return null; + } + } + final text = response.text; + + return text == null || text.isEmpty ? null : parseClientConfig(text); + } + + static bool _isInvalidAutoConfigResponse(HttpResult response) { + final text = response.text; + + return response.statusCode != 200 || + (text == null) || + (text.isEmpty) || + (!text.startsWith('<')); + } + + /// Looks up domain referenced by the email's domain DNS MX record + static Future discoverMxDomainFromEmail(String emailAddress) async { + final domain = getDomainFromEmail(emailAddress); + + return discoverMxDomain(domain); + } + + /// Looks up domain referenced by the domain's DNS MX record + static Future discoverMxDomain(String domain) async { + final mxRecords = + await basic.DnsUtils.lookupRecord(domain, basic.RRecordType.MX); + if (mxRecords == null || mxRecords.isEmpty) { + //print('unable to read MX records for [$domain].'); + return null; + } + // for (var mxRecord in mxRecords) { + // print( + // 'mx for [$domain]: ${mxRecord.name}=${mxRecord.data} ' + // '- rType=${mxRecord.rType}'); + // } + var mxDomain = mxRecords.first.data; + final dotIndex = mxDomain.indexOf('.'); + if (dotIndex == -1) { + return null; + } + final lastDotIndex = mxDomain.lastIndexOf('.'); + if (lastDotIndex <= dotIndex - 1) { + return null; + } + mxDomain = mxDomain.substring(dotIndex + 1, lastDotIndex); + + return mxDomain; + } + + /// Automatically discovers mail configuration from Mozilla ISP DB + /// + /// Compare: https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration + static Future discoverFromIspDb( + String? domain, { + bool isLogEnabled = false, + }) async { + //print('Querying ISP DB for $domain'); + final url = 'https://autoconfig.thunderbird.net/v1.1/$domain'; + if (isLogEnabled) { + print('Discover: trying $url'); + } + final response = await HttpHelper.httpGet(url, connectionTimeout: _timeout); + //print('got response ${response.statusCode}'); + if (response.statusCode != 200) { + return null; + } + final text = response.text; + + return text == null || text.isEmpty ? null : parseClientConfig(text); + } + + /// Discovers settings from the list of [domains] + static Future discoverFromCommonDomains( + List domains, { + bool isLogEnabled = false, + }) async { + assert(domains.isNotEmpty, 'At least 1 input domain is required'); + final baseDomain = domains.first; + final variations = _generateDomainBasedVariations(baseDomain); + for (var i = 1; i < domains.length; i++) { + _generateDomainBasedVariations(domains[i], variations); + } + + return discoverFromConnections( + baseDomain, + variations, + isLogEnabled: isLogEnabled, + ); + } + + /// Discovers the settings from the given [baseDomain] + static Future discoverFromConnections( + String baseDomain, + List connectionInfos, { + bool isLogEnabled = false, + }) async { + final futures = >[]; + for (final info in connectionInfos) { + futures.add(_tryToConnect(info, isLogEnabled)); + } + final results = await Future.wait(futures); + final imapInfo = + results.firstWhereOrNull((info) => info.ready(ServerType.imap)); + final popInfo = + results.firstWhereOrNull((info) => info.ready(ServerType.pop)); + final smtpInfo = + results.firstWhereOrNull((info) => info.ready(ServerType.smtp)); + if ((imapInfo == null && popInfo == null) || (smtpInfo == null)) { + print( + 'failed to find settings for $baseDomain: ' + 'imap: ${imapInfo != null ? 'ok' : 'failure'} ' + 'pop: ${popInfo != null ? 'ok' : 'failure'} ' + 'smtp: ${smtpInfo != null ? 'ok' : 'failure'}', + ); + + return null; + } + final preferredIncomingInfo = (imapInfo != null && imapInfo.isSecure) + ? imapInfo + : (popInfo != null && popInfo.isSecure) + ? popInfo + : imapInfo ?? popInfo.toValueOrThrow('failed to find settings'); + if (isLogEnabled) { + print(''); + print('found mail server for $baseDomain:'); + print('incoming: ${preferredIncomingInfo.host}:' + '${preferredIncomingInfo.port} ' + '(${preferredIncomingInfo.serverType})'); + print( + 'outgoing: ${smtpInfo.host}:${smtpInfo.port} ' + '(${smtpInfo.serverType})', + ); + } + final incoming = ServerConfig( + hostname: preferredIncomingInfo.host, + port: preferredIncomingInfo.port, + type: preferredIncomingInfo.serverType, + socketType: + preferredIncomingInfo.isSecure ? SocketType.ssl : SocketType.starttls, + usernameType: UsernameType.unknown, + authentication: Authentication.unknown, + ); + final outgoing = ServerConfig( + hostname: smtpInfo.host, + port: smtpInfo.port, + type: smtpInfo.serverType, + socketType: smtpInfo.isSecure ? SocketType.ssl : SocketType.starttls, + usernameType: UsernameType.unknown, + authentication: Authentication.unknown, + ); + final config = ClientConfig(version: '1') + ..emailProviders = [ + ConfigEmailProvider( + displayName: baseDomain, + domains: [baseDomain], + displayShortName: baseDomain, + id: baseDomain, + incomingServers: [incoming], + outgoingServers: [outgoing], + ) + ..preferredIncomingServer = incoming + ..preferredIncomingImapServer = + incoming.type == ServerType.imap ? incoming : null + ..preferredIncomingPopServer = + incoming.type == ServerType.pop ? incoming : null + ..preferredOutgoingServer = outgoing + ..preferredOutgoingSmtpServer = outgoing, + ]; + + return config; + } + + static Future _tryToConnect( + DiscoverConnectionInfo info, + bool isLogEnabled, + ) async { + try { + // ignore: close_sinks + final socket = info.isSecure + ? await SecureSocket.connect( + info.host, + info.port, + timeout: const Duration(seconds: 10), + ) + : await Socket.connect( + info.host, + info.port, + timeout: const Duration(seconds: 10), + ); + info.socket = socket; + if (isLogEnabled) { + print('success at ${info.host}:${info.port}'); + } + } on Exception { + // ignore connection error + if (isLogEnabled) { + print('failed at ${info.host}:${info.port}'); + } + } + + return info; + } + + static List _generateDomainBasedVariations( + String? baseDomain, [ + List? infos, + ]) { + infos ??= []; + var host = 'imap.$baseDomain'; + addIncomingVariations(host, infos); + host = 'mail.$baseDomain'; + addIncomingVariations(host, infos); + host = 'in.$baseDomain'; + addIncomingVariations(host, infos); + host = 'pop.$baseDomain'; + addIncomingVariations(host, infos); + host = 'smtp.$baseDomain'; + addOutgoingVariations(host, infos); + host = 'out.$baseDomain'; + addOutgoingVariations(host, infos); + + return infos; + } + + /// Adds common incoming variations + static void addIncomingVariations( + String host, + List infos, + ) { + infos + ..add(DiscoverConnectionInfo(host, 993, ServerType.imap, isSecure: true)) + ..add(DiscoverConnectionInfo(host, 143, ServerType.imap, isSecure: false)) + ..add(DiscoverConnectionInfo(host, 995, ServerType.pop, isSecure: true)) + ..add(DiscoverConnectionInfo(host, 110, ServerType.pop, isSecure: false)); + } + + /// Adds common outgoing variations + static void addOutgoingVariations( + String host, + List infos, + ) { + infos + ..add(DiscoverConnectionInfo(host, 465, ServerType.smtp, isSecure: true)) + ..add(DiscoverConnectionInfo(host, 587, ServerType.smtp, isSecure: false)) + ..add(DiscoverConnectionInfo(host, 25, ServerType.smtp, isSecure: false)); + } + + /// Parses a Mozilla-compatible autoconfig file + /// + /// Compare: https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat + static ClientConfig? parseClientConfig(String definition) { + //print(definition); + final config = ClientConfig(); + try { + final document = xml.XmlDocument.parse(definition); + for (final node in document.children) { + if (node is xml.XmlElement && node.name.local == 'clientConfig') { + final versionAttributes = + node.attributes.where((a) => a.name.local == 'version'); + config.version = versionAttributes.isNotEmpty + ? versionAttributes.first.value + : '1.1'; + final providerNodes = node.children.where( + (c) => c is xml.XmlElement && c.name.local == 'emailProvider', + ); + for (final providerNode in providerNodes) { + if (providerNode is xml.XmlElement) { + final provider = ConfigEmailProvider(); + // ignore: cascade_invocations + provider.id = providerNode.getAttribute('id'); + for (final providerChild in providerNode.children) { + if (providerChild is xml.XmlElement) { + switch (providerChild.name.local) { + case 'domain': + provider.addDomain(providerChild.innerText); + break; + case 'displayName': + provider.displayName = providerChild.innerText; + break; + case 'displayShortName': + provider.displayShortName = providerChild.innerText; + break; + case 'incomingServer': + provider + .addIncomingServer(_parseServerConfig(providerChild)); + break; + case 'outgoingServer': + provider + .addOutgoingServer(_parseServerConfig(providerChild)); + break; + case 'documentation': + provider.documentationUrl ??= + providerChild.getAttribute('url'); + break; + } + } + } + config.addEmailProvider(provider); + } + } + break; + } + } + } catch (e) { + print(e); + print('unable to parse: \n$definition\n'); + } + if (config.isNotValid) { + return null; + } + + return config; + } + + static ServerConfig _parseServerConfig(xml.XmlElement serverElement) { + final typeName = serverElement.getAttribute('type'); + final children = + serverElement.children.whereType().toList(); + final hostname = + children.firstWhereOrNull((e) => e.name.local == 'hostname')?.innerText; + final port = + children.firstWhereOrNull((e) => e.name.local == 'port')?.innerText; + final socketTypeName = children + .firstWhereOrNull((e) => e.name.local == 'socketType') + ?.innerText; + final authenticationElements = + children.where((e) => e.name.local == 'authentication').toList(); + final authenticationName = authenticationElements.isNotEmpty + ? authenticationElements.first.innerText + : null; + final authenticationAlternativeName = authenticationElements.length > 1 + ? authenticationElements.last.innerText + : null; + final username = + children.firstWhereOrNull((e) => e.name.local == 'username')?.innerText; + + final serverType = _serverTypeFromText(typeName); + + int defaultPort() { + switch (serverType) { + case ServerType.imap: + return 143; + case ServerType.pop: + return 110; + case ServerType.smtp: + return 25; + default: + return 0; + } + } + + return ServerConfig( + type: serverType, + hostname: hostname ?? '', + port: port != null ? int.tryParse(port) ?? 0 : defaultPort(), + socketType: _socketTypeFromText(socketTypeName), + authentication: _authenticationFromText(authenticationName), + authenticationAlternative: authenticationAlternativeName == null + ? null + : _authenticationFromText(authenticationAlternativeName), + usernameType: _usernameTypeFromText(username), + ); + } + + static ServerType _serverTypeFromText(String? text) { + ServerType type; + switch (text?.toLowerCase()) { + case 'imap': + type = ServerType.imap; + break; + case 'pop3': + type = ServerType.pop; + break; + case 'smtp': + type = ServerType.smtp; + break; + default: + type = ServerType.unknown; + } + + return type; + } + + static SocketType _socketTypeFromText(String? text) { + SocketType type; + switch (text?.toUpperCase()) { + case 'SSL': + type = SocketType.ssl; + break; + case 'STARTTLS': + type = SocketType.starttls; + break; + case 'PLAIN': + type = SocketType.plain; + break; + default: + type = SocketType.unknown; + } + + return type; + } + + static Authentication _authenticationFromText(String? text) { + switch (text?.toLowerCase()) { + case 'oauth2': + return Authentication.oauth2; + // cSpell: disable-next-line + case 'password-cleartext': + return Authentication.passwordClearText; + case 'plain': + return Authentication.plain; + case 'password-encrypted': + return Authentication.passwordEncrypted; + case 'secure': + return Authentication.secure; + // cSpell: ignore ntlm + case 'ntlm': + return Authentication.ntlm; + // cSpell: ignore gsapi + case 'gsapi': + return Authentication.gsapi; + case 'client-ip-address': + return Authentication.clientIpAddress; + case 'tls-client-cert': + return Authentication.tlsClientCert; + case 'smtp-after-pop': + return Authentication.smtpAfterPop; + case 'none': + return Authentication.none; + default: + return Authentication.unknown; + } + } + + static UsernameType _usernameTypeFromText(String? text) { + switch (text?.toUpperCase()) { + case '%EMAILADDRESS%': + return UsernameType.emailAddress; + case '%EMAILLOCALPART%': + return UsernameType.emailLocalPart; + case '%REALNAME%': + return UsernameType.realName; + default: + return UsernameType.unknown; + } + } +} + +/// Provides information about a connection +class DiscoverConnectionInfo { + /// Creates a new info object + DiscoverConnectionInfo( + this.host, + this.port, + this.serverType, { + required this.isSecure, + }); + + /// The host + final String host; + + /// The port + final int port; + + /// If a SSL connection is used + final bool isSecure; + + /// The server type + final ServerType serverType; + + /// The used socket, when not null the caller is required to close it + Socket? socket; + + /// Checks if the server is ready to be used + bool ready(ServerType type) => serverType == type && socket != null; +} diff --git a/packages/enough_mail/lib/src/private/util/http_helper.dart b/packages/enough_mail/lib/src/private/util/http_helper.dart new file mode 100644 index 0000000..c82e7dd --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/http_helper.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'uint8_list_reader.dart'; + +/// Provides simple HTTP requests +class HttpHelper { + HttpHelper._(); + + /// Gets the specified [url] + static Future httpGet( + String url, { + Duration? connectionTimeout, + }) async { + try { + final client = HttpClient(); + if (connectionTimeout != null) { + client.connectionTimeout = connectionTimeout; + } + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + + if (response.statusCode != 200) { + return HttpResult(response.statusCode); + } + final data = await _readHttpResponse(response); + + return HttpResult(response.statusCode, data); + } on Exception { + return HttpResult(400); + } + } + + static Future _readHttpResponse(HttpClientResponse response) { + final completer = Completer(); + final contents = OptimizedBytesBuilder(); + response.listen( + (data) { + if (data is Uint8List) { + contents.add(data); + } else { + contents.add(Uint8List.fromList(data)); + } + }, + onDone: () => completer.complete(contents.takeBytes()), + ); + + return completer.future; + } +} + +/// The result of a HTTP request +class HttpResult { + /// Creates a new result + HttpResult(this.statusCode, [this.data]); + + /// The status code + final int statusCode; + String? _text; + + /// The response as text + String? get text { + var t = _text; + if (t == null) { + final d = data; + if (d != null) { + t = utf8.decode(d); + _text = t; + } + } + + return t; + } + + /// The response data + final Uint8List? data; +} diff --git a/packages/enough_mail/lib/src/private/util/mail_address_parser.dart b/packages/enough_mail/lib/src/private/util/mail_address_parser.dart new file mode 100644 index 0000000..25fed18 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/mail_address_parser.dart @@ -0,0 +1,169 @@ +import '../../codecs/mail_codec.dart'; +import '../../mail_address.dart'; +import 'ascii_runes.dart'; +import 'word.dart'; + +/// Helps parsing email addresses +class MailAddressParser { + MailAddressParser._(); + + /// Parses one or more addresses given in the [emailText]. + static List parseEmailAddresses(String? emailText) { + if (emailText == null || emailText.isEmpty) { + return []; + } + /* + cSpell:disable + TODO: the current email parsing implementation is quite naive + Here is a list of valid email addresses (without name): + Abc@example.com (English, ASCII) + Abc.123@example.com (English, ASCII) + user+mailbox/department=shipping@example.com (English, ASCII) + !#$%&'*+-/=?^_`.{|}~@example.com (English, ASCII) + "Abc@def"@example.com (English, ASCII) + "Fred Bloggs"@example.com (English, ASCII) + "Joe.\\Blow"@example.com (English, ASCII) + simple@example.com + very.common@example.com + disposable.style.email.with+symbol@example.com + other.email-with-hyphen@example.com + fully-qualified-domain@example.com + user.name+tag+sorting@example.com (may go to user.name@example.com + inbox depending on mail server) + x@example.com (one-letter local-part) + example-indeed@strange-example.com + admin@mailserver1 (local domain name with no TLD, although ICANN highly + discourages dotless email addresses) + example@s.example (see the List of Internet top-level domains) + " "@example.org (space between the quotes) + "john..doe"@example.org (quoted double dot) + mailhost!username@example.org (bangified host route used for uucp mailers) + user%example.com@example.org (% escaped mail route to user@example.com via + example.org) + 用户@例子.广告 (Chinese, Unicode) + अजय@डाटा.भारत (Hindi, Unicode) + квіточка@пошта.укр (Ukrainian, Unicode) + θσερ@εχαμπλε.ψομ (Greek, Unicode) + Dörte@Sörensen.example.com (German, Unicode) + коля@пример.рф (Russian, Unicode) + Latin alphabet with diacritics: Pelé@example.com + Greek alphabet: δοκιμή@παράδειγμα.δοκιμή + Traditional Chinese characters: 我買@屋企.香港 + Japanese characters: 二ノ宮@黒川.日本 + Cyrillic characters: медведь@с-балалайкой.рф + Devanagari characters: संपर्क@डाटामेल.भारत + cSpell:enable + */ + final addresses = []; + final addressParts = _splitAddressParts(emailText); + for (final addressPart in addressParts) { + //print('processing [$addressPart]'); + final emailWord = _findEmailAddress(addressPart); + if (emailWord == null) { + print( + 'Warning: no valid email address: [$addressPart] in [$emailText]', + ); + continue; + } + var name = emailWord.startIndex == 0 + ? null + : addressPart.substring(0, emailWord.startIndex - 1).trim(); + if (name != null) { + if (name.startsWith('"') && name.endsWith('"')) { + name = name.substring(1, name.length - 1); + } + name = name.replaceAll(r'\"', '"'); + if (name.contains('=?')) { + try { + name = MailCodec.decodeHeader(name); + } catch (e) { + print('Unable to decode personal name "$name": $e'); + name = ''; + } + } + } + final address = MailAddress(name, emailWord.text); + addresses.add(address); + } + + return addresses; + } + + static List _splitAddressParts(final String text) { + if (text.isEmpty) { + return []; + } + final result = []; + final runes = text.runes.toList(); + var isInValue = false; + var startIndex = 0; + var valueEndRune = AsciiRunes.runeSpace; + for (var i = 0; i < text.length; i++) { + final rune = runes[i]; + if (isInValue) { + if (rune == valueEndRune) { + isInValue = false; + } + } else { + if (rune == AsciiRunes.runeComma || rune == AsciiRunes.runeSemicolon) { + // found a split position + final textPart = text.substring(startIndex, i).trim(); + result.add(textPart); + startIndex = i + 1; + } else if (rune == AsciiRunes.runeDoubleQuote) { + valueEndRune = AsciiRunes.runeDoubleQuote; + isInValue = true; + } else if (rune == AsciiRunes.runeSmallerThan) { + valueEndRune = AsciiRunes.runeGreaterThan; + isInValue = true; + } + } + } + if (startIndex < text.length - 1) { + final textPart = text.substring(startIndex).trim(); + result.add(textPart); + } + + return result; + } + + static Word? _findEmailAddress(String text) { + final atIndex = text.lastIndexOf('@'); + if (atIndex == -1) { + return null; + } + var isInValue = false; + var startIndex = 0; + var endIndex = text.length; + var valueEndRune = AsciiRunes.runeSpace; // space + final runes = text.runes.toList(); + var isFoundAtRune = false; + for (var i = endIndex; --i >= 0;) { + final rune = runes[i]; + if (isInValue) { + if (rune == valueEndRune) { + isInValue = false; + } + } else { + if (rune == AsciiRunes.runeAt) { + isFoundAtRune = true; + } else if (!isFoundAtRune) { + if (rune == AsciiRunes.runeGreaterThan || + rune == AsciiRunes.runeSpace) { + endIndex = i; + } + } else if (rune == AsciiRunes.runeSmallerThan || + rune == AsciiRunes.runeSpace) { + startIndex = i + 1; + break; + } else if (isFoundAtRune && rune == AsciiRunes.runeDoubleQuote) { + isInValue = true; + valueEndRune = AsciiRunes.runeDoubleQuote; + } + } + } + final email = text.substring(startIndex, endIndex); + + return Word(email, startIndex); + } +} diff --git a/packages/enough_mail/lib/src/private/util/mail_signature.dart b/packages/enough_mail/lib/src/private/util/mail_signature.dart new file mode 100644 index 0000000..63c7c7c --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/mail_signature.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypter_plus/encrypter_plus.dart'; +import 'package:pointycastle/pointycastle.dart' show RSAPrivateKey; + +import '../../message_builder.dart'; +import '../../mime_message.dart'; +import 'non_nullable.dart'; + +/// Extends Message Builder with signature methods +extension MailSignature on MessageBuilder { + static final RSAKeyParser _rsaKeyParser = RSAKeyParser(); + static const List _signedHeaders = [ + 'from', /*, 'to', 'mime-version'*/ + ]; + static const int _bodyLength = 72; // Fails over >76 + static const String _crlf = '\r\n'; + static const String _headerName = 'DKIM-Signature'; + + String _cleanWhiteSpaces(String target) => + target.replaceAll(RegExp(r'\s+', multiLine: true), ' '); + String _cleanLineBreaks(String target) { + final parts = + target.replaceAll(_crlf, '\n').replaceAll('\n', _crlf).split(_crlf); + + for (var i = 0; i < parts.length; i++) { + parts[i] = _cleanWhiteSpaces(parts[i]).trimRight(); + } + + return parts.join(_crlf); + } + + int get _secondsSinceEpoch => + (DateTime.now().millisecondsSinceEpoch / 1000).floor(); + + Header _createDkimHeader(String body, String? domain, String? selector) => + Header( + _headerName, + ''' + v=1; t=$_secondsSinceEpoch; + d=$domain; s=$selector; + h=${_signedHeaders.join(':')}; + q=dns/txt; + l=$_bodyLength; + c=relaxed/relaxed; a=rsa-sha256; + bh=${_hash(body.substring(0, _bodyLength))}; + b= + ''' + .replaceAll(RegExp(r'^ +', multiLine: true), ''), + ); + + String _hash(String target) => + base64.encode(sha256.convert(utf8.encode(target)).bytes); + String _relaxedHeaderValue(Header head) { + final headValue = head.value?.replaceAll(RegExp(r'\r|\n'), ' ') ?? ''; + + return '${head.lowerCaseName}:' + '${_cleanWhiteSpaces(headValue).trim()}$_crlf'; + } + + bool _isSignedHeader(Header head) => + _signedHeaders.contains(head.lowerCaseName); + + String _relaxedHeader(List
headers) { + final relaxed = StringBuffer(); + + for (final head in headers.where(_isSignedHeader)) { + relaxed.write(_relaxedHeaderValue(head)); + } + + return _cleanLineBreaks(relaxed.toString()); + } + + // Use to see existence of escape characters + // void _debugTrace(String target) { + // print(target + // .replaceAll(' ', '') + // .replaceAll('\r', '') + // .replaceAll('\n', '\n')); + // } + + String _relaxedBody(String body) { + final cleaned = _cleanLineBreaks(body).trimRight(); + + return cleaned.isEmpty ? '' : cleaned + _crlf; + } + + String _sign(String privateKeyText, String value) { + final privateKey = _rsaKeyParser.parse(privateKeyText) as RSAPrivateKey?; + final data = utf8.encode(value); + + return RSASigner(RSASignDigest.SHA256, privateKey: privateKey) + .sign(data) + .base64; + } + + /// Signs the builder with the given [privateKey] + /// + /// Adds the signature to the `DKIM-Signature` message header + bool sign({required String privateKey, String? domain, String? selector}) { + final msg = buildMimeMessage(); + final body = _relaxedBody(msg.renderMessage(renderHeader: false)); + final header = _relaxedHeader( + msg.headers.toValueOrThrow('no headers found'), + ); + final dkim = _relaxedHeaderValue(_createDkimHeader(body, domain, selector)); + final signature = dkim.trim() + _sign(privateKey, (header + dkim).trim()); + + addHeader(_headerName, signature.substring(_headerName.length + 1).trim()); + + return true; + } +} diff --git a/packages/enough_mail/lib/src/private/util/non_nullable.dart b/packages/enough_mail/lib/src/private/util/non_nullable.dart new file mode 100644 index 0000000..ed9b0cd --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/non_nullable.dart @@ -0,0 +1,22 @@ +/// Extracts the value or throws an [ArgumentError] if the value is `null`. +T toValueOrThrow( + T? value, + String reason, +) => + value.toValueOrThrow(reason); + +/// Allows to extract the non-nullable value or throws an [ArgumentError] if the +/// value is `null`. +extension ValueExtension on T? { + /// Extracts the value or throws an [ArgumentError] if the value is `null`. + T toValueOrThrow( + String reason, + ) { + final value = this; + if (value == null) { + throw ArgumentError(reason); + } + + return value; + } +} diff --git a/packages/enough_mail/lib/src/private/util/stack_list.dart b/packages/enough_mail/lib/src/private/util/stack_list.dart new file mode 100644 index 0000000..a6e81d4 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/stack_list.dart @@ -0,0 +1,36 @@ +/// A typed stack of elements +class StackList { + final List _elements = []; + + /// Adds the [value] on top of the stack + void put(T value) { + _elements.add(value); + } + + /// Retrieves the last added element without changing the stack + T? peek() { + if (_elements.isEmpty) { + return null; + } + + return _elements.last; + } + + /// Removes the last added element from the stack + T? pop() { + if (_elements.isEmpty) { + return null; + } + + return _elements.removeLast(); + } + + /// Returns `true` when the stack has elements + bool get isNotEmpty => _elements.isNotEmpty; + + /// Returns `true` when the stack has no elements + bool get isEmpty => _elements.isEmpty; + + @override + String toString() => _elements.toString(); +} diff --git a/packages/enough_mail/lib/src/private/util/uint8_list_reader.dart b/packages/enough_mail/lib/src/private/util/uint8_list_reader.dart new file mode 100644 index 0000000..2b372b0 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/uint8_list_reader.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'ascii_runes.dart'; + +/// Combines several Uin8Lists to read from them sequentially +class Uint8ListReader { + static const Utf8Decoder _utf8decoder = Utf8Decoder(allowMalformed: true); + final OptimizedBytesBuilder _builder = OptimizedBytesBuilder(); + + /// Adds the given [list] data to this builder + void add(Uint8List list) => _builder.add(list); + + /// Adds the given [text] to this builder + void addText(String text) => _builder.add(Uint8List.fromList(text.codeUnits)); + + /// Finds the position of the first line break + int? findLineBreak() => _builder.findLineBreak(); + + /// Finds the position of the last line break + int? findLastLineBreak() => _builder.findLastLineBreak(); + + /// Checks of there is a line break + bool hasLineBreak() => _builder.findLastLineBreak() != null; + + /// Reads the current line until the first line-break + String? readLine() { + final pos = _builder.findLineBreak(); + if (pos == null) { + return null; + } + final data = _builder.takeFirst(pos + 1); + final line = _utf8decoder.convert(data, 0, pos - 1).trimLeft(); + + return line; + } + + /// Reads the lines until the last line break + List? readLines() { + final pos = _builder.findLastLineBreak(); + if (pos == null) { + return null; + } + final data = _builder.takeFirst(pos + 1); + final text = _utf8decoder.convert(data).trimLeft(); + + return text.split('\r\n')..removeLast(); + } + + /// Finds the last CR-LF.CR-LF sequence + int? findLastCrLfDotCrLfSequence() { + for (var charIndex = _builder.length; --charIndex > 4;) { + if (_builder.getByteAt(charIndex) == 10 && + _builder.getByteAt(charIndex - 1) == 13 && + _builder.getByteAt(charIndex - 2) == AsciiRunes.runeDot && + _builder.getByteAt(charIndex - 3) == 10 && + _builder.getByteAt(charIndex - 4) == 13) { + // ok found CRLF.CRLF sequence: + return charIndex; + } + } + + return null; + } + + /// Reads all data until a CT-LF.CT-LF + List? readLinesToCrLfDotCrLfSequence() { + final pos = findLastCrLfDotCrLfSequence(); + if (pos == null) { + return null; + } + final data = _builder.takeFirst(pos); + final text = _utf8decoder.convert(data, 0, pos - 4); + + return text.split('\r\n'); + } + + /// Reads the data until the given [length] + Uint8List? readBytes(int length) { + if (!isAvailable(length)) { + return null; + } + + return _builder.takeFirst(length); + } + + /// Checks if the given [length] of data is available + bool isAvailable(int length) => length <= _builder.length; +} + +/// A non-copying [BytesBuilder]. +/// +/// Accumulates lists of integers and lazily builds +/// a collected list with all the bytes when requested. +class OptimizedBytesBuilder { + static final _emptyList = Uint8List(0); + int _length = 0; + final List _chunks = []; + + /// Adds the given [bytes] data + void add(final Uint8List bytes) { + _chunks.add(bytes); + _length += bytes.length; + } + + /// Adds a single [byte] data + void addByte(int byte) { + _chunks.add(Uint8List(1)..[0] = byte); + _length++; + } + + /// Removes the available bytes + Uint8List takeBytes() { + if (_length == 0) { + return _emptyList; + } + if (_chunks.length == 1) { + final buffer = _chunks[0]; + clear(); + + return buffer; + } + final buffer = Uint8List(_length); + var offset = 0; + for (final chunk in _chunks) { + buffer.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + clear(); + + return buffer; + } + + /// Takes the first [len] bytes + Uint8List takeFirst(final int len) { + if (len <= 0) { + return _emptyList; + } + if (len >= _length) { + return takeBytes(); + } + // optimization for first chunk: + final firstChunk = _chunks.first; + if (firstChunk.length == len) { + _chunks.removeAt(0); + _length -= len; + + return firstChunk; + } + final buffer = Uint8List(len); + var offset = 0; + var chunkIndex = 0; + for (final chunk in _chunks) { + final endOffset = offset + chunk.length; + if (endOffset > len) { + // only part of this chunk should be copied: + buffer.setRange(offset, len, chunk); + final chunkStartIndex = chunk.length - (endOffset - len); + final updatedChunk = chunk.sublist(chunkStartIndex); + _chunks[chunkIndex] = updatedChunk; + break; + } else { + buffer.setRange(offset, endOffset, chunk); + offset += chunk.length; + } + chunkIndex++; + if (offset >= len) { + break; + } + } + _chunks.removeRange(0, chunkIndex); + _length -= len; + + return buffer; + } + + /// Converts the whole data + Uint8List toBytes() { + if (_length == 0) { + return _emptyList; + } + final buffer = Uint8List(_length); + var offset = 0; + for (final chunk in _chunks) { + buffer.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + + return buffer; + } + + /// Retrieves the available length + int get length => _length; + + /// Checks if this builder is empty + bool get isEmpty => _length == 0; + + /// Checks if this builder is not empty + bool get isNotEmpty => _length != 0; + + /// Clears the buffer of this builder + void clear() { + _length = 0; + _chunks.clear(); + } + + /// Gets the byte at the given [index] + int getByteAt(final int index) { + var i = index; + for (final chunk in _chunks) { + if (i < chunk.length) { + return chunk[i]; + } + i -= chunk.length; + } + throw IndexError.withLength( + index, + length, + name: 'index', + message: 'for index $index in builder with length $length', + ); + } + + /// Tries to the find the position of the first CR-LF line break + int? findLineBreak() { + if (_length == 0) { + return null; + } + var index = 0; + var isPreviousCr = false; + for (final chunk in _chunks) { + for (var charIndex = 0; charIndex < chunk.length - 1; charIndex++) { + final currentChar = chunk[charIndex]; + if (currentChar == 13 && chunk[charIndex + 1] == 10) { + // ok found CR + LF sequence: + return index + 1; + } else if (isPreviousCr) { + if (currentChar == 10) { + return index; + } + isPreviousCr = false; + } + index++; + } + if (isPreviousCr && chunk.length == 1 && chunk[0] == 10) { + return index; + } + isPreviousCr = chunk[chunk.length - 1] == 13; + index++; + } + + return null; + } + + /// Tries to the find the position of the last CR-LF line break + int? findLastLineBreak() { + if (_length == 0) { + return null; + } + var isPreviousLf = false; + var index = _length; + for (var chunkIndex = _chunks.length; --chunkIndex >= 0;) { + final chunk = _chunks[chunkIndex]; + for (var charIndex = chunk.length; --charIndex > 0;) { + index--; + final currentChar = chunk[charIndex]; + if (currentChar == 10 && chunk[charIndex - 1] == 13) { + // ok found CR + LF sequence: + return index; + } else if (isPreviousLf) { + if (currentChar == 13) { + return index + 1; + } + isPreviousLf = false; + } + } + if (isPreviousLf && chunk.length == 1 && chunk[0] == 13) { + return index - 1; + } + isPreviousLf = chunk[0] == 10; + } + + return null; + } +} diff --git a/packages/enough_mail/lib/src/private/util/word.dart b/packages/enough_mail/lib/src/private/util/word.dart new file mode 100644 index 0000000..b627c51 --- /dev/null +++ b/packages/enough_mail/lib/src/private/util/word.dart @@ -0,0 +1,14 @@ +/// A word within another text +class Word { + /// Creates a new word + Word(this.text, this.startIndex); + + /// The word content + String text; + + /// The index of the word in the parent text + int startIndex; + + /// The end index of the work + int get endIndex => startIndex + text.length; +} diff --git a/packages/enough_mail/lib/src/smtp/smtp_client.dart b/packages/enough_mail/lib/src/smtp/smtp_client.dart new file mode 100644 index 0000000..d85449d --- /dev/null +++ b/packages/enough_mail/lib/src/smtp/smtp_client.dart @@ -0,0 +1,488 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:event_bus/event_bus.dart'; + +import '../mail_address.dart'; +import '../mime_data.dart'; +import '../mime_message.dart'; +import '../private/smtp/commands/all_commands.dart'; +import '../private/smtp/smtp_command.dart'; +import '../private/util/client_base.dart'; +import '../private/util/uint8_list_reader.dart'; +import 'smtp_events.dart'; +import 'smtp_exception.dart'; +import 'smtp_response.dart'; + +/// Keeps information about the remote SMTP server +/// +/// Persist this information to improve initialization times. +class SmtpServerInfo { + /// Creates a new server information + SmtpServerInfo(this.host, this.port, {required this.isSecure}); + + /// The remote host + final String host; + + /// Is a secure connection being used (from the start)? + final bool isSecure; + + /// The remote port + final int port; + + /// The maximum message size in bytes + int? maxMessageSize; + + /// The server capabilities + List capabilities = []; + + /// The supported authentication mechanisms + List authMechanisms = []; + + /// Checks of the specified [authMechanism] is supported. + bool supportsAuth(AuthMechanism authMechanism) => + authMechanisms.contains(authMechanism); + + /// Checks if the server supports sending of `8bit` encoded messages. + bool get supports8BitMime => capabilities.contains('8BITMIME'); + + /// Checks if the server supports chunked message transfer + /// using the `BDATA` command. + /// + /// Compare https://tools.ietf.org/html/rfc3030 for details + bool get supportsChunking => capabilities.contains('CHUNKING'); + + /// Checks if the server supports (and usually expects) + /// switching to SSL connection before authentication. + bool get supportsStartTls => capabilities.contains('STARTTLS'); + + /// Checks if the given capability is supported, e.g. + /// `final supportsPipelining = smtpClient.serverInfo.supports(PIPELINING);`. + bool supports(String capability) => capabilities.contains(capability); +} + +/// Defines the available authentication mechanism +enum AuthMechanism { + /// PLAIN text authentication + /// + /// Should only be used over SSL protected connections. + /// Compare https://tools.ietf.org/html/rfc4616. + plain, + + /// LOGIN authentication + /// + /// Should only be used over SSL protected connections. Compare https://datatracker.ietf.org/doc/draft-murchison-sasl-login/. + login, + + /// CRAM-MD5 authentication. + /// + /// Compare https://tools.ietf.org/html/rfc2195 + cramMd5, + + /// OAUTH 2.0 authentication + /// + /// Compare https://tools.ietf.org/html/rfc6750. + xoauth2 +} + +/// Low-level SMTP library for Dart +/// +/// Compliant to [Extended SMTP standard](https://tools.ietf.org/html/rfc5321). +class SmtpClient extends ClientBase { + /// Creates a new instance with the specified [clientDomain] + /// that is associated with your service's domain, + /// e.g. `domain.com` or `enough.de`. + /// + /// Set the [eventBus] to add your specific `EventBus` + /// to listen to SMTP events. + /// + /// Set [isLogEnabled] to `true` to see log output. + /// Set the [logName] for adding the name to each log entry. + /// [onBadCertificate] is an optional handler for unverifiable certificates. + /// The handler receives the [X509Certificate], and can inspect it and + /// decide (or let the user decide) whether to accept the connection or not. + /// The handler should return true to continue the [SecureSocket] connection. + SmtpClient( + String clientDomain, { + EventBus? bus, + bool isLogEnabled = false, + String? logName, + bool Function(X509Certificate)? onBadCertificate, + }) : _eventBus = bus ?? EventBus(), + _clientDomain = clientDomain, + super( + isLogEnabled: isLogEnabled, + logName: logName, + onBadCertificate: onBadCertificate, + ); + + /// Information about the SMTP service + late SmtpServerInfo serverInfo; + + /// Allows to listens for events + /// + /// If no event bus is specified in the constructor, + /// an asynchronous bus is used. + /// Usage: + /// ```dart + /// eventBus.on().listen((event) { + /// // All events are of type SmtpConnectionLostEvent (or subtypes of it). + /// _log(event.type); + /// }); + /// + /// eventBus.on().listen((event) { + /// // All events are of type SmtpEvent (or subtypes of it). + /// _log(event.type); + /// }); + /// ``` + EventBus get eventBus => _eventBus; + final EventBus _eventBus; + + final String _clientDomain; + + final Uint8ListReader _uint8listReader = Uint8ListReader(); + SmtpCommand? _currentCommand; + + @override + FutureOr onConnectionEstablished( + ConnectionInfo connectionInfo, + String serverGreeting, + ) { + serverInfo = SmtpServerInfo( + connectionInfo.host, + connectionInfo.port, + isSecure: connectionInfo.isSecure, + ); + log('SMTP: got server greeting $serverGreeting', initial: 'A'); + } + + @override + void onConnectionError(dynamic error) { + eventBus.fire(SmtpConnectionLostEvent(this)); + } + + @override + void onDataReceived(Uint8List data) { + //print('onData: [${String.fromCharCodes(data). + // replaceAll("\r\n", "\n")}]'); + _uint8listReader.add(data); + final lines = _uint8listReader.readLines(); + if (lines != null) { + onServerResponse(lines); + } + } + + /// Issues the enhanced helo command to find out the service capabilities + /// + /// EHLO or HELO always needs to be the first command + /// that is sent to the SMTP server. + Future ehlo() async { + final result = await sendCommand(SmtpEhloCommand(_clientDomain)); + for (final line in result.responseLines) { + if (line.code == 250) { + serverInfo.capabilities.add(line.message); + if (line.message.startsWith('AUTH ')) { + if (line.message.contains('PLAIN')) { + serverInfo.authMechanisms.add(AuthMechanism.plain); + } + if (line.message.contains('LOGIN')) { + serverInfo.authMechanisms.add(AuthMechanism.login); + } + if (line.message.contains('CRAM-MD5')) { + serverInfo.authMechanisms.add(AuthMechanism.cramMd5); + } + if (line.message.contains('XOAUTH2')) { + serverInfo.authMechanisms.add(AuthMechanism.xoauth2); + } + } else { + serverInfo.capabilities.add(line.message); + if (line.message.startsWith('SIZE ')) { + final maxSizeText = line.message.substring('SIZE '.length); + serverInfo.maxMessageSize = int.tryParse(maxSizeText); + } + } + } + } + + return result; + } + + /// 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 { + final response = await sendCommand(SmtpStartTlsCommand()); + if (response.isOkStatus) { + log('STARTTLS: upgrading socket to secure one...', initial: 'A'); + await upgradeToSslSocket(); + await ehlo(); + } + + return response; + } + + /// Sends the specified [message]. + /// + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + /// Specify [from] in case the originator is different from the `From` + /// header in the message. + /// Optionally specify the [recipients], in which case the recipients + /// defined in the message are ignored. + Future sendMessage( + MimeMessage message, { + bool use8BitEncoding = false, + MailAddress? from, + List? recipients, + }) { + final recipientEmails = recipients != null + ? recipients.map((r) => r.email).toList() + : message.recipientAddresses; + if (recipientEmails.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand( + SmtpSendMailCommand( + message, + from, + recipientEmails, + use8BitEncoding: use8BitEncoding, + ), + ); + } + + /// Sends the specified message [data] [from] to the [recipients]. + /// + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + Future sendMessageData( + MimeData data, + MailAddress from, + List recipients, { + bool use8BitEncoding = false, + }) { + if (recipients.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand( + SmtpSendMailDataCommand( + data, + from, + recipients.map((r) => r.email).toList(), + use8BitEncoding: use8BitEncoding, + ), + ); + } + + /// Sends the specified message [text] [from] to the [recipients]. + /// + /// In contrast to the other methods the text is not modified apart from + /// the padding of `.` sequences. + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + Future sendMessageText( + String text, + MailAddress from, + List recipients, { + bool use8BitEncoding = false, + }) { + if (recipients.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand( + SmtpSendMailTextCommand( + text, + from, + recipients.map((r) => r.email).toList(), + use8BitEncoding: use8BitEncoding, + ), + ); + } + + /// Sends the specified [message] using the `BDAT` SMTP command. + /// + /// `BDATA` is supported when the SMTP server announces the `CHUNKING` + /// capability in its `EHLO` response. + /// You can query `SmtpServerInfo.supportsChunking` for this. + /// + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + /// + /// Specify [from] in case the originator is different from the `From` + /// header in the message. + /// + /// Optionally specify the [recipients], in which case the recipients + /// defined in the message are ignored. + Future sendChunkedMessage( + MimeMessage message, { + required bool supportUnicode, + bool use8BitEncoding = false, + MailAddress? from, + List? recipients, + }) { + final recipientEmails = recipients != null + ? recipients.map((r) => r.email).toList() + : message.recipientAddresses; + if (recipientEmails.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand(SmtpSendBdatMailCommand( + message, + from, + recipientEmails, + use8BitEncoding: use8BitEncoding, + supportUnicode: supportUnicode, + )); + } + + /// Sends the specified message [data] [from] to the [recipients] + /// using the `BDAT` SMTP command. + /// + /// `BDATA` is supported when the SMTP server announces the `CHUNKING` + /// capability in its `EHLO` response. + /// You can query `SmtpServerInfo.supportsChunking` for this. + /// + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + Future sendChunkedMessageData( + MimeData data, + MailAddress from, + List recipients, { + required bool supportUnicode, + bool use8BitEncoding = false, + }) { + if (recipients.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand( + SmtpSendBdatMailDataCommand( + data, + from, + recipients.map((r) => r.email).toList(), + supportUnicode: supportUnicode, + use8BitEncoding: use8BitEncoding, + ), + ); + } + + /// Sends the specified message [text] [from] to the [recipients] + /// using the `BDAT` SMTP command. + /// + /// `BDATA` is supported when the SMTP server announces the `CHUNKING` + /// capability in its `EHLO` response. + /// You can query `SmtpServerInfo.supportsChunking` for this. + /// + /// In contrast to the other methods the text is not modified apart from the + /// padding of `.` sequences. + /// + /// Set [use8BitEncoding] to `true` for sending a UTF-8 encoded message body. + Future sendChunkedMessageText( + String text, + MailAddress from, + List recipients, { + required bool supportUnicode, + bool use8BitEncoding = false, + }) { + if (recipients.isEmpty) { + throw SmtpException(this, SmtpResponse(['500 no recipients'])); + } + + return sendCommand( + SmtpSendBdatMailTextCommand( + text, + from, + recipients.map((r) => r.email).toList(), + supportUnicode: supportUnicode, + use8BitEncoding: use8BitEncoding, + ), + ); + } + + /// Signs in the user with the given [name] and [password]. + /// + /// For `AuthMechanism.xoauth2` the [password] must be the OAuth token. + /// By default the [authMechanism] `AUTH PLAIN` is being used. + Future authenticate( + String name, + String password, [ + AuthMechanism authMechanism = AuthMechanism.plain, + ]) { + late SmtpCommand command; + switch (authMechanism) { + case AuthMechanism.plain: + command = SmtpAuthPlainCommand(name, password); + break; + case AuthMechanism.login: + command = SmtpAuthLoginCommand(name, password); + break; + case AuthMechanism.cramMd5: + command = SmtpAuthCramMd5Command(name, password); + break; + case AuthMechanism.xoauth2: + command = SmtpAuthXOauth2Command(name, password); + break; + } + + return sendCommand(command); + } + + /// Signs the user out and terminates the connection + Future quit() async { + final response = await sendCommand(SmtpQuitCommand(this)); + isLoggedIn = false; + + return response; + } + + /// Sends the command to the server + Future sendCommand(SmtpCommand command) { + _currentCommand = command; + writeText(command.command, command); + + return command.completer.future; + } + + /// Handles server responses + void onServerResponse(List responseTexts) { + if (isLogEnabled) { + for (final responseText in responseTexts) { + log(responseText, isClient: false); + } + } + final response = SmtpResponse(responseTexts); + final cmd = _currentCommand; + if (cmd != null) { + try { + final next = cmd.next(response); + final text = next?.text; + final data = next?.data; + if (text != null) { + writeText(text); + } else if (data != null) { + writeData(data); + } else if (cmd.isCommandDone(response)) { + if (response.isFailedStatus) { + cmd.completer.completeError(SmtpException(this, response)); + } else { + cmd.completer.complete(response); + } + //_log("Done with command ${_currentCommand.command}"); + _currentCommand = null; + } + } catch (exception, stackTrace) { + log('Error proceeding to nextCommand: $exception'); + _currentCommand?.completer.completeError(exception, stackTrace); + _currentCommand = null; + } + } + } + + @override + Object createClientError(String message) => + SmtpException.message(this, message); +} diff --git a/packages/enough_mail/lib/src/smtp/smtp_events.dart b/packages/enough_mail/lib/src/smtp/smtp_events.dart new file mode 100644 index 0000000..ffc20fa --- /dev/null +++ b/packages/enough_mail/lib/src/smtp/smtp_events.dart @@ -0,0 +1,29 @@ +import 'smtp_client.dart'; + +/// Types of SMTP events +enum SmtpEventType { + /// Connection is lost, ie because of a network error + connectionLost, + + /// Unsupported event type + unknown +} + +/// Base SMTP event +abstract class SmtpEvent { + /// Creates a new SMTP event + SmtpEvent(this.type, this.client); + + /// The type of the event + final SmtpEventType type; + + /// The client from which the event originates + final SmtpClient client; +} + +/// Event signalling a lost connection +class SmtpConnectionLostEvent extends SmtpEvent { + /// Creates a new connection lost event + SmtpConnectionLostEvent(SmtpClient client) + : super(SmtpEventType.connectionLost, client); +} diff --git a/packages/enough_mail/lib/src/smtp/smtp_exception.dart b/packages/enough_mail/lib/src/smtp/smtp_exception.dart new file mode 100644 index 0000000..fd31f80 --- /dev/null +++ b/packages/enough_mail/lib/src/smtp/smtp_exception.dart @@ -0,0 +1,53 @@ +import 'smtp_client.dart'; +import 'smtp_response.dart'; + +/// Contains details about SMTP problems +class SmtpException implements Exception { + /// Creates a new SMTP exception + SmtpException(this.smtpClient, this.response, {this.stackTrace}) + : _message = response.errorMessage; + + /// Creates a new SMTP exception + SmtpException.message(this.smtpClient, String message) + : response = SmtpResponse(['500 $message']), + stackTrace = null, + _message = message; + + /// The used SMTP client + final SmtpClient smtpClient; + + /// The full SMTP response + final SmtpResponse response; + + final String _message; + + /// The error message + String? get message => _message; + + /// The stacktrace, if known + final StackTrace? stackTrace; + + @override + String toString() { + final buffer = StringBuffer(); + var addNewline = false; + for (final line in response.responseLines) { + if (addNewline) { + buffer.write('\n'); + } else { + addNewline = true; + } + buffer + ..write(line.code) + ..write(' ') + ..write(line.message); + } + if (stackTrace != null) { + buffer + ..write('\n') + ..write(stackTrace); + } + + return buffer.toString(); + } +} diff --git a/packages/enough_mail/lib/src/smtp/smtp_response.dart b/packages/enough_mail/lib/src/smtp/smtp_response.dart new file mode 100644 index 0000000..2d30084 --- /dev/null +++ b/packages/enough_mail/lib/src/smtp/smtp_response.dart @@ -0,0 +1,121 @@ +/// Basic type of a SMTP response +enum SmtpResponseType { + /// The request has been accepted + accepted, + + /// The request has been successfully processed + success, + + /// The server requires information before proceeding + needInfo, + + /// The request resulted into an temporary error - try again + temporaryError, + + /// The request resulted in a permanent error and should not be retried + fatalError, + + /// Other response type + unknown +} + +/// Contains a response from the SMTP server +class SmtpResponse { + /// Creates a new response + SmtpResponse(List responseTexts) { + for (final responseText in responseTexts) { + if (responseText.isNotEmpty) { + responseLines.add(SmtpResponseLine.parse(responseText)); + } + } + } + + /// Individual response lines + List responseLines = []; + + /// The (last) response code + int? get code => responseLines.last.code; + + /// The (last) message + String? get message => responseLines.last.message; + + /// The (last) response type + SmtpResponseType get type => responseLines.last.type; + + /// Checks if the request succeeded + bool get isOkStatus => type == SmtpResponseType.success; + + /// Checks if the request failed + bool get isFailedStatus => !(isOkStatus || type == SmtpResponseType.accepted); + + /// Retrieves the error message + String get errorMessage { + final buffer = StringBuffer(); + var appendLineBreak = false; + for (final line in responseLines) { + if (line.isFailedStatus) { + if (appendLineBreak) { + buffer.write('\n'); + } + buffer.write(line.message); + appendLineBreak = true; + } + } + + return buffer.toString(); + } +} + +/// Contains a single SMTP response line +class SmtpResponseLine { + /// Creates a new response line + const SmtpResponseLine(this.code, this.message); + + /// Parses the given response [text]. + factory SmtpResponseLine.parse(String text) { + final code = int.tryParse(text.substring(0, 3)); + final message = (code == null) ? text : text.substring(4); + + return SmtpResponseLine(code ?? 500, message); + } + + /// The code of the response + final int code; + + /// The message of the response + final String message; + + /// The type of the response + SmtpResponseType get type { + 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; + } + + /// Checks if the request failed + bool get isFailedStatus { + final t = type; + + return !(t == SmtpResponseType.accepted || t == SmtpResponseType.success); + } +} diff --git a/packages/enough_mail/migration.md b/packages/enough_mail/migration.md new file mode 100644 index 0000000..0722f99 --- /dev/null +++ b/packages/enough_mail/migration.md @@ -0,0 +1,60 @@ +## Migrating + +If you have been using a 0.0.x version of the API you need to switch from evaluating responses to just getting the data and handling exceptions if something went wrong. + +Old code example: +```dart +final client = ImapClient(isLogEnabled: false); +await client.connectToServer(imapServerHost, imapServerPort, + isSecure: isImapServerSecure); +final loginResponse = await client.login(userName, password); +if (loginResponse.isOkStatus) { + final listResponse = await client.listMailboxes(); + if (listResponse.isOkStatus) { + print('mailboxes: ${listResponse.result}'); + final inboxResponse = await client.selectInbox(); + if (inboxResponse.isOkStatus) { + // fetch 10 most recent messages: + final fetchResponse = await client.fetchRecentMessages( + messageCount: 10, criteria: 'BODY.PEEK[]'); + if (fetchResponse.isOkStatus) { + final messages = fetchResponse.result.messages; + for (var message in messages) { + printMessage(message); + } + } + } + } + await client.logout(); +} +``` + +Migrated code example: +```dart +final client = ImapClient(isLogEnabled: false); +try { + await client.connectToServer(imapServerHost, imapServerPort, + isSecure: isImapServerSecure); + await client.login(userName, password); + final mailboxes = await client.listMailboxes(); + print('mailboxes: ${mailboxes}'); + await client.selectInbox(); + // fetch 10 most recent messages: + final fetchResult = await client.fetchRecentMessages( + messageCount: 10, criteria: 'BODY.PEEK[]'); + for (var message in fetchResult.messages) { + printMessage(message); + } + await client.logout(); +} on ImapException catch (e) { + print('imap failed with $e'); +} +``` + +As you can see the code is now much simpler and shorter. + +Depending on which API you use there are different exceptions to handle: +* `MailException` for the high level API +* `ImapException` for the low level IMAP API +* `PopException` for the low level POP3 API +* `SmtpException` for the low level SMTP API diff --git a/packages/enough_mail/pubspec.yaml b/packages/enough_mail/pubspec.yaml new file mode 100644 index 0000000..aed7227 --- /dev/null +++ b/packages/enough_mail/pubspec.yaml @@ -0,0 +1,43 @@ +name: enough_mail +description: IMAP, POP3 and SMTP for email developers. Choose between a low + level and a high level API for mailing. Parse and generate MIME messages. + Discover email settings. +version: 2.1.7 +homepage: https://github.com/Enough-Software/enough_mail +topics: + - email + - imap + - pop3 + - smtp + - mime + + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + basic_utils: ^5.8.2 + collection: ^1.19.1 + crypto: ^3.0.6 + # encrypt: ^5.0.3 + encrypter_plus: ^5.1.0 + enough_convert: ^1.6.0 + event_bus: ^2.0.1 + intl: any + json_annotation: ^4.9.0 + pointycastle: ^4.0.0 + synchronized: ^3.4.0 + xml: ">=6.0.0 <7.0.0" + +dependency_overrides: + # http: ^1.4.0 # for dart_code_metrics + +dev_dependencies: + build_runner: ^2.6.0 + # dart_code_metrics: 5.7.6 + dart_code_linter: ^3.0.0 + flutter_lints: ^6.0.0 + json_serializable: ^6.10.0 + lints: ^6.0.0 + test: ^1.26.3 + timezone: ^0.10.1 diff --git a/packages/enough_mail/test/codecs/base64_mail_codec_test.dart b/packages/enough_mail/test/codecs/base64_mail_codec_test.dart new file mode 100644 index 0000000..1785111 --- /dev/null +++ b/packages/enough_mail/test/codecs/base64_mail_codec_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:enough_mail/src/codecs/mail_codec.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('Base64 decoding', () { + test('encoding.iso-8859-1 base64 directly repeated', () { + const input = '=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?==?ISO-' + '8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?='; + expect( + MailCodec.decodeHeader(input), + 'If you can read this you understand the example.', + ); + }); + + test('encoding.UTF-8.Base64 with non-devidable-by-four base64 text', () { + expect(MailCodec.base64.decodeText('8J+UkA', utf8), '🔐'); + const input = '=?utf-8?B?8J+UkA?= New Access Request - local.name'; + expect( + MailCodec.decodeHeader(input), + '🔐 New Access Request - local.name', + ); + }); + + test('encoding.US-ASCII.Base64', () { + var input = '=?US-ASCII?B?S2VpdGggTW9vcmU?= '; + expect(MailCodec.decodeHeader(input), 'Keith Moore '); + input = '=?US-ASCII?B?S2VpdGggTW9vcmU=?= '; + expect(MailCodec.decodeHeader(input), 'Keith Moore '); + }); + }); + + group('Base64 encoding', () { + test('encodeHeader.base64 with ASCII input', () { + const input = 'Hello World'; + expect(MailCodec.base64.encodeHeader(input), 'Hello World'); + }); + test('encodeHeader.base64 with UTF8 input', () { + const input = 'Hello Wörld'; + expect(MailCodec.base64.encodeHeader(input), 'Hello W=?utf8?B?w7Y=?=rld'); + // counter test: + expect( + MailCodec.decodeHeader('Hello W=?utf8?B?w7Y=?=rld'), + 'Hello Wörld', + ); + }); + }); +} diff --git a/packages/enough_mail/test/codecs/date_codec_test.dart b/packages/enough_mail/test/codecs/date_codec_test.dart new file mode 100644 index 0000000..edc09d7 --- /dev/null +++ b/packages/enough_mail/test/codecs/date_codec_test.dart @@ -0,0 +1,124 @@ +import 'package:enough_mail/src/codecs/date_codec.dart'; +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +void main() { + tz.initializeTimeZones(); + + group('encode dates', () { + test('encodeDate for UTC DateTime', () { + expect( + DateCodec.encodeDate(DateTime.utc(2022, 1, 7, 22, 18)), + 'Fri, 07 Jan 2022 22:18:00 -0000', + ); + }); + test('encodeDate for DateTime east of Greenwich', () { + expect( + DateCodec.encodeDate( + tz.TZDateTime(tz.getLocation('Europe/Berlin'), 2022, 1, 7, 22, 18), + ), + 'Fri, 07 Jan 2022 22:18:00 +0100', + ); + }); + test('encodeDate for DateTime west of Greenwich', () { + expect( + DateCodec.encodeDate(tz.TZDateTime( + tz.getLocation('America/Panama'), + 2022, + 1, + 7, + 22, + 18, + )), + 'Fri, 07 Jan 2022 22:18:00 -0500', + ); + }); + }); + + group('decode dates', () { + test('decodeDate simple', () { + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0000'), + DateTime.utc(2020, 2, 11, 22, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0100'), + DateTime.utc(2020, 2, 11, 21, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0200'), + DateTime.utc(2020, 2, 11, 20, 45).toLocal(), + ); + }); + test('decodeDate with weekday', () { + expect( + DateCodec.decodeDate('Tue, 11 Feb 2020 22:45 +0000'), + DateTime.utc(2020, 2, 11, 22, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('Tue, 11 Feb 2020 22:45 +0100'), + DateTime.utc(2020, 2, 11, 21, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('Tue, 11 Feb 2020 22:45 +0200'), + DateTime.utc(2020, 2, 11, 20, 45).toLocal(), + ); + }); + test('decodeDate with timezone name', () { + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0000 GMT'), + DateTime.utc(2020, 2, 11, 22, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0100 CET'), + DateTime.utc(2020, 2, 11, 21, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0200 EET'), + DateTime.utc(2020, 2, 11, 20, 45).toLocal(), + ); + }); + test('decodeDate with timezone name and weekday', () { + expect( + DateCodec.decodeDate('Tue, 11 Feb 2020 22:45 +0000 GMT'), + DateTime.utc(2020, 2, 11, 22, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('Tue, 11 Feb 2020 22:45 +0100 CET'), + DateTime.utc(2020, 2, 11, 21, 45).toLocal(), + ); + expect( + DateCodec.decodeDate('11 Feb 2020 22:45 +0200 EET'), + DateTime.utc(2020, 2, 11, 20, 45).toLocal(), + ); + }); + test('decodeDate without timezone offset', () { + expect( + DateCodec.decodeDate('Thu, 26 Mar 2020 18:11:28'), + DateTime.utc(2020, 3, 26, 18, 11, 28).toLocal(), + ); + }); + test('decodeDate without timezone offset but timezone name', () { + expect( + DateCodec.decodeDate('Thu, 26 Mar 2020 18:11:28 GMT'), + DateTime.utc(2020, 3, 26, 18, 11, 28).toLocal(), + ); + }); + + test('decodeDate with Zulu timezone', () { + expect( + DateCodec.decodeDate('Fri, 25 Dec 2020 08:57:44 Z'), + DateTime.utc(2020, 12, 25, 8, 57, 44).toLocal(), + ); + }); + + test('decodeDate with only year-fraction', () { + // while this is invalid, some mails are badly formatted: + expect( + DateCodec.decodeDate('Mon, 9 May 22 14:46:31 +0300 (MSK)'), + DateTime.utc(2022, 05, 09, 11, 46, 31).toLocal(), + ); + }); + }); +} diff --git a/packages/enough_mail/test/codecs/folding_test.dart b/packages/enough_mail/test/codecs/folding_test.dart new file mode 100644 index 0000000..a523366 --- /dev/null +++ b/packages/enough_mail/test/codecs/folding_test.dart @@ -0,0 +1,77 @@ +import 'package:enough_mail/src/codecs/mail_codec.dart'; +import 'package:enough_mail/src/mail_address.dart'; +import 'package:enough_mail/src/message_builder.dart'; +import 'package:enough_mail/src/mime_message.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('folding test qp-encode full', () { + const subject = 'àáèéìíòóùúỳýäëïöüÿæßñµ¢łŁ àáèéìíòóùúỳýäëïöü' + 'ÿæßñµ¢łŁasciiàáèéìíòóùúỳýäëïöüÿæßñµ¢łŁ'; + final message = _buildTestMessage(subject); + expect(message?.decodeSubject(), subject); + final buffer = StringBuffer(); + message?.getHeader('subject')?.first.render(buffer); + final output = buffer.toString().split(RegExp(r'\r\n\s+')); + expect(output.length, greaterThan(1)); + expect(output, everyElement(_HasLength(lessThanOrEqualTo(76)))); + }); + + test('folding test qp-encode greek', () { + const subject = 'Λορεμ ιπσθμ δολορ σιτ αμετ, φερρι φαβθλασ οπορτεατ σεα ει'; + final message = _buildTestMessage(subject); + expect(message?.decodeSubject(), subject); + final buffer = StringBuffer(); + message?.getHeader('subject')?.first.render(buffer); + final output = buffer.toString().split(RegExp(r'\r\n\s+')); + expect(output.length, greaterThan(1)); + expect(output, everyElement(_HasLength(lessThanOrEqualTo(76)))); + }); + + test('folding test mixed qp-encode', () { + const subject = 'Quick: do you have a plan to become proactive ' + 'àáèéìíòóùúỳýäëïöüÿæßñµ¢łŁ. ' + 'We understand that if you integrate intuitively then you may also ' + 'mesh iteravely.'; + final message = _buildTestMessage(subject); + expect(message?.decodeSubject(), subject); + final buffer = StringBuffer(); + message?.getHeader('subject')?.first.render(buffer); + final output = buffer.toString().split(RegExp(r'\r\n\s+')); + expect(output.length, greaterThan(1)); + expect(output, everyElement(_HasLength(lessThanOrEqualTo(76)))); + }); + + test('folding test b-encode', () { + const subject = 'Quick: do you have a plan to become proactive ' + 'àáèéìíòóùúỳýäëïöüÿæßñµ¢łŁ. ' + 'We understand that if you integrate intuitively then you may also ' + 'mesh iteravely.'; + final message = _buildTestMessage(subject, HeaderEncoding.B); + expect(message?.decodeSubject(), subject); + final buffer = StringBuffer(); + message?.getHeader('subject')?.first.render(buffer); + final output = buffer.toString().split(RegExp(r'\r\n\s+')); + expect(output.length, greaterThan(1)); + expect(output, everyElement(_HasLength(lessThanOrEqualTo(76)))); + }); +} + +MimeMessage? _buildTestMessage( + String subject, [ + HeaderEncoding encoding = HeaderEncoding.Q, +]) => + MessageBuilder.buildSimpleTextMessage( + const MailAddress('mittente', 'test@example.com'), + [const MailAddress('destinatario', 'recipient@example.com')], + 'This is a short text', + subject: subject, + subjectEncoding: encoding, + ); + +class _HasLength extends CustomMatcher { + _HasLength(matcher) : super('String which length than is', 'length', matcher); + @override + int featureValueOf(dynamic actual) => (actual as String).length; +} diff --git a/packages/enough_mail/test/codecs/mail_codec_test.dart b/packages/enough_mail/test/codecs/mail_codec_test.dart new file mode 100644 index 0000000..4a4b528 --- /dev/null +++ b/packages/enough_mail/test/codecs/mail_codec_test.dart @@ -0,0 +1,124 @@ +import 'package:enough_mail/src/codecs/mail_codec.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('Wrap', () { + test('wrap short input', () { + const input = 'Hello World'; + expect(MailCodec.wrapText(input), 'Hello World'); + }); + + test('wrap long input', () { + const input = + 'Hello World! This is somewhat larger text that should span across ' + 'multiple lines. This will be wrapped in the middle of a word unless ' + 'accidentally this happens to fall on a space. Best regards, ' + 'your unit test'; + final wrapped = MailCodec.wrapText(input); + expect( + wrapped, + 'Hello World! This is somewhat larger text that should span across ' + 'multiple l\r\n' + 'ines. This will be wrapped in the middle of a word unless ' + 'accidentally this \r\n' + 'happens to fall on a space. Best regards, your unit test', + ); + }); + + test('wrap long input at word boundary', () { + const input = + 'Hello World! This is somewhat larger text that should span across ' + 'multiple lines. This will be wrapped in the middle of a word unless ' + 'accidentally this happens to fall on a space. Best regards, ' + 'your unit test'; + final wrapped = MailCodec.wrapText(input, wrapAtWordBoundary: true); + expect( + wrapped, + 'Hello World! This is somewhat larger text that should span across ' + 'multiple \r\n' + 'lines. This will be wrapped in the middle of a word unless ' + 'accidentally this \r\n' + 'happens to fall on a space. Best regards, your unit test', + ); + }); + + test('wrap long input with line breaks', () { + const input = + 'Hello World!\r\nThis is somewhat larger text\r\nthat should span ' + 'across multiple lines.\r\nThis will be wrapped in the middle\r\nof ' + 'a word unless accidentally\r\nthis happens to fall on a space. ' + 'Best regards, your unit test'; + final wrapped = MailCodec.wrapText(input, wrapAtWordBoundary: true); + expect(wrapped, input); + }); + + test('wrap long input with line breaks at beginning and end', () { + const input = + '\r\nHello World!\r\nThis is somewhat larger text\r\nthat should ' + 'span across multiple lines.\r\nThis will be wrapped in the middle' + '\r\nof a word unless accidentally\r\nthis happens to fall on a ' + 'space. Best regards, your unit test\r\n'; + final wrapped = MailCodec.wrapText(input, wrapAtWordBoundary: true); + expect(wrapped, input); + }); + + test('wrap long input with line break at 76', () { + const input = + '01234567890123456789012345678901234567890123456789012345678901234' + '5678901234\r\n56789'; + final wrapped = MailCodec.wrapText(input, wrapAtWordBoundary: true); + expect(wrapped, input); + }); + }); + + group('Decode header', () { + test('decode 2 consecutive encoded words', () { + var input = + '=?utf-8?Q?=D0=9E=EF=BB=BF=EF=BB=BF=EF=BB=BFf=EF=BB=BF=EF=BB=BF=' + 'EF?= =?utf-8?Q?=BB=BFf=EF=BB=BF=EF=BB=BF=EF=BB=BF=D1=96=EF=BB=BF=E' + 'F=BB=BF?= =?utf-8?Q?=EF=BB=BF=D1=81=EF=BB=BF=EF=BB=BF=EF=BB=BF=D0=' + 'B5=EF=BB=BF?= =?utf-8?Q?=EF=BB=BF=EF=BB=BF=E2=80=85=E2=80=8B=E2=80=' + '8B=EF=BB=BF3=EF?= =?utf-8?Q?=BB=BF=EF=BB=BF=EF=BB=BF6=EF=BB=BF=EF=B' + 'B=BF=EF=BB=BF5?='; + expect( + MailCodec.decodeHeader(input), + 'Оffісе' + ' ​​365', + ); + input = + '=?UTF-8?B?RXhrbHVzaXZlIEVpbmxhZHVuZzogSW5mbHVlbmNlci1WZXJidW5kIA=' + '=?= =?UTF-8?B?aW0gQ2hlY2s=?='; + expect( + MailCodec.decodeHeader(input), + 'Exklusive Einladung: Influencer-Verbund im Check', + ); + input = + '=?UTF-8?B?4oCcUmVwLiBNYXR0IEdhZXR6IFN0YWZmZXIgQ2hlZXJlZCBvbiBDYXBp' + 'dG9sIFJpb3RlcnMgdmlhIFBhcmxlcuKAnSAtIFRoZSBCZQ==?= =?UTF-8?B?c3Qgb' + '2YgTnV6emVsIE5ld3NsZXR0ZXIgVHVlLCBGZWIgMiAyMDIx?='; + expect( + MailCodec.decodeHeader(input), + '“Rep. Matt Gaetz Staffer Cheered on Capitol Rioters via Parler” - ' + 'The Best of Nuzzel Newsletter Tue, Feb 2 2021', + ); + }); + test('decode empty Q encoded header', () { + const input = '=?utf-8?Q??='; + expect(MailCodec.decodeHeader(input), ''); + }); + test('decode empty Base64 encoded header', () { + const input = '=?utf-8?B??='; + expect(MailCodec.decodeHeader(input), ''); + }); + + test('decode header with tab between decoded words', () { + const input = + '=?UTF-8?B?RWluZSB3aWNodGlnZSBJbmZvcm1hdGlvbiB6dSBkZWluZXIgTEU=?= =?UTF-8?B?R0/CriBCZXN0ZWxsdW5nIQ==?='; + expect( + MailCodec.decodeHeader(input), + 'Eine wichtige Information zu deiner LEGO® Bestellung!', + ); + }); + }); +} diff --git a/packages/enough_mail/test/codecs/modified_utf7_codec_test.dart b/packages/enough_mail/test/codecs/modified_utf7_codec_test.dart new file mode 100644 index 0000000..8511c27 --- /dev/null +++ b/packages/enough_mail/test/codecs/modified_utf7_codec_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:enough_mail/src/codecs/modified_utf7_codec.dart'; +import 'package:test/test.dart'; + +void main() { + const codec = ModifiedUtf7Codec(); + const encoding = utf8; + + group('Modified UTF7 decoding', () { + test('Simple case 1', () { + const input = '&Jjo-!'; + expect(codec.decodeText(input, encoding), '☺!'); + }); + test('Simple case 2', () { + const input = 'Hello, &ThZ1TA-'; + expect(codec.decodeText(input, encoding), 'Hello, 世界'); + }); + + test('Encoded Ampersand', () { + const input = 'hello&-goodbye'; + expect(codec.decodeText(input, encoding), 'hello&goodbye'); + }); + + test('English, Japanese, and Chinese', () { + const input = '~peter/mail/&ZeVnLIqe-/&U,BTFw-'; + expect(codec.decodeText(input, encoding), '~peter/mail/日本語/台北'); + }); + }); + + group('Modified UTF7 encoding', () { + test('Simple case 1', () { + const input = '☺!'; + expect(codec.encodeText(input), '&Jjo-!'); + }); + test('Simple case 2', () { + const input = 'Hello, 世界'; + expect(codec.encodeText(input), 'Hello, &ThZ1TA-'); + }); + + test('Encoded Ampersand', () { + const input = 'hello&goodbye'; + expect(codec.encodeText(input), 'hello&-goodbye'); + }); + + test('English, Japanese, and Chinese', () { + const input = '~peter/mail/日本語/台北'; + expect(codec.encodeText(input), '~peter/mail/&ZeVnLIqe-/&U,BTFw-'); + }); + + test('quotes', () { + const input = '""'; + expect(codec.encodeText(input), '""'); + }); + + test('* wildcard', () { + const input = '*'; + expect(codec.encodeText(input), '*'); + }); + + test('% wildcard', () { + const input = '%'; + expect(codec.encodeText(input), '%'); + }); + }); +} diff --git a/packages/enough_mail/test/codecs/quoted_printable_mail_codec_test.dart b/packages/enough_mail/test/codecs/quoted_printable_mail_codec_test.dart new file mode 100644 index 0000000..5066667 --- /dev/null +++ b/packages/enough_mail/test/codecs/quoted_printable_mail_codec_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert' as convert; + +import 'package:enough_mail/src/codecs/mail_codec.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('Quoted Printable decoding', () { + test('encodings.quoted-printable header', () { + const 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(MailCodec.decodeHeader(input), 'Chat: oh hi, how do you do? :-)'); + }); + + test('encodings.quoted-printable header no direct start', () { + const 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?_?==?ut' + 'f-8?Q?do=3F?==?utf-8?Q?_?==?utf-8?Q?=3A-)?='; + expect( + MailCodec.decodeHeader(input), + ' Chat: oh hi, how do you do? :-)', + ); + }); + + test('encoding.iso-8859-1 quoted printable', () { + const input = '=?iso-8859-1?Q?Bj=F6rn?= Tester '; + expect( + MailCodec.decodeHeader(input), + 'Björn Tester ', + ); + }); + + test('encoding.iso-8859-1 quoted printable not at start', () { + const input = 'Tester =?iso-8859-1?Q?Bj=F6rn?= '; + expect( + MailCodec.decodeHeader(input), + 'Tester Björn ', + ); + }); + + test('encoding.UTF-8.QuotedPrintable with several codes', () { + const input = '=?utf-8?Q?=E2=80=93?='; + expect( + MailCodec.decodeHeader(input), + isNotNull, + ); // this results in a character - which for some + // reasons cannot be pasted as Dart code + }); + test('encoding.US-ASCII.QuotedPrintable', () { + const input = '=?US-ASCII?Q?Keith_Moore?= '; + expect(MailCodec.decodeHeader(input), 'Keith Moore '); + }); + + test('encoding.UTF-8.QuotedPrintable with line break', () { + const input = 'Viele Gr=C3=BC=C3=9Fe'; + expect( + MailCodec.quotedPrintable.decodeText(input, convert.utf8), + 'Viele Grüße

', + ); + }); + + test('encoding latin1.QuotedPrintable', () { + const input = 'jeden Tag =E4ndern k=F6nnen'; + expect( + MailCodec.quotedPrintable + .decodeText(input, const convert.Latin1Codec()), + 'jeden Tag ändern können', + ); + }); + }); + + group('Quoted Printable encoding', () { + test('encodeHeader.quoted-printable with ASCII input', () { + const input = 'Hello World'; + expect(MailCodec.quotedPrintable.encodeHeader(input), 'Hello World'); + }); + test('encodeHeader.quoted-printable with UTF8 input', () { + const input = 'Hello Wörld'; + expect( + MailCodec.quotedPrintable.encodeHeader(input), + 'Hello W=?utf8?Q?=C3=B6?=rld', + ); + // counter test: + expect( + MailCodec.decodeHeader('Hello W=?UTF8?Q?=C3=B6?=rld'), + 'Hello Wörld', + ); + }); + + test('encodeText.quoted-printable with UTF8 and = input', () { + const input = + 'Hello Wörld. This is a long text without linebreak and this ' + 'contains the formula c^2=a^2+b^2.'; + expect( + MailCodec.quotedPrintable.encodeText(input), + 'Hello W=C3=B6rld. This is a long text without linebreak and this ' + 'contains t=\r\nhe formula c^2=3Da^2+b^2.', + ); + // counter test: + expect( + MailCodec.quotedPrintable.decodeText( + 'Hello W=C3=B6rld. This is a long text without linebreak and ' + 'this contains t=\r\nhe formula c^2=3Da^2+b^2.', + convert.utf8, + ), + 'Hello Wörld. This is a long text without linebreak and this ' + 'contains the formula c^2=a^2+b^2.', + ); + }); + + test(r'encodeText.quoted-printable \r\n line breaks', () { + const input = + 'Hello Wörld.\r\nThis is a long text with\r\na linebreak and this ' + 'contains the formula c^2=a^2+b^2.'; + expect( + MailCodec.quotedPrintable.encodeText(input), + 'Hello W=C3=B6rld.\r\nThis is a long text with\r\na linebreak and ' + 'this contains the formula c^2=3Da^2+b^2.', + ); + // counter test: + expect( + MailCodec.quotedPrintable.decodeText( + MailCodec.quotedPrintable.encodeText(input), + convert.utf8, + ), + input, + ); + }); + + test(r'encodeText.quoted-printable \n line breaks', () { + const input = + 'Hello Wörld.\nThis is a long text with\na linebreak and this ' + 'contains the formula c^2=a^2+b^2.'; + expect( + MailCodec.quotedPrintable.encodeText(input), + 'Hello W=C3=B6rld.\r\nThis is a long text with\r\na linebreak and ' + 'this contains the formula c^2=3Da^2+b^2.', + ); + }); + }); + + group('Q Encoding', () { + group( + 'Decode examples from https://tools.ietf.org/html/rfc2047#section-8', + () { + test('Decode space', () { + const input = 'Keith_Moore'; + expect( + MailCodec.quotedPrintable + .decodeText(input, convert.utf8, isHeader: true), + 'Keith Moore', + ); + }); + + test('Remove space between 2 encoded words', () { + const input = + '=?UTF-8?Q?=E5=9B=9E=E5=A4=8D=EF=BC=9ARe:_Nutzer-Anfrage_zu_dei' + 'ner_A?= =?UTF-8?Q?nzeige_\"Brotbackmaschine_WK84300\"?='; + expect( + MailCodec.decodeHeader(input), + '回复:Re: Nutzer-Anfrage zu deiner Anzeige "Brotbackmaschine' + ' WK84300"', + ); + }); + }, + ); + group( + 'Encode examples from https://tools.ietf.org/html/rfc2047#section-8', + () { + test('Encode space only when required', () { + const input = 'Keith Moore'; + expect(MailCodec.quotedPrintable.encodeHeader(input), 'Keith Moore'); + }); + }, + ); + }); +} diff --git a/packages/enough_mail/test/imap/imap_client_test.dart b/packages/enough_mail/test/imap/imap_client_test.dart new file mode 100644 index 0000000..37ce706 --- /dev/null +++ b/packages/enough_mail/test/imap/imap_client_test.dart @@ -0,0 +1,1488 @@ +// ignore_for_file: lines_longer_than_80_chars +// cSpell:disable + +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:test/test.dart'; + +import '../mock_socket.dart'; +import 'mock_imap_server.dart'; + +late ImapClient client; +late MockImapServer mockServer; +List fetchEvents = []; +List expungedMessages = []; +MessageSequence? vanishedMessages; + +void main() { + setUp(() async { + final envVars = Platform.environment; + final isLogEnabled = envVars['IMAP_LOG'] == 'true'; + client = ImapClient(bus: EventBus(sync: true), isLogEnabled: isLogEnabled); + + client.eventBus + .on() + .listen((e) => expungedMessages.add(e.messageSequenceId)); + client.eventBus + .on() + .listen((e) => vanishedMessages = e.vanishedMessages); + client.eventBus.on().listen((e) => fetchEvents.add(e)); + + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: + const ConnectionInfo('imaptest.enough.de', 993, isSecure: true), + ); + mockServer = MockImapServer(connection.socketServer); + connection.socketServer.write( + '* OK [CAPABILITY IMAP4rev1 CHILDREN ENABLE ID IDLE LIST-EXTENDED LIST-STATUS LITERAL- MOVE NAMESPACE QUOTA SASL-IR SORT SPECIAL-USE THREAD=ORDEREDSUBJECT UIDPLUS UNSELECT WITHIN AUTH=LOGIN AUTH=PLAIN] IMAP server ready H mieue154 15.6 IMAP-1My4Ij-1k2Oa32EiF-00yVN8\r\n', + ); + // allow processing of server greeting: + await Future.delayed(const Duration(milliseconds: 15)); + }); + + test('ImapClient login', () async { + mockServer.response = + '* CAPABILITY IMAP4rev1 CHILDREN ENABLE ID IDLE LIST-EXTENDED LIST-STATUS LITERAL- MOVE NAMESPACE QUOTA SASL-IR SORT SPECIAL-USE THREAD=ORDEREDSUBJECT UIDPLUS UNSELECT WITHIN AUTH=LOGIN AUTH=PLAIN\r\n' + ' OK LOGIN completed'; + final capResponse = await client.login('testuser', 'testpassword'); + expect( + capResponse, + isNotNull, + reason: 'login response does not contain a result', + ); + expect( + capResponse.isNotEmpty, + true, + reason: 'login response does not contain a single capability', + ); + expect(capResponse.length, 20); + expect(capResponse[0].name, 'IMAP4rev1'); + expect(capResponse[1].name, 'CHILDREN'); + expect(capResponse[2].name, 'ENABLE'); + }); + + test('ImapClient login without capability', () async { + // setup own initial response for test: + client = ImapClient(bus: EventBus(sync: true), isLogEnabled: false); + + client.eventBus + .on() + .listen((e) => expungedMessages.add(e.messageSequenceId)); + client.eventBus + .on() + .listen((e) => vanishedMessages = e.vanishedMessages); + client.eventBus.on().listen((e) => fetchEvents.add(e)); + + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: + const ConnectionInfo('imap.qq.com', 993, isSecure: true), + ); + mockServer = MockImapServer(connection.socketServer); + connection.socketServer.write( + '* OK [CAPABILITY IMAP4 IMAP4rev1 ID AUTH=PLAIN AUTH=LOGIN AUTH=XOAUTH2 NAMESPACE] QQMail XMIMAP4Server ready\r\n', + ); + // allow processing of server greeting: + await Future.delayed(const Duration(milliseconds: 15)); + + mockServer.response = ' OK LOGIN completed'; + final capResponse = await client.login('testuser', 'testpassword'); + expect( + capResponse, + isNotNull, + reason: 'login response does not contain a result', + ); + expect( + capResponse.isEmpty, + true, + reason: 'login response should not contain a single capability', + ); + expect(capResponse.length, 0); + expect(client.serverInfo.capabilities, isNotNull); + expect(client.serverInfo.capabilities?.length, 7); + expect(client.serverInfo.capabilities?[2].name, 'ID'); + expect(client.serverInfo.capabilities?[6].name, 'NAMESPACE'); + }); + + test('ImapClient authenticateWithOAuth2', () async { + mockServer.response = ' OK AUTH completed'; + final authResponse = + await client.authenticateWithOAuth2('testuser', 'ABC123456789abc'); + expect( + authResponse, + isNotNull, + reason: 'auth response does not contain a result', + ); + }); + + test('ImapClient authenticateWithOAuthBearer', () async { + mockServer.response = ' OK AUTH completed'; + final authResponse = + await client.authenticateWithOAuthBearer('testuser', 'ABC123456789abc'); + expect( + authResponse, + isNotNull, + reason: 'auth response does not contain a result', + ); + }); + + test('ImapClient capability', () async { + mockServer.response = + '* CAPABILITY IMAP4rev1 CHILDREN ENABLE ID IDLE LIST-EXTENDED LIST-STATUS LITERAL- MOVE NAMESPACE QUOTA SASL-IR SORT SPECIAL-USE THREAD=ORDEREDSUBJECT UIDPLUS UNSELECT WITHIN AUTH=LOGIN AUTH=PLAIN\r\n' + ' OK CAPABILITY completed'; + final capabilityResponse = await client.capability(); + expect( + capabilityResponse, + isNotNull, + reason: 'capability response does not contain a result', + ); + expect( + capabilityResponse.isNotEmpty, + true, + reason: 'capability response does not contain a single capability', + ); + expect(capabilityResponse.length, 20); + expect(capabilityResponse[0].name, 'IMAP4rev1'); + expect(capabilityResponse[1].name, 'CHILDREN'); + expect(capabilityResponse[2].name, 'ENABLE'); + }); + + test('ImapClient listMailboxes with escaped Mailbox-Flags', () async { + mockServer.response = '* LIST (\\HasChildren \\Marked) "/" INBOX\r\n' + '* LIST (\\HasChildren \\Noselect) "/" Public\r\n' + '* LIST (\\HasNoChildren \\Trash) "/" Trash\r\n' + '* LIST (\\HasChildren \\Noselect) "/" Shared\r\n' + ' OK List completed (0.000 + 0.000 secs).'; + final listResponse = await client.listMailboxes(); + expect( + listResponse, + isNotNull, + reason: 'list response does not contain a result', + ); + expect( + listResponse.isNotEmpty, + true, + reason: 'list response does not contain a single mailbox', + ); + expect(listResponse.length, 4, reason: 'Set up 3 mailboxes in root'); + var box = listResponse[0]; + expect('INBOX', box.name); + expect(box.hasChildren, isTrue); + expect(box.isSelected, isFalse); + expect(box.isMarked, isTrue); + expect(box.isNotSelectable, isFalse); + box = listResponse[1]; + expect('Public', box.name); + expect(box.hasChildren, isTrue); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isTrue); + box = listResponse[2]; + expect('Trash', box.name); + expect(box.hasChildren, isFalse); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isFalse); + box = listResponse[3]; + expect('Shared', box.name); + expect(box.hasChildren, isTrue); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isTrue); + expect( + client.serverInfo.pathSeparator, + '/', + reason: 'different path separator than in server', + ); + }); + + test('ImapClient LSUB', () async { + mockServer.response = '* LSUB (\\HasChildren \\Marked) "/" INBOX\r\n' + '* LSUB (\\HasChildren \\Noselect) "/" Public\r\n' + ' OK LSUB completed (0.000 + 0.000 secs).'; + final listResponse = await client.listSubscribedMailboxes(); + expect( + listResponse, + isNotNull, + reason: 'lsub response does not contain a result', + ); + expect( + listResponse.length, + 2, + reason: 'lsub response does not contain 2 mailboxes', + ); + expect( + client.serverInfo.pathSeparator, + '/', + reason: 'different path separator than set up', + ); + var box = listResponse[0]; + expect('INBOX', box.name); + expect(box.hasChildren, isTrue); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isFalse); + box = listResponse[1]; + expect('Public', box.name); + expect(box.hasChildren, isTrue); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isTrue); + }); + + test('ImapClient LIST and SELECT', () async { + mockServer.response = + '* LIST (\\HasNoChildren \\UnMarked \\Archive) "/" INBOX/Archive\r\n' + '* LIST (\\HasNoChildren \\UnMarked \\Sent) "/" INBOX/Sent\r\n' + '* LIST (\\HasNoChildren \\Marked \\Trash) "/" INBOX/Trash\r\n' + '* LIST (\\HasNoChildren \\Marked \\Junk) "/" INBOX/Spam\r\n' + '* LIST (\\HasNoChildren \\UnMarked \\Drafts) "/" INBOX/Drafts\r\n' + ' OK List completed (0.000 + 0.000 secs).'; + final listResponse = await client.listMailboxes(path: '"INBOX"'); + expect( + listResponse, + isNotNull, + reason: 'list response does not conatin a result', + ); + expect( + client.serverInfo.pathSeparator, + '/', + reason: 'different path separator than set up', + ); + expect(listResponse.length, 5, reason: 'Set up 6 mailboxes'); + var box = listResponse[0]; + expect(box.name, 'Archive'); + expect(box.hasChildren, isFalse); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isFalse); + expect(box.isArchive, isTrue); + box = listResponse[1]; + expect(box.name, 'Sent'); + expect(box.hasChildren, isFalse); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isFalse); + expect(box.isSent, isTrue); + box = listResponse[2]; + expect(box.name, 'Trash'); + expect(box.hasChildren, isFalse); + expect(box.isSelected, isFalse); + expect(box.isNotSelectable, isFalse); + expect(box.isTrash, isTrue); + + final archive = listResponse[0]; + + mockServer.response = '* 63510 EXISTS\r\n' + '* 23 RECENT\r\n' + '* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \$Forwarded \$Unsubscribed)\r\n' + '* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen \$Forwarded \$Unsubscribed \\*)] Unlimited\r\n' + '* OK [UNSEEN 30130] Message 30130 is first unseen\r\n' + '* OK [UIDNEXT 351118] Predicted next UID\r\n' + '* OK [UIDVALIDITY 1245213765] UIDs valid\r\n' + ' OK [READ-WRITE] SELECT completed'; + final selectResponse = await client.selectMailbox(archive); + expect( + selectResponse, + isNotNull, + reason: 'select response does not contain a result ', + ); + expect( + selectResponse.isReadWrite, + true, + reason: 'SELECT should open in READ-WRITE ', + ); + expect( + selectResponse.messagesExists, + 63510, + reason: 'expecting at least 63510 mails in SELECT response', + ); + expect(archive.messagesRecent, 23); + expect(archive.firstUnseenMessageSequenceId, 30130); + expect(archive.uidValidity, 1245213765); + expect(archive.uidNext, 351118); + expect(archive.highestModSequence, null); + expect(archive.messageFlags, isNotNull, reason: 'message flags expected'); + expect(archive.messageFlags, [ + '\\Answered', + '\\Flagged', + '\\Deleted', + '\\Seen', + '\\Draft', + '\$Forwarded', + '\$Unsubscribed', + ]); + expect( + archive.permanentMessageFlags, + [ + '\\Answered', + '\\Flagged', + '\\Draft', + '\\Deleted', + '\\Seen', + '\$Forwarded', + '\$Unsubscribed', + '\\*', + ], + reason: 'permanent message flags expected', + ); + }); + + test('ImapClient search', () async { + mockServer.response = '* SEARCH 3423 17 3\r\n' + ' OK SEARCH completed'; + final searchResponse = + await client.searchMessages(searchCriteria: 'UNSEEN'); + expect(searchResponse.matchingSequence, isNotNull); + expect(searchResponse.matchingSequence?.toList(), [3, 17, 3423]); + }); + + test('ImapClient uid search', () async { + mockServer.response = '* SEARCH 3423 17 3\r\n' + ' OK UID SEARCH completed'; + final searchResult = + await client.uidSearchMessages(searchCriteria: 'UNSEEN'); + expect(searchResult.matchingSequence, isNotNull); + expect(searchResult.matchingSequence?.isNotEmpty, true); + expect(searchResult.matchingSequence?.toList(), [3, 17, 3423]); + }); + + test('ImapClient sort', () async { + final testSequence = [ + 184, + 182, + 183, + 181, + 180, + 179, + 178, + 177, + 176, + 175, + 174, + 173, + 172, + 171, + 170, + 169, + 168, + 167, + 166, + 164, + 163, + ]; + mockServer.response = '* SORT ${testSequence.join(' ')}\r\n' + ' OK SORT Completed'; + final sortResponse = await client.sortMessages('ARRIVAL'); + expect(sortResponse.matchingSequence, isNotNull); + expect(sortResponse.matchingSequence?.toList(), testSequence); + }); + + test('ImapClient uid sort', () async { + final testSequence = [ + 184, + 182, + 183, + 181, + 180, + 179, + 178, + 177, + 176, + 175, + 174, + 173, + 172, + 171, + 170, + 169, + 168, + 167, + 166, + 164, + 163, + ]; + mockServer.response = '* SORT ${testSequence.join(' ')}\r\n' + ' OK UID SORT Completed'; + final sortResponse = await client.uidSortMessages('ARRIVAL'); + expect(sortResponse.matchingSequence, isNotNull); + expect(sortResponse.matchingSequence?.toList(), testSequence); + }); + + test('ImapClient extended search', () async { + final testSequence = [2, 17, 3423]; + mockServer.response = + '* ESEARCH (TAG "") MIN 2 COUNT 3 ALL ${testSequence.join(',')}\r\n' + ' OK SEARCH Completed'; + + final searchResponse = await client.searchMessages( + searchCriteria: 'UNSEEN', + returnOptions: [ + ReturnOption.count(), + ReturnOption.min(), + ReturnOption.all(), + ], + ); + expect(searchResponse.isExtended, isTrue); + expect(searchResponse.count, 3); + expect(searchResponse.min, 2); + + expect(searchResponse.matchingSequence, isNotNull); + expect(searchResponse.matchingSequence?.toList(), testSequence); + }); + + test('ImapClient extended uid search', () async { + final testSequence = [2, 17, 3423]; + mockServer.response = + '* ESEARCH (TAG "") MIN 2 COUNT 3 UID ALL ${testSequence.join(',')}\r\n' + ' OK UID SEARCH Completed'; + + final searchResponse = await client.uidSearchMessages( + searchCriteria: 'UNSEEN', + returnOptions: [ + ReturnOption.count(), + ReturnOption.min(), + ReturnOption.all(), + ], + ); + expect(searchResponse.isExtended, isTrue); + expect(searchResponse.count, 3); + expect(searchResponse.min, 2); + + expect(searchResponse.matchingSequence, isNotNull); + expect(searchResponse.matchingSequence?.toList(), testSequence); + }); + + test('ImapClient extended sort', () async { + final testSequence = [ + 184, + 182, + 183, + 181, + 180, + 179, + 178, + 177, + 176, + 175, + 174, + 173, + 172, + 171, + 170, + 169, + 168, + 167, + 166, + 164, + 163, + ]; + mockServer.response = + '* ESEARCH (TAG "") COUNT 21 ALL ${testSequence.join(',')}\r\n' + ' OK UID SORT Completed'; + final sortResponse = await client.sortMessages( + 'ARRIVAL', + 'ALL', + 'UTF-8', + [ReturnOption.count(), ReturnOption.all()], + ); + expect(sortResponse.matchingSequence, isNotNull); + expect(sortResponse.matchingSequence?.toList(), testSequence); + expect(sortResponse.count, 21); + }); + + test('ImapClient extended uid sort', () async { + final testSequence = [ + 184, + 182, + 183, + 181, + 180, + 179, + 178, + 177, + 176, + 175, + 174, + 173, + 172, + 171, + 170, + 169, + 168, + 167, + 166, + 164, + 163, + ]; + mockServer.response = + '* ESEARCH (TAG "") COUNT 21 UID ALL ${testSequence.join(',')}\r\n' + ' OK UID SORT Completed'; + final sortResponse = await client.uidSortMessages( + 'ARRIVAL', + 'ALL', + 'UTF-8', + [ReturnOption.count(), ReturnOption.all()], + ); + expect(sortResponse.matchingSequence, isNotNull); + expect(sortResponse.matchingSequence?.toList(), testSequence); + expect(sortResponse.count, 21); + }); + + test('ImapClient fetch FULL', () async { + mockServer.response = + '* 123456 FETCH (MODSEQ (12323) 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' + '* 123455 FETCH (MODSEQ (12328) 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"))\r\n' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'FULL', + changedSinceModSequence: 0, + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.modSequence, 12323); + expect(message.flags, isNotNull); + expect(message.flags?.length, 0); + expect(message.internalDate, '25-Oct-2019 16:35:31 +0200'); + expect(message.size, 15320); + expect(message.envelope, isNotNull); + expect( + message.envelope?.date, + DateCodec.decodeDate('Fri, 25 Oct 2019 16:35:28 +0200 (CEST)'), + ); + expect( + message.decodeDate(), + DateCodec.decodeDate('Fri, 25 Oct 2019 16:35:28 +0200 (CEST)'), + ); + expect( + message.envelope?.subject, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps', + ); + expect( + message.decodeSubject(), + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps', + ); + expect( + message.envelope?.inReplyTo, + '', + ); + expect( + message.getHeaderValue('in-reply-to'), + '', + ); + expect( + message.envelope?.messageId, + '<130499090.797.1572014128349@product-gw2.domain.com>', + ); + expect( + message.getHeaderValue('message-id'), + '<130499090.797.1572014128349@product-gw2.domain.com>', + ); + expect(message.cc, isNotNull); + expect(message.cc?.isEmpty, isTrue); + expect(message.bcc, isNotNull); + expect(message.bcc?.isEmpty, isTrue); + expect(message.envelope?.from, isNotNull); + expect(message.envelope?.from?.length, 1); + expect(message.envelope?.from?.first.personalName, 'Schön, Rob'); + expect(message.envelope?.from?.first.mailboxName, 'rob.schoen'); + expect(message.envelope?.from?.first.hostName, 'domain.com'); + expect(message.from, isNotNull); + expect(message.from?.length, 1); + expect(message.from?.first.personalName, 'Schön, Rob'); + expect(message.from?.first.mailboxName, 'rob.schoen'); + expect(message.from?.first.hostName, 'domain.com'); + expect(message.sender, isNotNull); + expect(message.sender?.personalName, 'Schön, Rob'); + expect(message.sender?.mailboxName, 'rob.schoen'); + expect(message.sender?.hostName, 'domain.com'); + expect(message.replyTo, isNotNull); + expect(message.replyTo?.first.personalName, 'Schön, Rob'); + expect(message.replyTo?.first.mailboxName, 'rob.schoen'); + expect(message.replyTo?.first.hostName, 'domain.com'); + expect(message.to, isNotNull); + expect(message.to?.first.personalName, 'Alice Dev'); + expect(message.to?.first.mailboxName, 'alice.dev'); + expect(message.to?.first.hostName, 'domain.com'); + expect(message.body, isNotNull); + expect(message.body?.contentType, isNotNull); + expect( + message.body?.contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(message.body?.parts, isNotNull); + expect(message.body?.parts?.length, 2); + expect(message.body?.parts?[0].contentType, isNotNull); + expect( + message.body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(message.body?.parts?[0].description, null); + expect(message.body?.parts?[0].cid, null); + expect(message.body?.parts?[0].encoding, 'quoted-printable'); + expect(message.body?.parts?[0].size, 1289); + expect(message.body?.parts?[0].numberOfLines, 53); + expect(message.body?.parts?[0].contentType?.charset, 'utf-8'); + expect( + message.body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(message.body?.parts?[1].description, null); + expect(message.body?.parts?[1].cid, null); + expect(message.body?.parts?[1].encoding, 'quoted-printable'); + expect(message.body?.parts?[1].size, 7496); + expect(message.body?.parts?[1].numberOfLines, 302); + expect(message.body?.parts?[1].contentType?.charset, 'utf-8'); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect(message.modSequence, 12328); + expect(message.flags, isNotNull); + 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.envelope?.date, + DateCodec.decodeDate('Fri, 25 Oct 2019 11:02:30 -0400 (EDT)'), + ); + expect(message.envelope?.subject, 'New appointment: Discussion and Q&A'); + expect( + message.envelope?.inReplyTo, + '', + ); + expect( + message.envelope?.messageId, + '<1814674343.1008.1572015750561@appsuite-gw2.domain.com>', + ); + expect(message.cc, isNotNull); + expect(message.cc?.isEmpty, isTrue); + expect(message.bcc, isNotNull); + expect(message.bcc?.isEmpty, isTrue); + expect(message.from, isNotNull); + expect(message.from?.length, 1); + expect(message.from?.first.personalName, 'Tester, Theresa'); + expect(message.from?.first.mailboxName, 't.tester'); + expect(message.from?.first.hostName, 'domain.com'); + expect(message.sender, isNotNull); + expect(message.sender?.personalName, 'Tester, Theresa'); + expect(message.sender?.mailboxName, 't.tester'); + expect(message.sender?.hostName, 'domain.com'); + expect(message.replyTo, isNotNull); + expect(message.replyTo?.first.personalName, 'Tester, Theresa'); + expect(message.replyTo?.first.mailboxName, 't.tester'); + expect(message.replyTo?.first.hostName, 'domain.com'); + expect(message.to, isNotNull); + expect(message.to?.first.personalName, 'Alice Dev'); + expect(message.to?.first.mailboxName, 'alice.dev'); + expect(message.to?.first.hostName, 'domain.com'); + expect(message.body, isNotNull); + expect( + message.body?.contentType?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(message.body?.parts, isNotNull); + expect(message.body?.parts?.length, 2); + expect( + message.body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(message.body?.parts?[0].description, null); + expect(message.body?.parts?[0].cid, null); + expect(message.body?.parts?[0].encoding, '7bit'); + expect(message.body?.parts?[0].size, 1152); + expect(message.body?.parts?[0].numberOfLines, 23); + expect(message.body?.parts?[0].contentType?.charset, 'us-ascii'); + expect( + message.body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(message.body?.parts?[1].description, 'Compiler diff'); + expect( + message.body?.parts?[1].cid, + '<960723163407.20117h@cac.washington.edu>', + ); + expect(message.body?.parts?[1].encoding, 'base64'); + expect(message.body?.parts?[1].size, 4554); + expect(message.body?.parts?[1].numberOfLines, 73); + expect(message.body?.parts?[1].contentType?.charset, 'us-ascii'); + expect(message.body?.parts?[1].contentType?.parameters['name'], 'cc.diff'); + }); + + test('ImapClient fetch BODY[HEADER]', () async { + mockServer.response = '* 123456 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' + '* 123455 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' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY[HEADER]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.headers, isNotNull); + expect(message.headers?.length, 8); + expect( + message.getHeaderValue('From'), + 'Terry Gray ', + ); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect(message.headers, isNotNull); + expect(message.headers?.length, 9); + expect(message.getHeaderValue('Chat-Version'), '1.0'); + expect( + message.getHeaderValue('Content-Type'), + 'text/plan; charset="UTF-8"', + ); + }); + + test('ImapClient uid fetch BODY[HEADER]', () async { + mockServer.response = '* 123456 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' + '* 123455 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' + ' OK FETCH completed'; + final fetchResponse = await client.uidFetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY[HEADER]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.headers, isNotNull); + expect(message.headers?.length, 8); + expect( + message.getHeaderValue('From'), + 'Terry Gray ', + ); + + message = fetchResponse.messages[1]; + expect(message.headers, isNotNull); + 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.PEEK[HEADER.FIELDS (References)]', () async { + mockServer.response = + '* 123456 FETCH (BODY[HEADER.FIELDS (REFERENCES)] {50}\r\n' + r'References: ' + '\r\n\r\n' + ')\r\n' + '* 123455 FETCH (BODY[HEADER.FIELDS (REFERENCES)] {2}\r\n' + '\r\n' + ')\r\n' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY.PEEK[HEADER.FIELDS (REFERENCES)]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.headers, isNotNull); + expect(message.headers?.length, 1); + expect( + message.getHeaderValue('References'), + r'', + ); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect(message.headers, isEmpty); + expect(message.getHeaderValue('References'), null); + }); + + test('ImapClient fetch BODY.PEEK[HEADER.FIELDS.NOT (References)]', () async { + mockServer.response = + '* 123456 FETCH (BODY[HEADER.FIELDS.NOT (REFERENCES)] {46}\r\n' + 'From: Shirley \r\n' + '\r\n' + ')\r\n' + '* 123455 FETCH (BODY[HEADER.FIELDS.NOT (REFERENCES)] {2}\r\n' + '\r\n' + ')\r\n' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY.PEEK[HEADER.FIELDS.NOT (REFERENCES)]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.headers, isNotNull); + expect(message.headers?.length, 1); + expect( + message.getHeaderValue('From'), + 'Shirley ', + ); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect(message.headers, isEmpty); + expect(message.getHeaderValue('References'), null); + expect(message.getHeaderValue('From'), null); + }); + + test('ImapClient fetch BODY[]', () async { + mockServer.response = '* 123456 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' + '* 123455 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' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY[]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.decodeContentText(), 'Hello Word\r\n'); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect(message.decodeContentText(), 'Welcome to Enough MailKit.\r\n'); + expect(message.getHeaderValue('MIME-Version'), '1.0'); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + }); + + test('ImapClient fetch with split response', () async { + mockServer.response = '* 123456 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' + '* 123456 FETCH (UID 16 FLAGS (\\Seen))\r\n' + ' OK FETCH completed'; + final fetchResponse = await client.fetchMessages( + MessageSequence.fromId(123456, isUid: false), + 'BODY[]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 1); + final message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + expect(message.decodeContentText(), 'Hello Word\r\n'); + expect(message.uid, 16); + expect(message.flags, ['\\Seen']); + }); + + test('ImapClient fetch BODY[1]', () async { + mockServer.response = '* 123456 FETCH (BODY[1] {14}\r\n' + '\r\nHello Word\r\n' + ')\r\n' + '* 123455 FETCH (BODY[1] {27}\r\n' + '\r\nWelcome to Enough Mail.\r\n' + ')\r\n' + ' OK FETCH completed'; + + final fetchResponse = await client.fetchMessages( + MessageSequence.fromRange(123455, 123456), + 'BODY[1]', + ); + expect(fetchResponse, isNotNull, reason: 'fetch result expected'); + expect(fetchResponse.messages.length, 2); + var message = fetchResponse.messages[0]; + expect(message.sequenceId, 123456); + final part = message.getPart('1'); + expect(part?.decodeContentText(), '\r\nHello Word\r\n'); + + message = fetchResponse.messages[1]; + expect(message.sequenceId, 123455); + expect( + message.getPart('1')?.decodeContentText(), + '\r\nWelcome to Enough Mail.\r\n', + ); + }); + + Future _selectInbox() { + mockServer.response = '* 63510 EXISTS\r\n' + '* 23 RECENT\r\n' + '* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \$Forwarded \$Unsubscribed)\r\n' + '* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen \$Forwarded \$Unsubscribed \\*)] Unlimited\r\n' + '* OK [UNSEEN 30130] Message 30130 is first unseen\r\n' + '* OK [UIDNEXT 351118] Predicted next UID\r\n' + '* OK [UIDVALIDITY 1245213765] UIDs valid\r\n' + ' OK [READ-WRITE] SELECT completed'; + client.serverInfo.pathSeparator ??= '/'; + + return client.selectInbox(); + } + + test('ImapClient noop', () async { + expungedMessages = []; + final box = await _selectInbox(); + await Future.delayed(const Duration(milliseconds: 20)); + mockServer.response = ' OK NOOP Completed'; + await client.noop(); + mockServer.response = '* 2232 EXPUNGE\r\n' + '* 1234 EXPUNGE\r\n' + '* 23 EXISTS\r\n' + '* 3 RECENT\r\n' + '* 14 FETCH (FLAGS (\\Seen \\Deleted))\r\n' + '* 2322 FETCH (FLAGS (\\Seen \$Chat))\r\n' + ' OK NOOP Completed'; + await client.noop(); + await Future.delayed(const Duration(milliseconds: 10)); + expect( + expungedMessages, + [2232, 1234], + reason: 'Expunged messages should fit', + ); + expect(box.messagesExists, 23); + expect(box.messagesRecent, 3); + expect(fetchEvents.length, 2, reason: 'Expecting 2 fetch events'); + var event = fetchEvents[0]; + expect(event.message, isNotNull); + expect(event.message.sequenceId, 14); + expect(event.message.flags, [r'\Seen', r'\Deleted']); + event = fetchEvents[1]; + expect(event.message, isNotNull); + expect(event.message.sequenceId, 2322); + expect(event.message.flags, [r'\Seen', r'$Chat']); + + expungedMessages.clear(); + fetchEvents.clear(); + vanishedMessages = null; + mockServer.response = '* VANISHED 1232:1236\r\n' + '* 233 EXISTS\r\n' + '* 33 RECENT\r\n' + '* 14 FETCH (FLAGS (\\Seen \\Deleted))\r\n' + '* 2322 FETCH (FLAGS (\\Seen \$Chat))\r\n' + ' OK NOOP Completed'; + await client.noop(); + await Future.delayed(const Duration(milliseconds: 50)); + expect(expungedMessages, [], reason: 'Expunged messages should fit'); + expect(vanishedMessages, isNotNull); + expect(vanishedMessages?.toList(), [1232, 1233, 1234, 1235, 1236]); + expect(box.messagesExists, 233); + expect(box.messagesRecent, 33); + expect(fetchEvents.length, 2, reason: 'Expecting 2 fetch events'); + event = fetchEvents[0]; + expect(event.message, isNotNull); + expect(event.message.sequenceId, 14); + expect(event.message.flags, [r'\Seen', r'\Deleted']); + event = fetchEvents[1]; + expect(event.message, isNotNull); + expect(event.message.sequenceId, 2322); + expect(event.message.flags, [r'\Seen', r'$Chat']); + }); + + test('ImapClient check', () async { + await _selectInbox(); + await Future.delayed(const Duration(seconds: 1)); + expungedMessages = []; + mockServer.response = '* 2232 EXPUNGE\r\n' + '* 1234 EXPUNGE\r\n' + '* VANISHED 1232:1236\r\n' + '* 233 EXISTS\r\n' + '* 33 RECENT\r\n' + '* 14 FETCH (FLAGS (\\Seen \\Deleted))\r\n' + '* 2322 FETCH (FLAGS (\\Seen \$Chat))\r\n' + ' OK CHECK Completed'; + await client.check(); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + expungedMessages, + [2232, 1234], + reason: 'Expunged messages should fit', + ); + }); + + test('ImapClient expunge', () async { + await _selectInbox(); + expungedMessages = []; + await Future.delayed(const Duration(seconds: 1)); + + mockServer.response = '* 3 EXPUNGE\r\n' + '* 3 EXPUNGE\r\n' + '* 23 EXPUNGE\r\n' + '* 26 EXPUNGE\r\n' + ' OK EXPUNGE completed'; + + await client.expunge(); + await Future.delayed(const Duration(milliseconds: 50)); + expect( + expungedMessages, + [3, 3, 23, 26], + reason: 'Expunged messages should fit', + ); + }); + + test('ImapClient uidExpunge', () async { + await _selectInbox(); + expungedMessages = []; + await Future.delayed(const Duration(seconds: 1)); + + mockServer.response = '* 12345 EXPUNGE\r\n' + '* 12346 EXPUNGE\r\n' + ' OK UID EXPUNGE completed'; + + await client.uidExpunge(MessageSequence.fromRange(12345, 12346)); + await Future.delayed(const Duration(milliseconds: 50)); + expect( + expungedMessages, + [12345, 12346], + reason: 'Expunged messages should fit', + ); + }); + + test('ImapClient copy', () async { + await _selectInbox(); + mockServer.response = ' OK messages copied'; + await client.copy( + MessageSequence.fromRange(1, 3), + targetMailboxPath: 'TRASH', + ); + }); + + test('ImapClient uid copy', () async { + await _selectInbox(); + mockServer.response = + ' OK [COPYUID 1232132 1232,1236 12345,12346] messages copied'; + final copyResponse = await client.uidCopy( + MessageSequence.fromRange(1232, 1236), + targetMailboxPath: 'TRASH', + ); + expect(copyResponse, isNotNull); + expect(copyResponse.responseCodeCopyUid?.targetSequence, isNotNull); + expect( + copyResponse.responseCodeCopyUid?.targetSequence.toList(), + [12345, 12346], + ); + }); + + test('ImapClient move', () async { + await _selectInbox(); + mockServer.response = + ' OK [COPYUID 1232132 1232,1236 12345,12346] messages copied'; + final moveResponse = await client.move( + MessageSequence.fromRange(1, 3), + targetMailboxPath: 'TRASH', + ); + expect(moveResponse, isNotNull); + expect(moveResponse.responseCodeCopyUid?.targetSequence, isNotNull); + expect( + moveResponse.responseCodeCopyUid?.targetSequence.toList(), + [12345, 12346], + ); + }); + + test('ImapClient uid move', () async { + await _selectInbox(); + mockServer.response = + ' OK [COPYUID 1232132 1232,1236 12345,12346] messages copied'; + final moveResponse = await client.uidMove( + MessageSequence.fromRange(1, 3), + targetMailboxPath: 'TRASH', + ); + expect(moveResponse, isNotNull); + expect(moveResponse.responseCodeCopyUid?.targetSequence, isNotNull); + expect( + moveResponse.responseCodeCopyUid?.targetSequence.toList(), + [12345, 12346], + ); + }); + + test('ImapClient store', () async { + await _selectInbox(); + mockServer.response = '* 1 FETCH (FLAGS (\\Flagged \\Seen))\r\n' + '* 2 FETCH (FLAGS (\\Deleted \\Seen))\r\n' + '* 3 FETCH (FLAGS (\\Seen))\r\n' + ' OK store completed'; + final storeResponse = await client.store( + MessageSequence.fromRange(1, 3), + [r'\Seen'], + unchangedSinceModSequence: 12346, + ); + expect(storeResponse.changedMessages, isNotNull); + expect(storeResponse.changedMessages, isNotEmpty); + expect(storeResponse.changedMessages?.length, 3); + expect(storeResponse.changedMessages?[0].sequenceId, 1); + expect(storeResponse.changedMessages?[0].flags, [r'\Flagged', r'\Seen']); + expect(storeResponse.changedMessages?[1].sequenceId, 2); + expect(storeResponse.changedMessages?[1].flags, [r'\Deleted', r'\Seen']); + expect(storeResponse.changedMessages?[2].sequenceId, 3); + expect(storeResponse.changedMessages?[2].flags, [r'\Seen']); + }); + + test('ImapClient store with modified sequence', () async { + await _selectInbox(); + + mockServer.response = '* 5 FETCH (MODSEQ (320162350))\r\n' + ' OK [MODIFIED 7,9] Conditional STORE done'; + final storeResponse = await client.store( + MessageSequence.fromRange(4, 9), + [r'\Seen'], + unchangedSinceModSequence: 12345, + ); + expect(storeResponse.changedMessages, isNotNull); + expect(storeResponse.changedMessages, isNotEmpty); + expect(storeResponse.changedMessages?.length, 1); + expect(storeResponse.changedMessages?[0].sequenceId, 5); + expect(storeResponse.modifiedMessageSequence, isNotNull); + expect(storeResponse.modifiedMessageSequence?.length, 2); + expect(storeResponse.modifiedMessageSequence?.toList(), [7, 9]); + }); + + test('ImapClient uid store', () async { + await _selectInbox(); + mockServer.response = '* 123 FETCH (UID 12342 FLAGS (\\Flagged \\Seen))\r\n' + '* 124 FETCH (UID 12343 FLAGS (\\Deleted \\Seen))\r\n' + '* 125 FETCH (UID 12344 FLAGS (\\Seen))\r\n' + ' OK store completed'; + + final storeResponse = await client + .uidStore(MessageSequence.fromRange(12342, 12344), [r'\Seen']); + expect(storeResponse.changedMessages, isNotNull); + expect(storeResponse.changedMessages, isNotEmpty); + expect(storeResponse.changedMessages?.length, 3); + expect(storeResponse.changedMessages?[0].uid, 12342); + expect(storeResponse.changedMessages?[0].flags, [r'\Flagged', r'\Seen']); + expect(storeResponse.changedMessages?[1].uid, 12343); + expect(storeResponse.changedMessages?[1].flags, [r'\Deleted', r'\Seen']); + expect(storeResponse.changedMessages?[2].uid, 12344); + expect(storeResponse.changedMessages?[2].flags, [r'\Seen']); + }); + + test('ImapClient markSeen', () async { + await _selectInbox(); + mockServer.response = '* 1 FETCH (FLAGS (\\Flagged \\Seen))\r\n' + '* 2 FETCH (FLAGS (\\Deleted \\Seen))\r\n' + '* 3 FETCH (FLAGS (\\Seen))\r\n' + ' OK store completed'; + final storeResponse = + await client.markSeen(MessageSequence.fromRange(1, 3)); + expect(storeResponse.changedMessages, isNotNull); + expect(storeResponse.changedMessages, isNotEmpty); + expect(storeResponse.changedMessages?.length, 3); + expect(storeResponse.changedMessages?[0].sequenceId, 1); + expect(storeResponse.changedMessages?[0].flags, [r'\Flagged', r'\Seen']); + expect(storeResponse.changedMessages?[1].sequenceId, 2); + expect(storeResponse.changedMessages?[1].flags, [r'\Deleted', r'\Seen']); + expect(storeResponse.changedMessages?[2].sequenceId, 3); + expect(storeResponse.changedMessages?[2].flags, [r'\Seen']); + }); + + test('ImapClient markFlagged', () async { + await _selectInbox(); + mockServer.response = '* 1 FETCH (FLAGS (\\Flagged \\Seen))\r\n' + '* 2 FETCH (FLAGS (\\Deleted \\Flagged \\Seen))\r\n' + '* 3 FETCH (FLAGS (\\Seen \\Flagged))\r\n' + ' OK store completed'; + final storeResponse = + await client.markFlagged(MessageSequence.fromRange(1, 3)); + expect(storeResponse.changedMessages, isNotNull); + expect(storeResponse.changedMessages, isNotEmpty); + expect(storeResponse.changedMessages?.length, 3); + expect(storeResponse.changedMessages?[0].sequenceId, 1); + expect(storeResponse.changedMessages?[0].flags, [r'\Flagged', r'\Seen']); + expect(storeResponse.changedMessages?[1].sequenceId, 2); + expect( + storeResponse.changedMessages?[1].flags, + [r'\Deleted', r'\Flagged', r'\Seen'], + ); + expect(storeResponse.changedMessages?[2].sequenceId, 3); + expect(storeResponse.changedMessages?[2].flags, [r'\Seen', r'\Flagged']); + }); + + test('ImapClient enable', () async { + mockServer.response = '* ENABLED CONDSTORE QRESYNC\r\n' + ' OK Enabled Caps'; + final enabledCaps = await client.enable(['QRESYNC', 'CONDSTORE']); + expect(enabledCaps, isNotEmpty); + expect(enabledCaps.length, 2); + expect(enabledCaps[0].name, 'CONDSTORE'); + expect(enabledCaps[1].name, 'QRESYNC'); + }); + + test('ImapClient getmetadata 1', () async { + mockServer.response = + '* METADATA "INBOX" (/private/comment "My own comment")\r\n' + ' OK Metadata completed'; + final metaData = await client.getMetaData('/private/comment'); + expect(metaData, isNotNull); + expect(metaData, isNotEmpty); + expect(metaData[0].name, '/private/comment'); + expect(metaData[0].mailboxName, 'INBOX'); + expect(metaData[0].valueText, 'My own comment'); + }); + + test('ImapClient getmetadata 2', () async { + mockServer.response = + '* METADATA "" (/private/vendor/vendor.dovecot/webpush/vapid {136}\r\n' + '-----BEGIN PUBLIC KEY-----\r\n' + 'MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgACYHfTQ0biATut1VhK/AW2KmZespz+\r\n' + 'DEQ1yH3nvbayCuY=\r\n' + '-----END PUBLIC KEY-----)\r\n' + ' OK Metadata completed'; + final metaData = await client.getMetaData('/private/comment'); + expect(metaData, isNotNull); + expect(metaData, isNotEmpty); + expect(metaData[0].name, '/private/vendor/vendor.dovecot/webpush/vapid'); + expect(metaData[0].mailboxName, ''); + expect( + metaData[0].valueText, + '-----BEGIN PUBLIC KEY-----\r\n' + 'MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgACYHfTQ0biATut1VhK/AW2KmZespz+\r\n' + 'DEQ1yH3nvbayCuY=\r\n' + '-----END PUBLIC KEY-----', + ); + }); + + test('ImapClient getmetadata with several entries', () async { + mockServer.response = + '* METADATA "" (/private/vendor/vendor.dovecot/coi/config/enabled {3}\r\n' + 'yes' + ' /private/vendor/vendor.dovecot/coi/config/mailbox-root {3}\r\n' + 'COI' + ' /private/vendor/vendor.dovecot/coi/config/message-filter {6}\r\n' + 'active' + ')\r\n' + ' OK Metadata completed'; + final metaData = await client.getMetaData('/private/comment'); + expect(metaData, isNotNull); + expect(metaData, isNotEmpty); + expect(metaData.length, 3); + expect( + metaData[0].name, + '/private/vendor/vendor.dovecot/coi/config/enabled', + ); + expect(metaData[0].mailboxName, ''); + expect(metaData[0].valueText, 'yes'); + expect( + metaData[1].name, + '/private/vendor/vendor.dovecot/coi/config/mailbox-root', + ); + expect(metaData[1].mailboxName, ''); + expect(metaData[1].valueText, 'COI'); + expect( + metaData[2].name, + '/private/vendor/vendor.dovecot/coi/config/message-filter', + ); + expect(metaData[2].mailboxName, ''); + expect(metaData[2].valueText, 'active'); + }); + + test('ImapClient setmetadata', () async { + mockServer.response = ' OK Metadata completed'; + final entry = MetaDataEntry(name: '/private/comment'); + await client.setMetaData(entry); + }); + + test('ImapClient append', () async { + await _selectInbox(); + final message = MessageBuilder.buildSimpleTextMessage( + const MailAddress('User Name', 'user.name@domain.com'), + [const MailAddress('Rita Recpient', 'rr@domain.com')], + 'Hey,\r\nhow are things today?\r\n\r\nAll the best?', + subject: 'Appended draft message', + ); + mockServer.response = '+ OK\r\n' + ' OK [APPENDUID 1466002016 176] Append completed (0.068 + 0.059 + 0.051 secs).'; + final appendResponse = + await client.appendMessage(message, flags: [r'\Draft', r'\Seen']); + expect(appendResponse, isNotNull); + expect(appendResponse.responseCode, isNotNull); + expect( + appendResponse.responseCode?.substring(0, 'APPENDUID'.length), + 'APPENDUID', + ); + final uidResponseCode = appendResponse.responseCodeAppendUid; + expect(uidResponseCode, isNotNull); + expect(uidResponseCode?.uidValidity, 1466002016); + expect(uidResponseCode?.targetSequence.toList().first, 176); + }); + + test('ImapClient idle', () async { + final box = await _selectInbox(); + expungedMessages = []; + mockServer.response = + '* CAPABILITY IMAP4rev1 CHILDREN ENABLE ID IDLE LIST-EXTENDED LIST-STATUS LITERAL- MOVE NAMESPACE QUOTA SASL-IR SORT SPECIAL-USE THREAD=ORDEREDSUBJECT UIDPLUS UNSELECT WITHIN AUTH=LOGIN AUTH=PLAIN\r\n' + ' OK LOGIN completed'; + await client.login('testuser', 'testpassword'); + + mockServer.response = '+ OK IDLE started\r\n' + ' OK IDLE done'; + await client.idleStart(); + + unawaited(mockServer.fire( + const Duration(milliseconds: 100), + '* 2 EXPUNGE\r\n* 17 EXPUNGE\r\n* ${box.messagesExists} EXISTS\r\n', + )); + await Future.delayed(const Duration(milliseconds: 200)); + await client.idleDone(); + expect(expungedMessages.length, 2); + expect(expungedMessages[0], 2); + expect(expungedMessages[1], 17); + }); + + test('ImapClient setquota', () async { + mockServer.response = '* QUOTA INBOX (STORAGE 0 120 MESSAGES 0 5000)\r\n' + ' OK Quota set'; + final quotaResult = await client.setQuota( + quotaRoot: 'INBOX', + resourceLimits: {'STORAGE': 120, 'MESSAGES': 5000}, + ); + expect(quotaResult, isNotNull); + expect(quotaResult.rootName, 'INBOX'); + expect(quotaResult.resourceLimits.length, 2); + expect(quotaResult.resourceLimits[0].name, 'STORAGE'); + expect(quotaResult.resourceLimits[0].currentUsage, 0); + expect(quotaResult.resourceLimits[0].usageLimit, 120); + expect(quotaResult.resourceLimits[1].name, 'MESSAGES'); + expect(quotaResult.resourceLimits[1].currentUsage, 0); + expect(quotaResult.resourceLimits[1].usageLimit, 5000); + }); + + test('ImapClient getquota', () async { + mockServer.response = '* QUOTA INBOX (STORAGE 100 1000 TRASH 3 10)\r\n' + ' OK Quota set'; + final quotaResult = await client.getQuota(quotaRoot: 'INBOX'); + expect(quotaResult.rootName, 'INBOX'); + expect(quotaResult.resourceLimits.length, 2); + expect(quotaResult.resourceLimits[0].name, 'STORAGE'); + expect(quotaResult.resourceLimits[0].currentUsage, 100); + expect(quotaResult.resourceLimits[0].usageLimit, 1000); + expect(quotaResult.resourceLimits[1].name, 'TRASH'); + expect(quotaResult.resourceLimits[1].currentUsage, 3); + expect(quotaResult.resourceLimits[1].usageLimit, 10); + }); + + test('ImapClient getquotaroot', () async { + mockServer.response = '* QUOTAROOT INBOX "User quota"\r\n' + '* QUOTA "User quota" (STORAGE 232885 1048576)\r\n' + ' OK Quota set'; + final quotaRootResult = await client.getQuotaRoot(mailboxName: 'INBOX'); + expect(quotaRootResult.mailboxName, 'INBOX'); + expect(quotaRootResult.rootNames[0], 'User quota'); + expect(quotaRootResult.quotaRoots['User quota'], isNotNull); + expect( + quotaRootResult.quotaRoots['User quota']?.resourceLimits, + isNotEmpty, + ); + expect( + quotaRootResult.quotaRoots['User quota']?.resourceLimits[0].name, + 'STORAGE', + ); + expect( + quotaRootResult.quotaRoots['User quota']?.resourceLimits[0].usageLimit, + 1048576, + ); + }); + + test('ImapClient close', () async { + mockServer.response = ' OK bye'; + await client.closeMailbox(); + }); + + test('ImapClient logout', () async { + mockServer.response = ' OK bye'; + await client.logout(); + }); +} diff --git a/packages/enough_mail/test/imap/mailbox_test.dart b/packages/enough_mail/test/imap/mailbox_test.dart new file mode 100644 index 0000000..2e4ac07 --- /dev/null +++ b/packages/enough_mail/test/imap/mailbox_test.dart @@ -0,0 +1,48 @@ +import 'package:enough_mail/src/imap/mailbox.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('virtual mailbox', () { + final mailbox = Mailbox.virtual('All Inboxes', [MailboxFlag.inbox]); + expect(mailbox.isInbox, true); + expect(mailbox.isVirtual, true); + expect(mailbox.name, 'All Inboxes'); + expect(mailbox.flags, [MailboxFlag.inbox, MailboxFlag.virtual]); + }); + + test('virtual mailbox to not have virtual flags duplicated', () { + final mailbox = Mailbox.virtual( + 'All Inboxes', + [MailboxFlag.inbox, MailboxFlag.virtual], + ); + expect(mailbox.isInbox, true); + expect(mailbox.isVirtual, true); + expect(mailbox.name, 'All Inboxes'); + expect(mailbox.flags, [MailboxFlag.inbox, MailboxFlag.virtual]); + }); + + test('encode simple path', () { + const original = 'Inbox/Archive'; + expect(Mailbox.encode(original, '/'), original); + }); + test('encode path with special characters', () { + const original = 'Inbox/Müllhalde'; + expect(Mailbox.encode(original, '/'), 'Inbox/M&APw-llhalde'); + }); + + test('reset name', () { + final mailbox = Mailbox( + encodedName: 'Inbox', + encodedPath: 'root/Inbox', + flags: [MailboxFlag.inbox], + pathSeparator: '/', + )..name = 'Posteingang'; + expect(mailbox.name, 'Posteingang'); + expect(mailbox.encodedName, 'Inbox'); + expect(mailbox.encodedPath, 'root/Inbox'); + expect(mailbox.path, 'root/Inbox'); + mailbox.setNameFromPath(); + expect(mailbox.name, 'Inbox'); + }); +} diff --git a/packages/enough_mail/test/imap/message_sequence_test.dart b/packages/enough_mail/test/imap/message_sequence_test.dart new file mode 100644 index 0000000..90c8cf7 --- /dev/null +++ b/packages/enough_mail/test/imap/message_sequence_test.dart @@ -0,0 +1,393 @@ +import 'package:enough_mail/src/exception.dart'; +import 'package:enough_mail/src/imap/message_sequence.dart'; +import 'package:test/test.dart'; + +void main() { + group('MessageSequence Tests', () { + group('Add ids', () { + test('1 id', () { + final sequence = MessageSequence(); + [1].forEach(sequence.add); + expect(sequence.toString(), '1'); + }); + + test('1 uid', () { + final sequence = MessageSequence(isUidSequence: true); + [12345].forEach(sequence.add); + final buffer = StringBuffer(); + sequence.render(buffer); + expect(buffer.toString(), '12345'); + expect(sequence.toString(), '12345'); + }); + test('3 separate ids', () { + final sequence = MessageSequence(); + [1, 999, 7].forEach(sequence.add); + expect(sequence.toString(), '1,999,7'); + }); + test('4 ids with range', () { + final sequence = MessageSequence(); + [1, 7, 5, 6].forEach(sequence.add); + expect(sequence.toString(), '1,7,5:6'); + }); + + test('9 ids with range', () { + final sequence = MessageSequence(); + [1, 7, 5, 6, 9, 12, 11, 10, 2].forEach(sequence.add); + expect(sequence.toString(), '1,7,5:6,9,12,11,10,2'); + }); + test('9 ids with range but last', () { + final sequence = MessageSequence(); + [1, 7, 5, 6, 9, 13, 11, 10, 2].forEach(sequence.add); + expect(sequence.toString(), '1,7,5:6,9,13,11,10,2'); + }); + }); + + group('Add ids sorted', () { + test('1 id', () { + final sequence = MessageSequence(); + [1].forEach(sequence.add); + expect(sequence.toString(), '1'); + }); + test('3 separate ids', () { + final sequence = MessageSequence(); + [1, 999, 7].forEach(sequence.add); + expect((sequence..sort()).toString(), '1,7,999'); + }); + test('4 ids with range', () { + final sequence = MessageSequence(); + [1, 7, 5, 6].forEach(sequence.add); + expect((sequence..sort()).toString(), '1,5:7'); + }); + + test('9 ids with range', () { + final sequence = MessageSequence(); + [1, 7, 5, 6, 9, 12, 11, 10, 2].forEach(sequence.add); + expect((sequence..sort()).toString(), '1:2,5:7,9:12'); + }); + test('9 ids with range but last', () { + final sequence = MessageSequence(); + [1, 7, 5, 6, 9, 13, 11, 10, 2].forEach(sequence.add); + expect((sequence..sort()).toString(), '1:2,5:7,9:11,13'); + }); + }); + + group('Add Last', () { + test('Only last', () { + final sequence = MessageSequence()..addLast(); + expect(sequence.toString(), '*'); + }); + test('id + last', () { + final sequence = MessageSequence(); + [232].forEach(sequence.add); + sequence.addLast(); + expect(sequence.toString(), '232,*'); + }); + }); + + group('Add ranges', () { + test('1 range', () { + final sequence = MessageSequence()..addRange(12, 277); + expect(sequence.toString(), '12:277'); + }); + test('2 ranges', () { + final sequence = MessageSequence() + ..addRange(12, 277) + ..addRange(1, 7); + expect(sequence.toString(), '12:277,1:7'); + }); + test('2 ranges with id and last', () { + final sequence = MessageSequence(); + [2312].forEach(sequence.add); + sequence + ..addRange(12, 277) + ..addRange(1, 7) + ..addLast(); + expect(sequence.toString(), '2312,12:277,1:7,*'); + }); + }); + + group('Add range-to-last', () { + test('1 range to last', () { + final sequence = MessageSequence()..addRangeToLast(12); + expect(sequence.toString(), '12:*'); + }); + test('1 range to end, 1 normal range', () { + final sequence = MessageSequence() + ..addRangeToLast(12) + ..addRange(1, 7); + expect(sequence.toString(), '12:*,1:7'); + }); + test('mixed ranges', () { + final sequence = MessageSequence(); + [2312, 2322].forEach(sequence.add); + sequence + ..addRange(12, 277) + ..addRangeToLast(23290); + expect(sequence.toString(), '2312,2322,12:277,23290:*'); + }); + }); + + group('Add all', () { + test('Just all', () { + final sequence = MessageSequence()..addAll(); + expect(sequence.toString(), '1:*'); + }); + test('all + 1 id', () { + final sequence = MessageSequence() + ..add(12) + ..addAll(); + expect(sequence.toString(), '12,1:*'); + }); + }); + + group('Convenience methods', () { + test('from all', () { + final sequence = MessageSequence.fromAll(); + expect(sequence.toString(), '1:*'); + }); + test('from id', () { + final sequence = MessageSequence.fromId(12); + expect(sequence.toString(), '12'); + }); + test('from last', () { + final sequence = MessageSequence.fromLast(); + expect(sequence.toString(), '*'); + }); + test('from range', () { + final sequence = MessageSequence.fromRange(12, 17); + expect(sequence.toString(), '12:17'); + }); + test('from range to last', () { + final sequence = MessageSequence.fromRangeToLast(12); + expect(sequence.toString(), '12:*'); + }); + }); + + group('Parse', () { + test('1 id', () { + final sequence = MessageSequence.parse('1'); + expect(sequence.toString(), '1'); + }); + test('2 ids', () { + final sequence = MessageSequence.parse('18,7'); + expect(sequence.toString(), '18,7'); + }); + test('2 ids, 1 range', () { + final sequence = MessageSequence.parse('18,7,233:244'); + expect(sequence.toString(), '18,7,233:244'); + }); + test('last', () { + final sequence = MessageSequence.parse('*'); + expect(sequence.toString(), '*'); + }); + test('id + last', () { + final sequence = MessageSequence.parse('*,234'); + expect(sequence.toString(), '*,234'); + }); + test('range to last', () { + final sequence = MessageSequence.parse('17:*'); + expect(sequence.toString(), '17:*'); + }); + test('id and range to last', () { + final sequence = MessageSequence.parse('17:*,5'); + expect(sequence.toString(), '17:*,5'); + }); + test('NIL', () { + final sequence = MessageSequence.parse('NIL'); + expect(sequence.toString(), 'NIL'); + }); + }); + + group('Parse sorted', () { + test('2 ids', () { + final sequence = MessageSequence.parse('18,7'); + expect((sequence..sort()).toString(), '7,18'); + }); + test('2 ids, 1 range', () { + final sequence = MessageSequence.parse('18,7,233:244'); + expect((sequence..sort()).toString(), '7,18,233:244'); + }); + test('id + last', () { + final sequence = MessageSequence.parse('*,234'); + expect((sequence..sort()).toString(), '234,*'); + }); + test('id and range to last', () { + final sequence = MessageSequence.parse('17:*,5'); + expect((sequence..sort()).toString(), '5,17:*'); + }); + }); + + group('List', () { + test('1 id', () { + final sequence = MessageSequence.fromId(1); + expect(sequence.toList(), [1]); + }); + + test('3 ids', () { + final sequence = MessageSequence.fromId(1) + ..add(8) + ..add(7); + expect(sequence.toList(), [1, 8, 7]); + }); + + test('all', () { + final sequence = MessageSequence.fromAll(); + try { + sequence.toList(); + fail('sequence.toList() should fail when * is included an not ' + 'exists parameter is specified'); + // ignore: avoid_catching_errors + } on InvalidArgumentException { + // expected + } + expect(sequence.toList(7), [1, 2, 3, 4, 5, 6, 7]); + }); + + test('range', () { + final sequence = MessageSequence.fromRange(17, 21); + expect(sequence.toList(), [17, 18, 19, 20, 21]); + }); + + test('range with single id', () { + final sequence = MessageSequence.fromRange(17, 21)..add(12); + expect(sequence.toList(), [17, 18, 19, 20, 21, 12]); + }); + + test('rangeToLast', () { + final sequence = MessageSequence.fromRangeToLast(17); + expect(sequence.toList(20), [17, 18, 19, 20]); + }); + + test('id, range, rangeToLast', () { + final sequence = MessageSequence.fromRangeToLast(17) + ..addRange(5, 8) + ..add(3); + expect(sequence.toList(20), [17, 18, 19, 20, 5, 6, 7, 8, 3]); + }); + test('NIL', () { + final sequence = MessageSequence.parse('NIL'); + expect(sequence.toList, throwsException); + }); + }); + + group('List sorted', () { + test('3 ids', () { + final sequence = MessageSequence.fromId(1) + ..add(8) + ..add(7); + expect((sequence..sort()).toList(), [1, 7, 8]); + }); + + test('range with single id', () { + final sequence = MessageSequence.fromRange(17, 21)..add(12); + expect((sequence..sort()).toList(), [12, 17, 18, 19, 20, 21]); + }); + + test('id, range, rangeToLast', () { + final sequence = MessageSequence.fromRangeToLast(17) + ..addRange(5, 8) + ..add(3); + expect((sequence..sort()).toList(20), [3, 5, 6, 7, 8, 17, 18, 19, 20]); + }); + }); + }); + + group('MessageSequence.fromPage', () { + test('100-8 first page', () { + final sequence = MessageSequence.fromPage(1, 8, 100); + expect(sequence.toList(100), [93, 94, 95, 96, 97, 98, 99, 100]); + }); + + test('100-8 second page', () { + final sequence = MessageSequence.fromPage(2, 8, 100); + expect(sequence.toList(100), [85, 86, 87, 88, 89, 90, 91, 92]); + }); + + test('100-8 third page', () { + final sequence = MessageSequence.fromPage(3, 8, 100); + expect(sequence.toList(100), [77, 78, 79, 80, 81, 82, 83, 84]); + }); + test('100-8 page 12', () { + final sequence = MessageSequence.fromPage(12, 8, 100); + expect(sequence.toList(100), [5, 6, 7, 8, 9, 10, 11, 12]); + }); + test('100-8 last page', () { + final sequence = MessageSequence.fromPage(13, 8, 100); + expect(sequence.toList(100), [1, 2, 3, 4]); + }); + + test('50-10 first page', () { + final sequence = MessageSequence.fromPage(1, 10, 50); + expect(sequence.toList(50), [41, 42, 43, 44, 45, 46, 47, 48, 49, 50]); + }); + + test('50-10 second page', () { + final sequence = MessageSequence.fromPage(2, 10, 50); + expect(sequence.toList(50), [31, 32, 33, 34, 35, 36, 37, 38, 39, 40]); + }); + + test('50-10 third page', () { + final sequence = MessageSequence.fromPage(3, 10, 50); + expect(sequence.toList(50), [21, 22, 23, 24, 25, 26, 27, 28, 29, 30]); + }); + test('50-10 fourth page', () { + final sequence = MessageSequence.fromPage(4, 10, 50); + expect(sequence.toList(50), [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + }); + test('50-10 fifth page', () { + final sequence = MessageSequence.fromPage(5, 10, 50); + expect(sequence.toList(50), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + }); + + group('PagedMessageSequence Tests', () { + test('4 pages', () { + final sequence = MessageSequence.fromIds( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + ); + final paged = PagedMessageSequence(sequence, pageSize: 4); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [13, 14, 15, 16]); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [9, 10, 11, 12]); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [5, 6, 7, 8]); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [1, 2, 3, 4]); + expect(paged.hasNext, isFalse); + }); + test('not full page', () { + final sequence = MessageSequence.fromIds([1, 2]); + final paged = PagedMessageSequence(sequence, pageSize: 4); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [1, 2]); + expect(paged.hasNext, isFalse); + }); + test('one more than a single page', () { + final sequence = MessageSequence.fromIds([1, 2, 3, 4, 5]); + final paged = PagedMessageSequence(sequence, pageSize: 4); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [2, 3, 4, 5]); + expect(paged.hasNext, isTrue); + expect(paged.next().toList(), [1]); + expect(paged.hasNext, isFalse); + }); + }); + + group('SequenceNode Tests', () { + test('latestId', () { + final t1 = SequenceNode.root(isUid: true) + ..addChild(17) + ..addChild(18) + ..addChild(20); + final t2 = SequenceNode.root(isUid: true) + ..addChild(19) + ..addChild(21); + + final threadData = SequenceNode.root(isUid: true) + ..children.addAll([t1, t2]); + expect(threadData[0].latestId, 20); + expect(threadData[1].latestId, 21); + }); + }); +} diff --git a/packages/enough_mail/test/imap/mock_imap_server.dart b/packages/enough_mail/test/imap/mock_imap_server.dart new file mode 100644 index 0000000..9e7838c --- /dev/null +++ b/packages/enough_mail/test/imap/mock_imap_server.dart @@ -0,0 +1,66 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// Simple IMAP mock server for testing purposes +class MockImapServer { + /// Creates a new mock server + MockImapServer(this._socket) { + _socket.listen( + parseRequest, + onDone: () { + print('server connection done'); + }, + onError: (error) { + print('server error: $error'); + }, + ); + } + + final Socket _socket; + + String? response; + String? _overrideTag; + + void parseRequest(Uint8List data) { + final line = String.fromCharCodes(data); + // print('C: $line'); + final firstSpaceIndex = line.indexOf(' '); + String? tag = + firstSpaceIndex == -1 ? '' : line.substring(0, firstSpaceIndex); + final response = this.response; + if (response != null) { + if (response.startsWith('+')) { + _overrideTag = tag; + final splitIndex = response.indexOf('\r\n'); + final firstLine = response.substring(0, splitIndex + 2); + this.response = response.substring(splitIndex + 2); + write(firstLine); + + return; + } + if (_overrideTag != null) { + tag = _overrideTag; + _overrideTag = null; + } + final lines = response.replaceAll('', tag ?? '').split('\r\n'); + this.response = null; + lines.forEach(writeln); + + return; + } + } + + void writeln(String data) { + write('$data\r\n'); + } + + void write(String data) { + // print('S: $data'); + _socket.write(data); + } + + Future fire(Duration duration, String s) async { + await Future.delayed(duration); + write(s); + } +} diff --git a/packages/enough_mail/test/imap/qresync_test.dart b/packages/enough_mail/test/imap/qresync_test.dart new file mode 100644 index 0000000..b376f9a --- /dev/null +++ b/packages/enough_mail/test/imap/qresync_test.dart @@ -0,0 +1,31 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('Render', () { + test('Default', () { + final param = QResyncParameters(123456789, 987654321); + expect(param.toString(), 'QRESYNC (123456789 987654321)'); + }); + + test('With known UIDs', () { + final param = QResyncParameters(123456789, 987654321) + ..knownUids = MessageSequence.fromAll(); + expect(param.toString(), 'QRESYNC (123456789 987654321 1:*)'); + }); + + test('With known UIDs and sequence IDs', () { + final param = QResyncParameters(123456789, 987654321) + ..knownUids = MessageSequence.fromAll() + ..setKnownSequenceIdsWithTheirUids( + MessageSequence.fromRange(12, 23), + MessageSequence.fromRange(514, 525), + ); + expect( + param.toString(), + 'QRESYNC (123456789 987654321 1:* (12:23 514:525))', + ); + }); + }); +} diff --git a/packages/enough_mail/test/imap/response_test.dart b/packages/enough_mail/test/imap/response_test.dart new file mode 100644 index 0000000..a9294e3 --- /dev/null +++ b/packages/enough_mail/test/imap/response_test.dart @@ -0,0 +1,36 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('GenericImapResult', () { + test('Valid COPYUID with original and target single IDs', () { + final result = GenericImapResult() + ..responseCode = 'COPYUID 14 35986 172551'; + final copyUid = result.responseCodeCopyUid; + expect(copyUid, isNotNull); + expect(copyUid?.uidValidity, 14); + expect(copyUid?.originalSequence?.toList(), [35986]); + expect(copyUid?.targetSequence.toList(), [172551]); + }); + + test('Valid COPYUID with original and target sequence', () { + final result = GenericImapResult() + ..responseCode = 'COPYUID 14 35986:35989 172551:172554'; + final copyUid = result.responseCodeCopyUid; + expect(copyUid, isNotNull); + expect(copyUid?.uidValidity, 14); + expect(copyUid?.originalSequence?.toList(), [35986, 35987, 35988, 35989]); + expect( + copyUid?.targetSequence.toList(), + [172551, 172552, 172553, 172554], + ); + }); + + test('Igmore invalid COPYUID withhout sequences', () { + final result = GenericImapResult()..responseCode = 'COPYUID 12 '; + final copyUid = result.responseCodeCopyUid; + expect(copyUid, isNull); + }); + }); +} diff --git a/packages/enough_mail/test/mail/mail_account_test.dart b/packages/enough_mail/test/mail/mail_account_test.dart new file mode 100644 index 0000000..4c5f40f --- /dev/null +++ b/packages/enough_mail/test/mail/mail_account_test.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + void _compareAfterJsonSerialization(MailAccount original) { + final text = jsonEncode(original.toJson()); + //print(text); + final copy = MailAccount.fromJson(jsonDecode(text)); + //print('copy==original: ${copy == original}'); + expect(copy.incoming.serverConfig, original.incoming.serverConfig); + expect(copy.incoming, original.incoming); + expect(copy.outgoing, original.outgoing); + expect(copy, original); + } + + group('Serialization', () { + test('Test ServerConfig', () async { + const originalServerConfig = ServerConfig( + type: ServerType.imap, + hostname: 'imap.example.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.passwordClearText, + usernameType: UsernameType.emailAddress, + ); + + final restoredServerConfig = ServerConfig.fromJson( + originalServerConfig.toJson(), + ); + + expect( + originalServerConfig.toJson(), + restoredServerConfig.toJson(), + ); + }); + + test('serialize account', () { + const original = MailAccount( + email: 'test@domain.com', + name: 'A name with "quotes"', + outgoingClientDomain: 'outgoing.com', + userName: 'First Last', + incoming: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.imap, + hostname: 'imap.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: PlainAuthentication('user@domain.com', 'secret'), + serverCapabilities: [Capability('IMAP4')], + pathSeparator: '/', + ), + outgoing: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.smtp, + hostname: 'smtp.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: PlainAuthentication('user@domain.com', 'secret'), + ), + supportsPlusAliases: true, + aliases: [MailAddress('just tester', 'alias@domain.com')], + ); + _compareAfterJsonSerialization(original); + }); + +// cSpell:disable + test('serialize OAuth account', () { + const tokenText = '''{ +"access_token": "ya29.asldkjsaklKJKLSD_LSKDJKLSDJllkjkljsd9_2n32j3h2jkj", +"expires_in": 3599, +"refresh_token": "1//09tw-sdkjskdSKJSDKF-L9Ir8GN-XJlyFkYRNLV_SKD,SDswekwl9wqekqmxsip2OS", +"scope": "https://mail.google.com/", +"token_type": "Bearer" +}'''; + final original = MailAccount( + email: 'test@domain.com', + userName: 'Andrea Ghez', + name: 'A name with "quotes"', + outgoingClientDomain: 'outgoing.com', + incoming: MailServerConfig( + serverConfig: const ServerConfig( + type: ServerType.imap, + hostname: 'imap.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.oauth2, + usernameType: UsernameType.emailAddress, + ), + authentication: OauthAuthentication.from( + 'user@domain.com', + tokenText, + provider: 'gmail', + ), + serverCapabilities: [const Capability('IMAP4')], + pathSeparator: '/', + ), + outgoing: MailServerConfig( + serverConfig: const ServerConfig( + type: ServerType.smtp, + hostname: 'smtp.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.oauth2, + usernameType: UsernameType.emailAddress, + ), + authentication: OauthAuthentication.from( + 'user@domain.com', + tokenText, + provider: 'gmail', + ), + ), + supportsPlusAliases: true, + aliases: [ + const MailAddress( + 'just tester', + 'alias@domain.com', + ), + ], + ); + _compareAfterJsonSerialization(original); + }); + + test('deserialize oauth token', () { + const tokenText = '''{ +"access_token": "ya29.asldkjsaklKJKLSD_LSKDJKLSDJllkjkljsd9_2n32j3h2jkj", +"expires_in": 3599, +"refresh_token": "1//09tw-sdkjskdSKJSDKF-L9Ir8GN-XJlyFkYRNLV_SKD,SDswekwl9wqekqmxsip2OS", +"scope": "https://mail.google.com/", +"token_type": "Bearer" +}'''; + final token = OauthToken.fromText(tokenText); + expect( + token.accessToken, + 'ya29.asldkjsaklKJKLSD_LSKDJKLSDJllkjkljsd9_2n32j3h2jkj', + ); + expect(token.expiresIn, 3599); + expect( + token.refreshToken, + '1//09tw-sdkjskdSKJSDKF-L9Ir8GN-XJlyFkYRNLV_SKD,SDswekwl9wqekqmxsip2OS', + ); + expect(token.scope, 'https://mail.google.com/'); + expect(token.tokenType, 'Bearer'); + expect(token.isExpired, isFalse); + expect(token.isValid, isTrue); + }); + + test('serialize list of accounts', () { + final accounts = [ + const MailAccount( + email: 'test@domain.com', + name: 'A name with "quotes"', + userName: 'Andrea Ghez', + outgoingClientDomain: 'outgoing.com', + incoming: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.imap, + hostname: 'imap.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: PlainAuthentication('user@domain.com', 'secret'), + serverCapabilities: [Capability('IMAP4')], + pathSeparator: '/', + ), + outgoing: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.smtp, + hostname: 'smtp.domain.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: PlainAuthentication('user@domain.com', 'secret'), + ), + ), + const MailAccount( + email: 'test2@domain2.com', + name: 'my second account', + userName: 'First Last', + outgoingClientDomain: 'outdomain.com', + incoming: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.imap, + hostname: 'imap.domain2.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: + PlainAuthentication('user2@domain2.com', 'verysecret'), + serverCapabilities: [Capability('IMAP4'), Capability('IDLE')], + pathSeparator: '/', + ), + outgoing: MailServerConfig( + serverConfig: ServerConfig( + type: ServerType.smtp, + hostname: 'smtp.domain2.com', + port: 993, + socketType: SocketType.ssl, + authentication: Authentication.plain, + usernameType: UsernameType.emailAddress, + ), + authentication: + PlainAuthentication('user2@domain2.com', 'topsecret'), + ), + ), + ]; + final jsonAccountsList = + accounts.map((account) => account.toJson()).toList(); + final jsonText = jsonEncode(jsonAccountsList); + final jsonList = jsonDecode(jsonText) as List; + final parsedAccounts = + // ignore: unnecessary_lambdas + jsonList.map((json) => MailAccount.fromJson(json)).toList(); + expect(parsedAccounts.length, accounts.length); + for (var i = 0; i < accounts.length; i++) { + final original = accounts[i]; + final copy = parsedAccounts[i]; + expect(copy, original); + } + }); + }); +} diff --git a/packages/enough_mail/test/mail/results_test.dart b/packages/enough_mail/test/mail/results_test.dart new file mode 100644 index 0000000..9a47dd2 --- /dev/null +++ b/packages/enough_mail/test/mail/results_test.dart @@ -0,0 +1,75 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:test/test.dart'; + +void main() { + group('DeleteResult', () { + late List messages; + late DeleteResult deleteResult; + + setUp(() { + messages = [ + MimeMessage() + ..sequenceId = 12 + ..uid = 120, + MimeMessage() + ..sequenceId = 13 + ..uid = 121, + ]; + final originalSequence = messages.toSequence(); + final originalMailbox = Mailbox.virtual('inbox', [MailboxFlag.inbox]); + final targetSequence = MessageSequence.fromIds([400, 401], isUid: true); + final targetMailbox = Mailbox.virtual('trash', [MailboxFlag.trash]); + final mailClient = MailClient( + MailAccount.fromManualSettings( + name: 'name', + userName: 'userName', + email: 'email', + incomingHost: 'incomingHost', + outgoingHost: 'outgoingHost', + password: 'password', + ), + ); + deleteResult = DeleteResult( + DeleteAction.move, + originalSequence, + originalMailbox, + targetSequence, + targetMailbox, + mailClient, + canUndo: true, + messages: messages, + ); + }); + + test('DeleteResult changes the message UID', () { + expect(messages[0].uid, 400); + expect(messages[1].uid, 401); + }); + + test('Undo DeleteResult changes the message UID again', () { + expect(messages[0].uid, 400); + expect(messages[1].uid, 401); + final result = GenericImapResult() + ..responseCode = 'COPYUID 14 400,401 17,18'; + final copyUid = result.responseCodeCopyUid; + expect(copyUid, isNotNull); + expect( + copyUid?.originalSequence?.toList(), + [400, 401], + ); + expect( + copyUid?.targetSequence.toList(), + [17, 18], + ); + final undoResult = deleteResult.reverseWith(copyUid); + expect( + undoResult.targetMailbox?.flags, + [MailboxFlag.inbox, MailboxFlag.virtual], + ); + expect(undoResult.originalSequence.toList(), [400, 401]); + expect(undoResult.targetSequence?.toList(), [17, 18]); + expect(messages[0].uid, 17); + expect(messages[1].uid, 18); + }); + }); +} diff --git a/packages/enough_mail/test/message_builder_test.dart b/packages/enough_mail/test/message_builder_test.dart new file mode 100644 index 0000000..9dbca50 --- /dev/null +++ b/packages/enough_mail/test/message_builder_test.dart @@ -0,0 +1,1811 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:enough_mail/src/mail_address.dart'; +import 'package:enough_mail/src/mail_conventions.dart'; +import 'package:enough_mail/src/media_type.dart'; +import 'package:enough_mail/src/message_builder.dart'; +import 'package:enough_mail/src/mime_data.dart'; +import 'package:enough_mail/src/mime_message.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + String? getRawBodyText(MimePart part) { + final mimeData = part.mimeData; + if (mimeData is TextMimeData) { + return mimeData.body; + } + + return null; + } + + group('buildSimpleTextMessage', () { + test('Simple text message', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final message = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + subject: subject, + ); + //print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect( + message.getHeaderValue('from'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('to'), + '"Recipient Personal Name" ', + ); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + expect( + message.getHeaderValue('Content-Transfer-Encoding'), + 'quoted-printable', + ); + expect( + getRawBodyText(message), + 'Hello World - here\s some text that should spans two lines in the ' + 'end when t=\r\nhis sentence is finished.\r\n', + ); + }); + + test('Simple text message with reply to message', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.'; + final replyToMessage = MimeMessage() + ..addHeader('subject', 'Re: Hello from test') + ..addHeader('message-id', ''); + final message = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + replyToMessage: replyToMessage, + ); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect( + message.getHeaderValue('references'), + '', + ); + expect( + message.getHeaderValue('in-reply-to'), + '', + ); + expect(message.getHeaderValue('date'), isNotNull); + expect( + message.getHeaderValue('from'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('to'), + '"Recipient Personal Name" ', + ); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + expect( + message.getHeaderValue('Content-Transfer-Encoding'), + 'quoted-printable', + ); + expect( + getRawBodyText(message), + 'Hello World - here\s some text that should spans two lines in the ' + 'end when t=\r\nhis sentence is finished.', + ); + //print(message.renderMessage()); + }); + }); + + group('multipart tests', () { + test('multipart/alternative with 2 text parts', () { + final builder = MessageBuilder.prepareMultipartAlternativeMessage() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!') + ..addTextHtml('

Hello world!

'); + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(parsed.parts?[1].decodeContentText(), '

Hello world!

\r\n'); + }); + + test('multipart/mixed with vcard attachment', () { + final builder = MessageBuilder.prepareMultipartMixedMessage() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!') + ..addText( + ''' +BEGIN:VCARD\r +VERSION:4.0\r +UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1\r +FN:J. Doe\r +N:Doe;J.;;;\r +EMAIL;PID=1.1:jdoe@example.com\r +EMAIL;PID=2.1:boss@example.com\r +EMAIL;PID=2.2:ceo@example.com\r +TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555\r +TEL;PID=2.1,2.2;VALUE=uri:tel:+1-666-666-6666\r +CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556\r +CLIENTPIDMAP:2;urn:uuid:1f762d2b-03c4-4a83-9a03-75ff658a6eee\r +END:VCARD\r +''', + mediaType: MediaSubtype.textVcard.mediaType, + disposition: ContentDispositionHeader.from( + ContentDisposition.attachment, + filename: 'contact.vcard', + ), + ); + + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textVcard, + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'contact.vcard'); + expect(parsed.parts?[1].decodeContentText(), ''' +BEGIN:VCARD\r +VERSION:4.0\r +UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1\r +FN:J. Doe\r +N:Doe;J.;;;\r +EMAIL;PID=1.1:jdoe@example.com\r +EMAIL;PID=2.1:boss@example.com\r +EMAIL;PID=2.2:ceo@example.com\r +TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555\r +TEL;PID=2.1,2.2;VALUE=uri:tel:+1-666-666-6666\r +CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556\r +CLIENTPIDMAP:2;urn:uuid:1f762d2b-03c4-4a83-9a03-75ff658a6eee\r +END:VCARD\r +\r +'''); + }); + + test('implicit multipart with binary attachment', () { + final builder = MessageBuilder() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..text = 'Hello world!' + ..addBinary( + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + MediaSubtype.imageJpeg.mediaType, + filename: 'helloworld.jpg', + ) + ..setRecommendedTextEncoding( + supports8BitMessages: true, + ); + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + // print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + expect( + parsed.parts?[1].getHeaderContentType()?.parameters['name'], + 'helloworld.jpg', + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'helloworld.jpg'); + expect(disposition?.size, 10); + expect( + parsed.parts?[1].decodeContentBinary(), + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ); + }); + + test('implicit multipart with binary attachment and text', () { + final builder = MessageBuilder() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!') + ..addText( + ''' +BEGIN:VCARD\r +VERSION:4.0\r +UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1\r +FN:J. Doe\r +N:Doe;J.;;;\r +EMAIL;PID=1.1:jdoe@example.com\r +EMAIL;PID=2.1:boss@example.com\r +EMAIL;PID=2.2:ceo@example.com\r +TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555\r +TEL;PID=2.1,2.2;VALUE=uri:tel:+1-666-666-6666\r +CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556\r +CLIENTPIDMAP:2;urn:uuid:1f762d2b-03c4-4a83-9a03-75ff658a6eee\r +END:VCARD\r +''', + mediaType: MediaSubtype.textVcard.mediaType, + disposition: ContentDispositionHeader.from( + ContentDisposition.attachment, + filename: 'contact.vcard', + ), + ) + ..addBinary( + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + MediaSubtype.imageJpeg.mediaType, + filename: 'helloworld.jpg', + ); + + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 3); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textVcard, + ); + var disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'contact.vcard'); + expect(parsed.parts?[1].decodeContentText(), ''' +BEGIN:VCARD\r +VERSION:4.0\r +UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1\r +FN:J. Doe\r +N:Doe;J.;;;\r +EMAIL;PID=1.1:jdoe@example.com\r +EMAIL;PID=2.1:boss@example.com\r +EMAIL;PID=2.2:ceo@example.com\r +TEL;PID=1.1;VALUE=uri:tel:+1-555-555-5555\r +TEL;PID=2.1,2.2;VALUE=uri:tel:+1-666-666-6666\r +CLIENTPIDMAP:1;urn:uuid:53e374d9-337e-4727-8803-a1e9c14e0556\r +CLIENTPIDMAP:2;urn:uuid:1f762d2b-03c4-4a83-9a03-75ff658a6eee\r +END:VCARD\r +\r +'''); + expect( + parsed.parts?[2].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + expect( + parsed.parts?[2].getHeaderContentType()?.parameters['name'], + 'helloworld.jpg', + ); + disposition = parsed.parts?[2].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'helloworld.jpg'); + expect( + parsed.parts?[2].decodeContentBinary(), + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ); + }); + + test( + 'implicit multipart/mixed with binary attachment and both plain and html text', + () { + final builder = MessageBuilder() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress( + 'Recipient Personal Name', + 'recipient@domain.com', + ), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addMultipartAlternative( + plainText: 'Hello world!', + htmlText: '

Hello world!

', + ) + ..addBinary( + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + MediaSubtype.imageJpeg.mediaType, + filename: 'helloworld.jpg', + ); + + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(parsed.parts?[0].parts?.length, 2); + expect( + parsed.parts?[0].parts?[0].mediaType.sub, + MediaSubtype.textPlain, + ); + expect( + parsed.parts?[0].parts?[0].decodeContentText(), + 'Hello world!\r\n', + ); + expect(parsed.parts?[0].parts?[1].mediaType.sub, MediaSubtype.textHtml); + expect( + parsed.parts?[0].parts?[1].decodeContentText(), + '

Hello world!

\r\n', + ); + + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + expect( + parsed.parts?[1].getHeaderContentType()?.parameters['name'], + 'helloworld.jpg', + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'helloworld.jpg'); + expect( + parsed.parts?[1].decodeContentBinary(), + Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ); + }, + ); + }); + + group('reply', () { + test('reply simple text msg without quote', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [ + const MailAddress('Me', 'recipient@domain.com'), + const MailAddress('Group Member', 'group.member@domain.com'), + ]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = + MessageBuilder.prepareReplyToMessage(originalMessage, to.first) + ..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"Personal Name" , "Group Member" ' + '', + ); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + expect(message.getHeaderValue('Content-Transfer-Encoding'), '7bit'); + expect(message.decodeContentText(), 'Here is my reply'); + }); + + test('reply just sender 1', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in ' + 'the end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + to.first, + replyAll: false, + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect(message.getHeaderValue('cc'), null); + }); + + test('reply just sender 2', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [ + const MailAddress('Me', 'recipient@domain.com'), + const MailAddress('Group Member', 'group.member@domain.com'), + ]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in ' + 'the end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + to.first, + replyAll: false, + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect(message.getHeaderValue('cc'), null); + }); + + test('reply simple text msg with quote', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + to.first, + quoteOriginalText: true, + ); + replyBuilder.text = 'Here is my reply\r\n${replyBuilder.text}'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + expect( + message.getHeaderValue('Content-Transfer-Encoding'), + 'quoted-printable', + ); + const expectedStart = 'Here is my reply\r\n>On '; + expect( + message.decodeContentText()?.substring(0, expectedStart.length), + expectedStart, + ); + const expectedEnd = 'sentence is finished.\r\n>'; + expect( + message.decodeContentText()?.substring( + (message.decodeContentText()?.length ?? 0) - expectedEnd.length, + ), + expectedEnd, + ); + }); + + test('reply multipart text msg with quote', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalBuilder = + MessageBuilder.prepareMultipartAlternativeMessage() + ..from = [from] + ..to = to + ..cc = cc + ..subject = subject + ..addTextPlain(text) + ..addTextHtml('

$text

'); + final originalMessage = originalBuilder.buildMimeMessage(); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + to.first, + quoteOriginalText: true, + ); + final textPlain = replyBuilder.getTextPlainPart(); + expect(textPlain, isNotNull); + textPlain?.text = 'Here is my reply.\r\n${textPlain.text}'; + final textHtml = replyBuilder.getTextHtmlPart(); + expect(textHtml, isNotNull); + textHtml?.text = '

Here is my reply.

\r\n${textHtml.text}'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Re: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + //expect(message.getHeaderValue('Content-Transfer-Encoding'), '8bit'); + const expectedStart = 'Here is my reply.\r\n>On '; + final plainText = message.decodeTextPlainPart(); + expect(plainText?.substring(0, expectedStart.length), expectedStart); + const expectedEnd = 'sentence is finished.\r\n>'; + expect( + plainText?.substring(plainText.length - expectedEnd.length), + expectedEnd, + ); + final html = message.decodeTextHtmlPart(); + const expectedStart2 = '

Here is my reply.

\r\n

On '; + expect(html?.substring(0, expectedStart2.length), expectedStart2); + const expectedEnd2 = 'sentence is finished.\r\n

'; + expect(html?.substring(html.length - expectedEnd2.length), expectedEnd2); + }); + + test('reply to myself', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = + MessageBuilder.prepareReplyToMessage(originalMessage, from) + ..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + expect( + message.getHeaderValue('from'), + '"Personal Name" ', + ); + expect(message.getHeaderValue('to'), '"Me" '); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + }); + + test('reply to myself with alias', () { + const from = MailAddress('Alias Name', 'sender.alias@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + const MailAddress('Personal Name', 'sender@domain.com'), + aliases: [from], + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + expect(message.getHeaderValue('to'), '"Me" '); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + expect( + message.getHeaderValue('from'), + '"Alias Name" ', + ); + }); + + test('reply to myself with plus alias', () { + const from = MailAddress('Alias Name', 'sender+alias@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + const MailAddress('Personal Name', 'sender@domain.com'), + handlePlusAliases: true, + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + expect(message.getHeaderValue('to'), '"Me" '); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + expect( + message.getHeaderValue('from'), + '"Alias Name" ', + ); + }); + + test('reply simple text msg with alias recognition', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient.full@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + const replyFrom = MailAddress('Me', 'recipient@domain.com'); + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + replyFrom, + aliases: [const MailAddress('Me Full', 'recipient.full@domain.com')], + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect( + message.getHeaderValue('from'), + '"Me Full" ', + ); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + }); + + test('reply simple text msg with +alias recognition', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient+alias@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + const replyFrom = MailAddress('Me', 'recipient@domain.com'); + final replyBuilder = MessageBuilder.prepareReplyToMessage( + originalMessage, + replyFrom, + handlePlusAliases: true, + )..text = 'Here is my reply'; + final message = replyBuilder.buildMimeMessage(); + // print('reply:'); + // print(message.renderMessage()); + expect( + message.getHeaderValue('from'), + '"Me" ', + ); + expect( + message.getHeaderValue('to'), + '"Personal Name" ', + ); + expect( + message.getHeaderValue('cc'), + '"=?utf8?Q?One_m=C3=B6re?=" ', + ); + }); + }); + group('forward', () { + test('forward simple text msg', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + cc: cc, + subject: subject, + ); + // print('original:'); + // print(originalMessage.renderMessage()); + + final forwardBuilder = + MessageBuilder.prepareForwardMessage(originalMessage, from: to.first) + ..to = [ + const MailAddress('First', 'first@domain.com'), + const MailAddress('Second', 'second@domain.com'), + ]; + forwardBuilder.text = + 'This should be interesting:\r\n${forwardBuilder.text}'; + final message = forwardBuilder.buildMimeMessage(); + // print('forward:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Fwd: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"First" , "Second" ', + ); + expect( + message.getHeaderValue('Content-Type'), + 'text/plain; charset="utf-8"', + ); + expect( + message.getHeaderValue('Content-Transfer-Encoding'), + 'quoted-printable', + ); + const expectedStart = 'This should be interesting:\r\n' + '>---------- Original Message ----------\r\n' + '>From: "Personal Name" \r\n' + '>To: "Me" \r\n' + '>CC: "One m=C3=B6re" '; + expect( + getRawBodyText(message)?.substring(0, expectedStart.length), + expectedStart, + ); + const expectedEnd = 'sentence is finished.\r\n>'; + expect( + getRawBodyText(message)?.substring( + (getRawBodyText(message)?.length ?? 0) - expectedEnd.length, + ), + expectedEnd, + ); + }); + + test('forward multipart text msg', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalBuilder = + MessageBuilder.prepareMultipartAlternativeMessage() + ..from = [from] + ..to = to + ..cc = cc + ..subject = subject + ..addTextPlain(text) + ..addTextHtml('

$text

'); + final originalMessage = originalBuilder.buildMimeMessage(); + // print('original:'); + // print(originalMessage.renderMessage()); + + final forwardBuilder = + MessageBuilder.prepareForwardMessage(originalMessage, from: to.first) + ..to = [ + const MailAddress('First', 'first@domain.com'), + const MailAddress('Second', 'second@domain.com'), + ]; + final textPlain = forwardBuilder.getTextPlainPart(); + textPlain?.text = 'This should be interesting:\r\n${textPlain.text}'; + final textHtml = forwardBuilder.getTextHtmlPart(); + textHtml?.text = '

This should be interesting:

\r\n${textHtml.text}'; + final message = forwardBuilder.buildMimeMessage(); + // print('forward:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Fwd: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"First" , "Second" ', + ); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + const expectedStart = 'This should be interesting:\r\n' + '>---------- Original Message ----------\r\n' + '>From: "Personal Name" \r\n' + '>To: "Me" \r\n' + '>CC: "One möre" '; + final plainText = message.decodeTextPlainPart(); + expect(plainText, isNotNull); + expect(plainText?.substring(0, expectedStart.length), expectedStart); + const expectedEnd = 'sentence is finished.\r\n>'; + expect( + plainText?.substring(plainText.length - expectedEnd.length), + expectedEnd, + ); + //expect(message.getHeaderValue('Content-Transfer-Encoding'), '8bit'); + const expectedStart2 = '

This should be interesting:

\r\n' + '
---------- Original Message ----------
\r\n' + 'From: "Personal Name"
\r\n' + 'To: "Me"
\r\n' + 'CC: "One möre"
'; + final htmlText = message.decodeTextHtmlPart(); + expect(htmlText, isNotNull); + expect(htmlText?.substring(0, expectedStart2.length), expectedStart2); + const expectedEnd2 = 'sentence is finished.\r\n

'; + expect( + htmlText?.substring(htmlText.length - expectedEnd2.length), + expectedEnd2, + ); + }); + + test('forward multipart msg with attachments', () async { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalBuilder = + MessageBuilder.prepareMultipartAlternativeMessage() + ..from = [from] + ..to = to + ..cc = cc + ..subject = subject + ..addTextPlain(text) + ..addTextHtml('

$text

'); + final file = File('test/smtp/testimage.jpg'); + await originalBuilder.addFile(file, MediaSubtype.imageJpeg.mediaType); + final originalMessage = originalBuilder.buildMimeMessage(); + // print('original:'); + // print(originalMessage.renderMessage()); + + final forwardBuilder = + MessageBuilder.prepareForwardMessage(originalMessage, from: to.first) + ..to = [ + const MailAddress('First', 'first@domain.com'), + const MailAddress('Second', 'second@domain.com'), + ]; + final textPlain = forwardBuilder.getTextPlainPart(); + expect(textPlain, isNotNull); + expect(textPlain?.text, isNotNull); + textPlain?.text = 'This should be interesting:\r\n${textPlain.text}'; + final textHtml = forwardBuilder.getTextHtmlPart(); + expect(textHtml, isNotNull); + expect(textHtml?.text, isNotNull); + textHtml?.text = '

This should be interesting:

\r\n${textHtml.text}'; + final message = forwardBuilder.buildMimeMessage(); + // print('forward:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Fwd: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"First" , "Second" ', + ); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + const expectedStart = 'This should be interesting:\r\n' + '>---------- Original Message ----------\r\n' + '>From: "Personal Name" \r\n' + '>To: "Me" \r\n' + '>CC: "One möre" '; + final plainText = message.decodeTextPlainPart(); + expect(plainText?.substring(0, expectedStart.length), expectedStart); + const expectedEnd = 'sentence is finished.\r\n>'; + expect( + plainText?.substring(plainText.length - expectedEnd.length), + expectedEnd, + ); + //expect(message.getHeaderValue('Content-Transfer-Encoding'), '8bit'); + const expectedStart2 = '

This should be interesting:

\r\n' + '
---------- Original Message ----------
\r\n' + 'From: "Personal Name"
\r\n' + 'To: "Me"
\r\n' + 'CC: "One möre"
'; + final htmlText = message.decodeTextHtmlPart(); + expect(htmlText?.substring(0, expectedStart2.length), expectedStart2); + const expectedEnd2 = 'sentence is finished.\r\n

'; + expect( + htmlText?.substring(htmlText.length - expectedEnd2.length), + expectedEnd2, + ); + expect(message.parts?.length, 3); + final filePart = message.parts?[2]; + final dispositionHeader = filePart?.getHeaderContentDisposition(); + expect(dispositionHeader, isNotNull); + expect(dispositionHeader?.disposition, ContentDisposition.attachment); + expect(dispositionHeader?.filename, 'testimage.jpg'); + expect(dispositionHeader?.size, 13390); + final binary = filePart?.decodeContentBinary(); + expect(binary, isNotEmpty); + final contentType = filePart?.getHeaderContentType(); + expect(contentType, isNotNull); + expect(contentType?.mediaType.sub, MediaSubtype.imageJpeg); + }); + + test('forward multipart msg with attachments without quote', () async { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [const MailAddress('Me', 'recipient@domain.com')]; + final cc = [const MailAddress('One möre', 'one.more@domain.com')]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final originalBuilder = MessageBuilder.prepareMessageWithMediaType( + MediaSubtype.multipartMixed, + ) + ..from = [from] + ..to = to + ..cc = cc + ..subject = subject; + originalBuilder.addPart(mediaSubtype: MediaSubtype.multipartAlternative) + ..addTextPlain(text) + ..addTextHtml('

$text

'); + final file = File('test/smtp/testimage.jpg'); + await originalBuilder.addFile(file, MediaSubtype.imageJpeg.mediaType); + final originalMessage = originalBuilder.buildMimeMessage(); + // print('original:'); + // print(originalMessage.renderMessage()); + final forwardBuilder = MessageBuilder.prepareForwardMessage( + originalMessage, + from: to.first, + quoteMessage: false, + )..to = [ + const MailAddress('First', 'first@domain.com'), + const MailAddress('Second', 'second@domain.com'), + ]; + // ..addTextPlain(text) + // ..addTextHtml('

$text

'); + + final message = forwardBuilder.buildMimeMessage(); + // print('forward:'); + // print(message.renderMessage()); + expect(message.getHeaderValue('subject'), 'Fwd: Hello from test'); + expect(message.getHeaderValue('message-id'), isNotNull); + expect(message.getHeaderValue('date'), isNotNull); + expect(message.getHeaderValue('from'), '"Me" '); + expect( + message.getHeaderValue('to'), + '"First" , "Second" ', + ); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + + expect(message.parts?.length, 1); + final filePart = message.parts?[0]; + + final dispositionHeader = filePart?.getHeaderContentDisposition(); + expect(dispositionHeader, isNotNull); + expect(dispositionHeader?.disposition, ContentDisposition.attachment); + expect(dispositionHeader?.filename, 'testimage.jpg'); + expect(dispositionHeader?.size, 13390); + final binary = filePart?.decodeContentBinary(); + expect(binary, isNotEmpty); + final contentType = filePart?.getHeaderContentType(); + // print(contentType.render()); + expect(contentType, isNotNull); + expect(contentType?.mediaType.sub, MediaSubtype.imageJpeg); + }); + }); + + group('File', () { + test('addFile', () async { + final builder = MessageBuilder.prepareMultipartMixedMessage() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!'); + + final file = File('test/smtp/testimage.jpg'); + await builder.addFile(file, MediaSubtype.imageJpeg.mediaType); + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'testimage.jpg'); + expect(disposition?.size, isNotNull); + expect(disposition?.modificationDate, isNotNull); + final decoded = parsed.parts?[1].decodeContentBinary(); + expect(decoded, isNotNull); + final fileData = await file.readAsBytes(); + expect(decoded, fileData); + }); + + test('addFile with large image', () async { + final builder = MessageBuilder.prepareMultipartMixedMessage() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!'); + + final file = File('test/smtp/testimage-large.jpg'); + await builder.addFile(file, MediaSubtype.imageJpeg.mediaType); + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + // print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + expect(disposition?.filename, 'testimage-large.jpg'); + expect(disposition?.size, isNotNull); + expect(disposition?.modificationDate, isNotNull); + final decoded = parsed.parts?[1].decodeContentBinary(); + expect(decoded, isNotNull); + final fileData = await file.readAsBytes(); + expect(decoded, fileData); + }); + }); + + group('Binary', () { + test('addBinary', () { + final builder = MessageBuilder.prepareMultipartMixedMessage() + ..from = [const MailAddress('Personal Name', 'sender@domain.com')] + ..to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + const MailAddress('Other Recipient', 'other@domain.com'), + ] + ..addTextPlain('Hello world!'); + final data = Uint8List.fromList([127, 32, 64, 128, 255]); + builder.addBinary(data, MediaSubtype.imageJpeg.mediaType); + final message = builder.buildMimeMessage(); + final rendered = message.renderMessage(); + //print(rendered); + final parsed = MimeMessage.parseFromText(rendered); + expect( + parsed.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(parsed.parts, isNotNull); + expect(parsed.parts?.length, 2); + expect( + parsed.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(parsed.parts?[0].decodeContentText(), 'Hello world!\r\n'); + expect( + parsed.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + final disposition = parsed.parts?[1].getHeaderContentDisposition(); + expect(disposition, isNotNull); + expect(disposition?.disposition, ContentDisposition.attachment); + final decoded = parsed.parts?[1].decodeContentBinary(); + expect(decoded, isNotNull); + expect(decoded, data); + }); + }); + + group('Helper methods', () { + test('createReplySubject', () { + expect(MessageBuilder.createReplySubject('Hello'), 'Re: Hello'); + expect( + MessageBuilder.createReplySubject( + 'Hello', + defaultReplyAbbreviation: 'AW', + ), + 'AW: Hello', + ); + expect(MessageBuilder.createReplySubject('Re: Hello'), 'Re: Hello'); + expect(MessageBuilder.createReplySubject('AW: Hello'), 'AW: Hello'); + expect( + MessageBuilder.createReplySubject('[External] Re: Hello'), + 'Re: Hello', + ); + expect( + MessageBuilder.createReplySubject('[External] AW: Hello'), + 'AW: Hello', + ); + }); + + test('createFowardSubject', () { + expect(MessageBuilder.createForwardSubject('Hello'), 'Fwd: Hello'); + expect( + MessageBuilder.createForwardSubject( + 'Hello', + defaultForwardAbbreviation: 'WG', + ), + 'WG: Hello', + ); + expect(MessageBuilder.createForwardSubject('Fwd: Hello'), 'Fwd: Hello'); + expect(MessageBuilder.createForwardSubject('WG: Hello'), 'WG: Hello'); + expect( + MessageBuilder.createForwardSubject('[External] FWD: Hello'), + 'FWD: Hello', + ); + expect( + MessageBuilder.createForwardSubject('[External] Fwd: Hello'), + 'Fwd: Hello', + ); + }); + + test('createRandomId', () { + var random = MessageBuilder.createRandomId(); + //print(random); + expect(random, isNotNull); + random = MessageBuilder.createRandomId(length: 1); + expect(random, isNotNull); + expect(random.length, 1); + random = MessageBuilder.createRandomId(length: 20); + expect(random, isNotNull); + expect(random.length, 20); + }); + + test('fillTemplate', () { + const from = MailAddress('Personal Name', 'sender@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + const subject = 'Hello from test'; + const text = + 'Hello World - here\s some text that should spans two lines in the ' + 'end when this sentence is finished.\r\n'; + final message = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + subject: subject, + ); + var template = 'On wrote:'; + var filled = MessageBuilder.fillTemplate(template, message); + //print(template + ' -> ' + filled); + expect(filled.substring(0, 3), 'On '); + expect(filled.substring(filled.length - ' wrote:'.length), ' wrote:'); + expect( + filled.substring(filled.length - + ' "Personal Name" wrote:'.length), + ' "Personal Name" wrote:', + ); + template = '---------- Original Message ----------\r\n' + 'From: \r\n' + '[[to To: \r\n]]' + '[[cc CC: \r\n]]' + 'Date: \r\n' + '[[subject Subject: \r\n]]'; + filled = MessageBuilder.fillTemplate(template, message); + //print(template + ' -> ' + filled); + + final optionalInclusionsExpression = RegExp(r'\[\[\w+\s[\s\S]+?\]\]'); + final match = optionalInclusionsExpression.firstMatch(template); + expect(match, isNotNull); + expect(match?.group(0), '[[to To: \r\n]]'); + + final lines = filled.split('\r\n'); + expect(lines.length, 6); + expect(lines[0], '---------- Original Message ----------'); + expect(lines[1], 'From: "Personal Name" '); + expect(lines[2], 'To: "Recipient Personal Name" '); + expect(lines[4], 'Subject: Hello from test'); + expect(lines[5], ''); + }); + }); + + group('Content type', () { + test('MultiPart', () { + final builder = MessageBuilder() + ..from = [const MailAddress('personalName', 'someone@domain.com')] + ..setContentType(MediaSubtype.multipartMixed.mediaType); + final message = builder.buildMimeMessage(); + final contentType = message.getHeaderContentType(); + expect(contentType, isNotNull); + expect(contentType?.boundary, isNotNull); + expect(contentType?.mediaType.top, MediaToptype.multipart); + expect(contentType?.mediaType.sub, MediaSubtype.multipartMixed); + //print(message.renderMessage()); + }); + }); + group('mailto', () { + test('adddress, subject, body', () { + const from = MailAddress('Me', 'me@domain.com'); + final mailto = + Uri.parse('mailto:recpient@domain.com?subject=hello&body=world'); + final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); + final message = builder.buildMimeMessage(); + expect(message.getHeaderValue('subject'), 'hello'); + expect(message.getHeaderValue('to'), 'recpient@domain.com'); + expect(message.decodeContentText(), 'world'); + }); + test('several adddresses', () { + const from = MailAddress('Me', 'me@domain.com'); + final mailto = Uri.parse('mailto:recpient@domain.com,another@domain.com'); + final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); + final message = builder.buildMimeMessage(); + expect( + message.getHeaderValue('to'), + 'recpient@domain.com, another@domain.com', + ); + }); + + test('to, subject, body', () { + const from = MailAddress('Me', 'me@domain.com'); + final mailto = + Uri.parse('mailto:?to=recpient@domain.com&subject=hello&body=world'); + final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); + final message = builder.buildMimeMessage(); + expect(message.getHeaderValue('subject'), 'hello'); + expect(message.getHeaderValue('to'), 'recpient@domain.com'); + expect(message.decodeContentText(), 'world'); + }); + test('address & to, subject, body', () { + const from = MailAddress('Me', 'me@domain.com'); + final mailto = + Uri.parse('mailto:recpient@domain.com?to=another@domain.com&' + 'subject=hello&body=world'); + final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); + final message = builder.buildMimeMessage(); + expect(message.getHeaderValue('subject'), 'hello'); + expect( + message.getHeaderValue('to'), + 'recpient@domain.com, another@domain.com', + ); + expect(message.decodeContentText(), 'world'); + }); + + test('address, cc, subject, body, in-reply-to', () { + const from = MailAddress('Me', 'me@domain.com'); + final mailto = Uri.parse( + 'mailto:recpient@domain.com?cc=another@domain.com&subject=hello' + '%20wörld&body=let%20me%20unsubscribe&in-reply-to=%3C3469A91.D10A' + 'F4C@example.com%3E', + ); + final builder = MessageBuilder.prepareMailtoBasedMessage(mailto, from); + final message = builder.buildMimeMessage(); + expect(message.getHeaderValue('subject'), 'hello w=?utf8?Q?=C3=B6?=rld'); + expect(message.getHeaderValue('to'), 'recpient@domain.com'); + expect(message.getHeaderValue('cc'), 'another@domain.com'); + expect(message.decodeContentText(), 'let me unsubscribe'); + expect( + message.getHeaderValue('in-reply-to'), + '<3469A91.D10AF4C@example.com>', + ); + }); + }); + + group('addMessagePart', () { + test('add text message', () { + const from = MailAddress('Me', 'me@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + const subject = 'Original Message'; + const text = 'Hello World - this is the original message'; + final original = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + subject: subject, + ); + final builder = MessageBuilder() + ..addMessagePart(original) + ..subject = 'message with attached message' + ..text = 'hello world'; + final message = builder.buildMimeMessage(); + //print(message.renderMessage()); + final parts = message.parts ?? []; + expect(parts.length, 2); + expect(parts[0].isTextMediaType(), isTrue); + expect(parts[0].decodeContentText(), 'hello world'); + expect(parts[1].mediaType.sub, MediaSubtype.messageRfc822); + expect(parts[1].decodeFileName(), 'Original Message.eml'); + final embeddedMessage = parts[1].decodeContentMessage(); + expect(embeddedMessage, isNotNull); + expect( + embeddedMessage?.decodeTextPlainPart(), + 'Hello World - this is the original message', + ); + }); + + test('add text message with quotes in subject', () { + const from = MailAddress('Me', 'me@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + const subject = '"Original" Message'; + const text = 'Hello World - this is the original message'; + final original = MessageBuilder.buildSimpleTextMessage( + from, + to, + text, + subject: subject, + ); + final builder = MessageBuilder() + ..addMessagePart(original) + ..subject = 'message with attached message' + ..text = 'hello world'; + final message = builder.buildMimeMessage(); + // print(message.renderMessage()); + final parts = message.parts ?? []; + expect(parts.length, 2); + expect(parts[0].isTextMediaType(), isTrue); + expect(parts[0].decodeContentText(), 'hello world'); + expect(parts[1].mediaType.sub, MediaSubtype.messageRfc822); + expect(parts[1].decodeFileName(), '"Original" Message.eml'); + final embeddedMessage = parts[1].decodeContentMessage(); + expect(embeddedMessage, isNotNull); + expect( + embeddedMessage?.decodeTextPlainPart(), + 'Hello World - this is the original message', + ); + }); + + test('add multipart/alternative message', () { + const from = MailAddress('Me', 'me@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + final originalBuilder = + MessageBuilder.prepareMultipartAlternativeMessage() + ..from = [from] + ..to = to + ..subject = 'Original Message' + ..addTextPlain('Hello World - this is the original message') + ..addTextHtml( + '

Hello World - this is the original message' + '

', + ); + final original = originalBuilder.buildMimeMessage(); + final builder = MessageBuilder() + ..addMessagePart(original) + ..subject = 'message with attached message' + ..text = 'hello world'; + final message = builder.buildMimeMessage(); + // print(message.renderMessage()); + final parts = message.parts ?? []; + expect(parts.length, 2); + expect(parts[0].isTextMediaType(), isTrue); + expect(parts[0].decodeContentText(), 'hello world'); + expect(parts[1].mediaType.sub, MediaSubtype.messageRfc822); + expect(parts[1].decodeFileName(), 'Original Message.eml'); + final embeddedMessage = parts[1].decodeContentMessage(); + expect(embeddedMessage, isNotNull); + expect( + embeddedMessage?.decodeTextPlainPart(), + 'Hello World - this is the original message\r\n', + ); + }); + + test('add multipart/mixed message', () { + const from = MailAddress('Me', 'me@domain.com'); + final to = [ + const MailAddress('Recipient Personal Name', 'recipient@domain.com'), + ]; + final originalBuilder = MessageBuilder() + ..from = [from] + ..to = to + ..subject = 'Original Message' + ..addTextPlain('Hello World - this is the original message') + ..addTextHtml('

Hello World - this is the original ' + 'message

') + ..addBinary( + Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]), + MediaSubtype.applicationOctetStream.mediaType, + filename: 'mydata.bin', + ); + final original = originalBuilder.buildMimeMessage(); + final builder = MessageBuilder() + ..addMessagePart(original) + ..subject = 'message with attached message' + ..text = 'hello world'; + final message = builder.buildMimeMessage(); + // print(message.renderMessage()); + final parts = message.parts ?? []; + expect(parts.length, 2); + expect(parts[0].isTextMediaType(), isTrue); + expect(parts[0].decodeContentText(), 'hello world'); + expect(parts[1].mediaType.sub, MediaSubtype.messageRfc822); + expect(parts[1].decodeFileName(), 'Original Message.eml'); + final embeddedMessage = parts[1].decodeContentMessage(); + expect(embeddedMessage, isNotNull); + expect(embeddedMessage?.mediaType.sub, MediaSubtype.multipartMixed); + expect(embeddedMessage?.parts?.length, 3); + expect( + embeddedMessage?.decodeTextPlainPart(), + 'Hello World - this is the original message\r\n', + ); + expect(embeddedMessage?.parts?.length, 3); + expect( + embeddedMessage?.parts?[2].decodeContentBinary(), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 0], + ); + }); + + test('real world test', () { + final original = MimeMessage.parseFromText(complexMessageText); + expect( + original.decodeSubject(), + 'Ihre Telekom Mobilfunk RechnungOnline Januar 2021 (Adresse: ' + '1234567 89, Kundenkonto: 123)', + ); + final builder = MessageBuilder() + ..addMessagePart(original) + ..subject = 'message with attached message'; + builder.getPart(MediaSubtype.multipartAlternative, recursive: false) ?? + builder.addPart( + mediaSubtype: MediaSubtype.multipartAlternative, + insert: true, + ) + ..addTextPlain('hello world') + ..addTextHtml('

hello world

'); + final message = builder.buildMimeMessage(); + //print(message.renderMessage()); + final parts = message.parts ?? []; + expect(parts.length, 2); + expect(parts[0].mediaType.sub, MediaSubtype.multipartAlternative); + expect(parts[0].getHeaderValue('content-transfer-encoding'), isNull); + expect(parts[0].parts?[0].decodeContentText(), 'hello world'); + expect(parts[1].mediaType.sub, MediaSubtype.messageRfc822); + expect( + parts[1].decodeFileName(), + 'Ihre Telekom Mobilfunk RechnungOnline Januar 2021 (Adresse: 1234567 ' + '89, Kundenkonto: 123).eml', + ); + final embeddedMessage = parts[1].decodeContentMessage(); + expect(embeddedMessage, isNotNull); + expect(embeddedMessage?.mediaType.sub, MediaSubtype.multipartMixed); + expect(embeddedMessage?.parts?.length, 2); + expect( + embeddedMessage?.parts?[0].mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect( + embeddedMessage?.decodeTextPlainPart()?.startsWith('Guten Tag '), + isTrue, + ); + expect( + embeddedMessage?.parts?[1].decodeFileName(), + 'Rechnung_2021_01_27317621000841.pdf', + ); + expect( + embeddedMessage?.parts?[1].decodeContentBinary()?.sublist(0, 9), + [37, 80, 68, 70, 45, 49, 46, 53, 10], + ); + final parsedAgain = MimeMessage.parseFromText(message.renderMessage()); + final parsedAgainEmbedded = parsedAgain.parts?[1].decodeContentMessage(); + expect( + parsedAgainEmbedded?.decodeTextPlainPart()?.startsWith('Guten Tag '), + isTrue, + ); + expect( + parsedAgainEmbedded?.parts?[1].decodeFileName(), + 'Rechnung_2021_01_27317621000841.pdf', + ); + expect( + parsedAgainEmbedded?.parts?[1].decodeContentBinary()?.sublist(0, 9), + [37, 80, 68, 70, 45, 49, 46, 53, 10], + ); + }); + }); + + group('MDNs', () { + test('buildReadReceipt', () { + final originalMessage = MimeMessage.parseFromText(complexMessageText); + originalMessage.addHeader( + MailConventions.headerDispositionNotificationTo, + originalMessage.fromEmail, + ); + const finalRecipient = MailAddress('My Name', 'recipient@domain.com'); + final mdn = + MessageBuilder.buildReadReceipt(originalMessage, finalRecipient); + // print(mdn.renderMessage()); + expect(mdn.to, isNotEmpty); + expect(mdn.to?.first.email, originalMessage.fromEmail); + expect(mdn.mediaType.sub, MediaSubtype.multipartReport); + expect(mdn.decodeTextPlainPart(), isNotEmpty); + expect(mdn.decodeSubject(), isNotNull); + final part = mdn + .getPartWithMediaSubtype(MediaSubtype.messageDispositionNotification); + expect(part, isNotNull); + //print(part?.decodeContentText()); + //expect(part?.decodeDispositionNotification()) + }); + }); +} + +const String complexMessageText = ''' +Return-Path: \r +Received: from AWMAIL121.telekom.de ([194.25.225.147]) by mx.kundenserver.de\r + (mxeue009 [212.227.15.41]) with ESMTPS (Nemesis) id 1Ml5Rc-1lbqtm34yi-00lUqM\r + for ; Thu, 11 Feb 2021 18:33:10 +0100\r +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r + d=telekom.de; i=@telekom.de; q=dns/txt; s=dtag;\r + t=1613064790; x=1644600790;\r + h=message-id:date:reply-to:to:subject:mime-version:from;\r + bh=4vLAh5zEUU6anl5LtECqWZ9saDTN5t4Fm1DsX3ESDnM=;\r + b=OmIkORmUche6cTg7qSzdOedxm89GO4Ds+BLyR/90l5cN+kQvhmyrybg5\r + FcGiLFZGXpA2kk3C7sIx2thk8kg5JO2ABqXLOfauPrbqD6zWUcABI/mbE\r + 6528JRE8wsWw72AGdmfe77aylYnUg/3sl6I3VoL8Eu/u0KfQCN1v0yavT\r + N7B5+ZcIFVDrnPDsPdbdWGQmn3XBCWDROKePSyfdehjuAO9IdbNQi+3wB\r + 76wtIrwtr2E7qasQlrj2lqKgjL4x/NEsh+grW9qs6tX6MDmCLx3iilUw6\r + yUp8o6KryC0aMeJesxvmzmaR8pCuVFb0UUKR6h/g2rWeoDm5ku+g8XB8B\r + A==;\r +IronPort-SDR: VxF26s0FdetMHR5JRLP0L5hbEtpatw3K8/ZGViA+0IOAahqJ370uq8lBmeqlOR+En0TiGTvysE\r + x8A75djr15ObnQt+J0wnsC1Fg8Yj1B7Uc=\r +From: Kundenservice.Rechnungonline@telekom.de\r +X-IronPort-AV: E=Sophos;i="5.81,170,1610406000";\r + d="pdf'?scan'208,217";a="479162390"\r +X-MGA-submission: =?us-ascii?q?MDEm1u9470rHCgAHtYzzhb1psCrUxbP110X++4?=\r + =?us-ascii?q?WCPLLvoG5rR/jceXacvoRs72CA3MMfu9abKjR/kfmiWDEBkdkk9I+q+m?=\r + =?us-ascii?q?nOjtro3+4vYV+op3hpzkj3UpaeLVQa7J2XO+lxImRrMF60Ob5Uu62T4g?=\r + =?us-ascii?q?iH0yINyX3uTzHlDKQI6zbPzYMhjh8IaI70AJAl84AP/jQ=3D?=\r +Received: from qde7xg.de.t-internal.com ([10.169.152.30])\r + by AWMAIL121.dmznet.de.t-internal.com with ESMTP; 11 Feb 2021 18:33:10 +0100\r +Received: from qde5nb (QDE5NB [10.105.40.71])\r + by QDE7XG.de.t-internal.com (Postfix) with ESMTP id 41BB91585391\r + for ; Thu, 11 Feb 2021 17:57:30 +0100 (CET)\r +Message-ID: <1447244645.1613062650263.JavaMail.rechnung-online@telekom.de>\r +Date: Thu, 11 Feb 2021 17:57:30 +0100 (CET)\r +Reply-To: noreply@telekom.de\r +To: recipient@domain.com\r +Subject: Ihre Telekom Mobilfunk RechnungOnline Januar 2021 (Adresse: 1234567\r + 89, Kundenkonto: 123)\r +MIME-Version: 1.0\r +Content-Type: multipart/mixed;\r + boundary="----=_Part_2450395_-60847697.1613062650261"\r +Envelope-To: \r +Authentication-Results: mqeue011.server.lan; dkim=pass header.i=@telekom.de\r +x-tdresult: feb8e846-d674-402a-b71a-3c2bc1e37567;c0aae587-6cd1-41fe-8a27-41c495be5c1b;1;0;1;0\r +x-tdcapabilities:\r +X-Spam-Flag: NO\r +\r +------=_Part_2450395_-60847697.1613062650261\r +Content-Type: multipart/alternative;\r + boundary="----=_Part_2450396_1831469312.1613062650261"\r +\r +------=_Part_2450396_1831469312.1613062650261\r +Content-Type: text/plain; charset=iso-8859-15\r +Content-Transfer-Encoding: quoted-printable\r +\r +Guten Tag XXX,\r +\r +------=_Part_2450396_1831469312.1613062650261\r +Content-Type: text/html; charset=iso-8859-15\r +Content-Transfer-Encoding: quoted-printable\r +\r +TELEKOM - ERLEBEN, WAS VERBINDET.
Guten Tag XXX=\r +
\r +------=_Part_2450396_1831469312.1613062650261--\r +\r +------=_Part_2450395_-60847697.1613062650261\r +Content-Type: application/octet-stream;\r + name=Rechnung_2021_01_27317621000841.pdf\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r + filename=Rechnung_2021_01_27317621000841.pdf\r +\r +JVBERi0xLjUKJeLjz9MKJUlTSVMgRERERV9QZGYtVjcuNC9sNiAnMjAxOS0wOC0xMyAoYnVpbGQ6\r +Ny40MC4xOTI4MC4xOTMzMCknICAgICAgICAgICAgIA00IDAgb2JqDVsNL0RldmljZVJHQg1dDWVu\r +ZG9iag01IDAgb2JqDVsvUGF0dGVybiA0IDAgUl0gDWVuZG9iag02IDAgb2JqDVsNL0RldmljZUNN\r +WUsNXQ1lbmRvYmoNNyAwIG9iag1bL1BhdHRlcm4gNiAwIFJdIA1lbmRvYmoNOSAwIG9iag08PA0v\r +jPwJy0fIq54MMvIIOcir/YqRkZH3IGc6OcjIG5KzPjnIyFuSM5V8nUcO8qKXWhPJQV6VnGnklCEH\r +uZecQuRMIacUOQeSM07OceSUI2eQnILkjJBLPFDenPjrripCTic5hcnpIqc0+aXVnoJy5FwPyPen\r +nJcm/+n34V5h8m/Xb1d7ZtmLkIeCjIyM/Jn8AOeAsMUNCmVuZHN0cmVhbQplbmRvYmoNMTQgMCBv\r +dCAyIDAgUg0vSW5mbyAxIDAgUg0+Pg1zdGFydHhyZWYNNzk3NzIgICAgIA0lJUVPRg0=\r +------=_Part_2450395_-60847697.1613062650261--\r +\r +'''; diff --git a/packages/enough_mail/test/mime_message_test.dart b/packages/enough_mail/test/mime_message_test.dart new file mode 100644 index 0000000..8cbc6d8 --- /dev/null +++ b/packages/enough_mail/test/mime_message_test.dart @@ -0,0 +1,1799 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:enough_convert/enough_convert.dart'; +import 'package:enough_mail/src/codecs/date_codec.dart'; +import 'package:enough_mail/src/codecs/mail_codec.dart'; +import 'package:enough_mail/src/mail_address.dart'; +import 'package:enough_mail/src/media_type.dart'; +import 'package:enough_mail/src/mime_message.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('content type tests', () { + test('content-type parsing 1', () { + const contentTypeValue = 'text/HTML; charset=ISO-8859-1'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'text/html'); + expect(type.mediaType.top, MediaToptype.text); + expect(type.mediaType.sub, MediaSubtype.textHtml); + expect(type.charset, 'iso-8859-1'); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'ISO-8859-1'); + }); + + test('content-type parsing 2', () { + const contentTypeValue = 'text/plain; charset="UTF-8"'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'text/plain'); + expect(type.mediaType.top, MediaToptype.text); + expect(type.mediaType.sub, MediaSubtype.textPlain); + expect(type.charset, 'utf-8'); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'UTF-8'); + }); + + test('content-type parsing 3', () { + const contentTypeValue = + 'multipart/alternative; boundary=bcaec520ea5d6918e204a8cea3b4'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'multipart/alternative'); + expect(type.mediaType.top, MediaToptype.multipart); + expect(type.mediaType.sub, MediaSubtype.multipartAlternative); + expect(type.charset, isNull); + expect(type.boundary, 'bcaec520ea5d6918e204a8cea3b4'); + expect(type.parameters, isNotNull); + expect(type.parameters['boundary'], 'bcaec520ea5d6918e204a8cea3b4'); + }); + + test('content-type parsing 4', () { + const contentTypeValue = 'TEXT/PLAIN; charset=ISO-8859-15; format=flowed'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'text/plain'); + expect(type.mediaType.top, MediaToptype.text); + expect(type.mediaType.sub, MediaSubtype.textPlain); + expect(type.charset, 'iso-8859-15'); + expect(type.isFlowedFormat, isTrue); + expect(type.boundary, isNull); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'ISO-8859-15'); + expect(type.parameters['format'], 'flowed'); + }); + + test('content-type parsing 5', () { + const contentTypeValue = + 'text/plain; charset=ISO-8859-15; format="Flowed"'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'text/plain'); + expect(type.mediaType.top, MediaToptype.text); + expect(type.mediaType.sub, MediaSubtype.textPlain); + expect(type.charset, 'iso-8859-15'); + expect(type.isFlowedFormat, isTrue); + expect(type.boundary, isNull); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'ISO-8859-15'); + expect(type.parameters['format'], 'Flowed'); + }); + + test('content-type parsing 6 - other text', () { + const contentTypeValue = + 'text/unsupported; charset=ISO-8859-15; format="Flowed"'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'text/unsupported'); + expect(type.mediaType.top, MediaToptype.text); + expect(type.mediaType.sub, MediaSubtype.other); + expect(type.charset, 'iso-8859-15'); + expect(type.isFlowedFormat, isTrue); + expect(type.boundary, isNull); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'ISO-8859-15'); + expect(type.parameters['format'], 'Flowed'); + }); + + test('content-type parsing 6 - other text', () { + const contentTypeValue = + 'augmented/reality; charset=ISO-8859-15; format="Flowed"'; + final type = ContentTypeHeader(contentTypeValue); + expect(type, isNotNull); + expect(type.mediaType.text, 'augmented/reality'); + expect(type.mediaType.top, MediaToptype.other); + expect(type.mediaType.sub, MediaSubtype.other); + expect(type.charset, 'iso-8859-15'); + expect(type.isFlowedFormat, isTrue); + expect(type.boundary, isNull); + expect(type.parameters, isNotNull); + expect(type.parameters['charset'], 'ISO-8859-15'); + expect(type.parameters['format'], 'Flowed'); + }); + }); + + group('parse tests', () { + test('multipart/alternative 1', () { + const body = ''' +From: Me Myself \r +To: You \r +Subject: \r +Date: Mon, 4 Dec 2019 15:51:37 +0100\r +Message-ID: \r +Content-Type: multipart/alternative;\r + boundary=unique-boundary-1\r +Reference: \r +Chat-Version: 1.0\r +Disposition-Notification-To: Me Myself \r +MIME-Version: 1.0\r +\r +--unique-boundary-1\r +Content-Type: text/plain; charset=UTF-8\r +\r +hello COI world\r +\r +\r +--unique-boundary-1\r +Content-Type: multipart/mixed;\r + boundary=unique-boundary-2\r +\r +--unique-boundary-2\r +Content-Type: text/html; charset=UTF-8\r +\r +

hello COI world

\r +\r +--unique-boundary-2\r +Content-Type: text/html; charset=UTF-8\r +Chat-Content: ignore\r +\r +

This message is a chat message - consider using my awesome COI app for best experience

\r +\r +--unique-boundary-2--\r +\r +--unique-boundary-1\r +Content-Type: text/markdown; charset=UTF-8\r +\r +hello **COI** world\r +\r +--unique-boundary-1--\r + '''; + final message = MimeMessage.parseFromText(body); + var contentTypeHeader = message.getHeaderContentType(); + expect(contentTypeHeader, isNotNull); + expect(contentTypeHeader?.mediaType, isNotNull); + expect(contentTypeHeader?.mediaType.top, MediaToptype.multipart); + expect( + contentTypeHeader?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(contentTypeHeader?.charset, isNull); + expect(contentTypeHeader?.boundary, 'unique-boundary-1'); + expect(message.headers, isNotNull); + expect(message.parts, isNotNull); + expect(message.parts?.length, 3); + expect(message.decodeTextPlainPart()?.trim(), 'hello COI world'); + contentTypeHeader = message.parts?[0].getHeaderContentType(); + expect(contentTypeHeader, isNotNull); + expect(contentTypeHeader?.mediaType.top, MediaToptype.text); + expect(contentTypeHeader?.mediaType.sub, MediaSubtype.textPlain); + expect(contentTypeHeader?.charset, 'utf-8'); + expect(message.parts?[1].parts, isNotNull); + expect(message.parts?[1].parts?.length, 2); + expect( + message.parts?[1].parts?[0].decodeContentText()?.trim(), + '

hello COI world

', + ); + expect( + message.parts?[1].parts?[1].decodeContentText()?.trim(), + '

This message is a chat message - consider using my awesome COI app for best experience

', + ); + expect( + message.parts?[2].decodeContentText()?.trim(), + 'hello **COI** world', + ); + expect(message.isTextPlainMessage(), isTrue); + }); + + test('multipart example rfc2046 section 5.1.1', () { + const body = ''' +From: Nathaniel Borenstein \r +To: Ned Freed \r +Date: Sun, 21 Mar 1993 23:56:48 -0800 (PST)\r +Subject: Sample message\r +MIME-Version: 1.0\r +Content-type: multipart/mixed; boundary="simple boundary"\r +\r +This is the preamble. It is to be ignored, though it\r +is a handy place for composition agents to include an\r +explanatory note to non-MIME conformant readers.\r +\r +--simple boundary\r +\r +This is implicitly typed plain US-ASCII text.\r +It does NOT end with a linebreak.\r +--simple boundary\r +Content-type: text/plain; charset=us-ascii\r +\r +This is explicitly typed plain US-ASCII text.\r +It DOES end with a linebreak.\r +\r +--simple boundary--\r +\r +This is the epilogue. It is also to be ignored.\r +'''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + expect(message.parts, isNotNull); + expect(message.parts?.length, 2); + expect(message.parts?[0].headers, isNull); + expect( + message.parts?[0].decodeContentText(), + 'This is implicitly typed plain US-ASCII text.\r\n' + 'It does NOT end with a linebreak.\r\n', + ); + expect( + message.parts?[0].decodeContentText(), + 'This is implicitly typed plain US-ASCII text.\r\nIt does NOT end ' + 'with a linebreak.\r\n', + ); + expect(message.parts?[1].headers?.isNotEmpty, isTrue); + expect(message.parts?[1].headers?.length, 1); + final contentType = message.parts?[1].getHeaderContentType(); + expect(contentType, isNotNull); + expect(contentType?.mediaType.top, MediaToptype.text); + expect(contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(contentType?.charset, 'us-ascii'); + expect( + message.parts?[1].decodeContentText(), + 'This is explicitly typed plain US-ASCII text.\r\n' + 'It DOES end with a linebreak.\r\n\r\n', + ); + expect(message.isTextPlainMessage(), isTrue); + }); + + test('complex multipart example from rfc2049 appendix A', () { + const body = ''' +MIME-Version: 1.0\r +From: Nathaniel Borenstein \r +To: Ned Freed \r +Date: Fri, 07 Oct 1994 16:15:05 -0700 (PDT)\r +Subject: A multipart example\r +Content-Type: multipart/mixed;\r + boundary=unique-boundary-1\r +\r +This is the preamble area of a multipart message.\r +Mail readers that understand multipart format\r +should ignore this preamble.\r +\r +If you are reading this text, you might want to\r +consider changing to a mail reader that understands\r +how to properly display multipart messages.\r +\r +--unique-boundary-1\r +\r + ... Some text appears here ...\r +\r +[Note that the blank between the boundary and the start\r +of the text in this part means no header fields were\r +given and this is text in the US-ASCII character set.\r +It could have been done with explicit typing as in the\r +next part.]\r +\r +--unique-boundary-1\r +Content-type: text/plain; charset=US-ASCII\r +\r +This could have been part of the previous part, but\r +illustrates explicit versus implicit typing of body\r +parts.\r +\r +--unique-boundary-1\r +Content-Type: multipart/parallel; boundary=unique-boundary-2\r +\r +--unique-boundary-2\r +Content-Type: audio/basic\r +Content-Transfer-Encoding: base64\r +\r + ... base64-encoded 8000 Hz single-channel\r + mu-law-format audio data goes here ...\r +\r +--unique-boundary-2\r +Content-Type: image/jpeg\r +Content-Transfer-Encoding: base64\r +\r + ... base64-encoded image data goes here ...\r +\r +--unique-boundary-2--\r +\r +--unique-boundary-1\r +Content-type: text/enriched\r +\r +This is enriched.\r +as defined in RFC 1896\r +\r +Isn't it\r +cool?\r +\r +--unique-boundary-1\r +Content-Type: message/rfc822\r +\r +From: (mailbox in US-ASCII)\r +To: (address in US-ASCII)\r +Subject: (subject in US-ASCII)\r +Content-Type: Text/plain; charset=ISO-8859-1\r +Content-Transfer-Encoding: Quoted-printable\r +\r + ... Additional text in ISO-8859-1 goes here ...\r +\r +--unique-boundary-1--\r +'''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + expect(message.parts, isNotNull); + expect(message.parts?.length, 5); + expect(message.parts?[0].headers, isNull); + final decodedContentText = message.parts?[0].decodeContentText(); + expect(decodedContentText, isNotNull); + final firstLine = + decodedContentText?.substring(0, decodedContentText.indexOf('\r\n')); + expect(firstLine, ' ... Some text appears here ...'); + expect(message.parts?[1].headers?.isNotEmpty, isTrue); + expect( + message.parts?[1].getHeaderContentType()?.mediaType.text, + 'text/plain', + ); + expect( + message.parts?[2].getHeaderContentType()?.mediaType.text, + 'multipart/parallel', + ); + expect(message.parts?[2].parts, isNotNull); + expect(message.parts?[2].parts?.length, 2); + }); + + test('realworld maillist-example 1', () { + const body = ''' +Return-Path: \r +Received: from mx1.domain.com ([10.20.30.1])\r + by imap.domain.com with LMTP\r + id 4IBOKeP/dV67PQAA3c6Kzw\r + (envelope-from ); Sat, 21 Mar 2020 12:52:03 +0100\r +Received: from localhost (localhost.localdomain [127.0.0.1])\r + by mx1.domain.com (Postfix) with ESMTP id 031456A8A0;\r + Sat, 21 Mar 2020 12:52:03 +0100 (CET)\r +Authentication-Results: domain.com;\r + dkim=fail reason="signature verification failed" (1024-bit key; unprotected) header?.d=domain.com header?.i=@domain.com header?.b="ZWO+bEJO";\r + dkim-atps=neutral\r +Received: from [127.0.0.1] (helo=localhost)\r + by localhost with ESMTP (eXpurgate 4.11.2)\r + (envelope-from )\r + id 5e75ffe2-5613-7f000001272a-7f0000019962-1\r + for ; Sat, 21 Mar 2020 12:52:02 +0100\r +X-Virus-Scanned: Debian amavisd-new at \r +Received: from mx1.domain.com ([127.0.0.1])\r + by localhost (mx1.domain.com [127.0.0.1]) (amavisd-new, port 10024)\r + with ESMTP id Dlbmr3fEFtex; Sat, 21 Mar 2020 12:52:00 +0100 (CET)\r +Received: from lists.mailman.org (lists.mailman.org [78.47.150.134])\r + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))\r + (Client did not present a certificate)\r + by mx1.domain.com (Postfix) with ESMTPS id C0AFF6A84C;\r + Sat, 21 Mar 2020 12:51:59 +0100 (CET)\r +Authentication-Results: domain.com; dmarc=fail (p=none dis=none) header?.from=domain.com\r +Authentication-Results: domain.com; spf=fail smtp.mailfrom=maillist-bounces@mailman.org\r +Received: from lists.mailman.org (localhost.localdomain [127.0.0.1])\r + by lists.mailman.org (Postfix) with ESMTP id A6BB6662F7;\r + Sat, 21 Mar 2020 12:51:57 +0100 (CET)\r +Received: from mx1.domain.com (mx1.domain.com [198.252.153.129])\r + by lists.mailman.org (Postfix) with ESMTPS id 59284662F7\r + for ; Sat, 21 Mar 2020 09:36:35 +0100 (CET)\r +Received: from bell.domain.com (unknown [10.0.1.178])\r + (using TLSv1 with cipher ECDHE-RSA-AES256-SHA (256/256 bits))\r + (Client CN "*.domain.com", Issuer "Sectigo RSA Domain Validation Secure Server CA" (not verified))\r + by mx1.domain.com (Postfix) with ESMTPS id 48kvBS5CmdzFdkQ\r + for ; Sat, 21 Mar 2020 01:36:32 -0700 (PDT)\r +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=domain.com; s=squak;\r + t=1584779792; bh=HmYvFZSHKCOKVVnMnSa/hT4hGnYoH0rnFpeFMpdfdPw=;\r + h=From:Subject:To:Date:From;\r + b=ZWO+bEJO78+T5/RiNpKBHMTPoqvYjQ/E/BiDrEjJA9r6elA66ZqKsQDhCrL3P60UO\r + cZgUds8jDBWCwQ8nEyjVB0MCZ4VeEvM0TZWKvdJNXG0QmcsnlKFbUBQAOZSDHi15KD\r + fF8s6XwdsBtZOHg9ZexFAhQr/inmbySL57fh55UY=\r +X-Riseup-User-ID: CB036983EADDB67FB2CA8BEBB99A6F0C1684CE1D6B8DA175C55981616B3FADFF\r +Received: from [127.0.0.1] (localhost [127.0.0.1])\r + by bell.domain.com (Postfix) with ESMTPSA id 48kvBS1gcQzJthb\r + for ; Sat, 21 Mar 2020 01:36:31 -0700 (PDT)\r +From: MoMercury \r +To: "coi-dev Chat Developers (ML)" \r +Message-ID: <3971e9bf-268f-47d0-5978-b2b44ebcf470@domain.com>\r +Date: Sat, 21 Mar 2020 09:36:29 +0100\r +MIME-Version: 1.0\r +Content-Type: multipart/mixed;\r + boundary="------------86BEE1CE827E0503C696F61E"\r +Content-Language: de-DE\r +X-MailFrom: reporter@domain.com\r +X-Mailman-Rule-Hits: nonmember-moderation\r +X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; emergency; loop; banned-address; member-moderation\r +Message-ID-Hash: CFYU7VLSB2J7MM6YYBZHLKZBMX5MHPDE\r +X-Message-ID-Hash: CFYU7VLSB2J7MM6YYBZHLKZBMX5MHPDE\r +X-Mailman-Approved-At: Sat, 21 Mar 2020 12:51:54 +0100\r +X-Mailman-Version: 3.3.0\r +Precedence: list\r +Subject: [coi-dev] ffi Crash Report\r +List-Id: "discussions about and around https://coi-dev.org developments" \r +Archived-At: \r +List-Archive: \r +List-Help: \r +List-Post: \r +List-Subscribe: \r +List-Unsubscribe: \r +X-purgate-ID: 151428::1584791522-00005613-69FA3901/0/0\r +X-purgate-type: clean\r +X-purgate-size: 2450\r +X-purgate: clean\r +\r +This is a multi-part message in MIME format.\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=utf-8\r +Content-Transfer-Encoding: 7bit\r +\r +hello world\r +\r +\r +\r +\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=UTF-8;\r + name="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r + filename="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +\r +bmFtZSA9ICdkZWx0YWNoYXRfZmZpJwpvcGVyYXRpbmdfc3lzdGVtID0gJ3VuaXg6QXJjaCcK\r +Y3JhdGVfdmVyc2lvbiA9ICcxLjI3LjAnCmV4cGxhbmF0aW9uID0gJycnClBhbmljIG9jY3Vy\r +cmVkIGluIGZpbGUgJ3NyYy9saWJjb3JlL3N0ci9tb2QucnMnIGF0IGxpbmUgMjA1NQonJycK\r +bWV0aG9kID0gJ1BhbmljJwpiYWNrdHJhY2UgPSAnJycKICAgMDogICAgIDB4N2Y3YzQyMzEz\r +MjE4IC0gPHVua25vd24+CiAgIDE6ICAgICAweDdmN2M0MjMxMDAzMyAtIDx1bmtub3duPgog\r +ICAyOiAgICAgMHg3ZjdjNDI2NTdjM2MgLSA8dW5rbm93bj4KICAgMzogICAgIDB4N2Y3YzQy\r +MjcxYzQ4IC0gPHVua25vd24+CiAgIDQ6ICAgICAweDdmN2M0Mjk3YzEyOCAtIDx1bmtub3du\r +PgogICA1OiAgICAgMHg3ZjdjNDI5N2JlN2UgLSA8dW5rbm93bj4KICAgNjogICAgIDB4N2Y3\r +YzQyOTkyM2Y2IC0gPHVua25vd24+CiAgIDc6ICAgICAweDdmN2M0MjMyNTI0ZCAtIDx1bmtu\r +b3duPgogICA4OiAgICAgMHg3ZjdjNDIzMjZhNjMgLSA8dW5rbm93bj4KICAgOTogICAgIDB4\r +N2Y3YzQyNWRmN2MxIC0gPHVua25vd24+CiAgMTA6ICAgICAweDdmN2M0MjNlM2Q5NCAtIDx1\r +bmtub3duPgogIDExOiAgICAgMHg3ZjdjNDIzZDFiN2EgLSA8dW5rbm93bj4KICAxMjogICAg\r +IDB4N2Y3YzQyM2QxMWY3IC0gPHVua25vd24+CiAgMTM6ICAgICAweDdmN2M0MjU3NGNmZiAt\r +IDx1bmtub3duPgogIDE0OiAgICAgMHg3ZjdjNDIzYzI0ODIgLSA8dW5rbm93bj4KICAxNTog\r +ICAgIDB4N2Y3YzQyM2JlZGQ4IC0gPHVua25vd24+CiAgMTY6ICAgICAweDdmN2M0MjUzODg3\r +MCAtIDx1bmtub3duPgogIDE3OiAgICAgMHg3ZjdjNDIyN2M5NmUgLSBkY19wZXJmb3JtX2lt\r +YXBfZmV0Y2gKICAxODogICAgIDB4N2Y3YzQyMjY0ZTIwIC0gPHVua25vd24+CiAgMTk6ICAg\r +ICAweDdmN2M1NWIxODQ2ZiAtIHN0YXJ0X3RocmVhZAogIDIwOiAgICAgMHg3ZjdjNTFjMTkz\r +ZDMgLSBjbG9uZQogIDIxOiAgICAgICAgICAgICAgICAweDAgLSA8dW5rbm93bj4KJycnCg==\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset="us-ascii"\r +MIME-Version: 1.0\r +Content-Transfer-Encoding: 7bit\r +Content-Disposition: inline\r +\r +_______________________________________________\r +coi-dev mailing list -- mailinglistt@mailman.org\r +To unsubscribe send an email to coi-dev-leave@mailman.org\r +\r +--------------86BEE1CE827E0503C696F61E--\r +\r +'''; + final message = MimeMessage.parseFromText(body)..parse(); + expect(message.headers, isNotNull); + expect(message.parts, isNotNull); + expect(message.parts?.length, 3); + expect(message.parts?[0].headers?.isNotEmpty, isTrue); + expect( + message.parts?[0].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + var decodedContentText = message.parts?[0].decodeContentText(); + expect(decodedContentText, isNotNull); + var firstLine = + decodedContentText?.substring(0, decodedContentText.indexOf('\r\n')); + expect(firstLine, 'hello world'); + expect(message.parts?[1].headers?.isNotEmpty, isTrue); + expect( + message.parts?[1].getHeaderContentType()?.mediaType.text, + 'text/plain', + ); + expect( + message.parts?[1].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + decodedContentText = message.parts?[1].decodeContentText(); + expect(decodedContentText, isNotNull); + expect( + message.parts?[2].getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(message.parts?[2].parts, isNull); + decodedContentText = message.parts?[2].decodeContentText(); + expect(decodedContentText, isNotNull); + firstLine = + decodedContentText?.substring(0, decodedContentText.indexOf('\r\n')); + expect(firstLine, '_______________________________________________'); + }); + + test('realworld maillist-example 2', () { + const body = ''' +Return-Path: \r +Received: from mx1.domain.com ([10.20.30.1])\r + by imap.domain.com with LMTP\r + id TAmOEPuDdl4lWgAA3c6Kzw\r + (envelope-from ); Sat, 21 Mar 2020 22:15:39 +0100\r +Received: from localhost (localhost.localdomain [127.0.0.1])\r + by mx1.domain.com (Postfix) with ESMTP id 906166A8D4;\r + Sat, 21 Mar 2020 22:15:38 +0100 (CET)\r +Authentication-Results: domain.com;\r + dkim=fail reason="signature verification failed" (2048-bit key; unprotected) header?.d=previouslyNoEvil.com header?.i=@previouslyNoEvil.com header?.b="oN0X9Vdd";\r + dkim-atps=neutral\r +Received: from [127.0.0.1] (helo=localhost)\r + by localhost with ESMTP (eXpurgate 4.11.2)\r + (envelope-from )\r + id 5e7683fa-5613-7f000001272a-7f0000019142-1\r + for ; Sat, 21 Mar 2020 22:15:38 +0100\r +X-Virus-Scanned: Debian amavisd-new at \r +Received: from mx1.domain.com ([127.0.0.1])\r + by localhost (mx1.domain.com [127.0.0.1]) (amavisd-new, port 10024)\r + with ESMTP id P9LKNQTeIkwh; Sat, 21 Mar 2020 22:15:35 +0100 (CET)\r +Received: from lists.mailman.org (lists.mailman.org [78.47.150.134])\r + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))\r + (Client did not present a certificate)\r + by mx1.domain.com (Postfix) with ESMTPS id 4E4B06A853;\r + Sat, 21 Mar 2020 22:15:33 +0100 (CET)\r +Authentication-Results: domain.com; dmarc=fail (p=none dis=none) header?.from=previouslyNoEvil.com\r +Authentication-Results: domain.com; spf=fail smtp.mailfrom=maillist-bounces@mailman.org\r +Received: from lists.mailman.org (localhost.localdomain [127.0.0.1])\r + by lists.mailman.org (Postfix) with ESMTP id C3938666B2;\r + Sat, 21 Mar 2020 22:15:30 +0100 (CET)\r +Received: from mail-lj1-x236.previouslyNoEvil.com (mail-lj1-x236.previouslyNoEvil.com [IPv6:2a00:1450:4864:20::236])\r + by lists.mailman.org (Postfix) with ESMTPS id E1CAE666B2\r + for ; Sat, 21 Mar 2020 22:15:26 +0100 (CET)\r +Received: by mail-lj1-x236.previouslyNoEvil.com with SMTP id w4so10324769lji.11\r + for ; Sat, 21 Mar 2020 14:15:26 -0700 (PDT)\r +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r + d=previouslyNoEvil.com; s=20161025;\r + h=subject:to:references:from:message-id:date:user-agent:mime-version\r + :in-reply-to:content-transfer-encoding:content-language;\r + bh=LZmRjRlNHLRcS5FVoR+0sUb3WG40WTa+5hYBIWyP9W8=;\r + b=oN0X9VddQBe02B509+0YCKMeHFVNRDLCiQFlmex88163GZqT8g7f/0/UUanHS5fJSo\r + 4XGtmVBbpSolUUcK+4Pnu8QkkhkmCmkSEqHTE9kcUONsCefDkyneOZzK5M8YfmBNBHVM\r + MunlHFBaadP5rgWG+iuMM7KqG9Ln3DJ3WXHqTwxjMySheiVBRkVv/jD72kaqTqHd/Rx8\r + 9EE5nhxZbVuXLDc+M9FS0S5TLB4KdVtITS13z2vDQY5kjZAd1eMM6g7W3ybokZ+43VfC\r + z4GIzYDECXKfRQpQf5JZjMOSWBYycCFx72ojkltBf2TeBokC47c93+SiAmgvEm92ohfX\r + fklw==\r +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r + d=1e100.net; s=20161025;\r + h=x-gm-message-state:subject:to:references:from:message-id:date\r + :user-agent:mime-version:in-reply-to:content-transfer-encoding\r + :content-language;\r + bh=LZmRjRlNHLRcS5FVoR+0sUb3WG40WTa+5hYBIWyP9W8=;\r + b=BJAddXQjaDDQRJC/g+uXPLfRv4xCT3MLAk16JK+8DI0//FbLC7IVkbgqvfCOcmRn5q\r + e1W8UFJzE949Q5G/NM5LnVPoa31/BEBB2nVqpUgrJayf/HYbZdHdUK9y8Dpv4fP8xXOE\r + diLKMnjAprg4joEFOPNGy2MjHXWOFlpRjypite9r8GmrVOC8iyTwBpyy6ABUZXH1s231\r + nIgjcHhjLWlNr2IejTHVfgf2IMntt+ReRfub+8+X1U28IZvFMTNpYCtTHjrUv+J6S1BN\r + BpqgwvUF++dEvY2MiUi+XSNxPVIzQ4x42pzgj72Clct04k9Vy0xgLG4M7rfuxHE/5U0A\r + gbzA==\r +X-Gm-Message-State: ANhLgQ3HZTaH97hknNii3AeUxpcap545C0INIo4iUYuhvPGpQPpXlImc\r + sBtHvKiTLj+HDYHIFhnUxtnXHf9r\r +X-Google-Smtp-Source: ADFU+vuQQhJaAe+pMd1Nn7Os7/RHK1A6V1CC7p8hm+FXDznWO2KUE0voPM9TPCJDNIlj8ZzJlEYizA==\r +X-Received: by 2002:a2e:7e0a:: with SMTP id z10mr9164061ljc.42.1584825324994;\r + Sat, 21 Mar 2020 14:15:24 -0700 (PDT)\r +Received: from [10.64.227.58] ([185.245.84.124])\r + by smtp.previouslyNoEvil.com with ESMTPSA id k1sm5932121lji.43.2020.03.21.14.15.23\r + for \r + (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);\r + Sat, 21 Mar 2020 14:15:24 -0700 (PDT)\r +To: mailinglistt@mailman.org\r +References: <3971e9bf-268f-47d0-5978-b2b44ebcf470@domain.com>\r +From: Alexander \r +Message-ID: <1ac969a2-b175-ef3a-a3c8-b9dbc93811f7@previouslyNoEvil.com>\r +Date: Sun, 22 Mar 2020 00:17:46 +0300\r +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101\r + Thunderbird/68.6.0\r +MIME-Version: 1.0\r +In-Reply-To: <3971e9bf-268f-47d0-5978-b2b44ebcf470@domain.com>\r +Content-Language: en-US\r +Message-ID-Hash: U5WVNW4Y2AAPID373PV3AX5X4O5ZZ725\r +X-Message-ID-Hash: U5WVNW4Y2AAPID373PV3AX5X4O5ZZ725\r +X-MailFrom: abc@previouslyNoEvil.com\r +X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; emergency; loop; banned-address; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; suspicious-header\r +X-Mailman-Version: 3.3.0\r +Precedence: list\r +Subject: [coi-dev] Re: ffi Crash Report\r +List-Id: "discussions about and around https://coi-dev.org developments" \r +Archived-At: \r +List-Archive: \r +List-Help: \r +List-Post: \r +List-Subscribe: \r +List-Unsubscribe: \r +Content-Type: text/plain; charset="us-ascii"\r +Content-Transfer-Encoding: 7bit\r +X-purgate-ID: 151428::1584825338-00005613-E081282F/0/0\r +X-purgate-type: clean\r +X-purgate-size: 566\r +\X-purgate: clean\r +\r +This is a reply\r +to explain and ask for details\r +_______________________________________________\r +coi-dev mailing list -- mailinglistt@mailman.org\r +To unsubscribe send an email to coi-dev-leave@mailman.org\r +'''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + expect(message.parts, isNull); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.textPlain, + ); + final decodedContentText = message.decodeContentText(); + expect(decodedContentText, isNotNull); + final firstLine = + decodedContentText?.substring(0, decodedContentText.indexOf('\r\n')); + expect(firstLine, 'This is a reply'); + }); + + test('Realworld PGP Message Test', () { + const body = ''' +From: sender@domain.com\r +To: receiver@domain.com\r +Message-ID: <05fb895f-e6e8-4e40-fc9e-1a86a2b7ac55@xxxxxxxx.org>\r +Subject: Re: XXXXXX\r +References: <66704825-5855-4783-b7c3-d48ee34c46d8@xxxxx.re>\r +In-Reply-To: <66704825-5855-4783-b7c3-d48ee34c46d8@xxxxxx.re>\r +Date: Sun, 8 Nov 2020 00:17:46 +0300\r +Content-Type: multipart/signed; boundary="jsRvMCvIu46WpNX1JGpxzIxzfAm6xTTQ6";\r +\r +This is an OpenPGP/MIME signed message (RFC 4880 and 3156)\r +--jsRvMCvIu46WpNX1JGpxzIxzfAm6xTTQ6\r +Content-Type: multipart/mixed; boundary="BHExnvuVOviQxAIGaEirfgZbVhPGKVh6z";\r + protected-headers="v1"\r +From: =?UTF-8?Q?XXXXXXX_XXXXXX?= \r +To: Xxxxxxx \r +Message-ID: <05fb895f-e6e8-4e40-fc9e-1a86a2b7ac55@xxxxxxxx.org>\r +Subject: Re: XXXXXX\r +References: <66704825-5855-4783-b7c3-d48ee34c46d8@xxxxx.re>\r +In-Reply-To: <66704825-5855-4783-b7c3-d48ee34c46d8@xxxxxx.re>\r +\r +--BHExnvuVOviQxAIGaEirfgZbVhPGKVh6z\r +Content-Type: multipart/mixed;\r + boundary="------------831B4B68BAC422FAB7101CF9"\r +\r +This is a multi-part message in MIME format.\r +--------------831B4B68BAC422FAB7101CF9\r +Content-Type: multipart/alternative;\r + boundary="------------E7938C4510EDF90BE5C237F5"\r +\r +\r +--------------E7938C4510EDF90BE5C237F5\r +Content-Type: text/plain; charset=utf-8; format=flowed\r +Content-Transfer-Encoding: quoted-printable\r +\r +THE MESSAGE TEXT....\r +Regards\r +.....\r +\r +Am 01.11.2020 um 18:30 schrieb XXXXXX:\r +> A quote\r +\r +--------------E7938C4510EDF90BE5C237F5\r +Content-Type: text/html; charset=utf-8\r +Content-Transfer-Encoding: quoted-printable\r +\r +\r + \r + \r + \r + \r +

XXXXX,
\r +

\r +

SOME HTML\r +

\r +
Am 01.11.2020 um 18:30 schrieb XXXXXXX:<=\r +br>\r +
\r +
\r + \r +SOME HTML\r +
\r + \r +\r +\r +--------------E7938C4510EDF90BE5C237F5--\r +\r +--------------831B4B68BAC422FAB7101CF9\r +Content-Type: application/pgp-keys;\r + name="OpenPGP_0xXXXXXXXXX.asc"\r +Content-Transfer-Encoding: quoted-printable\r +Content-Disposition: attachment;\r + filename="OpenPGP_0xXXXXXXXXX.asc"\r +\r +-----BEGIN PGP PUBLIC KEY BLOCK-----\r +\r +[...]\r +ALkh8XOCbFCWAP9OpfmHxIuwbmK6yNuoQhygxjqh4gcuE3nrJYYbt8/vAw=3D=3D\r +=3DLyed\r +-----END PGP PUBLIC KEY BLOCK-----\r +\r +--------------831B4B68BAC422FAB7101CF9--\r +\r +--BHExnvuVOviQxAIGaEirfgZbVhPGKVh6z--\r +\r +--jsRvMCvIu46WpNX1JGpxzIxzfAm6xTTQ6\r +Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"\r +Content-Description: OpenPGP digital signature\r +Content-Disposition: attachment; filename="OpenPGP_signature"\r +\r +-----BEGIN PGP SIGNATURE-----\r +\r +wnsEABYIACMWIQS\r +[...]\r +b6oUGuLbJCwEAmL28F+QOf1nLe3ABYV1J/6aTDZir\r +UckHnSueOzINHwA=\r +=bNL9\r +-----END PGP SIGNATURE-----\r +\r +--jsRvMCvIu46WpNX1JGpxzIxzfAm6xTTQ6--\r + '''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + expect( + message.getHeaderContentType()?.mediaType.sub, + MediaSubtype.multipartSigned, + ); + expect(message.parts, isNotNull); + expect(message.allPartsFlat, isNotNull); + expect(message.allPartsFlat, isNotEmpty); + final keysPart = message.allPartsFlat.firstWhereOrNull((part) => + part.getHeaderContentType()?.mediaType.sub == + MediaSubtype.applicationPgpKeys); + expect(keysPart, isNotNull); + expect( + message.allPartsFlat.last.getHeaderContentType()?.mediaType.sub, + MediaSubtype.applicationPgpSignature, + ); + }); + }); + + group('header tests', () { + test('https://tools.ietf.org/html/rfc2047 example 1', () { +// + const body = ''' +From: =?US-ASCII?Q?Keith_Moore?= \r +To: =?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= \r +CC: =?ISO-8859-1?Q?Andr=E9?= Pirard \r +Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\r + =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\r +\r +'''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + var header = message.decodeHeaderMailAddressValue('from'); + expect(header, isNotNull); + expect(header?.length, 1); + expect(header?[0].personalName, 'Keith Moore'); + expect(header?[0].email, 'moore@cs.utk.edu'); + header = message.decodeHeaderMailAddressValue('to'); + expect(header, isNotNull); + expect(header?.length, 1); + expect(header?[0].personalName, 'Keld Jørn Simonsen'); + expect(header?[0].email, 'keld@dkuug.dk'); + header = message.decodeHeaderMailAddressValue('cc'); + expect(header, isNotNull); + expect(header?.length, 1); + expect(header?[0].personalName, 'André Pirard'); + expect(header?[0].email, 'PIRARD@vm1.ulg.ac.be'); + + final rawSubject = message.getHeaderValue('subject'); + expect( + rawSubject, + '=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=' + '=?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=', + ); + + final subject = message.decodeHeaderValue('subject'); + expect(subject, 'If you can read this you understand the example.'); + }); + + test('https://tools.ietf.org/html/rfc2047 example 2', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final message = MimeMessage.parseFromText(body); + expect(message.headers, isNotNull); + var header = message.decodeHeaderMailAddressValue('from'); + expect(header, isNotNull); + expect(header?.length, 1); + expect(header?[0].personalName, 'Nathaniel Borenstein'); + expect(header?[0].email, 'nsb@thumper.bellcore.com'); + header = message.decodeHeaderMailAddressValue('to'); + expect(header, isNotNull); + expect(header?.length, 3); + expect(header?[0].personalName, 'Greg Vaudreuil'); + expect(header?[0].email, 'gvaudre@NRI.Reston.VA.US'); + expect(header?[1].personalName, 'Ned Freed'); + expect(header?[1].email, 'ned@innosoft.com'); + expect(header?[2].personalName, 'Keith Moore'); + expect(header?[2].email, 'moore@cs.utk.edu'); + final subject = message.decodeHeaderValue('subject'); + expect(subject, 'Test of new header generator'); + final contentType = message.getHeaderContentType(); + expect(contentType, isNotNull); + expect(contentType?.mediaType.top, MediaToptype.text); + expect(contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(contentType?.charset, 'iso-8859-1'); + }); + }); + + group('Header tests', () { + test('header?.render() short line', () { + final header = Header( + 'Content-Type', + 'text/plain; charset="us-ascii"; format="flowed"', + ); + final buffer = StringBuffer(); + header.render(buffer); + final text = buffer.toString().split('\r\n'); + expect(text.length, 2); + expect( + text[0], + 'Content-Type: text/plain; charset="us-ascii"; format="flowed"', + ); + expect(text[1], ''); + }); + test('header?.render() long line 1', () { + final header = Header( + 'Content-Type', + 'multipart/alternative; boundary="12345678901233456789012345678901234567"', + ); + final buffer = StringBuffer(); + header.render(buffer); + final text = buffer.toString().split('\r\n'); + expect(text.length, 3); + expect(text[0], 'Content-Type: multipart/alternative;'); + expect(text[1], '\tboundary="12345678901233456789012345678901234567"'); + expect(text[2], ''); + }); + + test('header?.render() long line 2', () { + final header = Header( + 'Content-Type', + 'multipart/alternative;boundary="12345678901233456789012345678901234567"', + ); + final buffer = StringBuffer(); + header.render(buffer); + final text = buffer.toString().split('\r\n'); + expect(text.length, 3); + expect(text[0], 'Content-Type: multipart/alternative;'); + expect(text[1], '\tboundary="12345678901233456789012345678901234567"'); + expect(text[2], ''); + }); + + test('header?.render() long line 3', () { + final header = Header( + 'Content-Type', + 'multipart/alternative;boundary="12345678901233456789012345678901234567"; fileName="one_two_three_four_five_six_seven.png";', + ); + final buffer = StringBuffer(); + header.render(buffer); + final text = buffer.toString(); + expect( + text, + 'Content-Type: multipart/alternative;\r\n' + '\tboundary="12345678901233456789012345678901234567";\r\n' + '\tfileName="one_two_three_four_five_six_seven.png";\r\n', + ); + }); + + test('header?.render() long line without split pos', () { + final header = Header( + 'Content-Type', + '1234567890123456789012345678901234567890123456789012345678901234' + '5678901234567890123456789012345678901234567890123456789012345678' + '90123456789012345678901234567890', + ); + final buffer = StringBuffer(); + header.render(buffer); + final text = buffer.toString().split('\r\n'); + expect(text.length, 4); + expect( + text[0], + 'Content-Type: 123456789012345678901234567890123456789012345678901' + '23456789012', + ); + expect( + text[1], + '\t345678901234567890123456789012345678901234567890123456789012345' + '678901234567', + ); + expect(text[2], '\t89012345678901234567890'); + expect(text[3], ''); + }); + }); + + group('decodeSender()', () { + test('From', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + final sender = mimeMessage.decodeSender(); + expect(sender, isNotEmpty); + expect(sender.length, 1); + expect(sender.first.personalName, 'Nathaniel Borenstein'); + expect(sender.first.email, 'nsb@thumper.bellcore.com'); + }); + + test('Reply To', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +Reply-To: Mailinglist \r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + final sender = mimeMessage.decodeSender(); + expect(sender, isNotEmpty); + expect(sender.length, 1); + expect(sender.first.personalName, 'Mailinglist'); + expect(sender.first.email, 'mail@domain.com'); + }); + + test('Combine Reply-To, Sender and From', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +Reply-To: Mailinglist \r +Sender: "Real Sender" \r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + final sender = mimeMessage.decodeSender(combine: true); + expect(sender, isNotEmpty); + expect(sender.length, 3); + expect(sender[0].personalName, 'Mailinglist'); + expect(sender[0].email, 'mail@domain.com'); + expect(sender[1].personalName, 'Real Sender'); + expect(sender[1].email, 'sender@domain.com'); + expect(sender[2].personalName, 'Nathaniel Borenstein'); + expect(sender[2].email, 'nsb@thumper.bellcore.com'); + }); + }); + + group('isFrom()', () { + test('From', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + expect( + mimeMessage.isFrom(const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + )), + isTrue, + ); + expect( + mimeMessage.isFrom(const MailAddress( + 'Nathaniel Borenstein', + 'ns2b@thumper.bellcore.com', + )), + isFalse, + ); + expect( + mimeMessage.isFrom( + const MailAddress( + 'Nathaniel Borenstein', + 'other@thumper.bellcore.com', + ), + aliases: [ + const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + ), + ], + ), + isTrue, + ); + }); + + test('From with + Alias', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + expect( + mimeMessage.isFrom(const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + )), + isFalse, + ); + expect( + mimeMessage.isFrom( + const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + ), + allowPlusAliases: true, + ), + isTrue, + ); + }); + + test('Combine Reply-To, Sender and From', () { + const body = ''' +From: Nathaniel Borenstein \r + (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)\r +Reply-To: Mailinglist \r +Sender: "Real Sender" \r +To: Greg Vaudreuil , Ned Freed\r + , Keith Moore \r +Subject: Test of new header generator\r +MIME-Version: 1.0\r +Content-type: text/plain; charset=ISO-8859-1\r +'''; + final mimeMessage = MimeMessage.parseFromText(body); + expect( + mimeMessage.isFrom(const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + )), + isTrue, + ); + expect( + mimeMessage.isFrom(const MailAddress('Sender', 'sender@domain.com')), + isTrue, + ); + expect( + mimeMessage.isFrom(const MailAddress('Reply To', 'mail@domain.com')), + isTrue, + ); + expect( + mimeMessage.isFrom(const MailAddress( + 'Nathaniel Borenstein', + 'ns2b@thumper.bellcore.com', + )), + isFalse, + ); + expect( + mimeMessage.isFrom( + const MailAddress( + 'Nathaniel Borenstein', + 'other@thumper.bellcore.com', + ), + aliases: [ + const MailAddress( + 'Nathaniel Borenstein', + 'nsb@thumper.bellcore.com', + ), + ], + ), + isTrue, + ); + }); + }); + + group('ContentDispositionHeader tests', () { + test('render()', () { + final header = ContentDispositionHeader.from(ContentDisposition.inline); + expect(header.render(), 'inline'); + header.filename = 'image.jpeg'; + expect(header.render(), 'inline; filename="image.jpeg"'); + final creation = DateTime.now(); + final creationDateText = DateCodec.encodeDate(creation); + header.creationDate = creation; + expect( + header.render(), + 'inline; filename="image.jpeg"; creation-date="$creationDateText"', + ); + header.size = 2046; + expect( + header.render(), + 'inline; filename="image.jpeg"; creation-date="$creationDateText";' + ' size=2046', + ); + header.setParameter('hello', 'world'); + expect( + header.render(), + 'inline; filename="image.jpeg"; creation-date="$creationDateText";' + ' size=2046; hello=world', + ); + }); + + test('listContentInfo() 1', () { + const body = ''' +Return-Path: \r +Received: from mx1.domain.com ([10.20.30.1])\r + by imap.domain.com with LMTP\r + id 4IBOKeP/dV67PQAA3c6Kzw\r + (envelope-from ); Sat, 21 Mar 2020 12:52:03 +0100\r +Received: from localhost (localhost.localdomain [127.0.0.1])\r + by mx1.domain.com (Postfix) with ESMTP id 031456A8A0;\r + Sat, 21 Mar 2020 12:52:03 +0100 (CET)\r +Authentication-Results: domain.com;\r + dkim=fail reason="signature verification failed" (1024-bit key; unprotected) header?.d=domain.com header?.i=@domain.com header?.b="ZWO+bEJO";\r + dkim-atps=neutral\r +Received: from [127.0.0.1] (helo=localhost)\r + by localhost with ESMTP (eXpurgate 4.11.2)\r + (envelope-from )\r + id 5e75ffe2-5613-7f000001272a-7f0000019962-1\r + for ; Sat, 21 Mar 2020 12:52:02 +0100\r +X-Virus-Scanned: Debian amavisd-new at \r +Received: from mx1.domain.com ([127.0.0.1])\r + by localhost (mx1.domain.com [127.0.0.1]) (amavisd-new, port 10024)\r + with ESMTP id Dlbmr3fEFtex; Sat, 21 Mar 2020 12:52:00 +0100 (CET)\r +Received: from lists.mailman.org (lists.mailman.org [78.47.150.134])\r + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))\r + (Client did not present a certificate)\r + by mx1.domain.com (Postfix) with ESMTPS id C0AFF6A84C;\r + Sat, 21 Mar 2020 12:51:59 +0100 (CET)\r +Authentication-Results: domain.com; dmarc=fail (p=none dis=none) header?.from=domain.com\r +Authentication-Results: domain.com; spf=fail smtp.mailfrom=maillist-bounces@mailman.org\r +Received: from lists.mailman.org (localhost.localdomain [127.0.0.1])\r + by lists.mailman.org (Postfix) with ESMTP id A6BB6662F7;\r + Sat, 21 Mar 2020 12:51:57 +0100 (CET)\r +Received: from mx1.domain.com (mx1.domain.com [198.252.153.129])\r + by lists.mailman.org (Postfix) with ESMTPS id 59284662F7\r + for ; Sat, 21 Mar 2020 09:36:35 +0100 (CET)\r +Received: from bell.domain.com (unknown [10.0.1.178])\r + (using TLSv1 with cipher ECDHE-RSA-AES256-SHA (256/256 bits))\r + (Client CN "*.domain.com", Issuer "Sectigo RSA Domain Validation Secure Server CA" (not verified))\r + by mx1.domain.com (Postfix) with ESMTPS id 48kvBS5CmdzFdkQ\r + for ; Sat, 21 Mar 2020 01:36:32 -0700 (PDT)\r +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=domain.com; s=squak;\r + t=1584779792; bh=HmYvFZSHKCOKVVnMnSa/hT4hGnYoH0rnFpeFMpdfdPw=;\r + h=From:Subject:To:Date:From;\r + b=ZWO+bEJO78+T5/RiNpKBHMTPoqvYjQ/E/BiDrEjJA9r6elA66ZqKsQDhCrL3P60UO\r + cZgUds8jDBWCwQ8nEyjVB0MCZ4VeEvM0TZWKvdJNXG0QmcsnlKFbUBQAOZSDHi15KD\r + fF8s6XwdsBtZOHg9ZexFAhQr/inmbySL57fh55UY=\r +X-Riseup-User-ID: CB036983EADDB67FB2CA8BEBB99A6F0C1684CE1D6B8DA175C55981616B3FADFF\r +Received: from [127.0.0.1] (localhost [127.0.0.1])\r + by bell.domain.com (Postfix) with ESMTPSA id 48kvBS1gcQzJthb\r + for ; Sat, 21 Mar 2020 01:36:31 -0700 (PDT)\r +From: MoMercury \r +To: "coi-dev Chat Developers (ML)" \r +Message-ID: <3971e9bf-268f-47d0-5978-b2b44ebcf470@domain.com>\r +Date: Sat, 21 Mar 2020 09:36:29 +0100\r +MIME-Version: 1.0\r +Content-Type: multipart/mixed;\r + boundary="------------86BEE1CE827E0503C696F61E"\r +Content-Language: de-DE\r +X-MailFrom: reporter@domain.com\r +X-Mailman-Rule-Hits: nonmember-moderation\r +X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; emergency; loop; banned-address; member-moderation\r +Message-ID-Hash: CFYU7VLSB2J7MM6YYBZHLKZBMX5MHPDE\r +X-Message-ID-Hash: CFYU7VLSB2J7MM6YYBZHLKZBMX5MHPDE\r +X-Mailman-Approved-At: Sat, 21 Mar 2020 12:51:54 +0100\r +X-Mailman-Version: 3.3.0\r +Precedence: list\r +Subject: [coi-dev] ffi Crash Report\r +List-Id: "discussions about and around https://coi-dev.org developments" \r +Archived-At: \r +List-Archive: \r +List-Help: \r +List-Post: \r +List-Subscribe: \r +List-Unsubscribe: \r +X-purgate-ID: 151428::1584791522-00005613-69FA3901/0/0\r +X-purgate-type: clean\r +X-purgate-size: 2450\r +X-purgate: clean\r +\r +This is a multi-part message in MIME format.\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=utf-8\r +Content-Transfer-Encoding: 7bit\r +\r +hello world\r +\r +\r +\r +\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=UTF-8;\r + name="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r + filename="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +\r +bmFtZSA9ICdkZWx0YWNoYXRfZmZpJwpvcGVyYXRpbmdfc3lzdGVtID0gJ3VuaXg6QXJjaCcK\r +Y3JhdGVfdmVyc2lvbiA9ICcxLjI3LjAnCmV4cGxhbmF0aW9uID0gJycnClBhbmljIG9jY3Vy\r +cmVkIGluIGZpbGUgJ3NyYy9saWJjb3JlL3N0ci9tb2QucnMnIGF0IGxpbmUgMjA1NQonJycK\r +bWV0aG9kID0gJ1BhbmljJwpiYWNrdHJhY2UgPSAnJycKICAgMDogICAgIDB4N2Y3YzQyMzEz\r +MjE4IC0gPHVua25vd24+CiAgIDE6ICAgICAweDdmN2M0MjMxMDAzMyAtIDx1bmtub3duPgog\r +ICAyOiAgICAgMHg3ZjdjNDI2NTdjM2MgLSA8dW5rbm93bj4KICAgMzogICAgIDB4N2Y3YzQy\r +MjcxYzQ4IC0gPHVua25vd24+CiAgIDQ6ICAgICAweDdmN2M0Mjk3YzEyOCAtIDx1bmtub3du\r +PgogICA1OiAgICAgMHg3ZjdjNDI5N2JlN2UgLSA8dW5rbm93bj4KICAgNjogICAgIDB4N2Y3\r +YzQyOTkyM2Y2IC0gPHVua25vd24+CiAgIDc6ICAgICAweDdmN2M0MjMyNTI0ZCAtIDx1bmtu\r +b3duPgogICA4OiAgICAgMHg3ZjdjNDIzMjZhNjMgLSA8dW5rbm93bj4KICAgOTogICAgIDB4\r +N2Y3YzQyNWRmN2MxIC0gPHVua25vd24+CiAgMTA6ICAgICAweDdmN2M0MjNlM2Q5NCAtIDx1\r +bmtub3duPgogIDExOiAgICAgMHg3ZjdjNDIzZDFiN2EgLSA8dW5rbm93bj4KICAxMjogICAg\r +IDB4N2Y3YzQyM2QxMWY3IC0gPHVua25vd24+CiAgMTM6ICAgICAweDdmN2M0MjU3NGNmZiAt\r +IDx1bmtub3duPgogIDE0OiAgICAgMHg3ZjdjNDIzYzI0ODIgLSA8dW5rbm93bj4KICAxNTog\r +ICAgIDB4N2Y3YzQyM2JlZGQ4IC0gPHVua25vd24+CiAgMTY6ICAgICAweDdmN2M0MjUzODg3\r +MCAtIDx1bmtub3duPgogIDE3OiAgICAgMHg3ZjdjNDIyN2M5NmUgLSBkY19wZXJmb3JtX2lt\r +YXBfZmV0Y2gKICAxODogICAgIDB4N2Y3YzQyMjY0ZTIwIC0gPHVua25vd24+CiAgMTk6ICAg\r +ICAweDdmN2M1NWIxODQ2ZiAtIHN0YXJ0X3RocmVhZAogIDIwOiAgICAgMHg3ZjdjNTFjMTkz\r +ZDMgLSBjbG9uZQogIDIxOiAgICAgICAgICAgICAgICAweDAgLSA8dW5rbm93bj4KJycnCg==\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset="us-ascii"\r +MIME-Version: 1.0\r +Content-Transfer-Encoding: 7bit\r +Content-Disposition: inline\r +\r +_______________________________________________\r +coi-dev mailing list -- mailinglistt@mailman.org\r +To unsubscribe send an email to coi-dev-leave@mailman.org\r +\r +--------------86BEE1CE827E0503C696F61E--\r +\r +'''; + final message = MimeMessage.parseFromText(body); + var attachments = message.findContentInfo(); + expect(attachments, isNotEmpty); + expect(attachments.length, 1); + expect( + attachments[0].contentDisposition?.filename, + 'report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml', + ); + expect(attachments[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + + attachments = + message.findContentInfo(disposition: ContentDisposition.attachment); + expect(attachments, isNotEmpty); + expect(attachments.length, 1); + expect( + attachments[0].contentDisposition?.filename, + 'report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml', + ); + expect(attachments[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + + final inlineAttachments = + message.findContentInfo(disposition: ContentDisposition.inline); + expect(inlineAttachments, isNotEmpty); + expect(inlineAttachments.length, 1); + expect( + inlineAttachments[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + }); + + test('listContentInfo() 2', () { + const body = ''' +From: MoMercury \r +To: "coi-dev Chat Developers (ML)" \r +Message-ID: <3971e9bf-268f-47d0-5978-b2b44ebcf470@domain.com>\r +Date: Sat, 21 Mar 2020 09:36:29 +0100\r +MIME-Version: 1.0\r +Content-Type: multipart/mixed;\r + boundary="------------86BEE1CE827E0503C696F61E"\r +\r +This is a multi-part message in MIME format.\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=utf-8\r +Content-Transfer-Encoding: 7bit\r +\r +hello world\r +\r +\r +\r +\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset=UTF-8;\r + name="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r + filename="report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml"\r +\r +bmFtZSA9ICdkZWx0YWNoYXRfZmZpJwpvcGVyYXRpbmdfc3lzdGVtID0gJ3VuaXg6QXJjaCcK\r +Y3JhdGVfdmVyc2lvbiA9ICcxLjI3LjAnCmV4cGxhbmF0aW9uID0gJycnClBhbmljIG9jY3Vy\r +ZDMgLSBjbG9uZQogIDIxOiAgICAgICAgICAgICAgICAweDAgLSA8dW5rbm93bj4KJycnCg==\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: image/jpg; \r + name="hello.jpg"\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r + filename="hello.jpg"\r +\r +bmFtZSA9ICdkZWx0YWNoYXRfZmZpJwpvcGVyYXRpbmdfc3lzdGVtID0gJ3VuaXg6QXJjaCcK\r +Y3JhdGVfdmVyc2lvbiA9ICcxLjI3LjAnCmV4cGxhbmF0aW9uID0gJycnClBhbmljIG9jY3Vy\r +ZDMgLSBjbG9uZQogIDIxOiAgICAgICAgICAgICAgICAweDAgLSA8dW5rbm93bj4KJycnCg==\r +--------------86BEE1CE827E0503C696F61E\r +Content-Type: text/plain; charset="us-ascii"\r +MIME-Version: 1.0\r +Content-Transfer-Encoding: 7bit\r +Content-Disposition: inline\r +\r +_______________________________________________\r +coi-dev mailing list -- mailinglistt@mailman.org\r +To unsubscribe send an email to coi-dev-leave@mailman.org\r +\r +--------------86BEE1CE827E0503C696F61E--\r +\r +'''; + final message = MimeMessage.parseFromText(body); + final attachments = message.findContentInfo(); + expect(attachments, isNotEmpty); + expect(attachments.length, 2); + expect( + attachments[0].contentDisposition?.filename, + 'report-ffb73289-e5ba-4b13-aa8a-57ef5eede8d9.toml', + ); + expect(attachments[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + + expect(attachments[1].contentDisposition?.filename, 'hello.jpg'); + expect(attachments[1].contentType?.mediaType.sub, MediaSubtype.imageJpeg); + }); + + test('apple message with image attachment', () { + const body = '''Return-Path: \r +X-Original-To: xxx@xxx.com\r +Delivered-To: to@xxx.com\r +Received: from mail-pf1-f179.google.com (mail-pf1-f179.xxx.com\r + [219.185.20.19]) by mail.mymailer.com (Postfix) with ESMTP id 94EDE2B233\r + for ; Tue, 13 Apr 2021 08:59:27 +0000 (UTC)\r +Received: by mail-pf1-f179.xxx.com with SMTP id i190so10995473pfc.12\r + for ; Tue, 13 Apr 2021 01:59:27 -0700 (PDT)\r +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r + d=gmail.com; s=20161025;\r + h=content-transfer-encoding:from:mime-version:date:subject:message-id\r + :to;\r + bh=gS2crawKAAJIL0lhrGgpMH/+SncTCQgHD0EzFvQziUs=;\r + b=Kjk9RU9CBBlIdBXA8kIXuJhe7XHyy96OXnwSQ8m5dDhUKIlTiJjBFwI58TQFvRO6S7\r + n/+PPZoo1MmQ4R7ZFyUjiFrxRGZTieORltFnFdR+DMND2bu/0ZHedwDmrySb3Ntn8n8K\r + UphR0KkvV1Bg3aHmUnX8oV/Okyj5fRvUE9X/u69eJ2jVTIOQeMLlFPdfu7WbXFG7L334\r + Aa15cDZuVzPweLbRzTxHLZnWob0eIgvw83lwDJKEn1eyprpGbcb3yLEkTvfjdQprHall\r + arRD1xoZxNs1v17IWlsYuERKLjiB4xQ+6gWDvn+YR3F8q9Ltq2M22WsNdN7iho1WUpAM\r + NjyA==\r +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r + d=1e100.net; s=20161025;\r + h=x-gm-message-state:content-transfer-encoding:from:mime-version:date\r + :subject:message-id:to;\r + bh=gS2crawKAAJIL0lhrGgpMH/+SncTCQgHD0EzFvQziUs=;\r + b=INzi5r2oA2ljYMtOdL8GogLkrTgp6VGZwTvCA+0ede5Teikmq57hxr7+Eo+H1ifjp8\r + Y8tPb667T1BziUKGxxrWtF+9+OA9jcY3dIBjvjv7LuR05MEPZmlbZxZNZ/25mExzp9tS\r + s/IXg6B0c8wwZHcXdUt2f9gXRPAR93AhgH4dWt/q5wHESbD2yAYYxcG4oiJvuQlxZWMp\r + Wfly3iQY48rxUHv5iEB3e351M1uuT6wJoBW62cpig1qtOXKTwasuOF0IJoIWjBHHR0L9\r + Nk2qnD3Lx5CnJRoEtRIyTTQJZbW+goOqUaMl+6qNhWPu31eiJSiH/XrFf0tgr5Rg07zc\r + xb8w==\r +X-Gm-Message-State: AOAM533WF/dIyilsto6awfIZI4pSOsaOUI05xiWlBMezEBKxnsee0PRl\r + JyqpM4m/piAADkfoVfII+PkQV2EA4YmC+Q==\r +X-Google-Smtp-Source: =?utf-8?q?ABdhPJw5iY9314ApYnp58r1WeDkp/tws+5AnSGbNrSCY?=\r + =?utf-8?q?zokPQvxrEEXt900NQk/Ri0+LFXv2IEywDw=3D=3D?=\r +X-Received: by 2002:a63:342:: with SMTP id 63mr3052e000pgd.151.1618304344742;\r + Tue, 13 Apr 2021 01:59:26 -0700 (PDT)\r +Received: from [192.168.0.197] ([103.72.10.65]) by smtp.gmail.com with\r + ESMTPSA id r22sm14923402pgu.81.2021.04.13.01.59.25 for\r + (version=TLS1_3\r + cipher=TLS_AES_128_GCM_SHA256 bits=128/128); Tue, 13 Apr 2021 01:59:25\r + -0700 (PDT)\r +Content-Type: multipart/mixed; + boundary="Apple-Mail-96411171-B27C-4257-BB83-14E1DC508502"\r +Content-Transfer-Encoding: 7bit\r +From: Manoj Subberwal \r +Mime-Version: 1.0 (1.0)\r +Date: Tue, 13 Apr 2021 14:29:21 +0530\r +Subject: Inline image \r +Message-Id: <77881527-9B7E-4FF1-92A1-A731762A7AD8@xxx.com>\r +To: xxx@xxx.com\r +X-Mailer: iPhone Mail (18D70)\r +\r +\r +--Apple-Mail-96411171-B27C-4257-BB83-14E1DC508502\r +Content-Type: image/jpeg;\r + name=image0.jpeg;\r + x-apple-part-url=7CAB7826-682D-4FC4-9060-B59F36A45035-L0-001\r +Content-Disposition: inline;\r + filename=image0.jpeg\r +Content-Transfer-Encoding: base64\r +\r +/9j/2wCEAAEBAQEBAQIBAQIDAgICAwQDAwMDBAUEBAQEBAUGBQUFBQUFBgYGBgYGBgYHBwcHBwcI\r +CAgICAkJCQkJCQkJCQkBAQEBAgICBAICBAkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJ\r +CQkJCQkJCQkJCQkJCQkJCQkJCQkJCf/dAAQADf/AABEIACYAxgMBIgACEQEDEQH/xAGiAAABBQEB\r +AQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQci\r +cRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpj\r +ZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfI\r +ycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcI\r +CQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEK\r +FiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SF\r +hoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo\r +6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP7+K/Gn/got/wAFy/2Ff+CbU58J/FbWJ/EHjBo/MHh7\r +QgtxeIOceexYRW+dvAkZa9V/4K4ftpXP/BPr9gDxv+0H4dUPrllbx6fo6n5v9OuyIYHAx8wiz5jL\r +3CGv4R/+Dcn4KfC/9vH/AIKC/EDXP2ytGj+Idy3hm71d21fdLvvWu7ZDM+7/AFhKSODu4z60Afr3\r +ff8AB598LUvjHYfBDUZoOiM2tQIT+HkkVUX/AIPPfh+4zH8Cb5gOuNbjOPyta+MfEn/BXb/g378O\r ++J7nw5f/ALHd601hdSW7GOPTwhkg/c8DzRnI6YFej/tJ/AT/AIJef8FE/wDgkH8Rf+Cgf7I/wjn+\r +D+r/AA6uZIraQKlsbp7fyllRhE8kMsBSXyxJjPnr7UAfR4/4PPPAZ6fAjUf/AAdR/wDyNVeT/g9E\r ++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\r +tRv9qjooKP/Z\r +--Apple-Mail-96411171-B27C-4257-BB83-14E1DC508502\r +Content-Type: text/plain;\r + charset=us-ascii\r +Content-Transfer-Encoding: 7bit\r +\r +\r +\r +Sent from my iPhone\r +--Apple-Mail-96411171-B27C-4257-BB83-14E1DC508502--\r +'''; + final mime = MimeMessage.parseFromText(body); + expect(mime.parts, isNotNull); + expect(mime.parts?.length, 2); + expect(mime.mediaType.sub, MediaSubtype.multipartMixed); + final inlineInfos = + mime.findContentInfo(disposition: ContentDisposition.inline); + expect(inlineInfos.length, 1); + final info = inlineInfos.first; + expect(info.mediaType?.sub, MediaSubtype.imageJpeg); + expect(info.fileName, 'image0.jpeg'); + expect(info.fetchId, '1'); + + final part = mime.getPart(info.fetchId); + expect(part, isNotNull); + expect(part?.mediaType.sub, info.mediaType?.sub); + }); + }); + + group('RFC822 tests', () { + test('UTF8 message', () { + //origin: + const body = '''Return-Path: \r +Delivered-To: account@test.domain.mail\r +Date: Tue, 30 Mar 2021 09:54:40 +0200 (CEST)\r +From: "On behalf of: account@test.domain.mail" \r +Reply-To: account@test.domain.mail\r +To: account@test.domain.mail\r +Message-ID: <94B20AE4-F0A8-0CB9-8FEE-9984E7D4759C@domain.mail>\r +Subject: =?ISO-8859-1?Q?CERTIFIED:_Test_emai?=\r + =?ISO-8859-1?Q?l_with_unicode_characters_=E0=E8=F6?=\r +MIME-Version: 1.0\r +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-1; \r + boundary="----=_Part_5490272_1539179725.1617090882104"\r +Importance: normal\r +\r +------=_Part_5490272_1539179725.1617090882104\r +Content-Type: multipart/mixed; \r + boundary="----=_Part_5490270_1731033816.1617090882102"\r +\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: text/plain; charset=ISO-8859-1\r +Content-Transfer-Encoding: quoted-printable\r +\r +Server wrapper message.\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: message/rfc822; name=wrappedmsg.eml\r +Content-Disposition: attachment; filename=wrappedmsg.eml\r +\r +Message-ID: <94B20AE4-F0A8-0CB9-8FEE-9984E7D4759C@domain.mail>\r +MIME-Version: 1.0\r +X-VirusFound: false\r +X-Spam: Score=3.001\r +X-Virus-Scanned: amavisd-new at domain.mail\r +Date: Tue, 30 Mar 2021 09:54:40 +0200\r +From: account@test.domain.mail\r +To: account@test.domain.mail\r +Subject: =?UTF-8?Q?Test_email_with_unicode_characters_=C3=A0=C3=A8=C3=B6?=\r +X-Sender: account@test.domain.mail\r +User-Agent: Roundcube Webmail\r +Content-Type: multipart/alternative;\r + boundary="=_3f11875cceb7d049b4b157dbf88b4e65"\r +X-TransactionId: 5e2dfdf6-6020-4670-a902-4366ac9b5f5a\r +\r +--=_3f11875cceb7d049b4b157dbf88b4e65\r +Content-Transfer-Encoding: 8bit\r +Content-Type: text/plain; charset=UTF-8\r +\r +This pårt of the emäįl contains various accéntè characterś\r +\r +←←→→↑↓↑↓ⒶⒷ«SELECT»«START» \r +\r +List:\r +\r + * one\r + * zwei\r + * tróis\r + * quátro\r +\r +-- long dash\r +\r +enough_mail on GitHub [1] \r +\r +Links:\r +------\r +[1] https://github.com/enough_software/enough_mail\r +--=_3f11875cceb7d049b4b157dbf88b4e65\r +Content-Transfer-Encoding: quoted-printable\r +Content-Type: text/html; charset=UTF-8\r +\r +\r +

This pårt of the emä=C4=AFl contains various accént&eg=\r +rave; character=C5=9B

\r +

←←→→↑↓↑↓=E2=92=B6=E2=92=B7«=\r +;SELECT»«START»

\r +

List:

\r +
    \r +
  1. one
  2. \r +
  3. zwei
  4. \r +
  5. tróis
  6. \r +
  7. quátro
  8. \r +
\r +

— long dash

\r +

enough_mail on GitHu=\r +b

\r +\r +\r +\r +--=_3f11875cceb7d049b4b157dbf88b4e65--\r +\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: application/xml; name=data.xml\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment; filename=data.xml\r +\r +\r +------=_Part_5490270_1731033816.1617090882102--\r +\r +------=_Part_5490272_1539179725.1617090882104\r +Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment; filename="smime.p7s"\r +Content-Description: S/MIME Cryptographic Signature\r +\r +\r +------=_Part_5490272_1539179725.1617090882104--\r +'''; + + final mime = MimeMessage.parseFromText(body); + final messagePart = mime.getPart('1.2'); + expect(messagePart?.mediaType.sub, MediaSubtype.messageRfc822); + final embedded = messagePart?.decodeContentMessage(); + expect(embedded, isNotNull); + expect( + embedded?.decodeSubject(), + MailCodec.decodeHeader('=?UTF-8?Q?Test_email_with_unicode_characters' + '_=C3=A0=C3=A8=C3=B6?='), + ); + expect(embedded?.mediaType.sub, MediaSubtype.multipartAlternative); + expect( + embedded?.decodeTextPlainPart()?.substring( + 0, + 'This pårt of the emäįl contains various accéntè characterś' + .length, + ), + 'This pårt of the emäįl contains various accéntè characterś', + ); + // print(embedded.decodeTextHtmlPart()); + expect( + embedded + ?.decodeTextHtmlPart() + ?.contains('This pårt of the emäįl contains various ' + 'accéntè characterś'), + isTrue, + ); + }); + + test('cp-1253 message', () { + //origin: + const body1 = '''Return-Path: \r +Delivered-To: account@test.domain.mail\r +Date: Tue, 30 Mar 2021 09:54:40 +0200 (CEST)\r +From: "On behalf of: account@test.domain.mail" \r +Reply-To: account@test.domain.mail\r +To: account@test.domain.mail\r +Message-ID: <94B20AE4-F0A8-0CB9-8FEE-9984E7D4759C@domain.mail>\r +Subject: =?ISO-8859-1?Q?CERTIFIED:_Test_emai?=\r + =?ISO-8859-1?Q?l_with_unicode_characters_=E0=E8=F6?=\r +MIME-Version: 1.0\r +Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-1; \r + boundary="----=_Part_5490272_1539179725.1617090882104"\r +Importance: normal\r +\r +------=_Part_5490272_1539179725.1617090882104\r +Content-Type: multipart/mixed; \r + boundary="----=_Part_5490270_1731033816.1617090882102"\r +\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: text/plain; charset=ISO-8859-1\r +Content-Transfer-Encoding: quoted-printable\r +\r +Server wrapper message.\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: message/rfc822; name=wrappedmsg.eml\r +Content-Disposition: attachment; filename=wrappedmsg.eml\r +\r +Message-ID: <94B20AE4-F0A8-0CB9-8FEE-9984E7D4759C@domain.mail>\r +MIME-Version: 1.0\r +X-VirusFound: false\r +X-Spam: Score=3.001\r +X-Virus-Scanned: amavisd-new at domain.mail\r +Date: Tue, 30 Mar 2021 09:54:40 +0200\r +From: account@test.domain.mail\r +To: account@test.domain.mail\r +Subject: =?UTF-8?Q?Test_email_with_unicode_characters_=C3=A0=C3=A8=C3=B6?=\r +X-Sender: account@test.domain.mail\r +User-Agent: Roundcube Webmail\r +Content-Type: multipart/alternative;\r + boundary="=_3f11875cceb7d049b4b157dbf88b4e65"\r +X-TransactionId: 5e2dfdf6-6020-4670-a902-4366ac9b5f5a\r +\r +--=_3f11875cceb7d049b4b157dbf88b4e65\r +Content-Transfer-Encoding: 8bit\r +Content-Type: text/plain; charset="cp-1253"\r +\r +'''; + const body2 = '''\r +--=_3f11875cceb7d049b4b157dbf88b4e65\r +Content-Transfer-Encoding: 8bit\r +Content-Type: text/html; charset=cp-1253\r +\r +\r +

'''; + const body3 = '''

\r +\r +\r +--=_3f11875cceb7d049b4b157dbf88b4e65--\r +\r +------=_Part_5490270_1731033816.1617090882102\r +Content-Type: application/xml; name=data.xml\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment; filename=data.xml\r +\r +\r +------=_Part_5490270_1731033816.1617090882102--\r +\r +------=_Part_5490272_1539179725.1617090882104\r +Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment; filename="smime.p7s"\r +Content-Description: S/MIME Cryptographic Signature\r +\r +\r +------=_Part_5490272_1539179725.1617090882104--\r +'''; + final bytes = + const Windows1253Encoder().convert('Χαίρομαι που σας γνωρίζω'); + final builder = BytesBuilder() + ..add(utf8.encode(body1)) + ..add(bytes) + ..add(utf8.encode(body2)) + ..add(bytes) + ..add(utf8.encode(body3)); + final messageBytes = builder.toBytes(); + + final mime = MimeMessage.parseFromData(messageBytes); + final messagePart = mime.getPart('1.2'); + expect(messagePart?.mediaType.sub, MediaSubtype.messageRfc822); + // print(messagePart.mimeData); + // print('\n-------------------\n'); + // print(messagePart.decodeContentText()); + final embedded = messagePart?.decodeContentMessage(); + expect(embedded, isNotNull); + expect( + embedded?.decodeSubject(), + MailCodec.decodeHeader('=?UTF-8?Q?Test_email_with_unicode_characters' + '_=C3=A0=C3=A8=C3=B6?='), + ); + expect(embedded?.mediaType.sub, MediaSubtype.multipartAlternative); + // print(embedded.decodeTextPlainPart()); + expect(embedded?.decodeTextPlainPart(), 'Χαίρομαι που σας γνωρίζω\r\n'); + // print(embedded.decodeTextHtmlPart()); + expect( + embedded?.decodeTextHtmlPart()?.contains('Χαίρομαι που σας γνωρίζω'), + isTrue, + ); + }); + }); +} diff --git a/packages/enough_mail/test/mock_socket.dart b/packages/enough_mail/test/mock_socket.dart new file mode 100644 index 0000000..e1fe5d8 --- /dev/null +++ b/packages/enough_mail/test/mock_socket.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +class MockConnection { + MockConnection() + : socketClient = MockSocket(), + socketServer = MockSocket() { + socketClient._other = socketServer; + socketServer._other = socketClient; + } + + final MockSocket socketClient; + final MockSocket socketServer; +} + +class MockSocket implements Socket { + MockSocket? _other; + late MockStreamSubscription _subscription; + final Utf8Encoder _encoder = const Utf8Encoder(); + static const String _crlf = '\r\n'; + + @override + late Encoding encoding; + + @override + void add(List data) { + Timer.run(() => _other?._subscription.handleData?.call(data as Uint8List)); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + print(error); + } + + @override + Future addStream(Stream> stream) { + throw UnimplementedError(); + } + + @override + InternetAddress get address => throw UnimplementedError(); + + @override + Future any(bool Function(Uint8List element) test) { + throw UnimplementedError(); + } + + @override + Stream asBroadcastStream({ + void Function(StreamSubscription subscription)? onListen, + void Function(StreamSubscription subscription)? onCancel, + }) { + throw UnimplementedError(); + } + + @override + Stream asyncMap(FutureOr Function(Uint8List event) convert) { + throw UnimplementedError(); + } + + @override + Stream cast() { + throw UnimplementedError(); + } + + @override + Future close() { + _subscription.handleDone?.call(); + + return Future.value(); + } + + @override + Future contains(Object? needle) { + throw UnimplementedError(); + } + + @override + void destroy() { + print('mock socket destroyed'); + } + + @override + Stream distinct([ + bool Function(Uint8List previous, Uint8List next)? equals, + ]) { + throw UnimplementedError(); + } + + @override + Future get done => throw UnimplementedError(); + + @override + Future drain([E? futureValue]) { + throw UnimplementedError(); + } + + @override + Future elementAt(int index) { + throw UnimplementedError(); + } + + @override + Future every(bool Function(Uint8List element) test) { + throw UnimplementedError(); + } + + @override + Stream expand(Iterable Function(Uint8List element) convert) { + throw UnimplementedError(); + } + + @override + Future get first => throw UnimplementedError(); + + @override + Future firstWhere( + bool Function(Uint8List element) test, { + Uint8List Function()? orElse, + }) { + throw UnimplementedError(); + } + + @override + Future flush() => Future.value(); + + @override + Future fold( + S initialValue, + S Function(S previous, Uint8List element) combine, + ) { + throw UnimplementedError(); + } + + @override + Future forEach(void Function(Uint8List element) action) { + throw UnimplementedError(); + } + + @override + Uint8List getRawOption(RawSocketOption option) { + throw UnimplementedError(); + } + + @override + Stream handleError( + Function onError, { + bool Function(dynamic)? test, + }) { + throw UnimplementedError(); + } + + @override + bool get isBroadcast => throw UnimplementedError(); + + @override + Future get isEmpty => throw UnimplementedError(); + + @override + Future join([String separator = '']) { + throw UnimplementedError(); + } + + @override + Future get last => throw UnimplementedError(); + + @override + Future lastWhere( + bool Function(Uint8List element) test, { + Uint8List Function()? orElse, + }) { + throw UnimplementedError(); + } + + @override + Future get length => throw UnimplementedError(); + + void onErrorImpl(dynamic error) { + print('ON SOCKET ERROR'); + } + + void onDoneImpl() { + print('ON SOCKET DONE'); + } + + @override + StreamSubscription listen( + void Function(Uint8List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + onError ??= onErrorImpl; + onDone ??= onDoneImpl; + final subscription = MockStreamSubscription(onData, onError, onDone); + _subscription = subscription; + + return subscription; + } + + @override + Stream map(S Function(Uint8List event) convert) { + throw UnimplementedError(); + } + + @override + Future pipe(StreamConsumer streamConsumer) { + throw UnimplementedError(); + } + + @override + int get port => throw UnimplementedError(); + + @override + Future reduce( + Uint8List Function(Uint8List previous, Uint8List element) combine, + ) { + throw UnimplementedError(); + } + + @override + InternetAddress get remoteAddress => throw UnimplementedError(); + + @override + int get remotePort => throw UnimplementedError(); + + @override + bool setOption(SocketOption option, bool enabled) { + throw UnimplementedError(); + } + + @override + void setRawOption(RawSocketOption option) { + print('setRawOption $option'); + } + + @override + Future get single => throw UnimplementedError(); + + @override + Future singleWhere( + bool Function(Uint8List element) test, { + Uint8List Function()? orElse, + }) { + throw UnimplementedError(); + } + + @override + Stream skip(int count) { + throw UnimplementedError(); + } + + @override + Stream skipWhile(bool Function(Uint8List element) test) { + throw UnimplementedError(); + } + + @override + Stream take(int count) { + throw UnimplementedError(); + } + + @override + Stream takeWhile(bool Function(Uint8List element) test) { + throw UnimplementedError(); + } + + @override + Stream timeout( + Duration timeLimit, { + void Function(EventSink sink)? onTimeout, + }) { + throw UnimplementedError(); + } + + @override + Future> toList() { + throw UnimplementedError(); + } + + @override + Future> toSet() { + throw UnimplementedError(); + } + + @override + Stream transform(StreamTransformer streamTransformer) { + throw UnimplementedError(); + } + + @override + Stream where(bool Function(Uint8List event) test) { + throw UnimplementedError(); + } + + @override + void write(Object? obj) { + final text = obj.toString(); + final data = _encoder.convert(text); + add(data); + //print('socket: [$text]'); + // make the socket asynchronous. + } + + @override + void writeAll(Iterable objects, [String separator = '']) { + print('writeAll [$objects]'); + } + + @override + void writeCharCode(int charCode) { + print('writeCharCode [$charCode]'); + } + + @override + void writeln([Object? obj = '']) { + //print('writeln [$obj]'); + write(obj.toString() + _crlf); + } + + @override + Stream asyncExpand(Stream? Function(Uint8List event) convert) { + throw UnimplementedError(); + } +} + +class MockStreamSubscription implements StreamSubscription { + MockStreamSubscription(this.handleData, this.handleError, this.handleDone); + void Function(Uint8List data)? handleData; + Function? handleError; + void Function()? handleDone; + + @override + Future asFuture([E? futureValue]) { + throw UnimplementedError(); + } + + @override + Future cancel() => Future.value(); + + @override + bool get isPaused => throw UnimplementedError(); + + @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]) { + throw UnimplementedError(); + } + + @override + void resume() { + throw UnimplementedError(); + } +} diff --git a/packages/enough_mail/test/pop/mock_pop_server.dart b/packages/enough_mail/test/pop/mock_pop_server.dart new file mode 100644 index 0000000..fc01393 --- /dev/null +++ b/packages/enough_mail/test/pop/mock_pop_server.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +// cSpell:disable + +class MockPopServer { + // ignore: avoid_unused_constructor_parameters + MockPopServer(this._socket) { + _socket.listen( + (data) { + onRequest(String.fromCharCodes(data)); + }, + onDone: () { + print('server connection done'); + }, + onError: (error) { + print('server error: $error'); + }, + ); + } + + String? nextResponse; + List? nextResponses; + final Socket _socket; + + void onRequest(String request) { + final response = nextResponse ?? + ((nextResponses?.isNotEmpty ?? false) + ? nextResponses?.removeAt(0) + : '-ERR no reponse defined'); + writeln(response); + nextResponse = null; + } + + void writeln(String? response) { + _socket.writeln(response); + } +} diff --git a/packages/enough_mail/test/pop/pop_client_test.dart b/packages/enough_mail/test/pop/pop_client_test.dart new file mode 100644 index 0000000..2c66e70 --- /dev/null +++ b/packages/enough_mail/test/pop/pop_client_test.dart @@ -0,0 +1,194 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:test/test.dart'; + +import '../mock_socket.dart'; +import 'mock_pop_server.dart'; +// cSpell:disable + +late PopClient client; +bool _isLogEnabled = false; +late MockPopServer _mockServer; + +void main() { + setUp(() async { + _log('setting up SmtpClient tests'); + final envVars = Platform.environment; + _isLogEnabled = envVars['SMTP_LOG'] == 'true'; + + client = PopClient( + logName: 'enough.de', + bus: EventBus(sync: true), + isLogEnabled: _isLogEnabled, + ); + + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: + const ConnectionInfo('pop.enough.de', 995, isSecure: true), + ); + _mockServer = MockPopServer(connection.socketServer); + _mockServer.writeln('+OK ready <1896.697170952@dbc.mtview.ca.us>'); + + /// allow server greeting to arrive + await Future.delayed(const Duration(milliseconds: 200)); + + _log('PopClient test setup complete'); + }); + + test('PopClient.status()', () async { + _mockServer.nextResponse = '+OK 2 320'; + final response = await client.status(); + expect(response.numberOfMessages, 2); + expect(response.totalSizeInBytes, 320); + }); + + test('PopClient.list()', () async { + _mockServer.nextResponse = '+OK 2 320\r\n\1 120\r\n2 200\r\n.\r\n'; + final response = await client.list(); + expect(response.length, 2); + expect(response.first.id, 1); + expect(response.first.sizeInBytes, 120); + expect(response.last.id, 2); + expect(response.last.sizeInBytes, 200); + }); + + test('PopClient.list(2)', () async { + _mockServer.nextResponse = '+OK 2 200'; + final response = await client.list(2); + expect(response.length, 1); + expect(response.first.id, 2); + expect(response.first.sizeInBytes, 200); + }); + + test('PopClient.list(3) fails', () async { + _mockServer.nextResponse = '-ERR invalid ID'; + try { + await client.list(3); + fail('invalid list(3) should throw PopException'); + } on PopException catch (_) { + // expected + } + }); + + test('PopClient.uidList()', () async { + _mockServer.nextResponse = + '+OK unique-id listing follows\r\n\1 XSLKDSL\r\n2 QhdPYR:00WBw1Ph7x7\r\n.\r\n'; + final response = await client.uidList(); + expect(response.length, 2); + expect(response.first.id, 1); + expect(response.first.uid, 'XSLKDSL'); + expect(response.last.id, 2); + expect(response.last.uid, 'QhdPYR:00WBw1Ph7x7'); + }); + + test('PopClient.uidList(2)', () async { + _mockServer.nextResponse = '+OK 2 QhdPYR:00WBw1Ph7x7'; + final response = await client.uidList(2); + expect(response.length, 1); + expect(response.first.id, 2); + expect(response.first.uid, 'QhdPYR:00WBw1Ph7x7'); + }); + + test('PopClient.uidList(3) fails', () async { + _mockServer.nextResponse = '-ERR invalid ID'; + try { + await client.uidList(3); + fail('invalid uidList(3) should throw PopException'); + } on PopException catch (_) { + // expected + } + }); + + test('PopClient.login() valid', () async { + _mockServer.nextResponses = ['+OK Please enter a password', '+OK welcome']; + await client.login('name', 'password'); + }); + + test('PopClient.login() invalid', () async { + _mockServer.nextResponses = [ + '+OK Please enter a password', + '-ERR password wrong', + ]; + try { + await client.login('name', 'password'); + fail('invalid login should throw PopException'); + } on PopException catch (_) { + // expected + } + }); + + test('PopClient.apop() valid', () async { + expect(client.serverInfo.timestamp, '<1896.697170952@dbc.mtview.ca.us>'); + _mockServer.nextResponse = '+OK welcome'; + await client.loginWithApop('name', 'password'); + }); + + test('PopClient.retrieve() simple message', () async { + const from = + MailAddress('Rita Levi-Montalcini', 'Rita.Levi-Montalcini@domain.com'); + final to = [ + const MailAddress('Rosalind Franklin', 'Rosalind.Franklin@domain.com'), + ]; + final expectedMessage = MessageBuilder.buildSimpleTextMessage( + from, + to, + 'Today as well.\r\nOne more time:\r\nHello from enough_mail!', + subject: 'enough_mail hello', + ); + _mockServer.nextResponse = + '+OK some bytes follow\r\n$expectedMessage\r\n.\r\n'; + + final message = await client.retrieve(120); + expect(message.decodeSubject(), 'enough_mail hello'); + expect(message.from?.length, 1); + expect(message.from?.first.personalName, 'Rita Levi-Montalcini'); + expect(message.from?.first.email, 'Rita.Levi-Montalcini@domain.com'); + expect(message.to?.length, 1); + expect(message.to?.first.personalName, 'Rosalind Franklin'); + expect(message.to?.first.email, 'Rosalind.Franklin@domain.com'); + }); + + test('PopClient.delete() success', () async { + _mockServer.nextResponse = '+OK message deleted'; + await client.delete(2); + }); + + test('PopClient.delete() failed', () async { + _mockServer.nextResponse = '-ERR unknown message ID'; + try { + await client.delete(2); + fail('invalid login should throw PopException'); + } on PopException catch (_) { + // expected + } + }); + + test('PopClient.noop()', () async { + _mockServer.nextResponse = '+OK I am alive'; + await client.noop(); + }); + + test('PopClient.reset()', () async { + _mockServer.nextResponse = + '+OK all messages marked as deleted are restored'; + await client.reset(); + }); + + test('PopClient.quit()', () async { + _mockServer.nextResponse = '+OK bye'; + await client.quit(); + }); +} + +void _log(String text) { + if (_isLogEnabled) { + print(text); + } +} diff --git a/packages/enough_mail/test/smtp/mock_smtp_server.dart b/packages/enough_mail/test/smtp/mock_smtp_server.dart new file mode 100644 index 0000000..2823ed0 --- /dev/null +++ b/packages/enough_mail/test/smtp/mock_smtp_server.dart @@ -0,0 +1,86 @@ +import 'dart:io'; +// cSpell:disable + +enum _MailSendState { notStarted, rcptTo, data, bdat } + +class MockSmtpServer { + // ignore: avoid_unused_constructor_parameters + MockSmtpServer(this._socket, String? userName, String? userPassword) { + _socket.listen( + (data) { + onRequest(String.fromCharCodes(data)); + }, + onDone: () { + print('server connection done'); + }, + onError: (error) { + print('server error: $error'); + }, + ); + } + + String? nextResponse; + final Socket _socket; + + _MailSendState _sendState = _MailSendState.notStarted; + + void onRequest(String request) { + // check for supported request: + // print('onMockRequest "$request"'); + final nextResponse = this.nextResponse; + 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); + this.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 if (request.startsWith('BDAT')) { + if (request.contains('LAST\r\n')) { + _sendState = _MailSendState.notStarted; + writeln('250 2.0.0 Ok: queued as 66BF93C0360'); + } else { + _sendState = _MailSendState.bdat; + writeln('354 continue'); + } + } else { + writeln('250 2.1.5 Ok'); + } + } else if (_sendState == _MailSendState.data) { + if (request.endsWith('\r\n.\r\n')) { + _sendState = _MailSendState.notStarted; + writeln('250 2.0.0 Ok: queued as 66BF93C0360'); + } else { + writeln('354 continue'); + } + } else if (_sendState == _MailSendState.bdat) { + if (request.contains('LAST\r\n')) { + _sendState = _MailSendState.notStarted; + writeln('250 2.0.0 Ok: queued as 66BF93C0360'); + } else { + writeln('354 continue'); + } + } + } + + void writeln(String? response) { + _socket.writeln(response); + } +} diff --git a/packages/enough_mail/test/smtp/smtp_client_test.dart b/packages/enough_mail/test/smtp/smtp_client_test.dart new file mode 100644 index 0000000..b972e9e --- /dev/null +++ b/packages/enough_mail/test/smtp/smtp_client_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/smtp_command.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:test/test.dart'; + +import '../mock_socket.dart'; +import 'mock_smtp_server.dart'; +// cSpell:disable + +late SmtpClient client; +bool _isLogEnabled = false; +late String _smtpUser; +late String _smtpPassword; +late MockSmtpServer _mockServer; + +void main() { + setUp(() async { + _log('setting up SmtpClient tests'); + final envVars = Platform.environment; + _isLogEnabled = envVars['SMTP_LOG'] == 'true'; + + client = SmtpClient( + 'enough.de', + bus: EventBus(sync: true), + isLogEnabled: _isLogEnabled, + ); + + _smtpUser = 'testuser'; + _smtpPassword = 'testpassword'; + final connection = MockConnection(); + client.connect( + connection.socketClient, + connectionInformation: + const ConnectionInfo('dummy.domain.com', 587, isSecure: true), + ); + _mockServer = + MockSmtpServer(connection.socketServer, _smtpUser, _smtpPassword); + _mockServer.writeln('220 domain.com ESMTP Postfix'); + + // capResponse = await client.login("testuser", "testpassword"); + + _log('SmtpClient test setup complete'); + }); + + test('SmtpClient EHLO', () async { + _mockServer.nextResponse = '250-domain.com Hello\r\n' + '250-PIPELINING\r\n' + '250-SIZE 200000000\r\n' + '250-ETRN\r\n' + '250-AUTH PLAIN LOGIN XOAUTH2\r\n' + '250-AUTH=PLAIN LOGIN XOAUTH2\r\n' + '250-ENHANCEDSTATUSCODES\r\n' + '250-8BITMIME\r\n' + '250 DSN'; + final response = await client.ehlo(); + expect(response.type, SmtpResponseType.success); + expect(response.code, 250); + expect(client.serverInfo.supports8BitMime, isTrue); + expect(client.serverInfo.supportsAuth(AuthMechanism.plain), isTrue); + expect(client.serverInfo.supportsAuth(AuthMechanism.login), isTrue); + expect(client.serverInfo.supportsAuth(AuthMechanism.xoauth2), isTrue); + expect(client.serverInfo.maxMessageSize, 200000000); + expect(client.serverInfo.supports('PIPELINING'), isTrue); + expect(client.serverInfo.supports('DSN'), isTrue); + expect(client.serverInfo.supports('NOTTHERE'), isFalse); + }); + + test('SmtpClient login', () async { + _mockServer.nextResponse = '235 2.7.0 Authentication successful'; + final response = await client.authenticate(_smtpUser, _smtpPassword); + expect(response.type, SmtpResponseType.success); + expect(response.code, 235); + }); + + test('SmtpClient sendMessage', () async { + const from = + MailAddress('Rita Levi-Montalcini', 'Rita.Levi-Montalcini@domain.com'); + const to = [ + MailAddress('Rosalind Franklin', 'Rosalind.Franklin@domain.com'), + ]; + final message = MessageBuilder.buildSimpleTextMessage( + from, + to, + 'Today as well.\r\nOne more time:\r\nHello from enough_mail!', + subject: 'enough_mail hello', + ); + final response = await client.sendMessage(message); + expect(response.type, SmtpResponseType.success); + expect(response.code, 250); + }); + + test('SmtpClient sendBdatMessage', () async { + const from = + MailAddress('Rita Levi-Montalcini', 'Rita.Levi-Montalcini@domain.com'); + const to = [ + MailAddress('Rosalind Franklin', 'Rosalind.Franklin@domain.com'), + ]; + final message = MessageBuilder.buildSimpleTextMessage( + from, + to, + 'Today as well.\r\nOne more time:\r\nHello from enough_mail!', + subject: 'enough_mail hello', + ); + final response = + await client.sendChunkedMessage(message, supportUnicode: false); + expect(response.type, SmtpResponseType.success); + expect(response.code, 250); + }); + + test('SmtpClient quit', () async { + final response = await client.quit(); + expect(response.type, SmtpResponseType.success); + expect(response.code, 221); + }); + + test('SmtpClient with exception', () async { + try { + final response = + await client.sendCommand(DummySmtpCommand('example', client)); + fail('sendCommand should throw. (but got: $response)'); + } catch (e) { + expect(e, isA()); + } + }); +} + +void _log(String text) { + if (_isLogEnabled) { + print(text); + } +} + +class DummySmtpCommand extends SmtpCommand { + DummySmtpCommand(String command, this.client) : super(command); + final SmtpClient client; + @override + String nextCommand(SmtpResponse response) { + // ignore: only_throw_errors + throw SmtpException(client, response); + } +} diff --git a/packages/enough_mail/test/smtp/testimage-large.jpg b/packages/enough_mail/test/smtp/testimage-large.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4d3bf274c0307519d761ef38da8e4ced297ffcdd GIT binary patch literal 872619 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≠G(Pkpqf~5z$Cp-P=bFiC9zsQs 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� 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

l!n%iX9>m;XY$* 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

tPnBKWeW;-2Tet4c zI#{w{ngVq#(HaQY6=I;C`Ohxk_We3BtXa|yl1N-Ku_cHn{kp?6M=N&Shj|H9B;|?@ z3O~_DVbZ{%8+K%pO){1|%PX!y!5JU#&|D~kZ1PA#E=zyXI%37NNUtis2|V-MAJeB0 z3|-W1u7=#M?Gm%#a$*#cpn8*!r%ECiGAmdA04Yi$1<$nq0Ix(WL1A%fE29|5^*I?G zv(pK^V6T+DcsGSw@;#M(btsDLtS}G(fQ&z@C5Xr0sacKGp;T4Ne~|cnuNK(0r)L%{ zLI=t3QN>6N?P16gd3NfMPZ|*=Mc+xE?yRdkp_SIUh6<;%cm28T*JMKl7!wO^oko(a zu|gW@1$g3W$Xu+g823Bm=aUok?e^&yl9cP#KOB#y+DA*fGhif=c&P4rbX@K> zk|yMgRk4D@)MxG2Fa~0=5xr_t$w>+mA28nL&KMtFCms5ks^tnzP9U{vq)!A=l#e-d zAGL@Ccl-5>p^enlVg*7xOvyIgqYa2;Rt%@?e%(?95JW*X2s1_Us(S4u*Vjq-8rOYZrwi@Z&^bd`APEUyiB6Lay`eIUGi9IL*#}@DQc;`O&4_stt7U^?*%ZTk-Oc zQlu^Mj!RL&$hXEPC2@u131ip;jC%A;^fVU*Qq5gQI*&Kn!4(?T8~l`1M6lA=TtPip zW1M^06VyL0{ZE&+2J1`f)=`VNBClHW+5@YtYkRpU&aewog_YE*B8NWJ0f+%}fCo)x z7}MbtsB%BXNwKeIRF9~?3PQq0c;}CcJMnG>WMwDVFnfLa#Bk@5nXZKg79Tle@ojV) z4cv>Z+Sl>bzfqw}d5}USVS`bNobs4{xawT(OYR(~_%f^t3mzwpj{{gIgm}K9@0LlS z@+-A9&`0y6*JqRaLP*r{Q7RmFZ$tIzoyJz+89Kz^#0EmI(p5LwnHlhteDfJuBUDp~ z5eZP}N3L<{>(bqx}pG`t;UYx{6aoUeUz+=%Be`Ep$kl*(|M?*4it|J~8c9 zbpYV~Li+TN`1S?;)mlgY0Lb?qKW*Nyxiq!yc<#r_Z0xF~rkqAJs^1*W(wvDAFhPwz zqoG$JhA=eqgvbhktVG^pWnEvEo;|{p?o`<;Y*DxB9N2Dw{jv{p4JMfFQE7!4ZtNar}0>^1tl0+@r zDp$U7+p4={2PGY3z1y(G$Qq9;czpY9DB4lE0e{K-TNUg~vC3s}$yzl%*+}(oq3h7g z+yrVP^qzhm)Z|dW>(=7B8y_cvmdi=1wK#^@Kjf)E-pL}bmL>lH>I>hj$Pkdt@`sC< z1xL;auj~G>@{;;ySV`ebjc=Znz+uPRfv9;R3YRg6nGc2_v zM?9pCX&2x7Fb59c^p(hRC@Y}W(G!4+xZm z!e=8Ru#PSB#GNl~G@rDVQtswEm*lOltW0%UMT{ygKv5?I_kA+VJM_Hk zNyJ$(xX9!Qpe5sD{Ef8Lf8c9(FD?%}a$0KAa}GCmE>2=)xXt+iD#Shr;#{<>V^N$a#sL2S zez2YZ4x4Kak0NyO)_U6ds^aBqO>&%5BFyO-g@T+CSC_PU;B~>t(26q|0PA9UPRmQ= zamiKY)lC$uElH;(C5=hUauNsI>Z7jqx@+#70Y=k9(nWt~X4cYOow<$6HjA4xSO!4O z-H8mumN+>F>DI0=7l_iJmn7NL%XXD^)@@Sp?KbW0R@)>?bw|${LhQjfSQ-A}p*hY7 z9Y>HVg6y?hZLFmW5)oSOee{BVCin)^!TgBoZ%GuAJVHq4hrDt|!}lR!pI*m3^V9oW z;~@A%=f%K}Sz50;#}r|aBuee_#&}?sIPC5_l^E4l5#}F6DpUGx;8KgJ(C&t zewgcqARg1_G&?fWblO(oP*@pJ<%vPaQbsfN&Isy~AY8Il*Z9nPn{;4%n`%Y?)Fx0W z1(}sgG~xN;H0}xd`t>_A@drgcdd6`=PhX_=_m6o6Z9N_JiKDXxgjgv2XXa&JaQMho zDuiJBPkeVB3gtrO2Axdi#{5HcgH58_Sc6wpK51p07{vsu!Z_5qz(-@mhV?$3`eNiz zSm_6YX}prJl5Vw8y?u4rRK0Uf%MhfZbL7#+-N;;zQP!La`mpP8MrK`&SgAhGU%ZYQ zb@mA^yIRmatz33VF|xiH%uE60@;_RPq>?pmuyQK^0nl`ooga-&=Q{e8tHT_7h>US4 z;A1jD8u9j(1RmsMk(1Opa-0l$sr}`c@r>#^5&K75{{Rk$!hCMcJ)K!4-&=HQ#4~V? zBphGl1xNJ|eR^Iz?VBLz1U#4q9Dn%}D|-!Jk58(Go~L^~d#_kcy{&zOH6Fx76iB$^ zo)n%r2dfd>8)dP2R{lRpAyldjMzy`XezKP|4;wEm`^Hg;&mo*=A5O0ZMQ8s2^2Q{xf~$qW$6jtnEZnh>Sb6@!dRUT{oKW=h^D?Ws ziPE#fMN-CtTW(mZ?NT6*BTP93k^4J)SFk;YRbo2X`^ef&lzV7upzjRdc_a?d+1Kjh zq#InC>uG7fCz}-n&kD)^0Bg7He(V1LFrP3x3ij9dm5VW1ttzbXvM}P| zWCyyC3Y;II9Z{i)6ZG;)1I(~a2xcXIFjY#o>GU6=>2+|gSUk~MjjPJ>yelGxhD;I2 z1CD5We^Bc*Hy8y86thb^%`K*B6xE`l1$8MSkMyN z<=zWh{{WAF@+98tUvReC4VIb!ArKyA5Wqp89u_F49 z=`(j}9#(k|BJ5eu+~>LN)yk$yOrF%09hg>$;g8#ho%!K__Z;DUdNmqRn0L7>)b7-+ z3r>Y%wUUT%7^uMq9-tid>y?2tI!r6pNRe(ecTsD|&|8rMM)qxD4+ceU}9CU@!->Khx>c!VS*xLCFwwwPGq0!m2(SC37KATPSnwW88kY z=*R?vK^q4B7Ek6T{#(1~Ik{D~j^rkLQgffybn z{{YXgFSP8&WxL~EKg_eZ?v5s{>Gg9irnoI-<%BZ4@`1@!nMV*Ak8z** zbO8e9<5yaE{v!hx##-v<$-YNW)4b5mtrX)wEG#fpv-JnQPhV5pw~P zuP(pDsK4X^MwQd~m0OiBSJ=L?^1ORG5||_ab7mQ-a{m4twDA#ea}cJw{_eR|uy zAb}ie^fmW8YC4GQK~l|$S!CGAU*ITI5ab0oRVqOsb(vq?gJKg3palbS2p%+_~yOz`iT)jQIY;; zR^kI5NB*P#01k@{flO?`%|zOY5toH{PP2VWXEw`bns30T`B2F^l9M+q2)hy8zo(>k zIf}5YW4nAofxRVY=~~H;iB#t1G-7ZD22U@qRNO@ypRL!o1Y)$j)XTuK6yh0I_A-(I z$NF>uR_t_yOI04QJF?EB5rR1TTn|o)!7yln2QJaXufdFOl`L1=$a;W&^-n4j_;I=P{A(^zHTOtm)Umix}VidJjbAB5Y#nB7ay)>MpuB3ci zN{@0bJ7=K;mo+qi6khO8BDtrHo~0+*i*H`rQK`L*q_CK5 zDK+E{IP)Nnq3$v|PHaDsKkX3hosMIfVb1k?gWn;@fAV4repSF8+ zD}et1v9S%akM$H{x%Lsm3ZxN(EJ#C20Sv*;a>0-5j@>lkCvy}sfwc2gPiJ_y!yCGC zNXz{L%5b5H^vUUOky0AtFCY0ghIt2w?IfYUM1uTF8<-MAk&JFRE3Y2jodRd&r9*5q zF6R|BvdgcVWl8>V7-iV0j{*d87B^>Z#JTk2>CrY#Lbje$7ap9KUpT^_jW>|ptFH0- zl1;J^h^#^xTum?pv%`#gaKB!X*_k)%v}nYww@u}Ld8)Ufy7VtZ)%FlEN#JpYL;nCt z2kX&e$;)UioM#rdn;EsRQ|Wi*i7-; zXx6oDihR(Idw9xgj>Rt{U=p^+VSwnd>@oU)CLfc1f+GB3jnc(UvIW zb&n(?u=hsU1Kg*i<7PXG)-}~e#`J>Jd747SSBX--;0^F)uPG3)vu0TdJ&z?`*zNS|K&_@+ zK|G9XWE^onPM8E)F`tpYg}09CMxwo>@*(nno0?_@yX5}>Z{QxIx35}++{UTzhj~8# z07Ca4QD9fMa=et^AzPeN3^ER2`u+OHVh)gzY)bo8wH1d`5f2u5C>_BaA0S5aIGc?% z@JCx=6t?45l2mRL-#*eu?jUr#Cqc9=wVKcIF6%+#8&hj}PR+NLGHNYyQc)?vMo#Jf z0Iy5R2{tx|i$PsXN95j3Z*z9U*4?GIE0z+-#h(C-1}Eygan%TD2$vQ}mHne7w_A6m zS#3y5dh&K)KtCs@J9>0Pjevjw+?{7vwHo7A;>ECrS*oOSp^HBgs}AYNl{hP`W}N8K*veB5UAEu6^L%#kKd|@lY=@^S-2mm_3H?o zEFUeSR=sg4F+@FzbA{#mxR2MV*+g=a0@+uNtWdH{9p=wCxR5{|hbOmww;3dmMCSki zVZIGUYa4ZF6tt*E06j_jf4@%MgkHeZvsj3wrcM}gSzQ=@)0}-iy;#`6)=Ieu(xUB( z%Cfi3CpmDuSP(}Xee>1Wfa9jq&dq$i(9a%uE<-M!)rnJ8EQ?}TAqaTL2OpS&--<4C zj*;9BKHxpxG?ezom#~w^#=j|H+0}mz-|Q&(PRV?Rg{^x*ZtZwO299y>jO0i%nDtzG z{SQ+b$e`r*>nR+uwPW`Rnc7yFE{apX;)Y<8EWoO+JK@*VW2*@jV_7%`=;;dHZMIt> z3QkAGuZ?QJgNmLbBphHCLF?DY?+oB?4tAbk}|&{ukG~dZFu7VBVI(wl<8Fp(0Lx)S0c%;j5W9zO&rBTvNAAbVVwTb zI!0^?+s9$f%e0Gqw%F|UGHf<(ENrejShVE~XXKFgQn*$6XRztGP$;3Wip<50o#$~$ zVjX)?Q5j1mWs|ZyDMNvV{m6dfJskphc|atA$Id_A`3?J$Yhu3?7oed5g=Lbsi{CH1 zGf25IaqWVBow}PAAORyw^_1nxc?%a_a1Cb1;ry#RO=LS&maD-5ot*@1*a<9rKdL-- z9T`AqDdVgGkc%uiM6FI(H=?#P+x>CW*@)$&ejq~YDt4!yUJB&2aRQL`>=z}m{{R!wB7wH%W3fLe zeewOyiM%E)TpDp-bqumKz1PS|o;Pfye;MpJefmDf6Mx7pe%jl|&-ID_0I!MgiuIpbl9$#!qgyfem+|h8%FJ);N}xjVF!kcGflHUA1PGST5UQ z;`Mn~iu_gCN?|#E{{a5SQZsXMw`=Ls=POj;p{kqr`F!Pn$@iOWnroK%u+Lr&H5$jK zMtG{t*kM9?aqpgz zao4p#;&+YbdXjl`f>uV@T@kvleDa=oq&=IDt2qZ8o1-nnMFq3I@#Sv_KbKCW>)|f# zpjoxc2}z@QW^OSgvWCQbzpIz0N^(bJs>oyRFUe75~L9`5n+^;lq!(LxE|Rc;eP#fk#seM zetS#ThIyZg)?ru7s7vG;$_mlxBbnGFF`h-E_|%Mij6wYmQfKY*6L`-OclG(l;m(^l zzb{Eq6or`YU8P)vg@mEjOf7ibn-P%R-{=AAtEvS50C_(4K+<)ny;oaW+Py@xV%$M1 zPR!`_oF8+i5(5%DckS0cGDy~d`pg+c9W4IS-S?2)-R%y>>`;k}X%h<3tSQDd;tvJw z%kF-?ZahH+3E5n^vm~u}>fPto-j+9=1QJSxp_LWDGXBGcL)~$n{b62TaF`Dh?Jz5P zg@8dqtP~unSeX#eTcc?Bny188R9>}hXqe1BJYtZFRE zi1MfaSdJ6UCL;v>axv2Jo@%bboA!;#>?J|5`}s+HcILK>lC9`wv8ueHDdtGPnN%s{ zu}E8p!xhebdK{`-W9}0%xLwAF#*;+wFBRVSho5ToyIZS93n?A?>>4`=izxQV#ByGI zewfMY&hGOrOniXW=*zogWXVU$J^ujkhSh2G_2^O5?3&dziMXyK%86N}$0bqNvCe;7 zbU*drenVRN!taUvV)XQsi9B+(N_q)3@LIK{+kx_2k`-5JkbmSrn9nsv?i;sO{!)2z zpvxmThqsQZ((n9>{8{7vJ>z<g^HGtd|>uWk3r~qpSSLvjcM-k zj_x~TUq5f2urCtwiXI=nk7-^7sP|o7g&Ab|O2S4ySC{MD9@z9f23355#-%#?{39{4 zW6Djp{M+Q)KOmAAHWgo8yQ>$=C3h;zV!YnyyQ^}Uji+}m53-VjrQ z=~EuFZj|X(K#d*P{kI*slp$CID45ASe|CL(_Eku~E|bS%4SDs3@9S%9Un%4N0F$zg z76NOb9Q>t*RIGT)1;GGg>C*AD0q>#d8<7DtmVIY5{GWAdU-7m!b3;bzr8{y-BOF%^ zkc?lK6;|)ttb7OuAi9l5#uhxU_Lk7;0I_3WA(b5xO;E5{vLF$B%$!%=pm*T8CDm)^*?ysAK2=C!@&RL1WSYTObY2k&m5t#s2^n-pk{=MUA!;Kt_74JND zH=qNg;m90v5v`1&qY+E5nyuHPwC7QwE%G5=Os~fvaR=+$t>t3Iu*joEL;hOH*TJtV z8LwVeoiiGiF4zP!aux1>acCMyemQmvyzd&v|6mnhK!RTS1Tr5ORtrR~?jw z_I)}8RZ;C4Ur0{O$KB;3d2LgTpIKr^>CT2Z8<1CZa08>Np6lDDS1P^4ST%LlbD+6W zp6ZRwg=D+0hGc6L<}9(x8lLg7T(AUvx;%^BUG_|Ls~DzuPgrmZYPtM#-#ceAad!8U~9Y=j(mT} zWbwVto3ccguA~xHtMdh#5L|E!Lu4>j{kT0VHXq2K4F$$w%fR07@8g8=+Z)8CnYhRXiq*vF&v~^oIT2BfT!G$qf+?whGO~hLciH{{ROsA(kr^(o~OMESXDK!T2+g zDNaB1datiuSN{OXng+BTdVaG10Qd`Ja@78_GV;oiR}(|42-@nT#9ha^`DYEqyNvp- zI`n&2<*R+hN8F%Y7wr!Lx|$T)6=6@ zIPwDHea&_A{?K@HLKNxHPoB5COIrwIOR@ZuSI-&9#H@|qF5a*1`jgwQqrjnc9cPP` z$@Sj>5e%s7zo@*X+0k!mtW-L$Z05w%vQ~+(7t8hL-Srt-@hJ* zKD}YY3UNDCP5gv6A@PiS^Utw*9%PAo~e zRt$NUcOlsOvFn5D_UOi+7%`6P#&_C_sb6tf>Q6LL*^MP5lfx;(I*guK=?RfvP-7hm z?j?)M{{V~c_?DkbxxIF+ntS@sJt19-upvSC-`m-}t!5RTG|Mz@laKAs#1b+<_T*=-T#DL_#MVY#4#o`z#_qQ1yIpZxSkb))rCW7p=SAIRRQL1U)1=? z=y7BGVu9LrN9Jyz@KhIJcCMlA{{Z!nf&D-AARqJUfI-$LXhzRXzwy1&(Y~!mX9!13 zNHVJ`^F|JZyE3+V98Rt*Y9k$44IK>qYfHL|RNEP0W3PJ+SLX~KHn5|c1t6by9{K3Q zD;n!xNCik2(l1YiVv!vkrYyV|5yU9ZAVxEuj9ttz)g2_zP^-O`SeBI1N|3}PMwqlX zklx9Cps&5*D(DLlGM&xd~>+qI#WcjHy; z+1e|gn#l|B!Bv+Xfl|pTxX5sQI*T`QOL1?sc-B>iw*f;b_a8svQ?{+N{PwLJ5^OFn zHni^zcSy;YGu6=>`o|_BsdZrO=_>Jn%EXK*KleLLd~RD{l1q9+F-@!W&<`}XJ#x?a99@pj6ainPOpRSn@I(cu#qCo%;jTQNYBQc zPjy`6XShAO;b%iRsA?h~9HmcrYhSK*ikrKv654Q80Cva8ANBV3{(1JQGjuYa#e#KVq)tjA?jdY+QK{x?xY;kPZN z*~KkSmE((?KMj`$5V2wJD0`kgk3_uRKsf94g4|PE<)l`$Z!!xo22^Hq0uUS=k9P$* z_jKHdy<)Mmbe!uWlIF@lCW}~~6Nh)^k$V%m9yvUpAMxvlwR*O*zUJt&*cK31imXw|zM>diOCc}5qvE&~Ae?ic=jV~#?mA>wXY%Xo_T zrouh8uE6r^62~^a_sGixa5gy!6rXpY1GoG217Tvrf%Z$rWee^e=|sIVH1Ekv%J}44 zxzEt_d?-|3D>p3O8{zi+k(>N&Kax#65?79!yDXync$9rKp^;eMS{!hqHzm6N3Xs)lQFOA0R{S1QD+k=y~@i0$9M zRC43IPaaZ6iQ#rx01kwy0|&V6^yDS0du#hdpFre&J9R&DgOJBxfpB zoxf@HIr@D%S;L^M2k96yYHw3Kn&kE+n#;J&micDkg;K}A?OZN8p%4H)B3;QR)-%-k zE$C(omOI+H3Kjno0-c}h10=x!+7S=w(Z&*WPM zGtVubj@7qiW_ctW`>uJD{(~P*ix>#pV7;>Pi3y#A(8S_3$3J&&Tz-__>j;~0h@0byk^QWFN7t?baWQ*E#Iav?eMj4^Fhnj*L&;Gl2yifRI?Je4a3-vY zZ3iqIj2`0$J&$g%wvd3D36UK(%u5 zQ7p$$x5vfI4{Hn(ZL$_mnPhd5{yXvf^VJ7(Xmxk$Y{ck zV+Qo~>DSVm(>V|# z_=0ti`CjVnxZ#G(@=mCrNAfXZss;kCbAkGGnABd7osDZND4?+V^zvc=lTQc45zlbh zJp#4DWvO=YJ)eagHp(9%TTOXj{{ZhN;}OAZAO8RypXv1KJnNH?w;(d*To^nAq}Wl0*CWeLqf_+GIdjf=`%> z?(w+_C=Vkkn<%YNvm_1itE&8FNQf%SpHR!|pTAa$VR9{0)H}s|&wru41hnqSR=N`$ z%^Ir}ic&sKD#HZh(>)_UZmtfR%5ZlKgi+FUv88Lwe7gL;IYz~ZaMOs;7o5Wq`=)+O zPb2%A-O`Z$9AG(($^QV?Au1$N+LKiEwsy6(u0yb-(bQRG=E)-aKOlr5mPKLh4gUZ> zvboxVMIQnX_EQiw9t^Cslg~J2_e&`)-vcM=e%(0GiK~`R9^UyB6>Tj$!&_9eLZ2bp*rOAyiQFdiG@;w?gLJ8#C3l}D@^UGpc{xQyL5^`Bo03wkS`aOdf z>G)76HB}Rt`BmIwN|@f%dYbC#9iM5Eo&YTBFl3CMuYBV@O!*Eb_Z>Xt35;c0UHqi` z9|OOnk)zwk`00SSkO0oyhh*;9$MtoK-iP~VH)K=poXz7qI??|CliE6KYDW}@PQ^>b za!4F@G(gR^(?r&{v>lbc!n-47At=+81b~boLHK*egcvT4> zAfpNt4(F;97{s(TljO+RTF&v;iunv)HRQEtYBa9B6fyi3lr$C#Da$)AEdJrd^o-cf zMbh;#*s=_5t!-Wmv}pD)?sdams?td(kl(v3uL?Oeq>4az!YiLnBRDx7x`#3pI-LOd z%dog`XlwL>`23zV;(JN8o^Pg}b@@<}$nDSieSY0uxNQeLNRXWL=Z1j5Pcpz`_PFi9{V~yS88t9YM#N}G8qK}i7ompgtDd4K4t_BXlf|@*u4Wt4%lrs)ihb0za?rI(Ph$ z(|E7K-DKNc4~|3Q6gE2T&*W^ZG>2uTC{Wi(&Le-2o-fZngTGp?{K#RaoZ=UVAnO+D zsO#**VtXuNio8U8aW}oCZ~JYA^<4h!bZuaOAZ!i7YRe6~l;PM*DXko06i5QRxWVEh z9hW?@`?}Xs0pnKzO_9p#A5X=lyF4_V^RFthe7_7$@|vJ8$|FCyv-iON08Xh4HEO#j=svi=6H5B=tr%D*oq61 zahA&R;v7KNp=gRf3lpHvOvH35Uq#iA*{{SDWxgr_{c3_bsukD$n^}_>${=+>A zcU%+PY{GuqlY2&;nE2Rqc-~2_1OJH@+uxQv& zu{Xfe1I8mFj%5fRPzXIclJc&xL=Y@(KTmoe#*U;=Mj97<+8He>lkHXqwio?6)l+wK z2(h#$Q@OcL%@(ckw3Vh=;NprT`Bk!>@Hreer$dng1ARp0Vhz^DeG&X0M{j#=cYEZ9 zNp%;j7Po3pJ|?_`9FTLx`~5#&yyoGe54Xoza&y(c+s8;(^ZMv@^rM?hK%m5%5yOv; zXuDyzLYhjsM7+3P1X2Ftk`^ptQ)W@gStqQ~*UIgOfPCqzL zk6-@)7#Wh1-k!j56?tSP4~vQA@H1k%Y@)x~u*gE;*=gqkhrXpPhaB1IT}`r=OIw1A8?iiRyg(rGLw|axS-7 zB)e*xBXSuQ(ed4K#7_Ym>R0x#9-U@9 z*=qDv+7sc&e065ute`>TJ1w<)bgAm(JDq#It*rcjjw*;a{r>=JpHe#Y4p4*=eWyY3 zo?ygWP+xH0;TO@{O;cw=O)aL>?cP>N_6s16s4RG+rUp?}N#svN`0O4-lKFLz2d8VFp76h({I{KkH1WBB`Z zv=aE8YnqBuF!^cBK4_xA1Lk6^>;WW`k3rC385Dcy`D?Gj7E_Invd5m9e)6iC`Ci$j z42oR|5xxqS$-;$Pay-6+s=11@C*$s@M#{G5O=?|s;=Hx*!NDH2gv79g9M_L5@yM_#tlC(yHtY$hkKS(Cu$2WQ}z>{fB>kII$x?)urmIgl8ToaWz>RfphzSDIa z$VTv+_G{hWEz5Ss#=j&yWi1alqZs(P0sXuI#AB{RrJ<+RYdyBQ%g6D3FXO2;p}f`F zEiH)VmaW7Dp8e{kY>j-zu4RdhBl*J!TBr{>nJ{e4N=twjm5x_iJUW;|6}052sSfjK=r zUYdy(cJqqkrHhU{b8)Yusg619M`lZQL{jUffn`YJRVuR{C|jjzh6Iu zyYgY@`3cryzEi02YQ8mU-21sD@^-ed$3__V*RfQQ{o?rcu@QL_kAR%DfWT~wlLdl zJaftU-leA+n{6b9)Nwx=P}d5)1Z$2(^OACV^p)p~(Kb4H{{V=`3gW<{^p;z`0p?qe z2#?Gj^mcSL*(t^*oGiu|eW@8-4`B2IIq4sl8odRt>lzW+ZYI+=Vt8?a~;vu-T9DsL`y=F^l5u!cfEzvby?b z)2R~`t0i8#+^OD}Hri=4Z{rye+WBnCEJV~3;t++vDH=B=;@@7lW8gxG z-;`1M`9FJEpa*WkoP$uzhUo!qqlCTLP*znJAe+7O;3N)0P&7UfIj?; zA@9NLOkG*_h9O}^Sf zk)7VM>M)KLg@!9Epq_D+`md)*L7@O~9V$J}pp$2;4}av^eT-IGrnJU4l*wMI zhUAM_c46Bgx#ywBh^f^70Dp|^nL`b{dPKJ?(o1p2Ld)EZ)ekR-qla%);z`K|?0fX( zC$5o92Tf!Ww9`!_jtP*G(qLo#djZ?6nLjL&#CQiGTLqLJ$G2?#KD`h#47^(MiqUJL ztK)i!wU1haY2;EQ{{S*su@i$8lB^gatDiyZ)aMjxGhs#{AQf6`T!9bDI#%VeZ7$%@h>VPfENUoq#B z_aF|eIb7DCDK`ZgU(RT+t zCg0ksZ^xXd00l0rNAdLjW^+o$$|bQ@oR0-Z_`g26llx9k`%H`5kPYdH zwHc*&-}8;{57gY$c>8>{EUK)MNUE$@spb`Z*yEC_!1{eU^Ur3?NxJ2{v;Ine1zl|` zwYS?C_Ve%b+cT}2Xc`qTAPxk8ERi?v{mb<|F_0>7BK~j#66L|Kl+wH_z=rd|cRLUB zqhAGNStp~<31#6%U`M+E=eJ;Tdh)W6Nvih#v(QTbUHhgrJ(`O(*qn&wfDv6(dkIs; zlZnrAKD|(?#1A77!~$<|3aNVCNhe)%iKMR*$oA@cR3-q%8;SOo!+yOGBvt8WtP(e^ zYsyLfRy!53v<~xg%Ght*6p!2b_wUn~HQZW|MM$N)uB8e|9L1haW8qXhfKlvzN3mAV z)6*Sk$WFot#s^ube-(JghWWKSRy>z!4!qNhtP(I(4}<%AvX;R6cRB6Uz#c>$B}C{A zT&8Y(VhQ#)B-ckg`dwMX_C$WjlOD#Gk1iyh**#H*DFTn&WcjfIY<;33sjuAmN06}b z{d!ig5vN-9j|4=npvdZ7zJuI*^;SZ-vD2H9plpw7Dk-qD7&U39x^V+OX|;;TEQd1aW8k%GVjCO(Uvu#gn6 z*Q~&#uu^InYf(zCm!`UViE^}dRw=Gkbqe4d7m3^|f%Ye)7UhqS5y3*_;!VglRv?DN zwQ4+$tXNAkw-@ACU(qxhmZw?C(Fc;rYpY$NK#0FEYb2R;{fCwkbb=rB?*D- z!}L*24J5TSdiH5gVQX=j!m>MHlY#C4uk~ZMK;#ovLjiEqP`U&YQJ%-f(v5wU*SSgL zt7q{RrFs5VJb-VKBaA#?cF#}4n#dGla^av&jp{3kbXXu9gtH?tjt&C>i5=O$tMvPH z?2D=Li9(rDez}Ul_FxDwpuF}`y z^(n3Da#)6WqLhVZjB@16Mnb6WdwLGM?&O*x#Jjfw!J|II@eTKlX*_u-Z0B0`E280S zI)_Dm=v%q7?{CV|HQ0(3l_d1xr%-Op#a6xRO40%*v)=Vh&}5 znL^y7QS1KzNIh?6C>~8axi?g^J+7)WRiv)uP zk~zEB69p-73{j3KG>gzKP5Cg&`FCwv7bu|KXl(rO6ScY5O%FhFr0PE~ye`}LJJq)VmWtI77Ce$j#v3P)ch6uk`+IaOP0avF z=AA?WQtAgTJTva}^~c+;2c+UU#pdG|EOUT;dT$uJhU;2qCPS50B}1OW{WH>@uA>)e zWYn~Zyi!G)C-$^Hr~Z8bF^VF+tSLs<WtMS%PjxrH=gOB=;sr&RevGLP*Ss#|W zPUPO};;$O&s2U>8L04t@C+11rj!cpA9?t%l>4O#nQF5E%=-E-O7 z>p3WIE8A-B=y)h6)RCO``}Dvl>9h;dj7!PloC!M-*|GlsuSdAj4bj$FYH#NM03N!r z8hj>Cnn`$%kgym7pRXU&q;b~FZzEJ9J+%DiBOZep&rQZ6+{pE@8KwvZOB{XipY-dp zniH{$j2hmmD4HvEUek=MY_b@@`UdnpBZIXSJbqUh$p+%$*d>@N6&V0~jy(bG)eHnu zlOdV;5HW&1Pe!5(87LnzsSWz-^R7&Ilh{t6hk?ZTzdt5r~EF4 zSvFGqakZ%Ut8kguk&ZJNA=kfCcD=!6x6)VLcHvwExnHwfX?8W!Nw;?r)>gY>6?kNT z$UnUcjDp+;t_}}OzG`_dSO?g{i*)}0Fn!9yQiMABX#}$btbaX7B52%YbL;NG9f9aE z*=|>%>E#O`0eIGkTkF3j;{k z{ADWIDH6qzXYyC%h{Ws`wDGqVSz{yI&ZD+J`&}dQ68Z|SsE+(#CZ_M`WR*N;cS~FT zNh*A_EdZkT#?QkC_N(KHvkt|5anL_>r18B(B<+|Btqjor0E*Ma^tNAj&@;RvKk#ff9dNz90(9!pY0s~04@3d0FL>-rKE_6w)JF< zA*m9ujpFa}N?nw4$1XS@^Bo>A{{WCN6d=D3##9?K9NTWL%~^EuS!NGKw|<=D0TO_dHFW+=t*q1PTVF<| zv&b|qT05$Q%W|Psew}$g@k=+S?c=2Nf8;8!uavF6&Tll`M|*f{sat6# z-sH0gjFC*hV4mDS9fk+Ds=|I1JZzFQ_{mFtVmSZ+I{YI2{^LnU%zr1uBU<{(68`}A zyw0LlIQ24?JVZW##C< zpy`B+V{JaMKpc&}r}UlCr(6jH(L2q@5vq~eOyCyn`+Dqym3d6Wc;B3>-`B++#&}5L zqFghlyMP1SF_HB>c})A%^}=X5Zy~2j5n%cRT{od!&Xy}QCpLiL0$ zmRk^{(E=B~LGFJ;j*M~w>H*_*H$Mg6e-&$^*S7YB*&aGe9HE_XE|wU0*OK8dKE9uB zjdA8U>!b$Bggy23l%FT@m6UD#f~C6RtT#S1sU(Ul$g+3&V?_3{1N*r4>bx2L)F9nS zJC5J(aCEsov&YkKMeq5gtI)brFXL$3Nu@n84p(NZbq0T!ihU z>%sp3m1FRI-pc3rc#r1PG>*KIu_``2+>{n1eSZB0Zq(#aM)%j_8HYGlI(dC14&K6> zvO*K)K`XSjGDvi601;SnOaOkpQmASqnX`Wh?LH@Hu1xZHjJE->rJ0qbMP31c8RQUl zKApb(c>Vf|7xNyI)9i_&N6X_K{{T8{{C3L5o-a*q(q`jfAws}PJFh-H)cWH;Oy{;b z4$)WgTAnM@`Hb#MemY^vy3d`(h?3KpE2#_$G9RHmvJZS5{kr$~*xq=9NbbyabzVA_ zwL93_&f+GAYp9Mk4;m|i6`b-1kpT9==_1+bmqpmeeW_u-?Zio~nkenpX`I1R=8xu` z24qmA7GyrT1HK1NaiOTC8jG6_)A=^8=gBoTOf|3S^sKa?Nh17tuh+unTPjO7PyWB_ z*NS^`sYGjDHlCo)8Ls?%=7HuP#{MxEg~hkfM;mQ~4`c9ar^sMNR%R@@VpTF1EI(1y znDUT8Ep~MtQl~&{ajMwN8%5b_lM)NZ=RP-r%Kno;g&4_1xk zF;uXsenylw9wp=eVm`R)XUZJ$8q}dNl^l;)y5628GB&d7+MX#2OIzb)D1eSESoS$@ zr$#c9_X-eBFh0|BM)7~=orjEQwmOgh04{6DBA#0Sw2|>rxR1K1UZZZzl4^i5 zrS7)7Buw(><6ZI(&fAM$ag z9-wjUoc^bv844}FzDLYWbmY{(r<5Ce;+ri5=(n0Uwq|0MHN-MY4gfikSOB2%$4teT zKHGW4<;TDdL|pfArE2yfzT@yI-8`r&0NkTS;_L`4a1MRnQ`X{Z6}cPC$SYDP9iw{w zf4_RMXm#b{)TE+gR5KN49f^qffXSM^}cZM^QpxO&_$Tqa4w@yY|8qJm^6_Lv! zLJEhEzC(Tfy%Hc{>L9z2Na$je@#*EEe)N`Ou4uG)r9U;XD3FnqTzBL3>7n%iYj8r) zH=z-{Kgajv@fo~{ZPO8}#T;}*p{>YfI2q{zp1;~tpA#?)Xbrr+a)WnT7OXSFIJGPMsXC&SNX!Qw*FBsv z`-VEXUe2PwyojY%zH`4E)#`7{BJB4t>ET@A<&H^99wdw^54hv&_2_eCW!ZT~W5lj? zJYjo{Ee%A|e~!Bi>26!*CAC_CTzvTsw$)QD!jXouTf|1c>REHD#P5^JkLp1@=qXk z@~hiF9cgJ(xYHz2^9X(iV(k+JAaE;;f!pcTSaJIRdL~VqC;tFK$ZCG^>0ZP;DzVzR zuh`kI$5T$6Z07IEd)JYM566L#(Pp7UfK(kx=`#)LEQTXMI>kE87TaO@5!Z?-C$G&W ztvf0*U=m0V#C;F@^w-LwudH4?EJ?1Acxc0M>(kWDIhx9GvsKXHod+|Ik382uv|~MS zHD#)VATEO0CLERN(W_@;tCn@Bu`NrrW}P9hY)phUBVO$YKmERzZHQLiai*W<7@!Fs z4fX#36ZVHme#EfsG_K8NA5~USBywZ3cmAHUHdRqiw9Hu9wN1qD%4+N(lufp=V$RNQ ze9LA}l2(%)q#*J<$mxTxRD+2i#96pI@tv;H}2o5zA_3uCY7ek+3+k_6o7F zIOU$*Ir3HMu!~$|(7v+i;a+3C(D_7G_Hn^#2wO3AcR~mh{^@y0~+-NUiIk*H{8NakwQ=DJ`C3_XhpF0`hUh zAGdq!7(%OA8|_^s3J)XMBy*(fE*YyDQMIx2m|-LwL(e}M80=Tmtl&Nx-jg|>j+F(1 z&19uw2`enKN>IaKu?8+DEr2^7L$`jJXt+RG5YwU}~dhOmK1sKcxj6vh+^zYVl$HWoSXjJ@&^N61A#t5zI zA-MADqmZ=GMD*Mc*Sar}3s*V4r`T(zjVvLJr-f!iA~FJ!j^Dfw z_>Q$4Fw>;x1!~0o7RPGU3ROQHYN!mNJSaIhH9 zt5v-{s1jH`>BHFw9;&^A9{KOot=i~Cb<)4Ac+6}Y*1Tye{{S*IBobWYh|FSusb>s5 zKiRYQ>VN=isDorx%j}o`0Eq7R9HLD<+P+a*szn9ZW#?W?1&=8uLVc^p9ZDB4$-lJ6 za>P?_aPj-frLC5_&aYihTL_~iWtFc@B-vXohmsx`_IrDNr>3ql7_)18dO>FeKmxq| zVb;9U$^442)u4qVn5)keYOy4Z2s~MiKtspdk&N^U+T+G~Lw@1k+bZhk^5aP(;z0_^ zLFL=@C#s2(@zQEL4-3`!LU{h|Zqo8L+rn;7t}VhCRz5ApW0vlD;{*(ldv)a;=Pi%7 z;i;a2;uz@f@z6#3zc#bt(8E5FF?Q63%+tiD`)JSmBK^z3y)Z{kKw820c=h#*b_+y& ze1Azk_xT3Ln#y$^P`quDqK&x|i$NodGGhzvZp3GyD#*NnY8y;^TvpETuQu^$JXBDpTW42j+Zu7%w_RnEG}NnRT-I3 zB@5nG{{RidGd!Q#c)~ixCKZ_+<0=~j{SRU3({3)H-DK#`TcNXmSFyhdC3VQMN`Q9a zP75;<0^{lT`g9GD0s-p{P>@LJBiF{Z?$s93Pr`_zS$fE#MT9rGhin!A`nO!>hp+Q7 zMW0E3t*!ngT`!T=sXpIlwt>XDn@}pktw}=s#>8*f70YM+vDD@A=uHnFufjYgJwUVL z#lZXn__@5IruSs`>WaE5jc&wmF^S~`S%=2OTevIVBiFY~&6ubax$*OXix4E<(vdQ? zku1ve4>3|tfg_B9E^+{_Unwue!gkz-wpAV;!8@Wk^q?Z zWG)Ud*BwPp8W_W8@o%>hOQO`JDyAUjjdwEfSXYda6Cuj-Bat41tU^(J#^JZT`$bFF z@RhCBvvC!TYR0W6$dVfXGT`A)w2_mJrtW|y`iTD6uockHu4(u#gJG-x03|dCvs;Wb z$e*;5FxUtFudhyAYvmOASQD)I@A<-Yv9f}M`v@uOpywP3@$!NmN<)-Ul34KrKTej7 z?<5O8k&eE=39Iqr%3-Z;hP`Dc`1rWo84v;v`ta5vxTbay-P&d#K`aG1U%VkmL6Ci9ob(ev|i)?;+LeHr1!0 zUhLbNpABi`j7uyG9E``@d~AQ}#(K<{%QBm;CK@$sW9hmbLy<s}j8I>5~Lx z$h<~>wgB!+9p?Gy79=nUcu)La)SP&m)2pn3?XR4O08hI9QP#Ff zRCh@xm_sO6)H94vuBNA;El=RYg>#aHdR!0dTX7>@rcsIBOA78Ij<18At5`2M5 z*wDor9e9jL%OWyHKvJjoXTNNZr`M*3xT2LK*Z%;G7Lq-PcBZPul^ct9AJR@nd32ni zs@HhjWM8{!ER2*<-FLJ%shO!Bf*<4_S-SF#)Vp2G&1(FiR!Q6iB~Wou zg#dN~9T^ZgTwY;F*}g^P$Pt6vAO5b7m54lO#m{LGq>ll)c4zO7hommColRDP&B`^> z+t;l!pucitC1Q~0nMF@?p!$7!L3aR}*Q9p@Bn#858(|;BUadI;*HgsE2NL5xz5Ddh ztytD5>2qqaIn~lOHY#51bUsk+`ECyd!k)w+KfBY@q~tWUSLqyr z+j{kuU5fa0o-ReZtw(XJs~`B?e09~9+_JH03rxQ5AYkRs>D0>k3jtQXaph1CS)bj% z8#l24$R6KN2>$?IzgD6ivR7f!GV$*px4WgbyA1WAi%(zjtVd!|8%UWWj3EB@?dj5c zjN=Cl2eM@P{)8T;kGLIONQ%K{Jl8owKEtiKkc-wA@);pg-hngR zl7G{#+HsCG9#w9>j`qH4QCp5!3?D4Z$*Z9`QyxdO^7{JqjDV1Cp(~IGsH0t{+Ibwi zTlHd%Dr+mXyUe5jh`3yU&B)~av(}t}2p(_-GWKHaeOWGBNTWXac*eOQS0wrpdjZuN zZxTGb<@a%0#0P{=HK&DqmKbBLO1*WFKfM%%W@%g&jz%%y^6Wa0en9$tvZmluTIDI8 zALE+8B=Q+Y&<)6S6VkFtc|0RV_qGfAMHG#XNEyIZ_};C-J>2q@8t#iay9<|Sgx&Beo_zq ztb^0t>qw8E^gGy=QKrRDtI=GZtV>Uv^n9 zJUxx3Bk(3klOHfT`guk5r?pztbJ8l)ux>HqnFG+}NXJ$? zt1r4mjTu`_$cED}Y{`2rF=BC+%%xanB&kOxZMGbTVu>w9`ne79c?t|h4jOO@b|#}lQm zh>>nEKOsRvPXWs-{{TLl+T`WbSpA~Atb7FkYx$4p=s%3>=mS~V+PSuptjFT4m=)}X z#4*Ut?P%M%$mva!F@2~;t}H=!voq^8uR$BJS-uEdMzBr#wv3==Hxt#w1{NC4L`bXDy znq8#`Y}cV4uXu&a%U7QXNjW9eva6i-$EQe4PNaf)C#-vAM-~Sc^pzXBFN;o_H5ML8 zY)SIE#!M?1Ba;^&P*2ydEu8W*)KvD*+Df|3h5G{hk{BaWwB?2|?{yx*z>$D{y*~yL zMS<~?=FAlFCw-w&*-AEp6%5e~pg9;}-A`|*J$r{5m7zQp$m;fWob4{hu*Sy1Igsb_ z<}L$ED`knsI}h|7Ek_*(%hEiaTAlj(n6huqSyH1btA!sLFlAt_IdQ@D<(|66^LU+% z(JKD{DQc;2{A_NijfahCX{>+Y;+N--oUxHBD3^#P31z|%@ zJvEt+liJr^+}*jXu4+)8XL{9H{zi^6*;Oy~!0Nn8LV!(}lEAT{LM={m%BDE;zz#}^ zPD1*VkNNaO)Y{TqfB8o5@{)Z}ZDE>k#Fn0a-5U1Nr4 zy*+!sK*!`+p!~=&m@h9d2c2TS6yd zDjJ-8XI^ov@#wZRE9xee8q!1>-D%-5nv*#s#2k)uD*pi0(DuybNpJHDKLFZ&dQRr6 zrPO$WG_%P*^K8N4Xl0ICzl5iW`B47=aVF$Fx%zd0v*g>_zr4}{z}~)5znF14Qq@ZCv%~kc zH8=M9S!FV5VKZ;fHHg3&%BRM`xkwm#G1tl`^1DZUx!i25Z&J*ZJJGphu%7Hl{9*_@;gtaFew{N2 zD5^N`zoZ<5J2B{;ETk;%(3KK$poIUc<=KnF;sC#;8GB?X;-$fS~1R`){*3dCTJ z%zF$ReLB_#w3@Ycc9719BaE*fcOpO}zB7T#trI8f)mF6T6qYL-G5zRRlx{#A0zmZ6 z4{otdi-dGF8f>FSr>JP}E!hkuq^t3m#d58WgtzsU13e`-Z7cbQlDLg;_?BN3)KlEl zd2GiV8t=(Og}aqW*kne@lOreUI*+={akhtAmUg&CKx}D$S#zKGd&+h6DsC=I_z+Xf zrsZy2;Eg#I3&e8u>^SEiUZfrPHY&mM>nS~rQ7uGybd+u6_hs8s*tZG*xvyCeFU)v* zUTH!8rYAXJ->YyC6py>Fr@}|vSzzC9LFe&~Jm2`mJTFD1@)$OfO=D>Qu?8Y|3~YGz zSI>4Fy6hd%%G^rnGdFB8FCf~=WeeKKB#!v2^6a2!nAWmBcxGVy-QV_=2LN&J)rA=8 zdVbPTkwqT{X+8txb$my0{{YIZ)33_hk1^NR07V#eYyhA)1p&RkZoKT+*~nGbkDpn7 z7gYtDPF`IipXE<7gLA!KBhp(~=8=E8kgG;uXCCOdu`5i60jwZ zDjdkFZ~^U}hTD$XkE}i`V_hT~YtOIQEw~?y(B-SlRa=XX+T33%f6(;a9DxQekzGV> za=r zbOU}zdA@=K)#q=K);x$$83K0;j@j*=-MxCk+Rh6ZYvsR6#cN9tvbC3(uQIbnLwCUg z_YC!pREoXkrGs@Hc)rd#w%0b^L9Jcd3iB%q2jhrL@!=jvTsN~Ffay8KC{#Rkjlyk< zFu7Zo^4*ddoTA8q&=G*(k7*x#{m)ft(od{B)z~JMuGHL!r2MGES_cbZL?k|DjX)(k z_G6s%m0`<`dd4!IJg!-M^2vWQ*K9BDY56W`)p@3@q(l)Hh_5Ifj;b(m)cBO}AOf-3 zTiyvFr_|rYkoa`|Ar!lZh3>3h_UvPvaI}nLMfp9N(*RgzmV^Oh+4pOORgkY^oNmmRobXbCnq0eFF_i06t;l{lxfW?3%RM1r+x zm7K{6XZEDPL_DkLcj6cKZ0J zPG^y<1!V+HGKU_rXR`V)>(X*|#p{!K^cx==#-?z)4=%nxNmcQ=^%3nMj^?}b;jpa) z(aR$z11ZAG8BPb3W508PdVWB}Xlg4NP<0mv*x5?EnVVg8?pKCxP+OHa8A2SZyiLm) z$D!zy60}7@CYr&xK(%CDue6Rh?c2Qa%QXWCFtH4MVq7Ux>wr6BtgS0)x2?`0(ZhNT zbUK;jyS%v5EuC7zP{Z+@rx^*voX``1J7cadMBajMORChxEY3;fNhJG;KBuNd!i1KR z7_kq=u8kaWs+BGaWS&DDvHN-1LJ{;Xpma7R{yB_a$2g#1h2k?aSdJqsR_N1Q6fc8Yk!?vP2#gwUWN zqxkSLLF`L<_B{%C$}BjuN(o^AEaEv50H;V_0L0;ty2rQ zc&_Xnpl+*W1cx$A{`uRG&$yOj+aABKTa!T)nwY*B;^895(k!Dt3$6&y*@yoCW2u=< zirP>dRPrQ5b@?PbT>I5Kb{?5M86;>R3Oblm#jg~E@fS7&vb;p7K%{#LpWMVBUfp4K zx|$J4xnR`AukoKVsyvQJcJyj2b4wLj1&Cz~qD6<0$KMb3Jt3UsPi~j;j#U`n+-{?f zC96wudG~Z>j;&jBbJgmH%<)GH1I$Mx*i-R3`?2*sFCIdpjeKJ=V+?s;!hH$+4XQUa zucX^pOSECc&_^Z@76cF>f7}#j(Ea;%>&7_KE_yVe{=epW4(*E&BU3ED%r!JxUl^VQ zu(T`N2jp1zhm@ckV^?q5r}p7~{kq=c0^-W5n#}F9Hbr9RY4Sh$AI01LOjf@dTJ6rQ zlnurvjFT7u0)CuEdiM|(P%Hb-F~|*ftWC9>ZMDAg?dEUFw8BZJvUrltIR|6gu^m#O zRC{FXS)1JlsT6f5r)slBJlJ=ZDabMZ0Bn)m9ApvPbj~&iKS(!JM6_spv;P2r`D7dK z9=5mGd#gQ&xSr@$)<{F-46#w)KT*=38)RBO(jyhs8ppXzcctBLJj>4{+cl~>{iK$o zmUOyA5E&jt9oHw4{{Ym#Zs)1;BI8OrgCC8Hy^ShW{{WG%%WKQFrsP!4#Vko3iyG64 zk}{q=qEYSPfq{;!?L{b~q?fri+V2oYb7?$rMAOM6HBg9An<*OiYyf)!p8YHve@5lpbh7^H=?{;#;V}OIkEI1$v07^z8ABQC6d*#% zeVm!HBv)Ti(t?*_2=>Mn3IPXL!d-lHG}`|FA&%`y=bqHD%c=#wWeePyP+)v+4h9cT zLu92Lw~4I-*!V%TI|%FT#>#DNU4?aKR@Y8S!c59leV~T+6OoU%ev2+UULcX{I~#!3 zMav6${{WM(iQDjXc)!P8va>n_S92;b0ajw()f@*&7*H^HUzy63h;*7c_ENl6qWrtq zWc(6+&QD-hiOC?InUgOa_Va^}A1wy{QuR+!veTH7O4fAVTTPV3ZXjWf2$6AugmU?d z9OpirdC!4MjiFqYK}~enkeqY2;gtU5|n|gtE)<<2>-+D-*2T|N`v5!>sk z-;PV727yB(!(|DHmA>UeHh)}o-PR^y?Va0X3U!^n;B1~+Neoa`o==@)s-y*V$nDE4 z2Oj>tXv4%^>OA0_ypI~7e5a%H>s&{uB6(Uju~`%2k$y4?xp3Z%ft+=PL0x_jgD`Jw zO(hu2q^=bm(}QvZWxaBJJ%8cX8vMFZl~oAiU>(lpdMM1`-4Z`Yi+2Zt#pL@aKQh{AtH(Le$yzn}9#G%e`Bj4P zSN@~_04|FO$;QT}^}i{Z0$#cb>E|kOBZ)E`voOnp+rO_~jDj(gr}+0WMz18zh?|+@ zoKJkO>-5h{*6KG(zc`Xe8K+bumQSbO{dzlrQJTvfG+k%pCO`xdDuSSba;$!t&u*4% z5m3btZVI*lKO!&p4{rXLJ&4DrT1D>#eI@f({70mto)e*XY$^=iqCLp<||981}c)9uqjgR}xj zgP`Ro4d_k}MsyH5%g>j1Ri7B~y-c!NrE(4IYj!hS{F7Gz0m4E+C{Hfq>yOi^>f9UY zAF|Y_MGR$}O#c8E6)dEJazO03{dzLTqyWBh6hd8FImyT$UtWxviZz!FcjLT8(6T!? zRDg~{jysay>-u%)>J3W8RI|3Ns81YQgr1}?AIg8UdGKNEa2p=ddf@l5xJ4^?k+ttU zJLV1*?^&xZe^73t%~I3)Fq#DGAY z6aqmpZ9O*9O15po9IX^f&T$_i7ChsVB*!^ zYbV;*_@h}?RAS_j5IJBzRZ2<8=4!;03p|q_2Zm$ywLIO9bKz5rccU{JcA3BWnfsSD}ueTj)4f!3C&>H zf=wL~&$dXwdQL^_C2n)KM0Edi^D9b3*l9L^h)|-2)tv$L?x90a z`-EfP$BO;CbtV_!+*?UrROUIZv2M3*v8|)MtgY^X>nG+TM6ZB{k}^m~D9(Lx>(y_F z77Thx7a?#uYQ#3z<>sZF&RiCokW%!D>YD7 zQxmD>6S3sMvyxXowD#z0iG|CMoZtM_hP?QeF(Q%j{AYxPP7fCJY!2t9VKz)&YXCwT z_Uiutki&0ef=iPG`D3uZ8akqJQrW`((b)9TsJQ8N^ok7alG}e-q4>}6*2i}gTMY>k zQ)tYO4B>!^PT`fiFz?ud(vD(BoN&Sc^7z5_lU4Dr!`4$4YVH-@E!NU02$D$Tz-ZM$ z1B>0Ix_Piyd_7A8Hf|*Q~Er@SWYgZo|l`tdnXrvIMPWSiOyx7*+!)4j5;bZmRsC zP_6fp3?$oL+g#^w9=}^oxr!*ohlqTWBQpZ++~rO<5$Hhe(oR#Y$NnHHw~J|8sB{}B zC%*$$+^~2A{Mf&JZyt`^d=9>-CnaNgV6ASBdq` zjK2cHMqOrK-Ov7%hm&wJS@vPS_v1M4)kUdq_KH!hLUwvMHQK6{zdau0^2;5qXU8H-Tt+}veqaOg z1Ch`sLd->J=M9ur{_iQ?kIH<5OR=x8&`K#?DJ4{zDC21yN&>pF5ZTUfJ-rT0O2V6H zSlq7L#SzbcUu{`Tm91BlNT6jTj#NF4Jyn$SdJ*X|sQQRaix#d~x3e}2yFWP%i8;+F zX70`Z09ns=&se$uDsyXh8)>QCvwEJdQCsBSe4jmg)!B^2igFR4LPxXLBfmkBfCZcF z@|=LR1^chg8&}8nHIc8h;1Vl9rs`(@0LOjyU}XE4 ze1~&($B=m?m$g0>rnfmKnV2 zNh4Ntv>CwoPtqY6$?*9^QrP`E!iA2oLxmb|B5yN`$u>0hHzl5y`jV>6et{9s zENHlC71RtkUbir9bO!wPor;BBit^r4*9$iOoe4qQ9ukY>s6o~Q~c zyyAh@=eKrt?QEB4X3?=~X8C5AukCg`hzZzxe?gA15COyr5S@uPdd9vryj>*wD@vlw z_A5yYBt;z2132zvR|kn7aolvQ*!h4q))qJ7N$XKVS;n<#tTFR<=Ou!J#`3aoT{}k0_RIEJ)NJJ>c4SE>CCiE6ywaRBo>9!2>8# z{8^3$4<3&p09hAM6H`J762`BY!!j;GRUkHgo}lz*05Zc?_$w?nC2oLCM;G<6*%UbRh?n!=&D=(Zt6ny?$k*+N5W$lcuz#N88|KbP1}CRT8YcnbaOW z^&^Nc+o*r+g{^g|Z~b@OeJ9mEU9tH*7BVJ8*W8FlM7Yd@*m!)ak(>eF9*3_fi*Z}~ zZ|9`+F@@&e`F$tJyVx!Me9=n+&#{%tvyeaqNpbNmJ-**wMCY$-11}Ikr4!2T6}b>a zvGJs7tMdDMiM4u9F0DS(MMyPj(86;(K|uG$8yN!tclvhES(6YzT^fwsSxbJdu|me7_qUh?~El?dUyvzZEb9`ABm6Nds#?;t1AJC70q;YAIsi z9(4DoU`r?j;I1$a+oy4{fqE@q*hZUtF_uoaE6VQIUns0X(SwXVBNv%5z7*;S?p#__54OttW#?QhzLe6mf zaDD2aXQjDd2?Jh}R1QEBq?*r(Q>Wpy{GoRBUc~kcDW?s}&lM*!l^?Z6KD|aV-a?dg z>nQwwz}LL}SIQ707e=K&yeqbjW*tACD`>Aw7xc#%C5Goi}NjoL+(=i zlm~odbXalaRI3HAdEG}bK;2e@qJn{!P8SU4j+UTBU<5z`d4&NNnGbxGJ>a4?%^e4Yd?|^J@`$i$>)3kqqbldHJ`Fc!g>ukr) zwG-W8`4oJiNMLYSWMk8>7a7d!$ZR+B(t3Ft61Nk-^_chaWB&k!qq)#)w2{ZCghc_G zLLO)xWIpoXe&^%&InP#q*ZbHxI>dK}ij@P~q{=G6cGOR1NaV2vB)~17i9qfUu_GT+ z6m{(qF1nfJQQkvh>U@l~RimlMxZ*59@mJ|SmE6AI_V<2xZB@^c)r?nuPO+YEZB3!LN7G=7-#xVZrvKfJv>KP=#mKv{6Cwme@iYl%>XF zSLApAqI zE{-V8`vIB=WRYW16c!_!k=zc0CQysW%|t9{MR%)O*W9y0zzJfktrWmu;z*dO!w=Mb zIyGw+I?QFGMnBVd{{WHsm*Q%)8f39EUe5~X2~RyLuC5r z>z)z@*6UKMO#y~IGv^vP4dRPxU^-AfCThxW1u+!YTNdPP}eBt zZyxdb-X%i(i?MsszHCn%L{%9-{!ht={l!pDLH_`5lrp(;1yGD^Y7i55Jn?@q*lDI| z=vS+-k~NkpQEVX-HDMtHPn;?jz{k5I^yso;4Y{v`6v|g^elRV+iS6R9HZ~x5WU{Mc z;*uwXNV1;q4=|yW6a789Y08Xri~Mc|ux})b$F=)sgU9T)%U)qsHY~;Qu$9yMfG$t{ zw(P7udK6|O+$h3epy+H#d?UtS@?RsVtFd-Hb$bpHTeAMhf&QlbI_%kaFDkj1laq0s z`Japr9Nqr_iW3Wng^F8=gGD-XDz6bAMhn*9%lQS(%a4Jm9TiFAnlOG9RW2I^vyQk6h>P)PS)vk_BrS>1gY3JT_LYRvA_0jo&EB zrzKxMM)d2lF*;pN#-M9g8*gq$=2>j}elrmrfyPfSzh6b#l2lmJ%6PrN z0Q$kS`mMg&p4Mvb8zl&~M#5HxsM7*Dp z#x-id7gTSMWHPsJ+n=}T(?b)skP6Y(G1vb99{G*u)cy{==Y7J>HnBs-uPCfJNg^)aM29g;a)e4m63ZC|K#{NQiIs7{bSsFe5zF9azCc_xe4+kD zzK+(%bGy}0k6ozS6H*!#I3#Eji7L@ICsk}`BOUz@OGMd8_YHrHcJXBjlbv zLszudZuKj%ZIogvGrX@F(~(IcP#2LrLwa-_#wKDJPSbE?RRl25$@TvL{A%W%^+!u9 z?&q-6)!SBb#6pRW#t>%&78&Gw^fu^ua?{owBaSt%q+T@D?>gAi*Po0e8+2bT;HmfV zA%AEdrM)xNqaj@wjdn6ZfwfS5M0KR`U;aUUwM{m-$kpsK&tKfrv|w}i%P3&ofyZu< zm$*2=Yx)* zWr+gkkNJ@pr}$d8IR5~(BC8-}1BqaMx%%~@w%!4~<%(|~vs+6od9;>SX9$k`n+XfX zt4dYEkA#dBFWf$QP|6Df%iFbj#vDW`_MThcq)|oYntJt%#*o6sT*#!xx%Dz&9^H0e z04J$~gpxpzNcL<@YtrQ*kgyR$kTMQ?0qfL`fNEuGV>DW;N9~ew`1#!zc z=oFII(lLMny1}*4#RRq{Xk)PMrZ`#2!Z<&FP$ViI!pn1JuZFW}=?67*ttC!US&KLBJzOM)dtD1iFf)$*V;v@_)<3gN zA?5_)(vYSy0TG<8N%vs#Q`@OjlhUKe1J*;i{HrD#Uyfp^*@`JK`9(M`(qq&*dM<_n1byI@)Ho!wY1jShh;*?!DhURBrH3S z;QEaA?fUh(06HCv>|~8Z+O(g?{ypTr6Klz@?IMqJGX{FnFh`n41KpCt9E)Uj$KS6h z`+@dD-5y zf!D}9lRTH`da_I;mZX|a%7xetSzi5zu0Z>Arh1GCaaC<~oxaRgX;!pIj_)Ly;sPmS z*mvt6Fjcy<2{9Hmu#2Ir5zAPNn8&Fezo$%Ry`U=j#-2BRYI|sQ5k!J&u4PhCI%nz0 z8!AWX(z75d#Oz}+xd!AG%E`Ag91}CNjl^*TvCm=XJD!O?+JeVhGIzgc@&5oC1dvwe z@>pN!W>P|)!?rW`>#JBdr=%{9!(UF#YgV9p4+|{3Re(}ESz3 zC!ol{3O!*Wo&2VWLY|_|y7j9!9<{3QhNl`vT_=@~>?D8N?bQvDfKUa461dcsB<&Dv zb(=cT*4W*GRD$Rxy@^4@p$;31H}1!$Oys7(=@-R?6V_O_8u%pfO%C2YqDqWqtkl^Z zO?E?`KPT;OT(g1c)cCTFObFIfml5Km9VENIG1++(I(=;4jXiAyDkF&_SfzB21-?hl zTeAHz&|_6w*LZHs)u!6Yw)Je?-zK`m(NU#pQ9K_T^M(lLZdq<6vB&y`db1TOavG8R zNb=i16QDmyW%92R@y%U&P-$<$x7iq^t7+*+A&Ddh+d4{1b22IP1GiD*?pTw{LA^r>mSqy>C+Bsa;@@!Fp_|; z&O;mnT=PL(CvSa+1N>DzQW9} ze&qbn@J4_sJfG~#+$df>fa`N$3q?kQ^_hn`YYa`3@|zEi{{Sm(yjM>h3T`bb#1T=# zsZ)|j7!BAg_08yOVER%OrCD0D8?(oSGZ}96u6s82TQb z*%S}!Cm7h7rTG5a1?9Q;{`}fAJxKqat6zu*6C5!5|>8PWdEp`}I3v(nRy&}abk^!;Un{PMOE47 z#LEBHF7gL{&JxmPa=~`K$&5o7ydmZT$DhZk1#*D#YrcVef#xa{b?e}^WIJW0AMT% z@|g5BW}KqM9FtgnU)fY8hCBAgbK5<8e5-loZ=j9zG;i;!zK>n6f@_9RWb7qz9LpMx z9}stCK^{mw#(GXyR6LlP+M}_lVZ;G@#S_V~*4vsXBM?pTwRcuPeW$Y(KArLVhevAq z^q3T`h7V%0!l=tKP8ubU@h8|=yQR zlyM*%n}~qk;iW*dUOTkh+_h6#u50sFuGXxiawur)#6glpVnUoL!S(BMWe5Q#pyNW? z2-{WUn<_e8WomI!NEXF_W>rSI@k@nx?0;9r0rvLkIWVg+CieM!qgOL18-Fuqz2MaU z0Q}YKHPrUHrG3StXhmgXj!N#R%tVtDkc-0%FgxIN<^Ezab2|Jtm;NAAfYal*ocZWl zH1Fj?+x&Tz*<|;_IE9WmC)Ei50GD3y)qJjb6-UmckHob)FCg-jujH2_(CTLSXO``B z1~}9L%CWC-R5!P_dR|*-v8{T=+nwdzjNncdC}Rq+RD@y}ka{YQUfKGb^!#|j z?H2A^St41fVv5f0>$#3n=ER4RJYju`LC?D*1FoVCf2o*Z)a6y-yqSDoE zHGWZBVO|Yp>Tj0ryHO)7l=z>HNW!rvkzQO|_39M2QiOY#sp~9yMqGgVTcGIywW9Go zt^M`gR@z;DhJ;@}uj@`9z#Z<%l$+_wn!UN$q39twBYS?gsfL6}9~$-X&T z#pux;PU1-*a4^xC7qj5^{Eg z>~#qokZGX&o8jbCVHn9JLw@6hJ$7E@agZAIfsYeCktwO!2aez$KCeoT*%*)FtW9%_TefIjbA0o3#t7~Dcb`)yH5DKBf@yP`8<??j%VJ|7`B!)g~p6ISQ75xuPB!NTqfC#&g#O~IaYNkGNQaFwr;m!i& zk6!rCS`AF8Xcw(`UeHxA!*1ZH@Lr{Uy2_BrVRFDJKe+}7C(!hQd1y-X`rwUVva?fyiIBn%~t0!U{=lzhffe~5i+Etaoqm2mGN=7|PCCQd%#{{SP`mbh_q0Be3z z&}_Lll60>*Q=W$V&pg56OKW3obhML6ZopM$M9C&;4nJz)d-Z=Ij~%p+iFctS@g00H zZq_@tzYdiyQd_Xt_61@>ukH>{AKW^hylYJbmfAbDT769cyxWui0KzgYKA_doB27XN z77EYHkX!+Va#S8|oObQkn4P9!?Y({0LBIFiEl3E%qj?_-{VEIL8T4_MS0Ff?S<&H83e0rX_%C7U26SVhL(&Bj z6TIHEHl6FL(pxoQu`H4!&%NN;2BB2un@!5N5pGi*jYpxO=iaIt*CrMB4R-k@;%d z)cxbX$vfW<*wESd>@e!~5LQ?wF@oa8J!F|&p6~S?hoK!Yu>-j~{*hxRkm>rt?Riq~ zY40T5*tm9^D`|chDy(9enTZogG-Pu9huO#L*DgLJS5MY!WaJJ10B9^46TGJ-&3DZ9 z>n|(t80DBGC@^e1$__GrryuXuvvJ>JF;|e&aTCG$71Bwg zGLcVN351(7h7rb$yov5IKK`9AAskJbhm2|{T;04U_?vm>dNWV+CZS7TD>a=%NU}h; z6qGU|oM8H?AM+h~itWgR)c%@JLDfQzdJiq;Q~ZOigT^e&qpuN0j$izX2*9l1C?~fb zN;1Q?dbhK4liXyz+64BFv45I*?(@ju(noJ?CH2-*TJ__2)-T8O4HzmHPCm+t?!U5xU{e&@vlQifSwzXf>(jZ2nT$UX3xz|T#_peVD* zfxHZiOBxmF8tbOj){k1%rn2^t<~t;jQD9XB7>p++j#u|`AL-KAwVML8uQ=5UpakDp zXs=sCWq&!NYg8qLkH-d4A-2ac#VnY|r@M&nj+8U9`*t__h|j`}uH*TNe3F>8avK#Y z8t~46%Ec2!8>D@t5wCvALF9U?7E#8pSvFjL>jO!_*wU?Y^5CeH72^@WiOIrt_Q}bQ zc5nD}z6FI7$}^Z#bx^2xo1ZASnz^k0RMKT2S>)h7z56*lQ~{pE;B*H9;vA}>49%4B ziFMRAS1+cDv`b*xcla^Gnc@yWb|bLk)hy}d4XhxG>SxNeoh8IMl8nwf_+!FuU7Js)UfDjZFIX-+t8=}T0L$>?_{P3UPWla z%OYdyatLmdk(JkpJa1^^Ocl|!S9z5*S z$}MI-1zQY#-8_t_i06XyL0WH>k&4GGC4g@D%AUs^R9IG`OQxZ=(1g9JV`X2pf2j@frvw0^A1cslrpU7TE zOG$6W>d2GnwoE13QgsTlIu5|U?jNvz)8CNB+fvs46H|969v02(O{_w9NfM@ z#N-sm*Zy5r39ux?VrO@DqTZ&IMzj!3uU~>WV*cE+jF&Gc@63-(_UJ5u*X8KiuL}@tkzd#zx5N^hskz#Y(*s2 zt^Onmw|GnP@v9H*Q4l(}# zYd^I+`^o!t&J+=Ta8R)Zyr9w3yBe*^aLWjQg=;DpfhKd^v4uaUL6?rlTZN03<4S_< zZESB_n$`KFvubBDO)RAHcRs97NmNuG-2`(vW3KZ}{vz;u-choxSaGdlCyrXT)z^|9 z?4ifI{+#+}q<|j8cBw`&{#Ef5@r#z|+qARqB#_v#WSsnw$h?RI0mS5wQT6K?LJ$Vf zgL4F(=2;Y_oGA*bJ7f%h`p-`q2|3iuey_{+dY%_GR*g$PvPZ63GQ3nOpKdthXY1dj z3^r)W4r&(??Di7vCVQ5NlGB{YDJ1eFlb-qS-=bV$Oa^{J2^fJOKie%c{?-geFn-wY z(GYF`BBHB0`;!TtC`&~U4J(0=p^gTA**&@f!A`KiBt-V1o*CXL<7lNr#p7^Qf%_18 zdJsj8qL>h9$6DP*y0Q~)yGJ0w_+?Uu2e`;MJ^D*HaiNR~S9ov2@0KG95A7dLj4qH1 zYXP=#8A-W(LnRX=g=6)`dIB}E4NpS>sLYND9gkY`gntZ{Za9)>=f!XUz#&w9hw0Sd z0kn0K3866jvLYWOJ%Nr->R;C#4oyTPI?Yzc@c`ENC;tE}vgtJ^Ifkzw7(XHuK3CHt z@1MU=wr3-k`jcL=wL}E3`vG$k+<6Yn%*!2yjC14>2?{>I52s#+4%I5il4kB0Y-C4t z^E%DMBQeCF02H^}0n<|%O)e>zHoapA9%$E>0?OmI4nDu9SYB{hB##qRmUyf^d*QgO z2rxb}JNvVqluey=5vl+hXlE5^%QR-dQdI!v-)>)We*I`5X)uEXy9}#3aq>BGihpM#>IX!Fjo(?0 zc~BiBZ4Rb=I7*uf39wn0;-NTIu2ir0Bob$eoZtbBo`DMn)C%((!eFFmP5B+7x;p0A zUXHAjMH9++{PMU+*Vnth)6=fX1zk?XWYxh0a{SW3&FqDNiHB^1*zwQ&dfX&|J1_>O z&HO>w!~RyJ_!fCP-bgZ@2J*{ky{=leu=MR*1B{pM>Yl5E>uPPK~`q@I&w zStCl{6fpXfi4URY-zs|aP9eyhDDNTsgs9(5q{m}#VNT?m3l=7~F;)`D{Da`(jI6}F zAGdyzNLB%AqW~>LB1l1Fb>!R~&&N~{p-Zc@rzXexPD#l0&@VuLhS+Bs{?m=Dk7QjV$! zBli1xrC2_PX*(%FWA1=xBVB&nSG=DUsqnR#{Sk@&tY@pUxO{fHi4SgtGE)jN_u#vz ze0R!??eh=a zlxmY2h?wNX?E~BQ>sdk5dCKH#3!%4a`faOx_u!^tALAo}FOn9p0K`+V?5m9aqoieX zU}=40aUh~~u8|g|{{V``tJ>SYO>RphLcK^!NT#%ma%_fQYbyT$(t7M@#m5~dWOWE{ zScb(GwaaOK%BG&65!96s#3vx_)@&J&OahdgOb4a#yTJPya2PHhyMU4nL27^Gk>+aVnHp7FLLZF z`14tjgO7;-NYw!u3Zu~VW+kDZp(S(4wKiiP0H0|F&@?vx0L4?e6TxobQWV#3Yp#E* zKU{S`dQ`5~M@dI&7(Vn5S+?2!a(KLAKOHrS>tziLHvk2=av)2NS>vB}Kj)6TVcX;@ z!u0W^^$u)dxz?w}&z`sRB~68aJ%nXN=M>5aSrlLZzP<6+y@A0|LU{R9s~81+RqHgZ z->iYE(rDIfvWFirB0MH}Hc0-F(@TIrJ!S>5a_J|6&9(6hJG~STjc$S{mwRdEDDxo+=EVBuz3h7mdGTDaH(3pSEP0S+jolEVj*tpoXuEoF z&*WAZ*`UHUwvzC66n7fIucJAtOPwvH8@S6EEO-OtRSWI<;%986%P}#!BB9Wr=_T+#~yU z&r9t#^|7EIQ4u&OJWAx z%!^=InTZ1$CWKbzI4{ds*b~=D8a13`#>lQfY<-WfPg7#fkq))A)O>Hle2Z~z?$=|o zyCjVSaHQ4a`NZ|ms+go8j7B3--_xiwHyTx6?JL5(t(m{P;k+%jTU~?b#BXw>2;0hz z@zcV~+l^qXs`^M%=IzN}VWS<3F23zV zFHPavExDE!-NjPHm5FsNCKA8S?nJRqBYTx2U+Vzpp?=}l{dV8s=Qkb{J$KvVD~)ey zLi40{Ld`{*D49E`DtMC~M__$@JM~u(%eICh_@9KoYW$Z?sjcH%HQV0UL#As5wCENo z>B%DDW{yVZ6D!KzoP9ldiLkR46bCN8a-_ zYQ@3?mTU;QLkQv<9B@z5JxGNydq+X~b=Ef^e4gXf{=GDWdDn*T{7+*AA1ANzZ7eFx z({m0!1MFOJ%J$-VZVaLCk4T)@%GLGqiLG4IL4L(;JSP2Vc%@4h6B7sR-BAx8Q|NQk z2O zjpSPD+Wm>x*tYEiG^-gD!g(1c1S;p{Im5BZ`u?43xmp9MoQMsR^kN*HbDr>WF zJV*ZkGG$^~kP48=T5~XU;z=2+d}kSy*b$B%xo`EwLSb-&_vK0F%1zfb0SmAcqW(p#;Hw^~8cQ2VRLyau1fUMQjRs{@`5o?l+HBja$)F8Z04j-^!X zJ8eIqLA>i;*(XuCm3ut1pgeV$@isw7i$*k?%-L zt*GF2!a(t#XBHmt%ipPyzay5rPM%jNalrs1-PhqD(Rr=E7}+M*#Te`E#RmymR#_XF zUNSO<7~;?J{_XjgstUkd4d z7ViO0Uy<`?jx*bjuUK8bC2{uF^Fpr+zjf^tB&xAIW+h_y#D_eWAN1gX{{Ruw1YGH7EPy)4-cRAzHU2{Si@PfJH>S_y zN)kfwoT%e30igH^;z&YRJltd*Bj5{{T){9T68YFyFSmJM%?B zjCG3zXiNO$6;%cPObq1bx6`4<2p;X`wG*b$TK8^Nt2)Odf*S*j$^QV=G5T@`Ae?%A zx;8~7$kag$b{j@Iiat@g)=jO|MJ3wxl-QP>NFC8~3p)1Y>yO*eWGI9)X>o`htw6X> zmfPO#c9o->NSdkt01gyd6rpHDs}d3U4-hAlxCfJTI6|tLH?H$?l1Q;odKQMGT7Ye= zEISztJKY^-mb{G&M=A0?3~n1efzA)ppF?~tjGz{tR4DI$ELSLKs1`IhTmfbs*Fn|y^N^^aQIxv%rFDC z0`zP^1f8HLB!DBDwCC{8Cb3`0^wHdsn@dv7J0-pppBTw_e{oOTIG(XGDH={>AnPrj zH~a_W9#vaqu+iSFYg4a{rW*%mhARa}DHbkj$C7$CED!kfrOpoGo2ZOz1Z+VaCSJ15 zQplFHtsIe)@y!`3-k9u02=(c%qvaH=cb+YYmLmvk#BxF~$OPjg1L%8tje&0^EWF`yNYuRb_%_U>Ui3-G?I2?f>41!0e zTvo=jL9L~NJ+Ga3U8|6ZEb6T8sxRg#AT^4%hdiFgD<|A^ASOZP1kzbPaDC$V%6Rsx z{8_j*(V0KZEY60v$jAFex(|1B9^!i7UzsOvlTRKZx9dN7M*jfHH=5l=z3$9jinJ!4 zvIb=@6cHXcDu7^Qe^+zRpioT-tVsgQQ>t9drERrY8=*t}z-RgyKY4384=3&}uEcwDwlPp?=RF$(gBO_1%pS8}Dz zX3oV)9U9b9GEXFYelW^NZaskdb=fm1O*SwW69@Q0t60`;H!!!5PSrJeNNilC0yCsh zj0O>}6)pEC*Qi-a3QhfGVYwTBq(yEOhGr12%UHHW6!u2yII|;;j`dsLsXJ6>-@SPCE{piy6kWOjExzpq5|beRqv-H9K7; z{f(-YB(a%YdoqM1Fc}|X_v(^48xcIXf()Nwu7ah* z1aMf5EDm&JusBE%pKd_F_YbJ(mryRfV7`Rb(i;@+#KNqQI!hTqYp`P>XYQp(*p7s8 zyAX}-Jf3={+m(pGxn5b%F5R2}^*{>09Q{v3pn<)?YA0xIjl~-KCX-syNepa7TN8Gf zBP+y$pyTR(y$Yc#uJGBZw4HfZi=Ab-=A&TkHFhIeV%bApNMS5g86#d}8I1O9cI&Zb z<-UY;o0A_MwH|WG;`^JPM@L!wd8QF)PR?J8Z?H1BOT-fJep31Y7tbuF9o+Pmqj)rim>G01GgR-&%34N8VP15;X%SP0?Nb1F2 zg^{0<8smq?v7YR`^4aNs@zqy`zV5KUF=pqdx1^`}2Ko0FC%DyNt@F&&2;p8TfzEz3 z1F%4S2UX(45LLuveoRbbV@B2WI<4FrI=d-K31-!RU8;!Rh)W7^xNlq!r%4$tiqRgv zafK;ESU!?{Uaw`T^4fB3bwb0*G!{m$1BB5`;p0v#%LyRtIt;lLv6??Qsf~s~W91C~ zNRGALw$klS9sdC1?^?hXHOSnfg&9JH3xG0M5=ia#=zB(>2QEPE54c@Vw}~6dU1g~8 zO7;o)7yiPi1eW#pf9=5Pw%fw%!-k0fc8yL@4NG0&bDo~IHWG72 z7kkXCZFN`b?c;{Ut7RmIX6eep{AVIC{{TflU%x|dwLYphG_(i02QO0ql4A)Gh~2m^^u`7GJ>2lVNd*QU{a+SKbJuWi|WE-C`$xe@Ni z`kuIvcQgZ47{#uYsIBvl$1YKQ!;xSzeV(}edIoXIp(-9X6K-x8VRem)nQ#h%MnLc1 z@7Hkz&awnW{p&M=%w(EY0hya6nUCD`%@le;)R}bri`JOQdU-5dA!B!uxv}6lh0p1Z z-6IkOT{ex##g4niI^P?=wOJw8L}Zq;(SM8S;P8a>xC;R#u@Ij9He4^1aOT z9+kOUiV_+bO9cvhHbMP6efnBpO~h+vZKi#=rTCjD>=jw6^HvH;D!?3-KU^P9t;E)) zddV^^Fg+xjr>i{E&sv((NnFa6qvi2N7=F~`OJn^{U5iQ=ezTleCaMV)jf`liW1Mli z^M#xc3jNpaI@VM>Oj5&n*UqA3S7XcX)^XAiI>y!&;;S5|(2RlfBc*~2Dk2Fp%`|^D z0>%#j+4mAhZpWr8Fkhs zaWoOjBs@5*RvEv!7k=h6`W})4)i4@<-hUY5!t8aLMNJR!-p5_z&}byIcXk0?$((V9 z8L%<&F;Fm1+an&mdBO6Bw%B|;^!3}%th0g?2iRN2-fme88CG1iL{mtxjK6(vZ(Q=Y>I z>(!{Vb=pSlV@=^T*s3BPPZj3f4M7;2 zJC7f<#9&Ea1r4SU?{?L+vmPH{^L&YK)GW@xt0ynpkEdS#BC561%6R!znXjy$X|T6k z{{W5HGg;d!uzRPA6eRatgO6XgTZsc(4HwF0S1U?&Z#dR}9pAzJDm%1ds*~2|6tK4; zJSuY1$O*!~e*T>#_gLtVV~1?^0UzEuZO4#M@ymKD78vL+51v%`)45PNo?Y2j0D5&8 z%&u?6^pzY%MR@g;@vriFnj1|A{w9xT%94Dl0?vqe0vL>i&)2JaEclB6bz$_8cX)E~ zBxt5qtiI>St=FqAR=R&?s*H$Y0N~&NbL;f$)AnPsBmlljAL4ssQ@++(t3u=rTl1ge zhnfXvDE|N!3Y-u*b~)=mA}}m9so#y%(G_yjv;G~uN5*V!b~bC*y;{kuE}4sdNo;}~ zAnm{pU(=~a{eUe)X;1tn0{Zuxa5}EAnILPv7iu+oqKp z^@69Mmj3`5sjsb9Z#M4b{a?ecX|(G|s|Wec6G_6GCnfrtA|Ne=L6`$GEIS_{$(Ilve)$wz>B7uG_WY?rm7dWzWmnG!ZY0XsB%NYrpv> z!L%MfG;=IlDC0AGqwIM`rz4R#%LRV{23$2^NHn!u)&22|$Zj(u_6M=ay( zp4~0jDfso82O;pA--drZ>wG80BGUX3O>r3Ww4*AI`&kd{&Iee@Fe08c%V_=S5+8|wi z1zzKVg4qYZEaRt^qhx>}h3GehZSCmT(``jLs!bYZ=g%+98c@WJ0I0wVk6yI{YslQj zAk_&CorIIT4ACrBA<6k-P>$?dmp3Eo52iXLP#_k$Cc@m8+}^ij*uOJkenSU{Py-en ziR)29iUUY2#f_ckDKM>1hZBc#<(SPwaA@;wKL>aK5f+Z%SK+Uu*s7PIk8QbR4T zSqnOkbPB)K>(sciSNv(!1RG#d{Hu<8JxO@{YXxb?TeYy8C z;C;Vdn)unTTK+^*vaK4w>*+h&Zg2S*xw6~sL)xq&5W_r@O)RQ=1x8}skbMd0v7pCF zqj(O*?TL|i_lJ27{#hSKQfn_+erZG~?$L!myVx55efli9)jsGrk)LjoO7~%>YW+Q( zX?Ag1!ZS3^8XHbVEGSd`$5qY)dh{d`0aI1`c|r!PFu&{P72bj9l%aAnJr;p}Rxxii|TGqcG^U@FF_h6}Zr3fq~sO-n_ zUk<2Z!6lR<5F6|6C!)=4rK+oF7)VwMI`8E*{{ZA)9PvLK@Yr@~6AO!JcFI$$QoM1RD#hi7B3@C;mpd5G zK0bisuk+h%0~)=xr$*Xdq5?YGPssvC=B&3B!1KtO5 z)-sZ9G^170yx|4_Q)^=Hs6JOJjV+i(c|?k(IXpoab5`ZwmmdB90AHt6aR$MYh9aoT z7me=p{{ZCcY&UXPvl`op?P_6Wi|17tG6_Q}tE*!l`t*#Lj~*%p+y4L&>66{bk6pi= zDbF2ike1)v}mkClex>aKmiuUh;NkDwpY7ByxD{$&3ESy#F8i5(;aI+9qo?mz3( zrX-cOHzucQwYw9_v${+0`h>f5(W+l0poa7)t0%+gAMz0dC?2I1Ia zw?o8$Yvl^XidTSu`&F1F3-gm#cs?=*6vm0x@Q&SSsRsc|zlkM|Aq z^Nw{ks9C2#`7x^P2-zvzy(S!8si!g92+vf-<<7De`AW~Q<#!sjF z^vZ#9$>N?nKK(9hmuFs^LoDWg7sss8}r@ucz}7NNEAkI6RX5*zDi1%(Dr6^!vD5PrS;bhH^S zpw;a)~&-d{se;}co&>Uwv(Xw+zLT7Q)a*+2tGioZykKEBh|r-1^e(Ja?R_Du07muJSU-_>z9DPMo%~s6n^6xRz>>-$Fjid4@*q_$@5u$b z^se6v0k8M;^oO4)Cr|Az+s^^;g#I#%%9nDJc^N+KW3s%Fg<=5;Np2_xdyY6gx|K6O zD)p+VL*rUHbi0|qhTibKY#SYx{8(>L_2!Br2*~V{BAu@bC6G-6d^htrFx_3jV)bk#9L zT+vVRq<8#ET^`3tt4SlT3{-ZDTQ*gyo=P&m)i0wt`gJe-N?_ofLs2F@psb%9F$`n-Xb)la>Vy<4c!z7(X@2ux3G!bJ*3{NE#&(8ypxD%{T2Gn| za;Xm|B;)b01JfBJsqp7qjbsn)(ovTaAl&}|c}mtqqT4;H(q4@fn!-ldBO?mMz21z& zt1_r7L;VyDXXmv z*tP!v{tOhZYu{r|$rNnymIaUzD#zJ^w|sJ5;D67c3hnOy0AClRVPsRs{iZkkrA{bz z3$NDJYI@4#g^kn)bM%rz3l2x^p4}gAb%ERukcl)tPp{Nzc9cf0J6mh38qnI7c;}*1 zAXrCtRTc^O1PQ+C2^EZ+v*TrG602>n2Zj{{W>h->Jyy9mX=o zx(MtU0*mTqrRSf_o?Woo-O_I1rDoJH{yG*&Zb6w>+El1j<^-8m2lrz>olG#6r(#di zP;!N|QCIVs*N^Y_z9~;(H7k!!)s>T5X1PZB0y^?$EPcOBW7n@mu_js_H;^l~JM-Eo z)tI2SUiG-l^vr>5K&~TaQ{9Ino;myDs*2d5yqp>~brI&{acWJ)I~&BG@#?H!;^DLJ zkWwU=)kxwQK=eOeg%EH&cm82BH!**2@g%F{g5KTyf>BYnvfDo$H;zQYq<`CFF_5Xm z;0$-qQ@?LZz+W1aE`N6;T&`8+m^-nbVEXsz_9(5+rR1GqeF08NoSAD_t8^$Xk2Z6uA~OMT9%l}l(&Vm@dq8gUO8{K)1;gW zdg@PD;hn2Z2b4>()m4VY2hv_!b6-!B@nH@ntmTS`U7PndJLG*jVmYJ8RefeMiyU>h zSB2}fvw1~mxAqpMswwzvFG(9%iXnmnI-v!JA&*|WGad(y{U#tBwc2^P+D%(yvo&l* zZl=p9wnQl(IOCA2I>tN1PaO4_&=4;|Cp()pt;sg~-N%t$sj=NDoV10EMLYE1waw?v*OF-l-64I9Ms=?xnDW+L_+~hMFnPKnDI*Kpr3S@`xb6E!70Ho# zZ~Mg*^fK9bS~`~3;Z=|+M(n4tGB96a=h3=x%m5ZdP{c_z3_AY+gYT>B_4C@QRct@Q zPO2gNsf%HT6Nq+J&Jq1SohduCEI>cfF}r692_M!gv#WFY7H4ryVr~)Cu%%`y2~&kt z{(>;wJ#uUYzOf}VJte7J({fhqou;AT)@gqfwzBr#!pXYXkD2w5@o$c+DJdwF9vN}li5*P23!724 z(dQUNraSoY=L7yi{z;el3+Jz@3hHWpeMsuiPa5!Xe^b2d)@5(EEzx>z<-+M=c3Z;xXBk8XDnp z>X<6aD#q(2nza#~*1BNI;P><&Q`I>bs@43@oSzzoH(wzM@||vyUDe0&q_Gx}5oUw) znLd6D21{}E%O1nmrQ&7^Id2Oh0@`^+^ew|25+s3xw-6am^yj$a^(Uu2=>aN?t?njv zvSMW5BPZI!3<>+^zekOFz+=)04mo2PVn-96iKZ;LYZ2As5=F^oQSovmN3X7Wm0IKY z!|uqT<kMSmrp~pJ+YQH`o38aNE`?hQ5=Hn^&(pRDwwwtHvc{M_E_oWcBn5?amL> zbd5-}ym6ptZ3gk}WOgQ!%(AY&t#FAh$C+1ya)(#sZ2HT#{_T?$cST3?~4cyN^TD zIaD4q_m1myR<`u3`32c2>Sd6v%jlmYM1f8UuYC8w^yzNw2e)`i0XoGVQB^A3XsO+j z=B-`RUo4=j7!in82!C!zd=9AXm=UO^>+y=t2-xWo6?OjNnXdYfhGAPP9fV2 znEQQH{XbFFUAQ%@PI50v)|HcCaw?|8H8OrRnp~=={oI+r`}A1{5p{4=T`s2;;iqch zrDn=iZc;OB96X%c`c<*?C+pUm0>-|RG$x6!Sr)JSCck!0`kQTak%*!pr?Xp7+8`}0o#zpGX8s+D z*@tB`R>Ug+welE`!yoOBlH=L}^`o~^v_!+5`DkI@Mdr=A)K-gO!SB}QMeZcowep@v z=alD{410PEU9ncNv5d>y23k9bQkK)s5HxEPU6k=1iBDGV_51Z|rAZmC8@kres}ow+ zT9VBNo8zxLLeVRZ0h^C3{?^L+^ca|vL{Wz52BT3p(LwS())3^!Ro*#%Q5VMJ7;wk` z0P*_tc}-Ww4l#G|kyDg@>Ero7c4b_LVpop>e%)q4 z*qxUV#MFAoqlx8}f3&R|`;>Ze2lO78>q~*wa6)=bVxNK2(|C@%$)k_*EA6IO1QNQ) zM44;_j1FF?eM$X#^1H-hIbMQ#U9%`WM)Os~n95$Oj`dG6Gcl0ph?G8PA~WGUX2Z z&X?22!fq^O7l_o%9`$&ky%k$Fi%RXAcHtQcr_?CO@BY1eUAx#M3Fl`@AQKGL+(V_= z>FdEpaU`oecP*$ERU`KBiTwd#+^>GODK-w%F#$vkX0_l+LvyT)Pkytb)c)NYp-(hC zc#y@roS)Ovub*@WCiTjC{K!kMwp`cv?yX2GrMQ`@BH^p2q@emEb{SqrJsb9qjBOd@@Utl9c>>Gn=RK@!+y@c^di9Sszs70iw4=YVxmuTtX;MK$VGL>> zXv?b*%or2tg(P&0F6yL>hl=>d<_K6R)S2_&obC0ad2&4yv~@otjd_wZs-F)L{{V6o z2a9L*>(}jZlD68iw3WJ$!>};R ze#xapBoQl>mUfCDryMiw;2YFu)b#5kVXR*Jm|m^6J~!k#{gp}S+A%4TySlB2SzTFp zNfGQ{1p6E7(7j|*4Z>5wtVLTJcCT9aEC}j~=2bqxtT{Pi9DrH9#~-IesY6IC-f@?L zM-{Inr>eaq=7sqZm6fjGF^)##?JPome14u*T=k6y0U?vbOBbo%?V z#T$%#yp)t)d|jd~?fQPbVrn@Z$eh3f#B2$L>wGPt*28;!w2M{a_a~AGC5)}9MVKQl zfuM}@;>6>Zr+%@ri=`GsCC8<)p%Bp6)Z;MQMLon?vO})nDU3rgIY#an6jDx5V>#)h z9-ex@6|wS?Y63kS9iX#drT%w*>k+4ZVpQl)>K#RYO;%wgE2y%Ii z$nB#*Ql*fQ|t z{{Rux=LtDET^}gWj5Bhaqa`8I@gN*v3B!&wA$K|ebY4i2+}(7ONEbu zKsW>_ssZdeB*qj{?J=0K))8OGHJ|)@Wk%MfJ3DKV#U*{gW>>DM`_WNH5+C*JKN;o- zG^3>MLx~ls>ja87xrB{XCIL|)jTp3m0$KZK)9ceosFFOT&%-~Fx4t7pfAQav9fh8C z?%F`4$s6Fu8io=KsRV)-BdN1?;o8{gU*XAO1|C@Ddi6Ic3g!W1;+X<;l{f zmbD97+~>*Ow0rr@3Tv*7LSm9TN+M!_I`VOnd1pE3u#!+&xEX7a(!azzRdVjj$1HgL zdR0QhD%z7~_(@}nvXw5)`!nvsjOV4~3a%WJtckoyq-ysD#2R|No#NdK_G8;B1cFV zvT_3LKCsSQWD`{X07$P%dueW2WEKF9DDgB?vv80xkepNz9pPLh~XTN>-}bO z+X(8(`4p}>m*Z8JE5Z3ccL;sml!5N`>i+<{LNt*8SM55^?`jO2grh=g_bgbAW#v{z zk|4!7vX^iVF@wwX=oc@x&5w*}#CcYS;RdSOS6&x{^$yCbv*{W}yN&f(iJO=NYZ7u2G zEQUyCcnY${8fa89E3d+#uw zgK46F6>mHLc<9?!hUoQYHpyWkZf_k}=q4IrQ(;-PR{^IO$%szTcDnmioNR^=?oA~zxgQ}gy#_N`*V^3>h(|n2T~-ZixXM{30*gnr;R6NvP7^z5W=BnEZoG7JzEE`>48sJ7Y4EY!evqdf&fw5k8eN; zCj+eGIb#|cxpy;Kc$&3SUIv|!qhG4MAdRwz#nhs61Vi zwp4-JDtcj9I?u}y1CU|QBMbF8{{WX*7g>d*(R{JT&J}-^9Dj9lg(uK@dgt})OyRDv z_mb{*o>e^v;nm8PsaE)GaA~YbJkbyDPW<^WVgSmH!>KW1TtMrglw`?S&Hc3%!*MRawj2*Z-=ksy2DLhWn6mt)cBZ@!5tXqdD z)B@zUeoo%w(=n-0M3WZm7~NE~c?XPnonHl^E` zUr|=tZfT`htlD6bM87Flma@n=%D|D!iT3vDY{gmB6XSoBqX=N6f#dm#{K|`X)u?p# zkq41djejn92r3NYR{zw6u;~xaWCv5_?k&25wYa2mcuAx z%K^Qozfa;kgiW70As|+aDrF zjOPdTpRZeuO&x^Hgc3I}S_W9GS)p2hn(flD)cHT|!g+>Lm?}Au+rLD@wYiI~l{~QW z-g#_VOxvgje&0 zcs#fD-8vFH0-Qp zIe(O340_=D=cDY{-Bm#DlvUl#`n(cIT4%bHrz@lK+H$pJo~3&cyTf$}ibPiRkx-J#5B(?U z)Oi=4Gw10jL&3!B$ZUh;n+feCGp)&9R5i)qt0NXxja#_w^ltwEex2JW{9?LA_YLni z)(sU+y}f!_j+0GRIT#B{xlqcyiV{0^Uij(Os@ug=7211iVSi_KZL=&88e%CDx1@Md z6;?Rho)QFHvvTB8Nph0a_{0qxclgQ-I(N_M((%6= z2S7U9H$c=zr20zNCdobfdY+xj&Mu=kO^bEsv+=*dN;S*H7bI3<6nu_9Z!fv}^c;X9 zy3Hs^kFM=EzDEofuPu62>sJ-4Yy=`CkknpD2PGIXHhbr}=^!c^7JWWFV@YQgbc*yX zMs0&fvsoMG49P49gTpf(&Ojc6>IY5C2w!YoBBj03^#1_E-b<~~OR>8&@p$#MFFf$8 zH_FjvlyNxa?jL^NuTcL0%>;@X^zxQ}#UkwdM_aUr{5 zzvb2H4OlW3BIwOO$9!ADe4^u1x4etTv{kD;je1&;Pa;k@he>;)^MBpj>FL*+*&Pkn zxb>-j1YxJ!<#RalLtx$&V~K0ih(>`^CM=Qv0CCCXhJW4HquT+-&hjk(0CjW_Eu>;j zI|PatW0~48kPs6wfDahSE;H+s>(lY+q*D@gk7;?t%j48^V_8x|O2m`FY{DMHnSoM_ zf2)pifb^&B{l%|%YnX)#QDu!j_9e9P)s)qw%9f{i&&OS?dw_mDl(*A8Z{$rV>VC67 z8+98-G50Gwu z$<1y=f~`oUSh&GfFsMvQ6qPJ_DQ{N$o|%sq8r;CimyzfoI=!CTUt%j7nXFa0556g& zb_}^vu0dZ+sXaBZsUGME#V9KiQxUq6p}W6B2~S( z@7JA?Rq}8HZyz~o7F>ME)cpSd#D~fM0G2$f%;?gNY2vRZkH&{zVlc4Gt?dA-hUCrd z9oPOH8g}6bDHUMfz3e7E+`gSi;D-vQPv7AwLJAgP=@yODk!x;f zD_oW7%Q{aKAYoo)_WOo1Sm)c*KNq+;8pZxkX+U)8JbHVIMH0$OS>z6ilLEe;tHk#Q zp?)o>))I$Z4v@bXy|$rk?oyga5CdA&v9|^>P1)mb(T7UzF{u=HjKPqDV`02!=M?;B zTfPx^^s`yj{8336wxwiNWy>QBEIEwiZ@)f03NH&o*zne!XTVy)NNoTGpy94H1$*jS64@ z**vn}Q|K}NogErC2TK(!+W81XSsVo;9FK6I{koT|d}$qsq(xthN|B=mBnAHfa7V5) z+dVK}oB~Ib1=2oCAZ~bUG=2X7XdwFjy%s=rfP`-j{y_2_cIMq!^wb+$r=u4Nn&xpS zjhno!&(w!*_$RkgcKAwPBdL|;${wp>EA`vh6`S0CoiU=sGP{b{c_id;ei7;c&!<3r z1Y{~roc3dm6$u)rqaxvseRI;n)FRQmqgatWVQ=z~DEQ?H$Vnr)RX=anu2O4JP?!#e zR$0NE*AJNadrzh__34~?z_=~=qarM}QJaZGIgkK*_WFLEHBB8qv{4o{lIS(M>3oW< z9gT?M*1W*aJdDUbJRfU10#`nn1J|JLGPuN9N4}xq#=O99wsho0Xvs=L6GbDuB%B3B zbMf$Vj$OXE>lVoOdVha}*EDp2+}JZmB2DjhV*dakB%Va%=lyzeW7kNQeKnBBGqqUa zNYPv|Z@mrS$pZr)T=l~s6a?dpnS(62y}2dhd*xtZ8SNMu40G7z^Z+xl{{Y4jDyl#7 zjr@{(7xt1(6wyu|IE1oSi{d~P0Kq^9b@XHPJ^FqRXrdx>`;CKen$>A)hsU?pSHB#! z#L$JZSKBHOj9Cw=u>`5_+pg8ksUu%0v=CD$I(f)^&OMzi7KdAVT9s-nQdqBG#)&kq zHaTV2EYYuSAoP8|63ju?27W zdvxY_)V+BR@W%7TH5Im-h)dI#`;kPa87>EYJct9h&U^I`?~59)H?fs}!vGU}t8o_j z2GhoCYIM4~I?HdrsM9{{Td-nje2l)whA>7Oh|l_UQYc_r_v#`U_>i@?NuOQ4B=)D3 zQ7bKmW_TgWMgtu23zO|1O!Zh=9|<+tCR}_TU*og*{{Z}+z3vFiEQ)d}i4Y@UfpRe* za=nMssJoGu{Z5}*S8j3FxNG!@=bk%C+WPrg!grpyek|U&T(&>x#z&_^i`%K*7GHO? zZ7thdb5w)>0E&5K0ZQCar26SeB+}3tY*9H&tF~jE*Wh_v^p~16e-hI}WqeerzZW2$rj!bq3a0wGATIdEzEJ zDmkLC$M56-!SwIz(|eBi=3XuB9&tFkPsZGavkd-2v$51ku+iIsI~F7hFt;L8zz{zCk zJGTujX2SC9WclybMKso%q_zG=!yax=b-~ZK>(>I#qmIXW-Vp&<^53OxI@8&oVIAvs zUBW~Jj3dVosglD7xL*GNPM4Vi&=Hu}nSuU94!9>X)+r#I$ z?nkdw_Wil#$+rIZ@ilsbd6mDJ^wqs3W(~jDT={oftPb&Aa%|LF1cG6TPz7`3K@%)vwR2)yxsr z%>Muz2E;`BnBXU2pQ-9R!(F$H!gStIHubjD_g5-!ZOL9mu4k)G0~REbYC=z5V5b>%PK3YsC(^@AvYD^MllRIZLFloT1i+uf?giQY~w1uhB;&D)dN_Qq&D2vsv~J0QqS|)4 zZ{9BE6(jqpB#);|Izg+sYqPxBd3;(OH~9Dh>f-!5>qa$-#hz?2@-fdh&H(My!3I7} zd&U>NTXlLNak$KBRO7A-2VVqr$_kL9kP#eevk%I^sRI^iS>GI#rt+5 z(8nd2A`&|Y0p;+7@E5BBHAcG%IA(0gZ}_7sr)L< z%DPs*Qg6)4yl+5nPdFy8Rry;5pJ61@QIXtSvt52>4#O+R_FsD+zgv?p5kjw|%y{`5 zZM?+3`FDZA;(i^f+Uhp(?1txzlI!5f_~lg$q1T%hWaW=R->7i+2P4w~EX#+2dvB-0 zXWQ%hsVmlPfFc_Kgb_0kRbxCDaLyMk-_Uh#6hKcIE<)@*&?$7*X<9MgiQ`D5Rj?O} z5y?a2Ad!;2G&o_TTY{%iEb%Uj$9$?dsQDT#R#@kYeM-B#RcQtuFog2;lci5#oO zRYoCP4&XQ*qy2i)0fr1OT_hFEa}y(2XJwir?2my_#PbI^)$B>CE&j#aD4_WesU4?1b_pbe-Fi{HUYec$_dkoh#gR z1d6-$jnHYK`Ry!zW#l(y(^v67DEV5?k3hA&ZVGP$z^=p@UUnY#%JJelMj>aKoeuZ> zMmeFyhNH{*l)o-&Ry>0AnzL*mV6jq82d~qv2m#aK zG@BaF)SA2#th2@W5vXj%oDNv_=k(9httx9toTZb%{8MY@JNh~bY><~S{Bo4!s!Eq) zMn@yk5kRp%4g`r{maZRQ$l8fdgZ?KH@fC_`+twPD0NB^0cOJ5>e;fQg z*N|%m(nB(|m;uc0__L6W%NwzFm7!ZGW&uAQ25N=bF?S!o@C?#~Q zVU?zg5*9-lB?}L_=c-DAK|93)Kr+uKhMqVe*SNwuaRJUE|R+54jetPF)D%ZncBFe{;{<#CKTjDtz^_bOq3V=M0 z&KsF^OmXG({{UnDod5JaT<&YiWOe($4!Phzn!em5m!d*#5Xx=t}#6 z@`M{gJm9vkC5>%rqKZdiyF1Bv7WpPeBZ|qAR|)+%{{VitDdct1abZ9~tj`DJB$QF$ zR8%-Ek|a<$FhA-u)@wUS`R8%V@#c^T0<_9bIXL$d+qMIGXQJbKOt|(6R{WD-E7jP~ zvftl$B9G?wi42Q|MwUj%;Mh_0Bc-tjJd{HKx$|QS-o&~(k@)0Vs}=XPr+VH-B#~!= zd6kdAkqGm|sLpt|ew2fe<#swoK7);G)>Pw?%ns-aMsvnRIT$0iar$)NVn~K=s!LMY zq}JfQ9dZD(RfLhTa2)Y`DQssQ{=K@@_W*hyIjktuf9A9{dQD!3Sli0`DlY)oRi#=& z?Mg}TT_gl%H)1(sKd(u~Rf%9I?Hb^K0X8L8K2~gEdjc}~W|~sWGmQ7ZX77x2*pm#o zejAZf1tWp0Mzx$orFno)_d5??`o5>FO%Kjv8|f^&4khA$cbr<$E8eUH~n@K_LqZDaa>=Lv|SAdP@6< zDWJdBHNBvQ)Ys`f(bl22v+-><66>jjmV4Jyv7RWMQ~N&1@IZDPN$rk{C7||;KNvW` zbM7_rfmWk9ur>QhhPoSSEDIJ@%J3kvI;ii5`VrST70E{;Jf@# zQVz*rpbP^X<(PM352rzyK-XPn<7?B(X}%xflzg(;(LrUWwghckX%uX+HzFD0l^cr! zyoea}KAm}w+%l0}dQU+xD-C*emLEF)F`xMIG`+HhJ+V>4T^z$P4m^t)0c?Se>J*>& z^tN{8#QRBxs=Rp*bxb1T&G}Cxj7z!8vF)7wy7sYXx=$hXCgI~{eh3n*{D!^USbFN% z3>W}d74$5l9d{5-kGgLnH0d1qCzft(Th;4h*Ckzkm^`|A45=)u6vO2H^q_K^9;27j zq$V%{B-w4ud*n6%6<;`ig?R^$X?D7QBeA`=9s4uS50F%=4+&tV;HF`jfk!eS#~fsI z8L_h|TK4psj1-2we1B+`z|qI#*0xo(cD9}>k1UW@o!-(!X~|LK|-Cq_O`ufwmw0qiN6p$3zA^@8I+QQl_W{TjPV?@z59N> zK)snZw_$auY3@Mx0Zeq6X3D?hiS9!>@_`kse9{2(GZG6#r@0vQ9eNmYX;mQ0j9t$( zF1NQ8pq6&0K$R0PahTu-G8N7_5TudQatS^i)swgTuzeqRoXrq?Mt({Fg<}T>u@!F;H>WVJtwbr(I+!95+dTNVRWNaRQJ9n`xl@m?FRxOLOw3iZ+u&ops;Ub5 z{6rRLE6}e-X(|5zZQtj6k;?9}MIi-XI004BbDsI{(SA#@emv$Csx{-vT0Do#g+0-W zNmgAyjll5iYxvg5o>-n_MfVjW4f1_IPLqodCAQU$mLoD`A*pmxtxA65eQx%~?CbDF z9orw0&{T{^3ct`qxa6w2><3ii#sD6XF65Io9|G6-9`D5_)L4RRUUhOri&2cK24J8G za#cruT(kb&d6`3p()~QVc}wvXDlWZzc4$!KgM_;)eql#E1*k-B67jzNnC z$Rn>q8uAo&kYqefjp7-iGDfv7%&ls?EBGchjiU&C+#G@H-~E1_FdEbi4)9L87#$cu zn7q)msDrWw3OOFjl5>%tUX9O@i&*Ppv`k5_(XEYUvh;B#!n4WpM?I*0Ul;*bk?cRG zq^3XiYGxaH2-nI!)v%|3j9qSat7NH#A-5P%B#*fEd#?VOH`4nut+$>Mg_NcXN@ zy3H)o-~i^!B{qd&ABSUCRxUWD%pT9+EHZFY(Ws@(=5T`jIzW)HPK-4HDjyY*(y7aw~M_PEL%>18`Cs0e~a8{{UX7##Mw{ zf~FU))9x+n?_Yv1l8q9k{eVCbhyA(92kF=jmy;p_0o?5vnAn1P4dnJHYAfuuYi3!S zTX7?n)rfesf+k`dqUZMvb`9U4$C24vLCH?jT~sjgGxY1OX)ZxbPiDB34Q<(*go$1< za3qOxyqII_+oQ&;NWDDAtOsBL>*W$j33J5}oy!nD=l!~%8i+d2R7#VmU{{Y~KBMo} z8mXXb7(jkE1jcx;yB+(EgOuJf*KuKsU#{2-aG-epII2ch=ZlFXhX7~3av8tVrQ+0; zA|71;U}Y}#NNY`FF_$tZdhg4RA9sG=*VF9S0O{lOiW?F}vP#w?nNl|)PD13I0iM}C zDUgk0RNQ?_lGS)XN94b__W?n{V0xnn(&YJ0wH<0aqCI+0hB|OGwCyz0N_&e@oZqZI$B)`u(Sh~P zP3-JxtyEWb0n~34`3C!8FM6a@p`-lyx+vEvxpfY8O{ z$Z`uOQ7&FHrM2UC^xMxZ+ODThzpBdx+Fu-!dxtXS_MkdkvFDf;03I=Cs z>8YD%@y3r%`pC3<-Y+pCT}IL4;Uq=C!k$1LH9O8`$j3pn_FpHxxzp_ATNUYkA|gnKly77);8q_{IDYJPNBmS$0M;aT z48W75s`)pO?sgEvsMyLa+NZ@TYzYiPupZWb>i+;d6uC5r`;obEC(hY*eHiY^`eq$&Wf%+ zNfFqYQ0G1I#x@2r7v0ljKPr15Vk-_p4HEo}CxQIF=>yerF$YIzoO`hJ~uOp2sgnux~g z7aiXRi(5(9fHH)nh%HTe(mHg3FjagCnl_-zhLnD7zq3JKl(9i+8b&No$!uFcqj{g7@Q1Th5 zzm@H3RgTuY&7zVw6G>qVhlFj!qkN#he0M)iyr=GpUmZH1kN5SSfWgQM*ZNG$%_&&% zs@FVb?R$`?%^jL`Y&XUtlqnKPBz!?)$k<%{I=2J!r_%b#GOr6>n~714_H)t|OtO9g zNMu;uh=FEh?%n;N*;gNP)j(O-c$=X*xgBaY6=~c2xZWf(NUG6*?x4JV`<@53e!X(o z7DUDX#~P`8tH|{4)Z^{88SkK9abZ>Vvk_gY#^kk4=3L-w#>h7nIY3xysw2fkDo~q8s@E2Dc zgsR?=^6YsJPL}Qpwcdgn0todni|(M+dFAbx)xy@P-WC?UNl)TNVsc~%(U00&_Lasu zSVGu>-h_4D8G3$IvFWtD+12kqlDsEn<=ej_`I|rAmmH<^wY8=JCY{P?EK|$|K_!UYc*ZbKAHG;)w_bx2KmlO> z(p-IYqCa`umUkOX&As%KOCgSQv9Q^sBaCe$;bmyl1yP?t*8r-aN9i>3W61gM6-e=! zrV=wq{Ex=?L*IG$>pR!1rsKvzFryNRK7%4s6>x`A41amteGWN=X;E??T>Y-RpD zXW9?c^ucF%y4n-0txsWYrn1QuEYD<#wo<+nBaYm`?b!F_(7P1`jda=)fK6=&*}|1? zTdxv^iZ(IFFL9Mqfa52)Z(fZJfM5_Vt~&AU1xp%vcK-mK1iK28q%de59ptjIsrQCL zxC(ksT%VgVp_9G3=1SB)t#JRMeOc*`eUc$D_V?gAx&xZnCy@#tX?J&*@*mR5y{$2 z_$MEOE$Vt2<>0Op)?q>B)Qd?mDLug0@6PLPKbD62U|G z>vyxIr;etz=DWF=$xY-UKbbp*A1fRcZX}M`A6}4qr3`i2J$rIcA30y&>gLq#C)62! z8PZRR)FMFfEP$R!Ty{K82UOyIOUh4?IJX(i3%Z?KY%1(^vFw^?gmszZWH3vRS@}kN z#0;)II@b);L8-_N;)fI)(h>?e*Q}`}qPbggVMLHWykKtE{OKiq;WR z*_dI^o(*_CeojFX+?`BTB!@qGZ$3_7Cz^cYM|gKC;hs38&OGXl6US;)1@vj3DHyZ08dU&a%;2((7+=N8xlJ@ z=hOrKxd*24fK9?Dfg^&n$ecq0ul>bx&ZoO@9n=s;Gu92v!ftmK#??uhN;At;la*-P ztB?NxX?UFVjDdwPxyVR143=AWpb-4fd=iFBG1%hCG0&rk4}W*AdLAJD-jj8m?Y9>% zFrM`DTe&1l9MW9Gjun6H{A_)}LiNfgUC8ck_PZ5(8S)SC)hr8@D7Gj~kjueX@&_fkD<0#&Zv7TAEd6F=U9aU4O-5apiAlMpkhPlZ zW86s617X9Qp^glDV~tnb~4^PDGc89{!$4%fqJ-&xgtJ;_;eA_!wellEjSs{iA zdrBHNRveqR2JBSzxpCPV=r#GlN1$}n$b5Tarl}S9@6daetUzPd%^b!SuVXn`I|Iuf zZi#~$qgw0gAgKcEUzeea?awOPVkVK^CLAU{)(od4_9wqDu6iqj*5$*){BOj(iq*=u zH&JOOg1nZa+1_yROyN}gJQydqlHK}NM`WS}j~-FD)muVpQzb~zip1Q}eO!!W%M%!i zw|N+o!NQe3-=}Y1zd~ln;)W%d%>?!(z(E)mm=4@!g_e-jVR2sCB zYH-gehWyA-*o-hS^IVk#4^TSgX8!9V;U2B9Q^dXtZpB5!z(LVt4S_A$qNeL7$kZV5V6YQ86S*NR{Anm-IW z$}>k=+)X(7xlN8CBO{Xx`uFP&K&`;;W`reqbP+zfu3kwC*9jbK#8_-AWMc|FOZ&1q zDySgboAMx}^`BW{S)^!laY!))kdhe`qh*&EIqlZ{)(D-pqB`-7ou%1vSr#egon`qV zQSt^tMk9Z=5A7X~Zj;=g;qr{_+=rh|`8I?2>3@$J_O}u3k|vxaObaZmr!p=8%Vc|g z{+)d4u+vk{=z~478qR}-STpA@7!`J#cGF2|YgE}w99Aq$yww4aa+8~3haC6DI`^^y zh#IvM#X=d1^e~U)pB%5@yX*QrF6A~9-ZV=VYn(?gVb9|rGlRhX;hvAT$@vAHY7S)l zW`*kzz|^)LGd*XR9n4e4E3ARp(ZZ#Y;F?r9V%#_#~xgsA!fbFCse%BqE<)81V(Y0Y!wW| zD*AEmI`i}6ENpb~r}UR$aH5;9jbr}+Ay6g@8na{adSURVu zKHpEjSP`sbZD#UXIM+#$9SzAAYdKh!IaV}^W&&3yCn&$xqmObr^jElD1ck6RrZMq5 zZK?7qSFBTx!q${aG&Z97Vq!CdXvR4!eY$Q(9CV7tp6y{?O-DmW;p8-azRt+^yw*;i6c))=0yMT{!*zXI48ASAPvPv03cu};u#ELPU;w=VYr`Z1MT(cwEzgh=IYhDZntr6X-c$qA0|lndwBBt zjw92d7{Z$qd6S`n0*H0_QDbQwpN%8$?HR(3ufm*E1JX$C$%*rV z+tBQ4%#g_$Y2=JJiZjW`V19$Yw;t}aWmXr`VNwl22&K;wi4~v0O?~Sv_5>Ici z?a(^@lU1SCJht_nY+4fXL*nsT{KeIy2IC_RF2UGxCysHG(ncYCw`$%r>I{7_0run3@vCUE8Wx^2?Wb^Oet$aQhE}nm z*y{Hdw-=_@?u{{TbfA=a9{uth0a0fI5MybEq`g;m4Pg2zj;=UFL7B2i-;!< zO6RsmY#&~%pE`&2HP+Rrzh!5s+%LoP0#KLoow69U)k;HYfue>%QSoRt^6)fvN6Y{BAQ0Dnn7E)Nb0JY@*0P#%N=aG}rhBsmw z>4#72>l$4U6{}O%*VED}t1Xesi@23X5a`{&Vd?&dr{)5r_!!(txP~@G;72q#B>h44 zIsX7&l5{c4tQMVCHO%cI2;-BF##`P=9-)5UuS&@J)kdP}bMV-_LU>hCl5QZoE?Ib- z`%lw9Uc2#KA1U9GtU@EXSVbZv#XXAHb^XuiR1xSq`ksf21GL*7bAt^zq>q({jz5gF z(iqsPNHRipJ^6k5?o0;S{APty);92uJ=l1L>P?2YI9cG9wTO|zvqdW7k32RoKsm=j z+;+c}Xqqta_Z%AQNV><9T(jd9e0iFM5$) zk!_fi%JY;5hKvrPC`e6qAMTan7wQj9{o<38LB{rVx9 zxbhBz*yI#f|^{{YI-AKoGd zg0@IdeuMowa9F-o1xGm})Uh4UT13EQ@{JbS$xfXJqmElX)GpjqU}WWwr>9+s!ecCY zOv;7aDC<0%$M40egJv2L1hY#DW>n|O0m-u;?gQ+74@5J9)iB{8Tn2r#c51CF2^w-^ zBuISMc>m0&LjYqVAZulA&b`(KDTIXFLmUYw2XvlPp% z2^Cpnn)M{H6Ko=HN=YLUSGeFYl83f?b<6+;wDp9&FItT}ufx2v#onFWopW9rOB~wy zm-~r=IV^u59C@OFmG$WubEZoIKQ0DJBt^SB+ba@0yDm>HmigW}5n>IHKy?d(JD)%? z(@|7tu}sWPyTG7=eT>NfXx2qcBS^wE;)5V$_Rrg*Via8E1et2_-yKb;yQqbgc7uvU z%rcVk@5C_A7Rbj^mL-Ad929XQtke8s_{L2RzW(RQHGbzc+o0^TYB~80KpxQ?%o}@8_Srg;U{wEXFYiNQI1|Gsj=lf1!WlT$W6p= z^-$~9tc4Oq#70B=c>)KnKijWxcbL_9-=y+CoULOd3PTm*CxFlX%8h_2^r!8 zyFdc^=e9k6Uc5z0%6VvgC#&{n!H#n@Sa!>tUXsNoR3btp6yiSr{{XJZz57R4qu(!8 za2ttZpK1R9UYOW9QDS)xyG5>UHWYhLP2~PD{CBhRCyw@kWSx&D_>>UPxFG$HZ+H8B zdR}hxhqa?I_PqVdVH;gvlK2(p@yV}Q)oeCEMYIbeENL94{{XEqo-!#z%eT|6RvC38 z?ddRsk3fFtH6Ii3OS)~XsXV3%H|W*p4KIc9ZfMz*#*NFrJ|VI`{WJ393_ZFZ9xe~! zLO$(BkDRGew>YgN5XBs|72}#}^AJg7IQE}u_h%R#`lZxe^@&zBQHH9wh$q1NxhF8% z{DSb&Bl0UU0wGrtfTxq=>*>g=gJa=}_;Xx(r_t8sDu<15xW zE=ADHLfG5-$Dhi+T?d$KY40rF7PdAaSuVo31|LIH}s85Lsx4HGQ~ZaVlc@pWtHOrlQ9fG>~GvxA8wwl?jzD9t*A0MYwWi3SJmt0 zlJ$%7tkT!vgZV5<_C3pcPjl(kxBwD#1NNK=GzPah-fAuAZCcV^MO)A~TWiHxqM0-O zu^=Q7f(an?h{2Y|(_Kon9pP|G;@7HGrEp2Bnyp#BYoUH`md_&5uK~n`VTB}~-o0U0 z1fBJo3X$dYxE-xU?L?6#!o1Y>GAuT!tco}^5(3L9&TB~bgT!R`V{g%4^s`#3{C$Q~d9y@bveSG$F9ed+sgfuTOPcB+9&vC`K769%h8#f z#d55H(NIK26@fj-K8k%Y({D=}#futFq||HINpEB}(6zX(ek6M0kMm0x9$%2GGJ$@b z&Uzkal~+yS@fExRq)BGNK%l5_23Yzx-_z5h=N3Vi&pGfvG5$3%*10-BH+22+)|;A3 z0U)x!Vf|y%{v9hGMa$D|VKTCH-%TY#8N6`mt$fhgUDHa2o5aibq8xv5WLEd`{{WK7h^uBm$*!y2O`K&nX^g#$SL zuIE2qk&gt*7#G&>3tFP2YuFZrdFQ4+E5 zz^kxK=eM|ix$1nQBC%oi`0;S?oNNN0yT@OpMSex4*Xp$T%hYH}k}xsNwpspIe=;cx zVm=rM;Q+`v`}A0H7h(zdS4r8Eih>8u`p77GCf{-8bT4o0{{WS;rYK^UV<)qUHc&|C z`a$*2Ov!~;84K=RE0p7~&G!%3I%T0S9SF(?BifaJ~f z=zCmcM)o0Qa8A^IF>i@^b&npmEt+LMU=`ppqPY^MWj^4;7-NysbKn^OxV%|g9g!Sr zcYDtT^P^|xsW#bJJ661^B+=@l56%l&mosxvxW#ZB~fftuf>)YD~3V=AW7RN z2iK)y$Qg9H^@W!iQ>?W4FMw3>3R*3q>?&;r<^f_#kT*7o7$_+qK8^3dKX7`V7IT!5 zv}Rmii~@D>`+Cg=uZ>;uF9+6XbPl&P@mzay*F{g38MtSIkU8Td`u6KF;USB+!fWMH zp%>%)&zp^jT~;|Ha~R?{l1Kq_j05(`$6a%bKHM4@_BJ7{YH9WAnW<#N5!v{IheCZh zWqp5MwM!5zYc+rb(VbMgwiNRWpZN8-tKcarzb7^9d;b3buTND6ez9hIw%gy^Z0FeC zn9Xi>4D)eR<-`RCihu`TJvw$I3Mg$c*s-&`CsV1hv%6lbQ6PJ+W3JLTi8v>s`!R9g_KlBstEqZ43^t0^Ak^&R>K*Gr=q39X>itIcZEb>l+p=eSko z->Jvfw_MFa%<8?Q@N8tSb}KHz%hfz0hRT*giU+Ne%8DOWk(IAQWhb%o+F-rKz2HKa1!#FkanwRMSnS z)z_~37_Ty|kt~OdjT(SCM6H3JUV|2PQWGcK+eWq(AhLa;QcyLNN@%B}GeKoRB6wNjb>uO`Fu@ofZn1^vMw3*Lta$z>v!hOg`+8!$UP7U@ z8yThJ5~y$e>w$w%Py1K8jGNbxf{kmUp&ZCkJ zN6Ky>q|wrQyYkxNw!c-QsU0Y_f@t4i31yb&Cicrddkg{T>G$X{qYLDI?w(MxFzM5; zoLxoRc>e&6v%M8@87#N$HcEs2J%M)}y4GqNU&0C{o$7w^pDEb*w%%*Hw~kWrO;S~s zF!4(a!;1%vyD;=PJ!V{L>%{zFW5`h0i>vIe*Qaiz5}51U;w+%;@+vnaR{c45==g$f z$`2w1dC6(OO=<5*BS-j(qPa{4GP8Rnc}0yEcmuItE6YWk`|TMHXBvz8Et z_L+UgQlPL^J;CqN@;auZ#wIp>gjFSI=|?PABVl!hBZY$@jPe9I^aqmX9T+}GNCZ~! zc-lj8OpF8b9G&~FbKJLP{{YjinHt!;slBggO$e!4iP}cl#$+NuBMPv5L)g5Td2+^j z0H*HjLo6z)jQuOx)5WLQP`wDY@wDkBS(&GWm5PO#PyMz7&<|1Htv>4&r>yU`h&s!+ z@pg)*oNTv)f4PF3(^8VvosHOp%vu*#_OWCt$LhnrI-j_2#utokA9-$C_}*2QLt6d2 zTKKKB60a7a6yxHr^06N>7jy06ry;-Nj+|Wa1dosL77!9e$dkzhJvCiC6wEDN(VJ^x z8!`cO#`%R^6!0GpyMr&^t^9UNO#GApi;cl-UBno-ZT>?jY%0f+%)C$j?0d7(sDQ>U za8-(u2~&)W5!>}0aWkaVV*H8Wu4PzJ*egpZA~nx$OhEJ<73nYyWa`pYl0``2w+&f+ z_5v4;#&A2F9u1D!@74yfHnow1iHxxOWe>_eAGZTJz&-x})1wIhx16+il^v{_t&Y}> ziuJ~)gLAVuiO@>;{{Y8AL3Yn}(w}MaTIow z<$gx`%N?CQ+SNK$Zf)w`)Xii_W{J@J5>AQ>%192OOL1?fV~&y?K{TW_ezDAqK(@8N z(ly`cd`HLpH}JN!Ee|xBaX!BCmi!w!;y@Ci&$vDh76bL`u)8~2{l72s{6Nf#i%0JH z{{W4?b2zs>`@235EYnI^7P`wJlMUHpQd_rS+oiZswNzqqJKf7SiRdNV?PSw!V`RA_ zGQnYZ`(+-1fFKpm+odxFq7ISBrHKY!{ImEoW#Tnp*=-ePM&h;L?26Ky75NJ=!VLHC zo|IUP^tix^cGjkEuPL#}oyOW8`SRwSQE%%0Ot5!?W;?iR9*7PdXMsz%n) zb-AvNOFK&@Wf2V>h0cF;~l^0hQ&$fTf+8u%q3R^uY8VZkVD0GG(h~8+HCvMKW-e z1ci7MJ;84NhwGk;d!4jmmnvA*+oZb0d-BdEirPDmfd~D>jThO$zh6#=i3A(cZ!HL^ zdGP%giR!1JXyl#z)O*B>5rKzjde6ssNJjheqC!@uXvJf>Od?4gbE(y>R@&2*DhWsyW^qw)j zxmM2cg;$s_!4#hpMlRzG5jH zycY{2CQB)Ar~G=U8H$2NzEK$!Y=Cr(Z|yenXlu{5uPA}lfR=OG%C}_rkcIu7&mo?H zTi!r7>E$(&)DkP#&Q}FC4+NFpw7&S+4p}g9hR5w;j-AYH28^`$Pv+=+t{u3p`zO~( zwgq60TEYXD1OUtziSBXKox`iA9RG?GkbWYF^b*lne{Fi+W&&0Kw`0EW$rbf4R10+1NGPwc>Yz)aHU@%-U?0S(g z6?6W?e?4UxG6H~XHTp^%Riv{L$5yP8PNZ{W1@oNtz#Y9ibx{>28q4SLKJUeRV|i0! zPY&3zf!dw452|3U^_lh!l(%N z`Ic2spaTB5Bc!T;(Z#GpZawTra6Y|KA`I@)tMg`{kVVCR?vPuT5zF>~G0QmpIuzwz z>c2^^SM7u>c8;~@5~|lOBY63M%tvwT^&|D@EqT>z3HOEP?I%Z^=pE;Gas2i$u4${bk+ z{@IOenmG5CXV|nU42>_uI2nNt$g3)XxFaLf`t;5xhzq~z7k=w8H5eth?7V^n1^JLq z?HT@_{{T*^u(EQtWb!0V&?7$pxo$vz%l-Pb1q~!_Y>S5)J4F>)SW41{lDu)cnGsR4 z+?0OS9=-aTGc6G)#I0b<+1&UA-vWnPHS@{l^3Ff=23Z{ocT95q*z{gLz4OoW%?h}=W>m-TWtZ3(irSUIAE_91%K-Odh~INT_jF*)&aNKczoVHuYwJ2l-;7V zTOEte3?;;Xt3?R^0Qh+X9CW;R@`t`dkC&hH`o?5@Wp>xk#{U3GSdJBvMpboHP|O|u zxdXqae&_AgrKm{)*^_H&%Wm3NjnX!g+s;-BqbD87^yqT9QDzo3B-Fc)@UYm|Q>7J| zLiqQs6dlGGq%bo_uVnfq^NM+VttsU+&G(>pfbUR!lSYF zxj$dGMTuXH8uUJMGO`A-exu3<28-m@$Goyq9dwrkW-7q4k5(xe?!iY)Y?Q@DrlQ6_ zXk(zc$C&E8YL}7AN5=O50OYkiQ{vdph5%WYlL9gQC$@S#IUJ?1)@Dp<9+bruALDS< z=a#K$6;{;h3vprJ>GvbIewf40S%Ay5dd;Sf#6QUSMVnBIkqaIzNz8U@&m#;o$r)l| z_8hB^uTc`OlZp+#?vk(>Qr!GH`1(bDGkeC7DBsx6Vs9jZ`FDoJQEsEwjv`T57FTSz z;#1Qd`etN(K>KUq0~4l%{okxsyJ)11o;^)TB{p84fefm@jD33srY5AqlCB;HY{ zsDQ z1a?ABI+6P)e1wx-G>;v^Dvq$pzmSdEaZ)FfO6xNAjQ;>@_Xy5g1ok=oane_`Wjgfu z@3e956z$jcjg!r`TiqS)ZK={L8o1uxt8gV)=0#J)$HCI%4*$tfU*Rk!K?N zx?G`c7VY`#L*zAUYpm9%nsQ^|He(;$R%Rc)oOV;!pvqRl{N$kWqSE{PN?&A=!%786 zDPUQd!*M_|ND_9*Am<%;xzqmuuDx}+dN}-gd?v*I0LU7uy3q@`HTisBkO)827dZa_ zUcEv80A_)B0jAeH^VqRGzH=}BRU3Lj2a?*FlgXH5jR;8;uk9_9hV7H|>I^79A-{H- ze^D)G_GzTXHg>t$W==`n*&~<_Z!_u$#(w>K{kmy9tnaL-Se|JwJQ8yFu&&Z5@9WuA z5%ucu;5QK#&g@FXAwKoWMuXI5ZLjFDePH*D}vsw?1 z)u)g9>Tp#QmG**Nd4AlCdVP9`NmglQX=1esG?)cS$jrb-pd2FXyNGF%K{W>wE6Fng;TUCq|Sr{q|WcTgpJx^P9ZeSE8 zhGp{a64~qTBh60LJT|&!2$H5pK1#V`k8#003bME$YjD|FPP$0!--gXLx3;EiTiML^ zA8gYTJC$SdDyQrOvv({yHCjH?py>sP>NO{LrYH%j*15;Q8-t@2FUf(%6r7Rz{W@rE zU=bSl_PbNLYrp5WXI9mzyqfuBjpq|f7C2V+oUb5#r_-fmW>7e->llGe*{vfV3E$P$ z+l|E$YRWKgh25c9g69Y%PkI$>5LEO%z+xyG9cE$T4UuGREW15FnNrk4<(tcKO+w?B zjyn>GXGx=fXh`3aWP_hf5#On?cL>DdQ5}=FaakD5>5ZEoohsQ#(a3pWEyT;o@&cgj z#1ou-I`k^2NrBSjRX|x1X$SUWm3bTjGo1Ug>*?vyopgh8&2MeA@$WSoyBeSI_a;xv zsa7sj)jm|jHX-&1s}3Gq)hp z25;BudZT~?h|(yzyE?*mrVIAg%~)$vsUXBOYZAsFa#bHb_iv$N&>~QyW^1^+0tXV9 zLnJXD&54=XZhnXc3i0&q(NNwE*p1HU>@C)>zA{e?Tw=8=O!7&?mtz@X!FVacsO{4K z02KmLnASEe!jg2Xb4gm`>*u4 z+~gB#C{$d&KQhpzBj2F4FGV%T<7t!RPlG69mjjYJe?p(#(@s%mL#Cgs4hFXpEiRf3 ztWpZ$&Deo)T}u1V4EuBS>oVowb=qQMCdXN2O?R07RSUNxStF#FsJ3JN-FQ&^g;q9I zOddoP>QBvxf(KtIN8-z&JtI5X{{Rj0D13uwb*p%_xI}Vkb+E!#8*-p3=oybB z&sdJ>#1D6$q~;3TkK5(r$DBtW@z(2M<1ucvwCAxjhSiG^M2caKopuV-33;4={@i1# z2XvvzleI7@Ac%O|^@)}zDaBR)3wEqB;9GZuK&mCxEjFDv#2+Wfxs8^^eEikqUye>W0eGW(i#BtC)x#(t9)?Iz-F1cRDAv&Iqr|4u zsz*Fr)P(n@d-C-tdwOG`MmYzRH(n>sEre^HwK44dU{pwemuxzt#`Z<_aF&*XNnB>=rX0L5s9M%EczDljlY@g z2b0YDZ5`&bE5W5tkt2Rz+ep`o{{UnZDIo z(r)bQZlgghaz_W^%&P0jG8B=Lq!ZN`@`g~*Ul|06`(3n#wzf)g)q(y=QcuY$MYZAI zgi?&~R?mNLSjlGZ`cCE2qeatbE_mF#YS*M)>=GZJSs)LX6-PTCD;4j|fyWu?Ws1EK zr<^Vzr>~R;Qu6HfHLBCBWs`<%MI?aytbnu0mH;obdVZaA9w3rtemfumKB)c@-D^B= zT-P=+isN9v*@R)F;T~I?C|s&>Z&UvL0n`IJwW{&p{{UI>#1O2XCS+LnsbR}L;yjtn$~xE>8)ShZ4%I zk(DDW>C@DYCmxH}y2Q|@w=B~E?;!op*mc#d&YVV#Ddq8zT{xM3KcMu_f2rt9*wa~hN_QlW zSz&23%*zJGRx!&Qd`M+1gyWi=H%rKtJ;MDVBM@{kRhVFp93~QydxZzE9ld%ntic*q z+u2VUv8aH?@XugcvW6_k1WSZJFZ~%mv~;GzKOl4EQG+1}SIok88dD#KP})<3$*!7n z9lOsD2U}cnU|BgHXcxHc>(C?r0QD+&^7QkD$U*`({$+YdW{czbBQc3E(X-@aee!;t zI^tt18oj*Q-^yz#W*TF(0$}YFWCO@4I182lf%W=ypU9!AsGmsCq^|l1H~8SX#Jp`+ zGJJtW9z@Jkf_aq#97h};`RK#hs`>PQMwfG$gIl|S@K zS^S$gnm8qj3r47pFk|8ZvIE4k9w#lwA5wZrAPOH?bWBt#D^Bi-BU4Fu79320N$uDi z7RTGJ8cs4at{y2Q`A`&PRf&!rxpwS7Pp?Nn+zwSjrLV$tS}VR&Q%U7lhK`JFBHgpMusPo0~PEx>{k z3V7qL3%N5(ZBa^2@sshf!!Hs$k6-jZPPm!XEP6_C?kX)?dl>bz(1yjT@>vovn*cb4 z6+!O!J^ujX(1BX{&EH9T{yF|4pU$COhLAlw^+?243sy>DQl|xpiWIa+c!4 zjG@Wn0sc>prlZScuJr{aDZ=cF!e(N?i402r0QH=nEsiI*)2{4^b>D82d$1L@-e>P6 z71Ti$dc*T4mPLCnA~`YTcrV9{#&i8d_xgQ$^Z+-$k|ITVz(g{Ln7J{a&jP`UyDwrC zfJS;SdO?N8&votD_+X7u72YKxATUDz0BJMsZ_~futW#7qoZ85uiV3HV2!j&Dd(ZCV zKo}kI>(Sg3a>u;>E%Ti&@M&-AKoD**Di)>E zRx!9+XuiDq)s64GrfaW15l6gh@v;;07hg^;a0gQm+g6@(fJXPPl(YCX__b13+iYYI zD0dZQZ+S8*=PrOT;4}8- zwekMHeuS$UEz`%GAj_y-E@sD7H4U2cNmQZM=}7SKn#A ze-(J^{{SIVXM2ZPH7JU-$XYg-2ILHdNaX9w_j-ZXiugU{YQG*yEqCo{q_1uM{a>loquoZGg^1`Vyl~6zC>2| z>J|RXtlhFo5ZPbXt6Ljhl4WsP!n~$`6o+d{e-2A^;9Bt+&9+u?Yz#}okD_POJ#H0x zA?9RdvBrLvP?2b2y=CiJYi&6-P_VaLjDL1Q1`2lUGuH>V;w)8s4Bj-Cz5cO&kID9W ziP@=2;Ig_dF_2w8JogR#Jsw0>(7{J#)JGa!eD=1I?6vU!0G|AJK{aVhL?7imLofwf z9KT+?{O`eOy}wyz6y(;v==W*kzDcu_W4GNx@=aPOs@zQl; z%e=alvt_EX(#2nW98GB1jF}~gTbyjKAmxI8seO7S!Hkec`uR@$*#IE_0JL4O(Ncr_ zdQ;g&W$o7z&It-4n~_ry!EZs|CmkB)I@s9EWExQ$Lv|iPKJ!7Q(aj}^u4k63|pc7VKdr=>l*Ghx~~?J?t@)I#c5`Otb229voc(O^M8b4 zc%v2_m~@P4at=&E-o6p8!4DPFYm@me^CtJm_tkedQ7hV`Md(!g64gmYQl**Hvl4k6 zk4$#!m$C}AeqB7K9^{s~^ZLrJm&f*3sBJbin!81{^O6R9-UE> zAOr(?#VQgB@`c{yDL_>lJfPfj!r+r{3J^Zaq+R6L#wH16!`>6>3#YZWcn zZ-hL6f>gSdV%R>>->JKoC_x(EkF0hD4`AtDK9Z%nh2w%OTN1}3F^;)Bq>K*V+&CB~ z?e^*T=$)dQLA)6uiK@fF!m)C$8FBjZ<~>LJ`e3kPn2GC15;uxBV2oFne`^)`(%9LkV!*l8`Yjzgw<*zhDmgzL_9eNx z{{T)h!EizC_3D0Fl_t-WeP{?8O~?4h__M}**T<&V-I+Dkslq;4S&IDSO9I4l0db5H zGya`;@0r6e3;+|;TxrEgTP9}Pck%j`c3Rt&eFe8}%|zMUPQ*wQI>Kq&5!sQdAw*>O*@F^5VTL&$^?kZR zg4#55FkMywcC+kumFUxqbDBkl2-J@9F$$wBK<4g#PK7y`}E!b*OU%qUz~sV`oz&v_~PYg zvo(#1J2FUI0wCZ(;_>}Y*QtSHklx;3%zIEl=jSJu@6E1C5rOh@;_O0@$=Hu^9D`(k z!=gATxpaUtTGv=ss!c5&ZJXxA(0K(pVr^UkC6cs*C*>0{^uY8T^V2vMHU6h~9KwtL z0AV`YZEg7^@N9PE^A*}8TD7BwHTaGr{h%oXpL2TkLLkH`eEOJ%bCiZ}FDNFv$F}!i zywsst8(#xixTf3Usc5=lev=(y|_m6b^i89z``W79>PfjXp z(od4uU#zNZAXv%B2u3gfKH`5~n1t;G>Yz=;G2%&Ip(6+W9r|F4FzsACrR9#x+@D^v ztnGwjJINVVPH4;S<=hZ|L)C$Sk(D6MXj!TK({!^W+2lugIRTJCC+bgrmO&>-(N#t` z*VC&#@5K4uTGfe{@j&DxU%3Ie4&JyuFC&n*)W+jLTDl1it5#syy~&z2iitK#FxPZHb%%u`nc9yLbKIT(f8 zU6>Mz8zGYmlgA%!{X^SgX16+dOEcwFr5`y+GE&!14Ag8a@yj3gSfK$~vaJ`p*8}U< zq@b{)AYv5hCZ~Q6o;l}>=7y^Vu@G?^JVAget&E00ez~#q^PIUl`AOlJ%$6+IP?go% zNNc`jc-Qqf!2llYanKb5Lkto{%YT9S6jvMZcG~!FLsQ~tuUbxP7>&bxT$|I77#%CP z#J~ckGX_9x5d`v&jXg1!AcIw3MIf1!*%!~V2kZ=>xayn4#f}5)Y>l)p`VOtXh-+v;-u%mNZ zG9viq#$3~k{{Z}qjN=Mh+Ez97@BaX&O~u&z!!C@yBHy_INIHY1{{UG**rn}t=;~}4 zEbDUL7Qr&{uwSY$`W$xa+f@J(zju`KU5$X>Z=B<0;|nf!hiDS{NBHjtx#RDurt@96vL@liZbD)TO2oi+Q;)yb ztN#EWES#@lB3CM$YsQhUf&TzD`A>;KHkeHo$4_BojMTUqO2EzQCmeDoJ^P-6KWMF} z(8I&rGViaC+Etf*UMZTU?^_PSI`B!^apMy(7S1@HeFuIkX z(npm2s@UWYp&v~2^B269jP|QPxW{~+xj?NpnXg^odx$F4*sXXbifCevH8vp(QTZrh*l?#k`;Wgu zgw>rUup0;+TQ_LhhW$r+^Fttd))OjK(1rz1-FtKm*2absn)u6ihs`d^<^8p-omh93 zIo+ghL??UptT00hats`{ayfPc^qjydfnL+}5x9-9H?Qw2yZ-9>(dm577SY{Q${w}>@~Z3S~^P$>gejJw033(@(a$yyU)QwvMaU* z2{`Ll8B$L7IkEyI|?bYuJK6^#VfMmnQ*cKJ=sj;bjDJ{Lr_(KI*Xj^ zbx_->e$>`K9tv^tL%_Lt3BpH(KHgvTdUeco1I};~Ix)>GjaCK_tl?73l8_NpusLIc zh2@U@6A(H<5?eZX>&I2Z(ae^zV|b6J7T|lZU$0uO4f;-DUpQZmIZd<^Sa506kYy!) zAcEb{5$VhH>3PMe19J-zrUdkwKk}#ZEq^$(4Qc5`3hJUT(M0z|?agvs{{V9@6&S`p z=hR)dYRg2PpGih+>Olj}We}9+@=i+jAwmVne^u;%r>|B+q&D8yo~jysvbTNVX&EWV_#aV zX&*EBn*>(*W(-5DPRA>REA7W~(>M-RPs#zw?W&D+n%@kLIt*ig1?u(SB;k0;`^ zWusvm`=g7@>`Ag0GTVbG29iysFP7rWx}t#B*@FCmo1l@?6(&8n+=63TJy7l zDCDONU;^>R8op2V+3}WP>(bM<3&y$`nLCnsUr{TX>u(Lp&rxL)D|?N0aLkSBqldT> zxzAK(0E2a2vTRletG`KU^Ue02{{WL$+4$;N)ffbFi#HvtTUn4}Xl| zxQd}x*7|h*C7s4#Tou#T#mW`yH!sX=O=3%yT(e7_PU@iKl787gPOVy927)5V)Xw#= z8EvEjZYsdP6;k*r4`x*v&s@zy;BmO~$^QV8yrX}#r+;T)Nqb#fREf-L2gI4ew0Pou zexs?=w?zbb$___j3EV{6{k;3_ujb80y#pdgAHz-|1XztimJ6J6Z&p8knTR&+tSWnOG)Y%GaLstHUd#(u`HZj`yL?+52~mD z5BYUiiQ@>=--5QvDegx0X@kbhDCRbJ&tjp*anQD-NI(I+>f}{yNnq0xOwB(U!H9BW zV};M7v-)}txZP_oZ&H6D*HYA5Y7trdyHSM}CNaEbHv=UHBkD7Q+v(DiAR^&1QK{PK zH~P&jA1W~_>aNc%TJ}ke`AZo7;+V-T=zU7{oRqO!gjDglsCiXSv8ZDZQUsC|x~MFe*|nvIELMPyP|M~srK z!_ec`pw8^7R_U1gYHlX)>avh}8`A)XR;+sJ)_AAO`m*wN^kiLBo`t;%(j)sg{ zmF2F{bduDy`0>`Pwb>IE9Jwni0!VBh+uODWPg@se;@W_P*wpN`OG{yDvMphN{cvunK+QB>&H0mKHYX@RutPz#N-_yv*@itCHW?eEI_K(pwGHB$F2wN zBkPW|W!K|3jONyu(W_p~#}EEOAC{4YmDD#Z5ifkNZ};mLCn7-UzxJBSK(hssAx?Sg zJ0#t>G?5VNp3Dk2Oa{flVFnoPGlBF!{T*!SI>&kHM6DwVa!AV>m&yqM9FGk4?0s?2 z3cpxH(3UOdi1{sF@wGQEBsV9I8v0h1(EdI$j)aUdx$L;_o~6g!A+o!5F|Cco1H57X z0Oq&NHyZ7J@xK{{*5TH{&S;@;nlESZoHA)<_Cz?j=upt2Em589RSi+zmxPyq`9i`31)_iL}gnWCR7Nq z$tw^EPEz3tarYCCr*56v=4S^&^K9?O$~}xWRI9xYe}AHMtLc~D$D-> z9>=dsaeX8QLm_8gA=r~SZbgm=BiAQA$&OebIdElBjM+4vY;NU%D(J( z>ul8O5`CQ1f5^=AqfdzP958-0Bx5O(PClbOasyO%oYYQ{je}olK&FF2>T6Vq_f~S& z8|96UIpgd;6n;Pz=}L`e&Fc-FXaFLaP2GK%KqkGa#5%Hqk|UpYyC5D*(bbPgJ0Dn8 zEj^8``n83c@kwYqwTm)*QLlD9rjffZEOyW7*5n`yt)?KezVVlb`8WRn7TargkX2J| zjK3lm%cRV&$Z}JF0^1!xAQJc_|gta+Yz&lpLGm>AXhdx<&!0A7m%wk6|V8=07{LwE3$J?iWK03tuZp*00n z;XG2w^8+fe4Z*mtZmmFio02_Vf};w$k1*Ky=`~V&T3u0lb|Q=xj;UkCq>z74E~}vq$H$AO5IB+Bti%DnHZ|)vDLP$B z>~qN)s>Xmi7GmIgjz`z@9XjhsqApvv*X-6ipURv_{*YT(6GdW#2 z{V(~9{{V~jdhZwVdeYr}e34p;NG6kG8$1vzj2D(LIjIDHzf=DJ*t51jXpQWGtJyou z(`3~~#?Xw$=xj(*SaKs|=5fVOaHr@G)1br;5%7nSxgU%_Vg4S~s2fdEdX*uKTI{ms za3smhfFh1D`d}WGh17eFbacur}!w*vhpNEuY5J zd`A8GDtVsHS{g)I8}ngM?eZwZY6(|7Pk*OL?g126c+3PF<+O@FD3Y|+>FTbHbxP4Y zR1P>B1C|sKh94sWF`rJ5-L-&NSv&el7r3HAngefGAO8R#wX)e&k7F5^cK|gT<8_PY zM$Z*8FKmK;cSL)BAeH+!_(A-|h`+mUjFZda^G4HFo5>8YKoKL>Qk=_%fcE)njI?4f z$~j>7J#GcY8k6;dnVhY(r&dVX8xJ#%Q7Zfckyb2Q)kY3L`t@NX5G2B?<${-y?sZ;0 zv(u$o@>1KFo_n%G&Q&m+{{UuI?u4J+j)3DwU~g6F2xMhaIvDH7f0DMF-wW1w&BU)2 z4KtUoIFE&ixq_ux{=9kphJL*cJON(ESXna`O7fLoD_<8)s6H~*TU;WHmbMjS`MBmx zasHlPx2EGQ$KSjc<9^+x-}t)LyHR$(kcyKvO?s={Sr_DvNlT0jC~xiid-Chg?^L(9 zOX(}^NEUUjvcvqp{E4jdxg)cns9?7i1&T@S>_NjW0{o1}_jdbr-M$j8S-?c~_o!ACTHMcD5QzO#)MA!Xfw~_K2BPRC@mazh0Ie*-&XA$(@+%7fa+@ON#|< zh07DutW-jb!I>5{DH^LV?5Yj`^yz~VjX>4`azF&>A}P1lZV*dQ!+)_#(n33lR||;P zVDeV&+pe``-$}Go`p9O1YFB93&j!jl{m{Db3KV1$-yy?&G1h&@p<11``^Qye)$F{N zT=O*BO3xT;;`GA1nM#u{_s1d>H=`bsRH`0_(_f@*g2S)J`pLXsU9IvDD2^-cy8R$@ zip{d2maGN1td2llL=WmcW@2%snzS5an`^W?&192LEzcYAjiYTmQiZW{89%J zrsaZ0IdT2mPp?^pL;IY2j-M}tJd9V`rqq7_02vpAYi;ae-B+>l<|<#68;Zu;2d?5o zP&56tZ1-=sL){RiPpmH9*b(XF6mMnS&E<1Vy{aheEXb{DnSfSR3h5oQv{{X1*_W*vQw@Ag6*{H{5 z#(-S&!T$h@_1;tByMH0Ew68|Ssv$9gQ^n^YSo?c`54RqNE!(Wm+%h*2w~eP^!mPG> z_|{eSyE(RzwRtC}58_1)Na8Upa!5WxGMqqlK+WMbNBrjb6wTDrU2 z4Q9op7O9_(6_0}^b;?zv^d^ zd9Kd16T@4bNQ$U{#N0xa9f?o~EZOdQpC@1m1nVB1;Q)c8g_Hc>!##=X)U?RWEUwC- z);uXJ2Y!ECdY+t%`1BS;Q=WR75-U}xemI)Fv}s+;e~hZcs;6N5h#jBT)2apa5@rAk z1~l=%8nfgx&r40KvwGLX&b(1^W>!pmj{U#wjP*Zpn4lJ8@L~>5lh{0GV)i$br`XZf z$*F`JaM+bc&Q-_utFnd;;QfzKK45s7CgrJwjza39<8HNj=(Ws=BdJ@Quva4Tv!C@3|eaA+wBa0VLjIe7;eL*eoBqu zC;tEs8rhIZGAf}UDibD1$Dtoj*P$7S8VRQu2U7}a)?wC9XA+9(A}cxr@pHi{AL*QW zb(Exlcb#ol(gPOic)?IPD}LZ{{{SwUG1LP0Iem?oXIUd07+{~MBkX?Tq5MIHu)iTw zq<~qGl0*ujW+1;|p2z9-_2{?@2g+wMesPwlG?m?6LCs37co~Q#nX#4ncIX&;w1ht0 zV;K-rCMn@RA~!1>yD16| zvauLEV@VZ}qde3%W9mkJqpi(_VB*X~cdglPscCLQu4R(0)P6OJyhPGB5zmrE-IRg- zV1Au>`BiKd+zzzv=bpMS*^ENd)={Y4VRWeT4>k8i;9Y zp17e}qc6|!Mu+5PhH@3Tvu8QaZr+_Q4gjZ9ZM<$={Dy*t(GH7SsM<{~nzV9S+unNd z>aCYpB92F3Bw1CzaQXw=rxtY_j*2%CAytnNs~wE7rh~|I{#h+~Ar`m#&P#5D5v<4r z0Y`6)6>i?A{yk3l6_U17);$q}UhO>oQvO1>h9HS%}&Yhq?rmb!4Y z_jMW#m&2n?yiv;?=ONa3rF_LCq%q>EM{HxIK0!styN}Xpp|Gdh%j-0)hL2fxkBInX z?cTmkL=#$jTS~RamJSS6zDbCoxUClSFD#lUPK;|p0{aM87f!tXr`B9?CSY&$m+ zd3Wx5#>IADZ%9j%V4M2EykEk+lYhT$MJk;40LdfLm4qkze&-G!97y!XOv#i15}3{F zCGq(z#pF`OBABX&XtveCVH-k^nIJq#Jbk$J=~=)S>}q;P;Uwgvw^-ZDe8*bFwEAi= zQqW8f#SqMea^Jf-4VA}kT`RQh+wNoTjP84d6skS2YSs1wSq9kIib}OCEqd%+e4+hB-z=NS(EEGx>VQO zO~oJ0TUAUIL~?v08vy%fV58lSvFWYbkVhRVDRZeF?Qi#z_`j4>rLVK&enSD9TV;b& zx9V%RsUUq-vOc}KS9ja95tAR>I(f%-y~3zC@_nXM_i1lmuyqgfR&d`8gSwRcKhvu4 zxOE1o$+F|(#9Rwrlpf4&cjx>8lVH~X$N>i&d3%3$J$h>sT5ka={n94U#aVC&8X(|5L59wMr~k=VPeFOTWEez($`<8Rv#0NM;wQK-6csm4K|gx ztYZPd0yX*VAlB(qVOmvNHO!IMYfm(c$kF_HAC5U;S>MzF{@rtA;&mNs`^*b_d0YmC z3Nu%@*6lS};fP62ABmsma=;WU2;tklPfTJ#6fgLhVzn|m6){#Tk17_KnM5Gvz)TFV z`UCXnuUdjM6PpstZWJt0QnHjp^dU(k44&=l)^HPUdtoAmkgq?=ZRAM`a$kjGkwMM| zPb1rx(PjCJwz>P*J zub48>G@4Hwxl;cCmD@f~uiMl4Bz=6VEYbYwh~t(?JtYjI_a9D^`AhpyapM{I!X8cT z<>UNHB=sg}gsAGwOw52$n`r7PUcuI#Ue@=9Lvo7gZ{N<2w7X`dfZN|wzHiInmfyPj_Y*s zP`OrW6}*G|jT;9~EOtIQX-tHka)#lK%1BevGBXklm7}k$VgN7wu2QwsXe61eH_3^4A9u%|C`W8{m@)CaOecG(KZy93 zka?ZqM^_oOv0!-CP=LC`ndO!!I}cJ%MUybc6u65WA%DmoE34Aldb&vN&MBe|y^ApX ze~)Xr$zR*;!m#x{E4I#G?jv2Ja^ebfv*!@QvX@s*_NF(2#2Z*(6U@=EXo@hD1S|nk z4`#sW2Z9B1Pay{q~CbeEe+at^%87$l1s{sv1&+Rg?JVBM8CX-Jc0iJZigl} zEC@6qFAgTgv3|nj+gaWHUERyGlrca+9E%KVEQk@oG;qVWe_v1Hr{=UG`e#;D4m z4mkj@^=8g}e@>2NR4Z9aWo~kmBbrMk42qS*7Aik+_V>m*7b*w3a^3rNk2E`fBJoY0 zw|!o5`5L)PQ-W;Bu`$Ubfjn^8K8K~UtAakWLzScDF5~$w@005{*RO2u>->a9)(EJo zuv&=-Wv=l@f{Lz7b0qcWw`vi_fT@=bg>oeZR>`gVQS>e;@p6P zf(|j29C(A@s}~4RNBZ>ilQv5r_5J5wU2DcI#YXm#O+BKH8!D=@0Me-BY$;=e1&;uO z?Vqn%g&j!;QPLhpLID~WcZSvQ){I5uCRU?m*Ot<>XXmRg5tSi{=NJT*!u>lQvmwY0 zrv6@VSBA~8@`^l&X`1GJAcXIw67M@b4uq1 zQt}HQ++xX$g=YHw`jBVFSYxlZ_>}%EflJ=N}t=f$(82C%Rsv_vr)i2({IG%UE`M2cbc20c&p_f49VCnMlbc zPBYUp64;Fm#?e^CWEOUeJd47ABXXNaCl!2mZ9S#c?rgU)Y2aohjA@w=x$H8%Cl+d2 zKYG@Y*)i*X>Uzg#`9obx#(%_*52Cwjk;aj3N}gWS5wp5TzV1N!f4@sTp=Q2Dj3w@P zisCGI@`8CM@;%Qqy{+AOKCXq?HLdt&ipeEeY)2|$6~dFoIGFO}Jx{MqOdKpJI`#6X zEti#8ALdm2bH*j!`89nf%#>N>F<7%Fz9;}El#W;W7wU7>8FGY9Q8@9E++nw?R`8EA zmX(^-w@<6Gt0GIt*0J%DPq*tFibqQa@}+~u=9S>|b*Q3g&Hd!J1xnib*Q9rj23A^- ztCN7fq$>IjmZ$ZZlla*10PG`4DY``}L>l(Pv9z&!KvA^XDiS?7; zvq|RIZZ?$_=D9pFIB5*bNm0k$ha;?T6Rmj;lZCfWj9V>5mOJ*T$t0T1bc*mpGcPU^ zKNZdh1-&_O9ToAeTfid0t)yEGyplp|#a*jYk(N}CoQIO+{_gx(XVBxfKuIR%fkK9n z&bwu$x8vrv=2n+#)@zzqnVLfcF{>eg(fw1h{@+3W01lKx2DREW72`*EHID@Hy4r8^ zMx5W_rf~}1Kz?wlfV$Eu)@^L+u6*^ujz!92>)c@U)=8X{Nxr)#d*UrQ8(qFo@w7y&^*KIb{;@Pdm?P;wB)#m{27 z<4Fbdj+FCQbx-kgNh+y@fgd52K*WK&@g1?z5afen*VbXyAL4pk0!TM^R3v} zUaFDSqFA#$aiPLT9})Wh0Ix#0(-<@;b(qe~M_IIg82Dd{+%wSg%MYlQoNO^=*W}EB zSQc3n@dO|BC%0Z6Y@oli(w?Asd?&4s840Mi9SM{_gUPX1psvUU?m!)|i66Oj=}p@~*dbKl?h&rQRJ@e2O{Sgg5! zA;0#Cb<*toe{QzcyhhzqnB%a5RLgb^!M(5k`Ra} zemNpADg0$y4<8t%{=iVH85zbpQ*jCvTB;h7Se6%YH2T?WZR=69ZnZM-svAtE7!^@Y zIkK<2Jb!i$Pfic*ECGST-A1C?^UF=vXbLo*@Nxr=IO2IL0>7x~tTd>gXL`wQiH%yb zNeQ|H5AnH0VC*|SJy-PWZiUS&a?iKi3b0((>U3*PD-~mhWnyl28QlmJwCIYMuc_xMe#KJ;Jfi6MH?yy;y4o+1m#XaC+nYHh-(1L$MN5S z(DQAS-}&|Quuz4q+nUQ16C`dqtYJt2xSa7Fhg0_%&NcHV$B#JNIJvgF2$KHs>q}cl z9p972>sxJwdgz0aG@tvJ7vxY6sROM1zH{{xtCa02g}tolfLSU3RFr z(ehh*s&>B)L#$Pj2rMLgV{r-jBn-p%l;xA2ni)fHtOimTp?_O7&)$7hz^E zaQL~YB?qA>z46gx28kPuqLhmlpz9fbChBzBoyE8{4RXx_;6ZxYd}d(CtXQc4gYN$T zhkm6@rI1}mQtdf7yq~Ni}eLZ>?x;4Bcs~5Do_@$V2H@ABB z*xYSp>a*0A#ht3*LFfp|*w1jHbnu

N{;!vRW$t!>X@6e*E=pj2ZYGgG;lLH)(@~#RJtDb2X zY`KrVGmpPaZ6GhwNx!$EEMd||Jas+En?+hyj4)*6SK1HXk6DQu{i^*Txm)(#NF>|G zV^i+(_jW8jc>AbfvcO!eQ2n~?n>eoI=YL>_bHP*qC9V=UFtV@Tkf z{IQ}y{Nq$t&kzsWp?=#L*0qGW+-Z74>*&|XbQ`ZGwYsTR-mIf8rc9*P7DM0h)O{E0 z)PIxlxIiPyb5g{^=zl8R{{Z6Ed`oMttFZCviW|JGnNcz2lYRu+hN=sMRf9D?xm0j9^Nl%K@Br&AP~tzO$(RcchR zE6=h;ku5ULyg+x1hGm5Nly$kV6J#CwO~;fDAdLh?zp1Q?WpwN9n^h8GwPtiNyvlq0 zjD+QSqY-KrHHm7oM#Lt?UmCC=YA-!e6;5{rw^C%VzM>8z_MfJE^dPvq*Ig^hY2tSB z*W&{p#wKD|#s_ux{Xb5Mm2}fs1hLn8klKdQ&c^3InooPQoQq`rNj(|? zT5U0jzgW(~ZBtuMV9>;rf=Ncaz((B4l3yTqBy;s6{W=8;WOd$aa-eHTB^Qd_c%nB1 z4Gb~{fg{UG5b{{TjAXM0IA;5L^ce*ks7P-^Ns{*|zWnxaI;M%RG&MR=Wc4H)c ziOFt2_V3Fh>(RC`)@L#`5bIYiq~C>8B5bKx;=>HE{h$Gm-|5#3YIQR?u+TyF_3S{g znQE97+b-cq%!)AhBmxM}b?dRPCqr|b5xA*nxBPjz(MmS5L;g!$NAc{-ENagQSyV`? zEQ!feIl%<=I(LhZaoCvW?OPe;xDf@r`=yu0fUuJ5Q zt9ECKpaAlesLOCncUAPr>qb@NR`n2uCZN@!f>+jCxF>3Lq>$H)JgSP%!~=$7f!n{| zt!4{WcbKY9(n|Ft7>$9B(=aHm2*4j)pV##3UFdp2<7d`eYWZZksJ?9sGg93}3s&1~ zVY0$$<(aX{6;IlnM;0Gmyr;xi(;YfbNiqN|4SLGG`L_N+e$BUDc56lRSpXsgf&~l9 z9^T-3{d&x5xPToG?>WiJNshdGf8nFWcJ#LThr8rfN`HshEAWoEPy_sk7-yII9Q3TX zU!AG;_`aI|01(q6@z;MJr}Ha!W|~_9M_d&EV~ABDMg{==I<+y@(nraA%O;~^tdIaE zdWmt2H=szwijb}flxI0CSbfJ`lN(moTTV=id)4nN`$|#8E6ow$1LZJ%z-J)mw|Z8>4Lavf#I>Ii;G`iU3 zTV>*T2Rdq4g)xjHNT=9O&~!Wn?em5l)!)h`*3nmyo_Nbw4Z)8-x+Q3uNkMhs~)N;;{O0U ze$k33rIn&+RSp6^L5==aBOg(YgSg01xe@IG9vo`xSMD(HJn9)Zr-;#{AQta_8vu}w+7y~J$?GYTC~anFeG3V z2R^FbZl}zs>PGSSODkCwD^@;czVcXb@=KYda6_EPc@o(Ete)egsT_3pMzB1!j68c= zV&&zlRq zgiG~h!Tb7jLD7ZYh5<)YRduX`b$nc~&q7IR*Pj*|u>u%{i91Lup!S3wi}lA%!j-2_QG?u(j^K{C ziG=AiPcN-nOFVS?+i65a1&cd7(19||@q*DKr?=ts~sWEZr-zi2=$94I@8cCzo z$zoX^t!9(sj#frxc43mMgWI=$lyL&}xr`YIOV~v4-<8zNkSZzxAIM1{IsT)Z_dPN3 zjjjS@JJj=UUe3F7J$pA{wBV_dN^T{H{E8bWMh9<0`t`WTDWI>6#{@p6pTDhK8 zt}Pd{I)Fo>wiTgP83Y1O81%u6u_sHf7Z!2_0Bcv`p-md^#PN6T@)~l5%euxndw@NT zJ$lq%#6kUMIsBlH(pGj5Ee(Um9f+;Q0-u%~@F0%h1_QY2@$MRmkTtz8gN~uI2}rPaQSX2E_77DO@ak zjy+%fz&-l&vv*}$tNP3E_MnaT{U(30@x8vgV{WXIqy?og*@(Xr?QxL9(BZz_S&urn z2DXUFiI+n={zdUhekbJ3sjYD@#T-yd)5PLMEh6$|44`@*-EVD~vL&78XT%YxKz1MJ zZNU^$eDdmRtIJL%68=4On1Wdg00Q#upHAIsa0mYYZ>$#NM_Le<_;mVej~!^2Wg|s$ zJ8dCpLifN#L&xK9uRi@?_pt|Urr!_`UE_}#PcErzUFM6Z8+t}*VS*-6JaW7!UQ~Ef zFU3Q0KHPMyrx^zw(S(B_A;9G!^WCH#O}~omO*9%fo|?RR>R`NV%aYH<2eirlogZwR z6zh8U-VSFjbQPzKqBplnufWl2@gh5ZWHw7YQVfQTd+~L^^}+AcxRZ8%6$g+Nr0>M_ z5#P6ZTM^u9)v8Z3{sba-QvlLHEx*SL$TmX9lkRVz=rfYFRjKO>7zaao4v~JNT$8=A zV%2Jfv?cgkEDVG&9~@jk_x_z@F%{NofM%QgKYPZP%Krc-Wn1yQc$ALZY{bT8QQWB* zT({Hx{->yWq%;B7&*3WVn^Rh8r^Yb;c)Q^iZi&2ZHva$^ips2jv|?Zsg_U#ad+{yY zf5WWp0Ss&H@wDFL+KcaqALOrFVWo=n`BdV`#guTpkdGC$qWe)0X< zDA;R%v;%qkh2(q9aM^h-mOaMS#0IvnYi2w}6)07saFIvFO9>m7$bAn?PTf_hSb@}f z^!h<;#I2Ro_5J33RhK1Zd--_CQD4SPOC0DMWJ%z01bV!i{$ms2Y*k1kqy}ALE z1Pn|wjFu}>!{Fb>H@xOVO;y^_S(bs9n-;~E`eDLmWCZcy7% zYx4^GhgtT?fXh2Z+=Iy2EHF-ed-UPkpdi=Vv{}mf>%0`y*m(xk(%#AuBrybr<;F0? z@xlV41IMw)?ay078t4zeO%KfOK6Aet(#^FqYL?!*lgT2V$h{zlmP3IWN#6kHC+XYQ zqRRXz(LV`@y!6m~WB7Nz7fWTXx2&Nyvt_G=W1iB;mmqs_k{-vyC-05NZUBMO8x}@V z3AH~XQG+5W1ZaOzDA%3d^zP1*G?FvBMmV7?a(Ijk4*gQ1s6}1tEt+h2^f2rz?OH8` z=t9<#sS2^Hu}cuyEDI}f^x`^BPyU(_?H+$hnhy;@)cJjGY?Kl&=DHL$6)4xCwbNI4 zUUUl_Q$m6;u6?ZhxexjE=jF+h8--r7%xaE>nq>ZE;!}X9ptJ7AFNuwOhvLo?W zpO0i!;z!%JPwl&($y#gsMP|Xk@1=gf(p4&JFMc$;AymmmtdagiqXsoDa22p~?js$# zxa3fH-lj{$kzi;e9nh0~xJl`0V%3`wBF93Yk(GET4LdKrz2CXIUN#82wbxS^{^p=6 z5kuqF>ZIYSY$MqZ-&*7_ErZiKP#K71*2*zfP8ENn&(_ zJ-{d$&5QVRS9bRCyk~v!%}*jpp=AY{2CUY9+t$S@48t!q;4 z{NLgpGveAg8&z#7Nq5;ZAms0W%oa9g;~qb+9;2jU1Z4|foFegC5^C)1 zWVsz$b?h4O$A)tDa0-G4?NX=rp51*1<@W(v&lo4Pl62ZCN0teUSBK0XY{rG3ij`nl zR1V(oG1EemtP2%V*}d~$54jOtPmMuR&6;vSdP`3wjx#%kNo$sDt45=UC#2!+-szg? z#^uVr7E$j^ELnLGSyQk@VpxC%0Ldiu!ak4$xcHS%98E6t>|>g1sX=6+aIR({6?q0 z%C3#iapmO;8CM*3KfDz`kM@)x+)ZMgeZ(37TWKvh`Fss2_|UUw4yen^+Ax0oaP9+E zq+G*@i`dt!AH}usd6wT><8=&rd)9+RYTT1Jg43yIj5KOEr(wlF&wh&|@CA;w^q7gr zg&JJ5c_m%X@n?+x0EQx``5z>O-~6Qu4f%1_?6Osu0mp+ke*XaNjC7SNl=A4kBS>sx zk5krWR%$SzQu|LFvd_$XYbQAXjD-Q?11K@+pI)D2o9ZGc4I>>UjUm@Bl3%c^uB~!9 z*0s>gV%SL(i^-Z5Dn|mjURmmPV|+&S@{Uy%$nAeP9coc+?Y6s7T2pFk4%+zSnE(*r zx5nIpuP_FAWM{3q7EH>sLOPrI7s;jY==^E#^3p8D*IEF`e^D>(`h5p4UWi~o{Dt2C z0LIe}R^!J?m350hAo&DF73Ktlg2%8bpby>Cn8kr}h|Fk@@*@`@d@K(fHhoWS;~DFe z&N{|A$fU$`P>sUh-|9k~Qjel*efvDKyZlm<%qQOluZf`bRHMUb#DPi@(luW-i-l z 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 dpRuXII}j(nR3 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

A`tZs_Inp6_T*aQGj zgPz`5y@bzJKu^Jb3l{ylXw4W0x*p{{T$OzmL-S z^1784$KhUrQm{g-7vGNl@Gww?{l1+aarW5j0}tIseN!cE6gs17DAg_ml2Fj1{Gs@g z%$>h~K`3eiNDd|CkU9Ok@yAy7 zSRAPGiSIKYKC-!2#_v>+c^J~Z!^h!ojf)8u670T>A`AixWDNJ~&^bXIE}B9y(dEY0 zYQMvu$2y(5h@vX9!9$@wdu1y24m;P<(6inNGU4x~0aS$STT0 zMpmsjLaV)n1LHEQ=lf*-{{Xo3>(h2-6o=;_$Hh;$LH3ib-)|?!@$0He-#XJpb%Gal z9F{1(#~JKCy%G*9=4Th=e4sH>f>CS+G06mc(PedvlB5S6$GH07bi&&~5<6)MOYqdN z_9~1tY|F#kK;`VqV+Xfdw^l_Nq={hh&DOie4%$$$?3(=D3S0BC{{SAT5mU0Tis4H*v+{8<750S3uPo=5 zewgbl-=x|b%LkcwUHW%)}9Nw{6SvA+2p7Ib}XHS3C0gn_WjVwU<0ShJ1=fi zde&FqtzKG_&i0Z!5tR|vU~?k2c@W-%kL%OR8yaaBLZQ0ZFC;c1)U}}{y5cJ-3t+Nm zl!29f&lVRyr`MoxG%Z>mA333pylx$*^9eN4ShugQ@N8Jb)NMsHiTJWd`=mt&+|T-+ z-7ha~&Vf39@bTs2dT$=ldcIfXx|#fsZnhzmr2Bn@pq?P*q>ez?X&7+FWBLx1RN_YB zJ|ET)Zf>BRKTnjcZguo7`3=hY5|h=E%&k-7Nh4K+H?!LOh|Ya8)76MU&H)z3j?hhu zzRP1?3fk!D*+hz|t65tzN}j^(tWWnHy?!|Ue?r);%5zL-}4}%hj+OHbF}<#kRO*}Z{(MVjSmHGIdh&N z20c3BI4`t(gwS~%PW@oE@5^iBj#5fSqBuc05uiEZo&7)8qM$bG2U^xye~vaDIj`Kk zjory>K@p6_Em+y7IFNHNI4kkL4B?OZbdKp=mX?reV@5kitttX^cp278{EdW1etP*#XbK3k?KGAtlKza zpkkhA@60TGb3zrKSPOC)!g~~traFng`6F}Vub1<*q#wxY67<(!21DkXJro{6z1V2) zPkm-|Xl>t!M=MJ>CU$suh|jw?%J%i?xK)a%ItaXsp_q+KsbAU9mtC!ntwpVFl9jU} zsQ&Tg$V|KG43< zc#p3^hqgq%J!3NWEY)&o^pAlnUveKMJv8n0sX zO3Jyd5e)6`BRoz$Iwy5X<>xsRwbzss&h{wACbyr&_EoCEkG0Px;(uuG-=_htgLt#F zcz2#pCxd^AOQ87FS5{ZolNch)DG4SB=Ghta>^{9Of__V{bxj4hRp}wuZFTf2?P9+K z(rzV1^TF6+6`+Cko1>XH4>s980-_HDP!dOW1$H+$c!!XVP z7|st&4Pv%}M7nqn@*d;C=c#(LZhbP`H#%5c~;S^4WQaoV;zNY3q+i_0Lc(;Osg5n<2d^CJ(ePbmivq@?~u3Q9FHiLgyM<6yx)cmR^OSbqjD>D{6+XVePl1Hab;37;S z>{mvvl=8C*wq90=ITF0kfXo-rAE!cuawDjPL=Q<8vT8AG(o0HZs0j`@g4iP&3Qjo> zPzmUq61s0WXm7M>ACd1;FY!%g%-TaYIOtes;KX)U`18a#PLSA?>!5Ej3}qut$CTXs z3;5c$lf`U*AWKT7&X_{ADcO;i@!gy$V7S2qoE0b2zg{nMncP?UgI_u7V8)2w`w??6 zyXUX|V|%ahx)pRpl(`%-pbNyP!Nz<2bKjtSYM;%gwU!wUi{Xk?4M%2jLux4H=^bV?Z5*Lu$nv zTS@0y#RTw@-eFtQZVd0h4jukU@P>m_UJ+3gu zk;=!5J4OS^#gB8`kM!vo8FAV3jzw&gJfiy%FOOMOio<`Ef?1)spd^*dYIv{4-_#su zIUO-+g^}$$dBAUJq4y8cewT9FEyG{GT>k)!Gl{3AM1DLdP6K>Vf>twu*Knn!-nTQq zC7pKi6KU`-kw&T)3Z3&I&T=|6(1kuceIT}=u$UN<*EVR0R*6r|BAhk} z!Dc!8p1mPeA(LB#!)i!ccs+tSBOZ9BM`%Am_KITaOV&AOI8VaB{e1vQIyyKs6to6HjKe z9E@nirerK3BLp}j_OK(d>Dd4xiQXX#4M5s;CeFpoLvC>5d45T(I+lJ%w{km?_30~x zQpg)dwqp7dzggao#mmi1~Ct3~mz$7AC^Ke(P}B>nq#?T(NX*|_q; zg+<`{Z4+}EtGQPei4etj~M>| zNwBl4fp6Ed39)2LT1X0qnIj-(LKqbp&$Wkf)(l*Y5Nm%)-r#z=x zgiSSBX6)Rseafec6Z#H|@Qv7ez7q~pvi-jpg1w5O#L`-hK@_V002)L@4h-v^5QhOD$Td!U)6v08D9v`SCymYI3hAz3+bHl_q3mQ*amqa4 zWPL|M0fcHY*?%AGv|!lY#ML4Id~rALnY)4z4E9`my2~{;h?Tn-c?m@U0sW)UW2TLw zjEFHK>cM|#`V95gIxtB;w-S7zi;UqLa-_>XKO~B%G=XSTYg+0P#5X&r4ohecSp*u^hd- zMzqXEZB*NXW-3z6lWk8vFe_pH1UdI1=|;8VR~Ro#gYF~NE&D63l3A&Iq*R)93nRy# z%;0tn(@~9U7Rn%U$gzY|8phFq+yj+_!!gTu1OEVC zxC+)`H>B=b!f75uoGO64d!J~)`*n)dG_9FEbz3)Lr}+yU9F;#VMG1vt?q&ld@$b<1z4 zZ(fNC*#qtqAC&@6wD|bTKaP3LtwlNOD_N448GiyuWO~fA$h=E4srP_Bohv719D|^4 zy>yMm-yC3yC3z#EN>NRBdM4gLq>`(1p{{TQfokqABPa-Hvj%8)y4Vg#1_&M2~n$#X#eCMR&644`##5=z!{{Y|{YkI9lrkan6O4c6GHhGC~M2ZjX$MpXI&!*u| z$~hAUGyXHg36#u#E_Rg{5rzVCfR!AN?*9OOy^LnGo>PlAC*3ZhoNW^^zgy%}6!D*l znE>~WBpeggWGujRGqHtIdWaPo23=XWramF@6fGi|eQ*cI>V0}qVSG$*T{#KWKanu< zauIUk>bM63)OY^?)2$23XeFz7#+4{-+z!%zZ<%7b3nu`3<38S4>58@t20Fv1m{?_l zN5$84#~{&lV%T zPx_3Gg>nN4v5_IvD@7@1^&d~KMUc|L#x1AmZ`iGW^Hi$K0|_BXMmd?5ethic-tPnKx zQpU2+&jShg+>4O_d}OH&AE9+Q$5f1Vc?#N0a!}xJd0`uRF=)2heF}%KIg9cxScy#X zqQ7QBPGB9tU^AYV!HcUu>E-=nd8kHI{e1rbq_1u3r5k3AxTZ@nArql0oF-wA2Odku z>-XuX#f5{X@f64u(Hj2%Gut{n-lm;f*6T}Ft?0~^CSmQFJv$J8X(JshWJe?oNQQB` z0LMUMe-+=~@#)~(=`6KflKhd%+*jMoaUm3goUhz;84y3UwuFpR+`Gnm{YKh_N>=Z@ zVm-`1kzAuAl7QphtL|3M(DWE{DKtOUH#Q~$&-+MqzcpPNZGXn6{wv8B=ddAUcERH1 zj}pi|02%0+zxweFzfUL#wTqEmy7|kdulY+*d^PlY4RT)ET5_@7m=nehA#gnhs5tv{ z585zdMuHl5ic|xsl`kytP4A1WZGNbw4$VIzs>o0ujd0$^4Bnivk6x2F}q5B$oh07)xru)Y4F+LBPxMWT zNLxt$Gs-ogy>tW^RXOD3lm40X>4=O?Kym=NVy=b-deScxB#`UbyLTAtijiV)AnOxt zqBkQCApYu)U#@>%g-Hf$3lRi!J!c`k>5#+pVcd0EG?A7x{{ZCfWEP5WxT6ut8Q?p8 zPCmV9*IB~$gj)Va@>^Qxqdk@Y`b?hLJADfd`02}?fUApq3Qfx%l`S`mQ{BkPJe=OY zDVRI+QJkVynkUr(-0WJM&((AQvT+Jn|UTVF32zLf{Vg z?T&WG?Q> zkCsI)I|jyh^(n$g_ZvMU#!-IVJmk9_m8;dNTHzPSv&>|cGN{t=A(fgSeVjr1DE|PD zLYP95Jt1YvNCs?Gf0jIMdiQn}qSk2l^r^s=+^m?9Q1O%F;xJ}x=fBgh4<})g_V&+X zA9;?q)>W+k04H5owR;L1cbjPhpERk&AVybj-ZSs{KdXn+rl(+Rf+%$H{bHZJ97P-R z@%XLY_Uj?i ze=BS#c%+#Jbri64OdHKOhlARelnL z<0bRR5&oTVEr0sLpEffl~jOC!>N4FlFyXT=CwdLofd3w!ax>dlfd{J0dtVmE5hDge`GwMHH zh+S(qNp44b<`P_uppwFggtCSO!5I!S{{Ua#p;9_dBhGmgR3@u5f+e{sU8>0Std({Ah?#>FWgx~e{{UClu5}H*qF`=2K@vA1y{*N6b129izP%mHGr+9Q z5k18EW7n;n3fIqU+DL2I6{VQ)HW|!d1KSM$07d9riqu@y6k~l><6ElLY-40gn`?-l zjbO%0)S4+tGDj$3?q%l%&}9W_5l0&ezmU6)}kxn$Wqf?vy(xw(+` zW@c=$$mzd~RaTKK=a(+9hS2Vz`5E9zuGl37E-fX3uD>VG8Yu3+`-egKRQ1!(r@}XZ z^4H6(PcuZZM-sqenKCD4IV`}OU=TR=?T(BH5_G2<>t5ipqQp{!HF&o!1a_03?e*=C zUbfBD>J>Y>v&%fDM~S3GQz1qKKVUfmPq~hJ;B(KSm z(DwcAWfgxddKw1GI{arQU8z z4t?E<`u%z>70InDd-_Z^R133vNp(I&Z%0mgb!U)6d*Y96MbIiw9Bt3zVtEh)3>;&v zd_m2gn-~d@t=C<|dsq1{VWIOA1TZ8PC%Nz6rJmQ4@nT5_ zfAesLdV5d+BX98ttK%)Zx_qn14DtB(#U-<~zpt56LhNaKsbDHy@zt(O_BM_K~$-lKH3pE7-xK zZz$W%Ep07C`SsHn8fYv{9Ds_)3^?<_@9FgK)EPU=JXCL_w;yX><~wUEzD2Ozc~6mT zb`sc&UmU6V3>}^rIKsZjq6d)~9g#YAC0u9)TwA$zB*@|O9o^3tt>bV>9X&>;oukD+ zyDX0W;PNQsRPyYnw?O^6^W=3V7|z@{jVs6VF1O?Gli>P=t+8X|I~zRi36@S2kvRg& z%Yf+F86C;`W3M*eF5w~~|s^*%g#%!Ogw)TbkbnhJLmBeoe5RSl3o_b6f2 z)TGsm5-)HbhEBdcx`$}mso{E8jAhy>{{XT~e;>#*%a3MWzq!4{XB`eq%A3MOW;7z0qpoH|X+9-Cz1z6Ye@qWgPLAph@S65E^k~gJ za`95W8mzC_tbs^&kN*He(6<&ecN)SL2gVVwuXZ`m?9iF4!yJ_w2aKRXGCir0>HR&9 zeR>okwCORy>ltge9!vfP0BMVQ9W~^&Cpd@3Cz}J~mOwdilNjTkf$^s^tvd>a8MuzB zrsCb~;P32OFx8DFl+J0@EY;%A+=$oo;uNr z%H=>v(MkB?Qa-@_d-O=5bP!pI-c2-G_@j;o*Vuyc!UTqC0)r_z=fRW^g*^s(z)9A{ zPI?+vzC9%R&3%#b-W4TXQdo)>+VN=;r#A#QFaq<(pgz3@M;l+dw)2~eUcEg0pq63O zL$ldKG}7Dn-PV-q6-3X-fbw@dNA-5kS{aIsE6x!B6V?;6U5#zC%e#3b+CotzjEv!e z4~!RIZ|%l0(5X_iziF8KgL}7*b{=u%^lRDE%Pg<~BK}Uf!Swy(mwe*~vGwXS?J*J< zezKPCaskBYDtEpJmMc}-HZ}{fLQBaWJ*8b+BlT0&V3841on_5}B=joDAYFoprGh2q zWg&wiW7qcq>DL}6iRm~Kuk>pG_`r@K>0b7JDXOGXOPM=@w%=O7bFA0FIW*OL16 z#<-RPTK#5OurEuF?P}oG`J(v7s?2^>SWd_$uQOLmOELn`iE=oM{_glBbgovMkGJLZ zjSEl2d!hlUf`b`$D7PSL%^-@Uh2;Y~aF66TY!Ke5>(ed3tPrsHRC=le`%&& zj`k{A`=!nK8CbnB8Nd=?aX%;Yk^bYM$&r;!E~98jib%7ezaKgDM}v1| z*;2188kn{bMFp0EHD``u)NvtxK-l#Msd;lemQWO999kdb{{YIh z{{WD*R&^FDi2R-@)u?PEB(#WoHFZIrUY-8{<~!|JPE6jNGiH`@pB2gRp{E1 zyy{kXn~brrAghm6BlPRlRuysr4$y_6b{Zd@B5;aV_>o;tCd%^Uxg2r&=b{+_H8GP* zk^D3MI#?loIT)g_BPEvv`+a`B5NkC6tx3w(sW-?TD_(^VE*9ZvO!I z=nqIvt7}d5^6IyLFZlH`+Nf(Je=xSo#xTy^{v-WEq~yeTa!L z(%6wpBMw+E9sumWUW_r6OR$BFbwJrX{gxSV3)^ z$Yf~LM&l}qP6kFYI$<$1eK0CJL`v0=5V~ zzoF}9PsNEC`e!)$bZy*ji}1t!aM^0M!lC1PV{>u+^se_V@_Zjb=wdm3Enz~r?n7yBYANw+y zK~P&O#|%bUaDK<@*C9PcL2xfg>@hRM#17rMB1FI>iCS0)kn{zi1(&@t=;&^0f01Lf z(}a#|3tKTeD=)er&HGtTsr2eDY~xJ~uqadJC+kC7EEg@*vbEJ`<4;*GWSOCIaCwh! zuy#4%a|7r&aw%g8P}cm2M-Y{)HjS>%ofU>asnvNK>N zP6<72)atn}&bp1{l%%m6#|xNcnmkJxTz4uztNuL+JcZ2zi&S^x)M{=lvYT)&_fD;kyT_bjQ0WmW&sd4Vd(2?}*(-tX@ls{6NBC>o5EO}ps zdjl$pvi9Qd=%K$}kC5xA5O|)N85Z7Ib~XgEA}rCO#36+iH3)+Pvg5z&)!nfPw$d)- zbKiKf}Lx81J1L^D5 zVQ1o4o?cbfc63fbK;h?oBkDISX<}_PU5zc)iLGR)P|b*(6C_IDGJL#8*SP3eL;Hv- z^$!~|dqXQF$o6RFHn{Y^7M9KA{4*jval**iKM4J;_C3xzW;4?4uKuxkM_QoUdO<6~ z#$ou5C(L7;k&Yi$1N8^2&eXo)3%6cR?KdfYSzyUsHICvI?^0F3g*g@#n^*vpg_7x$*o12L%SJ+scDc27#&nov0VsWSrKm$~KX?W6>}&rM|@dI_G*dITK5fq%6{4XFozd{{X4z%V`8Dxp~@XwVHiC zvc)~3>~7nDz1r%fTASw|8|T!Yp&cO{l5A9Kr~^YQb?MnyjD+!NSeEVvKBGNf+ow|@ z?lJ)khV7{2h0Td$df*5dU4te)K0f~6_Uf<3wXoJr`5Nxdkn38FtSl2{Qdnl1FU}HA zlzCubo@vYX^7YSHGOt7FG~!@&8t)S^0Fs#seF62yP3Z=s4LsU`I}NCmtc;kMFu?~e zdomy2?erPx8ADgLHxWzsM|Q5sw$j$sC3qKKu9m~s+dCARL?o1gLQ5)r)qcH6#3OS5 z0ApH8@0#)d08x6*Z!eMT>}$5$bv3HmRi|5M@vKmCuDn;=g9nH8A6~UMC@Q+vwFUXH z4B7*wzzVC>pXaqS`w=*fvLNLQs0j?}=MCE(usV1eiUW;A6QR`dy*!Nfa=XoNO-=a- zhy|S?SvjZo2694#}tas?JQiN0089Rj+NHo9jYy!2pU8( z#9~6Al~IC5KXB#u>y(<%z+f*}roN|Re)7|yUQ1S_JY%m4mywP$g;nl*4y#tuB&-fd z|b^1yz_tT5DH3AsPEkLS+ejmJ8WFXkr1u^mY8KcABcvDM{-apt<9KZ!6{oDLUc-50)|KztaH25>a*GgeaD1E} zzt^eMyE$+4=@{V^0{;NMkdHj_j{@9(jMnM(Cq)ekf*pmU8p$;FR|K>1IU_5K=RNuy z7yueU<-W2nxSKy1d0iVoE9LZn{{R-&@g1j=>*()hwNWQB#%$MgLL_1RjwQzxUSGJe z=)0o9NgF}HtH=Yqm(A|@ojVX{ylNF7D@V22+C*u8A(*6!k|(glxa6b{UYFaWpL_g- zZgxTKO7$|W1sc{qSBasLXq)4eJE9cqqGbKQ{+~{(XCr?el5QrZwfr|+yFQC$OY-!4 z2QjKlz?j*O50)3lZ{6S1^y}yU0PLB02M63dr?c1w;#7?$<9qmX$Fv&A@0b?zQBy1s z%f(bb9z4ka{?XqUKk@2p*s^0@Oxwp{&W_wb+I+eEocAs6<(|Zi@=ipIi2TrDNF^5v zRCe^oUeEn$0Vq#1{=)g$fcQ6=!=r<5tl2Br({C!ZYn9SQjB^7p%G?2F;NQ|cdat>_ zUWogaPuu0PFH$N0C%v;xa- zb@mhSUxUjUtXh7Wr9l)WoA@U5<2$E zU;)P?w^kziYE5=;cs|bETN??iQ>e6{c?-i-Ge{#JL=4^757(_wK%#8JG)W_9dfL#M zm58Y)`Bktbvoy><6{7mb@u1E*eZHM2tJ&AvUpQ8v4><DDWsDw@hVYXy}a8M6VALY#9x)5r`k1y5VNz-mVi%FZH*$S!+~ zkUrTz@#~44WrDV&VXD6ISCWX36jSew}0F)~s!}S;0NGU3cjKh4orpmddum ztb2p-%U+!ItTIBD4g$`ugZqd(jy{K@W-I{*j=oa>3t;=caZ^t;aeM@hJoz$UXOfH$ zcl-VNZ(D#hmPPWfB)2R(le(2RP%3Mx7~h16RZxt3c=13E20BOKKsfRC@AHiOjt?u> zXh~lRq2e;Vuajftmzew*FBqgz@WxQUtio0F`*q*_4ox)1KC_48;jXNHkync9b~{^! zl~A?==1J&0;<%Y0Qz0fs!j2$){e3!SX9_IL#-y4NTrDILHW3`U6vF_(1|Fk7QS?1J zt|yR%YOK#v?R!wpYg&dyk}Du`C|A*d`t(epg%}~qn-RZ^`A3oI{E9usmUXSTPGu|r zi`|rmBsk&jT=Y3HsaiWuM_|@BgKq3q+ioVau7RLQhs!XUPDII$Qn_P+?riq!u_;BO zX5?_pND+I?WMTq@8OTsQKAxv1r&L!M?$?pYS6KR;ExWO6?5O_$Cj_$MD-5lb_9tvE zdYy3Fa$ql{X~|2)NdWHv6>V!~iS0nE6}Y(~Hy3UX+mfzwulCPGST@EDR`W&Hd6$~+ z{6^Q0R1{W|lqVMJ>^PLk|-S(MdiHHfKc{DaJs zX>P1*ZnnBKv9q%ytZZUY!2?ZLwq`pVx{!g;d>O6OJ-yJP1T(xmDVOS)f9_#=t7-IzWDq}#!m#llcP*c() zy|lfww)ZyHr@3jP3MZA!j(8|k1i3!d10SbNMG7bifXrCgo>H-Tg{Z7mX=>er`>{-f zqk;z|Wj%=P_35AjD2Nt8yPn$EY%g1q<+}vsO^9Ph0h%1~DjT;fk4}`?mzXw3 zPe{Ox#OOTW6=`VJwU*ggmHF2#uadat62g(jIf1|dm+X3LGZH9u)-6B)-;@hmUZ>0Q zT$XClsx!wlDa@>k`!X^+7VXm>>qsiX^@QuweR_rHAhO?xpjx>LOc7ki+&e1~%&*he zp?(TMBVB~hNdyhIFeyYPB~-3d#>^$17?phf-(P6QL^s|KPgw7G?G@@(x7Fr~%_yQv z)dvWRMli|1pd*t;-?_R@Az+j{?HQ880K;wkd?1^DAv;!{N!oKfrB~&WPmsKEc8E#H zXDFS!dh}SaaN2f9EvzOVD)MWdJ0FU{GmSFK^GyUx#4~cpGjaXl4|V0~(3~h7zxz)7 zjmM8kF8=_>-^4brQcDzfGFqszB=yL%3~N8tyL)6$`5u>#D9E>}>kZR^tqzf1+f6+i zQfuPa!E6Re2g4!Yn@{7_$l87DJXm_}Ju z4=}9#nYi}HS(K`+FtB0c1&xiwi%F~5c`n|jq6wl~Hstu?lEW-fh9lNVG3_0_PekM@ zl3*&^P=)LKTg$6#8pgJ}yHmQ<1=v|kAC#Ft7?&XrB~nLzq_8$i@?d+YWgkxcF@=$1K`|Pve8G8BO!iJxC#$t zXUFZ&>(Mg+HJGYTtQD$G4BS-6upCP3?or| z5r(=SBi>z2Z3LS2*|O;rd1)VlmWz*arL&#p7pvaN9^ zQ~J#W{lI8HNo4b1GNt6VC%0ld64b3y4Su#pm*i;Vfln43_^^%6PaZvo>(uygG6Al= zei7MntCOakJbKE;qj9v+Ua7UAIfCt3MD*lb6C8`&HV#MA>(z#a@;%Ozou1QUz1i8^ zZ6<41?aXGmDZpoTJbVgN4WNWBOFTl4*2NEH@pM1Wv!~BYN>8FwN}Pw zhO}~^auX|qkbT1*pKgOAUGdSUD55WECF;+$w)Uf=9=s~?dey-S&VYt>Q`mR){{WX< zjlfYi@tpR?z^VIHr}8+q6_Z^JgSD75Rj*f<oEWrn$*q8NdR8bHu=NxLylt%*zM801(C}0lrWOt8uuy> zV&8mp9Cf%{w5be$uYTAbihX(^Fv~B5Nv@%KEuP;k6l^~p3}=*??9cxIwR%oMZ$WX` zz^zS7&7bE_EUyYBJ44}yWB&k-MqqfkWXHvqkU~3Po{VK;Z14TPH3NcyLvQc;%-hQ? zX|`7++Up|xG_c&HpaA0=pWTC>+J2q76=vl`Ltr@AiD7;POmY;FgDafy7$X?SKT*>U ztXTAr_UNF_@_;s1x&5a;pZ%V)Z1>@B$N9N09$Yx*-JEAQ`*obPsnw;<;ekvqF?0b6 zkBpod^!wSDZKTfm(Fq0q->fw~(hoK#HPPx_{n=R^n zPg*Av8|oNDLj@87M>xpr!|T$rMq@QLe-{1^wdC4n+vk=vz^E;Cw*W`&|SDu3JPdLsa7IjScv z><4lR_ZjSd+3Sd$46#rkW>Evv(RfADj#oaL4yAPlP1-;)e;w11w=r5 zR~_?#>GtRs(iuK`#L_Jp$u&Q=LH8uhfI<3#}8W&LR7oKBRO(=Q6={dzs+Z-q#DWLsH_Yng#B%yhoA;AM5`B4x7u$ zE03J+tWx~Z8CAfP21Q=%;Px2*08`U{CYnWlJ$0JL@y~~+k-WEee*#UWhlA9(3aiGU zlq+Da3VN3McJ0(>F$9G)H>Okn(wV}KPT`cV^Yky<0dXd?c ztSN{^OtRrXjAMd;J=}4QxH6^Io^y(`k$U+-p}PzzbsDg_C76CSk;^HCgJd`gaB=B_ z&}L3#bg8(TfIV(cs?=;Jjt4cu)3Z2_@*gq8Ul}N6^d7nDzDhdk1e=2swuHcZj*3;Q zC@&e6W-)|A$YD?JR~?vsy$(#gs6xk!jhLrQs%$wRe&1-$JLGzO`coZ5YK;uDYq-H; zCn^sH3^@$1Z@B8RELWV6Xm83k+SP&@NqVCzRkrQLi610of264N>V9O}oy4Pn6vJZ_ z#d<1~OW1MngUf)%Nk4Dbtj0q8X1FKe38}fKu%zZ3qegqi_|N)`b&l6J8pvR_Qb8ng zd^RB*(SacIQ`n&%hwai>UW8bUQ(BD(pSBWBVT`v~0ccDF zlV(&lFCXPdu^?0ji29z#qd}w>I>w(YwDikd$ilsrStf*fGcI_y`i!2GP!{FZ7;*<5 zk;n1hm&fAUxpp7ot8Tf&Bo9*J@`x;e4rj6MI-k1j{{Scg>t7v2ek{B$6+L}ecrA)=CxV_LiO1iX{*&>!ZN%Yi1xNP{W0m(c(AJ;N_IUunOFQAnh)nlh&sw0V~jf%9$43rE~Qi6697TdX3<|Hc%U(+H(5a^TxJh4ol(3$gUbi{{X2u0m~n!LNX{dG~!|| z6ROv)!f7FMAS>E5Uf!%T*}lWRI&Mt7R^qW@=G8VY8ESqNR8p(6HnkhNvO&9hG~E9H zk1lxpfj*>z*MpZS%x;Gvx37;eJ?vP22&+7Nc-CfHH>a$#v4*tL%Cf)XRkZ+Ul*9+N zRzdIN*!2B+^fBH_C#I%A^^U;npny%NhgtqURhcadajfk+@=Gb8c)rqBO^wijH39F)LG^WwE8U3I_7o>k#O{R&1U{{Sry!E6;*JOC?|A4bC-{{XL0 zcL{DvohA5KgaNk_k9J>|`=L0a3~?v8JqVo9eYoaZqPt2ZC;(SS=AaRf0sT7EBaPGt z2I^ivjr<#RzTf&Ni4-A zF**ysAtG5D_cJy-x$W*J_aF7^(+kHR0nE0i?sFnT#RdxhC$GLA#z@EdlI!-KH zY>}yzWy{KkK@wTiNWNVzyK`=?s%CQ1-H3Co7E_vV5r0c|#yZt|$7%`v;Wj`1ludZJ zUD}>gMx9)5oBWDkz!$OR}aNo0lP+DDUk9r_@*% zHXq{M_MgVJ?YrFAFR+z!t<@~7K1Ktd%OUg_WdwKY=ez7@Bk@t7+|OgQV2NyXJ553V z0Gxb&{R=hxn_xvLs-dj}QIjO38bje0*!%4x?%3_ZWh`-?WVm@fMe9;ECykq4RpFQ7j&+(DILL93na86B`i!2v1vjlpXjBnoUSdX2 z_&MTz4nBvigRKob7|?dKWD zk;kW$7h!8xYU#CIfeJBFawDIapW0s_3nDW2-E8^jqN;?OJKuFrITpN?;)>` zjx?Ex2ahZ%%7NdvS#@NJgaI@llq!oBgbyOZo!RAX;X;g)+qv}mb)?XoD9yL{U-;^e zoK#5nb|9%;OrMEhU^_A>99VKw`hb64yq@bZT)b4(?|VzIVhxXz3!|lP6#oDvQHw(r z-xAi}iq#n`t49($mdSPn&-5^2gTJmi&dio>Y&8}xZ*}cXE3u3eu#2XA%8-kKNWV9Kf@6 zIA0T{kbO?V^=q}{*F~L=%l*D1m~g~#i{imk?(1Cv9_^isp%vJu4bF#4#>VD$HF$%e;K~ooDyp`mBqEB z_qI@9%{7CA@c7b6G9_X=_UTRDaVEx>rE74%;+Cp9^|-d@fcX_)8?EF|RohK!W{NAQ zIU#h;M`NGqIQ#VF?o_c}u`PvyhUpb-w!7W6<-K`~)@6-hj%HOEjwJ{Hzym6y*VnB? zMHDuK?psoBGSR~tc;@Q$y(j0_kv|@$&PjeGt7{>PFaH3Ha)Y=OC&K@PD zVnzD-NHiOpRq0sOAIY%|wK7$qO|V|T{f1s3yDH$GBdpDhO9#_Vr^;?8G*exCyyUIt zTghWu8xQ=t@uNfd^K<^$KU7k1jmUSv{{UXJ;RjG>46FvCd9~8`<>{%Wg5)#f+hej zZ~^_G2F`n&_v%jHv?=#({Q1Y`?zwH?c}kYTy;I>@oZX;o3v;LAK)?B7ChzjtVd|Oe zgYDHQPvvb5SIgxR#C|Yz{(f`KH;*pf9i=%uf}L90Xe6^qXDo|t^WG>s1}@(~M_ZFG z0uK}J`b|^}4!cU^z%WE9@tB7hF_KSmazXogbny}mM*0ghtWR4}yV=XG(21JCu^eA9 zNjMO$A|2Z}1J|VGQZ1BQM&oNB6YYcfF4;Dld9D~l4%}7MGlnQQ{D+sJd}kj{iv~Uj z1njTLt_!c!Z8i&LN>yaD6*!Fd8Z|7jL=Fs&N9|FcUWxJrt2@C8NHx-UvRb2RXj0A< z{{Xc+z}Z(r>M}bp>$ry0iI-bPmv3pbpA4QYv9TqaG|XEDu@b0_)Kin8F{;YL4 zMtw2?SEO$xS}1oK#(pKI(q8dR%{!3AS9cpDR=Hwf8>D=B6!;(as}bFYah{djWo30E zRgJ@qSy&&sQEW@{PGvEohws4#6n#4Z>D7Bl_m(Y5p$50*5ZkZhYi0)4SPFuxM8gcO z9CvbXQ|ar{a#=N}3fImW&=;>O(hqWvl$P34HLNmFb1v36RJ>6#l0IpEE>Wraa`fnx zlS6y$IICG7tO52dV!VlFMw&?(UEWW8jlgkL5;j;4{+$>=Hh@bV=M>_CH+k+?dEkiR zSkRQgaKS-ftQXum7Cu|eU!)^J;`=@1<{DoSaTJpTX%I6Av6WRPCC+`T>VBOoCQyRR zVj*KfS!{TY>V|=Ln;k;7iU$?$*Zh$biE>0JjU;`fjfOH;k4qVqn}|N|gg?Z}yok~t z;?=wqct!h7v$a#s(m8 z@#xZBe#R=6n)29|xOSP{c$nvA!#rcxf%^CBTLR=ZY#%5~n5Ke=mG33jZL034U8DJV&dU zQp~iZkg822EIu%U2b=!@(mE1Uuxi$7hN3S>qjRS^zr}f^MxNY4f5lEgpvfR4p@8>& zMmj86gITSoWX4vt-a`(m`_OHxMaqP71eQX;cR1~g^7{P;Ma*3jse{1PjihivUEq<8 zhC++TMi)G#<%B`s{+yisIy31r<2i-IaH&y~e|Xz#}(MM{RVBm2d8s6q-ltAO0d9gq6-tR=KUwhy0_~qdg zPSu_sA&k9<`FVQAFnv4p*Cj=WmEunm^o@LD$95h~D%aFklHJRxC&2^{$>QL0&m?&L znEDR9Or6IPZ$W>r!g}e3#{E3@_{z?~-(7BMlX5p9c8*mbf;bWg_jewihpLx1A!BJf zVd7}-0JABZ1SnEFvi|_|^{_@@mZ*OBrQZBLo#$KW;OVoPGL!MI>nwi~*6&Gz7`1MSxgXRPMP zItd(fK4Pk4Ei{dVj9?$XT39ZpX@N^mjSd4j^i1~j{W=o&twIUX;GZo% z!bg9IH&^^|h!b-hhw`7IoR*4K_hz`Ao&*_iTuEYo$>ov$HNfeQd*4hy5Da}g6h_b9x#?i4y?yds? z?LR}-V#tmyNFsJjY^U5R4lA&UBqQ4S)YwmE(HDY;06Gm{bkJ)uZmKIuz2SO zuDR71N2#8auuEV@#zje^OstAj_W{C_kJNO&e8H&BJP5ca!y%d7)Ffq_Bn%At;1AQH z2c!Zz#u}*Jdr2HF5kdFJeH0H<&^$EOZS}vC{G-6UcBM+N1=a2=iRj5AqZ#8q*MDd1 z8OQ+O^f>#VeXIVyaNh-L-DL}DH9eiHJ4*L{C3X3J3H!)pW8Nj=JNov|)1Yk*gLq?6 zWbFWs+GI1RDm#0-f_h^PkQKVgYVA^+7ls(+C0RnqIRVIHIQx&MLd#+jwLvcIb`mL$ zMlta}_VI)M{bgM4RXV5RK0&)%QtBzoZ79R}R>VLP93$1+xb6O(DY7bk#u!4|MRcy$ zfo+TRUevD_3VdCoF-yoEXCJ%Up2wneV^nL-VmKFKGMC6F zAx=}FMPfBD-NoqzlB`!hzZhXAJwAm00KY~-=>!oTcN;w`Xgm^xkz3>?6#nn8Z?{HZ11$ROp3Zu8@ zRp4frdIHA^T%YP=)&QV@(|IujuSkBs%ywGMZr||aSfkj&Ah{H6!R1m2_I7M~_v*#O zrGU93WniS%va3f-86M2`A75Tr9({XAhZD$*10phz7YF*joqFZ^0cQ2|o*2c{o+7Mz z&vXq$Z5#ZHtWZdf8foJ*aV2uOUsIAgEb4gscY)hF^E~!GddVuouX6&im2Bnz03;AT zGw#e{Kd8v{=vc6`rA8o9K-ZUF;-nr!Z5!pSS$|=`kN0CALVmq?xm>oh-Nu}Bl=!rp zTRRyQ6^sEsTP&Tz#yj#Lk)EY&iUzy%l!Rh^Bv)(7oR%l~AdnAm1OfLQ{dn|4)+~V| zi(dqGk*0~|fnz+BIFAlN3c;{)GvCv$cQsvRs*UA3Ooipcz6 z8~*^yH^h-QG?4s^h=MrQM(l8d+CH87#8A7!W-V0+)yHCXR3Vq)Za4?Bvma6a06$KJ zokZnAWtYf*__nuS_<9SH-?q~-TfSou$1Qe6Z;T^l!z<)x(DbA%&utw1RcT}kTtTIW zGJF#57e@&ZN`kE!j@Tsn;yA20GYyuE$%cg59Yrn${1-oF32N->l-TBt}r*=&re%!tx1|o8%1b#tm=-B5->4*WtW;B-Oo=rtb%r3 zqE|Hy4*SGaTEuozdjA0TU|}jZ*rDZ<{{Rk&z#@$0BpVXhwzE^nG&F76Yft2*r1{*( z62l}-CRRfIq?NrvC)2l3V+RwED}=it8B`PZ#fzfYNi-1b<@hR~NYZR%ByLZ2yY~Ttjw4+<&C%}4p{jaSY(D@LD7CvNa#Aj{6GWL z_|{4%rEYLTN{|ivt`K0v5$&iIdY>~*FA)?mo=)TT zpeMKdeFslx2rQW>M#yLgYtfL_hFbAUG;Z8cnGXWr^sm!Dr$Cv5`a^sym0sy(tG)8M ze-lG`aMdY9VufBcV^$|%&+0O`$Ghq}mnJ+^TyOi!G33i(4&Sueynp!H#oxu}@$IR?vOf}8XYK%BaqXU%J9jj8@%zPoWEx%g{iaReJ1-N~`2%dcrlZ={5+#l_#ERn` zp>dJx_x(EcU0#4+pGoFpWU^c3)-C=<*l9NV`#1bP~OjU^7fX1Ohy44eWENy$kLz+zN<=wm(AUmhDut*iW-I^>Qi zF0GC+o_GdyMg!B9LY|aAAP1I*q+$GIjyjI8%FnL~--jQ^U74CV!RF&XMq}%N+pBwY zM^y$yst~V=3)+IZ3%7KX*+i*yif?y*-K{DkW# zg0jyl%eyVk3A5TbRx$e+Fe8?5Pj09D=kgJHd4GT6JBR-OK2;Gxx!LOKU)RD?y?ul+ ze2B^eke@=d%et-d}hIWr>c}y|rt?Fl`T4c2>G8>G)`B9`@ zj8H$KhhN>1(whgkFf&&v3&e>I#^&12o+`F3kMUah3hVWfzGR7K{^4X8^!uKP5kp;S zcQYBuTD`hXYi)Ph@&5pWW_aYbL#x2-3%?~{*?ysrdUS1#8*ns$nHX$p;MF$iCh%;x zvB|S7GD~nxBNvng1;a7Ra$qr@v5@hl(l9r;6Rxu9vAO(Jw$pF6B8{z1>RFTj0FO|t z-0H&~0Ckz0xjv^q@akkt%D`QYzBl-NVY}iWnxo_8@%l;>_8K*4>o3C#q9ug{xRH?Z z^NJ5*q1uRPU%MsUtg$6gA~cla#7!|{kBvBw?Tmj`Fgjb3qL+AMjoF;n z(yi_E+WHpj*8s}!&n%`mg2oGgC15^-1MYev^nj5#Ewu^6HpA&HK|8|y>?5pU59F2z zKxo(wJU{9OtY!BCFK?9KeZ3%+wb!JF@)j{AbSIcB0PX4s$USLr4HE{mu?W)6`uWb^ z@KWu1>``L136it4ia=Q~Km-qP8R&8jUfZI7SZSuUb^1tkHDt2)I8zqN*LfLv75J4% zEh_ONJO|UQb5&-wm@cUPzv2cYy&Ey-xGDfk6e8`O>}+Xqy0U+h z4t^^q7-)TXyLM(gx^6r?4xemWsU=-wl(Z3aolEURtpstI_CUtK~4o48GNeA!K z2t0#7yvKmdL-GZP@7Vfu zJZ@+rT)Y?0HXb7<Q$o(kq^!1KtsF7QXf=o6tYaA}gaejg=sE{*y3su}j8(p( zHt`<^@=r0@NmET-tle0i!Az*+sf?VtU%x?{xLiY7n6WdG1S?hKTMreSuBP>sjL8*- ztW`uZ+NmID{%Ep!sxb8FXBf&Yfd0l9$k@#T{%2lg^=h^KLMRRosvtX|VibZ$I zv{8;<2=?P1zwzj>;!r4msqyiHn-7G;u8{;at;Id0>(uR_2kzw5PhnhAX1f-(Qc7`IQ8gW*1!A)c;F1)Qc`qObr*5Lh-12%Kr;d`B!NqL& z{_!7>3+qdPuRd!ugsyJ+IrD-75 zQeR|a9Lhw!yAC<)7=&A6poA2$p%vVzt&XKRVYt&>lu7y3o-&^UDZ+r{o_vS=I^||0 z*wd%1)&K!g=i^i8qP-1$?Ja~=Z)>yED*YZ35;>3_rviT-2q1p*tHtHj9?Ogybyg9iwV8({Ay4-(d+|L6 zUpl(m<2ec1>(AjHdCgBB(Qa*P{8wvU{=N!vMlH2+jc`fsam9lUY5xFNz$2;fmSJo< z{*l=zZKj_e?I|@9%!;+^IC-tQsDVocUS`|b9|S*6)rd*3wgH1-^p63p1-%=iU~z~~WGbpjJH zI$Yyjv!PFO4#HXy+N~6#M%Kt%;&jJ&*|W?2`X>afS-&}oXx(Wzh2phxTT-C|nUJ7m z3KzR^%b)fg5ZVdeaxJvdTb^`DCy@?5PQh|O%8ZY2AGfaKrB3Cn_ghY5WnAG*Osr6l z82TQ=*VCoBpkXMpKBe)kXYu!+X|;Youcqm`5N77YkU*73sL9D6aoe|Uyle9*=x2$Xul(1aYPtYP%-UA80zc_p>!1_%;dJF;$$+FF)Nix2;`->smGyg_U+S3 zz_{~UV|AgiKCV{$B-x~2l#(hxII<*(*dm!%?Aym<2i=rFbg4#996$DGKF_6YGNQ`e&;4lI~>lDT(K*lBDVw#wLqA z90TgP0O0!dwaIl554E(8{9nnpo-buK8mlc*C;IsJNvx$V`ZeYbE& z!XVfr`+HWlEd1;-GS1R4%NA^w{{YzifMA3qSd&WPI9e$HX7ll`?;$1cE* z9p5DK1iVWyBbDWZX-DOP%AH4D%?*7zv z_ZMZoVi=OVa?E)ZiayAL^c^b>1jH#tL&=e!Qv*=S6yvjc5z~2GRZDk+zJ`X%uG@bU zceRjCg3hZYWw8+iuu?EPf3HYqH&vv1D61xWD2!K@|Yb)gGm1Xz_q@o zS)SV4PIZS)BZyS|gJd_vrcnHN8pfu=hmtzvYs4zTnk#xmI~vHY)Hm& z+z+o*XkrD3)@W-rXhc6G)2>9No0MTsbW~H_ao;~qmBN_xjpd;080%_kBh>9?y=cRA zRzLC8eio2S#j@j=BjYMQr#)-gD^rw!4Z+fwm1murD58QhHwJt=4Dkq^j$7-Fh@FP; zK-|T#*=|_Q6#DeSv03l07or4*&&1=1I9ezj3wK$0|^_H9=(z zQy5&Kl04+2amGjb^q0i0b=ISkBQD)!x_fo?yJ|aY)mLR!IfS#-loK4hTP+)$e^)~5 z$Q2q*Q$#U~ysvFfYreO%qed-VOA;50Uj|HYss4rRJAHaAX+n(5k7mRc-rDJSO!7Wb zBuGKVJ~bZ9V;#EGMWbE}$8%!g8qXqnvx@{5Dt~v=KDg*x95jBi*>lJw+0}|?3jY8C za6o@@sX4$OY@U#-Mmd_qP?G~?TO3FA1L^*~x_F5)+vK*qG9;xRj>~Z%lDubXwn)LW zKX(AWs6LtNtWAlbv07Ve6cespxS@O~Kk83m(f&#nV)$918bb2TTk%|$g-%ota0C?kmg!0}uE5bu*1Fr3z@&R{|Jp8KcKFZ(;pM*PsH#GHA)iUDxUjnu0+FGLb+M0?k%w|Uq?~$n zj%KKjb#VOl5jgTW$olsG0B(pu>kjhjgIRJ2pmA^eamwq&6%50ZC$z9&KD|sImikIr zIGr_%JYq}KwoiJan)f~58^YW;tM+afApLRII|D%NITVdX@ufO6EzdjH_+xql=bPq~ z#U#i`Qyf6#78%c6&qqtva;1%WNu|`it1+cGzF{%pUKBrLkMuoyovMJgXPMoSTZ;AC zA2*Zy*RMxQ6MtnObO9DzMU1*}{{VllR16fLA)$Bih&{!zIR%=pgh}|- zBcb^gEC#f!zXd)7hC)w1ZZXI7`{$;TZh+IyDL|{ee50E;w|gC1SxppG(y~VDe!4U( z_|OuiNY5E^TMXULQ1L~2EqBw`_?L5LO9@riZ3pifc-pUrU9aRD6A;_SBzI`(h!RI6 z9J_`peV9Med;NN!I~=~%`+WUn7*JyOG4~yZq|$Xi$({wVx4PT;Zl*Cl!4;{&1{6Se z@y=P39JqpC*YxYoZrOn}FX5)XI!lIdK-RuKQx3USU5@fvFnn;EWPUqKjxYT~jDxVx zUcNTsfSqc2nHQ6gG`*o)X)H$sFuAl+Oti%B}HvOk%Vg()l z0C6P0rx^n>-rdhG5A3*|coG68CbLMvw?voam&-Ge-^ekm400)%b)SSo5}p=Ww5&gi#HM}ZO*gGvGNMTk>=o*J^udy z>(uX%p$aw;=z;ymb1W_98p-Z1lUaH?v@OdEKG$d-HiAwc-2g;*d_liV^-}hSh;|zK zN!z>}iYe39R_l2B`Buv9eO&d<*1yj3F_1xCc%klQNgI#)s^h;;84e&h1H}HL(oRri zCyDnD(E7^WgY#tDSFIF6!L;HbT$KUxaoC=~^;dIT3EHIHu{^-u=DAP!6K%QF+1Tpe z;iM3&Lpb+F%D(k1t@IM#lz>eWu#^ubr{dIg)im`e%}--i zc~+rEW*z&pO{{Yx!P&ffmEe%u8udy^r^T}V1D~MUs zi|3O&k7{xKeR}nKfVD?YobtPJY>}^}MPkCh8!d$&xZ?!=ow|@P0#;O$0&TD4MFn{4 zM;wr}jP{ozSp%NR`8dM;J9R5#)RK4UDp%5g+B3Gwx~QRA=q5)pJ+&>(g8%{%o;*f7 z9+q*C)=$b1a79#DpCBr%+%xJK> zU_;B56I~*+Fe8nDesaTORcUBk*u+vw)#dzll~{LX&I+&YAcgF4)xFULfd2quB%Po< zOJ0g*d9UXel|~hP<%ob_6Uzi92j4v-09zckxcud~o;%i&w$p32KlpP`IEE<;{wa0} zvU)QF&lPXi9eSk~a0K$v+NhJHLxhragv-GkoHUu@g#Au4&@q&tnh%QkZO;^;w$uLr zhpBD%l@=ATG-bp91|KXW{c_#=`uFS4%a54`_LbwvQ%c{*&Q|McXV`8bxx8~6I~kMZ zSdc4}EIBg9qaU;qG1FQ2p$pJJQOSocra1ELZlg`Py$6Eax3ck@D3HlwMF?b+gX}BC zOL1a&<$7KwE&<8+4ZMGNc?TQe_MPHeeqH3rqW=Km)7!9gP`}Fb7uyob$J*y3{{Xe0 z*QSJ0+=lUX+<5eXwKc1|QK#qmlIg0Ea}a$xVA_F{9@u^DRG- z`2F|Nc?CtOd6{lW6DoYFC0}a%gC~z4>CQSlU~WDW4fTo=z3D1pVlvl4STJ8sL+akY zPPDRvuJb6JqnWSh$mf<=_w0J?$VedVTf)Xdkb1$Eift|JF4>kXZ^az8VyacZl16NZ zBEW1PcWz%!h`$h^YvXx=d2%GzU+pLII$C&jI;$GF%zK3TAuI&@jy z9duS;d4Y9(=uz#|^1PNT&iLWUt7H$l$vJlYl?z@Am6*SQa&yqRyrc>T6pj zyuqFs55r+H18_dqT>THI>vt2bj6SBW*3pTMM>{6vERo1C9RMUUA67k&)2v@11&He{ z8jlCPS4UfR5(cp(lGd}Xx~<4Z$0ER12%s<1-JJb8LCeXC^cvWWDM}lu(v0VI=G*J~ ztxt{Yh_U0^MwTslqNohb`;W@G=lXZ((+YxEea0!=l>qEMwWPRI{tK9RiusJwEV@=# z$aVpN<-vOaoCo&j{#fY^-L}9=7qwqLlQwzdr5)Pw!(PH&MU5nt3p^oMHz-@%hW?Uz zxGy3OqNGavY-nYR#qaDi#`f}+^QEx$58xo=!o+NTIiroA>KDIr)cNs;r6ZQQ`bObm zw!_<|p0QF*Q)zNL_ijT*SUxlY2(Vm6J**Uad3^~#>Uw7qgNloS7&*2z@{N3!hO5JT z{aR|Zw^S>XoKk56MKp3pmHApC*m8Taj+ceoE-mQBBD6_7nS6_^y-JmzO(s8R#7^OpT50KTg`=mh@vIO=bP9KZ>`R+K6r&*Jm6(~m@vvKeo zxea?p15?m$COdUBQ7J%$&-@VBb~m)?~)4Mee3TW?su3mOO!uzE0XneO{VjH>% zQ4H3Ugjgna3;6PLA&V&>aswIZ3EmD=%0{&u-Lb0#9WGJZTW(A6>!XE*$(d0hC34Ls zI1KuC$m-(4uJn^Co2ksy>_c*DCKncBW{Oxy2e9));R{x9ZIrPPG<8*svjTzGtciZAORAb@8jz zL2qmjTKuY!y?IzU@CkR_r20FE|#e;A9**+@q0;FCWJlJaNm=k?YgVf>`b4{KXALR=zZz zYBl@!-yL;&-;uIUk{7KhQ-K&E*DP=_0qze%S$Nq#-#9@@G&h_!!YFid(Ek7%U8b>T z=3ST%^G2vJ&>_zb2h{rYrD?9u!U$o;stsOoNiif9?Z;(N&o6TzR?iT_x)Y2y>DLfU z>!}UNqqAyBRygV-Ws(T_a9j?`zqsW7yL^$dvojnr79QvdNId`4&BjMkQ;#X$?w-KiUVI+$6LK5GKw56oqV&Ze{14dBLuS^ z?u_bvxIee=*L#g!T*E@^X{>k$m{9NL9kwyZznn&AHN(nR{EY6BTKbKhZ^xTi z9~hOq&^|^uvW$Cr zJc7X*k60+wZl=@!0OwP~Zhsub;+HkGp|M?y$#8;`Og>L^Dnw_AaCq`8M-l1NO^wEb z^_4C}-2Rgvub{PmVX=o*6vg3*=YpRGl4pTVTmIN1*qzDy4y*i}KrJTW$mtgTT+-F} zWw2H_fcW79+Q%gOv5wtRG0jQ?A-Ry4KD*;(b+FB3r=75lM4|QZgOC{hv;W zm{?o)9j9?MeFW}iuPan~rhkzm0%vjvSrnepmG8hVJ$v-rV2iqw}SHPO`3(VLd+}fvL&{9cM%G z0ti1|C0o7^Zjq1v#AMi1bAQ+H+}!4B^l!)pyZ=oa9j)yOE#A^#5Z)ZrJ z&cE>ae98rY*aOp-BiQxW^77khnK4%T&MT;UDf7-tM#>cwli81cBz73-#k9~x1lLnF z8L9sO9!30eY>6lT0OpN1_M!y*W-pKowgwdA_2tX->&E2&0F$o%l~wxh@~P_aUOz9L z%<6rvwpbn+DPqBpV#_fceVkY@{{Zg0_3J7yq7IYHXH%gX`NeFiVxC8lL5Bi0Ck4CX zDly-#%EYh&W;FpxpOWOOP*;&a3f$D-bM(eJdgJu#Rdu<{+Eo2rWJ_UY4AGBOJ-w&D zVf5|RF|q44R*ZM%(s=dFsFy&pR@8`NNMW*6%%xWk%%A}KyN^?kUZ?GY1tGVO?l_a} z+Ec;-C$X|Y_MfTm>(kmLX5p>Xe;U{RHIHp(=RY-P4_=^AB7w*Q@&cF*fLpoFdv)O+ zMTpmZx>v?}!hi=#t@_-^Z|QD0mfMUWdF$r)S&Bxq75Ib8Sj`e&^8&N2r%Vn<+4r&=aLD|ae{oM#{(O!OvfyZHXW z_5M4tt|Fyp{IZIylSj_YbwkVLhyIRP#&guUG4pZ1xAc^w@k6%1tR5P1)vXPekfX@$ z{ju1ETwwk4o}am@imCwIXCIX9Bc_!rTXO)EIH>I353{1DZXzpP*!Zuazm{ydRW zg5;7!$ul_k1fShfoxSQisBHUw{b6e)3BhOrXD?#I#vwB|zz;QzkF+*1%LD7z1p{8Q ziyfyjLt0XzyFUVoWap0ZW8I8@PJKG%vApJ^w1;h~zeEamX}wz@hXQnHYeb^{&x^6LR4{{ZU>sCvsSDK9)uoT*%VMYE0{Tw~X%$v1Z#0II1}iYpbRi`eV%;iDt7DILKdZnKrl zsxemAZJRQ3T$0?FcgbV->4=vBngb12yDE%hJ;&*um<>hM$?sP+V=-Y|amaN&2sU96 zQ6e!{RtE$e^+6g$1FX_F6udIVheJM*!@i)OY^??DZ#Wk0Eo* zX;*US5OLlgsN7!E(~_(;s@#Ilxt`P_Mp96w6tNh`EdKzmI-&4}H)KR6Qlh9C?t^82 zOZl+FDT*DGAB}AARxJ}A;dz6P{{UyF;D8s-GzSzU?lGCezY&sgMwC{0=**_ss( zKCP?qEw7G^TsEt1RX#fwJ@VF+3>hL|J$~PAkl2Y}Z&=Zk1)w4r?3g2E=BGS(KdAtB z!25Mlk-V9K+J3$H4Fr-+$(Wu*K1*Qz^VbrUVr4bamCEoxHf2(BeZ9Y4mB>c%GRLH+ zZaF?kl;%ZlEC|RdJ=pK`>qCK|oy46)uy`K-0KqgHn=<)kz4uy~7DW*ZD~THcTO8NZ zxb^*dhq^{$D^u56`qZln01rz~ytccLSza4dzfFBw&dq-iD{{SAS zz=0EeVltAOvG+|!i9Fpaqa$?Bp+B!BxuuWPo*4ln`K&lkDG-;|Ox z5G$3@K$DNd@wvj`n2>$nzxKBQ>*>?Mi7TXez4ME^{idgJcUx9R*20jQ(Z;I;kyj(U ziZa{qfv)xPlj!K^fwxo1?aflM zR(GQ{L|KWm>lAIC>W%e1W>VS|8ugouE}_nyA)Yb4mq$wccB{iHY-t#)3vii25ONwO zByexD4ws(@$VGHI)I1rEFmI^R;O|RqX0?PBmh90)Wq4RSh!w~`Fyq)`)4xNIt#4n< zEMc$D`1Ob>R?=2eXLCwvVV1Pf!5wK9CN@X?xk(|15Px#xxav1Y)G65b5~Xy|u-r+! zU&w`BRgH13OvXVZ7m`O=A_QTh3J(&Dd$vbT$JvL;%Klz)IeW60`2BpNo%PxGl2neS zqAGs`DH{sPPtPD8=~$fmo9YKw;{y;j*YS}%Hc}~T;RKIkUteZ~sa7L$u?=Q9&`Apv zCx|K!xHvxWdfBi(33l#)1Qc}(=stbg3YkQuv`81|2+Qf6Wo zV`rh2;ZR$3p{h8J3hj@P*=YV?)u=C0Pg zp1k#*HL#`Yq^eRw7k(0P>Lz zucLSAcynWZN}uRx{9`iXbfVwvE*Y-6^2VUHSr6^~M{s)Q)8R37+7mL#dthWJs$_2I zAMFR~d;R)S3KbaU<;p#E>WHZN*=B|%DEST$K0SG_Z*E7|uXDC4s5PQzhusEjnyD%! zm%=qNZTya1b-O97-NA>WW@3@nMul?1VcZ;#PLbWX2nZI3c~5AhxecR@zr?0(HSjk2 zNoF72tnTtXd?N_e$$2B?#5exlr$hn8Y)4b?4}Hav|~)vT8b;}>hw$HL`GqEMDaTL{V_R+dvABGUjt-YYiO_K5+xs!DryJezDPp}QODyWw-J-+_0LrH=|fnI?QQUteS=qu2qB|8zsjpD z%3qfWFg~k+oagD0)6f&Rh%_~jR=sX0ELN7(iw*FRSj!$fQNtmB+*wx{$3na+^5(IC=mHLeL_3Avmn>&^#;prcbxC)^{`QNOqd3L?^`)Sq+6YA(#M7K(;?jB!) zQJCGIw=9BvI->&yUF^hVT5YP}n&VUac#3Pzs`Gz#ij046ANN$Zw72Pz>DIVBv>!RT zR;R3Qd7!aoUnSS+s?^hz6>G%hmNPPMsgIgJHIaMkL5IFmLpI*FttjO#GP#S?f6ake8!C`_u=I11E0-@6A2fw_mudp?;PCT)n2n*ty5UczYL2aIdBgc z*)pw;UHaTvLyG{xWa4PR_LelW2&UIXElCFydhW(Sg-||EkmI`_>BnxhISm-x=A*EU zr}1l){7R>k$F!*`%QM#$)_?rSg5`429{d|UvC_t1avXGjh+mBG4Sy+HFCWX4QWrge zIX~&1s0JvFB$1>1Zz8X?ambILC#|?ASV_IMw^AKv&pgzsv&mkMCwSv$?(H0YfS;)Y zpem|MX=ki3%tb?M*4N%>;iAn?;pEn>*>d08K8?W_AScw0mRXlp*U&-;t+(qSms1?k z*OOl(-FJw+g%Oqb+!6|Xc?@9Zq8T^kqzc-#kj+B8MPr)Dj%DPt#lx2Zqbq`>hU9y6 zL5bLDJ7_c#MGV}UF*@Vg8zZ?N{Rg5T&X&y{#)a=C*VuxDbZTp(j7S6W$cxZ;FN#WlE)BD{#4dAf7P zTaHK#-1I7>+YRQsf$ckbOuD{~jZGo)3!3efpA>7BVAmg-H1`Pb1H`Um?gR^wu{2sYdQ z0C^LrH$+hJr0Rbpw_c@6FyA+8)voc?ESM1sI5NT#PD6+}_W_*!dLe`oNfb%_wlfA7-n7GPt?$k$n+{{R{JMz_H&YxXZn zQ@UBDj^$ZYWV!+ufEYY*d;NKL9Uy|loA%a`yn=uz-Rts}kN*G&D>Sy6?SArK#FJyR ztg{5EG0YitC+3eH?{Bxc^#t`lX_UK~Iu4(#e{_ndENd~1e#lF+qdm>ls4UlyjeE(> z-l&W>#<7sWusdUpF`lhtr64Fio^n-VV3I_eZR1-lWR+}r4Lg*RVOgxjVo!TcWF@1U zzw|p1>Ussr8$=+~B7|zvz}|ni@l8B>jU~%b(%HlEUZ*^Nj;m;>UzRpPd4(gld>`r^ zN^t&Cd3pKnB@2`Bk8hWkSy$X?b~?RQqWRfFdL4*&udWh&tq_g!MA08;414~)XvnNs zRaaTX3aXpldEh<;H<;SFQnmOgTB}*?LlpdrSY+?9XQhlQ%b5d|BJEs3(j|J;Rvp1`5u<1!M7ea zCYG(q_Fyb~Sr;}qWbeg9jF@QM$8b%K1!v^az$*iav!&se@>dfk)&GaV=d0AZB44%7KSRZ z+dr2}U-AjcMb9XXdy+Wg_j>f4c>{vJT3*qvg-A8Nv!#z=A)XsCsv=K{8-;TAMxZv#EOM5{e58L{V? zq}+J6zMV_SzlkDaJSFTbo{B3l?jJ4w{Ua_lBr!F{GaHZy19|(*b^2{i$vj@VO%X0ygBY&BaOsk@_IeR4FSjh1+& zu&Tj8uazQ4Y~(MmTFyfOM8QF5QHLt6f+c%xx4pO9SlMhQ zGg-9!rkJYa_6wXSRnHOh9W^oV6gClV<&B-=3jR-KpWs7MD3t5umlu&5IqbzF48=PJ z_eX(_A7V#C_=8>EzfXkU$;PXvjbmB7AAf4xSE^FyS7grdED`?zZ9{shpy$Lcdvpuk zqKaeo`NMA6+fX-#!{I4Hy@_`*yS_-Eu`p2PBZ&*E1#AKShple&OG%5`kZGY`#+$z! z*6#(sBi2Q;*GAF@qzF}!c$Ho``xB9#gC<8|q1W{JX*n^Xp9lAs3-9HAH&U1KKAr^E z=q0xx)J7y(J|-;w!mLT|s#m!0*PO`NX-U`5)PG5LCB_LGd3qV2c^8#8mG36qZaH1p z%_d}r90Z`K83i%N96$gMPOrt;6w>}CME%fVx5_&4UpCcvPm|K#X$#$!7(-85s14c&X4u z=jD8uYGfw;{nhdLHk4a%0@$6E5D3=*7sqS?fzy~SF`&{Y6Oih4kccF<7B+q|A4WI; zmim+b0NLw_oqp#h2^t^f1&XC^;QLF*A zQ}Bk%$0y@UCl8Z}1KZtzC$VgOI(oR&UZN$C=xakHu_bz{(j%7)+)zLLz@yQd9W1fh zFfD5Vdy_qgq$;l(kU(!o!OjT%dK2X|X{;w{u!8o=#?$bk*QBQjTAG!C7y3wQCH`9WgUUkS}yQ_RZ{ZUDDACCTnba8K*g4j?s)841!{Hva(1n}gsh;foA1 z>Qcb4*2G+#NX%OXLVFDN^v~1NuRH!7J>RW$+NIy}L0_f(?q)1`K1t4fPhOCb876%C@A!UEC&2m>Q6^uhY{CA69bvdyg0YmU-*C$!VnuN$A9iu{3?0l=t!8CGM1 z_UdDms1PgTDHztsC-j~ELRWM^6&@c)T;#Ah$tS;I!rhf3S&n3o z0t2_z{{YwTj*AEiC_&6i8$Dsw+8-L)qHgrbb)yQ>Rhnb*za-Cd5k8@m`u6BeGo51eAXrfuPmB zStm};OYuFi(Bdxb#OYi3{UJF;UPG^s>oce*-Q3-IB6`%Io?^4|{?rf_$x!6yf}cg} z(Pms@TP8rN*$$KhhCyzRbtM%=D}r%B{LY7DI^Ii z8BPbq8SePUyB?iU`9*=}6Zp*&(%$5IUAZLLOOSG18YQVeR)uHXx^6C39=N+;6S^X4dSNt&~{I(=lfW7~+YO^aInT zss-9CxjL9dy0XSx-jDI5mbyJOuf#^dh$EMOLOY+XdP<-Cb=O;sVdtirPhgeatU<^o zA7Uzi8w8Wz1Hb$9!B%40^z@#t!YHGaB!OgU3hNPbOi4#J7L=beCxL6)QUp>=4~B;FM5HH*Q0>rarw-tYIQm@9k{d zaH>v8!TaN$1^je5r%nr!*r;jPTWj+sN$m0ZV*}~w(lU13&D?T#JVitU zY|*4*$QEFU3^7M1yMnnnD!s5n9FDk}x|`*39M<))ZR%||+Q!)Fu*hkvwRMz)Di}D= zxEvFXm6R3~hMF@O3T=g~t-~A~3)VQ2X$mJVYAA2o3m%V=8}t!nLE1O*+AnY9sU?_e zO0heMCu}QmT(X7$_did+Ly^E66A2Q|;7RE3bMjxdD^}`&Q4x^_k$* zY@@MHHRa92%BhV>GT`_4$j>hQUUDrRcHTm`+QyshINH^x1i=-VCM(KCe3nPf3P=cC zK1Y6*j!ZXDG?V!CJtc{yhjlcRtBB7VaWBZiY!edkBw<|oo`WfHEp^X{Uz6Q`Kx@M4k%3Op{3uc==H4XnmX7jz!l}6=~2;^7hu@>K;fqvq7TkH+19ITN|sAREK_Dj*ejokWl%mSk{k{V z>)-3txZI4D=hMr~+@~r7j{Lg$`3ivP{GWSL&dO>xjR~&Rc&rHcugr%4#0C%UA8wzQ z63_%)89p4ZWP?^C{{RX8Esx4Pdd-_xDrzD&MR+#1CnzhrMqI|w?lvN|Y52spC)`FVBA4!)NCRn(&z} ztho6V`>&|RKD`;td3#6B0Kfv<9=k!T>o)q0jJ0+Z-krFUpW|F!8A86_V-ERW+tbmN z#f?X#nU#^K>G6j6{{W3vxoR=9dG*@ljit4GL}dsFKNBi@SgSW~m7gyisGlg_-w!QX z!1Iv#n|TFE;v2ZY6AeSeLmC6o>|P2;Rnp_?}qWFEqO$_@@Ola8oGC4e1bG7uKd!e8ud zEWp3uYVI25g%sVA1m=;njDX1-aU8RO#Gg*RegKX|Y4-l}$K-hwR=j)v0BKXRv63=H zVAbJv`H?^AK7$SWhy%AyuRUUJw$N`m-0f%CM3=5x7H+bYm_KI;AGJaB2e+m=nGh?Q z6eVuu&{?wp)J$O7R}~IB zYgVNupDh(+WUm>MxQnsEsQ~uq0`X9!+4{ztB$}_y*pzesEZomkh_{xQQK8v%c>(kD0MuPRlSA!k$f?K# zqk4Dgwa8xA7JaAAqw8Kr9*@bk_V0MjI2&h8;)7OG{{S8z$O_3cXiIUx?zla=NKQxq zW1-$Rl`$noqDTCHqLa$}h7Ei)ntM{Rs>2Bj&x)V=h*j>nB>g(h?eRej}n`kZ5;4@RdErO9kty|OVGC6>i{$`w9lGBO|lanG+2j>GBEC=2OvY{dMe zbJa&lEJm@Se3%u~l1JP3Wbyw1ezbW(<<=Eaps%cLg!8g-mx5CWO34`>;u!WHZ%&rx z+lY-&D&bLijk8Wz9pphMMXmVKv}OQ}hGs>;a#Pc(&uFCI?;bmb0Eq{XZzyawACVm} zA&4vhuqbCl0CSVv#a*$U$D_l9sG@(g9JvEFkeWN)b$6yEYpmX3{j+Q~KB1=(J^7t3a>ag}P) z1OEVN$s{?)e%)fZB-pC@&R{4}yAf3NtU^_1M@GjkE~R@PLO(7d*lq>U^jBm7}x zP9arCv4GA?_s&mS#%0ESJFlsF%Rx5M9c$Px8565~K}r_OFyseuh3)H(m6?i*tEgz9 zja^0>>Kh##^{#8~%Tsm9(c6F#ED|YED#&xlH*iND-6gWCjYY54;e}<}Mx5z*K!weGf$W^gKn>0gq4)lEdPDXg`lGY}Z&IziHOh5AqPO zc-cEDN{b?#Degh~^ql><&H+B&zt%SvafOKA`)w#ZOtO+rByviV+rO_~fYL--S~fll zV_T|+XStGHb*Ty%s#r!zY@>{<)v^>A$Mq6=nLCb6#dROI#!%R}rJX%I;+nOsLeoWV zl+wUM4~4cY zjk-dwe+;#-8GVAL?TG zs9W9KwJmDGS^cOYBu3niWh$x_bH#l+qQU`YAOgooPNnp5!E!6KY+Vgi#WhJxVztA8 z^FI)IpaZ(}POYH?4S!MP7OuM3y&{OBa=`>E8Km09zc>jp^k^n8AhGz_2k(+mTJWi=T7V)m0D#yKnytj@sN9Tx1wu9 z=?yFk79bVu%=hE5W@GaFfu4CXoMh5`%S`hWuQe|FKgucjl4DL z*xS67tRzr9Sy{v|EG$H5OLF!v?(Nc-3Z;;{M;wmY6Fbw?X)gZ&jh@Uq2=Bn~G|3y; zHB5Ype0Lv<@>(bIkB&lFj zC=t}LD$VZfW5D%aUfnRCF#!3=YwZ>-Rm#k%B(pl0=RAagNz9x`>_cE=H(bkX4AHUD z1@jM-d_yW2l#TFz(dsTwkv_z(eNM-$K2w?GLNApj^w z{ux4f?KJi3*}GyPl&e`?%cvQDlZSpd=^fL1YQXg|xPI-4_)CSq=KlaIn?*N|Pa@jV zGl#gGF#Jj4RbL^3_xpG1A^!keqfl$ebh$_T<|k2oK2ttuwNYD=Svko@Ma*Dl>A^DcrMZXZ(8-S(X_5 zaID`wIFOHz<#~Nqk8fP`zS}D>0E-@eahdU|lUjOtNjzIkyQ$=&I&wa`;vB(pyrqyV z$)AO#jEsgx!wl!wJukS-ye#TGYYzq{=R-psd1vun%g5tQgz-sRT9^MLDTt}?d>+!0JG0hNMMwa6op8P@$~_ZW7nhzV^^$- zh!#YXTP@8s+WRGeUd3q=WsYR#fGGzOBLbyZAFo{K3Xo3ok?E{-kF_;-o=xRO?}@<$ z4V3o`y6sJQm1Rjv^Zw7vk8f|k>CzagFBh^ZJOa(hXc()?^%W^?B#JGV*xP8t*O)|M z5n5t?cn>3j(Xjo?*!?;zN0QxFm%;{7#j5Z6&io>PvAcz&mRga)A{D<09HEr9A#9)h z_~ zodS6GjmP6R=~Nr-oye!Dao3Hg6o8x_VtAxY$Tm903?G+3Hk`Cqk5XlGec~IB9oXHc zx%gw)YMwJKQhEH#Aq=jLpsS+>{n_Y~D0(BT7RFY`lp}qvdrtfJyQ-@-)q-W#Q-6%c zC(3aVDE1T!$;N)U`t_KbkG7cCQwt*U_gee}i&^2mL9y~1I;{)Cw%Jz=#i~fIlY%gQ z0{nhMhW`LwnAub)X0d*dMlwlJMxyvAY__)bxOs=Z=B_ve)eVd`D8+QdoJ7)FC9hR&r7?2|#j$J!rr6J4t zbw1CR`G$WV4F|jD{v`=#XM{rJv~lFbne42@av*-V>ZJnW7@71>@JE&C{6ZDCvH7cN385v`45D% z)$*I0>N@>Ry=8mR>nh7zSrlvd^BHF=!QIom%%}FJ*Qv7O7|q>Eabzwii_$Y9_U6@} z67w22?fF=SI+_WmX*p5gIdS+0xs}E-fz*s^-k)z#Ll@BJ;SkYad zPh#wL=cOkZ7P~QsHxO8W;9-aDJ+s%N+U5>9Ra}d?#8B1TS#G^lrB>z-E@V5maLx{R z;GXy&P&y?xyfRiMYKcLUAspynJCNk3NpxA7XOAU*a`>RD*g_N^u*^9)kOmx;N&Ef!>ry!`?K+qmZG;jZ8oPIIUWGVV zNqTHiuQP-^3G8Hc!j9gp(SB|#ZPE|no8_lG%snn@iOYwy#24-JvYo!tTH+I43V#7IQ^^ zvpC5E_3J1Zw=G^P{E4{n+ZW{3g|heQno^yBHaJ8EPY&Z4{*md9yzJemdc8g#zBk@o zk3DU#_Lcheqps0UVFZwomBCjzBm(ub}C+7PQ_bi(b1(;Si)KA|y(~51eDa+pRWtowm@_y=(Aa zTwQ02yo#&=DaX{K9^)W)=|w<1wu}x)sph?i3P@QRLzZPBJqKV< zBcJ!{R)oVHY3$7es3IhBActPWf4KDP3?(xC{{ZJA!uU0>6|WdYv1x73Xt2X7fC6wM z)Utz|^*O_BXK4Qb%R+|mO$O^juh49%Y-`wmA!kO`SXT=aC^5`Tg!eyg-4P5%%l`nUr?C}ADM7muErrLqFl#>Mk<63GzxNxF%M<$a zqv;ubarBP*hwZtwB2Fd$J#L z{dxv!gsXDpsMWN3rg8vhY@+7}IPKf*(P1^OoE)M!pJU_nHWcnwqchQnLT*)_KuoNo z`{7s*YQOaTx}PgQ5-(W%N`Qg{NpE-aZ&JpFyKB*9HY-Ogfy_WLWL|CuEDx#m=mx7z ztTUrp={oXF&zNs?bhUQu3mW=zJk@K)iULVv-613Z#hB;x>#?e%Us;aK;#CCODQW)z zjYerBguJZ~0|8WIwtq~Xok|;#>l6zW;~+~C1zZz^`*dYs29sL3T3MrNuo|kdDoP8P zImk1~`+>)o+o41tYHwIh;GG3dXiH{GQNs%JnnwXM`u_m0TIv9^F&L9m>5m2eU(|Rm zrFC#jx2`N@uSldUYcK&q#em@U?%C*3vh7M7m9omRlXxA|PkBp(vSf1~1*9_4`V+qX)@ z3o`~D*xaNlsZ;Kqc?XbhXOg9huOPD)T_<%aWDIgxu>ihLsq53jg#aLJJk0srkWTSe z`4Dd#Z%HL-!9}VId}YYTAfWf}-yeRP$J z>r77}Ge@3EnEK-#McicMNMD|wE?e8<-~&I)Ull37ajMl<@*C-@*{;%zWX&Docr>WY z0Bkl1bM*uLdTtjqELTXZ$T<=X!Y}T;fiU&g#(c){P<+{X>~pYh?)vKc2xy`$$RBeMGXbO$T) z;s?*i=?G#@Jc+MQAFNfm{{R^mmdjvmX58x))Sax6vb@+(N|h&&{{W-?IyDXW>bSR* z7c2R0`7fL&%yyc+<;H&(*1oq=0>Ai_b1{w8aPkxDhV}ang8~%$&c1N7IC1P6c}3Dw zhLaekf+?EaVfgkEMn43o12cf9yAQXgOfFkcYLDvxAWaCn5e~-OlGKy93JQ{?k8_^> zr`&$MFSr0u>kqdi*_W+fi|g$8<^KRAsNprb=pa?KY?41cu+Ldh2Xf=~@)-SkMm+2| zqYY1&mqQ--RPu0i9)5jcQB&{_UPAQo{| zL@o(A(IY26;g!cV2->L9p3U?Mp(fRUn8yW|BN7`>}c_za3 z?Zvw`cCI6ifWVWV77DSs?SK>0A6~7+kMV#M4n1U9U-A;ozes%BU5L@Hw7b5&W)5SJ zDVZfj;z1+c5Pb)yOBmPka{mBtA6RE2em+0ze?Rd7^EW$9EnT(E%ir587#ftp+2DdO z1ZU(v-Uri&>RkBvofenQRomp`EVZ|kptYHcqlYTdoW@j<#DkDW+o0kqA0s*vwALD1 z4$mxvvr5M-Sog~lob{Y&P}*oVY|A#5jqRV2;*PnaGR6#a>++D1!_`6L#GGfL?C2G? zx;vTO<+88?akR5uPZ>)Vpr>xcW)%Mb$F({Y`B*L*CXIWcnrR$TSYYpbv2@kbu%+d6q6p`F=5C?KK!xLeZ3xy2_zb;Rpf4NC`ZO`r!It=jqc#5G0h-Uof!(iQ%Ij)N*YKN+4q&Zo z(|^`zC;02h{{W9KYPY*FPAdCJqIl$#D^^ZS9R5UU>l*R|uiNX?Z=3Qc1Rbn;%99X6 zX!;*Xnfcytd8NAtT7%nhiR{{Y;HyEy=33|G+odgScF-EaKPZt0q=Y5YMoyDtf;w2>mJ_w{CfmXvnz zC7b(*7Aqt$NYt+{dgrDT$X9wkpTq#j8*33ylYEW5zco6s>nZ%sVb@om%YiQaLFuDw;7nSkOB;VaC%RDjI!uq_L)F^ zM~{TqwV%QI9q)@;@~Z9lm82~y(kvLLIADa8@9H!4KTf<)`FVgQ!BjQtJyYA^ECm~F zeI^;@wk$=lvsO8HL{P;wcu6q@56g;^`-VraUgv0_peA`e)0J`u_KLMX;p2wvcWcQl zxuaCp&aw#78A}Yjpo7VH{W_(?SgpFnD=7z*Gsrc1Dw7eXxHdI-#uXiUtWU~GY@d1J0MZ^n-Lv#XysWH@nk%rVgVTkBRx2XCFiQlW?93NBey9SjA4oT zb=9R#W|~D$o3h|ySq7(B2?Z(hB%+If{OYvjH&w9&;=e_mgcSwKOg{7B(K)&ue; z(}Bt3KArpZ6JT{~L-!qJ12Pd!{-a5`e-`e4j%U?cv*a`)*Yi-+X(LF>9AmIl5_jUn zXTP^O&t4{Mbmx_~ksfo_#^j(WOX=w|zvUkUmq~wFtUc6^sw)URWQeM~!Jh??NvpxRYC% zdh%VTlWuHjlg_OEcCMDNSfG_I(i^sN_BQzc01>O)C|!4y znPai!b!h1|emNX>C@j<2*~87}nhsg{B>s|+uhn|A5sulLL(|qu2QU>y5fynSkN*Ji z?bFnUV+HXLSs~cbW@uT8h5;Ob-A`uw9;n3p*6O_?@~;M~xVjg!B#sQ_9#feeoPbz& z1&`mRnh`|HKZAKL?@el0G>D0@*heKx+vO^0 zcQcsun`m^B>}kT5^}5?3tnDk71|cE%<5?TAjbz4Gwt6qfrKspg*udaI*wh2K-XNYL zUB9>S2+%XDHVXoMMtyUh-BEEOF`(MH9yhyUR@K#)m-y#q%S6mMh}}m8cK-lRr%8T8 zVeUO+myc7RJ5M~KA0B<}wa`UjHIb9}u-SEafWZCCosK)7^y@J5;yL}(G8GJXeX#9l z)<2NzqTWqsS>wDzz|yE`kr_Kksse@%<2l4uQUPe0mGs_AwX>RPj2 z67^|?Yit;070(qsNy+c_>CWm12du>ef=5YU^1l?`c*dVi@?VP79zglTF4`6Y`{{x-x)L=o2`4zff6&wl*AgRIC(o<#dV+8O{w0jyHe38E&WgUH{ zx)1TGa)-oYXc2gOT^Ed*2lx7)ze`M5-8RtcG_xyjsM09ZXpYLJ%H)>Pzxev*(2hlN zXZK@@6$~@q`nz<@nRt~Ixu^)7f!ARSwtQaB#++KH=UUEGOxq^`SFi`)7xZ%EeLHk3 z`2v#L{{ZX#<~Iu*e!to%5w&{_B1ov!-;!sMS1NrMx7YRhbi=5EG%>!nS7*vLHjcIg zk8J1XuSN`_4kz3cH!r8Bex}dd3@SN&yAvOW3ChQg{!-UV!1uTR0LItYJ4Ut{%r;mv z2pg7VMk9y}hulYAb9d^tSNzKtv137x_JBX}*Ou;fGwZ3q(@6&@EnAS~6%~lfNZIbe zat3-Y{{WE0i+B1#{{V%Nn!k8=gZcbEAFJEzWTR7IQp&U`H0oWFWzHps2lst4I;SfB zKs3~dj80IUbOtr@f97kxa+EZ?*P>`;i!5+P_a;m=$>OI{aXkH)Bbp{{R_nKZ~@NTVdqz{#8qqCs<b@Bq$|M-A+2JniNcy ztmr4*ei!7vH>&Wu^tQJlhIk`Ym1kvAK0%oC%;z2Zb%0e|CbM-uSpI0;e}(=*+%0Mx zMX02K4V4q4MJ{nHZ2Yt9mB1W&Q9|E#;M`|0zi7~knXT_6@(q@rJ$<>%^eMQJ3nKDX zE5UOnIg!M61oS}_SdpPHTO%-|u#2UuBrw4%GUjky0g2A!8FHqOETkb+%iRX~bVpn#;jPhq@B$9jm zdK`_#eh`&;8T1#8e?C(2?P1dR@p;yqY-hP34zlpTX%0%Vgdb7Z^uAT-KcB23x9j~r zlB@i^z0`Sy817fU9NtF+G0Ac|va{8iCtR-__7g@utOubO>ys|Tit*BGW72`Y;$`w$ z(^m1VX{T%E+etjKY+!2q9uS8S1+ky*E01wpc0Dz0xhL)M(k+1E0Nz-`a@2evnn!5rwicwG=*Jc( zivUO5^n3u+LF8vptHRA@N^kNzB$aWMjB;a-X&GQ~?eEantlN%u8>jp>*V?ShzmC>T zpl})&TzpIqe#aR90G~?5j9r85uLd(C*rWGffelFYF!;S2bIz(Jn#FCc80GnFnHLQl z-wOb-r}XESev8~(f$304P@~s*S7_8Y_}K8sApM80{Ccz*DKXCJDn7%FOAtzutVGEI zym5yrIRYOo$RFH&GtlEFj-bxS2Q4a3KZ?&wb~RGig4BiM4Pujv5r1>O6_?ff;~##M zxyJ(4`onxDm|OiJ$49@{Y^2p$tjR5QHeRizSfpMHo=mU*0JFazuSjf&%g+5H7aOZ_ z`NUTt_}$svNn^=XMlcEO{-5d75y61TP%Ab*S4UuZ401iNe?SLGBaYFa-tfA6CfBsf zG-)(*Az2_j*V8I;JurLrJuRD<(l8Du;~aSA=))bov^SumV_z+Mbaj$*V`5co6^!@& zpk=ev*^rVHi@h#UiNFPcs@9`w{#oT7MdwoaGh9kLYm=)blOaWch~SK_`6{SC;n!pB zg0CC7jhfBWRk_(E4W7wt$p+f-l0>m@nUze%yUdadbII-xq3Pu-L8MT7)awvehV8YH ztHjnB5Y0J7E>RTZvB&KL>UwwpYE48EPQn+gtdc0Q7=B8~h6ovXB&=H;G5-MQI&&J+ zq*9C5t;V(?6 zFY0V*{!PsDM)fyE*-TL{XGKri;rgBVm#}mlBbSIuBVRdDmN?yW0?1l5CUS9rGk``u zo%(2F*X06-Nj|}~l37ElhIpV6C(5(3yL_Mzk%3S>rM(A3kC-0vF8WQ$#Gha_IF3ov z0I5`AkVyJ{#(F&7E1&KY2wN3W?C=FV&k?_MK ze4Kk@-1r&q*C-%+jhV!PeWOU`zP|pBYVw_j<8M;Qj$KJZl!u6Qkjb75ubgr9>3@(h zusyzUgBejo`@E;rdancVM(~gG&SH;aJ#!?L@CvgwA0{$=z&E=dpYPQ_iCA*4Uri#$ zRt?7eJ$0WoZE4uqS-EaIG0I|IZ6PZeyWSxv{OI~#wDw1pzJ%ng2#a*~dUP|rnKPM7>M{YeNxo~*xx2?wn z;72X@^r&Ukn|*rr#;gjJVg_Sg6cMCR7Qj#!_JPam(JB0D0p1G(k%=2m+B&<7^UJ8# z)N9skNAH3pSC`|5-rNc9y@z~syv(EAbcci!?Hb5yMXi%tZ}MeYmLugQCt%KGWjHm0 zFQGZlGxq42m@utJU+XX`l{ca3{UXRCa8DpZV?2I??oUpzVra*EjkTMtMP@b0uU04` z6=M2Pr%InDiL2sJ-{=4=a;fZPEf|V%d?nmmc5;r%c;uz4x4{ekHOqoN-~1 zgWK!SlL${YI^C2LMMlgBlfbLNW8gz8o=VK?`@eUo>R`&&!>pjI3RBVrW5+iXsMp$A zr!*Stj_Fd{q4?!wBrrzwC2`h_s!tGg6Am&p=>V}6pM<{*pzK@@!2Z23RlwG+Llwr1 zkc>xS0mgHYk3-g!xxuPQTE->DIr1KyxZvZreubAvK#Py7O

?r0OCL$zvI?p$DsgK{HHtKyY0TRfBtc|wMuPQGZvc6 z(Mr`b@?+s#DkPF+)d#NtanmvO>JKmW5qYz*1%K8m*lg`3kBTW-U~x`pWlxvPC$ z@mlgcQV7FHl14)#I^=sv@6$u`TUer;8k%<=#?#V{yNp&0p%5s`6*wKof3Hb&0g3A< zMx)!kiPB1}yh-nm`E)^nm{z24 z4|!boBeCc)iZGvPBx;Do<$q7<9;EbRO-vRf=mhe-vVcGbS!eu26NZW-BvC#vzaYU?LEqP? zd!7IV^OyF;$t~qN(|P9GO|-4Bt-@PX1AHcA=AQ6D1%0_4`cvcNDlu~LF=o?q_Wl^MxM2Zv8l95hoFy>@CGc z6t3#h$chX>j#X6m_V?@$K&H-+*ihCdn`9>fNtE{Ns6TJ|^y-P_2A=yYAj@fj=dv$g zIv^ku>k;g?6Bvv_LgkNk`;Y$5S~1qMx!9=N$9$IFzOAPkfL0JwFigdL_nKtyY$^M4e0zA5$MD*<@NV6ixE zS>@~Y?cb_Gx$6*J2Tz=Juh?im#XMBkp4i$;5ML}Xk)S`@_|8=2y2rMCe%jh<&`mz; zyO3_I&ppq`j-*=tD5@fW5|T;%Qa3E+nD;$VhY+g1Qf%2;?zq5p`yI}jTMUlMZw2@A z;>#p#3O+}0RF2&SY)bY20IVz-UmgDd?I!XVuWPK`r6lp}?A{4H_u_VvHJFZUQ2k#A z56jT>jB2g{t{`-jWoAM}@+YjU*w^fJ`xr*TqpZ0}g?kE9<&5`MAo{BhuT?J=Q^d%d zlDOnG^_n(|#p=E4{!_5j!v~MwdD1GdN??vTRehv6ELBm5*RLx!Vi$lUkv)t!L2h&a zmYSYJ<{nYv7PaVOO?H8Pd8B!kGLUd(P>IBI$A44$4x}l>i#9x^LAhNDK64fG?H=Pt zw~DsYkIZ37Vqqf4S~)$+#gnkl+pll4$gH|g7rVyF)MN?0( zIJk_)BlI~V_a2PnZeWA$@{10?jDkJhbGSB+$P z&nVn#Ue>bg^FylIg^OUZ@>Td9!D(WT3JJhC9*3qjAix#-mv4`(T4X9pw$0+iWwri5 zxg3VlhLKS~!=wj#&f0U6Zmqsj8Waz6U}s{*v3|n@!J#($?yqQBtjlnB9irFq(NG zvc;kjGWPcUI^Na4AvR9ayRZI0P#PF?eoedHQ@6U>-H7b0&m0qL*$i`p;sVO51_EQT z>a1iT;G<3MOu)G2$6czU4Fk{Asnz^OXH zsMn!tI88|rScgefkdcAiId}ISnwd_w7WjshIG;r#+DXJ>tnqOwo%6^J?e6>f^^VF` ziLhrPFknEUCwrIixEEdcbVYlWqi^zdnnNPkmpKo>{@&fNI`jCl-nvWeO&+t~lh^upN(&R31?#sx5>ZnYhI_2_uBD#|YPuEHX|qdvwLjcyg$= z!VB@S1lj3VSGT{Y*{PFbT!NRcQV{x~~}J>P(2iv>wCjD#pvlQ|}&YS{qH8N~U%ybjEtbPL9jq1fE z>!l=y(Im@UhB*n}8E-?J^dT;__K&R6{A^da{UBQjw#gh+u1hb;Loq{EMknG|_u&S6 z0#18$#N)Ud#lI~9KWq-<)p9-=9%gvgn@QP~!R|j%j*UUZZ~xhw&#e>nvZf506!Oj>2G4f3&zg0X9 zUtwPSZ^6V-mU2Czh?60G4`b3xa?r=|A=g;iMIP>U)@tlKG?rqEiz0#S>^;B(%j?$xt;{u1&y;v@*J!ogRYF+*0K;u%k}6O%p{+$E zAHU{R7#Tg+zo$vXn%JrPgll8=h97B*^m;qH+eVz%;@80p5?V9gW>}R+cH_i1xzE^l z>vH4>EDQKS#+;2^RJZt;{yVGT+dI3tCVwZXD*ph9tcU*q9%aTDNO7Ec{d;!DQTHE_ zo`>3eAMGE2pk7CDf7(6%N%Q;OdE(m3Qp+4#>P2<&Q4JWd_EkeMF{#f3#Bp!_o|l6O z#`Jdsh)vsi;ewz0Q%kqTNctS~e!7-s|%&zi~Gu*NzJPUKq(jdf6Ib&0K5rDqNC{{W8y z1qmEMnI)AlypJQ>mvYDcT^8M7SEMeMy7js?Y<_G|$d!zWlUOM7SBtS!Q=UYTg-rhd zw^+^vY4n;F`pE5Rg-v}ujP_x7*dcf;Lc^McehemF+%i2e)&{mdlL(;?@;@Hi)ZVR6 zA`3%e@kQ}eiBd)}v_RKC+B`kuh|YO^PfJEpEp~>=Mus*`-|_NmHTv7WIl}!|ZCBZC z*2KJKna}oR2&4Z1s{4I?I>NauklJ}d43q^*JjMnxa0QC?SrlM45AGxSaynrki;Dn= z>%@Fg$C0Ma3Q$dFV_LDXRv1iUv}74xPGx=h<@&J?VoxK2Ez6|W>?mnqXyJ;k&j!RLqYuT1`Y|N0>CvT?7C}Cc8iCLe7VFIs zew^D#S!qQiZ+0k^M2`t0+^RrdPrt85!pD?a=zQY)ja{wI)fJYjNX7+xauMY54Ueup zdR3Uop#K0Ie-cOK(ccs7(v6DMjJ2W>wB~6~?TCD2VUPRttl5HAk(jWTy=7a;JbK@Z z>_vMDImnqXN-->8oD`Ki6n@9It~z2kBE$zuG+}e^Y3=+fEq=n(Rjz1QE}En@2RkWd zLerV;1CjAyaSi^R2w!m{uJDUm(djD%OF00k;yp$b5TpIND!3OtMu{X|N@R<%WzQ0R zhE;!^iJDmOfeLqgL%oENH!=Ln%jQ+m8WlgwV z!p^Q}>8%=VRpNjp$7xKzG{BP5fK`4lK?9F|uuU_~odHI|!}(BKW=Zy34A%14d6)ZiOz{tis zb*867wfft;i_mGjW-B(VY=U#vfEk)-!8in9I33s=3}+p2^O`)Q@oxOjUZY=DYgFXZ z84^UU8a9ZBZhs?3-?K9hcc(*3znt4)ruRI`~43HT6i1j7@qprz}RRKE|Lw0Awu2g}TyS2X*)e%E2IZ}9LSl6G+6LLl@LCz0d!td5_ax2m{*7>&Fd9wL-)@Q%4Gc?wv zEU~ z2^4ZED%?uR8DLwOKDfZb^k6~dbCWD3O7SJxKqQ{Uf%V64r{AubL__sj9i`e=_7zYX z+O!X8$rx2*fsFP)>Ciq!QUU7^fnZ1*Kv9Uqia|WESGi-5ew`VBM0etLD@A50Co$JC z$YhG#tU$^s{@DZQe*I0{sbwQwV{s519->}9l7E`^zH1#)@xxu~R%(c^iu%;!M7CQ2rg$+n0S?WnR0pG=x^O40NeHTnu90g_kEZ0p9}e5O^=hc zXd?LjNu&}8Oi9iJg3?x~)KoV0Cx>&g%DrQa8N6{_f|-HJDO z7!uYyFyeA~D`T0*TZQQollpuyr zo88Ch->co&ss8|3B7tN5qTQ@^YuS<(f=Y0PFAZkrfE%!Hcdk@^@2^CpRfBT~){Oih zH(H!=n4*?dC|I~qN)v*>0mmb^e_pwogyjpUmy|AyseIQmJ1a`-D zdAp8LkVbubeLA1G#MwY7bUXO|Be3KMNMW|oG@HTXUS_*{Fp9EIZ}MfMLN?*s1bh&T zdIkRgtEU`XG4UQNrI4_+S?m-juIvVXXvfs{9T`vnu8~}cCgqkt5c8+-y^hcE z5BQx#@P13q+-z29vA{hkC4$lE3-G!Y_WK>3Ic!g=xcci~m2$F% zSFaEPM4ltXxE?;{9sdAMlaz%3k8rW4^o_(h5;isBVD^mQaX*kG1vMhEGYc_I zbUr-bxKP?_o{8|Ie(+Z#EQ81O z)9tKYfn=pnq!Ld50GNIhKMLd|Xa4}kH}sFG>#{IV4!g`nw!Si+tkThBNMK^QFfyd#jB`}`pc6-e=5s1J&(YEr@8KXW2J)pMU8s;L*lk* z$)2vmNkS;E84On9X|(R~v8w9$4Fdzo&5cJ8M)2~cL#YzENG&^&PkMo~T?yx2LeYmM zaseZ_`}I=}LV=?YrjD_e#c9CN%^W|AC6X*fT|p?Y$TF&|%*63wj{VPFh@QP7@~>D< z+QyRAZG4q#tQTU8t>1evmO%LqK_0+Ywg*ZW+%dDKNKKIniWGXzE_n{Z?O|2IB{iGp zO3@;!tAz=ify?%fzekG#uDxRN_YB%}jc9F>^olnntjMYHRkZ9&#B?naol5~ zBpo1tR9q2CD##K)ksV6nLET9nqy0XeWfC==baaXxa7g=grZgGAW>N_HbgLp2TdYp?e(X^yrihlZ>2vL{IIKK z;7!nBvRKtsWL6}V#yz9le{MT;tdwigIV@kSKz_Kyj?1e*&S&!R(TsR$n{g{ z(2)w~g>!(v?s_#_gLCj!QH)^y`fTE1b#6^tKN}E8!SB{oSsqdTkIA+b+{f~nV1CUL z;f8+w`W6+anuP3S+g-NT+zSM$0 z`gMmPgfW3E2>SiUk8EPzY<@dck~Y?>!}7~HPhgAYKIBy&PfnJHi&%|e&1lK|5%f`u zLu4jD8luL%&2WU`UyvTk*z`S1+_5Iar?vnJGSB>>snTuqvRUxDZuDsw(_tiM(dIZs zWoG2VxcxKI@#Ij}HiYQKXzwteFiAC2OQv|_GR(0XM&jaFtSGY+OfJhI%`&zM9r z%`^W1<06$*c4PgtU#1UWI&x4^u8}zbEw=Jkv#^EQjHRI9L~~bc#xjS}Rqc`w{at8P zI>9A|n@bL+mxWl>?I*dmv8=RWU&5lX8Inc>l9DjSMnE9+mpMZU0FOB2Tt!W>Q|04% zSY(CfW*Jz>Bq{Iy#~A9WlJb@tTL>rd3L5Arl&4Bomc*>9^BHCK{E03BDcc+~>))vY z5s;hI%Ud8gz^w$cX}^vjK05wOKrLm*t-y*rZVoBZ0FM*Ze)MOzrWHB2T#qYJUU#C=<(I^ znd;KqMZ2$DtdQAWd>mt!#-Y!mXOCThuO8HVYYRR)$*Hm9Pn1RFK5er$S@W$#aiDrzAr^3nc0_+50jDK^gV7z6$Nn+(e}e@6w()rvbn%5oT~%r z*PK~Fo`6zBtZeL01i+(_Aosb?VfM#MyEh7`RJCY4m;8+-uW;vv06%fniIT@2vSv+r zbdy+`%||N%;9LIN1J`6)-eNWF2!qJ2NPt|F%_e)7KJMSILBtV4A0iI3+g58?h~>ia z{+~>qw}_d@%EsmF)nGl!5A8X~$5S||LK0wgE%Q)h?St#}J+si`$u;rE7Im5%T4A*R z03kQ$ixF2f8Y0<@f@GKEQ=CQi$9{+YQwHNu&upb`;;I^$HaocW)t#&?(wOVafT@_V z;~5IWBPad(^cO$>06tz*&p3bjar1<1yklvn{{Sgg?HM81>Kz)>NJ26z5yX6KLwmh) zGuM7#)vktb;VWXckWtiWY8 zrAa4Vkcw5ca(G?MvupzIDO!dk*OH^dmpD<#ICSHgQn5J@(ujj$Tjxs*FS);ts8F=iL1v07gSasM=kE<>(Y22VnNkExP4CD+%nYV4}10^xxY7AM-w%G^i%2VBaDt92j_vsInNFH0w+ zSFiF1tE`_-ehAlJbjKuyK@Av8KmU#oHlcNwgk)erGE+kS&<}$|w zoDSe%blQu$oXBiM!B6&`22&%t)3TTL-{WB@{F;=QLmgY z_`}7#XUVOKeo^ELrq`@fN|Q4w3o3EKm23gXWasOQbiU&&Cd29dBQfF(bw6M07Om;D zmfK}dX<6&t`6{#ke3G;gI0NR7j!;Mx5tE#Cym^WvX~31$P288uJkF1h!LhJ>W|qF? z*`boHn%tsTV~u_{NTckp@-WLEr(KHyQZ@Nae4y?nI!e=`e6vRCp^RjZN4ZC)e&ecc zF8T@MUA8MNF+7b-Kh5~%kH_+ng4{EVm7)ez4AK;?##KO_ zvgOqG3UV-d+tLYLe|Ss{ZWtntWJ&e%vN)5A7}i0a%)kT441&4q6x&gp!i9-s^WWod zEAa~Yt*-1vD(>O@iVTwchvNu`4n0?j$iMxbwRr2taRlCg^@vx@Hs{43#TY``*)A>K zMF+~Vh2TJw*NGoalR&-okK{tvNd--%%@*QWb#jDc%q^0Wqp28RPB=P~>DID?q{U)2 z>mKjCZM<_scKz_1H97g5Y*h-~yF2-Ijy;K1m06ZN=nS=iQxcP+)UJZcy6Ba&HT zi1&aXbIH+{9C43cm;kK=6J+ZG-LAoQQ>M(>ZGx~CKQEA%vW#-)?#@2{08X&b8g2BP z3mxZrN_y)S-W`FkSK==cQ$?0WZ~*}1`&Sv{ew_(Z^O^|j4BUA98e8+!ZH+6@HFD8h zftqNU%B%1~z1Bg?kAB(DST!4dvrooozs3Gt=Re1KO`g|NS{pG)h&EBES>>v#Ns38O zH?t-C6OoVf=~*b6x-p5MCag+to$Nf9%C1`S?SlSnbI6T$^>qHsQc8>Q!y_o-jum+E z{W>)Y1$ZzIBdn&M^u{{lGB@$-T8JdE7Ta2O5!iv!iV00~9hV>^#nckYxH(@#)(}Xc zHAn``DSS{xw!f^{U4|LzBQ#H38zOlXa-8(CPZTP8bMtJz1Dy18N<-~Ga zW7D@-=}G|TW}{gpy2?JPwHvz_Dcrg!QgAfPWSvICCQCzn* z2;sUZM)J4Jit5oL0AyI7*SB-qu+Ky32zkhM*YDer7Tu+=D@LgRQ|weYJP-f?#(VYE zFp~t9w9tX_uLeKR-J1jk2am1@ZryEZGUNR=wuGKPe(kNy6>P2}r>tmxd8`&ZAHF}d z6>K*^_7+~WKhLGZv2-xJp!E}GSo@d%#HN7 z^%QAdo?&Ee)JOgvNKAhM5=flRt{IMFI8Hu?t}}t0R^MNbYi8t5t1a}B3k@V|KeE8% zClMfi_&(A-!{~bLI!?0-*z2_#+M7zrwXpZ&u^dp;{C9>s74gi;FE#}k8y?07teQng z_nTyvI*CEj3ewbsTdQ3j82)&{M`u=MS96S)AAYp?%#Zv6#jV$rXf%53HT+&@Z^eDo z3d<=j4=E*(zJ1-70D;iCr9EM$tU5;fSgv?ilHAvL&y!KI`w?eXwYw7uWO&pAJdzo? zD*UHBK!4m04@n1->LWMH@mF6$Mf`<~{PC-Nh=eY1$G5qUe!C7~#-`@Q?zp?izmoKx zWv#oent#Q-#J0Z2E)h+~xk65T5AN%-X5s-OUmgVPctIosr;i4Fn{DdPuz4bgQ>!eV z*wnO(8k5`t$CG3IdVLT)$Qe=xW7Acj@u=%<$TN&v1=l$2gQC$%;WRqR^+nI zzqgY54wjhdi6;ELAv;(AX%+LKHpVZDl^SfwvN>ZZk2ntff|K{a=)pDTGk;0v!W#bo zn5kLSnSnxoEZNA zdi-GFT=Dm3`iIv&C4cLXaBMAQh%W(ja5m|!+8>zt05$zy< zzfw76$wNYX{UuApKDr+g+<%U$mD;ssRh5+hdDc89$1y^Hu1D_1Pki*= z%yJ|XsQo@7FS=>~fuZrNs%*FI=knD)MRv7YiyU;R>?DwduN8GCgI7h#Suy}U&s7Xx zi|w*Jb@YfiKPTNo$Cs>I7lq49$8TKQYpfrX5Al07=C#nHW?bblCnX+$_Vw$(yG7qhpk5Cc0au49Ck6LvrpqYn;RO=)+k!71d?l^i4CW*G7N-# z1@^ad%N>VE$&u2I z(dGrxl*OS6_QHAP-bXgi$Tn9s(@h4h>y}#Onngt&Om_zxKdZ{oR1aaC(O?JWMG2e02V0Sc2sS{_h=siJfiryGNb% z$dg>LK*>^hnYpSo87~?>c;FDnh(E7a;>ZBALgbH(-D+ws)x)fw2TMYK%OovP7aw_Z z#|&KY3csg*{W*(nHi9dxJVE5it+_}^G`h-RroB;Jz6hjK@fKvrLg>T{jQWn2N^%FO zngdY-Xp7=f1fL6VV0ia9&M}a2^ga4v7YCI>>3q;@FUb|js!qy?Yak&fh0nY^fR9ga zw1b|K>!{K(PKQ|)NwsZ4)K;UI++|O}Xjw#j!(oid*~sF1ar*VX;zfMs3IN>mUnI+M zCaVmrARM3L9Gz1bcFr(8dlTu=F%?E&6I=c@{xXZtqoo9rM-{?d;hkMs%xTCJo-#eH z?Z|KVb>`;I$ox~Rw;m;QzIZB#rZS6AhNCNz%Bw>Ci`1^(fa_n&5 zQPPtkcK7I3F_Vh>1%{c9qVJL17bMm)R*z2uS&}xHVPP6RUjUT(8$Z+B{krs@9z8j0 z%0h+z0Myk*H#fF6*c&GjSqxciw`TVoki-MqzfQ-ES4I6I@-rPyu~;fG+>fp?`t;y> zOfthIy?(dF7PU*2@7dj9uhwek=B4=W>Q6V=Z8b4b_#w5i zcYIoS$(9HLU{tPIxGX$ZmNAfePyA(8yY~M6r`8*0VmS5jpq8s2$$!c>d>%g@zamxf z>y<@X^VyCy1{DfI$0K3B!#=p_BMvb32_h<#M)qt-(F(kPloc5zm9PN&bSEemdrlZjRc{#4zhZ=T zs@LMsTSZG~z%e0PASpf92kp=@hN5>HLq;iJY1+6gS{l82$Nj;8zI+qqlazhO~c)3PIMAdik;-v>= zv9D&wDKezZh8<_do}1f2USs2CHh3l5Q~2)Bkm$D_MXhKcr>2P|*hMU%9P$L8g)#0Oy))EL-F`L*+LQfiShzW32})Tv^nx{-0m;=*_UWH&cyXuG=(pvp@}>cI4_7aRF1MK<0x7>C-&>VNt=FUK?Tz{P`M z%1-(J0HmI{F0(mUoV`{JlzgfBbjTGahhHqncsIM$FW04=3}Dn$3fP&;z&S?bqwUvXS zja;%Lw6j;`O7>jWl>wR+c0^_#oRoHAzfP7E9mcVRh}gvbN2;+TRh-yKn_z-q1cF9T znNY8jls~6c;mQY1q?z!aJ>p**k!kHr^4Xq^nyAlG#bOdfoPN{D`Y8JSdZ#I|r5QFL z6|9mZI~aZ)#g?sT+z5VO%T~c+KxTZL9ELqR8(NDM=@pcFjT`lmLpg%<7VO3?A$6IO zM$gIvdxM_;0J|OfVHPN977n&!rxYc(4Z9k_4N0Xo4LXuBFI~_5xY+Xv@#Fft^sr43 z4!&p38RN(jH1YdLq|)5sJ<(2h=%`kLK))h^D}QMub?gZr^XZG3bQi2sVQMCge_f-G zB;M57RM$ZkylQ3F!}t?Yg`1qRat!jx8;@y!)9cjuaTO%rE7s*1@`g|=jcHO@zma@^ zc<}Mbozq!fe~~CK_6G0Wf$x#_^v`~=dnM({_nO`7apPM=nmrfi*pWn~Bn!&43WemE zzKV)J=6a_igcs6Fj~RVB{_-#7Pbay%+CU~qtNs;usc<-Ly(P%b$O7*K-tGQK6O=3PyqCAi7oPwtZuPeCi1GsCVCHOm@ z3Rb0B+$PBl-MfQ=>W^_3RwmL#+-jT1W&f+D21d_wmN89hy8N8q>*7Nn* zvtNsqWbcJP{hpBQ400(ER@2CCTS{wfM37O0RjN@HNQ@HLVh6bQKI7l17Z3+dvX;n5 z)NKN%x-nS>B`8(HFEQ>MPJie1>5PT~w76|7#eV+)`OPv#r`W8~mDWVAs}mxEY>6NG zoyQ_S`Z{-S?Ho?4=M&rxN08C4XyIF&NM5U0& z7+&!-SoJ3dKd)V>AcO6wGyouvb-*UGUVc)#tTK)R;*D^uJs6Lo^`gd;h`nSnM<3=y zjWUR2VVMBsg(n1+9rOBhMYNk&ywyB^!fx&)68OjD?KD&oC7D_;jz#tP3-Qly-NgFi zuQx7JL=o4`TNeU5y&b@~~xLiH*_Qsrv!no@xx8G&7mRoxpEN=xwiZ@fvMNCGjfj}PIW2XvH zLudu8`NFAFX7P=kb3vup*OW|VHVkcA6aY$EqYr;BKy&pSET3>Ur;f3-2;0ZP0=k>k z^)&a^BIl{GVe2SI3e3@Aq+Z_T?0XVABH)rb!Qs|0O#$6+ZP4-hjdC!LV!Eu#(nkU( z$lRT{D->Vd+v(8r;-Imez?SyK`@NNohQd0pvy)>|2Kee&l}um<$0NX>Z+~C(>k@}b zxNc9z6}+pw)g<0Cva-(}wpOzpco}A{9#tHrSr;Jk97cN7Dn}s&`2)r#M)>PUrIDUM ztHglJtLv3-r`V2&ZLA)#{{V=-p03I3Y_z}T=9Vcc#Z_i18pPOG9y1?&qaQ$f^rsES zL&l*i4qA)2yM3YC>TJtTZ5*{Wk;oRty1-?a9vp;>2;+<%-TELyYP!xKsn!clsk3Ho zQ$Y;TDP>s)vC5CwIEGxdFgotOvku;ow!3M+*l16V)U*z&n>N;1CS-{cL|zWcNy}g! z$~&L8LO6!y))BHRq?gR%@>=lfM(<#|2&~dcb%7@n2&*2-oI%V)5&L@8%BICkILM>v z5$r5fE>6BNUZk<2v<$*m41?TD6<^%hZr^-#CqXz1G6|$fWQ;1TX~h@m>IV|UsQdTp zOKms`xX$D?^;_XjWh7g=RK17r>>q|e%gB6FaqI&(e{Wb#`a-HQeU6JyM(p+f0Lnpj zFU3ic^aa{Ao!6Ly805@9VLpKO>sL{A1%U>xC~NnPsBKR?vdHrGFkhd+tvjNp=WaB#LlN>foLX*%#%#f z7MY@pC}L*!p)pZ%;{`WILMrIBfnds#zzC2<>Xpd@={&{m9BII^aK{ z_2>#v^_$D|hmEh0`1N%<-NJ~rH4-WR01dk|YY$}(jStffmw#$QMIb#~Ij ze9IIvYG#R#Bw?8)6DPBZH_)znJ#W$l3cj%TFL^JJX=&=Ap%vPC67mbeCT51br;a9d zKG;>g-?Vq{(9lzUb8N7$DJ_2;edXuVQe9P4p(uh~z_;<&Zfw9iJclx&Vd|-zW3Da$ zvvYV7XjC6oHQqOOX&&ESPkz4?)ggsvU=mZ1mg8vfN~aTmaqHZ5q!PMm2EaP&5$bjJ zE$l1T21t^AMdd+~AprcYpp*^86}xA@r(LY+F1|fFc@$7hJISUtekm;ALhV_fiI9RN z;7T0*ar*U~h}v}mb=n1OBNpaJ7|9B|EKiczP;jR>A5YV+)Xv%*YVLG%)VxsDvlFd~ zlPN}JbYRQK0^CR=IAPY_l{%>0?m4J!B-`vax8_ZhsP-!qh)MYto$|{e%KTvDoPqw` zXGz<|3P3qM@$^4lxJ)v`;~Sk7R@jZjNo+>-C;tEy6cPy18_u9dEAtLnKwM+}I?~K` z(r^Q=u|~cc^4X3#>L`4*e48FpHWt*U$9&pE9Z}>-An|uG2*I zzcJuwhRDenACY0-qnU(M|YAkd#rhOa&y~?{krLpom4g>vtHOp+{E9J z1XHPa=J;3^jHu5n;~4vAuHQ++O=AhX^U9ljXL$|zYium5#AJnvh~a#Xf;^MPFsF!L zw_NDFc+?@0xpKW{o1ZW88~bJ3SrNfyqKGJlC5ke!uLN!YVuS#5M1ww3$O~&<~ zLjM5A*7nnGt-<8=?JN{Ei4pi7Hvq{e%ohsy&POct41M)lc=5C`_Fd~L+oP!e04Znu zJJ@||5MXP81ms{A=ff-ev+4Bd$Xd1DDeCmvMGSM@aTIAJ_YMSVft{QI>5k{H9k4nr z2D3Mo%{1O+H;i49rlmF-Xz9Hf?#lyCD#p@$raAzDBW5Knw(@6jpN zafC-2o)XE*O2F}G(SUKEpw4ms01ldcAY6e#Rwb9)>EG#|v1}hnq};fslJ}9ztZko> z){j>dZ9;~^g$z!Il8Pzm??XQ$j&@;dJVjA)k_2m%A%YRlobgWpo0=9pdxgVJXiRY1d z7Q1!i^K4gLUsXcTUR5XpvBpGrAq<1DC--*$08Y6vlu8EAtmk0OWLLr{z6~qZtIaG| zWFQ5#1cl=S;khXoC|~UV9oC9^#1ac?h2^m z{$j1ikjv#7VFRO==)RJZ)U<(7Ys^}aP?ui$AZ$%r;j34A$6Xq58(!;6wT zbqD-m$cw(U`^yGnS5?yHXpvW?auc|K+NEMMAT^vLoQ4Wf%8}Xs0C30Y)vV>T9VE)2 zZy0L)PskI;ta(@CM=pxAf{mG^WIQCWzRX5)`S*4qddA2(1+#O4+Ln>{Ue$G%r?tX( zog?kCs9Wc#r`qx$1yu4@;CXiM*Je&azMgQgzZ2s~PmlQ3pC_=o?QDxa{$5hY8|677 zu1a$!hH>=ALELvE5VAeHPQ!rY&>wVfqwwh^njKw*n!MI*i0oP7_^Q#8Bo3+TjB@SY zsPgxt?y+CLtg9bre%jaeg2CYaN3hsi*XpawBVmn+q*92mBX*I#PjdX2$R53E{{XPY zsu_>z1$!KYs0h1duT56A55d|+XxWxX?ACAk`jb(I(C0;)#v4B(Zp6o|OnW==#V~{g4*lD*qR%O^x zHB9x9;fmVim~4#4EI^-A#E?g)My#$aPg#Xo4G|o8SCVSzl>Y!fJ&QX1WQ%uQsZ~j9 z&twp+vn-vsG+?fJ4ETxrcRF9BW-Nex+YX+v>aCz}E~~Th+BUWR6SbPmwc@H%i1wSPn$Gh`l((KgLcc9_{fb!CY^=KpE5Qp$6gnUw zi^k)ZS;4smCt91#Q2=RhA)>uDf&79_E3B(3Qifzuzbx1eW6Jt4L(7wT^jwD^umTdK z#vV1h)z_X{(#>X_3noaVSW21YO9{Ypk(Yo}U<-Ei^zYE+QbmEREMyG{_RANBd9|Ms ze~(E20GG9SI2$Ng$vDcSQG$f!k7nr^upEZZx5u1rJi{2F?!OyC{Ikt>es3(s+;6P% z3S;Alt37iv5IG|&pWaz=$Dzl7V<7#?esJF@abIZ@EFeQLNuoTXVkSm7%L03Ton9V9 zMC2o|GAbXBI42ncwtxCMAZ*;|T3SeJ+J--hK{v)Zx@JMgx%ivE5%fQC(cZHL(T|XL z?Tsj_VA-j5t?2$r^3-!8V5sJv;0S2Tac{Bc6E7>!=?$^fYrOrqh6t+Fk5MhD^|OOF z$zlNz`O5K?6~=x*orZJ%-EKQKb<6mI^gsN6n6_&WyC)bH6jfDVq@Lr|yZuj25p^z5 z`EI`L9bUHG1&)M652Ul^lAI_T&=4>VA=@Fd!)!4duhjH($jt zc<_h9YT329*vTXoV7`J?{CqdW$_MR~92G0y@6`D5WaVyX4~LJ8bL7XykOEsyzCIH! zuAyd;ELK>hNLnDQ%?Vdg`=7D*9Qu8F^l!>W!=Z%fHrpL-(Kfchr*DHoW{ z_3LsXy3-30XzYz@YB#&3Z8F^(2oq+OR~0YTmAEA13}h{UPD2rf#(!R;8M)UJ#=1(5 zAIE%$E7;H4%-M+}C>0~ivxV>9_5FHrHLOyL6(`*6Db&^RiMc*|f_VP`!=)c_jULfb zo3h+PZk>1{u40}d{{TIJ+(o+VVa4Z?Let7z_iW^zD(I^4~5@&X0wkdOuD-a+__U=0Z53fQ(^4EAk zo<5N@q?RY|>;AnNxyvFwEW(|{{2xD0uUd^pV+#4o?i3lKHXtGCQklgoDWX0 z8H7UWS&}&sgJ5SP{d(dj15x~2AtX*hf>dSk=sFOP%gAG~8v<10m(cw>7lejKB!Eer z=l)%Cio$KRMUzNhBYy>F`N(XH!q_%*nK+8Ywj&) z_?pdNElAg!0^kw{W(*4>Q>1ACB|UNIext4ct3Xu0 zm}{)=ACSlxp(s5^SQ=qnBaM4V-G*26=}%FqbqGnKS1%}IG-PmGWk^1}x&jYPqX8S8 z;nX%m<#qjqy+7#~>j=^iI?Fcy0Cl6;#X+@twbsj85e?9;WNGCCEjJ&a>KtK|o<`-) zLkn1v;@6r!MoFU)I>v45o#jRk?Jw>+>$T7*w86f`v(n0C^k)f!K z0n}XQQ?Jvt`pdJGg-OdSzZnYA_rkFx9>e`QYvq0?j^ZIOry$GB00leVw4*oXKuH?MgWN^m4byt0${mL&M$VT`(#Z`;jN z8R$Elst}&OaJy_wTzPp*7m#^2uIAHaa-N1-UMFJ^N2RS`q$1iW;UQ%MIB;J8W1z+~ z9`AnsdP8%m_L7@n%(LK(3QL@~A|Y5;$Ti%uacBEX3#3^vm|yi=oPdt%2X6QFCPf00F|W z?1uxNs2}T&nraQpkA?Vt-^nJLrmbU=+z`nlq>?Or<4*04c=R17KXfsH${rrvxjbMo zK?>MaWAMh!#ib*cAGzb}(%khCh&mY0$0Mya!7E#ARd)+JfDf=77Qy{GLkbtxIZc}B zEuYFhKm2>9*g+4AUwwrMBUYMr05Fh~nDP}ffC2RVIt(kw*6{MT5qnC6Ri~e26=(zN>D>6%w*&6`h_Uq5?vbGkz(ETOdvMyDl_X%o$ z;)}Gk@=3En1tnE^jsqDujI3%vt(J0HJLuY58ZUFDzgxQ%w1KbEYGvJ)*)1rkZE z5{@bHsqNd>J-WXNankkcBFffow23a=wQAg!te=`$zwXO`8S+Lmf%L!~IdO&1MKhM( zNhZS8iP~x8d1hhmc?l%oV0S*a9W_(|rqN8qop+K`*_w8vvSCSnHssMUJYW9+Yh(IP z+olH_69BJJC$e0lYVDgc!fM4g%JJ8!fDzmFDChkE^uv=uQDYva#_|~=hULnTYbrDn zv}qHmlbMon`=kWpk1zUuoi$(DNjCwO$Q4tgM%?1m_a$Z(v-3+KE<=NW823H>GtmBU zC;_Z5!cCH7I?8QhWdt%hK^sVl)}`g~E_;IH0zk)2$lwDOi)2FY{5uV<#&c$*GQv** z0u_Q%geUfZAJzN2^t5gu3NV>@Su)|^-^#l`7OQ5argwx~K@FZNzV$iJ9>(JVrj8eR}ek5d4?@XQl`#N30{x zy!JmX)o$dWtcKjm(LrnS0a>W!myD4SmBadbi2ZuhKK@OoDU3awS6(w3yF+{A9!EBd zT?@;)wBbG|o;V?7uNej|Dpn~7E1r4p*Q<=GsAI7GXPDuch#Q&27OF14X-{XdhK!o( z?{8I7tSKtXYEU7Pyo|t+?fPc`^jg>|3BB%M0*GToZ78afl_gb*fLqX#0Q&a&e!W@B zDunL4+pjb>qi~WVEU7s;C_{T&>*&Mn(4DoK2)!ist?m4SYj0zIX)RlnneW^qkqeNm zBjX*1Zj`J5p`>G8@*B013xADX%G>vGF9v=x*}&jW?$54q(FeU?RACE4qEju1tykaI zvl}f5kdvcnejjTi>rY0B#k5V(g<(~;(L7$ zLAKDO^zYlHb}O~yXzjr3Br=cOu7onM{{TWd(%am?Jfm%|m~OW_D|@ZX?XcY2RZq{9 zj!T8$M+|WNp5IR1qrY091=B$^n>xt+W5!#5aqElqs4QsGJoen80HB<91fNcWBLFXZ znu5(mx9R>Rwy*QWI6x2019L->p2xS}zg_g4W9=Tl$Ewk@YssE;`RX*fSSy5(%PC@? z+&TWx7|uxR9os-YcQsUXi54oaUSwF_TU?@89wx$IgU^&~Ik7#ljANrB_JMLrTAhXI z>OoouYc}QhE93~#oL~hG#4j%W^Xb+z04(&Hs!5^NS-c`I7t;9{Up?2`eRzgm5my7` zGLeXxd3#xM58QQ(S(DmznxF>v4I&zl!#wi^XelHRO(bn&k@-rVLNSbwl#as~9X`md zBB&m*aFP>P#$(Lk!wHKX$N|c6^!4wafNKsm z*Fuq4Y3GDNI$3r8-~&1P$N^buMj?ThA%mkHUnn`wd-bRnQ8CMCev>>> z;lW?up4ji-rD_ae79)*Cjn~DoeOyr@B?gOPc*oweGONG$N%xXhx_iFeVKYah_d{0Y zy4w%My%iL#QKq*9lRQ6_8Zagm3!jnBe`)JjiW*E%b=D6RO-%ZW_WpSyzXY)w)P#Ja zXk3vaG^K|Ujl==O=b=86L}0!>Njx%5`)+Cr&uCWv00iny#!LtK*FK5*_18wHaTRlo z=Zx;Wc{LO6p;xK2s~CLb>y^QrP>B_*9wK~{PX!BU<4qu8O{QPU z__%<|CE`;&!NKFu(XUyyFVYjY@V&G;G39h9*&ZW2{{W3wVPM1<$_I31Mm_SU2OVcB zHP&fOENv}+!n7#I{EIb?S4p1UJ^beoNR+Qq-i zeis*yd8XQ~wjlb7)_AnAW=JAu!+c*hI|b!{4_S?g8l7ilP!5AhOW4%>Ym>&M9w_B_ z(ny?38RCDULHmY2y&MRCi(cK(*uCvTM7C=}^EsSCQ{#^e-r2pZdv;OW{W{~W^H%ym zJ*!F)<5IW&7og`JFvu0JNT}+QWim{*rEdGm+`1PdB z0v&sEwwLoDwliwWB3+S@d9a586Vuzor}W^TuU*o0>m1WNuRrrGT}rgI@Y^X3SZk{O zHA0owv6O?Kem%{e`RGoNhNf4x#o*lC2(ed@EJ~(2tV}8IBaAJUB?qFM`gON3$o>dn zwjr@9n7e$E!-;W`pKo;pjQ#o&O=&a%dcpEY?t2eTwq&we(Te^jM)Z5?mLs?)o=qyu zMAfB@mLOz_fl~4z_L1Ko>C%tgw!U$odw0{yN8~zlL;O{FwWhV{=&gxTIa%6Ymy<;! z#><$Si0*)TVEuaRZ+362+=*4DwHC!KYISQ}rFLZ7bAD+UF?=d86_!wP#lby*80nju zBS^i4gRFJqf5`q(U6e$9qhHqy4J(C*8c#K)>WL#JxF6F zdDXC4IVZS`E=T_WZ=uWyLR8e$u42XfqyGTpUm^Z4xgT2V?f#up z$c+isB{YOBmc@kQ|=?{>gDv0Y8?e=yy zdm9(*@cfG`USEwCBn15x5Cu{C0oLfEmT@GTFjaPm`6B{A^&Q7?`t&zbD&czRb>+-^ zDJB&#NcaMm502Ok+Zo1s=5tzaH9);&-;t@SUYyjvSvE-A>@>=s2<_QI5ZthSr1$%E z%1JuyIMsT<9zsA+PZ8e-(9E;hJh7U~T z0vy{*b6JK-&`lbs5Z0NTz!evQt}M zlquJYEN%Hvw%7EZFZpEE=;><9{ySY9+i;Uro}`7PfolH%X_eyyg)+Ce@*nBf(RTJ* zbEoq>jPwS>Sa0zY?k#ym6!DrB?RhG!z$N@iQgtZVSs^?}wLLxFy*EBiI-NvTB)tZP zReZx$X~|kZs4veeDx*0lqY4|i0R6t*0BOWRb>mwN`toc8TcWc6 z0OdhljBe3`{VvOe<@D$c){vI3SY2v+J&Q=1sxw)Jv$Vk?R-24!dl?l}sp1cIBcm)A zawY&y!XLZxT{W*0)#)qgD^=RjmRT-uBJC}eeDRHd9*dv9LQsb$_vr{YC>CwKT31y7C&{1Lk;{+Yttv0h#K6=iyDui)ZEm&Qg{HA+{DmF8Lrle- zjD5=63YhFz>Svqnbh^7MuCZR#OOd2)a=ow>gsi-Y2nX#Tf~5DySgsgd+$T;Z zpsFPr;ze(7K0@ESIL8wouN-9kv(W-z$>>su1hKSR5$cH`$qItS%AO&4VSb-qr(MX_ zhHwJx#yeWxF|C@En#;|!R*4Pz(?!FBvI8JT1gind3Vm`rjrGhn)UQl6vC!AlnWc?4*zfSytiCD5YfYQkphWCO=AJvYp-DN*ker9KdtW_=?ObQ1jH4Imd;Frjeql z{9sZsAW#Wx9A~8e0LE1$fvk7@mS6!oNG|L?IP6VUy3|?8aa{@Gfch%(C9pqUj0yk~ za7$`P6)Bs$rpw;*>{zuQZtpM}ni|LPL7}I$EWWTX7i8K!LGp433 z@7b`4G!^5CB(cs`;EWdpq-2kCAL$)&a*8x3q~%UbXo1!xlDjA@z0>I384y8{g(PRL z0_R*Vyy27Jo4Wd;`yQO9p^0_sCt*F^#}GcJt|Dd0X4l%lkPc|WAddaIUBdzj(K?TE zk^a45n>aZg37bC;Ubq>?6A9!Bllt^m2BAwn2e-CIxPPZuCYj1yY>*EUat=D-I^=>< zZ5Z%G;(P=YT6WRbbsblreTQCXw3yir0k`G&FFLB(iBre}*kHdt!1slk$L}F)*oV09J z@NzN^4^*RAxQ1G4oFRTbxKewMr*4ks8TwV?p}7nnrhESY!>;8x2`yClatnpP&ws8y zopEZJ#y+*CoINs5PtQo4?oV_UNcVxLqDnD40JWdoU-zuS#}~#())V3^r#)iqr%o@kkyc*!z%z4L_m5(uIv>a}CpY($$23sGi}wVOpt;J3K9 z1@-Ur9T|QrORNx_n%ha=3ekZDQ7faj8Ig_>Ks^XwQJ=S1T%Anb0B9%WuI|%INVQ*x znR2-*oN_?sJ>H*QfW1}g3?<(kAko*gEO5+|ET22DR1P_YTxFR@pzql9Uyv>OK>TFU z^MuDD)sfDcwhCm}Rr;$w)G-I~$tCU%mslvgftNWzb)82zK~(hGoS zZypelXK8RB$#6m9sp7wl9C59IFZp1ctSAkUf&SjP^zGEA3ExjYUADg|K*|T7l*l~2 zHx4&!z^O_drr(dMpM|)MQsWemB-J(lMI44}?@}VSbjt+2g zt<*<4wURVhV8p_JKXg;KZ@{%;oVEB`ts}!QS0nU=ExMpXCh*z`-TqGM^HgH33f(+syT%gMe^YpXAaE=FX9y`7Px;jeU%ZwxAFpF_I&QFBEnJapQx5o~3Z4Q2R`w3n{br2?Z+p zTb1UTTTC}EVj)(Np!g+?j%G4Tk@4~RbYGQ+O)6l?gj(9y-e@~a2NwSS8o5KCn> z{{T2)7=UHSnp6G3+m}SyO5^t(<_vtC@f$<@m&^QU>a=UB)=O7+RT;LozGC9UqaPb8 zHhA$PKl1A_U?%TrxibM(ysTI9jg{S%tGgLxmV46tm#y+4-|nOgACr=+q zPphccc^8(`)oASs(ON$lv$SxLiaQU6BcG{UqU4o|o$a+4(n;R(8{y z_=1FXb+N}(D2w5iOv;GZ%OjDTp1D;{pl__!W*T1Fe^{yH(tm@DB2K%pITAM|HO+oK zy}0Bbm+Opmh1&X6eC}-u^@YSdZ~UEE>n(w)h*ldor1yZ?73mo2)EM=ZVM*SkQb#;8%VJR&s-&62 zcLO;X0RI5@T`;s?8T#0=ku6&Yv6@Wrjwrl~miH^V{^CBFKk3(Wnbt>AjoV^otqaU6 zMrN>4OK^!u4DF9a8TISA0leBK8(+q^*0s}YYfzqjd<=^{w2jPhv;M_*1B&v`*R5ty zXq`;uQbmEZ3R<=+*_M@x?zYtYUkL;{$;fu&^*>B?fq@gK+;8EjtB=ZPN4!>DdfAC? z#l)8Q{y-gg`sW``xr=Z$hRSR5g_eU#U0ABjlD(S@j)6Wn!k1|WAQ%IjFBSC9`gFw?0Lf>x*^R0q#SJGW zyu49nV9&`+m?W_n{{U{ZgF5D){9EDm{LV!SLQ6Yp?ee50h^*x2`;Ma^g3In5v-j)I z?z28K#C82;y|yHAXQ$~bKgd+?_};#*ugGh>__)`<1~o*DLj{cdauLoxC_;oJkErQA zu`a|9Y;Jm01nMR;wp~nD?Z;i3)NVu6kx5X#I%;6^1r^+r>ib)cysucyj!x~hZuB>D@xmX|?M_4?^m zK01@h@)+M3aq=Hk<=-czd#G|YVVda`VS6a-`5~Jiu001>49U_nM(qnoV~xm3xcByk z2h$@s#(Kk{sl#1i`fYBu<%waxvQ1JV@=4~7Ndx48$BcZ4?cdX(W*}%GClF8!UBToLSZLru_u}|?U8MRYG5qRaYq;ZMg zw+=(o>-On+94P>GjK-XSCiIp4X4>Ym__KHC5b;RaPepiE%X z7w#a4WM~MHR+D3*oZr{j_^#KHp?P9xrA1j&+r`5-E=$NCZ*TSKsO5DLZh^Ei;rxH$ z{vmH%{#E6hYVhi5T}U<>6n~2B@iK$X01EmR^dDp2sqW;!=ns%+dv=|(hYV$B!+nWQt6PI7ald% zv#CV2BM96E2Z$_G0&|YK#`BHs1Kesh8l9!hZTjZ5+EO%jYve!1%5YbbmIohv^?=@@ zaReGNS-gjDHo84rmK^IM_eVrC2o_9nGYEm<@)7FT&t1e_^_|Gobe9U6KMFntcgW@P z8RGI-tgKZvjTq-VU6|M+k0*!6Ha;|$ zU3B|7lEvEPRGB1wp+AiEsTVL_O2bPzc>;_To9kGkY_ zvmsh-4%c(Cl9str)b*m4bIL?W{u;_%*fgaEIFC=SUCa&UguS8uLF9VhCh{iPU#A9^ zv92Q(^-w5Jk|>Bn@v|VyxE$L(3aw3M_8C5tw$$@$8$tO~)TX%`%E@@Lo)0LT0~>a8bT z>piJjwaDVF7+5Wak><5Lp&|Iff};d>BM0l>?bj1iK)oa|R*p=xj?u-z%D)`uL{3~i zN2%?ejtqm5B|&b(u+PwSw=xjL9?Q*MAMC~hw0`2DMghqroM-jxXGt}?ZI-I^dktNQ zDO`(}#bOpH=vWmYSRDS-jatHi+Al0nO zy(G7%oCc>PQ9_?D$~dSex`p=3-HKzl8OK=0%)vRkP6#JI49#Yi0FL6u$gKPW6#?=4 zSn)sc>so=SqUu8gs-2o8kQP!&$T(BoMhN}iUb#%;JhLQkW><(6jopeV#zK?WcIz%| zo$g+iifi6q{7UZA#R*aS!Zh&U#2>s_7w6!G=gz{h;7y(Xe_Dv#v(i zVap(P831GS{{Zdv)q2j7n)4z>DUAGbA8voE2k1Iy6ITb2+J2fm512+$DP4p>uxxSl z2kYA-p%AA{5l56yC%Q4jaA5q7FgUOz5&r9otbdr>vWs@G zoUC8017Q>~y?amy<4L1g7P_&Jsb6bz4m)ySPCYT!OfYnU&8C(}VlT(vBE~bs6YIo! zE_&OYQSOl|X#AQAzE45LTJAr{)gUa*6o57|bA!Z!r>s8YJPg|6!;p=Ck}CL2K19vp z)bG^N8u7eobKM}7$1LTJelyz{J!fvtSKMshji%*Q2SL-u@rQ?0Cu^#$TJ3iH*p_Qd zvJ#a-x&HvTxEx#$Mald14}8>wkaXMDS=q0+0C)ONqV<2v{wt@(vsGp0tyMTZ*7vl` zhl_sVq=W<1f2Uq1F_{Ifmpvz3Zhgk;R(zw(H9yFnK1Ff@(>#+Jn9j0EEx*oI7 zARFr%s5Tobjc@ZOk(9ijD=|Tqe3D#ubL;EYFB>WaRMzEIP25wg*-1+5aw|%e$x&)5 z@-%Gm`Z|Ug!DS?8_2`^j6>2gYh6yd$lE1dUHZdvn;mg`R7dRiMS}vv84 zQ^1$^yEpjPhy4*HH#1m4s+ZTO=cV><@WGH?m zMMOUs0YOy&e@}nEO%ZU-me>4^H=fJFw&b|eJ$T>|u@0jwBv2JlaR4X#^?+8;iM@Se zEw_?*u!m2?&C({dT?AlCsLR0zC+v4Wy89R^Xxkgv>z>| z5$C)IlL9;A9*3=&KPe@_JVES~V?i8_5ebCzh3Jqx$SOPk&3&f#Vf`pH7$JqTd)5fOmDCjDpX%%8+ zbr}b{DfAav#zTNdu*;Q2^@N+obF;NqNZ-lq z<3oG(iy6;PCMXHVBi39hopU5$ob|Udq^W7d1{~pYk@wH~^pZ8Wz;vk?CmqjE1}KTz zOalPK@yO0T-(I_v?lY@WL-V+Um%u(k!0dV;+`ipr7323o{{ST!c*NX!FLJH)J+N`n)xo-x z*gGA)yB3Ivq>2#oh{8m_96&BYi~@1%j_07Y#sKw)?s7~KT1Md-rhA-VcRBhJJ%3J% zL97k*mNBo~>gXEx8fwtoRIvms++vfy71$H>@86`gUDR^e6%xZX%WrnJrieCeyZkia zUL%u8-hiBXll47wxW)?|2Us8%_Q8CQ$2Xc=vs2nuXfIVfY)1Gk6A{P;!6UdJgVA8l z9BBhCBD=(T{{R}_?QFVhsZDN0MwV2C3l)tIx!WF!zf;qnI5=i)!M#wVJAb@#bN{%I7M~tIG|KQPFa>*F!Un zHkyg8?)=VehQC|myE>55v!skAUx1D=e&FAfhim0tLA}?*sg4n4s`8hVP?wpoFj>mv zw;xb`hoz3)G3&H5o!>#M<4@rGPcni!zC9GyELDl-y&QrdenP`6u1d2h&u&LQZjy2o zZLi_w7*sOvUmrP0@=qMuY2inDtkAI>?5PZj#Ghjo&jZ`huzF@Z!Ork9VJJ3>Yc6Ao zdsB0uvMfv)vhk_zc%jJ)(?QmT@kCg&w2sYaUd5!XBK&c=5hTarl10HGf;Z?oXDtQA z@zm4>&9yQ*aH`dm7FUo0#Qhikql4+wk%f8OTPeRO!%1g-c3C8K7nu}63~~Pe-C#n5 zP`_Mv>ue~Ss~B}Xp50-&rbb~k!x~&tK1Onl`*OYU>FxXUmsQccp{q8l=OMMHzpjzt z*;ke;70Dk2k+gCs^#rQ`K>GLTBQQU2Si^{qyQFpF7j)C;$qP18;O^H3R zkZ?<=e#8%6vn#b|MBGTdL5gJ6+KOg_b+r|i#O0Lpe5iC$`?&Tes*A+&xg{;0#tQac8N#co#WFJvPK?>WQ~QT* z{{U{ZfJo9{1YJ(!tquB5DX|V9f z!j`2FixV*l5B*Jywg!E1&^u6st7b!W(eBJ3hB(!mg2fjmPS_s8<(s(~>j9i(MqI*? z{AxHeH^>LMxdGUpu*Yxp>p`i~?dJYzPkrLr8doS-UnAEbihY#L#U!W~5XO-V00;e8 z={T{JW+3}dNbI=-CPHaRiq29S%Zv14HzNfNo7!)-T@K+-)b9$+qb%lI^U?BHREXNa%Ag z7+hp>Ur$cCXbvN!#(;2=UvH(cNqB zO@j@4Q9P&zCg6@4j~}mL)*C87>L#rQ1oSfH=U!>5^D7(cj~z{W3xAcWb*{q1@sJ@E z!H*H5g&!jpUtW`i87tR+DEzq<@6&HNWAG0U_%CNFvswu@%M&pLNcEk-v~%`3`BMdDgzhtC8>$8Yv?xJ;*>9W#f*(;B^9x2&m2xB0|u1_aY*usDa$n`lrVX4ra4P8CGt$O>5$62j%MT@BmSY#L^ z5fYGFz6Yr27@E*RPzlt)rZN1I1#=<-L~;>=z#IZW&O3Ftrn4)`SUfj*qu2R0d%KF# zjbX}LN)BU~MA4LS8FEHjJ@fwE3kwFfgoci?&0G17=BLFsei^5(qsI1=J1@*RSP^y# zIp+YCMdj`P08DhViq9f-(*98Y049_Mn*C)vPo<%IXB~U{DD~AWb7a~|tk8}M#|(-& z<(LH>G0g%%vjG;!7eYa)-CWg+g1SR2cPq(Kb(S#Wjm22AUOpc16keLYe!`zIoL=mN@AU%WnfAkFY&nKtUG&8 zTn&lDQKxHCB=PM-V=d-*wfA@($6I*^TwTnxe^3R>A3w$99ZD%Fhy z3S((mB(k!<#>rd({m#cM4n4hLJ7{4A-(93T9bKJesp;G+705X9iCJU>I)?1=mS5W4 z`~LvXu2#K7##+$NY-`djU0a_1QpqfGl4B{92+PNd7xaGofc-PytOr_}Mu5(?H7v_v zrh``{C=3zU5UR?`A;3?Yl$@J`zkXT9P6u0JVF$`L@vVo6)ZWiOmg=XTsfYU&b@B*4 zIk3DOhUJyVayrJk(85wLQ3$PH!)`B=J$kQJn62{YroA(BifG*#qU9I~Bglo7RlnDu zC51x)O~HO@n;pe{R;yz4G=aVfHukaz+PYX%^Zcy`ZVtt{drxkSL9Jjh7okF2Z(jBkz4UdjRwAWCct(Z3z7PZMj z2|$iO9IS03saIU!k@f2t6^Os}FqO)KU1E#V!8C|##qmcRYM3}Bii{RG1NR^Q0AEGC z3AFzJ1o)STc|Civ`9jz*P^~1%Eo3q}jDmTPvK~0}82h{coDZi% zMs|W5Hjir-wRV!h!jy$VOUVZ=S;iC|U($Y^Ws9z`xSGC_13cDN3jWX#a79&B&#B8E z**)=-(SrcUa<9ZYk$`)i;A6M|bUICkL#@1#O*DMR3gX6NBfKo(3o!@UGuG%QYbUOh z?Ng3L7Vho7R(o&-41&5S;{O0Ax%p?;BN*$Iz5qfZ{O&Qxq+)ad5D(lP!M25V% zRh<)xGZ_PWOLxc9&~ pmF=JtkvR9x(`UxMYi!@9@0U!qh)XT0JS7mG3O?MvgQLmY2CX0_ za=RGfrH!xWYc@2f$VR=@($oy=B#s-IVIam1Juo^S_}~Hw-e}HA1P!46OK(TGzqGYm zUn<|VZR|lTwjM$86+Gf@Ks1ldxpF;rJY^IM&l54{T=w)GDHVe?*B+6Mz=6HW?IoJR zH9D{)lgO@JpMgA650ovABm_TSPK=lUM&1=qK`#2T?luOLJQ`T0jvvY&GJ<1?;W*{Z zfF8YOOADa02>>TpzPzG)f-B0x2#fPb)GrcS`iIa0f7_-D=@x6O(0`M>Bf_W*(r=geK0bB2_}kAM=VM0;z#T0)Sb3>W+>Z_6)Mk#LWW(8tN5K?0lK#!@+(&L z^UTnuzT`?IjvTfnX_wWNSMAjqjzL8S#w!p_o8=H~=)p^4JT+%FsfT~Wwou3OLKuXX zfh4aWGlSPH)%AkHj5kF-neITIMKzhVamO)Q^;;4woKFC}!9mUOoPWPu{D}6~DXGW( zwvaMN*V=8e@OTy{dXk5U=0U|mE*G(6@1M7Rw|%B;b&*?^g4vczv0)@)@kB{+61IP| zOANU{N4ugLZ@d$((VvcMv@vf4UR(Jzb*mX^L}{7BJba=d8;(_uJN>!`EymJ`hJXgA zx`peh_{=6ZhC_d7Z8|o($yn<(Foo64kjutQjAO1m@5omDZ{g)M1+O!INWaM_)ULEF z)7hrMwiShoaK;!lX$c^~A#dDN0pFu6$4a0+v1LG`c4q-M5y2L&J6|PP0GvdJDy-|@ z)DLzCLZpCOY0Lqo-d&eNqR{GbxJY()cGMPqWc8;iY03|CVF#;Yw+?|bWXZOHPQI|; z7Cd?#c@NSoueBP3R@tnUeg|2a8Pnv2i1bMm`l*maWmJi1%kDXwX0HnNn>$|@}1BE&wZvLAB} z-(H8e4x*UZ^P8BdHo8UDtJXF13`95!%2H3S)MNhur>2X&Oj)YZ0Tg_R9)OGn`e&wy z1`#Y$)x4hvDCLifqht~oVB}-<=mD{;A}Hw(hCsG|I>QSGIh?nvI}G}duUvH;Mw5Zo z&a%%xoB0mULq|z^^Gm1IwrNr&K#aIP;eZ*vNbY-eI`+z~V|{mGBGDpxL^M6)Z)n2Aa#UQw<`Sq4e+NsTn zS-A+$v)9}I0NU#(Kw3u5bKmdL!JUf-7(Hc9oHUs|Y|5T<>!(!fm?Nw+fSoLzWZBoq zLF5m9iVaSf2alL>%(gx*{JlMT8`cmSLBo)GAu^}tIUNz4CpqgT&)Ge6>Yc3e2#i3E z3_}rtoQ~eUy$=z0HuR~_BaC!YDtVr=bnTy*IZ-5jbxrwIG^i;(18N+bZ&get1J4tWyH23x=1rKTlTEH2tHvKCMe`oU`&;&e7u+6?~yabqD| zkI;}u{JJ-~_U{F1_1Z>AgYy zMmj;{8%>ASI29vZ&(cl2O&nWSO$CYV>cXIg#gq|9$BtYwkNR7%JvRYzzU>TNV4X{eI2aY*&?x4R)+j8uMFzg;8(cEZ1M-VJq}ENlSB5-#@sgEUi=GZ zcYEcFV|8Wiwddipnr7x^jfM%6m)Hyg(o+b>qKzZD^4@Ge%bFh*@b4ctyfJH;kEW@FnO8I}7__f~vt#@s-X*Zk%W~uC` znLg;kfCCQLKBV?NcdV!TJq^9uP%m7EpuJ$fARq{Er{?9cZBv&h>v$a`m@uNux)jywE@1ahe*%#XoJ zg~(q+?maZ*p<`&Vj?EqAms{q$YAdf@TEj-&2&JtR$dOh_5_5+6oc`>63H0eNg>@vI zW0Nyz01yplmv1~$XMH`f6{%w)X<}gc4tuUZ?djYekl1;f)JF_~MH(?AK3zRCVcku- zR@9A_vD~av91t4=xFhICfB5vq?ZFkbY+~2AqkG=sQTDsRH z{(RC%%Op}q1BAmO z`o|legf5^tF$PjVE$;o_>DQdOG8H6O70~&aaa>`-o8;P$Au(?-w$^-# zds}vC;#pm1iZ^Crxgn3p-?_5By?ULR`#rnQKe}&!yJ=n6>f@;b(`q&$+9+!im%}IH zwi*^tFjv}r$3JeZR^)6DM3t6>5N-sT$!6EjdLn{4s)luW6fyZ6qWox2CiL%)jo1TT za8wcr@}H_0EkhMK7AnDH`6j-37ZIE;3g_Ght^m(VVt4vN6|~}O)+CNPT>e_M`K48G z2LU5wk?D+{{a{+x&T(#$y^j|1D*F3Yei*F4WDv_FMJAC_giFNZnk#Y~y29e>XhT;OhmsYZv&NyE zgmUT&p+^3ad#~%!LE04IsX(wTit|OVia91RsFC6U&#;yNqmDTtPp4cpoOJolYtb`b zh6>=hmmeR>0tdl8`=~&yCFbd&ma#Z^gh4q(*cVj9F%sK z^fT=xm1NiI+B)VRl@ZmZ1S2d*y92}Az<;kn$Of*a9}-sj3EzZlbo%cnqi=NfS5S_B zJ!v-?-XaJnxxgX6s2;>;q0FSI19`ZZ2D1d;->rXlu)9XG)2C*6B!a9;BS_*elwJ#v zFfr-TAR$0I!Ey@{Hjv!c+NTv+XHbIFe>4oPkI4zZI8sXbdLE20KPV+g->l-q@XG`! zk&!9}`DP@N`I@el%=J8m0?LBV=*LKD{%W zTK@pzbn)ppDv*5qePHyWsjygUELgwt7Ls=?aMil=5S7VHQPfW<=Z1jMOW}^>2 zzu`Cbb6N4Y?pBC8!xdX+nfRlO50RuS&C8xgAE4?yT)?KIACRHdZV}^2;!;6A<8d<9 zhg%v)2ClI!JoZZaCsmj_apnie4{nPqKFh83os1^ur%yTWkL>>dj?+r=?=@F;9!B>$ z!fQ&f*Jw)q-Xqb3m_3)Q%DD;lkQS!nWCKu1k#Az$Uh+dG+O;3@yXa$T@J13Sp)%q% zW>}eiDOcnDj6S&|phBTE*=INCQf^e6d$@omJDC^9F<@^=H?tNTyT z_vlIkuUJatY0@7{%V|*X{hyBwRZEw4FB7KrML&^VB@4<3`7fa(8P8nlYz^%-Tm>Sx zf_Rpm&dGkV!Hx(vkHRQlf_V#G+JhEwO(ygR| z#Q0j(ShaY)!F`1P07&)E`}8YeeCIV5@|GXvPvaWC0@UlBoO_AyshSIwfc#d6xI9=p zH^{C#k4$vDIg6A1ZZj?*pdEinRPl?NirW@lz+5d5+Zx@V!b=rBOub`9iCfeAU3?CVLC`+CNHJLo2=a@$iChmtZ>5~m|0(1O4HgVApTX>rpd z->p*Im!Z9CYl1zj77?0%j$ScG&R1;82p-VCwEFcfW(#_3G?ii;t6sXzd(3}`d`rZ< z1}`V8Za2GbxM5aN^6@1>Ipo>OlfK6J4pPs<8^b0f3M z%i2`q1CBdmzkK!TD4uI?41yYm$E{4&m8~s^k~>CXtcq33AcG$MSpC`U(2{w9(!6U3 z0mPWNBd}%)NM7tf>_6A0fMTv2t=ZYv$8X_Sh`U{@N%KUqdmMtsGQja+kK8--C2kOE zk|yT#wT^DNXCg^B9=J4KK&>6zKfFN8@^( z?v`I3r@ET+r{QfbDEVuI$jSnz_h_GJulxyBk Pt0x?mCm1}sbWH!*L)UM& literal 0 HcmV?d00001 diff --git a/packages/enough_mail/test/smtp/testimage.jpg b/packages/enough_mail/test/smtp/testimage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d82bb80d37affe98326ab4d21ec175ccbd2b98bc GIT binary patch literal 13390 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 literal 0 HcmV?d00001 diff --git a/packages/enough_mail/test/src/imap/fetch_parser_test.dart b/packages/enough_mail/test/src/imap/fetch_parser_test.dart new file mode 100644 index 0000000..f0fcad9 --- /dev/null +++ b/packages/enough_mail/test/src/imap/fetch_parser_test.dart @@ -0,0 +1,1569 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:enough_convert/enough_convert.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/imap/all_parsers.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('BODY 1', () { + const responseText = + '''* 70 FETCH (UID 179 BODY (("text" "plain" ("charset" "utf8") NIL NIL "8bit" 45 3)("image" "jpg" ("charset" "utf8" "name" "testimage.jpg") NIL NIL "base64" 18324) "mixed"))'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].sequenceId, 70); + final body = messages?[0].body; + expect(body, isNotNull); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].size, 45); + expect(body?.parts?[0].numberOfLines, 3); + expect(body?.parts?[0].contentType?.charset, 'utf8'); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.image); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.imageJpeg); + expect(body?.parts?[1].contentType?.parameters['name'], 'testimage.jpg'); + expect(body?.parts?[1].size, 18324); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + }); + + test('BODY 2', () { + const responseText = + '* 70 FETCH (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"))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + expect(body, isNotNull); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.charset, 'us-ascii'); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].size, 1152); + expect(body?.parts?[0].numberOfLines, 23); + expect(body?.parts?[0].encoding, '7bit'); + expect(body?.parts?[1].description, 'Compiler diff'); + expect(body?.parts?[1].cid, '<960723163407.20117h@cac.washington.edu>'); + expect(body?.parts?[1].contentType?.charset, 'us-ascii'); + expect(body?.parts?[1].contentType?.parameters['name'], 'cc.diff'); + + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[1].size, 4554); + expect(body?.parts?[1].numberOfLines, 73); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + }); + + test('BODY 3', () { + const responseText = + '* 32 FETCH (BODY (((("text" "plain" ("charset" "us-ascii") NIL NIL ' + '"7bit" 10252 819)' + '("text" "html" ("charset" "us-ascii") NIL NIL "quoted-printable" ' + '154063 2645) "alternative")' + '("image" "png" ("name" "image001.png") "" NIL ' + '"base64" 29038)' + '("image" "png" ("name" "image003.png") "" NIL ' + '"base64" 3286)' + '("image" "png" ("name" "image005.png") "" NIL ' + '"base64" 3552)' + '("image" "png" ("name" "image007.png") "" NIL ' + '"base64" 29874)' + '("image" "png" ("name" "image008.png") "" NIL ' + '"base64" 3314)' + '("image" "png" ("name" "image009.png") "" NIL ' + '"base64" 3576) "related")' + '("application" "pdf" ("name" "name.pdf") NIL NIL "base64" 749602)' + '("application" "pdf" ("name" "name.pdf") NIL NIL "base64" 611336)' + '("application" "pdf" ("name" "name.pdf") NIL NIL "base64" 586426) ' + '"mixed"))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 4); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartRelated, + ); + expect(body?.parts?[0].parts, isNotEmpty); + expect(body?.parts?[0].parts?.length, 7); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?.parts?[0].parts?[0].parts, isNotEmpty); + expect( + body?.parts?[0].parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect( + body?.parts?[0].parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.imagePng, + ); + expect( + body?.parts?[0].parts?[6].contentType?.mediaType.sub, + MediaSubtype.imagePng, + ); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect( + body?.parts?[2].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect( + body?.parts?[3].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + }); + + test('BODY 4 with encoded filename', () { + const responseText = + '''* 70 FETCH (UID 179 BODY (("text" "plain" ("charset" "utf8") NIL NIL "8bit" 45 3)("audio" "mp4" ("charset" "utf8" "name" "=?iso-8859-1?Q?01_So_beeinflu=DFbar.m4a?=") NIL "=?iso-8859-1?Q?01_So_beeinflu=DFbar.m4a?=" "base64" 18324) "mixed"))'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].sequenceId, 70); + final body = messages?[0].body; + expect(body, isNotNull); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].size, 45); + expect(body?.parts?[0].numberOfLines, 3); + expect(body?.parts?[0].contentType?.charset, 'utf8'); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.audio); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.audioMp4); + expect( + body?.parts?[1].contentType?.parameters['name'], + '=?iso-8859-1?Q?01_So_beeinflu=DFbar.m4a?=', + ); + expect( + body?.parts?[1].description, + '=?iso-8859-1?Q?01_So_beeinflu=DFbar.m4a?=', + ); + expect(body?.parts?[1].size, 18324); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + }); + + test('BODYSTRUCTURE 1', () { + const responseText = '* 70 FETCH (UID 179 BODYSTRUCTURE (' + '("text" "plain" ("charset" "utf8") NIL NIL "8bit" 45 3 NIL NIL NIL ' + 'NIL)' + '("image" "jpg" ("charset" "utf8" "name" "testimage.jpg") NIL NIL ' + '"base64" 18324 NIL ("attachment" ("filename" "testimage.jpg" "modifica' + 'tion-date" "Fri, 27 Jan 2017 16:34:4 +0100" "size" "13390")) NIL NIL) ' + '"mixed" ("charset" "utf8" "boundary" "cTOLC7EsqRfMsG") NIL NIL NIL))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + expect(body, isNotNull); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].contentType?.charset, 'utf8'); + expect(body?.parts?[0].contentDisposition, isNull); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.image); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.imageJpeg); + expect(body?.parts?[1].contentType?.parameters['name'], 'testimage.jpg'); + expect(body?.parts?[1].encoding, 'base64'); + final contentDisposition = body?.parts?[1].contentDisposition; + expect(contentDisposition, isNotNull); + expect(contentDisposition?.dispositionText, 'attachment'); + expect(contentDisposition?.disposition, ContentDisposition.attachment); + expect(contentDisposition?.size, 13390); + expect( + contentDisposition?.modificationDate, + DateCodec.decodeDate('Fri, 27 Jan 2017 16:34:4 +0100'), + ); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.charset, 'utf8'); + expect(body?.contentType?.boundary, 'cTOLC7EsqRfMsG'); + }); + + test('BODYSTRUCTURE 2', () { + const responseText = '* 2014 FETCH (FLAGS (\\Seen) BODYSTRUCTURE (' + '(' + '("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 2 1 NIL NIL NIL)' + '("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "7BIT" 24 1 NIL NIL NIL) ' + '"ALTERNATIVE" ' + '("BOUNDARY" "00000000000005d37e05a528d9c3") NIL NIL' + ')' + '("APPLICATION" "PDF" ("NAME" "gdpr infomedica informativa clienti.p' + 'df") "<171f5fa0424e36cb441>" NIL "BASE64" 238268 NIL ' + '("ATTACHMENT" ("FILENAME" "gdpr infomedica informativa clienti.pdf")' + ') NIL) "MIXED" ' + '("BOUNDARY" "00000000000005d38005a528d9c5") NIL NIL))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '00000000000005d38005a528d9c5'); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect( + body?.parts?[0].contentType?.boundary, + '00000000000005d37e05a528d9c3', + ); + expect(body?.parts?[0].parts, isNotNull); + expect(body?.parts?[0].parts, isNotEmpty); + expect(body?.parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(body?.parts?[0].parts?[0].encoding, '7bit'); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[0].parts?[1].encoding, '7bit'); + expect(body?.parts?[1].contentType, isNotNull); + expect(body?.parts?[1].contentType?.mediaType, isNotNull); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect( + body?.parts?[1].contentType?.parameters['name'], + 'gdpr infomedica informativa clienti.pdf', + ); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect( + body?.parts?[1].contentDisposition?.filename, + 'gdpr infomedica informativa clienti.pdf', + ); + }); + + test('BODYSTRUCTURE 3', () { + const responseText = '* 2175 FETCH (UID 3641 FLAGS (\\Seen) BODYSTRUCTURE (' + '(' + '(' + '("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" 274 ' + '6 NIL NIL NIL)' + '("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" 1455 ' + '30 NIL NIL NIL) ' + '"ALTERNATIVE" ("BOUNDARY" "0000000000002f322a05a71aaf69") NIL NIL' + ')' + '("IMAGE" "PNG" ("NAME" "icon.png") "" NIL "BASE64" 1986 ' + 'NIL ("ATTACHMENT" ("FILENAME" "icon.png")) NIL) ' + '"RELATED" ("BOUNDARY" "0000000000002f322205a71aaf68") NIL NIL' + ')' + '("MESSAGE" "DELIVERY-STATUS" NIL NIL NIL "7BIT" 488 NIL NIL NIL)' + '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 2539 ("Tue, 2 Jun 2020 16:25' + ':29 +0200" "tested" (("Tallah" NIL "Rocks" "domain.com")) (("Tallah"' + ' NIL "Rocks" "domain.com")) (("Tallah" NIL "Rocks" "domain.com")) ' + '(("Rocks@domain.com" NIL "Rocks" "domain.com")("Akari Haro" NIL "ak' + 'ari-haro" "domain.com")) NIL NIL NIL "GDQBjfh3TAG63B@domain.com") ' + '(("TEXT" "PLAIN" ("CHARSET" "utf8") NIL NIL "7BIT" 0 0 NIL NIL NIL)' + '("TEXT" "HTML" ("CHARSET" "utf8") NIL NIL "8BIT" 1 1 NIL NIL NIL) "' + 'ALTERNATIVE" ("BOUNDARY" "C6WuYgfyNiVn6u" "CHARSET" "utf8") NIL NIL)' + ' 51 NIL NIL NIL) "REPORT" ("BOUNDARY" "0000000000002f1f3705a71aaf47"' + ' "REPORT-TYPE" "delivery-status") NIL NIL' + ')' + ')'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].uid, 3641); + expect(messages?[0].flags, ['\\Seen']); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartReport); + expect(body?.contentType?.boundary, '0000000000002f1f3705a71aaf47'); + expect(body?.contentType?.parameters['report-type'], 'delivery-status'); + expect(body?.parts, isNotNull); + expect(body?.parts?.length, 3); + expect(body?.parts?[0].fetchId, '1'); + expect(body?.parts?[0].contentType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType, isNotNull); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartRelated, + ); + expect( + body?.parts?[0].contentType?.boundary, + '0000000000002f322205a71aaf68', + ); + expect(body?.parts?[0].parts, isNotNull); + expect(body?.parts?[0].parts, isNotEmpty); + expect(body?.parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.multipart, + ); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect( + body?.parts?[0].parts?[0].contentType?.boundary, + '0000000000002f322a05a71aaf69', + ); + expect(body?.parts?[0].parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(body?.parts?[0].parts?[0].parts?[0].contentType?.charset, 'utf-8'); + expect(body?.parts?[0].parts?[0].parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].parts?[0].size, 274); + expect( + body?.parts?[0].parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[0].parts?[0].parts?[1].contentType?.charset, 'utf-8'); + expect(body?.parts?[0].parts?[0].parts?[1].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].parts?[1].size, 1455); + expect(body?.parts?[1].contentType, isNotNull); + expect(body?.parts?[1].contentType?.mediaType, isNotNull); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.message); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.messageDeliveryStatus, + ); + expect(body?.parts?[1].size, 488); + expect(body?.parts?[1].encoding, '7bit'); + expect(body?.parts?[2].contentType?.mediaType.top, MediaToptype.message); + expect( + body?.parts?[2].contentType?.mediaType.sub, + MediaSubtype.messageRfc822, + ); + expect(body?.parts?[2].envelope, isNotNull); + expect(body?.parts?[2].envelope?.subject, 'tested'); + expect( + body?.parts?[2].envelope?.date, + DateCodec.decodeDate('Tue, 2 Jun 2020 16:25:29 +0200'), + ); + expect(body?.parts?[2].envelope?.from?.length, 1); + expect(body?.parts?[2].envelope?.from?[0].email, 'Rocks@domain.com'); + expect(body?.parts?[2].envelope?.to?.length, 2); + expect(body?.parts?[2].envelope?.to?[0].email, 'Rocks@domain.com'); + expect(body?.parts?[2].envelope?.to?[1].email, 'akari-haro@domain.com'); + expect(body?.parts?[2].envelope?.to?[1].personalName, 'Akari Haro'); + expect(body?.parts?[2].parts?.length, 1); + expect( + body?.parts?[2].parts?[0].contentType?.mediaType.top, + MediaToptype.multipart, + ); + expect( + body?.parts?[2].parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?.parts?[2].parts?[0].contentType?.boundary, 'C6WuYgfyNiVn6u'); + expect(body?.parts?[2].parts?[0].contentType?.charset, 'utf8'); + expect(body?.parts?[2].parts?[0].parts?.length, 2); + expect( + body?.parts?[2].parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(body?.parts?[2].parts?[0].parts?[0].contentType?.charset, 'utf8'); + expect(body?.parts?[2].parts?[0].parts?[0].encoding, '7bit'); + expect(body?.parts?[2].parts?[0].parts?[0].size, 0); + expect( + body?.parts?[2].parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[2].parts?[0].parts?[1].contentType?.charset, 'utf8'); + expect(body?.parts?[2].parts?[0].parts?[1].encoding, '8bit'); + expect(body?.parts?[2].parts?[0].parts?[1].size, 1); + }); + + test('BODYSTRUCTURE 4 - single part', () { + const responseTexts = [ + '''* 2175 FETCH (BODYSTRUCTURE ("TEXT" "PLAIN" ("CHARSET" "iso-8859-1") NIL NIL "QUOTED-PRINTABLE" 1315 42 NIL NIL NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.contentType?.mediaType.top, MediaToptype.text); + expect(body?.contentType?.charset, 'iso-8859-1'); + expect(body?.encoding, 'quoted-printable'); + expect(body?.size, 1315); + expect(body?.numberOfLines, 42); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 5 - simple alternative', () { + const responseTexts = [ + '''* 1 FETCH (BODYSTRUCTURE (("TEXT" "PLAIN" ("CHARSET" "iso-8859-1") NIL NIL "QUOTED-PRINTABLE" 2234 63 NIL NIL NIL NIL)("TEXT" "HTML" ("CHARSET" "iso-8859-1") NIL NIL "QUOTED-PRINTABLE" 2987 52 NIL NIL NIL NIL) "ALTERNATIVE" ("BOUNDARY" "d3438gr7324") NIL NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartAlternative); + expect(body?.contentType?.boundary, 'd3438gr7324'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].size, 2234); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(body?.parts?[1].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[1].encoding, 'quoted-printable'); + expect(body?.parts?[1].size, 2987); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 6 - simple alternative with image', () { + const responseTexts = [ + '''* 335 FETCH (BODYSTRUCTURE (("TEXT" "HTML" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 119 2 NIL ("INLINE" NIL) NIL)("IMAGE" "JPEG" ("NAME" "4356415.jpg") "<0__=rhksjt>" NIL "BASE64" 143804 NIL ("INLINE" ("FILENAME" "4356415.jpg")) NIL) "RELATED" ("BOUNDARY" "0__=5tgd3d") ("INLINE" NIL) NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartRelated); + expect(body?.contentType?.boundary, '0__=5tgd3d'); + // expect(body?.contentDisposition, isNotNull); + // expect(body?.contentDisposition?.disposition, ContentDisposition.inline); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(body?.parts?[0].contentType?.charset, 'us-ascii'); + expect(body?.parts?[0].encoding, '7bit'); + expect(body?.parts?[0].size, 119); + expect( + body?.parts?[0].contentDisposition?.disposition, + ContentDisposition.inline, + ); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.image); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.imageJpeg); + expect(body?.parts?[1].contentType?.parameters['name'], '4356415.jpg'); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.parts?[1].cid, '<0__=rhksjt>'); + expect(body?.parts?[1].size, 143804); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.inline, + ); + expect(body?.parts?[1].contentDisposition?.filename, '4356415.jpg'); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 7 - text + html with images', () { + const responseTexts = [ + '''* 202 FETCH (BODYSTRUCTURE (("TEXT" "PLAIN" ("CHARSET" "ISO-8859-1" "FORMAT" "flowed") NIL NIL "QUOTED-PRINTABLE" 2815 73 NIL NIL NIL NIL)(("TEXT" "HTML" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 4171 66 NIL NIL NIL NIL)("IMAGE" "JPEG" ("NAME" "image.jpg") "<3245dsf7435>" NIL "BASE64" 189906 NIL NIL NIL NIL)("IMAGE" "GIF" ("NAME" "other.gif") "<32f6324f>" NIL "BASE64" 1090 NIL NIL NIL NIL) "RELATED" ("BOUNDARY" "--=sdgqgt") NIL NIL NIL) "ALTERNATIVE" ("BOUNDARY" "--=u5sfrj") NIL NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartAlternative); + expect(body?.contentType?.boundary, '--=u5sfrj'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?.parts?[0].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[0].contentType?.isFlowedFormat, true); + expect(body?.parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].size, 2815); + // expect(body?.parts?[0].contentDisposition?.disposition, + // ContentDisposition.inline); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.multipartRelated, + ); + expect(body?.parts?[1].contentType?.boundary, '--=sdgqgt'); + expect(body?.parts?[1].parts?.length, 3); + expect( + body?.parts?[1].parts?[0].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[1].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[1].parts?[0].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[1].parts?[0].encoding, 'quoted-printable'); + expect( + body?.parts?[1].parts?[1].contentType?.mediaType.top, + MediaToptype.image, + ); + expect( + body?.parts?[1].parts?[1].contentType?.mediaType.sub, + MediaSubtype.imageJpeg, + ); + expect( + body?.parts?[1].parts?[1].contentType?.parameters['name'], + 'image.jpg', + ); + expect(body?.parts?[1].parts?[1].cid, '<3245dsf7435>'); + expect(body?.parts?[1].parts?[1].encoding, 'base64'); + expect(body?.parts?[1].parts?[1].size, 189906); + expect( + body?.parts?[1].parts?[2].contentType?.mediaType.top, + MediaToptype.image, + ); + expect( + body?.parts?[1].parts?[2].contentType?.mediaType.sub, + MediaSubtype.imageGif, + ); + expect( + body?.parts?[1].parts?[2].contentType?.parameters['name'], + 'other.gif', + ); + expect(body?.parts?[1].parts?[2].cid, '<32f6324f>'); + expect(body?.parts?[1].parts?[2].encoding, 'base64'); + expect(body?.parts?[1].parts?[2].size, 1090); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 8 - text + html with images 2', () { + const responseTexts = [ + '''* 41 FETCH (BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 471 28 NIL NIL NIL)("TEXT" "HTML" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 1417 36 NIL ("INLINE" NIL) NIL) "ALTERNATIVE" ("BOUNDARY" "1__=hqjksdm") NIL NIL)("IMAGE" "GIF" ("NAME" "image.gif") "<1__=cxdf2f>" NIL "BASE64" 50294 NIL ("INLINE" ("FILENAME" "image.gif")) NIL) "RELATED" ("BOUNDARY" "0__=hqjksdm") NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartRelated); + expect(body?.contentType?.boundary, '0__=hqjksdm'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?.parts?[0].contentType?.boundary, '1__=hqjksdm'); + expect(body?.parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(body?.parts?[0].parts?[0].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[0].parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].size, 471); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[0].parts?[1].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[0].parts?[1].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[1].size, 1417); + expect( + body?.parts?[0].parts?[1].contentDisposition?.disposition, + ContentDisposition.inline, + ); + expect(body?.parts?[1].contentType?.mediaType.top, MediaToptype.image); + expect(body?.parts?[1].contentType?.mediaType.sub, MediaSubtype.imageGif); + expect(body?.parts?[1].contentType?.parameters['name'], 'image.gif'); + expect(body?.parts?[1].cid, '<1__=cxdf2f>'); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.parts?[1].size, 50294); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.inline, + ); + expect(body?.parts?[1].contentDisposition?.filename, 'image.gif'); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 9 - mail with attachment', () { + const responseTexts = [ + '''* 302 FETCH (BODYSTRUCTURE (("TEXT" "HTML" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 4692 69 NIL NIL NIL NIL)("APPLICATION" "PDF" ("NAME" "pages.pdf") NIL NIL "BASE64" 38838 NIL ("attachment" ("FILENAME" "pages.pdf")) NIL NIL) "MIXED" ("BOUNDARY" "----=6fgshr") NIL NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '----=6fgshr'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(body?.parts?[0].contentType?.charset, 'iso-8859-1'); + expect(body?.parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].size, 4692); + expect( + body?.parts?[1].contentType?.mediaType.top, + MediaToptype.application, + ); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect(body?.parts?[1].contentType?.parameters['name'], 'pages.pdf'); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.parts?[1].size, 38838); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect(body?.parts?[1].contentDisposition?.filename, 'pages.pdf'); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 10 - alternative and attachment', () { + const responseTexts = [ + '''* 356 FETCH (BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" 403 6 NIL NIL NIL NIL)("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" 421 6 NIL NIL NIL NIL) "ALTERNATIVE" ("BOUNDARY" "----=fghgf3") NIL NIL NIL)("APPLICATION" "vnd.openxmlformats-officedocument.wordprocessingml.document" ("NAME" "letter.docx") NIL NIL "BASE64" 110000 NIL ("attachment" ("FILENAME" "letter.docx" "SIZE" "80384")) NIL NIL) "MIXED" ("BOUNDARY" "----=y34fgl") NIL NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '----=y34fgl'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].fetchId, '1'); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?.parts?[0].contentType?.boundary, '----=fghgf3'); + expect(body?.parts?[0].parts?.length, 2); + expect(body?.parts?[0].parts?[0].fetchId, '1.1'); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect(body?.parts?[0].parts?[0].contentType?.charset, 'utf-8'); + expect(body?.parts?[0].parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].size, 403); + expect(body?.parts?[0].parts?[1].fetchId, '1.2'); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[0].parts?[1].contentType?.charset, 'utf-8'); + expect(body?.parts?[0].parts?[1].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[1].size, 421); + expect( + body?.parts?[1].contentType?.mediaType.top, + MediaToptype.application, + ); + expect(body?.parts?[1].fetchId, '2'); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationOfficeDocumentWordProcessingDocument, + ); + expect(body?.parts?[1].contentType?.parameters['name'], 'letter.docx'); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.parts?[1].size, 110000); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect(body?.parts?[1].contentDisposition?.filename, 'letter.docx'); + expect(body?.parts?[1].contentDisposition?.size, 80384); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 11 - all together', () { + const responseTexts = [ + '''* 1569 FETCH (BODYSTRUCTURE (((("TEXT" "PLAIN" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 833 30 NIL NIL NIL)("TEXT" "HTML" ("CHARSET" "ISO-8859-1") NIL NIL "QUOTED-PRINTABLE" 3412 62 NIL ("INLINE" NIL) NIL) "ALTERNATIVE" ("BOUNDARY" "2__=fgrths") NIL NIL)("IMAGE" "GIF" ("NAME" "485039.gif") "<2__=lgkfjr>" NIL "BASE64" 64 NIL ("INLINE" ("FILENAME" "485039.gif")) NIL) "RELATED" ("BOUNDARY" "1__=fgrths") NIL NIL)("APPLICATION" "PDF" ("NAME" "title.pdf") "<1__=lgkfjr>" NIL "BASE64" 333980 NIL ("ATTACHMENT" ("FILENAME" "title.pdf")) NIL) "MIXED" ("BOUNDARY" "0__=fgrths") NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '0__=fgrths'); + expect(body?.parts?.length, 2); + expect(body?.parts?[0].fetchId, '1'); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartRelated, + ); + expect(body?.parts?[0].contentType?.boundary, '1__=fgrths'); + expect(body?.parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.multipart, + ); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?.parts?[0].parts?[0].contentType?.boundary, '2__=fgrths'); + expect(body?.parts?[0].parts?[0].fetchId, '1.1'); + expect(body?.parts?[0].parts?[0].parts?.length, 2); + expect(body?.parts?[0].parts?[0].parts?[0].fetchId, '1.1.1'); + expect( + body?.parts?[0].parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect( + body?.parts?[0].parts?[0].parts?[0].contentType?.charset, + 'iso-8859-1', + ); + expect(body?.parts?[0].parts?[0].parts?[0].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].parts?[0].size, 833); + expect(body?.parts?[0].parts?[0].parts?[1].fetchId, '1.1.2'); + expect( + body?.parts?[0].parts?[0].parts?[1].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect( + body?.parts?[0].parts?[0].parts?[1].contentType?.charset, + 'iso-8859-1', + ); + expect(body?.parts?[0].parts?[0].parts?[1].encoding, 'quoted-printable'); + expect(body?.parts?[0].parts?[0].parts?[1].size, 3412); + expect( + body?.parts?[0].parts?[0].parts?[1].contentDisposition?.disposition, + ContentDisposition.inline, + ); + expect(body?.parts?[1].fetchId, '2'); + expect( + body?.parts?[1].contentType?.mediaType.top, + MediaToptype.application, + ); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect(body?.parts?[1].contentType?.parameters['name'], 'title.pdf'); + expect(body?.parts?[1].encoding, 'base64'); + expect(body?.parts?[1].cid, '<1__=lgkfjr>'); + expect(body?.parts?[1].size, 333980); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect(body?.parts?[1].contentDisposition?.filename, 'title.pdf'); + }); + + // real world example + test('BODYSTRUCTURE 12 - real world example', () { + const responseText = '* 1569 FETCH (BODYSTRUCTURE ((' + '("text" "plain" ("charset" "iso-8859-1") NIL NIL "quoted-printable"' + ' 149 10 NIL NIL NIL NIL)' + '("text" "html" ("charset" "iso-8859-1") NIL NIL "quoted-printable" ' + '2065 42 NIL NIL NIL NIL) "alternative" ("boundary" "_000_AM5PR0701' + 'MB25139B9E8D23795759E68308E8AD0AM5PR0701MB2513_") NIL NIL)' + '("image" "jpeg" ("name" "20210109_113526.jpg") "" "20210109_113526.jpg" "base64" 3902340 NIL ("i' + 'nline" ("filename" "20210109_113526.jpg" "size" "2851709" "creation' + '-date" "Sat, 09 Jan 2021 7:39:59 GMT" "modification-date" "Sat, 09 ' + 'Jan 2021 10:39:59 GMT")) NIL NIL)' + '("image" "jpeg" ("name" "20210109_113554.jpg") "" "20210109_113554.jpg" "base64" 5166380 NIL ("' + 'inline" ("filename" "20210109_113554.jpg" "size" "3775431" "creation' + '-date" "Sat, 09 Jan 2021 7:40:40 GMT" "modification-date" "Sat, 09 J' + 'an 2021 7:40:40 GMT")) NIL NIL)' + '("image" "jpeg" ("name" "20210109_113545.jpg") "<63441da1-6a9e-4afc-' + 'b13a-6ee3700e7fa7>" "20210109_113545.jpg" "base64" 4294472 NIL ("inl' + 'ine" ("filename" "20210109_113545.jpg" "size" "3138267" "creation-da' + 'te" "Sat, 09 Jan 2021 7:40:45 GMT" "modification-date" "Sat, 09 Jan ' + '2021 7:40:45 GMT")) NIL NIL)' + '("image" "jpeg" ("name" "processed.jpeg") "<0756cb18-2a81-4bd1-a3af-' + 'b11816caf509>" "processed.jpeg" "base64" 306848 NIL ("inline" ("file' + 'name" "processed.jpeg" "size" "224235" "creation-date" "Sat, 09 Jan ' + '2021 7:41:25 GMT" "modification-date" "Sat, 09 Jan 2021 7:41:25 GMT"' + ')) NIL NIL)' + ' "related" ("boundary" "_007_AM5PR0701MB25139B9E8D23795759E68308E8AD' + '0AM5PR0701MB2513_" "type" "multipart/alternative") NIL "de-DE") UID' + ' 1234567)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartRelated); + expect( + body?.contentType?.boundary, + '_007_AM5PR0701MB25139B9E8D23795759E68308E8AD0AM5PR0701MB2513_', + ); + expect(body?.parts?.length, 5); + expect(body?.parts?[1].cid, ''); + expect(body?.parts?[2].cid, ''); + expect(body?.parts?[3].cid, '<63441da1-6a9e-4afc-b13a-6ee3700e7fa7>'); + expect(body?.parts?[4].cid, '<0756cb18-2a81-4bd1-a3af-b11816caf509>'); + + expect(body?.parts?[0].fetchId, '1'); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.multipart); + expect( + body?.parts?[0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect( + body?.parts?[0].contentType?.boundary, + '_000_AM5PR0701MB25139B9E8D23795759E68308E8AD0AM5PR0701MB2513_', + ); + expect(body?.parts?[0].parts?.length, 2); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.top, + MediaToptype.text, + ); + expect( + body?.parts?[0].parts?[0].contentType?.mediaType.sub, + MediaSubtype.textPlain, + ); + expect( + body?.parts?[0].parts?[1].contentType?.mediaType.sub, + MediaSubtype.textHtml, + ); + expect(body?.parts?[0].parts?[0].contentType?.boundary, null); + }); + + // source: http://sgerwk.altervista.org/imapbodystructure.html + test('BODYSTRUCTURE 13 - single-element lists', () { + const responseTexts = [ + '''* 2246 FETCH (BODYSTRUCTURE (("TEXT" "HTML" NIL NIL NIL "7BIT" 151 0 NIL NIL NIL) "MIXED" ("BOUNDARY" "----=rfsewr") NIL NIL))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '----=rfsewr'); + expect(body?.parts?.length, 1); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(body?.parts?[0].encoding, '7bit'); + expect(body?.parts?[0].size, 151); + expect(body?.parts?[0].fetchId, '1'); + }); + + test('BODYSTRUCTURE 14 - with raw data parameters', () { + final contentType = ContentTypeHeader('application/pdf') + ..setParameter('name', 'FileName.pdf'); + expect(contentType.parameters['name'], 'FileName.pdf'); + final contentDisposition = ContentDispositionHeader('attachment') + ..setParameter('filename', 'FileName.pdf'); + expect(contentDisposition.filename, 'FileName.pdf'); + const line1 = + '* 63644 FETCH (UID 351739 BODYSTRUCTURE (("TEXT" "html" ("charset" ' + '"utf-8") NIL NIL "BASE64" 5234 68 NIL NIL NIL NIL)("APPLICATION" "pdf"' + ' ("name" "Testpflicht an Schulen_09_04_21.pdf") NIL NIL "BASE64" ' + '638510' + ' NIL ("attachment" ("filename" "Testpflicht an Schulen_09_04_21.pdf" ' + '"size" "466602")) NIL NIL)("APPLICATION" "pdf" ("name" {42}'; + const line2 = 'Schnelltest Einverständniserklärung3.pdf'; + const line3 = ') NIL NIL "7BIT" 239068 NIL ("attachment" ("filename" {42}'; + const line4 = 'Schnelltest Einverständniserklärung3.pdf'; + const line5 = + '"size" "174701")) NIL NIL) "mixed" ("boundary" "--_com.android.email_' + '1204848368992460") NIL NIL NIL))'; + const responseTexts = [line1, line2, line3, line4, line5]; + final details = ImapResponse(); + var lastLineEndedInData = false; + for (final text in responseTexts) { + if (lastLineEndedInData) { + final rawData = utf8.encode(text); + details.add(ImapResponseLine.raw(rawData)); + lastLineEndedInData = false; + } else { + details.add(ImapResponseLine(text)); + lastLineEndedInData = text.endsWith('}'); + } + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + //print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect( + body?.contentType?.boundary, + '--_com.android.email_1204848368992460', + ); + expect(body?.parts?.length, 3); + expect(body?.parts?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?.parts?[0].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(body?.parts?[0].encoding, 'base64'); + expect(body?.parts?[0].size, 5234); + expect( + body?.parts?[1].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect( + body?.parts?[1].contentType?.parameters['name'], + 'Testpflicht an Schulen_09_04_21.pdf', + ); + expect(body?.parts?[1].contentDisposition, isNotNull); + expect( + body?.parts?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect( + body?.parts?[1].contentDisposition?.filename, + 'Testpflicht an Schulen_09_04_21.pdf', + ); + expect(body?.parts?[1].contentDisposition?.size, 466602); + + expect( + body?.parts?[2].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect( + body?.parts?[2].contentType?.parameters['name'], + 'Schnelltest Einverständniserklärung3.pdf', + ); + expect(body?.parts?[2].contentDisposition, isNotNull); + expect( + body?.parts?[2].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect( + body?.parts?[2].contentDisposition?.filename, + 'Schnelltest Einverständniserklärung3.pdf', + ); + expect(body?.parts?[2].contentDisposition?.size, 174701); + }); + + test('BODYSTRUCTURE 15 - complex with nested messages', () { + const responseText = + '''* 42780 FETCH (UID 147491 BODYSTRUCTURE (("TEXT" "plain" ("charset" "utf-8" "format" "flowed") NIL NIL "7BIT" 18 2 NIL NIL NIL NIL)("MESSAGE" "RFC822" ("name" "hello.eml") NIL NIL "7BIT" 198569 ("Wed, 14 Apr 2021 15:21:39 +0200" "hello" (("Laura Z" NIL "laura" "domain.com")) (("Laura Z" NIL "laura" "domain.com")) (("Laura Z" NIL "laura" "domain.com")) (("Robert" NIL "robert" "domain.org")) NIL NIL NIL "") (("TEXT" "plain" ("charset" "utf-8") NIL NIL "QUOTED-PRINTABLE" 428 29 NIL NIL NIL NIL)(("TEXT" "html" ("charset" "utf-8") NIL NIL "QUOTED-PRINTABLE" 7306 106 NIL NIL NIL NIL)("APPLICATION" "pdf" ("name" "document.pdf" "x-unix-mode" "0644") NIL NIL "BASE64" 184654 NIL ("inline" ("filename" "document.pdf")) NIL NIL)("TEXT" "html" ("charset" "us-ascii") NIL NIL "7BIT" 206 1 NIL NIL NIL NIL) "mixed" ("boundary" "Apple-Mail=_906E0701-F4B8-4A94-8CBA-E942B0E83C3D") NIL NIL NIL) "alternative" ("boundary" "Apple-Mail=_0818BF02-C6EC-4C85-ABD0-2A7CD6D0C178") NIL NIL NIL) 2619 NIL ("attachment" ("filename" "hello.eml")) NIL NIL)("MESSAGE" "RFC822" ("name" "Re: Foto test.eml") NIL NIL "7BIT" 813742 ("Thu, 15 Apr 2021 20:34:20 +0200" "Re: Foto test" (("Olga Z" NIL "sender" "domain.org")) (("Olga Z" NIL "sender" "domain.org")) (("Olga Z" NIL "sender" "domain.org")) (("Robert" NIL "robert" "domain.org")) NIL NIL "<1KxaI8FSujPYUDr_-0@domain.org>" "<6EJedHRKJ5sYJqjyqv@domain.org>") ((("TEXT" "plain" ("charset" "utf8") NIL NIL "QUOTED-PRINTABLE" 857 23 NIL NIL NIL NIL)("TEXT" "html" ("charset" "utf8") NIL NIL "QUOTED-PRINTABLE" 1252 35 NIL NIL NIL NIL) "alternative" ("boundary" "j2cHqGO6QhvyRZOtse") NIL NIL NIL)("IMAGE" "jpeg" ("name" "Screenshot_20210415-191139.jpg") NIL NIL "BASE64" 807126 NIL ("attachment" ("filename" "Screenshot_20210415-191139.jpg" "size" "589824")) NIL NIL) "mixed" ("boundary" "f44yw2ALkRvC4xc9Xm") NIL NIL NIL) 10490 NIL ("attachment" ("filename" "Re: Foto test.eml")) NIL NIL) "mixed" ("boundary" "------------511076DDA2208D9767CA39EA") NIL "en-US" NIL))'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + final body = messages?[0].body; + // print('parsed body part: \n$body'); + expect(body, isNotNull); + expect(body?.contentType, isNotNull); + expect(body?.contentType?.mediaType, isNotNull); + expect(body?.contentType?.mediaType.top, MediaToptype.multipart); + expect(body?.contentType?.mediaType.sub, MediaSubtype.multipartMixed); + expect(body?.contentType?.boundary, '------------511076DDA2208D9767CA39EA'); + expect(body?.length, 3); + expect(body?[0].contentType?.mediaType.top, MediaToptype.text); + expect(body?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(body?[0].encoding, '7bit'); + expect(body?[0].size, 18); + expect(body?[0].fetchId, '1'); + expect(body?[1].fetchId, '2'); + expect(body?[1].contentType, isNotNull); + expect(body?[1].contentType?.mediaType.sub, MediaSubtype.messageRfc822); + expect(body?[1].contentType?.parameters['name'], 'hello.eml'); + expect(body?[1].contentDisposition, isNotNull); + expect( + body?[1].contentDisposition?.disposition, + ContentDisposition.attachment, + ); + expect(body?[1].contentDisposition?.filename, 'hello.eml'); + expect(body?[1].length, 1); + expect(body?[1][0].contentType, isNotNull); + expect( + body?[1][0].contentType?.mediaType.sub, + MediaSubtype.multipartAlternative, + ); + expect(body?[1].fetchId, '2'); + expect(body?[1][0].fetchId, '2.TEXT'); + expect(body?[1][0].length, 2); + expect(body?[1][0][0].fetchId, '2.TEXT.1'); + expect(body?[1][0][0].contentType, isNotNull); + expect(body?[1][0][0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect( + body?[1][0][1].contentType?.mediaType.sub, + MediaSubtype.multipartMixed, + ); + expect(body?[2].fetchId, '3'); + expect(body?[2][0].fetchId, '3.TEXT'); + + final leafParts = body?.allLeafParts; + expect(leafParts?.length, 8); + expect(leafParts?[0].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(leafParts?[1].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(leafParts?[2].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect( + leafParts?[3].contentType?.mediaType.sub, + MediaSubtype.applicationPdf, + ); + expect(leafParts?[4].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(leafParts?[5].contentType?.mediaType.sub, MediaSubtype.textPlain); + expect(leafParts?[6].contentType?.mediaType.sub, MediaSubtype.textHtml); + expect(leafParts?[7].contentType?.mediaType.sub, MediaSubtype.imageJpeg); + }); + + test('MODSEQ', () { + const responseText = '* 50 FETCH (MODSEQ (12111230047))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].sequenceId, 50); + expect(messages?[0].modSequence, 12111230047); + }); + + test('HIGHESTMODSEQ', () { + const responseText = '* OK [HIGHESTMODSEQ 12111230047]'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, false); + }); + + test('VANISHED', () { + const responseText = '* VANISHED (EARLIER) 300:310,405,411'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + + expect(processed, true); + expect(parser.lastParsedMessage, isNull); + expect(parser.vanishedMessages, isNotNull); + expect( + parser.vanishedMessages?.toList(), + [300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 405, 411], + ); + final result = parser.parse(details, response); + expect(result?.messages, isEmpty); + expect(result?.vanishedMessagesUidSequence, isNotNull); + expect( + result?.vanishedMessagesUidSequence?.toList(), + [300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 405, 411], + ); + }); + + test('BODY[2.1]', () { + const responseText1 = '* 50 FETCH (BODY[2.1] {12}'; + const responseText2 = 'Hello Word\r\n'; + const responseText3 = ')'; + + final details = ImapResponse() + ..add(ImapResponseLine(responseText1)) + ..add(ImapResponseLine.raw(utf8.encode(responseText2))) + ..add(ImapResponseLine(responseText3)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + expect(result?.messages, isNotEmpty); + expect(result?.messages.length, 1); + final part = result?.messages[0].getPart('2.1'); + expect(part, isNotNull); + expect(part?.decodeContentText(), 'Hello Word\r\n'); + }); + + test('empty BODY[2.1]', () { + const responseText1 = '* 50 FETCH (BODY[2.1] {0}'; + const responseText3 = ')'; + + final details = ImapResponse() + ..add(ImapResponseLine(responseText1)) + ..add(ImapResponseLine.raw(Uint8List(0))) + ..add(ImapResponseLine(responseText3)); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + expect(result?.messages, isNotEmpty); + expect(result?.messages.length, 1); + final part = result?.messages[0].getPart('2.1'); + expect(part, isNotNull); + expect(part?.decodeContentText(), ''); + }); + + test('ENVELOPE 1', () { + const responseTexts = [ + r'* 61792 FETCH (UID 347524 RFC822.SIZE 4579 ENVELOPE ("Sun, 9 Aug 2020 09:03:12 +0200 (CEST)" "Re: Your Query" (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) ((NIL NIL "recipient" "enough.de")) NIL NIL NIL "<9jbzp5olgc9n54qwutoty0pnxunmoyho5ugshxplpvudvurjwh3a921kjdwkpwrf9oe06g95k69t@mail.ebay-kleinanzeigen.de>") FLAGS (\Seen))', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].uid, 347524); + expect(messages?[0].size, 4579); + expect(messages?[0].flags, ['\\Seen']); + expect(messages?[0].from, isNotNull); + expect(messages?[0].from?.length, 1); + expect( + messages?[0].from?[0].email, + 'anbieter-sdkjskjfkd@mail.ebay-kleinanzeigen.de', + ); + expect( + messages?[0].from?[0].personalName, + 'C. Sender über eBay Kleinanzeigen', + ); + expect(messages?[0].decodeSubject(), 'Re: Your Query'); + }); + + test('ENVELOPE 2 with escaped quote in subject', () { + const responseTexts = [ + r'* 61792 FETCH (UID 347524 RFC822.SIZE 4579 ENVELOPE ("Sun, 9 Aug 2020 09:03:12 +0200 (CEST)" "Re: Your Query about \"Table\"" (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) ((NIL NIL "recipient" "enough.de")) NIL NIL NIL "<9jbzp5olgc9n54qwutoty0pnxunmoyho5ugshxplpvudvurjwh3a921kjdwkpwrf9oe06g95k69t@mail.ebay-kleinanzeigen.de>") FLAGS (\Seen))', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].uid, 347524); + expect(messages?[0].size, 4579); + expect(messages?[0].flags, ['\\Seen']); + expect(messages?[0].decodeSubject(), 'Re: Your Query about "Table"'); + expect(messages?[0].from, isNotNull); + expect(messages?[0].from?.length, 1); + expect( + messages?[0].from?[0].email, + 'anbieter-sdkjskjfkd@mail.ebay-kleinanzeigen.de', + ); + expect( + messages?[0].from?[0].personalName, + 'C. Sender über eBay Kleinanzeigen', + ); + }); + + test('ENVELOPE 3 with base64 in subject', () { + const responseTexts = [ + '''* 43792 FETCH (UID 146616 RFC822.SIZE 23156 ENVELOPE ("Tue, 12 Jan 2021 00:18:08 +0800" " =?utf-8?B?SWbCoEnCoGhhdmXCoHRoZcKgaG9ub3LCoHRvwqBqb2luwqB5b3VywqB2ZW5kb3LCoGFzwqBhwqB0cmFuc2xhdGlvbsKgY29tcGFueQ==?=" (("Sherry|Company" NIL "company" "domain.com")) (("Sherry|Company" NIL "company" "domain.com")) ((NIL NIL "company" "domain.com")) (("info" NIL "info" "recipientdomain.com")) NIL NIL NIL " ") FLAGS ())''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect( + messages?[0].decodeSubject(), + ' If I have the honor to join your vendor as a translation company', + ); + expect(messages?[0].uid, 146616); + expect(messages?[0].size, 23156); + expect(messages?[0].flags, []); + expect(messages?[0].from, isNotNull); + expect(messages?[0].from?.length, 1); + expect(messages?[0].from?[0].email, 'company@domain.com'); + expect(messages?[0].from?[0].personalName, 'Sherry|Company'); + }); + + test('ENVELOPE 4 with linebreak in subject', () { + final details = ImapResponse() + ..add(ImapResponseLine( + '''* 65300 FETCH (UID 355372 ENVELOPE ("Sat, 13 Nov 2021 09:01:57 +0100 (CET)" {108}''', + )) + ..add(ImapResponseLine.raw(utf8 + .encode('''=?UTF-8?Q?Anzeige_"K=C3=BCchenutensilien,_K=C3=A4seme?=\r + =?UTF-8?Q?sser"_erfolgreich_ver=C3=B6ffentlicht.?='''))) + ..add(ImapResponseLine( + ''' (("eBay Kleinanzeigen" NIL "noreply" "ebay-kleinanzeigen.de")) (("eBay Kleinanzeigen" NIL "noreply" "ebay-kleinanzeigen.de")) (("eBay Kleinanzeigen" NIL "noreply" "ebay-kleinanzeigen.de")) ((NIL NIL "some.one" "domain.com")) NIL NIL NIL "<709648757.77104.1636790517873@tns-consumer-app-7.tns-consumer-app.ebayk.svc.cluster.local>"))''', + )); + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect( + messages?[0].decodeSubject(), + 'Anzeige "Küchenutensilien, Käsemesser" erfolgreich veröffentlicht.', + ); + }); + + test('ENVELOPE 5 with base-encoded personal name in email', () { + const responseTexts = [ + '''* 69457 FETCH (UID 366113 RFC822.SIZE 67087 ENVELOPE ("Tue, 26 Sep 2023 10:37:26 -0400" "New Release: Modernize Applications Faster Than Ever" (("=?utf-8?b?VGhl4oCvVGVsZXJpayAm4oCvS2VuZG8gVUk=?= =?utf-8?b?IFRlYW1z4oCvYXQgUHJvZ3Jlc3PigK8=?=" NIL "progress" "products.progress.com")) (("=?utf-8?b?VGhl4oCvVGVsZXJpayAm4oCvS2VuZG8gVUk=?= =?utf-8?b?IFRlYW1z4oCvYXQgUHJvZ3Jlc3PigK8=?=" NIL "progress" "products.progress.com")) (("=?utf-8?b?VGhl4oCvVGVsZXJpayAm4oCvS2VuZG8gVUk=?= =?utf-8?b?IFRlYW1z4oCvYXQgUHJvZ3Jlc3PigK8=?=" NIL "replytosales" "progress.com")) ((NIL NIL "robert.virkus" "enough.de")) NIL NIL NIL "") FLAGS (\Seen))''', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect( + messages?[0].decodeSubject(), + 'New Release: Modernize Applications Faster Than Ever', + ); + expect(messages?[0].uid, 366113); + expect(messages?[0].size, 67087); + expect(messages?[0].flags, ['Seen']); + expect(messages?[0].from, isNotNull); + expect(messages?[0].from?.length, 1); + expect(messages?[0].from?[0].email, 'progress@products.progress.com'); + expect( + messages?[0].from?[0].personalName, + 'The Telerik & Kendo UI Teams at Progress ', + ); + }); + + test('measure performance', () { + const responseTexts = [ + r'* 61792 FETCH (UID 347524 RFC822.SIZE 4579 ENVELOPE ("Sun, 9 Aug 2020 09:03:12 +0200 (CEST)" "Re: Your Query about \"Table\"" (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) (("=?ISO-8859-1?Q?C=2E_Sender_=FCber_eBay_Kleinanzeigen?=" NIL "anbieter-sdkjskjfkd" "mail.ebay-kleinanzeigen.de")) ((NIL NIL "recipient" "enough.de")) NIL NIL NIL "<9jbzp5olgc9n54qwutoty0pnxunmoyho5ugshxplpvudvurjwh3a921kjdwkpwrf9oe06g95k69t@mail.ebay-kleinanzeigen.de>") FLAGS (\Seen))', + ]; + final details = ImapResponse(); + for (final text in responseTexts) { + details.add(ImapResponseLine(text)); + } + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final stopwatch = Stopwatch()..start(); + const count = 10000; + for (var i = count; --i >= 0;) { + final processed = parser.parseUntagged(details, response); + if (!processed) { + fail('unable to parse during performance test at round ${count - i}'); + } + } + //print('elapsed time: ${stopwatch.elapsedMicroseconds}'); + stopwatch.stop(); + }); + + group('8bit encoding tests', () { + test('Simple text message - windows-1252', () { + final details = ImapResponse(); + const codec = Windows1252Codec(); + const messageText = '''Subject: Hello world\r +Content-Type: text/plain; charset=windows-1252; format=flowed\r +Content-Transfer-Encoding: 8bit\r +\r +Teší ma, že vás spoznávam\r +'''; + final codecData = codec.encode(messageText); + final messageData = Uint8List.fromList(codecData); + details + ..add(ImapResponseLine( + '* 61792 FETCH (UID 347524 BODY[] {${messageData.length}}', + )) + ..add(ImapResponseLine.raw(messageData)) + ..add(ImapResponseLine(')')); + + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].decodeSubject(), 'Hello world'); + expect(messages?[0].uid, 347524); + expect(messages?[0].sequenceId, 61792); + expect(messages?[0].decodeContentText(), 'Teší ma, že vás spoznávam\r\n'); + }); + + test('Multipart text message - windows-1252', () { + final details = ImapResponse(); + const codec = Windows1252Codec(); + const messageText = '''Subject: Hello world\r +Content-Type: multipart/alternative; boundary=abcdefghijkl\r +\r +--abcdefghijkl\r +Content-Type: text/plain; charset=windows-1252; format=flowed\r +Content-Transfer-Encoding: 8bit\r +\r +Teší ma, že vás spoznávam\r +--abcdefghijkl\r +Content-Type: text/html; charset=windows-1252\r +Content-Transfer-Encoding: 8bit\r +\r +

Teší ma, že vás spoznávam

\r +--abcdefghijkl--\r +'''; + final codecData = codec.encode(messageText); + final messageData = Uint8List.fromList(codecData); + details + ..add(ImapResponseLine( + '* 61792 FETCH (UID 347524 BODY[] {${messageData.length}}', + )) + ..add(ImapResponseLine.raw(messageData)) + ..add(ImapResponseLine(')')); + + final parser = FetchParser(isUidFetch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final messages = parser.parse(details, response)?.messages; + expect(messages, isNotNull); + expect(messages?.length, 1); + expect(messages?[0].headers, isNotNull); + expect(messages?[0].headers?.isNotEmpty, isTrue); + expect(messages?[0].headers?.length, 2); + expect(messages?[0].decodeSubject(), 'Hello world'); + expect(messages?[0].uid, 347524); + expect(messages?[0].sequenceId, 61792); + expect( + messages?[0].decodeTextPlainPart(), + 'Teší ma, že vás spoznávam\r\n', + ); + expect( + messages?[0].decodeTextHtmlPart(), + '

Teší ma, že vás spoznávam

\r\n', + ); + }); + }); +} diff --git a/packages/enough_mail/test/src/imap/id_parser_test.dart b/packages/enough_mail/test/src/imap/id_parser_test.dart new file mode 100644 index 0000000..a6006af --- /dev/null +++ b/packages/enough_mail/test/src/imap/id_parser_test.dart @@ -0,0 +1,57 @@ +import 'package:enough_mail/src/imap/id.dart'; +import 'package:enough_mail/src/imap/response.dart'; +import 'package:enough_mail/src/private/imap/id_parser.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('NIL', () { + const responseText = '* ID NIL'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = IdParser(); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final id = parser.parse(details, response); + expect(id, isNull); + }); + + test('Cyrus', () { + const responseText = + '''* ID ("name" "Cyrus" "version" "1.5" "os" "sunos" "os-version" "5.5" "support-url" "mailto:cyrus-bugs+@andrew.cmu.edu")'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = IdParser(); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final id = parser.parse(details, response); + expect(id, isNotNull); + expect(id?.name, 'Cyrus'); + expect(id?.version, '1.5'); + expect(id?.os, 'sunos'); + expect(id?.osVersion, '5.5'); + expect(id?.supportUrl, 'mailto:cyrus-bugs+@andrew.cmu.edu'); + expect(id?.nonStandardFields, isEmpty); + }); + + test('Cyrus with Date', () { + const responseText = + '''* ID ("name" "Cyrus" "version" "1.5" "os" "sunos" "os-version" "5.5" "support-url" "mailto:cyrus-bugs+@andrew.cmu.edu" "date" "Sun, 15 Aug 2021 22:45 +0000")'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = IdParser(); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final id = parser.parse(details, response); + expect(id, isNotNull); + expect(id?.name, 'Cyrus'); + expect(id?.version, '1.5'); + expect(id?.os, 'sunos'); + expect(id?.osVersion, '5.5'); + expect(id?.supportUrl, 'mailto:cyrus-bugs+@andrew.cmu.edu'); + expect(id?.nonStandardFields, isEmpty); + expect(id?.date?.toUtc(), DateTime.utc(2021, 08, 15, 22, 45)); + }); +} diff --git a/packages/enough_mail/test/src/imap/imap_response_line_test.dart b/packages/enough_mail/test/src/imap/imap_response_line_test.dart new file mode 100644 index 0000000..8612a83 --- /dev/null +++ b/packages/enough_mail/test/src/imap/imap_response_line_test.dart @@ -0,0 +1,43 @@ +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; + +void main() { + test('ImapResponseLine.init() with simple response', () { + const input = 'HELLO ()'; + final line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, input); + expect(line.isWithLiteral, false); + }); // test end + + test('ImapResponseLine.init() with complex response', () { + const input = 'HELLO {12}'; + final 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', + () { + const input = 'HELLO {12+}'; + final line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, 'HELLO'); + expect(line.isWithLiteral, true); + expect(line.literal, 12); + }, + ); // test end + + test('ImapResponseLine with empty literal', () { + const input = 'HELLO {0}'; + final line = ImapResponseLine(input); + expect(line.rawLine, input); + expect(line.line, 'HELLO'); + expect(line.isWithLiteral, true); + expect(line.literal, 0); + }); +} diff --git a/packages/enough_mail/test/src/imap/imap_response_reader_test.dart b/packages/enough_mail/test/src/imap/imap_response_reader_test.dart new file mode 100644 index 0000000..94ba917 --- /dev/null +++ b/packages/enough_mail/test/src/imap/imap_response_reader_test.dart @@ -0,0 +1,295 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_reader.dart'; +import 'package:test/test.dart'; + +// cSpell:disable +ImapResponse? _lastResponse; +void _onImapResponse(ImapResponse response) { + _lastResponse = response; +} + +List _lastResponses = []; +void _onMultipleImapResponse(ImapResponse response) { + _lastResponses.add(response); +} + +Uint8List _toUint8List(String text) => Uint8List.fromList(text.codeUnits); + +void main() { + test('ImapResponseReader.oneOnDataCall()', () { + final reader = ImapResponseReader(_onImapResponse); + const 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_Ro' + 'b?=" 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, isNotNull, reason: 'response expected'); + expect(_lastResponse?.isSimple, false); + expect(_lastResponse?.lines, isNotEmpty); + expect(_lastResponse?.lines.length, 3); + expect(_lastResponse?.lines[0].isWithLiteral, true); + expect(_lastResponse?.lines[0].literal, 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].isWithLiteral, false); + expect( + _lastResponse?.lines[1].line, + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps', + ); + expect(_lastResponse?.lines[2].isWithLiteral, false); + _lastResponse = null; + }); // test end + + test('ImapResponseReader - simple response', () { + final reader = ImapResponseReader(_onImapResponse); + const 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?.isSimple, true); + expect(_lastResponse?.lines.length, 1); + expect(_lastResponse?.first, _lastResponse?.lines[0]); + 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', () { + final 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?.isSimple, true); + expect(_lastResponse?.lines.length, 1); + expect(_lastResponse?.first, _lastResponse?.lines[0]); + 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', () { + final 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].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].isWithLiteral, true); + expect(_lastResponse?.lines[1].line, 'FRED FOOBAR'); + expect(_lastResponse?.lines[1].literal, 7); + expect(_lastResponse?.lines[2].isWithLiteral, false); + expect(_lastResponse?.lines[2].line, 'fat man'); + _lastResponse = null; + }); // test end + + test('ImapResponseReader - response in one go', () { + _lastResponses.clear(); + final reader = ImapResponseReader(_onMultipleImapResponse); + const text = + r'* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $social' + r' $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 (final 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); + } + final last = _lastResponses.last; + 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(); + final reader = ImapResponseReader(_onMultipleImapResponse); + const 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].line, '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(); + final 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)) + ..onData(_toUint8List('etch completed\r\n')); + expect(_lastResponses.isNotEmpty, true); + expect(_lastResponses[0].lines.length, 3); + expect(_lastResponses[0].lines[1].line, '0123456789'); + expect(_lastResponses.length, 2); + expect(_lastResponses[1].isSimple, true); + expect(_lastResponses[1].parseText, 'a002 OK Fetch completed'); + }); // test end + + test('ImapResponseReader - 2 responses in 1 delivery', () { + const input = '''* 3 FETCH (BODY[TEXT] {6}\r +Hi\r +\r + BODY[HEADER.FIELDS (DATE)] {47}\r +Date: Tue, 21 Jan 2020 11:59:55 +0100 (CET)\r +\r +)\r +a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).\r +'''; + _lastResponses.clear(); + ImapResponseReader(_onMultipleImapResponse).onData(_toUint8List(input)); + expect(_lastResponses.length, 2); + expect(_lastResponses[0].lines[0].rawLine, '* 3 FETCH (BODY[TEXT] {6}'); + expect(_lastResponses[0].lines[1].line, 'Hi\r\n\r\n'); + expect( + _lastResponses[0].lines[2].rawLine, + 'BODY[HEADER.FIELDS (DATE)] {47}', + ); + expect( + _lastResponses[0].lines[3].line, + 'Date: Tue, 21 Jan 2020 11:59:55 +0100 (CET)\r\n\r\n', + ); + expect(_lastResponses[0].lines[4].rawLine, ')'); + expect(_lastResponses[0].lines.length, 5); + expect(_lastResponses[1].isSimple, true); + expect( + _lastResponses[1].parseText, + 'a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).', + ); + }); + + test('ImapResponseReader - 2 responses in 1 delivery with 3 literals', () { + const input = '''* 3 FETCH (BODY[TEXT] {6}\r +Hi\r +\r + BODY[HEADER.FIELDS (DATE)] {47}\r +Date: Tue, 21 Jan 2020 11:59:55 +0100 (CET)\r +\r + BODY[HEADER.FIELDS (MESSAGE-ID)] {36}\r +Message-ID: <3049329.2-302-12-2>\r +\r +)\r +a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).\r +'''; + _lastResponses.clear(); + ImapResponseReader(_onMultipleImapResponse).onData(_toUint8List(input)); + expect(_lastResponses.length, 2); + expect(_lastResponses[0].lines[0].rawLine, '* 3 FETCH (BODY[TEXT] {6}'); + expect(_lastResponses[0].lines[1].line, 'Hi\r\n\r\n'); + expect( + _lastResponses[0].lines[2].rawLine, + 'BODY[HEADER.FIELDS (DATE)] {47}', + ); + expect( + _lastResponses[0].lines[3].line, + 'Date: Tue, 21 Jan 2020 11:59:55 +0100 (CET)\r\n\r\n', + ); + expect( + _lastResponses[0].lines[4].rawLine, + 'BODY[HEADER.FIELDS (MESSAGE-ID)] {36}', + ); + expect( + _lastResponses[0].lines[5].line, + 'Message-ID: <3049329.2-302-12-2>\r\n\r\n', + ); + expect(_lastResponses[0].lines[6].rawLine, ')'); + expect(_lastResponses[0].lines.length, 7); + expect(_lastResponses[1].isSimple, true); + expect( + _lastResponses[1].parseText, + 'a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).', + ); + }); + + test('ImapResponseReader - 3 response lines with break in rn sequence', () { + const input1 = '''* 3 FETCH (BODY[TEXT] {6}\r +123456)\r'''; + const input2 = '''\n* 4 FETCH (BODY[TEXT] {7}\r +1234567)\r +a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).\r +'''; + _lastResponses.clear(); + ImapResponseReader(_onMultipleImapResponse) + ..onData(_toUint8List(input1)) + ..onData(_toUint8List(input2)); + expect(_lastResponses.length, 3); + expect(_lastResponses[0].lines[0].rawLine, '* 3 FETCH (BODY[TEXT] {6}'); + expect(_lastResponses[0].lines[1].line, '123456'); + expect(_lastResponses[0].lines[2].rawLine, ')'); + expect(_lastResponses[1].lines[0].rawLine, '* 4 FETCH (BODY[TEXT] {7}'); + expect(_lastResponses[1].lines[1].line, '1234567'); + expect(_lastResponses[1].lines[2].rawLine, ')'); + expect( + _lastResponses[2].parseText, + 'a3 OK Fetch completed (0.020 + 0.000 + 0.019 secs).', + ); + }); +} diff --git a/packages/enough_mail/test/src/imap/imap_response_test.dart b/packages/enough_mail/test/src/imap/imap_response_test.dart new file mode 100644 index 0000000..9e6207f --- /dev/null +++ b/packages/enough_mail/test/src/imap/imap_response_test.dart @@ -0,0 +1,562 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; + +// cSpell:disable +void main() { + test('ImapResponse.iterate() with simple response', () { + const input = 'A001 OK FLAGS "seen" "new flag" DONE'; + final response = ImapResponse(); + final line = ImapResponseLine(input); + response.add(line); + final parsed = response.iterate(); + //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', () { + final response = ImapResponse() + ..add(ImapResponseLine('A001 OK FLAGS {10}')) + ..add(ImapResponseLine.raw(Uint8List.fromList('1"2 3 \r\n90'.codeUnits))) + ..add(ImapResponseLine('"DONE"')); + final parsed = response.iterate(); + //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, isNull); + expect(parsed.values[3].valueOrDataText, '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(); + //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(); + //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(); + + //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(); + + //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', () { + final response = ImapResponse() + ..add(ImapResponseLine('A001 OK FLAGS ({10}')) + ..add(ImapResponseLine.raw(Uint8List.fromList('1"2 3 \r\n90'.codeUnits))) + ..add(ImapResponseLine('seen)')); + final parsed = response.iterate(); + + //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].valueOrDataText, '1"2 3 \r\n90'); + expect(parsed.values[2].children?[1].value, 'seen'); + }); + + test( + 'ImapResponse.iterate() with simple response and double parentheses [1]', + () { + const input = 'A001 OK FLAGS (("seen" "new flag"))'; + final response = ImapResponse(); + final line = ImapResponseLine(input); + response.add(line); + final parsed = response.iterate(); + + //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?[0].children?.length, 2); + expect(parsed.values[2].children?[0].children?[0].value, 'seen'); + expect(parsed.values[2].children?[0].children?[1].value, 'new flag'); + }, + ); + test( + 'ImapResponse.iterate() with simple response and double parentheses [2]', + () { + const input = 'A001 OK FLAGS ((seen new))'; + final response = ImapResponse(); + final line = ImapResponseLine(input); + response.add(line); + final parsed = response.iterate(); + + //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, 1); + expect(parsed.values[2].children?[0].children?.length, 2); + expect(parsed.values[2].children?[0].children?[0].value, 'seen'); + expect(parsed.values[2].children?[0].children?[1].value, 'new'); + }, + ); // test end + + test( + 'ImapResponse.iterate() with simple response and emtpty ' + 'Flags parentheses', + () { + const input = 'A001 OK FLAGS () INTERNALDATE'; + final response = ImapResponse(); + final line = ImapResponseLine(input); + response.add(line); + final parsed = response.iterate(); + + //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'); + }, + ); // test end + + test('ImapResponse.iterate() with complex real world response', () { + final response = ImapResponse() + ..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.raw(Uint8List.fromList( + 'New appointment: SoW (x2) for rebranding of App & Mobile Apps' + .codeUnits, + ))) + ..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@produ' + 'ct-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"))', + )); + final parsed = response.iterate(); + + //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?[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?[0].value, 'Fri, 25 Oct 2019 16:35:28 +0200 (CEST)'); + expect( + values?[1].valueOrDataText, + '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, 1); + expect(values?[2].children?[0].children?.length, 4); + expect( + values?[2].children?[0].children?[0].value, + '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=', + ); + expect(values?[2].children?[0].children?[1].value, 'NIL'); + expect(values?[2].children?[0].children?[2].value, 'rob.schoen'); + expect(values?[2].children?[0].children?[3].value, 'domain.com'); + + expect(values?[3].value, null); + expect(values?[3].children != null, true); + expect(values?[3].children?.length, 1); + expect(values?[3].children?[0].children?.length, 4); + expect( + values?[3].children?[0].children?[0].value, + '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=', + ); + expect(values?[3].children?[0].children?[1].value, 'NIL'); + expect(values?[3].children?[0].children?[2].value, 'rob.schoen'); + expect(values?[3].children?[0].children?[3].value, 'domain.com'); + + expect(values?[4].value, null); + expect(values?[4].children != null, true); + expect(values?[4].children?.length, 1); + expect(values?[4].children?[0].children?.length, 4); + expect( + values?[4].children?[0].children?[0].value, + '=?UTF-8?Q?Sch=C3=B6n=2C_Rob?=', + ); + expect(values?[4].children?[0].children?[1].value, 'NIL'); + expect(values?[4].children?[0].children?[2].value, 'rob.schoen'); + expect(values?[4].children?[0].children?[3].value, 'domain.com'); + + expect(values?[5].value, null); + expect(values?[5].children != null, true); + expect(values?[5].children?[0].children?.length, 4); + expect(values?[5].children?[0].children?[0].value, 'Alice Dev'); + expect(values?[5].children?[0].children?[1].value, 'NIL'); + expect(values?[5].children?[0].children?[2].value, 'alice.dev'); + expect(values?[5].children?[0].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, 8); + expect(value?.children?[0].value, 'text'); + expect(value?.children?[1].value, 'plain'); + expect(value?.children?[2].children != null, true); + expect(value?.children?[2].children?[0].value, 'charset'); + expect(value?.children?[2].children?[1].value, 'UTF-8'); + expect(value?.children?[3].value, 'NIL'); + expect(value?.children?[4].value, 'NIL'); + expect(value?.children?[5].value, 'quoted-printable'); + expect(value?.children?[6].value, '1289'); + expect(value?.children?[7].value, '53'); + + value = values?[6].children?[1]; + expect(value?.value, null); + expect(value?.children != null, true); + expect(value?.children?.length, 8); + expect(value?.children?[0].value, 'text'); + expect(value?.children?[1].value, 'html'); + expect(value?.children?[2].children != null, true); + expect(value?.children?[2].children?[0].value, 'charset'); + expect(value?.children?[2].children?[1].value, 'UTF-8'); + expect(value?.children?[3].value, 'NIL'); + expect(value?.children?[4].value, 'NIL'); + expect(value?.children?[5].value, 'quoted-printable'); + expect(value?.children?[6].value, '7496'); + expect(value?.children?[7].value, '302'); + + expect(values?[6].children?[2].value, 'alternative'); + }); // test end + + test('ImapResponse.iterate() with HEADER.FIELDS response', () { + final response = ImapResponse() + ..add(ImapResponseLine('16 FETCH (BODY[HEADER.FIELDS (REFERENCES)] {50}')) + ..add(ImapResponseLine.raw(Uint8List.fromList( + r'References: '.codeUnits, + ))) + ..add(ImapResponseLine(')')); + final parsed = response.iterate(); + + //print(parsed.values); + expect(parsed.values.length, 2); + expect(parsed.values[0].value, '16'); + expect(parsed.values[1].value, 'FETCH'); + expect(parsed.values[1].children != null, true); + expect(parsed.values[1].children?.length, 2); + expect( + parsed.values[1].children?[0].value, + 'BODY[HEADER.FIELDS (REFERENCES)]', + ); + expect( + parsed.values[1].children?[1].valueOrDataText, + r'References: ', + ); + }); // test end + + test('ImapResponse.iterate() with HEADER.FIELDS empty response', () { + final response = ImapResponse() + ..add(ImapResponseLine('16 FETCH (BODY[HEADER.FIELDS (REFERENCES)] {2}')) + ..add(ImapResponseLine.raw(Uint8List.fromList('\r\n'.codeUnits))) + ..add(ImapResponseLine(')')); + final parsed = response.iterate(); + + //print(parsed.values); + expect(parsed.values.length, 2); + expect(parsed.values[0].value, '16'); + expect(parsed.values[1].value, 'FETCH'); + expect(parsed.values[1].children != null, true); + expect(parsed.values[1].children?.length, 2); + expect( + parsed.values[1].children?[0].value, + 'BODY[HEADER.FIELDS (REFERENCES)]', + ); + expect(parsed.values[1].children?[1].valueOrDataText, '\r\n'); + }); // test end + + test('ImapResponse.iterate() with HEADER.FIELDS.NOT response', () { + final response = ImapResponse() + ..add(ImapResponseLine( + '16 FETCH (BODY[HEADER.FIELDS.NOT (REFERENCES)] {42}', + )) + ..add(ImapResponseLine.raw(Uint8List.fromList( + 'From: Shirley '.codeUnits, + ))) + ..add(ImapResponseLine(')')); + final parsed = response.iterate(); + + //print(parsed.values); + expect(parsed.values.length, 2); + expect(parsed.values[0].value, '16'); + expect(parsed.values[1].value, 'FETCH'); + expect(parsed.values[1].children != null, true); + expect(parsed.values[1].children?.length, 2); + expect( + parsed.values[1].children?[0].value, + 'BODY[HEADER.FIELDS.NOT (REFERENCES)]', + ); + expect( + parsed.values[1].children?[1].valueOrDataText, + 'From: Shirley ', + ); + }); // test end + + test('ImapResponse.iterate() with HEADER.FIELDS.NOT empty response', () { + final response = ImapResponse() + ..add(ImapResponseLine( + '16 FETCH (BODY[HEADER.FIELDS.NOT (REFERENCES DATE FROM)] {2}', + )) + ..add(ImapResponseLine.raw(Uint8List.fromList('\r\n'.codeUnits))) + ..add(ImapResponseLine(')')); + final parsed = response.iterate(); + + //print(parsed.values); + expect(parsed.values.length, 2); + expect(parsed.values[0].value, '16'); + expect(parsed.values[1].value, 'FETCH'); + expect(parsed.values[1].children != null, true); + expect(parsed.values[1].children?.length, 2); + expect( + parsed.values[1].children?[0].value, + 'BODY[HEADER.FIELDS.NOT (REFERENCES DATE FROM)]', + ); + expect(parsed.values[1].children?[1].valueOrDataText, '\r\n'); + }); // test end + + test('ImapResponse.iterate() with TO Envelope part', () { + final response = ImapResponse() + ..add(ImapResponseLine( + 'ENVELOPE ("TEST" (("Jared" NIL "jared" "domain.com")) (("Ina" NIL ' + '"ina" "domain1.com")("Todd" NIL "todd" "domain2.com")("Dom" NIL ' + '"dom"' + ' "domain3.com")) NIL NIL NIL "<1526109049.228971.1564473376037@my.d' + 'omain.com>")', + )); + final parsed = response.iterate(); + + //print(parsed.values); + expect(parsed.values.length, 1); + expect(parsed.values[0].value, 'ENVELOPE'); + expect(parsed.values[0].children != null, true); + expect(parsed.values[0].children?.length, 7); + expect(parsed.values[0].children?[0].value, 'TEST'); + expect(parsed.values[0].children?[1].value, null); + expect(parsed.values[0].children?[1].children != null, true); + expect(parsed.values[0].children?[1].children?.length, 1); + expect(parsed.values[0].children?[1].children?[0].children != null, true); + expect(parsed.values[0].children?[1].children?[0].children?.length, 4); + expect( + parsed.values[0].children?[1].children?[0].children?[0].value, + 'Jared', + ); + expect( + parsed.values[0].children?[1].children?[0].children?[1].value, + 'NIL', + ); + expect( + parsed.values[0].children?[1].children?[0].children?[2].value, + 'jared', + ); + expect( + parsed.values[0].children?[1].children?[0].children?[3].value, + 'domain.com', + ); + expect(parsed.values[0].children?[2].value, null); + expect(parsed.values[0].children?[2].children != null, true); + expect(parsed.values[0].children?[2].children?.length, 3); + expect(parsed.values[0].children?[2].children?[0].children != null, true); + expect(parsed.values[0].children?[2].children?[0].children?.length, 4); + expect( + parsed.values[0].children?[2].children?[0].children?[0].value, + 'Ina', + ); + expect( + parsed.values[0].children?[2].children?[0].children?[1].value, + 'NIL', + ); + expect( + parsed.values[0].children?[2].children?[0].children?[2].value, + 'ina', + ); + expect( + parsed.values[0].children?[2].children?[0].children?[3].value, + 'domain1.com', + ); + expect(parsed.values[0].children?[2].children?[1].children != null, true); + expect(parsed.values[0].children?[2].children?[1].children?.length, 4); + expect( + parsed.values[0].children?[2].children?[1].children?[0].value, + 'Todd', + ); + expect( + parsed.values[0].children?[2].children?[1].children?[1].value, + 'NIL', + ); + expect( + parsed.values[0].children?[2].children?[1].children?[2].value, + 'todd', + ); + expect( + parsed.values[0].children?[2].children?[1].children?[3].value, + 'domain2.com', + ); + }); // test end + + test('ImapResponse.iterate() with nested BODY part', () { + final response = ImapResponse() + ..add(ImapResponseLine( + 'BODY (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 23)' + '("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff") "<9607231634' + '07.20117h@cac.washington.edu>" "Compiler diff" "BASE64" 4554 73) "MI' + 'XED")', + )); + final parsed = response.iterate(); + + expect(parsed.values.length, 1); + expect(parsed.values[0].children?.length, 3); + expect(parsed.values[0].children?[0].children?.length, 8); + expect(parsed.values[0].children?[0].children?[0].value, 'TEXT'); + expect(parsed.values[0].children?[0].children?[1].value, 'PLAIN'); + expect(parsed.values[0].children?[0].children?[2].children?.length, 2); + expect( + parsed.values[0].children?[0].children?[2].children?[0].value, + 'CHARSET', + ); + expect( + parsed.values[0].children?[0].children?[2].children?[1].value, + 'US-ASCII', + ); + expect(parsed.values[0].children?[0].children?[3].value, 'NIL'); + expect(parsed.values[0].children?[0].children?[4].value, 'NIL'); + expect(parsed.values[0].children?[0].children?[5].value, '7BIT'); + expect(parsed.values[0].children?[0].children?[6].value, '1152'); + expect(parsed.values[0].children?[0].children?[7].value, '23'); + expect(parsed.values[0].children?[1].children?.length, 8); + expect(parsed.values[0].children?[1].children?[0].value, 'TEXT'); + expect(parsed.values[0].children?[1].children?[1].value, 'PLAIN'); + expect(parsed.values[0].children?[1].children?[2].children?.length, 4); + expect( + parsed.values[0].children?[1].children?[2].children?[0].value, + 'CHARSET', + ); + expect( + parsed.values[0].children?[1].children?[2].children?[1].value, + 'US-ASCII', + ); + expect( + parsed.values[0].children?[1].children?[2].children?[2].value, + 'NAME', + ); + expect( + parsed.values[0].children?[1].children?[2].children?[3].value, + 'cc.diff', + ); + expect( + parsed.values[0].children?[1].children?[3].value, + '<960723163407.20117h@cac.washington.edu>', + ); + expect(parsed.values[0].children?[1].children?[4].value, 'Compiler diff'); + expect(parsed.values[0].children?[1].children?[5].value, 'BASE64'); + expect(parsed.values[0].children?[1].children?[6].value, '4554'); + expect(parsed.values[0].children?[1].children?[7].value, '73'); + + expect(parsed.values[0].children?[2].value, 'MIXED'); + }); +} diff --git a/packages/enough_mail/test/src/imap/list_parser_test.dart b/packages/enough_mail/test/src/imap/list_parser_test.dart new file mode 100644 index 0000000..7053dc4 --- /dev/null +++ b/packages/enough_mail/test/src/imap/list_parser_test.dart @@ -0,0 +1,154 @@ +import 'package:enough_mail/src/imap/imap_client.dart'; +import 'package:enough_mail/src/imap/mailbox.dart'; +import 'package:enough_mail/src/imap/response.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:enough_mail/src/private/imap/list_parser.dart'; +import 'package:enough_mail/src/private/util/client_base.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + final serverInfo = + ImapServerInfo(const ConnectionInfo('localhost', 993, isSecure: true)); + + Response> _parseListResponse(ListParser parser, sourceData) { + final response = Response>()..status = ResponseStatus.ok; + sourceData.forEach((details) => parser.parseUntagged(details, response)); + + return response; + } + + test('List all mailboxes', () { + final lines = [ + 'LIST (\\Marked \\NoInferiors) "/" "inbox"', + 'LIST () "/" "Fruit"', + 'LIST () "/" "Fruit/Apple"', + 'LIST () "/" "Fruit/Banana"', + 'LIST () "/" "Tofu"', + 'LIST () "/" "Vegetable"', + 'LIST () "/" "Vegetable/Broccoli"', + 'LIST () "/" "Vegetable/Corn"', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = ListParser(serverInfo); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 8); + expect(mboxes?[0].isInbox, true); + expect(mboxes?[0].hasFlag(MailboxFlag.marked), true); + expect(mboxes?[0].hasFlag(MailboxFlag.noInferior), true); + expect(mboxes?[4].path, 'Tofu'); + expect(mboxes?[6].path, 'Vegetable/Broccoli'); + expect(serverInfo.pathSeparator, '/'); + expect(parser.info.pathSeparator, '/'); + }); + + test('List extended: SUBSCRIBED response', () { + final lines = [ + r'LIST (\Marked \NoInferiors \Subscribed) "/" "inbox"', + r'LIST (\Subscribed) "/" "Fruit/Banana"', + r'LIST (\Subscribed \NonExistent) "/" "Fruit/Peach"', + r'LIST (\Subscribed) "/" "Vegetable"', + r'LIST (\Subscribed) "/" "Vegetable/Broccoli"', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = ListParser(serverInfo, isExtended: true); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 5); + expect(mboxes?[0].hasFlag(MailboxFlag.subscribed), true); + expect(mboxes?[2].hasFlag(MailboxFlag.nonExistent), true); + expect(mboxes?[4].path, 'Vegetable/Broccoli'); + }); + + test('List extended: return CHILDREN response', () { + final lines = [ + r'LIST (\Marked \NoInferiors) "/" "inbox"', + r'LIST (\HasChildren) "/" "Fruit"', + r'LIST (\HasNoChildren) "/" "Tofu"', + r'LIST (\HasChildren) "/" "Vegetable"', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = + ListParser(serverInfo, isExtended: true, hasReturnOptions: true); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 4); + expect(mboxes?[0].hasFlag(MailboxFlag.noInferior), true); + expect(mboxes?[2].hasFlag(MailboxFlag.hasNoChildren), true); + }); + + test('List extended: REMOTE, return CHILDREN response', () { + final lines = [ + r'LIST (\Marked \NoInferiors) "/" "inbox"', + r'LIST (\HasChildren) "/" "Fruit"', + r'LIST (\HasNoChildren) "/" "Tofu"', + r'LIST (\HasChildren) "/" "Vegetable"', + r'LIST (\Remote) "/" "Bread"', + r'LIST (\HasChildren \Remote) "/" "Meat"', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = + ListParser(serverInfo, isExtended: true, hasReturnOptions: true); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 6); + expect(mboxes?[4].hasFlag(MailboxFlag.remote), true); + expect(mboxes?[5].hasFlag(MailboxFlag.remote), true); + expect(mboxes?[5].hasFlag(MailboxFlag.hasChildren), true); + }); + + test('List extended: SUBSCRIBED RECURSIVEMATCH response', () { + final lines = [ + r'LIST () "/" "Foo" ("CHILDINFO" ("SUBSCRIBED"))', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = ListParser(serverInfo, isExtended: true); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 1); + expect(mboxes?[0].name, 'Foo'); + expect(mboxes?[0].extendedData, contains('CHILDINFO')); + expect(mboxes?[0].extendedData['CHILDINFO'], contains('SUBSCRIBED')); + }); + + test('List with return STATUS response', () { + final lines = [ + r'LIST () "." "INBOX"', + r'STATUS "INBOX" (MESSAGES 17 UNSEEN 16)', + r'LIST () "." "foo"', + r'STATUS "foo" (MESSAGES 30 UNSEEN 29)', + r'LIST (\NoSelect) "." "bar"', + ]; + final details = []; + for (final raw in lines) { + details.add(ImapResponse()..add(ImapResponseLine(raw))); + } + final parser = + ListParser(serverInfo, isExtended: true, hasReturnOptions: true); + final response = _parseListResponse(parser, details); + final mboxes = parser.parse(null, response); + expect(mboxes?.length, 3); + expect(mboxes?[0].messagesExists, 17); + expect(mboxes?[0].messagesUnseen, 16); + expect(mboxes?[1].messagesExists, 30); + expect(mboxes?[1].messagesUnseen, 29); + expect(mboxes?[2].flags, contains(MailboxFlag.noSelect)); + }); +} diff --git a/packages/enough_mail/test/src/imap/parser_helper_test.dart b/packages/enough_mail/test/src/imap/parser_helper_test.dart new file mode 100644 index 0000000..f31a504 --- /dev/null +++ b/packages/enough_mail/test/src/imap/parser_helper_test.dart @@ -0,0 +1,147 @@ +import 'package:enough_mail/src/private/imap/parser_helper.dart'; +import 'package:test/test.dart'; + +// cSpell:disable +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', () { + const headerText = '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'; + final result = ParserHelper.parseHeader(headerText); + final headers = result.headersList; + expect(result, isNotNull); + 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', () { + const headerText = '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'; + final result = ParserHelper.parseHeader(headerText); + final headers = result.headersList; + 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( + headerText.substring(result.bodyStartIndex ?? 0), + 'Hello world.\r\n', + ); + }); + + test('ParserHelper.parseHeader without space between name and value', () { + const headerText = 'Content-type:text/html;charset=UTF-8'; + final result = ParserHelper.parseHeader(headerText); + expect(result.headersList, isNotEmpty); + expect(result.headersList.length, 1); + expect(result.headersList[0].lowerCaseName, 'content-type'); + expect(result.headersList[0].value, 'text/html;charset=UTF-8'); + }); + + test('ParserHelper.parseListEntries', () { + const input = 'OK [MODIFIED 7,9] Conditional STORE failed'; + final textEntries = ParserHelper.parseListEntries( + input, + input.indexOf('[MODIFIED ') + '[MODIFIED '.length, + ']', + ',', + ); + expect(textEntries, isNotNull); + expect(textEntries?.length, 2); + expect(textEntries?[0], '7'); + expect(textEntries?[1], '9'); + final intEntries = ParserHelper.parseListIntEntries( + input, + input.indexOf('[MODIFIED ') + '[MODIFIED '.length, + ']', + ',', + ); + expect(intEntries, isNotNull); + expect(intEntries?.length, 2); + expect(intEntries?[0], 7); + expect(intEntries?[1], 9); + }); + + test('parseListEntries', () { + const input = + '''("name" "Cyrus" "version" "1.5" "os" "sunos" "os-version" "5.5" "support-url" "mailto:cyrus-bugs+@andrew.cmu.edu" "date" "Sun, 15 Aug 2021 22:45 +0000")'''; + final textEntries = ParserHelper.parseListEntries(input, 1, ')', ' '); + expect(textEntries, isNotNull); + expect(textEntries, [ + '"name"', + '"Cyrus"', + '"version"', + '"1.5"', + '"os"', + '"sunos"', + '"os-version"', + '"5.5"', + '"support-url"', + '"mailto:cyrus-bugs+@andrew.cmu.edu"', + '"date"', + '"Sun, 15 Aug 2021 22:45 +0000"', + ]); + expect(textEntries?.length, 12); + }); +} diff --git a/packages/enough_mail/test/src/imap/search_parser_test.dart b/packages/enough_mail/test/src/imap/search_parser_test.dart new file mode 100644 index 0000000..1fa2d94 --- /dev/null +++ b/packages/enough_mail/test/src/imap/search_parser_test.dart @@ -0,0 +1,109 @@ +import 'package:enough_mail/src/imap/response.dart'; +import 'package:enough_mail/src/private/imap/all_parsers.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('Search simple', () { + const responseText = 'SEARCH 2 5 6 7 11 12 18 19 20 23'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final ids = parser.parse(details, response)?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isNotEmpty); + expect(ids, [2, 5, 6, 7, 11, 12, 18, 19, 20, 23]); + }); + + test('Search empty', () { + const responseText = 'SEARCH'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final ids = parser.parse(details, response)?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isEmpty); + }); + + test('Search with mod sequence', () { + const responseText = 'SEARCH 2 5 6 7 11 12 18 19 20 23 (MODSEQ 917162500)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = result?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isNotEmpty); + expect(ids, [2, 5, 6, 7, 11, 12, 18, 19, 20, 23]); + expect(result?.highestModSequence, 917162500); + }); + + test('Extended search with MIN, MAX, COUNT', () { + const responseText = 'ESEARCH (TAG "C1") MIN 2 MAX 47 COUNT 25'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + expect(result?.isExtended, true); + expect(result?.min, 2); + expect(result?.max, 47); + expect(result?.count, 25); + expect(result?.tag, 'C1'); + }); + + test('Extended search with COUNT, ALL', () { + const responseText = 'ESEARCH (TAG "C2") COUNT 25 ALL 2,4,10:18,24,25,26'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = result?.matchingSequence?.toList(); + expect(result?.isExtended, true); + expect(result?.count, 25); + expect(result?.tag, 'C2'); + expect(ids, [2, 4, 10, 11, 12, 13, 14, 15, 16, 17, 18, 24, 25, 26]); + }); + + test('Extended search with MIN, MAX, MODSEQ', () { + const responseText = 'ESEARCH (TAG "C3") MIN 1 MAX 18 MODSEQ 123456'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + result?.matchingSequence?.toList(); + expect(result?.isExtended, true); + expect(result?.min, 1); + expect(result?.max, 18); + expect(result?.tag, 'C3'); + expect(result?.highestModSequence, 123456); + }); + + test('Extended search with PARTIAL', () { + const responseText = 'ESEARCH (TAG "C4") PARTIAL (1:10 3,5,7,9)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SearchParser(isUidSearch: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = result?.matchingSequence?.toList(); + expect(result?.isExtended, true); + expect(result?.tag, 'C4'); + expect(result?.partialRange, '1:10'); + expect(ids, [3, 5, 7, 9]); + }); +} diff --git a/packages/enough_mail/test/src/imap/sort_parser_test.dart b/packages/enough_mail/test/src/imap/sort_parser_test.dart new file mode 100644 index 0000000..60f92e9 --- /dev/null +++ b/packages/enough_mail/test/src/imap/sort_parser_test.dart @@ -0,0 +1,111 @@ +import 'package:enough_mail/src/imap/response.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:enough_mail/src/private/imap/sort_parser.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('Sort simple', () { + const responseText = 'SORT 7 5 8 18 19 20 34 33 32 30 10'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final ids = parser.parse(details, response)?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isNotEmpty); + expect(ids, [7, 5, 8, 18, 19, 20, 34, 33, 32, 30, 10]); + }); + + test('Sort empty', () { + const responseText = 'SORT'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final ids = parser.parse(details, response)?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isEmpty); + }); + + test('Sort with mod sequence', () { + const responseText = + 'SORT 7 5 8 18 19 20 34 33 32 30 10 (MODSEQ 917162500)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = parser.parse(details, response)?.matchingSequence?.toList(); + expect(ids, isNotNull); + expect(ids, isNotEmpty); + expect(ids, [7, 5, 8, 18, 19, 20, 34, 33, 32, 30, 10]); + expect(result?.highestModSequence, 917162500); + }); + + test('Extended sort with MIN, MAX, COUNT', () { + const responseText = 'ESEARCH (TAG "C1") MIN 2 MAX 47 COUNT 25'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + expect(result?.isExtended, true); + expect(result?.tag, 'C1'); + expect(result?.min, 2); + expect(result?.max, 47); + expect(result?.count, 25); + }); + + test('Extended sort with COUNT, ALL', () { + const responseText = + 'ESEARCH (TAG "C2") ALL 7,5,8,18:20,34,33,32,30,10 COUNT 11'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = result?.matchingSequence?.toList(); + expect(result?.isExtended, true); + expect(result?.tag, 'C2'); + expect(result?.count, 11); + expect(ids?.length, result?.count); + expect(ids, [7, 5, 8, 18, 19, 20, 34, 33, 32, 30, 10]); + }); + + test('Extended sort with MIN, MAX, MODSEQ', () { + const responseText = 'ESEARCH (TAG "C3") MIN 2 MAX 47 MODSEQ 123456'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + expect(result?.isExtended, true); + expect(result?.tag, 'C3'); + expect(result?.min, 2); + expect(result?.max, 47); + expect(result?.highestModSequence, 123456); + }); + + test('Extended sort with PARTIAL', () { + const responseText = 'ESEARCH (TAG "C4") PARTIAL (1:10 3,9,7,5)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = SortParser(isUidSort: false, isExtended: true); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + final result = parser.parse(details, response); + final ids = result?.matchingSequence?.toList(); + expect(result?.isExtended, true); + expect(result?.tag, 'C4'); + expect(result?.partialRange, '1:10'); + expect(ids, [3, 9, 7, 5]); + }); +} diff --git a/packages/enough_mail/test/src/imap/status_parser_test.dart b/packages/enough_mail/test/src/imap/status_parser_test.dart new file mode 100644 index 0000000..6244118 --- /dev/null +++ b/packages/enough_mail/test/src/imap/status_parser_test.dart @@ -0,0 +1,61 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/imap/all_parsers.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('Status with unseen', () { + const responseText = 'STATUS "[Gmail]/Spam" (UNSEEN 13)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final box = Mailbox( + encodedName: 'Spam', + encodedPath: '[Gmail]/Spam', + flags: [MailboxFlag.junk], + pathSeparator: '/', + ); + final parser = StatusParser(box); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(box.messagesUnseen, 13); + }); + + test('Status with unseen, messages, uidnext, uidvalidity', () { + const responseText = + 'STATUS "[Gmail]/Spam" (MESSAGES 123 UNSEEN 13 UIDVALIDITY 2222 UIDNEXT 876)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final box = Mailbox( + encodedName: 'Spam', + encodedPath: '[Gmail]/Spam', + flags: [MailboxFlag.junk], + pathSeparator: '/', + ); + final parser = StatusParser(box); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(box.messagesUnseen, 13); + expect(box.messagesExists, 123); + expect(box.uidValidity, 2222); + expect(box.uidNext, 876); + }); + + test('Status of Mailbox with name containing brackets', () { + const responseText = + 'STATUS "upper level.Funny folder (with brackets)" (MESSAGES 2)'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final box = Mailbox( + encodedName: 'Funny folder (with brackets)', + encodedPath: 'upper level.Funny folder (with brackets)', + flags: [MailboxFlag.junk], + pathSeparator: '.', + ); + final parser = StatusParser(box); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(box.messagesExists, 2); + }); +} diff --git a/packages/enough_mail/test/src/imap/thread_parser_test.dart b/packages/enough_mail/test/src/imap/thread_parser_test.dart new file mode 100644 index 0000000..293fe64 --- /dev/null +++ b/packages/enough_mail/test/src/imap/thread_parser_test.dart @@ -0,0 +1,128 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/imap/all_parsers.dart'; +import 'package:enough_mail/src/private/imap/imap_response.dart'; +import 'package:enough_mail/src/private/imap/imap_response_line.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + test('Thread nested', () { + const responseText = 'THREAD (2)(3 6 (4 23)(44 7 96))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = ThreadParser(isUidSequence: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(parser.result, isNotNull); + //print(parser.result); + expect(parser.result.isNotEmpty, isTrue); + expect(parser.result.length, 2); + expect(parser.result[0].hasId, false); + expect(parser.result[0].length, 1); + expect(parser.result[0][0].id, 2); + expect(parser.result[1].hasId, false); + expect(parser.result[1].length, 4); + expect(parser.result[1][0].id, 3); + expect(parser.result[1][1].id, 6); + expect(parser.result[1][2].hasId, false); + expect(parser.result[1][2].length, 2); + expect(parser.result[1][2][0].id, 4); + expect(parser.result[1][2][1].id, 23); + expect(parser.result[1][3].hasId, false); + expect(parser.result[1][3].length, 3); + expect(parser.result[1][3][0].id, 44); + expect(parser.result[1][3][1].id, 7); + expect(parser.result[1][3][2].id, 96); + final flattened = parser.result.flatten(); + //print('flattened: $flattened'); + expect(flattened, isNotNull); + expect(flattened.length, 2); + expect(flattened[0].length, 1); + expect(flattened[0][0].id, 2); + expect(flattened[1].length, 7); + expect(flattened[1][0].id, 3); + expect(flattened[1][6].id, 96); + + final sequence1 = parser.result.toMessageSequence(); + final sequence2 = flattened.toMessageSequence(); + expect(sequence1, isNotNull); + expect(sequence1.isNotEmpty, isTrue); + expect(sequence1.toList(), [2, 3, 6, 4, 23, 44, 7, 96]); + expect(sequence2.toList(), sequence1.toList()); + + expect( + parser.result + .toMessageSequence(mode: SequenceNodeSelectionMode.lastLeaf) + .toList(), + [2, 96], + ); + expect( + flattened + .toMessageSequence(mode: SequenceNodeSelectionMode.lastLeaf) + .toList(), + [2, 96], + ); + expect( + parser.result + .toMessageSequence(mode: SequenceNodeSelectionMode.firstLeaf) + .toList(), + [2, 3], + ); + expect( + flattened + .toMessageSequence(mode: SequenceNodeSelectionMode.firstLeaf) + .toList(), + [2, 3], + ); + }); + + test('simple real world', () { + const responseText = 'THREAD (62916)(62917 (63138)(63373))'; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = ThreadParser(isUidSequence: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(parser.result, isNotNull); + expect(parser.result.isNotEmpty, isTrue); + expect(parser.result.length, 2); + // print(parser.result); + expect(parser.result[0][0].id, 62916); + expect(parser.result[1][0].id, 62917); + // print(parser.result[1]); + expect(parser.result[1].length, 3); + expect(parser.result[1][1][0].id, 63138); + expect(parser.result[1][2][0].id, 63373); + + final flattened = parser.result.flatten(); + expect(flattened, isNotNull); + expect(flattened.isNotEmpty, isTrue); + expect(flattened.length, 2); + expect(flattened[0].length, 1); + expect(flattened[0][0].id, 62916); + expect(flattened[1].length, 3); + expect(flattened[1][0].id, 62917); + expect(flattened[1][1].id, 63138); + expect(flattened[1][2].id, 63373); + }); + test('full real world', () { + const responseText = + '''THREAD (62916)(62917 (63138)(63373))(62918)(62919)(62920)(62921)(62922 62923)(62924 62925)(62926 62990)(62927 (62935)(62938)(62941)(62942)(62943)(62945)(62963)(62973)(62974)(63090))(62928 62937)(62929)(62930)(62931)(62932)(62933 62934)(62936)(62939)(62940)(62944 (62946)(62948)(62951)(62954))(62947)(62949)(62950)(62952)(62953 (63139)(63330))(62955)(62956)(62957)(62958)(62959)(62960)(62961)(62962)(62964 62965)(62966 62967)(62968)(62969 62972)(62970 62983)(62971)(62975)(62976 63132)(62977)(62978)(62979)(62980)(62981)(62982)(62985)(62984)(62986)(62987)(62988)(62989)(62991)(62992)(62993 (63222)(63432))(62994)(62995)(62996 62997)(62998)(62999)(63000 63001)(63002)(63003)(63004 63024)(63005)(63006)(63007)(63008)(63009)(63010)(63011)(63012)(63013)(63014 63121)(63015)(63016)(63017)(63018)(63019)(63020)(63021)(63022)(63023)(63025)(63026)(63027)(63028)(63029)(63030 (63031)(63033)(63172))(63032)(63034)(63035)(63036)(63037)(63038)(63039)(63040)(63041 63042)(63043 (63095)(63137)(63207)(63276)(63318)(63372)(63420)(63471)(63536))(63044)(63045)(63046)(63047)(63048)(63049 (63052)(63056))(63050)(63051)(63053)(63054)(63055)(63057 (63059)(63063)(63067)(63068)(63091)(63186))(63058 63272)(63060)(63061)(63062 63064)(63065)(63066)(63069 63283)(63070)(63071)(63072 63079)(63073)(63074)(63075)(63076)(63077)(63078)(63080)(63081)(63082)(63083 (63086)(63093)(63094)(63097))(63084)(63085)(63087)(63088)(63089)(63092)(63096)(63098)(63099 63100)(63101)(63102)(63103)(63104)(63105)(63106)(63107 (63109)(63111)(63113))(63108)(63110)(63112)(63114 (63120)(63295))(63115)(63116)(63117 63118)(63119)(63122 (63123)(63124))(63125)(63126)(63127 (63208)(63211)(63290)(63428)(63430))(63128)(63129)(63130)(63131)(63133)(63134)(63135)(63136 (63163)(63557)(63574))(63140 (63141)(63149)(63150))(63142)(63143)(63144 (63308)(63398))(63145)(63146)(63147)(63148 (63152)(63154)(63155))(63151)(63153)(63156)(63157)(63158)(63159)(63160)(63161)(63162)(63164)(63165)(63166 (63167)(63168)(63177)(63178)(63245)(63252))(63169)(63170)(63171 (63173)(63176)(63189)(63190)(63192)(63195)(63248))(63174 63175)(63179)(63180)(63181)(63182)(63183)(63184 (63187)(63188)(63194)(63219)(63236)(63240)(63241)(63275))(63185)(63191 63209)(63193)(63196)(63197)(63198)(63199)(63200)(63201)(63202)(63203)(63204)(63205)(63206)(63210)(63212)(63213)(63214 63377)(63215)(63216)(63217)(63218)(63220)(63221)(63223 (63232)(63233))(63224)(63225)(63226)(63227)(63228)(63229)(63230)(63231)(63234)(63235)(63237)(63238 (63239)(63242)(63259))(63243)(63244)(63246 63247)(63249)(63250)(63251)(63253)(63254)(63255)(63256 63262)(63257)(63258)(63260)(63261 (63263)(63268)(63269)(63270)(63291)(63298))(63264)(63265)(63266)(63267)(63271)(63273 (63274)(63285)(63286)(63287)(63288))(63277)(63278)(63279)(63280)(63281)(63282)(63284)(63289)(63292)(63293)(63294)(63296)(63297 63390)(63299)(63300)(63302)(63301)(63303)(63304)(63305)(63306)(63307)(63309)(63310)(63311)(63312)(63313)(63314)(63315)(63316)(63317)(63319 (63320)(63321)(63326))(63322)(63323 63324)(63325 63335)(63327)(63328)(63329)(63331)(63332)(63333)(63334)(63336 (63361)(63371))(63337)(63338)(63339)(63340)(63341 (63343)(63344)(63345)(63347)(63348)(63350)(63374))(63342)(63346)(63355)(63349)(63351)(63352)(63353)(63354)(63356)(63357)(63362 (63383)(63499))(63358)(63359)(63360)(63363)(63364)(63365)(63366 (63368)(63369))(63367)(63370)(63375)(63376)(63378 (63382)(63387)(63389)(63395))(63379)(63380)(63381)(63384 (63385)(63393)(63429)(63451))(63386)(63388)(63391)(63392)(63394)(63396)(63397)(63399 63400)(63401)(63402)(63403)(63404)(63405)(63406)(63407 (63408)(63416)(63418)(63435))(63409 63414)(63410 (63411)(63412))(63413)(63415)(63417)(63419)(63421)(63422)(63423)(63424)(63425)(63426)(63427)(63431)(63433)(63434)(63436)(63437)(63438)(63439)(63440 63441)(63442)(63443)(63444 63447)(63445)(63446)(63448)(63449)(63450)(63452)(63453 (63491)(63515)(63518)(63523))(63454)(63455)(63456 (63457)(63458)(63475)(63479)(63480)(63481)(63497))(63459)(63460)(63461)(63462)(63463 (63464)(63465))(63466)(63467)(63468 (63469)(63472))(63470)(63473)(63474)(63476)(63477)(63478)(63482)(63483)(63484 63485)(63486)(63487)(63488)(63489 63498)(63490)(63492)(63493)(63494)(63495)(63496)(63500)(63501)(63502)(63503)(63504)(63505)(63506)(63507)(63508)(63509)(63510)(63511)(63512)(63513)(63514)(63516)(63517)(63519 (63520)(63521)(63522))(63524)(63525 (63526)(63527))(63528)(63529)(63530)(63531)(63532 63533)(63534)(63535)(63537)(63543)(63542)(63539)(63538 63540)(63541)(63544 63556)(63545 (63546)(63550))(63547)(63548 63578)(63549 (63554)(63555))(63551)(63552 63581)(63553)(63558 (63559)(63560))(63561 63562)(63563)(63564)(63565)(63566)(63567)(63568 (63569)(63570)(63571)(63572)(63575))(63573)(63576)(63577)(63579)(63580)(63582)(63583)'''; + final details = ImapResponse()..add(ImapResponseLine(responseText)); + final parser = ThreadParser(isUidSequence: false); + final response = Response()..status = ResponseStatus.ok; + final processed = parser.parseUntagged(details, response); + expect(processed, true); + expect(parser.result, isNotNull); + expect(parser.result.isNotEmpty, isTrue); + expect(parser.result[0][0].id, 62916); + expect(parser.result[1][0].id, 62917); + + expect(parser.result[parser.result.length - 1][0].id, 63583); + final flattened = parser.result.flatten(); + expect(flattened, isNotNull); + expect(flattened.isNotEmpty, isTrue); + expect(flattened[0][0].id, 62916); + expect(flattened[flattened.length - 1][0].id, 63583); + }); +} diff --git a/packages/enough_mail/test/src/smtp/commands/smtp_auth_cram_md5_command_test.dart b/packages/enough_mail/test/src/smtp/commands/smtp_auth_cram_md5_command_test.dart new file mode 100644 index 0000000..627b88e --- /dev/null +++ b/packages/enough_mail/test/src/smtp/commands/smtp_auth_cram_md5_command_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail/src/private/smtp/commands/all_commands.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('CRAM MD5 Tests', () { + test('Stackoverflow 1', () { + // source: https://stackoverflow.com/questions/186827/smtp-with-cram-md5-in-java + final cramAuth = SmtpAuthCramMd5Command('user@example.com', 'password'); + expect(cramAuth.command, 'AUTH CRAM-MD5'); + final serverResponse = SmtpResponse( + ['334 PDQ1MDMuMTIyMzU1Nzg2MkBtYWlsMDEuZXhhbXBsZS5jb20+'], + ); + expect( + serverResponse.message, + 'PDQ1MDMuMTIyMzU1Nzg2MkBtYWlsMDEuZXhhbXBsZS5jb20+', + ); + expect( + cramAuth.nextCommand(serverResponse), + 'dXNlckBleGFtcGxlLmNvbSA4YjdjODA5YzQ0NTNjZTVhYTA5N' + '2VhNWM4OTlmNGY4Nw==', + ); + }); + + test('Stackoverflow 2', () { + // source: https://stackoverflow.com/questions/44785181/different-hashes-during-cram-md5-authentication + final cramAuth = SmtpAuthCramMd5Command('alice', 'wonderland'); + expect(cramAuth.command, 'AUTH CRAM-MD5'); + final serverResponse = SmtpResponse([ + '334 ${base64.encode(utf8.encode('<17893.1320679123@tesse' + 'ract.susam.in>'))}', + ]); + expect( + cramAuth.nextCommand(serverResponse), + 'YWxpY2UgNjRiMmE0M2MxZjZlZDY4MDZhOTgwOTE0ZTIzZTc1ZjA=', + ); + }); + + test('RFC2195 Example', () { + // source: https://tools.ietf.org/html/rfc2195 + final cramAuth = SmtpAuthCramMd5Command('tim', 'tanstaaftanstaaf'); + expect(cramAuth.command, 'AUTH CRAM-MD5'); + final serverResponse = SmtpResponse( + ['334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+'], + ); + expect( + serverResponse.message, + 'PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+', + ); + expect( + cramAuth.nextCommand(serverResponse), + 'dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw', + ); + }); + }); +} diff --git a/packages/enough_mail/test/src/util/discover_helper_test.dart b/packages/enough_mail/test/src/util/discover_helper_test.dart new file mode 100644 index 0000000..43ad937 --- /dev/null +++ b/packages/enough_mail/test/src/util/discover_helper_test.dart @@ -0,0 +1,479 @@ +import 'package:enough_mail/src/discover/client_config.dart'; +import 'package:enough_mail/src/private/util/discover_helper.dart'; +import 'package:test/test.dart'; +// cSpell:disable + +void main() { + group('Autoconfigure tests', () { + test('Autodiscover - parse 1&1 config', () async { + const definition = ''' + + + + online.de + onlinehome.de + sofortstart.de + sofort-start.de + sofortsurf.de + sofort-surf.de + go4more.de + + kundenserver?.de + schlund.de + 1&1 + 1&1 + + imap.1und1.de + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + imap.1und1.de + 143 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + pop.1und1.de + 995 + SSL + password-cleartext + %EMAILADDRESS% + + + pop.1und1.de + 110 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + smtp.1und1.de + 587 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + + + + + + %EMAILADDRESS% + + + + + + +'''; + final config = DiscoverHelper.parseClientConfig(definition); + expect(config?.version, '1.1'); + expect(config?.emailProviders?.length, 1); + final provider = config?.emailProviders?.first; + expect(provider?.id, '1und1.de'); + expect(provider?.domains?.length, 9); + expect(provider?.domains?[0], 'online.de'); + expect(provider?.domains?[1], 'onlinehome.de'); + expect(provider?.domains?[2], 'sofortstart.de'); + expect(provider?.domains?[3], 'sofort-start.de'); + expect(provider?.domains?[4], 'sofortsurf.de'); + expect(provider?.domains?[5], 'sofort-surf.de'); + expect(provider?.domains?[6], 'go4more.de'); + expect(provider?.domains?[7], 'kundenserver?.de'); + expect(provider?.domains?[8], 'schlund.de'); + expect(provider?.displayName, '1&1'); + expect(provider?.displayShortName, '1&1'); + expect(provider?.incomingServers?.length, 4); + + var server = provider?.incomingServers?[0]; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.1und1.de'); + expect(server?.port, 993); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = config?.preferredIncomingServer; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.1und1.de'); + expect(server?.port, 993); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = config?.preferredIncomingImapServer; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.1und1.de'); + expect(server?.port, 993); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = config?.preferredIncomingPopServer; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'pop.1und1.de'); + expect(server?.port, 995); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = config?.preferredOutgoingServer; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'smtp.1und1.de'); + expect(server?.port, 587); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = config?.preferredOutgoingSmtpServer; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'smtp.1und1.de'); + expect(server?.port, 587); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[1]; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.1und1.de'); + expect(server?.port, 143); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[2]; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'pop.1und1.de'); + expect(server?.port, 995); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[3]; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'pop.1und1.de'); + expect(server?.port, 110); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect(provider?.outgoingServers?.length, 1); + server = provider?.outgoingServers?[0]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'smtp.1und1.de'); + expect(server?.port, 587); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect( + provider?.documentationUrl, + 'http://hilfe-center.1und1.de/access/search/go.php?t=e698123', + ); + + //expect(awesome.isAwesome, isTrue); + }); + + test('Autodiscover - parse systemschmiede config', () async { + const definition = ''' + + + + %EMAILDOMAIN% + %EMAILDOMAIN% Mail + One.com + + imap.one.com + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + imap.one.com + 143 + plain + password-cleartext + %EMAILADDRESS% + + + pop.one.com + 995 + SSL + password-cleartext + %EMAILADDRESS% + + + pop.one.com + 110 + plain + password-cleartext + %EMAILADDRESS% + + + send.one.com + 587 + SSL + password-cleartext + %EMAILADDRESS% + + + send.one.com + 2525 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + send.one.com + 25 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + send.one.com + 2525 + plain + password-cleartext + %EMAILADDRESS% + + + send.one.com + 25 + plain + password-cleartext + %EMAILADDRESS% + + + Thunderbird settings Page + + + +'''; + final config = DiscoverHelper.parseClientConfig(definition); + expect(config?.version, '1.1'); + expect(config?.emailProviders?.length, 1); + final provider = config?.emailProviders?.first; + expect(provider?.id, 'exdomain'); + expect(provider?.domains?.length, 1); + expect(provider?.domains?[0], '%EMAILDOMAIN%'); + expect(provider?.displayName, '%EMAILDOMAIN% Mail'); + expect(provider?.displayShortName, 'One.com'); + expect(provider?.incomingServers?.length, 4); + + var server = provider?.incomingServers?[0]; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.one.com'); + expect(server?.port, 993); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[1]; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'imap.one.com'); + expect(server?.port, 143); + expect(server?.socketType, SocketType.plain); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[2]; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'pop.one.com'); + expect(server?.port, 995); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[3]; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'pop.one.com'); + expect(server?.port, 110); + expect(server?.socketType, SocketType.plain); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect(provider?.outgoingServers?.length, 5); + server = provider?.outgoingServers?[0]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'send.one.com'); + expect(server?.port, 587); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.outgoingServers?[1]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'send.one.com'); + expect(server?.port, 2525); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.outgoingServers?[2]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'send.one.com'); + expect(server?.port, 25); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.outgoingServers?[3]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'send.one.com'); + expect(server?.port, 2525); + expect(server?.socketType, SocketType.plain); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.outgoingServers?[4]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'send.one.com'); + expect(server?.port, 25); + expect(server?.socketType, SocketType.plain); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect( + provider?.documentationUrl, + 'https://www.one.com/en/support/guide/mail/setting-up-thunderbird', + ); + }); + + test('Autodiscover - parse freenet.de config', () async { + const definition = ''' + + +freenet.de +Freenet Mail +Freenet + +mx.freenet.de +993 +SSL +password-encrypted +%EMAILADDRESS% + + +mx.freenet.de +995 +SSL +password-cleartext +%EMAILADDRESS% + + +mx.freenet.de +587 +STARTTLS +password-encrypted +%EMAILADDRESS% + + +Allgemeine Beschreibung der Einstellungen +Generic settings page + + +TB 2.0 IMAP-Einstellungen +TB 2.0 IMAP settings + + + +'''; + final config = DiscoverHelper.parseClientConfig(definition); + expect(config?.version, '1.1'); + expect(config?.emailProviders?.length, 1); + final provider = config?.emailProviders?.first; + expect(provider?.id, 'freenet.de'); + expect(provider?.domains?.length, 1); + expect(provider?.domains?[0], 'freenet.de'); + expect(provider?.displayName, 'Freenet Mail'); + expect(provider?.displayShortName, 'Freenet'); + expect(provider?.incomingServers?.length, 2); + + var server = provider?.incomingServers?[0]; + expect(server?.type, ServerType.imap); + expect(server?.typeName, 'imap'); + expect(server?.hostname, 'mx.freenet.de'); + expect(server?.port, 993); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordEncrypted); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + server = provider?.incomingServers?[1]; + expect(server?.type, ServerType.pop); + expect(server?.typeName, 'pop'); + expect(server?.hostname, 'mx.freenet.de'); + expect(server?.port, 995); + expect(server?.socketType, SocketType.ssl); + expect(server?.authentication, Authentication.passwordClearText); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect(provider?.outgoingServers?.length, 1); + server = provider?.outgoingServers?[0]; + expect(server?.type, ServerType.smtp); + expect(server?.typeName, 'smtp'); + expect(server?.hostname, 'mx.freenet.de'); + expect(server?.port, 587); + expect(server?.socketType, SocketType.starttls); + expect(server?.authentication, Authentication.passwordEncrypted); + expect(server?.username, '%EMAILADDRESS%'); + expect(server?.usernameType, UsernameType.emailAddress); + + expect( + provider?.documentationUrl, + 'http://email-hilfe.freenet.de/documents/Beitrag/15916/einstellungen-serverdaten-fuer-alle-e-mail-programme', + ); + }); + }); +} diff --git a/packages/enough_mail/test/src/util/uint8_list_reader_test.dart b/packages/enough_mail/test/src/util/uint8_list_reader_test.dart new file mode 100644 index 0000000..a6265dd --- /dev/null +++ b/packages/enough_mail/test/src/util/uint8_list_reader_test.dart @@ -0,0 +1,182 @@ +import 'dart:typed_data'; + +import 'package:enough_mail/src/private/util/uint8_list_reader.dart'; +import 'package:test/test.dart'; + +String _toString(Uint8List? bytes) => String.fromCharCodes(bytes ?? []); +// cSpell:disable + +void main() { + test('Uint8ListReader.readLine() with simple input', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\n'); + expect(reader.findLineBreak(), reader.findLastLineBreak()); + expect(reader.readLine(), 'HELLO ()'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in one', () { + final reader = Uint8ListReader()..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', () { + final reader = Uint8ListReader() + ..addText('HELLO ()\r\n') + ..addText('HI\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 2+ lines', () { + final reader = Uint8ListReader() + ..addText('HELLO ()\r\n') + ..addText('HI\r\nOHMY'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + }); // test end + + test('Uint8ListReader.readLine() with 2 lines in 3 lines', () { + final reader = Uint8ListReader() + ..addText('HELLO ()\r\n') + ..addText('HI\r\n') + ..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', () { + final reader = Uint8ListReader() + ..addText('HELLO ()\r\n') + ..addText('HI\r\nYEAH') + ..addText('\r\n'); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'HI'); + expect(reader.readLine(), 'YEAH'); + }); // test end + + test('Uint8ListReader.readBytes() with 1 line [1]', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\n'); + expect(reader.findLineBreak(), reader.findLastLineBreak()); + expect(_toString(reader.readBytes(5)), 'HELLO'); + }); // test end + + test('Uint8ListReader.readBytes() with 1 line [2]', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\n'); + expect(reader.findLineBreak(), reader.findLastLineBreak()); + expect(_toString(reader.readBytes(10)), 'HELLO ()\r\n'); + }); // test end + + test('Uint8ListReader.readBytes() [3]', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\n'); + expect(reader.findLineBreak(), reader.findLastLineBreak()); + reader + ..addText('HI\r\nYEAH') + ..addText('\r\n'); + expect(_toString(reader.readBytes(12)), 'HELLO ()\r\nHI'); + }); // test end + + test('Uint8ListReader.readBytes() [4]', () { + final reader = Uint8ListReader() + ..addText('HELLO ()\r\n') + ..addText('HI\r\nYEAH') + ..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]', () { + final reader = Uint8ListReader()..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()', () { + final reader = Uint8ListReader()..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', () { + final reader = Uint8ListReader()..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', () { + final reader = Uint8ListReader()..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.readLine() with 2 lines with no break in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\nWORLD ()\r\n'); + expect(reader.findLineBreak(), 9); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'WORLD ()'); + }); + + test('Uint8ListReader.findLineBreak() with 2 lines and break in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()\r'); + expect(reader.hasLineBreak(), false); + expect(reader.readLine(), null); + reader.addText('\nWORLD ()\r\n'); + expect(reader.hasLineBreak(), true); + expect(reader.findLineBreak(), 9); + }); + + test('Uint8ListReader.readLine() with 2 lines and break in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()\r'); + expect(reader.hasLineBreak(), false); + expect(reader.readLine(), null); + reader.addText('\nWORLD ()\r\n'); + expect(reader.hasLineBreak(), true); + expect(reader.readLine(), 'HELLO ()'); + expect(reader.readLine(), 'WORLD ()'); + }); + + test('Uint8ListReader.readLines() with 2 lines and break in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\nWORLD ()\r\n'); + expect(reader.readLines(), ['HELLO ()', 'WORLD ()']); + reader + ..addText('HELLO ()\r') + ..addText('\nWORLD ()\r\n'); + expect(reader.readLines(), ['HELLO ()', 'WORLD ()']); + }); + + test('Uint8ListReader.readLine() with 2 breaks in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()'); + expect(reader.hasLineBreak(), false); + expect(reader.readLine(), null); + reader + ..addText('\r') + ..addText('\n'); + expect(reader.hasLineBreak(), true); + expect(reader.findLineBreak(), reader.findLastLineBreak()); + expect(reader.readLine(), 'HELLO ()'); + }); // test end + + test('Uint8ListReader.findLineBreak() simple case', () { + final reader = Uint8ListReader()..addText('HELLO ()\r\n'); + final pos = reader.findLineBreak(); + expect(pos, 9); + }); // test end + + test('Uint8ListReader.findLineBreak() with break in newline', () { + final reader = Uint8ListReader()..addText('HELLO ()\r'); + expect(reader.findLineBreak(), null); + reader.addText('\n'); + final pos = reader.findLineBreak(); + expect(pos, 9); + }); // test end +}