Switch enough_mail from vendored path to forked git dependency

- pubspec.yaml: path: packages/enough_mail → git: guettli/enough_mail
  pinned to SHA 25320ada (same version as the vendored copy)
- Remove packages/enough_mail/ (170 files, 38k lines) from this repo
- Fix .gitignore: restore comment, add coverage/ (generated by --coverage)
- PLAN.md: document phase 0a

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 09:34:55 +02:00
co-authored by Claude Sonnet 4.6
parent 71952ed36b
commit 79ee498879
171 changed files with 6 additions and 38623 deletions
+1
View File
@@ -1,4 +1,5 @@
# Flutter/Dart
coverage/
.dart_tool/
.packages
pubspec.lock
+1
View File
@@ -19,6 +19,7 @@ UI never touches the network. The sync layer runs independently.
| Phase | Scope | Status |
| --- | --- | --- |
| 0 — Scaffold | pubspec, Drift schema, DI, router, enough_mail vendored | Done |
| 0a — enough_mail fork | Remove vendored copy; point pubspec at [guettli/enough_mail](https://github.com/guettli/enough_mail) via git dep | In progress |
| 1 — Core models | `Account`, `Mailbox`, `Email`, `EmailBody`, repository interfaces | Done |
| 2 — DB layer | Drift tables, `AccountRepositoryImpl`, `MailboxRepositoryImpl`, `EmailRepositoryImpl` | Done |
| 3 — IMAP sync | `connectImap`, `MailboxRepositoryImpl.syncMailboxes`, `EmailRepositoryImpl.syncEmails` | Done |
-45
View File
@@ -1,45 +0,0 @@
# 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
-15
View File
@@ -1,15 +0,0 @@
# 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
-441
View File
@@ -1,441 +0,0 @@
# 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<MailAddress> 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<DeleteResult> 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<ReturnOption>` 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<MimeMessage>)`
- 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<MailResponse<DeleteResult>> deleteMessages(`
` MessageSequence sequence, Mailbox trashMailbox)`
- `Future<MailResponse<DeleteResult>> 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<MimeMessage>`
- 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
-373
View File
@@ -1,373 +0,0 @@
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.
-350
View File
@@ -1,350 +0,0 @@
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: '<p>Hello world!</p>',
)
..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<MimeMessage> 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: '<p>Hello world!</p>',
);
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<void> 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<MailLoadEvent>().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<void> 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<void> 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<void> 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: '<p>hello <b>world</b></p>',
)
..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<void> 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!!
<a href="https://github.com/Enough-Software/enough_mail/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Enough-Software/enough_mail" />
</a>
## 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).
-147
View File
@@ -1,147 +0,0 @@
# 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
-20
View File
@@ -1,20 +0,0 @@
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
@@ -1,75 +0,0 @@
import 'dart:io';
import 'package:enough_mail/discover.dart';
// ignore: avoid_void_async
void main(List<String> 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);
}
@@ -1,225 +0,0 @@
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<void> 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: '<p>Hello world!</p>',
)
..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<MimeMessage> 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: '<p>Hello world!</p>',
);
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<void> 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<MailLoadEvent>().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<void> 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<void> 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<void> 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);
}
}
}
}
-6
View File
@@ -1,6 +0,0 @@
/// 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';
-4
View File
@@ -1,4 +0,0 @@
/// Discovers email settings based on an email address.
export 'src/discover/client_config.dart';
export 'src/discover/discover.dart';
-18
View File
@@ -1,18 +0,0 @@
/// 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';
-16
View File
@@ -1,16 +0,0 @@
/// 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';
-17
View File
@@ -1,17 +0,0 @@
/// 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';
-10
View File
@@ -1,10 +0,0 @@
/// 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';
-9
View File
@@ -1,9 +0,0 @@
/// 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';
-8
View File
@@ -1,8 +0,0 @@
/// 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';
@@ -1,187 +0,0 @@
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<int> 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();
}
}
@@ -1,549 +0,0 @@
/// Encodes and decodes dates according to MIME requirements.
class DateCodec {
// do not allow instantiation
DateCodec._();
static const _weekdays = <String>[
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
static const _months = <String>[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
static const _monthsByName = <String, int>{
'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 = <String, String>{
'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', // HawaiiAleutian Daylight Time
'HAEC': '+0200', // Heure Avancée d'Europe Centrale
// French-language name for CEST
'HST': '-1000', // HawaiiAleutian 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
}
@@ -1,461 +0,0 @@
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 = <String, convert.Encoding Function()>{
'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 = <String,
String Function(
String text,
convert.Encoding encoding, {
required bool isHeader,
})>{
'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 = <String, Uint8List Function(String)>{
'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();
}
}
@@ -1,258 +0,0 @@
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<int> _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();
}
}
@@ -1,267 +0,0 @@
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<int> 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);
}
@@ -1,418 +0,0 @@
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<ConfigEmailProvider>? 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 ??= <ConfigEmailProvider>[];
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<String?>? domains;
/// The name used for display purposes
String? displayName;
/// The short name
String? displayShortName;
/// All incoming servers
List<ServerConfig>? incomingServers;
/// All outgoing servers
List<ServerConfig>? 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 ??= <String>[];
domains?.add(name);
}
/// Adds the incoming [server].
void addIncomingServer(ServerConfig server) {
incomingServers ??= <ServerConfig>[];
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 ??= <ServerConfig>[];
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<String, dynamic> json) =>
_$ServerConfigFromJson(json);
/// Generates json from this [ServerConfig]
Map<String, dynamic> 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;
}
}
@@ -1,197 +0,0 @@
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<ClientConfig?> 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<MailAccount?> 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 = <DiscoverConnectionInfo>[];
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<ClientConfig?> _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);
}
}
}
@@ -1,17 +0,0 @@
/// 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);
}
@@ -1,5 +0,0 @@
/// Extended data results for LIST commands.
class ExtendedData {
/// Child information as result of "RECURSIVEMATCH" extended selection option
static const String childinfo = 'CHILDINFO';
}
-170
View File
@@ -1,170 +0,0 @@
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 <String, String>{},
});
/// 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<String, String> 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 = <String, String>{};
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();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,125 +0,0 @@
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);
}
@@ -1,36 +0,0 @@
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();
}
}
@@ -1,480 +0,0 @@
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 = <SearchTerm>[];
/// 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');
}
@@ -1,397 +0,0 @@
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<MailboxFlag> 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<MailboxFlag> 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<String>? messageFlags,
List<String>? permanentMessageFlags,
Map<String, List<String>>? 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<MailboxFlag> flags;
/// Supported flags for messages in this mailbox
List<String> messageFlags;
/// Supported permanent flags for messages in this mailbox
List<String> permanentMessageFlags;
/// Map of extended results
final Map<String, List<String>> 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<Mailbox> 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<T> on List<T> {
List<T> addIfNotPresent(T element) {
if (!contains(element)) {
add(element);
}
return this;
}
}
@@ -1,799 +0,0 @@
// 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<int> 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<MimeMessage> 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<String, dynamic> json) =>
_$MessageSequenceFromJson(json);
/// Converts this [MessageSequence] to JSON
Map<String, dynamic> 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<int> _ids = <int>[];
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<int> 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<int> 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<int>.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<int> 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 = <SequenceNode>[];
/// 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('<empty>');
} 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<int> _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<MimeMessage> {
/// Retrieves a message sequence from this list of [MimeMessage]s
MessageSequence toSequence() => MessageSequence.fromMessages(this);
}
@@ -1,69 +0,0 @@
// 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);
}
}
@@ -1,73 +0,0 @@
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(')');
}
}
@@ -1,14 +0,0 @@
/// 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;
}
@@ -1,277 +0,0 @@
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<T> {
/// 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<ImapWarning> warnings = <ImapWarning>[];
/// 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<MimeMessage> messages;
/// Replaces matching messages
void replaceMatchingMessages(List<MimeMessage> 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<MimeMessage>? 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<ResourceLimit> 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<String> rootNames;
/// The quota results
Map<String?, QuotaResult> 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;
}
}
@@ -1,111 +0,0 @@
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<String>? 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<String>? 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<String> 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();
}
}
@@ -1,36 +0,0 @@
/// 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';
}
}
}
@@ -1,439 +0,0 @@
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<MailAddress> 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<MailAddress> 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<MailAddress> 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<String, dynamic> 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<MailAddress> 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<String, dynamic> 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<MailAddress> 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<String, dynamic> 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<MailAddress>? aliases,
Map<String, dynamic>? 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 ? <String, dynamic>{} : 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 ? <MailAddress>[] : 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<String, dynamic> json) =>
_$MailServerConfigFromJson(json);
/// Converts this [MailServerConfig] to JSON
Map<String, dynamic> 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<Capability> 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<Capability>? serverCapabilities,
}) =>
MailServerConfig(
serverConfig: serverConfig ?? this.serverConfig,
authentication: authentication ?? this.authentication,
pathSeparator: pathSeparator ?? this.pathSeparator,
serverCapabilities: serverCapabilities ?? this.serverCapabilities,
);
}
@@ -1,328 +0,0 @@
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<String, dynamic> 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<String, dynamic> 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<void> 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<String, dynamic> json) =>
_$PlainAuthenticationFromJson(json);
/// Converts this [PlainAuthentication] to JSON
@override
Map<String, dynamic> toJson() =>
_$PlainAuthenticationToJson(this)..['typeName'] = typeName;
/// The password
final String password;
@override
Future<void> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() =>
_$OauthAuthenticationToJson(this)..['typeName'] = typeName;
/// Token for the access
final OauthToken token;
@override
Future<void> 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);
}
File diff suppressed because it is too large Load Diff
@@ -1,94 +0,0 @@
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;
}
@@ -1,71 +0,0 @@
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();
}
}
@@ -1,171 +0,0 @@
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<MailAddress>? 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,
);
}
@@ -1,641 +0,0 @@
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<MimeMessage>? messages;
/// Apply the message IDs from the [targetSequence] to the [messages]
bool applyMessageIds(
MessageSequence originalSequence,
MessageSequence? targetSequence,
List<MimeMessage>? 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<MimeMessage>? 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<MimeMessage>? 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<MimeThread> 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<MimeMessage> 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 = <MimeMessage>[];
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 = <int, MessageSequence>{};
/// 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 = <int, Future<List<MimeMessage>>>{};
/// 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<MimeMessage> messages;
/// The original fetch preference
final FetchPreference fetchPreference;
/// Requested pages
final Map<int, Future<List<MimeMessage>>> _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<MimeMessage> 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<MimeMessage> removeMessageSequence(MessageSequence removeSequence) {
assert(removeSequence.isUidSequence == pagedSequence.isUidSequence,
'Not the same sequence ID types');
final isUid = pagedSequence.isUidSequence;
final ids = removeSequence.toList();
final result = <MimeMessage>[];
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<MimeMessage> getMessage(
int messageIndex,
MailClient mailClient, {
Mailbox? mailbox,
FetchPreference fetchPreference = FetchPreference.envelope,
}) async {
Future<List<MimeMessage>> 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<MimeMessage> 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;
}
-166
View File
@@ -1,166 +0,0 @@
/// Contains a tree like structure
class Tree<T> {
/// Creates a new tree with the given root value
Tree(T rootValue) : root = TreeElement(rootValue, null);
/// The root element
final TreeElement<T> root;
@override
String toString() => root.toString();
/// Lists all leafs of this tree
/// Specify how to detect the leafs with [isLeaf].
List<T> flatten(bool Function(T element) isLeaf) {
final leafs = <T>[];
_addLeafs(root, isLeaf, leafs);
return leafs;
}
void _addLeafs(
TreeElement<T> root,
bool Function(T element) isLeaf,
List<T> 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<T> 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<T> _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<T>? 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<T> 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<T>? _locate(T value, TreeElement<T> 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<T> {
/// 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<TreeElement<T>>? 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<T>? parent;
/// Adds the [child] to this element
TreeElement<T> addChild(T child) {
children ??= <TreeElement<T>>[];
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');
}
}
}
@@ -1,214 +0,0 @@
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<String, dynamic> json) =>
_$MailAddressFromJson(json);
/// Converts this [MailAddress] to JSON
Map<String, dynamic> 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<MailAddress> searchForList,
List<MailAddress>? 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;
}
@@ -1,161 +0,0 @@
/// 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 <date> <from> wrote:'`
static const String defaultReplyHeaderTemplate = 'On <date> <from> 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: <from>\r\n'
'[[to To: <to>\r\n]]'
'[[cc CC: <cc>\r\n]]'
'Date: <date>\r\n'
'[[subject 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
/// `<subject>`, `<date>`, `<recipient>` and `<sender>`.
static const String defaultReadReceiptTemplate =
'''The message sent on <date> to <recipient> with '
'subject "<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<String> subjectReplyAbbreviations = <String>[
'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
'', // 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<String> subjectForwardAbbreviations = <String>[
'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 = '';
}
@@ -1,547 +0,0 @@
/// 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<String, MediaToptype> _topLevelByMimeName =
<String, MediaToptype>{
'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<String, MediaSubtype> _subtypesByMimeType =
<String, MediaSubtype>{
'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;
}
File diff suppressed because it is too large Load Diff
@@ -1,35 +0,0 @@
/// 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';
}
-400
View File
@@ -1,400 +0,0 @@
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<Header>? headersList;
/// Returns `true` when there are children
bool get hasParts => parts?.isNotEmpty ?? false;
/// The children of this mime data
List<MimeData>? 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<BinaryMimeData> _splitAndParse(
final String boundaryText,
final Uint8List bodyData,
) {
final boundary = '--$boundaryText\r\n'.codeUnits;
final result = <BinaryMimeData>[];
// 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<Header> _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);
}
File diff suppressed because it is too large Load Diff
@@ -1,223 +0,0 @@
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<SmtpConnectionLostEvent>().listen((event) {
/// // All events are of type SmtpConnectionLostEvent (or subtypes of it).
/// _log(event.type);
/// });
///
/// eventBus.on<SmtpEvent>().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<void> 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<void> 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<void> 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<void> 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<void> quit() async {
await sendCommand(PopQuitCommand(this));
isLoggedIn = false;
}
/// Checks the status ie the total number of messages and their size
Future<PopStatus> status() => sendCommand(PopStatusCommand());
/// Checks the ID and size of all messages
/// or of the message with the specified [messageId]
Future<List<MessageListing>> 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<List<MessageListing>> uidList([int? messageId]) =>
sendCommand(PopUidListCommand(messageId));
/// Downloads the message with the specified [messageId]
Future<MimeMessage> retrieve(int messageId) =>
sendCommand(PopRetrieveCommand(messageId));
/// Downloads the first [numberOfLines] lines of the message
/// with the given [messageId]
Future<MimeMessage> retrieveTopLines(int messageId, int numberOfLines) =>
sendCommand(PopTopCommand(messageId, numberOfLines));
/// Marks the message with the specified [messageId] as deleted
Future<void> delete(int messageId) =>
sendCommand(PopDeleteCommand(messageId));
/// Keeps any messages that are marked as deleted
Future<void> reset() => sendCommand(PopResetCommand());
/// Keeps the connection alive
Future<void> noop() => sendCommand(PopNoOpCommand());
/// Sends the specified command to the remote POP server
Future<T> sendCommand<T>(PopCommand<T> command) {
_currentCommand = command;
_currentFirstResponseLine = null;
writeText(command.command, command);
return command.completer.future;
}
/// Processes server responses
void onServerResponse(List<String> 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);
}
@@ -1,29 +0,0 @@
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);
}
@@ -1,46 +0,0 @@
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<String>(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();
}
}
@@ -1,54 +0,0 @@
/// Provides access to a POP response coming from the POP service
class PopResponse<T> {
/// 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;
}
@@ -1,15 +0,0 @@
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';
@@ -1,74 +0,0 @@
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<List<Capability>> {
/// Creates a new parser
CapabilityParser(this.info);
/// The server information
final ImapServerInfo info;
List<Capability>? _capabilities;
@override
List<Capability>? parse(
ImapResponse imapResponse,
Response<List<Capability>> 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<List<Capability>>? 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>(Capability.new).toList();
info.capabilities = caps;
}
}
@@ -1,114 +0,0 @@
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<String> 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<String>? 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<T> {
/// 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<T> parser;
/// Contains the response
final Response<T> response = Response<T>();
/// Completer for this task
final Completer<T> completer = Completer<T>();
@override
String toString() => '$id $command';
/// Retrieves the IMAP request to send
String get imapRequest => '$id ${command.commandText}';
/// Parses the response
Response<T> 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);
}
@@ -1,48 +0,0 @@
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<List<Capability>> {
/// Creates a new parser
EnableParser(this.info);
/// Information about the remote service
final ImapServerInfo info;
@override
List<Capability>? parse(
ImapResponse imapResponse,
Response<List<Capability>> response,
) {
if (response.isOkStatus) {
return info.enabledCapabilities;
}
return null;
}
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<List<Capability>>? 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>(Capability.new);
info.enabledCapabilities.addAll(caps);
}
}
@@ -1,656 +0,0 @@
import '../../codecs/date_codec.dart';
import '../../codecs/mail_codec.dart';
import '../../imap/message_sequence.dart';
import '../../imap/response.dart';
import '../../mail_address.dart';
import '../../media_type.dart';
import '../../mime_data.dart';
import '../../mime_message.dart';
import 'imap_response.dart';
import 'parser_helper.dart';
import 'response_parser.dart';
/// Parses FETCH IMAP responses
class FetchParser extends ResponseParser<FetchImapResult> {
/// Creates a new parser
FetchParser({required this.isUidFetch});
final List<MimeMessage> _messages = <MimeMessage>[];
/// The most recent message that has been parsed
MimeMessage? lastParsedMessage;
/// The most recent VANISHED response
MessageSequence? vanishedMessages;
/// The modified sequence if defined in the FETCH response
MessageSequence? modifiedSequence;
/// Is the FETCH request based on UIDs instead of sequence-IDs?
final bool isUidFetch;
@override
FetchImapResult? parse(
ImapResponse imapResponse,
Response<FetchImapResult> response,
) {
final text = imapResponse.parseText;
final modifiedIndex = text.indexOf('[MODIFIED ');
if (modifiedIndex != -1) {
final modifiedEntries = ParserHelper.parseListIntEntries(
text,
modifiedIndex + '[MODIFIED '.length,
']',
',',
);
if (modifiedEntries != null) {
modifiedSequence =
MessageSequence.fromIds(modifiedEntries, isUid: isUidFetch);
}
}
final vanishedMessages = this.vanishedMessages;
if (response.isOkStatus ||
_messages.isNotEmpty ||
(vanishedMessages != null && vanishedMessages.isNotEmpty)) {
return FetchImapResult(
_messages,
vanishedMessages,
modifiedSequence: modifiedSequence,
);
}
return null;
}
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<FetchImapResult>? response,
) {
final firstLine = imapResponse.first.line;
if (firstLine == null) {
return false;
}
final fetchIndex = firstLine.indexOf(' FETCH ');
lastParsedMessage = null;
if (fetchIndex != -1) {
// eg "* 2389 FETCH (...)"
final sequenceId = parseInt(firstLine, 2, ' ');
MimeMessage message;
if (_messages.isNotEmpty && _messages.last.sequenceId == sequenceId) {
message = _messages.last;
} else {
message = MimeMessage()..sequenceId = sequenceId;
_messages.add(message);
}
lastParsedMessage = message;
final iterator = imapResponse.iterate();
for (final value in iterator.values) {
if (value.value == 'FETCH') {
_parseFetch(message, value, imapResponse);
}
}
return true;
} else if (firstLine.startsWith('* VANISHED (EARLIER) ')) {
final parseText = imapResponse.parseText;
final messageSequenceText = parseText.startsWith('*')
? parseText.substring('* VANISHED (EARLIER) '.length)
: parseText.substring('VANISHED (EARLIER) '.length);
vanishedMessages =
MessageSequence.parse(messageSequenceText, isUidSequence: true);
return true;
}
return super.parseUntagged(imapResponse, response);
}
void _parseFetch(
MimeMessage message,
ImapValue fetchValue,
ImapResponse imapResponse,
) {
final children = fetchValue.children ?? [];
for (var i = 0; i < children.length; i++) {
final child = children[i];
final hasNext = i < children.length - 1;
switch (child.value) {
case 'UID':
if (hasNext) {
message.uid = int.parse(children[i + 1].value ?? '-1');
i++;
}
break;
case 'MODSEQ':
if (hasNext && (children[i + 1].children?.length == 1)) {
message.modSequence =
int.tryParse(children[i + 1].children?[0].value ?? '');
i++;
}
break;
case 'FLAGS':
message.flags = List.from(
child.children?.map<String?>((flag) => flag.value) ?? <String>[],
);
break;
case 'INTERNALDATE':
if (hasNext) {
message.internalDate = children[i + 1].value;
i++;
}
break;
case 'RFC822.SIZE':
if (hasNext) {
message.size = int.parse(children[i + 1].value ?? '-1');
i++;
}
break;
case 'ENVELOPE':
_parseEnvelope(message, child);
break;
case 'BODY':
_parseBody(message, child);
break;
case 'BODYSTRUCTURE':
_parseBodyStructure(message, child);
break;
case 'BODY[HEADER]':
case 'RFC822.HEADER':
if (hasNext) {
i++;
_parseBodyHeader(message, children[i]);
}
break;
case 'BODY[TEXT]':
case 'RFC822.TEXT':
if (hasNext) {
i++;
_parseBodyText(message, children[i]);
}
break;
case 'BODY[]':
case 'RFC822':
if (hasNext) {
i++;
_parseBodyFull(message, children[i]);
}
break;
default:
final value = child.value;
if (hasNext &&
value != null &&
value.startsWith('BODY[') &&
value.endsWith(']')) {
i++;
_parseBodyPart(message, value, children[i]);
} else {
print(
'fetch: encountered unexpected/unsupported element '
'${child.value} at $i in ${imapResponse.parseText}',
);
}
}
}
}
/// Parse a body part
///
/// parses elements starting with `BODY[`, excluding `BODY[]` and
/// `BODY[HEADER]` which are handled separately
/// e.g. `BODY[0]` or `BODY[HEADER.FIELDS (REFERENCES)]`
void _parseBodyPart(
MimeMessage message,
String bodyPartDefinition,
ImapValue imapValue,
) {
// this matches
// BODY[HEADER.FIELDS (name1,name2)], as well as
// BODY[HEADER.FIELDS.NOT (name1,name2)]
if (bodyPartDefinition.startsWith('BODY[HEADER.FIELDS')) {
_parseBodyHeader(message, imapValue);
} else {
const startIndex = 'BODY['.length;
final endIndex = bodyPartDefinition.length - 1;
final fetchId = bodyPartDefinition.substring(startIndex, endIndex);
final part = MimePart();
final value = imapValue.value;
final data = imapValue.data;
if (value != null) {
part.mimeData = TextMimeData(value, containsHeader: false);
} else if (data != null) {
part.mimeData = BinaryMimeData(data, containsHeader: false);
}
part.parse();
//print('$fetchId: results in [${imapValue.value}]');
message.setPart(fetchId.replaceFirst('.HEADER', ''), part);
}
}
void _parseBodyFull(MimeMessage message, ImapValue bodyValue) {
//print("Parsing BODY[]\n[${bodyValue.value}]");
final data = bodyValue.data;
final value = bodyValue.value;
if (data != null) {
message.mimeData = BinaryMimeData(data, containsHeader: true);
} else if (value != null) {
message.mimeData = TextMimeData(value, containsHeader: true);
//print("Parsing BODY text \n$bodyText");
}
// ensure all headers are set:
message.parse();
}
HeaderParseResult _parseBodyHeader(
MimeMessage message,
ImapValue headerValue,
) {
//print('Parsing BODY[HEADER]\n[${headerValue.value}]');
final headerParseResult =
ParserHelper.parseHeader(headerValue.valueOrDataText ?? '');
message.headers = headerParseResult.headersList;
return headerParseResult;
}
void _parseBodyText(MimeMessage message, ImapValue textValue) {
//print('Parsing BODY[TEXT]\n[${textValue.value}]');
final data = textValue.data;
message.mimeData = data != null
? BinaryMimeData(data, containsHeader: false)
: TextMimeData(textValue.value ?? '', containsHeader: false);
}
/// Also compare:
/// * http://sgerwk.altervista.org/imapbodystructure.html
/// * https://tools.ietf.org/html/rfc3501#section-7.4.2
/// * http://hea-www.cfa.harvard.edu/~fine/opinions/IMAPsucks.html
void _parseBodyRecursive(BodyPart body, ImapValue bodyValue) {
// print('_parseBodyRecursive from $bodyValue');
var isMultipartSubtypeSet = false;
var multipartChildIndex = -1;
final children = bodyValue.children ?? [];
if (children.length >= 7 && children[0].children == null) {
// this is a direct type:
final parsed = _parseBodyStructureFrom(children);
body
..bodyRaw = parsed.bodyRaw
..contentDisposition = parsed.contentDisposition
..contentType = parsed.contentType
..description = parsed.description
..encoding = parsed.encoding
..envelope = parsed.envelope
..cid = parsed.cid
..numberOfLines = parsed.numberOfLines
..size = parsed.size;
return;
}
for (var childIndex = 0; childIndex < children.length; childIndex++) {
final child = children[childIndex];
final grandchildren = child.children;
if (child.value == null &&
grandchildren != null &&
grandchildren.isNotEmpty &&
grandchildren.first.value == null) {
// this is a nested structure
final part = BodyPart();
body.addPart(part);
_parseBodyRecursive(part, child);
} else if (!isMultipartSubtypeSet &&
grandchildren != null &&
grandchildren.length >= 7) {
// TODO just counting cannot be a big enough indicator,
// compare for example
// ""mixed" ("charset" "utf8" "boundary" "cs2da2ss7EsqRfMsG")"
// this is a structure value
final structures = grandchildren;
final part = _parseBodyStructureFrom(structures);
body.addPart(part);
} else if (!isMultipartSubtypeSet) {
// this is the type:
isMultipartSubtypeSet = true;
multipartChildIndex = childIndex;
body.contentType =
ContentTypeHeader('multipart/${child.value?.toLowerCase()}');
} else if (childIndex == multipartChildIndex + 1 &&
grandchildren != null &&
grandchildren.length > 1) {
final parameters = grandchildren;
for (var i = 0; i < parameters.length; i += 2) {
body.contentType?.setParameter(
parameters[i].value ?? '',
parameters[i + 1].valueOrDataText ?? '',
);
}
}
}
}
BodyPart _parseBodyStructureFrom(List<ImapValue> structures) {
final size = int.tryParse(structures[6].value ?? '');
final mediaType =
MediaType.fromText('${structures[0].value}/${structures[1].value}');
final part = BodyPart()
..cid = _checkForNil(structures[3].value)
..description = _checkForNil(structures[4].value)
..encoding = _checkForNil(structures[5].value)?.toLowerCase()
..size = size
..contentType = ContentTypeHeader.from(mediaType);
final contentTypeParameters = structures[2].children;
if (contentTypeParameters != null && contentTypeParameters.length > 1) {
for (var i = 0; i < contentTypeParameters.length; i += 2) {
final name = contentTypeParameters[i].value;
final value = contentTypeParameters[i + 1].valueOrDataText;
// print('content-type: $name=$value');
if (name != null && value != null) {
part.contentType?.setParameter(name, value);
}
}
}
var startIndex = 7;
if (mediaType.isText &&
structures.length > 7 &&
structures[7].value != null) {
part.numberOfLines = int.tryParse(structures[7].value ?? '');
startIndex = 8;
} else if (mediaType.isMessage &&
mediaType.sub == MediaSubtype.messageRfc822) {
// [7]
// A body type of type MESSAGE and subtype RFC822 contains,
// immediately after the basic fields, the envelope structure,
// body structure, and size in text lines of the encapsulated
// message.
if (structures.length > 9) {
part.envelope = _parseEnvelope(null, structures[7]);
final child = BodyPart();
part.addPart(child);
_parseBodyRecursive(child, structures[8]);
part.numberOfLines = int.tryParse(structures[9].value ?? '');
}
startIndex += 3;
}
if ((structures.length > startIndex + 1) &&
(structures[startIndex + 1].children?.isNotEmpty ?? false)) {
// read content disposition
// example: <null>[attachment, <null>[filename, testImage.jpg,
// modification-date, Fri, 27 Jan 2017 16:34:4 +0100, size, 13390]]
final parts = structures[startIndex + 1].children ?? [];
if (parts[0].value != null) {
final contentDisposition =
ContentDispositionHeader(parts[0].value?.toLowerCase() ?? '');
final parameters = parts[1].children;
if (parameters != null && parameters.length > 1) {
for (var i = 0; i < parameters.length; i += 2) {
final name = parameters[i].value;
final value = parameters[i + 1].valueOrDataText;
if (name != null && value != null) {
// print('content-disposition: $name=$value');
contentDisposition.setParameter(name, value);
}
}
}
part.contentDisposition = contentDisposition;
} else {
print('Unable to parse content disposition from:');
print(parts);
}
}
return part;
}
void _parseBody(MimeMessage message, ImapValue bodyValue) {
// A parenthesized list that describes the [MIME-IMB] body
// structure of a message. This is computed by the server by
// parsing the [MIME-IMB] header fields, defaulting various fields
// as necessary.
// For example, a simple text message of 48 lines and 2279 octets
// can have a body structure of: ("TEXT" "PLAIN" ("CHARSET"
// "US-ASCII") NIL NIL "7BIT" 2279 48)
// Multiple parts are indicated by parenthesis nesting. Instead
// of a body type as the first element of the parenthesized list,
// there is a sequence of one or more nested body structures. The
// second element of the parenthesized list is the multipart
// subtype (mixed, digest, parallel, alternative, etc.).
// For example, a two part message consisting of a text and a
// BASE64-encoded text attachment can have a body structure of:
// (("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152
// 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff")
// "<960723163407.20117h@cac.washington.edu>" "Compiler diff"
// "BASE64" 4554 73) "MIXED")
// [0]body type
// A string giving the content media type name as defined in
// [MIME-IMB].
// [1]body subtype
// A string giving the content subtype name as defined in
// [MIME-IMB].
// [2] body parameter parenthesized list
// A parenthesized list of attribute/value pairs [e.g., ("foo"
// "bar" "baz" "rag") where "bar" is the value of "foo" and
// "rag" is the value of "baz"] as defined in [MIME-IMB].
// [3]body id
// A string giving the content id as defined in [MIME-IMB].
// [4]body description
// A string giving the content description as defined in
// [MIME-IMB].
// [5]body encoding
// A string giving the content transfer encoding as defined in
// [MIME-IMB].
// [6]body size
// A number giving the size of the body in octets. Note that
// this size is the size in its transfer encoding and not the
// resulting size after any decoding.
// [7]
// A body type of type MESSAGE and subtype RFC822 contains,
// immediately after the basic fields, the envelope structure,
// body structure, and size in text lines of the encapsulated
// message.
// A body type of type TEXT contains, immediately after the basic
// fields, the size of the body in text lines. Note that this
// size is the size in its content transfer encoding and not the
// resulting size after any decoding.
// Extension data follows the multipart subtype. Extension data
// is never returned with the BODY fetch, but can be returned with
// a BODYSTRUCTURE fetch. Extension data, if present, MUST be in
// the defined order. The extension data of a multipart body part
// are in the following order:
// [7 / 8]
// body parameter parenthesized list
// A parenthesized list of attribute/value pairs [e.g., ("foo"
// "bar" "baz" "rag") where "bar" is the value of "foo", and
// "rag" is the value of "baz"] as defined in [MIME-IMB].
// [8 / 9]
// body disposition
// A parenthesized list, consisting of a disposition type
// string, followed by a parenthesized list of disposition
// attribute/value pairs as defined in [DISPOSITION].
// [9 / 10]
// body language
// A string or parenthesized list giving the body language
// value as defined in [LANGUAGE-TAGS].
// [10 / 11]
// body location
// A string list giving the body content URI as defined in
// [LOCATION].
//
//
// The extension data of a non-multipart body part are in the
// following order:
// [7 / 8]
// body MD5
// A string giving the body MD5 value as defined in [MD5].
//
// [8 / 9]
// body disposition
// A parenthesized list with the same content and function as
// the body disposition for a multipart body part.
// [9 / 10]
// body language
// A string or parenthesized list giving the body language
// value as defined in [LANGUAGE-TAGS].
// [10 / 11]
// body location
// A string list giving the body content URI as defined in
// [LOCATION].
//print('body: $bodyValue');
final body = BodyPart();
_parseBodyRecursive(body, bodyValue);
message.body = body;
}
void _parseBodyStructure(MimeMessage message, ImapValue bodyValue) {
//print('bodystructure: $bodyValue');
_parseBody(message, bodyValue);
}
/// parses the envelope structure of a message
Envelope? _parseEnvelope(MimeMessage? message, ImapValue envelopeValue) {
// The fields of the envelope structure are in the following
// order: [0] date, [1]subject, [2]from, [3]sender, [4]reply-to, [5]to,
// [6]cc, [7]bcc, [8]in-reply-to, and [9]message-id.
//
// The date, subject, in-reply-to,
// and message-id fields are strings. The from, sender, reply-to,
// to, cc, and bcc fields are parenthesized lists of address
// structures.
// If the Date, Subject, In-Reply-To, and Message-ID header lines
// are absent in the [RFC-2822] header, the corresponding member
// of the envelope is NIL; if these header lines are present but
// empty the corresponding member of the envelope is the empty
// string.
Envelope? envelope;
final children = envelopeValue.children;
//print("envelope: $children");
if (children != null && children.length >= 10) {
final rawDate = _checkForNil(children[0].value);
final rawSubject = _checkForNil(children[1].valueOrDataText);
envelope = Envelope()
..date = rawDate != null ? DateCodec.decodeDate(rawDate) : null
..subject =
rawSubject != null ? MailCodec.decodeHeader(rawSubject) : null
..from = _parseAddressList(children[2])
..sender = _parseAddressListFirst(children[3])
..replyTo = _parseAddressList(children[4])
..to = _parseAddressList(children[5])
..cc = _parseAddressList(children[6])
..bcc = _parseAddressList(children[7])
..inReplyTo = _checkForNil(children[8].value)
..messageId = _checkForNil(children[9].value);
if (message != null) {
message.envelope = envelope;
if (rawDate != null) {
message.addHeader('Date', rawDate);
}
if (rawSubject != null) {
message.addHeader('Subject', rawSubject);
}
message
..addHeader('In-Reply-To', envelope.inReplyTo)
..addHeader('Message-ID', envelope.messageId);
}
}
return envelope;
}
MailAddress? _parseAddressListFirst(ImapValue addressValue) {
final addresses = _parseAddressList(addressValue);
if (addresses == null || addresses.isEmpty) {
return null;
}
return addresses.first;
}
List<MailAddress>? _parseAddressList(ImapValue addressValue) {
if (addressValue.value == 'NIL') {
return null;
}
final addresses = <MailAddress>[];
final addressChildren = addressValue.children;
if (addressChildren != null) {
for (final child in addressChildren) {
final address = _parseAddress(child);
if (address != null) {
addresses.add(address);
}
}
}
return addresses;
}
MailAddress? _parseAddress(ImapValue addressValue) {
// An address structure is a parenthesized list that describes an
// electronic mail address. The fields of an address structure
// are in the following order: personal name, [SMTP]
// at-domain-list (source route), mailbox name, and host name.
// [RFC-2822] group syntax is indicated by a special form of
// address structure in which the host name field is NIL. If the
// mailbox name field is also NIL, this is an end of group marker
// (semi-colon in RFC 822 syntax). If the mailbox name field is
// non-NIL, this is a start of group marker, and the mailbox name
// field holds the group name phrase.
final addressChildren = addressValue.children;
if (addressValue.value == 'NIL' ||
addressChildren == null ||
addressChildren.length < 4) {
return null;
}
final children = addressChildren;
final mailboxName = _checkForNil(children[2].value);
final hostName = _checkForNil(children[3].value);
if (mailboxName == null && hostName == null) {
print('Warning: invalid mail address in $addressValue: '
'both mailboxName and hostName are null');
return null;
}
String? personalName = '';
try {
personalName = MailCodec.decodeHeader(_checkForNil(children[0].value));
} catch (e) {
print('Warning: invalid mail address in $addressValue: '
'personalName is invalid: $e');
}
return MailAddress.fromEnvelope(
personalName: personalName,
//sourceRoute: _checkForNil(children[1].value),
mailboxName: mailboxName ?? '',
hostName: hostName ?? '',
);
}
String? _checkForNil(String? value) {
if (value == 'NIL') {
return null;
}
return value;
}
}
@@ -1,114 +0,0 @@
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<GenericImapResult> {
/// 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<GenericImapResult> 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<GenericImapResult>? 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<void> _fireDelayed(ImapEvent event) async {
await Future.delayed(const Duration(milliseconds: 100));
imapClient.eventBus.fire(event);
}
}
@@ -1,34 +0,0 @@
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? _id;
@override
Id? parse(ImapResponse imapResponse, Response response) {
if (response.isOkStatus) {
return _id;
}
return null;
}
@override
bool parseUntagged(ImapResponse imapResponse, Response<Id?>? 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);
}
}
@@ -1,265 +0,0 @@
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<ImapResponseLine> lines = <ImapResponseLine>[];
/// 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<String> _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<ParenthesizedListType>();
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 ??= <ImapValue>[];
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<ImapValue> 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 = <ImapValue>[];
}
}
/// The parent of this value
ImapValue? parent;
/// The text data
String? value;
/// The binary data
Uint8List? data;
/// The children, if any
List<ImapValue>? 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 ??= <ImapValue>[];
child.parent = this;
children?.add(child);
}
@override
String toString() {
final data = this.data;
return (value ?? (data != null ? '<${data.length} bytes>' : '<null>')) +
(children != null ? children.toString() : '');
}
}
@@ -1,73 +0,0 @@
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 ?? '<no valid data>';
}
@@ -1,87 +0,0 @@
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', '<CRLF>\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);
}
}
}
}
}
}
@@ -1,239 +0,0 @@
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<List<Mailbox>> {
/// 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<Mailbox> boxes = <Mailbox>[];
/// 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<Mailbox>? parse(
ImapResponse? imapResponse,
Response<List<Mailbox>> response,
) =>
response.isOkStatus ? boxes : null;
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<List<Mailbox>>? 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 = <MailboxFlag>[];
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 = <String, List<String>>{};
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<MailboxFlag> 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]');
}
}
}
}
}
@@ -1,23 +0,0 @@
import '../../imap/response.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Parses responses to logout requests
class LogoutParser extends ResponseParser<String> {
String? _bye;
@override
String? parse(ImapResponse imapResponse, Response<String> response) =>
_bye ?? '';
@override
bool parseUntagged(ImapResponse imapResponse, Response<String>? response) {
if (imapResponse.parseText.startsWith('BYE')) {
_bye = imapResponse.parseText;
return true;
}
return super.parseUntagged(imapResponse, response);
}
}
@@ -1,53 +0,0 @@
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<List<MetaDataEntry>> {
final List<MetaDataEntry> _entries = <MetaDataEntry>[];
//TODO consider supporting [METADATA LONGENTRIES 2199]
@override
List<MetaDataEntry>? parse(
ImapResponse imapResponse,
Response<List<MetaDataEntry>> response,
) =>
response.isOkStatus ? _entries : null;
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<List<MetaDataEntry>>? 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);
}
}
@@ -1,16 +0,0 @@
import '../../imap/response.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Returns the given value when the command succeeded
class NoResponseParser<T> extends ResponseParser<T> {
/// Creates a new parser
NoResponseParser(this.value);
/// The value to be returned for successful responses
final T value;
@override
T? parse(ImapResponse imapResponse, Response<T> response) =>
response.isOkStatus ? value : null;
}
@@ -1,127 +0,0 @@
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<Mailbox?> {
/// 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<FetchImapResult> _fetchResponse = Response<FetchImapResult>();
@override
Mailbox? parse(ImapResponse imapResponse, Response<Mailbox?> 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<Mailbox?>? 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,
));
}
}
@@ -1,236 +0,0 @@
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<String>? 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 = <String>[];
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<String>? 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<int>? parseListIntEntries(
String details,
int startIndex,
String endCharacter, [
String separator = ' ',
]) {
final texts =
parseListEntries(details, startIndex, endCharacter, separator);
if (texts == null) {
return null;
}
final integers = <int>[];
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<String> 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" <address@domain.com>`
static String? parseEmail(String value) {
if (value.length < 3) {
return null;
}
// check for a value like '"name" <address@domain.com>'
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 = <Header>[];
/// 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);
}
}
@@ -1,125 +0,0 @@
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> {
QuotaResult? _quota;
@override
QuotaResult? parse(
ImapResponse imapResponse,
Response<QuotaResult> response,
) =>
response.isOkStatus ? _quota : null;
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<QuotaResult>? 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 = <ResourceLimit>[];
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> {
QuotaRootResult? _quotaRoot;
@override
QuotaRootResult? parse(
ImapResponse imapResponse,
Response<QuotaRootResult> response,
) =>
response.isOkStatus ? _quotaRoot : null;
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<QuotaRootResult>? 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 = <ResourceLimit>[];
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<String> _parseStringEntries(String details) {
final output = <String>[];
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;
}
}
@@ -1,44 +0,0 @@
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<T> {
/// Parses the final response line, either starting with OK, NO or BAD.
T? parse(ImapResponse imapResponse, Response<T> response);
/// Parses intermediate untagged response lines.
bool parseUntagged(ImapResponse imapResponse, Response<T>? 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<String>? 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<int>? parseListIntEntries(
String details,
int startIndex,
String endCharacter, [
String separator = ' ',
]) =>
ParserHelper.parseListIntEntries(
details,
startIndex,
endCharacter,
separator,
);
}
@@ -1,151 +0,0 @@
import '../../imap/message_sequence.dart';
import '../../imap/response.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Parses search responses
class SearchParser extends ResponseParser<SearchImapResult> {
/// Creates a new search parser
SearchParser({required this.isUidSearch, this.isExtended = false});
/// Is this a UID-based search?
final bool isUidSearch;
/// The IDs
List<int> ids = <int>[];
/// 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<SearchImapResult> 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<SearchImapResult>? 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;
}
}
@@ -1,117 +0,0 @@
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<Mailbox> {
/// 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<FetchImapResult> _fetchResponse = Response<FetchImapResult>();
@override
Mailbox? parse(ImapResponse imapResponse, Response<Mailbox> 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<Mailbox>? 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;
}
}
}
@@ -1,149 +0,0 @@
import '../../imap/message_sequence.dart';
import '../../imap/response.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Parses sort responses
class SortParser extends ResponseParser<SortImapResult> {
/// 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<int> ids = <int>[];
/// 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<SortImapResult> 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<SortImapResult>? 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;
}
}
@@ -1,72 +0,0 @@
import '../../imap/mailbox.dart';
import '../../imap/response.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Parses status responses
class StatusParser extends ResponseParser<Mailbox> {
/// 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<Mailbox> response) =>
response.isOkStatus ? box : null;
@override
bool parseUntagged(ImapResponse imapResponse, Response<Mailbox>? 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;
}
}
@@ -1,58 +0,0 @@
import '../../../enough_mail.dart';
import 'imap_response.dart';
import 'response_parser.dart';
/// Parses responses to THREAD commands
class ThreadParser extends ResponseParser<SequenceNode> {
/// 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<SequenceNode> response,
) =>
response.isOkStatus ? result : null;
@override
bool parseUntagged(
ImapResponse imapResponse,
Response<SequenceNode>? 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);
}
}
}
}
@@ -1,13 +0,0 @@
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';
@@ -1,26 +0,0 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import '../pop_command.dart';
/// The `APOP` command signs in the user
class PopApopCommand extends PopCommand<String> {
/// 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 <MD5 scrambled>';
}
@@ -1,7 +0,0 @@
import '../pop_command.dart';
/// Deletes a specific message
class PopDeleteCommand extends PopCommand<void> {
/// Creates a new `DELE` request for [messageId]
PopDeleteCommand(int messageId) : super('DELE $messageId');
}
@@ -1,14 +0,0 @@
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<List<MessageListing>> {
/// Creates a new `LIST` command
PopListCommand([int? messageId])
: super(
messageId == null ? 'LIST' : 'LIST $messageId',
parser: PopListParser(isMultiLine: messageId == null),
isMultiLine: messageId == null,
);
}
@@ -1,7 +0,0 @@
import '../pop_command.dart';
/// Just tests the connection with a NO OP (no operation)
class PopNoOpCommand extends PopCommand<void> {
/// Creates a new `NOOP` command
PopNoOpCommand() : super('NOOP');
}
@@ -1,10 +0,0 @@
import '../pop_command.dart';
/// Signs in the user using a PASS command
class PopPassCommand extends PopCommand<String> {
/// Creates a new `PASS` command
PopPassCommand(String pass) : super('PASS $pass');
@override
String toString() => 'PASS <password scrambled>';
}
@@ -1,17 +0,0 @@
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<String> {
/// Creates a new `QUIT` command
PopQuitCommand(this._client) : super('QUIT');
final PopClient _client;
@override
String? nextCommand(PopResponse response) {
_client.disconnect();
return null;
}
}
@@ -1,7 +0,0 @@
import '../pop_command.dart';
/// Resets the connection, un-deleting any messages previously marked as deleted
class PopResetCommand extends PopCommand<void> {
/// Creates a new `RSET` command
PopResetCommand() : super('RSET');
}
@@ -1,14 +0,0 @@
import '../../../../enough_mail.dart';
import '../parsers/all_parsers.dart';
import '../pop_command.dart';
/// Retrieves a specific or all messages
class PopRetrieveCommand extends PopCommand<MimeMessage> {
/// Creates a new `RETR` command
PopRetrieveCommand(int messageId)
: super(
'RETR $messageId',
parser: PopRetrieveParser(),
isMultiLine: true,
);
}
@@ -1,9 +0,0 @@
import '../pop_command.dart';
/// Starts switching to a secure connection
///
/// Compare https://tools.ietf.org/html/rfc2595
class PopStartTlsCommand extends PopCommand<String> {
/// Creates a `STLS` command
PopStartTlsCommand() : super('STLS');
}
@@ -1,9 +0,0 @@
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<PopStatus> {
/// Creates a new `STAT` command
PopStatusCommand() : super('STAT', parser: PopStatusParser());
}
@@ -1,14 +0,0 @@
import '../../../mime_message.dart';
import '../parsers/all_parsers.dart';
import '../pop_command.dart';
/// Retrieves a part of the message
class PopTopCommand extends PopCommand<MimeMessage> {
/// Creates a new `TOP` command
PopTopCommand(int messageId, int lines)
: super(
'TOP $messageId $lines',
parser: PopRetrieveParser(),
isMultiLine: true,
);
}
@@ -1,14 +0,0 @@
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<List<MessageListing>> {
/// Creates a new `UIDL` command
PopUidListCommand([int? messageId])
: super(
messageId == null ? 'UIDL' : 'UIDL $messageId',
parser: PopUidListParser(isMultiLine: messageId == null),
isMultiLine: messageId == null,
);
}
@@ -1,7 +0,0 @@
import '../pop_command.dart';
/// Authenticates the user
class PopUserCommand extends PopCommand<String> {
/// Creates a new `USER` command
PopUserCommand(String user) : super('USER $user');
}
@@ -1,5 +0,0 @@
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';
@@ -1,45 +0,0 @@
import '../../../pop/pop_response.dart';
import '../pop_response_parser.dart';
/// Parses list responses
class PopListParser extends PopResponseParser<List<MessageListing>> {
/// 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<List<MessageListing>> parse(List<String> responseLines) {
final response = PopResponse<List<MessageListing>>();
parseOkStatus(responseLines, response);
if (response.isOkStatus) {
final result = <MessageListing>[];
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;
}
}
@@ -1,33 +0,0 @@
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<MimeMessage> {
@override
PopResponse<MimeMessage> parse(List<String> responseLines) {
final response = PopResponse<MimeMessage>();
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;
}
}

Some files were not shown because too many files have changed in this diff Show More