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:
co-authored by
Claude Sonnet 4.6
parent
71952ed36b
commit
79ee498879
@@ -1,4 +1,5 @@
|
||||
# Flutter/Dart
|
||||
coverage/
|
||||
.dart_tool/
|
||||
.packages
|
||||
pubspec.lock
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 [](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).
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,4 +0,0 @@
|
||||
/// Discovers email settings based on an email address.
|
||||
|
||||
export 'src/discover/client_config.dart';
|
||||
export 'src/discover/discover.dart';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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', // Hawaii–Aleutian Daylight Time
|
||||
'HAEC': '+0200', // Heure Avancée d'Europe Centrale
|
||||
// French-language name for CEST
|
||||
'HST': '-1000', // Hawaii–Aleutian Standard Time
|
||||
'HKT': '+0800', // Hong Kong Time
|
||||
'HMT': '+0500', // Heard and McDonald Islands Time
|
||||
'HOVST': '+0800', // Hovd Summer Time
|
||||
'HOVT': '+0700', // Hovd Time
|
||||
'ICT': '+0700', // Indochina Time
|
||||
'IDLW': '-1200', // International Day Line West time zone
|
||||
'IDT': '+0300', // Israel Daylight Time
|
||||
'IOT': '+0300', // Indian Ocean Time
|
||||
'IRDT': '+0430', // Iran Daylight Time
|
||||
'IRKT': '+0800', // Irkutsk Time
|
||||
'IRST': '+0330', // Iran Standard Time
|
||||
'IST': '+0530', // Indian Standard Time, could also be Irish Standard Time
|
||||
// +0100 or Israel Standard Time +0200
|
||||
'JST': '+0900', // Japan Standard Time
|
||||
'KALT': '+0200', // Kaliningrad Time
|
||||
'KGT': '+0600', // Kyrgyzstan Time
|
||||
'KOST': '+1100', // Kosrae Time
|
||||
'KRAT': '+0700', // Krasnoyarsk Time
|
||||
'KST': '+0900', // Korea Standard Time
|
||||
'LHST': '+1030', // Lord Howe Standard Time,
|
||||
// could also be Lord Howe Summer Time +1100
|
||||
'LINT': '+1400', // Line Islands Time
|
||||
'MAGT': '+1200', // Magadan Time
|
||||
'MART': '-0930', // Marquesas Islands Time
|
||||
'MAWT': '+0500', // Mawson Station Time
|
||||
'MDT': '-0600', // Mountain Daylight Time (North America)
|
||||
'MET': '+0100', // Middle European Time Same zone as CET
|
||||
'MEST': '+0200', // Middle European Summer Time Same zone as CEST
|
||||
'MHT': '+1200', // Marshall Islands Time
|
||||
'MIST': '+1100', // Macquarie Island Station Time
|
||||
'MIT': '-0930', // Marquesas Islands Time
|
||||
'MMT': '+0630', // Myanmar Standard Time
|
||||
'MSK': '+0300', // Moscow Time
|
||||
'MST': '-0700', // Mountain Standard Time (North America),
|
||||
// could also be Malaysia Standard Time +0800
|
||||
'MUT': '+0400', // Mauritius Time
|
||||
'MVT': '+0500', // Maldives Time
|
||||
'MYT': '+0800', // Malaysia Time
|
||||
'NCT': '+1100', // New Caledonia Time
|
||||
'NDT': '-0230', // Newfoundland Daylight Time
|
||||
'NFT': '+1100', // Norfolk Island Time
|
||||
'NOVT': '+0700', // Novosibirsk Time
|
||||
'NPT': '+0545', // Nepal Time
|
||||
'NST': '-0330', // Newfoundland Standard Time
|
||||
'NT': '-0330', // Newfoundland Time
|
||||
'NUT': '-1100', // Niue Time
|
||||
'NZDT': '+1300', // New Zealand Daylight Time
|
||||
'NZST': '+1200', // New Zealand Standard Time
|
||||
'OMST': '+0600', // Omsk Time
|
||||
'ORAT': '+0500', // Oral Time
|
||||
'PDT': '-0700', // Pacific Daylight Time (North America)
|
||||
'PET': '-0500', // Peru Time
|
||||
'PETT': '+1200', // Kamchatka Time
|
||||
'PGT': '+1000', // Papua New Guinea Time
|
||||
'PHOT': '+1300', // Phoenix Island Time
|
||||
'PHT': '+0800', // Philippine Time
|
||||
'PKT': '+0500', // Pakistan Standard Time
|
||||
'PMDT': '-0200', // Saint Pierre and Miquelon Daylight Time
|
||||
'PMST': '-0300', // Saint Pierre and Miquelon Standard Time
|
||||
'PONT': '+1100', // Pohnpei Standard Time
|
||||
'PST': '-0800', // Pacific Standard Time (North America),
|
||||
// could also be Philippine Standard Time +0800
|
||||
'PYST': '-0300', // Paraguay Summer Time
|
||||
'PYT': '-0400', // Paraguay Time
|
||||
'RET': '+0400', // Réunion Time
|
||||
'ROTT': '-0300', // Rothera Research Station Time
|
||||
'SAKT': '+1100', // Sakhalin Island Time
|
||||
'SAMT': '+0400', // Samara Time
|
||||
'SAST': '+0200', // South African Standard Time
|
||||
'SBT': '+1100', // Solomon Islands Time
|
||||
'SCT': '+0400', // Seychelles Time
|
||||
'SDT': '-1000', // Samoa Daylight Time
|
||||
'SGT': '+0800', // Singapore Time
|
||||
'SLST': '+0530', // Sri Lanka Standard Time
|
||||
'SRET': '+1100', // Srednekolymsk Time
|
||||
'SRT': '-0300', // Suriname Time
|
||||
'SST': '+0800', // Singapore Standard Time,
|
||||
// could also be Samoa Standard Time (-1100)
|
||||
'SYOT': '+0300', // Showa Station Time
|
||||
'TAHT': '-1000', // Tahiti Time
|
||||
'THA': '+0700', // Thailand Standard Time
|
||||
'TFT': '+0500', // French Southern and Antarctic Time
|
||||
'TJT': '+0500', // Tajikistan Time
|
||||
'TKT': '+1300', // Tokelau Time
|
||||
'TLT': '+0900', // Timor Leste Time
|
||||
'TMT': '+0500', // Turkmenistan Time
|
||||
'TRT': '+0300', // Turkey Time
|
||||
'TOT': '+1300', // Tonga Time
|
||||
'TVT': '+1200', // Tuvalu Time
|
||||
'ULAST': '+0900', // Ulaanbaatar Summer Time
|
||||
'ULAT': '+0800', // Ulaanbaatar Standard Time
|
||||
'UTC': '+0000', // Coordinated Universal Time
|
||||
'UYST': '-0200', // Uruguay Summer Time
|
||||
'UYT': '-0300', // Uruguay Standard Time
|
||||
'UZT': '+0500', // Uzbekistan Time
|
||||
'VET': '-0400', // Venezuelan Standard Time
|
||||
'VLAT': '+1000', // Vladivostok Time
|
||||
'VOLT': '+0400', // Volgograd Time
|
||||
'VOST': '+0600', // Vostok Station Time
|
||||
'VUT': '+1100', // Vanuatu Time
|
||||
'WAKT': '+1200', // Wake Island Time
|
||||
'WAST': '+0200', // West Africa Summer Time
|
||||
'WAT': '+0100', // West Africa Time
|
||||
'WEST': '+0100', // Western European Summer Time
|
||||
'WET': '+0000', // Western European Time
|
||||
'WIT': '+0700', // Western Indonesian Time
|
||||
'WGST': '-0200', // West Greenland Summer Time
|
||||
'WGT': '-0300', // West Greenland Time
|
||||
'WST': '+0800', // Western Standard Time (North America)
|
||||
'YAKT': '+0900', // Yakutsk Time
|
||||
'YEKT': '+0500', // Yekaterinburg Time
|
||||
};
|
||||
|
||||
/// Encodes the given [dateTime] to a valid MIME date representation
|
||||
static String encodeDate(DateTime dateTime) {
|
||||
/*
|
||||
Date and time values occur in several header fields. This section
|
||||
specifies the syntax for a full date and time specification. Though
|
||||
folding white space is permitted throughout the date-time
|
||||
specification, it is RECOMMENDED that a single space be used in each
|
||||
place that FWS appears (whether it is required or optional); some
|
||||
older implementations will not interpret longer sequences of folding
|
||||
white space correctly.
|
||||
date-time = [ day-of-week "," ] date time [CFWS]
|
||||
|
||||
day-of-week = ([FWS] day-name) / obs-day-of-week
|
||||
|
||||
day-name = "Mon" / "Tue" / "Wed" / "Thu" /
|
||||
"Fri" / "Sat" / "Sun"
|
||||
|
||||
date = day month year
|
||||
|
||||
day = ([FWS] 1*2DIGIT FWS) / obs-day
|
||||
|
||||
month = "Jan" / "Feb" / "Mar" / "Apr" /
|
||||
"May" / "Jun" / "Jul" / "Aug" /
|
||||
"Sep" / "Oct" / "Nov" / "Dec"
|
||||
|
||||
year = (FWS 4*DIGIT FWS) / obs-year
|
||||
|
||||
time = time-of-day zone
|
||||
|
||||
time-of-day = hour ":" minute [ ":" second ]
|
||||
|
||||
hour = 2DIGIT / obs-hour
|
||||
|
||||
minute = 2DIGIT / obs-minute
|
||||
|
||||
second = 2DIGIT / obs-second
|
||||
|
||||
zone = (FWS ( "+" / "-" ) 4DIGIT) / obs-zone
|
||||
|
||||
The day is the numeric day of the month. The year is any numeric
|
||||
year 1900 or later.
|
||||
|
||||
The time-of-day specifies the number of hours, minutes, and
|
||||
optionally seconds since midnight of the date indicated.
|
||||
|
||||
The date and time-of-day SHOULD express local time.
|
||||
|
||||
The zone specifies the offset from Coordinated Universal Time (UTC,
|
||||
formerly referred to as "Greenwich Mean Time") that the date and
|
||||
time-of-day represent. The "+" or "-" indicates whether the time-of-
|
||||
day is ahead of (i.e., east of) or behind (i.e., west of) Universal
|
||||
Time. The first two digits indicate the number of hours difference
|
||||
from Universal Time, and the last two digits indicate the number of
|
||||
additional minutes difference from Universal Time. (Hence, +hhmm
|
||||
means +(hh * 60 + mm) minutes, and -hhmm means -(hh * 60 + mm)
|
||||
minutes). The form "+0000" SHOULD be used to indicate a time zone at
|
||||
Universal Time. Though "-0000" also indicates Universal Time, it is
|
||||
used to indicate that the time was generated on a system that may be
|
||||
in a local time zone other than Universal Time and that the date-time
|
||||
contains no information about the local time zone.
|
||||
|
||||
A date-time specification MUST be semantically valid. That is, the
|
||||
day-of-week (if included) MUST be the day implied by the date, the
|
||||
numeric day-of-month MUST be between 1 and the number of days allowed
|
||||
for the specified month (in the specified year), the time-of-day MUST
|
||||
be in the range 00:00:00 through 23:59:60 (the number of seconds
|
||||
allowing for a leap second; see [RFC1305]), and the last two digits
|
||||
of the zone MUST be within the range 00 through 59.
|
||||
*/
|
||||
final buffer = StringBuffer()
|
||||
..write(_weekdays[dateTime.weekday - 1])
|
||||
..write(', ')
|
||||
..write(dateTime.day.toString().padLeft(2, '0'))
|
||||
..write(' ')
|
||||
..write(_months[dateTime.month - 1])
|
||||
..write(' ')
|
||||
..write(dateTime.year)
|
||||
..write(' ')
|
||||
..write(dateTime.hour.toString().padLeft(2, '0'))
|
||||
..write(':')
|
||||
..write(dateTime.minute.toString().padLeft(2, '0'))
|
||||
..write(':')
|
||||
..write(dateTime.second.toString().padLeft(2, '0'))
|
||||
..write(' ');
|
||||
if (dateTime.timeZoneOffset.inMinutes > 0) {
|
||||
buffer.write('+');
|
||||
} else {
|
||||
buffer.write('-');
|
||||
}
|
||||
final hours = dateTime.timeZoneOffset.inHours;
|
||||
if (hours < 10 && hours > -10) {
|
||||
buffer.write('0');
|
||||
}
|
||||
buffer.write(hours.abs());
|
||||
final minutes = dateTime.timeZoneOffset.inMinutes -
|
||||
(dateTime.timeZoneOffset.inHours * 60);
|
||||
if (minutes == 0) {
|
||||
buffer.write('00');
|
||||
} else {
|
||||
if (minutes < 10 && minutes > -10) {
|
||||
buffer.write('0');
|
||||
}
|
||||
buffer.write(minutes);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Encodes only day-month-year of the given dateTime, e.g. `"1-MAR-2021"`
|
||||
static String encodeSearchDate(DateTime dateTime) {
|
||||
final buffer = StringBuffer()
|
||||
..write('"')
|
||||
..write(dateTime.day)
|
||||
..write('-')
|
||||
..write(_months[dateTime.month - 1])
|
||||
..write('-')
|
||||
..write(dateTime.year)
|
||||
..write('"');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Decodes the given MIME [dateText] to the local DateTime
|
||||
static DateTime? decodeDate(final String? dateText) {
|
||||
/*
|
||||
Date and time values occur in several header fields. This section
|
||||
specifies the syntax for a full date and time specification. Though
|
||||
folding white space is permitted throughout the date-time
|
||||
specification, it is RECOMMENDED that a single space be used in each
|
||||
place that FWS appears (whether it is required or optional); some
|
||||
older implementations will not interpret longer sequences of folding
|
||||
white space correctly.
|
||||
date-time = [ day-of-week "," ] date time [CFWS]
|
||||
|
||||
day-of-week = ([FWS] day-name) / obs-day-of-week
|
||||
|
||||
day-name = "Mon" / "Tue" / "Wed" / "Thu" /
|
||||
"Fri" / "Sat" / "Sun"
|
||||
|
||||
date = day month year
|
||||
|
||||
day = ([FWS] 1*2DIGIT FWS) / obs-day
|
||||
|
||||
month = "Jan" / "Feb" / "Mar" / "Apr" /
|
||||
"May" / "Jun" / "Jul" / "Aug" /
|
||||
"Sep" / "Oct" / "Nov" / "Dec"
|
||||
|
||||
year = (FWS 4*DIGIT FWS) / obs-year
|
||||
|
||||
time = time-of-day zone
|
||||
|
||||
time-of-day = hour ":" minute [ ":" second ]
|
||||
|
||||
hour = 2DIGIT / obs-hour
|
||||
|
||||
minute = 2DIGIT / obs-minute
|
||||
|
||||
second = 2DIGIT / obs-second
|
||||
|
||||
zone = (FWS ( "+" / "-" ) 4DIGIT) / obs-zone
|
||||
|
||||
The day is the numeric day of the month. The year is any numeric
|
||||
year 1900 or later.
|
||||
|
||||
The time-of-day specifies the number of hours, minutes, and
|
||||
optionally seconds since midnight of the date indicated.
|
||||
|
||||
The date and time-of-day SHOULD express local time.
|
||||
|
||||
The zone specifies the offset from Coordinated Universal Time (UTC,
|
||||
formerly referred to as "Greenwich Mean Time") that the date and
|
||||
time-of-day represent. The "+" or "-" indicates whether the time-of-
|
||||
day is ahead of (i.e., east of) or behind (i.e., west of) Universal
|
||||
Time. The first two digits indicate the number of hours difference
|
||||
from Universal Time, and the last two digits indicate the number of
|
||||
additional minutes difference from Universal Time. (Hence, +hhmm
|
||||
means +(hh * 60 + mm) minutes, and -hhmm means -(hh * 60 + mm)
|
||||
minutes). The form "+0000" SHOULD be used to indicate a time zone at
|
||||
Universal Time. Though "-0000" also indicates Universal Time, it is
|
||||
used to indicate that the time was generated on a system that may be
|
||||
in a local time zone other than Universal Time and that the date-time
|
||||
contains no information about the local time zone.
|
||||
|
||||
A date-time specification MUST be semantically valid. That is, the
|
||||
day-of-week (if included) MUST be the day implied by the date, the
|
||||
numeric day-of-month MUST be between 1 and the number of days allowed
|
||||
for the specified month (in the specified year), the time-of-day MUST
|
||||
be in the range 00:00:00 through 23:59:60 (the number of seconds
|
||||
allowing for a leap second; see [RFC1305]), and the last two digits
|
||||
of the zone MUST be within the range 00 through 59.
|
||||
*/
|
||||
if (dateText == null || dateText.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var reminder = dateText;
|
||||
final splitIndex = reminder.indexOf(',');
|
||||
if (splitIndex != -1) {
|
||||
// remove weekday
|
||||
reminder = reminder.substring(splitIndex + 1).trim();
|
||||
}
|
||||
var spaceIndex = reminder.indexOf(' ');
|
||||
if (spaceIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
final dayText = reminder.substring(0, spaceIndex);
|
||||
reminder = reminder.substring(spaceIndex + 1).trimLeft();
|
||||
spaceIndex = reminder.indexOf(' ');
|
||||
if (spaceIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
final monthText = reminder.substring(0, spaceIndex);
|
||||
reminder = reminder.substring(spaceIndex + 1).trimLeft();
|
||||
spaceIndex = reminder.indexOf(' ');
|
||||
// ignore: invariant_booleans
|
||||
if (spaceIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
final yearText = reminder.substring(0, spaceIndex);
|
||||
reminder = reminder.substring(spaceIndex + 1).trimLeft();
|
||||
spaceIndex = reminder.indexOf(' ');
|
||||
var timeText = reminder;
|
||||
var zoneText = '+0000';
|
||||
if (spaceIndex != -1) {
|
||||
timeText = reminder.substring(0, spaceIndex);
|
||||
if (reminder.length > spaceIndex) {
|
||||
reminder = reminder.substring(spaceIndex + 1).trim();
|
||||
spaceIndex = reminder.indexOf(' ');
|
||||
zoneText =
|
||||
spaceIndex == -1 ? reminder : reminder.substring(0, spaceIndex);
|
||||
}
|
||||
}
|
||||
final dayOfMonth = int.tryParse(dayText);
|
||||
if (dayOfMonth == null || dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
print('Invalid day $dayText in date $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
final month = _monthsByName[monthText.toLowerCase()];
|
||||
if (month == null) {
|
||||
print('Invalid month $monthText in date $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
final year = int.tryParse(yearText.length == 2 ? '20$yearText' : yearText);
|
||||
if (year == null) {
|
||||
print('Invalid year $yearText in date $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
final timeParts = timeText.split(':');
|
||||
if (timeParts.length < 2) {
|
||||
print('Invalid time $timeText in date $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
int? second = 0;
|
||||
final hour = int.tryParse(timeParts[0]);
|
||||
final minute = int.tryParse(timeParts[1]);
|
||||
if (timeParts.length > 2) {
|
||||
second = int.tryParse(timeParts[2]);
|
||||
}
|
||||
if (hour == null || minute == null || second == null) {
|
||||
print('Invalid time $timeText in date $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
if (zoneText.length != 5) {
|
||||
if (zoneText.length == 4 &&
|
||||
!(zoneText.startsWith('+') || zoneText.startsWith('-'))) {
|
||||
zoneText = '+$zoneText';
|
||||
} else {
|
||||
// source: https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations
|
||||
final zoneOffset = _timeZonesByName[zoneText];
|
||||
if (zoneOffset == null) {
|
||||
print('warning: invalid time zone [$zoneText] in $dateText');
|
||||
}
|
||||
zoneText = zoneOffset ?? '+0000';
|
||||
}
|
||||
}
|
||||
final timeZoneHours = int.tryParse(zoneText.substring(1, 3));
|
||||
final timeZoneMinutes = int.tryParse(zoneText.substring(3));
|
||||
if (timeZoneHours == null || timeZoneMinutes == null) {
|
||||
print('invalid time zone $zoneText in $dateText');
|
||||
|
||||
return null;
|
||||
}
|
||||
var dateTime = DateTime.utc(year, month, dayOfMonth, hour, minute, second);
|
||||
final isWesternTimeZone = zoneText.startsWith('+');
|
||||
final timeZoneDuration =
|
||||
Duration(hours: timeZoneHours, minutes: timeZoneMinutes);
|
||||
dateTime = isWesternTimeZone
|
||||
? dateTime.subtract(timeZoneDuration)
|
||||
: dateTime.add(timeZoneDuration);
|
||||
|
||||
return dateTime.toLocal();
|
||||
}
|
||||
// cSpell:enable
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
'Vá', // Hungarian
|
||||
'R', // Italian
|
||||
'RIF', // Italian
|
||||
'BLS', // Indonesian
|
||||
'RES', // Portuguese
|
||||
'Odp', // Polnish
|
||||
'YNT', // Turkish
|
||||
'ATB', // Welsh
|
||||
];
|
||||
|
||||
/// Common abbreviations in subject header for forwarded messages,
|
||||
/// compare https://en.wikipedia.org/wiki/List_of_email_subject_abbreviations
|
||||
static const List<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';
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user