mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api
This commit is contained in:
@@ -26,17 +26,17 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
|
|
||||||
### Sending / EmailSubmission
|
### Sending / EmailSubmission
|
||||||
|
|
||||||
- [ ] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — `P1` — `bug`
|
- [x] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — fixed in ccbe67c — `P1` — `bug`
|
||||||
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
|
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
|
||||||
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
|
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
|
||||||
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
|
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
|
||||||
|
|
||||||
- [ ] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — `P1` — `rn-only-bug`
|
- [x] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — fixed in 7f956d6 — `P1` — `rn-only-bug`
|
||||||
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
|
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
|
||||||
- What RN does: `src/screens/EmailThreadScreen.tsx:379` passes `inReplyTo: email.id` (the JMAP id, e.g. `a1b2c3`), never `references`; `src/api/email.ts:823-826` writes that value verbatim into `header:In-Reply-To:asText` and `header:References:asText`. `EMAIL_FULL_PROPERTIES` (`src/api/email.ts:11-15`) does not even request `messageId`/`references`, and the RN `Email` type (`src/api/types.ts:65-87`) has no such fields. Every RN reply therefore breaks threading in recipients' clients and emits a syntactically invalid `In-Reply-To`.
|
- What RN does: `src/screens/EmailThreadScreen.tsx:379` passes `inReplyTo: email.id` (the JMAP id, e.g. `a1b2c3`), never `references`; `src/api/email.ts:823-826` writes that value verbatim into `header:In-Reply-To:asText` and `header:References:asText`. `EMAIL_FULL_PROPERTIES` (`src/api/email.ts:11-15`) does not even request `messageId`/`references`, and the RN `Email` type (`src/api/types.ts:65-87`) has no such fields. Every RN reply therefore breaks threading in recipients' clients and emits a syntactically invalid `In-Reply-To`.
|
||||||
- Fix hint: add `'messageId', 'inReplyTo', 'references'` to `EMAIL_FULL_PROPERTIES` and the `Email` type, port `computeReplyThreadingHeaders`, and send `inReplyTo`/`references` as JMAP array properties (bare msg-ids) instead of raw `header:*:asText` strings. Only replies should continue the chain; forwards should not (WEB `email-composer.tsx:2142-2145`).
|
- Fix hint: add `'messageId', 'inReplyTo', 'references'` to `EMAIL_FULL_PROPERTIES` and the `Email` type, port `computeReplyThreadingHeaders`, and send `inReplyTo`/`references` as JMAP array properties (bare msg-ids) instead of raw `header:*:asText` strings. Only replies should continue the chain; forwards should not (WEB `email-composer.tsx:2142-2145`).
|
||||||
|
|
||||||
- [ ] **No request timeout — a stalled send hangs the composer forever** — `P2` — `bugfix-parity` (changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)")
|
- [x] **No request timeout — a stalled send hangs the composer forever** — fixed in ccbe67c — `P2` — `bugfix-parity` (changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)")
|
||||||
- What WEB does: `JMAPClient.REQUEST_TIMEOUT_MS = 30_000` (`lib/jmap/client.ts:629`, `:811`, `:830-843`) raising `RequestTimeoutError`; the composer shows a dedicated "may already have gone out, check Sent" message instead of "send failed" so users do not re-send duplicates (`email-composer.tsx:2315-2323`).
|
- What WEB does: `JMAPClient.REQUEST_TIMEOUT_MS = 30_000` (`lib/jmap/client.ts:629`, `:811`, `:830-843`) raising `RequestTimeoutError`; the composer shows a dedicated "may already have gone out, check Sent" message instead of "send failed" so users do not re-send duplicates (`email-composer.tsx:2315-2323`).
|
||||||
- What RN does: `jmapClient.request` (`src/api/jmap-client.ts:405-445`) uses `secureFetch` with no `AbortSignal`/deadline; `performSend` (`ComposeScreen.tsx:906-982`) leaves `sending=true` until the promise settles, so the Send button spins indefinitely on a stalled connection.
|
- What RN does: `jmapClient.request` (`src/api/jmap-client.ts:405-445`) uses `secureFetch` with no `AbortSignal`/deadline; `performSend` (`ComposeScreen.tsx:906-982`) leaves `sending=true` until the promise settles, so the Send button spins indefinitely on a stalled connection.
|
||||||
- Fix hint: wrap `secureFetch` in an `AbortController` + `setTimeout` (30 s) in `jmapClient.request` (or a `timeoutMs` option used by `sendEmail`), throw a distinguishable `RequestTimeoutError`, and show a "may already be sent - check Sent" alert in `performSend`'s catch.
|
- Fix hint: wrap `secureFetch` in an `AbortController` + `setTimeout` (30 s) in `jmapClient.request` (or a `timeoutMs` option used by `sendEmail`), throw a distinguishable `RequestTimeoutError`, and show a "may already be sent - check Sent" alert in `performSend`'s catch.
|
||||||
@@ -46,17 +46,17 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
- What RN does: `onSend` (`ComposeScreen.tsx:825-828`) applies `sendDelaySeconds` as HOLDFOR and `performSend` deliberately stays silent for the undo window (`:961-963` comment: "invisible unless the user cancels from the Scheduled view"). There is an `UndoSnackbar` component (`src/components/UndoSnackbar.tsx`) but it is not used for send. The user has to know to open the Scheduled screen within the 5-30 s window.
|
- What RN does: `onSend` (`ComposeScreen.tsx:825-828`) applies `sendDelaySeconds` as HOLDFOR and `performSend` deliberately stays silent for the undo window (`:961-963` comment: "invisible unless the user cancels from the Scheduled view"). There is an `UndoSnackbar` component (`src/components/UndoSnackbar.tsx`) but it is not used for send. The user has to know to open the Scheduled screen within the 5-30 s window.
|
||||||
- Fix hint: after `sendEmail` returns `{ scheduled: true, emailSubmissionId }` show `UndoSnackbar` for `sendDelaySeconds` with Undo → `cancelScheduledSend(id)` and "Send now" → EmailSubmission/set update of the envelope without HOLDFOR (or cancel + resend), mirroring `rescheduleEmailSubmission` in `lib/jmap/client.ts:7985`.
|
- Fix hint: after `sendEmail` returns `{ scheduled: true, emailSubmissionId }` show `UndoSnackbar` for `sendDelaySeconds` with Undo → `cancelScheduledSend(id)` and "Send now" → EmailSubmission/set update of the envelope without HOLDFOR (or cancel + resend), mirroring `rescheduleEmailSubmission` in `lib/jmap/client.ts:7985`.
|
||||||
|
|
||||||
- [ ] **Double-submit guard relies on async state** — `P3` — `bugfix-parity` (changelog 1.6.x "Guard Send against double-submit")
|
- [x] **Double-submit guard relies on async state** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog 1.6.x "Guard Send against double-submit")
|
||||||
- What WEB does: synchronous `isSendingRef` re-entry guard (`email-composer.tsx:1980-1986`, `:2007`, `:2078-2079`).
|
- What WEB does: synchronous `isSendingRef` re-entry guard (`email-composer.tsx:1980-1986`, `:2007`, `:2078-2079`).
|
||||||
- What RN does: `performSend` checks `canSend` which includes `!sending` (`ComposeScreen.tsx:508-516`, `:883`), but `setSending(true)` only happens after two awaits (`getHtml` at `:892`, attachment reminder at `:905`), so two quick taps both pass the guard and submit twice.
|
- What RN does: `performSend` checks `canSend` which includes `!sending` (`ComposeScreen.tsx:508-516`, `:883`), but `setSending(true)` only happens after two awaits (`getHtml` at `:892`, attachment reminder at `:905`), so two quick taps both pass the guard and submit twice.
|
||||||
- Fix hint: add a `sendingRef = useRef(false)` set synchronously at the top of `performSend`, reset in `finally`.
|
- Fix hint: add a `sendingRef = useRef(false)` set synchronously at the top of `performSend`, reset in `finally`.
|
||||||
|
|
||||||
- [ ] **Reply / forward does not mark the original `$answered` / `$forwarded`** — `P2` — `missing`
|
- [x] **Reply / forward does not mark the original `$answered` / `$forwarded`** — fixed in 7f956d6 — `P2` — `missing`
|
||||||
- What WEB does: after a successful reply/forward the original gets `$answered` or `$forwarded` (`components/mail/mail-app.tsx:1663-1673`), routed to the owning account.
|
- What WEB does: after a successful reply/forward the original gets `$answered` or `$forwarded` (`components/mail/mail-app.tsx:1663-1673`), routed to the owning account.
|
||||||
- What RN does: nothing — `grep -F '$answered'` over `src/` returns no hits; `performSend` (`ComposeScreen.tsx:941-974`) has no access to the original email id (`replyTo` params in `src/navigation/types.ts:10-19` carry no `emailId`).
|
- What RN does: nothing — `grep -F '$answered'` over `src/` returns no hits; `performSend` (`ComposeScreen.tsx:941-974`) has no access to the original email id (`replyTo` params in `src/navigation/types.ts:10-19` carry no `emailId`).
|
||||||
- Fix hint: add `originalEmailId`/`jmapAccountId` to the `replyTo` route param and call the existing keyword primitive (`setEmailKeywords` via `applyOrQueue` in `src/stores/outbox-store.ts`) after the send resolves.
|
- Fix hint: add `originalEmailId`/`jmapAccountId` to the `replyTo` route param and call the existing keyword primitive (`setEmailKeywords` via `applyOrQueue` in `src/stores/outbox-store.ts`) after the send resolves.
|
||||||
|
|
||||||
- [ ] **Identity `replyTo` / `bcc` are never applied to outgoing mail** — `P2` — `missing`
|
- [x] **Identity `replyTo` / `bcc` are never applied to outgoing mail** — fixed in 7f956d6 — `P2` — `missing`
|
||||||
- What WEB does: `sendEmail` copies the identity's `replyTo` onto the message (`lib/jmap/client.ts:3167-3172`, `:3184`); identity form lets users edit Reply-To and Bcc (`components/identity/identity-form.tsx:39-41`, `:66-70`, `:132-133`).
|
- What WEB does: `sendEmail` copies the identity's `replyTo` onto the message (`lib/jmap/client.ts:3167-3172`, `:3184`); identity form lets users edit Reply-To and Bcc (`components/identity/identity-form.tsx:39-41`, `:66-70`, `:132-133`).
|
||||||
- What RN does: RN `Identity` type has `replyTo`/`bcc` (`src/api/types.ts:136-145`) but `sendEmail` (`src/api/email.ts:788-796`) never sets `replyTo`, and no composer Reply-To field exists; `IdentitySettings.tsx:209-215` edits only name/email/textSignature.
|
- What RN does: RN `Identity` type has `replyTo`/`bcc` (`src/api/types.ts:136-145`) but `sendEmail` (`src/api/email.ts:788-796`) never sets `replyTo`, and no composer Reply-To field exists; `IdentitySettings.tsx:209-215` edits only name/email/textSignature.
|
||||||
- Fix hint: in `sendEmail` set `replyTo: identity.replyTo` and merge `identity.bcc` into `bcc`; pass the full `Identity` (not only `primaryIdentity.id`) from `performSend`.
|
- Fix hint: in `sendEmail` set `replyTo: identity.replyTo` and merge `identity.bcc` into `bcc`; pass the full `Identity` (not only `primaryIdentity.id`) from `performSend`.
|
||||||
@@ -66,7 +66,7 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
- What RN does: no toggle, no setting, header never set.
|
- What RN does: no toggle, no setting, header never set.
|
||||||
- Fix hint: add a toolbar toggle + `requestReadReceipt` option to `sendEmail` that writes `header:Disposition-Notification-To:asText`.
|
- Fix hint: add a toolbar toggle + `requestReadReceipt` option to `sendEmail` that writes `header:Disposition-Notification-To:asText`.
|
||||||
|
|
||||||
- [ ] **Client-side Message-ID not generated** — `P3` — `partial`
|
- [x] **Client-side Message-ID not generated** — fixed in ccbe67c — `P3` — `partial`
|
||||||
- What WEB does: `generateMessageId(fromEmail)` with the sender's domain, guarded for insecure origins (`lib/jmap/client.ts:544-575`, `:3192`; changelog 1.9.0 fix).
|
- What WEB does: `generateMessageId(fromEmail)` with the sender's domain, guarded for insecure origins (`lib/jmap/client.ts:544-575`, `:3192`; changelog 1.9.0 fix).
|
||||||
- What RN does: `sendEmail` sets no `messageId`, relying on the server; RN's `src/lib/uuid.ts:6-20` already has a guarded UUID generator (Hermes fallback) so the crash class of the web fix does not apply.
|
- What RN does: `sendEmail` sets no `messageId`, relying on the server; RN's `src/lib/uuid.ts:6-20` already has a guarded UUID generator (Hermes fallback) so the crash class of the web fix does not apply.
|
||||||
- Fix hint: optional — set `messageId: [`${generateUUID()}@${domain}`]` in `sendEmail` so the id is known before the response (useful once drafts/threading exist).
|
- Fix hint: optional — set `messageId: [`${generateUUID()}@${domain}`]` in `sendEmail` so the id is known before the response (useful once drafts/threading exist).
|
||||||
@@ -87,12 +87,12 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
- What RN does: only To and Cc (`ComposeScreen.tsx:346-349` state, `:1058-1149` UI); `sendEmail` accepts `bcc` (`src/api/email.ts:772`) but the screen never passes it.
|
- What RN does: only To and Cc (`ComposeScreen.tsx:346-349` state, `:1058-1149` UI); `sendEmail` accepts `bcc` (`src/api/email.ts:772`) but the screen never passes it.
|
||||||
- Fix hint: clone the Cc block (`ccRecipients`/`ccInput`/`ccVisible`) as Bcc, include it in `alreadySelected`, `commitTyped`, `isDirty`, and the `sendEmail` call.
|
- Fix hint: clone the Cc block (`ccRecipients`/`ccInput`/`ccVisible`) as Bcc, include it in `alreadySelected`, `commitTyped`, `isDirty`, and the `sendEmail` call.
|
||||||
|
|
||||||
- [ ] **Reply-To header ignored when replying** — `P2` — `bugfix-parity` (changelog 1.9.0 "Honour an external Reply-To even on a self-sent message"; 1.7.x #703)
|
- [x] **Reply-To header ignored when replying** — fixed in 7f956d6 — `P2` — `bugfix-parity` (changelog 1.9.0 "Honour an external Reply-To even on a self-sent message"; 1.7.x #703)
|
||||||
- What WEB does: `buildReplyRecipients` (`lib/reply-recipients.ts:74-111`) replies to `replyToAddresses` when present (RFC 5322), falls back to From, and handles self-sent messages (`isSelfSent`, `:52-54`) by addressing the original To/Cc instead of yourself.
|
- What WEB does: `buildReplyRecipients` (`lib/reply-recipients.ts:74-111`) replies to `replyToAddresses` when present (RFC 5322), falls back to From, and handles self-sent messages (`isSelfSent`, `:52-54`) by addressing the original To/Cc instead of yourself.
|
||||||
- What RN does: `initialTo` (`ComposeScreen.tsx:291-312`) always uses `replyTo.from`; `EmailThreadScreen.tsx:370-381` does not pass `email.replyTo` although it is fetched (`EMAIL_FULL_PROPERTIES` includes `replyTo`). Replying to a mailing-list/ticket mail with Reply-To goes to the wrong address; replying to your own sent message mails yourself.
|
- What RN does: `initialTo` (`ComposeScreen.tsx:291-312`) always uses `replyTo.from`; `EmailThreadScreen.tsx:370-381` does not pass `email.replyTo` although it is fetched (`EMAIL_FULL_PROPERTIES` includes `replyTo`). Replying to a mailing-list/ticket mail with Reply-To goes to the wrong address; replying to your own sent message mails yourself.
|
||||||
- Fix hint: pass `replyToAddresses: email.replyTo` in the route param and port `buildReplyRecipients` (pure TS, no DOM) into `src/lib/`.
|
- Fix hint: pass `replyToAddresses: email.replyTo` in the route param and port `buildReplyRecipients` (pure TS, no DOM) into `src/lib/`.
|
||||||
|
|
||||||
- [ ] **Reply-all does not drop the user's own identities / duplicates** — `P2` — `bugfix-parity`
|
- [x] **Reply-all does not drop the user's own identities / duplicates** — fixed in 7f956d6 — `P2` — `bugfix-parity`
|
||||||
- What WEB does: reply-all filters every own identity address (exact and `+tag`-stripped) out of To/Cc (`lib/reply-recipients.ts:39-45`, `:104-110`) and `expandRecipients` dedupes case-insensitively across the whole list (`lib/email-composer-utils.ts:425-437`).
|
- What WEB does: reply-all filters every own identity address (exact and `+tag`-stripped) out of To/Cc (`lib/reply-recipients.ts:39-45`, `:104-110`) and `expandRecipients` dedupes case-insensitively across the whole list (`lib/email-composer-utils.ts:425-437`).
|
||||||
- What RN does: `initialTo` (`ComposeScreen.tsx:304-310`) adds every original `to` except the From; `initialCc` (`:314-319`) copies `cc` unfiltered — the user's own address lands in To/Cc of every reply-all, and the same address can appear in both To and Cc (`alreadySelected` at `:371-374` only guards suggestions).
|
- What RN does: `initialTo` (`ComposeScreen.tsx:304-310`) adds every original `to` except the From; `initialCc` (`:314-319`) copies `cc` unfiltered — the user's own address lands in To/Cc of every reply-all, and the same address can appear in both To and Cc (`alreadySelected` at `:371-374` only guards suggestions).
|
||||||
- Fix hint: filter against the identities list (already loaded at `:406-419`, but note it loads asynchronously — compute recipients after identities arrive or filter at send time) and dedupe across To/Cc/Bcc in `commitTyped`.
|
- Fix hint: filter against the identities list (already loaded at `:406-419`, but note it loads asynchronously — compute recipients after identities arrive or filter at send time) and dedupe across To/Cc/Bcc in `commitTyped`.
|
||||||
@@ -163,7 +163,7 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
- What RN does: `EmailThreadScreen.tsx:59-65` `plainTextBody` returns the text part or `preview`, and `navigateCompose` passes only `body` (`:377`); `buildInitialHtml` supports `htmlBody` (`compose-html.ts:76-80`) but is never given one. Replies to HTML-only mail quote whatever `textBody[0]` holds (for HTML-only messages RFC 8621 exposes the HTML source there — raw tags in the quote, the #649 bug) and inline images vanish.
|
- What RN does: `EmailThreadScreen.tsx:59-65` `plainTextBody` returns the text part or `preview`, and `navigateCompose` passes only `body` (`:377`); `buildInitialHtml` supports `htmlBody` (`compose-html.ts:76-80`) but is never given one. Replies to HTML-only mail quote whatever `textBody[0]` holds (for HTML-only messages RFC 8621 exposes the HTML source there — raw tags in the quote, the #649 bug) and inline images vanish.
|
||||||
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`/`email-html.ts`) from the thread screen, port `getQuoteBodies`' MIME routing, and for `cid:` images fetch the blob (`getDownloadUrl` in `src/api/blob.ts:119`) as base64, insert with `data-cid`, and add the part as `disposition: 'inline'` on send (RN's `rewriteInlineImages` at `compose-html.ts:143-161` already handles `data-cid`).
|
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`/`email-html.ts`) from the thread screen, port `getQuoteBodies`' MIME routing, and for `cid:` images fetch the blob (`getDownloadUrl` in `src/api/blob.ts:119`) as base64, insert with `data-cid`, and add the part as `disposition: 'inline'` on send (RN's `rewriteInlineImages` at `compose-html.ts:143-161` already handles `data-cid`).
|
||||||
|
|
||||||
- [ ] **Forward drops the original attachments** — `P1` — `missing`
|
- [x] **Forward drops the original attachments** — fixed in 7f956d6 — `P1` — `missing`
|
||||||
- What WEB does: forward seeds the composer with the original's attachments as blobId refs (`email-composer.tsx:598-617`), skipping cid-embedded images, and sends them along (`:2200-2213`); "Forward as attachment" also exists (`lib/forward-as-attachment.ts`).
|
- What WEB does: forward seeds the composer with the original's attachments as blobId refs (`email-composer.tsx:598-617`), skipping cid-embedded images, and sends them along (`:2200-2213`); "Forward as attachment" also exists (`lib/forward-as-attachment.ts`).
|
||||||
- What RN does: `navigateCompose` (`EmailThreadScreen.tsx:366-382`) passes no attachments and `Compose` params have no attachment field; `attachments` state starts empty (`ComposeScreen.tsx:354`). A forwarded invoice arrives without the invoice, silently.
|
- What RN does: `navigateCompose` (`EmailThreadScreen.tsx:366-382`) passes no attachments and `Compose` params have no attachment field; `attachments` state starts empty (`ComposeScreen.tsx:354`). A forwarded invoice arrives without the invoice, silently.
|
||||||
- Fix hint: add `attachments: email.attachments` (blobId/name/type/size, filter `disposition==='inline'` images referenced by the body) to the route param and seed `AttachmentEntry`s with `blobId` set and `uploading:false`; `OutgoingAttachment` already accepts blobIds from another message in the same account.
|
- Fix hint: add `attachments: email.attachments` (blobId/name/type/size, filter `disposition==='inline'` images referenced by the body) to the route param and seed `AttachmentEntry`s with `blobId` set and `uploading:false`; `OutgoingAttachment` already accepts blobIds from another message in the same account.
|
||||||
@@ -173,7 +173,7 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
|||||||
- What RN does: `compose-html.ts:84-105` hardcodes "Forwarded message", "From/Date/Subject", "On …, X wrote:" and `senderName` (`:30-33`) returns name **or** email, never both. Locale files exist under `locales/<lang>/`.
|
- What RN does: `compose-html.ts:84-105` hardcodes "Forwarded message", "From/Date/Subject", "On …, X wrote:" and `senderName` (`:30-33`) returns name **or** email, never both. Locale files exist under `locales/<lang>/`.
|
||||||
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
|
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
|
||||||
|
|
||||||
- [ ] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
|
- [x] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
|
||||||
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
|
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
|
||||||
- What RN does: `ComposeScreen.tsx:321-328` only tests `^re:`/`^fwd?:`; "AW: foo" becomes "Re: AW: foo", "Fwd: foo" replied becomes "Re: Fwd: foo".
|
- What RN does: `ComposeScreen.tsx:321-328` only tests `^re:`/`^fwd?:`; "AW: foo" becomes "Re: AW: foo", "Fwd: foo" replied becomes "Re: Fwd: foo".
|
||||||
- Fix hint: copy `lib/subject-prefix.ts` verbatim (pure TS) and use the locale's prefix strings.
|
- Fix hint: copy `lib/subject-prefix.ts` verbatim (pure TS) and use the locale's prefix strings.
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
|
||||||
import { CAPABILITIES } from './types';
|
|
||||||
import type { EmailAddress } from './types';
|
|
||||||
|
|
||||||
export async function sendEmail(
|
|
||||||
email: {
|
|
||||||
from: EmailAddress[];
|
|
||||||
to: EmailAddress[];
|
|
||||||
cc?: EmailAddress[];
|
|
||||||
bcc?: EmailAddress[];
|
|
||||||
subject: string;
|
|
||||||
htmlBody?: string;
|
|
||||||
textBody?: string;
|
|
||||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
|
||||||
inReplyTo?: string;
|
|
||||||
references?: string;
|
|
||||||
},
|
|
||||||
identityId: string,
|
|
||||||
sentMailboxId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const accountId = jmapClient.accountId;
|
|
||||||
const emailCreate: Record<string, unknown> = {
|
|
||||||
from: email.from,
|
|
||||||
to: email.to,
|
|
||||||
cc: email.cc,
|
|
||||||
bcc: email.bcc,
|
|
||||||
subject: email.subject,
|
|
||||||
mailboxIds: { [sentMailboxId]: true },
|
|
||||||
keywords: { $seen: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (email.htmlBody) {
|
|
||||||
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
|
||||||
emailCreate.bodyValues = { html: { value: email.htmlBody } };
|
|
||||||
} else {
|
|
||||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
|
||||||
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email.attachments?.length) {
|
|
||||||
emailCreate.attachments = email.attachments.map((a) => ({
|
|
||||||
blobId: a.blobId,
|
|
||||||
type: a.type,
|
|
||||||
name: a.name,
|
|
||||||
disposition: 'attachment',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email.inReplyTo) {
|
|
||||||
emailCreate['header:In-Reply-To:asText'] = email.inReplyTo;
|
|
||||||
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
await jmapClient.request(
|
|
||||||
[
|
|
||||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
|
||||||
['EmailSubmission/set', {
|
|
||||||
accountId,
|
|
||||||
create: { 'sub-1': { emailId: '#draft', identityId } },
|
|
||||||
}, '1'],
|
|
||||||
],
|
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../compose-html';
|
import {
|
||||||
|
buildInitialHtml, htmlToPlainText, rewriteInlineImages, extractUserAuthoredText,
|
||||||
|
rewriteCidImagesForEditor, replaceInlineImagePlaceholders,
|
||||||
|
} from '../compose-html';
|
||||||
|
|
||||||
describe('buildInitialHtml', () => {
|
describe('buildInitialHtml', () => {
|
||||||
it('returns empty paragraph for plain compose', () => {
|
it('returns empty paragraph for plain compose', () => {
|
||||||
@@ -18,9 +21,9 @@ describe('buildInitialHtml', () => {
|
|||||||
receivedAt: '2026-04-27T10:00:00Z',
|
receivedAt: '2026-04-27T10:00:00Z',
|
||||||
});
|
});
|
||||||
expect(html).toContain('<p><br></p>');
|
expect(html).toContain('<p><br></p>');
|
||||||
// Default header labels the sender by name only (matches webmail #295).
|
// Header shows the full sender incl. address, escaped (webmail #482).
|
||||||
expect(html).toMatch(/On .+, Alice wrote:/);
|
expect(html).toMatch(/On .+, Alice <a@b.com> wrote:/);
|
||||||
expect(html).not.toContain('a@b.com');
|
expect(html).toContain('data-quoted-html');
|
||||||
expect(html).toContain('<blockquote');
|
expect(html).toContain('<blockquote');
|
||||||
expect(html).toContain('line one<br>line two');
|
expect(html).toContain('line one<br>line two');
|
||||||
});
|
});
|
||||||
@@ -52,7 +55,7 @@ describe('buildInitialHtml', () => {
|
|||||||
receivedAt: '2026-04-27T10:00:00Z',
|
receivedAt: '2026-04-27T10:00:00Z',
|
||||||
});
|
});
|
||||||
expect(html).toContain('Forwarded message');
|
expect(html).toContain('Forwarded message');
|
||||||
expect(html).toContain('From: Bob');
|
expect(html).toContain('From: Bob <b@c.com>');
|
||||||
expect(html).toContain('Subject: Original Subject');
|
expect(html).toContain('Subject: Original Subject');
|
||||||
expect(html).toContain('forwarded body');
|
expect(html).toContain('forwarded body');
|
||||||
});
|
});
|
||||||
@@ -133,3 +136,54 @@ describe('rewriteInlineImages', () => {
|
|||||||
expect(out.usedCids).toEqual(['dup']);
|
expect(out.usedCids).toEqual(['dup']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('htmlToPlainText links', () => {
|
||||||
|
it('renders a link as text <href>', () => {
|
||||||
|
expect(htmlToPlainText('<p>see <a href="https://x.y/z">docs</a></p>')).toBe('see docs <https://x.y/z>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a bare URL link as the URL only', () => {
|
||||||
|
expect(htmlToPlainText('<a href="https://x.y">https://x.y</a>')).toBe('https://x.y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the mailto: prefix when the text is the address', () => {
|
||||||
|
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractUserAuthoredText', () => {
|
||||||
|
it('drops the quoted island and blockquotes', () => {
|
||||||
|
const html = buildInitialHtml('reply', {
|
||||||
|
from: { name: 'Alice', email: 'a@b.com' },
|
||||||
|
body: 'please find attached the invoice',
|
||||||
|
receivedAt: '2026-04-27T10:00:00Z',
|
||||||
|
});
|
||||||
|
const text = extractUserAuthoredText('<p>thanks</p>' + html);
|
||||||
|
expect(text).toBe('thanks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts everything from the forwarded separator', () => {
|
||||||
|
const text = extractUserAuthoredText(
|
||||||
|
'<p>fyi</p><div>---------- Forwarded message ----------<br>From: x</div><p>attached</p>',
|
||||||
|
{ forwardedSeparator: '---------- Forwarded message ----------' },
|
||||||
|
);
|
||||||
|
expect(text.trim()).toBe('fyi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops quoted lines in plain-text mode', () => {
|
||||||
|
expect(extractUserAuthoredText('mine\n> theirs attached', { plainTextMode: true })).toBe('mine');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cid image hydration', () => {
|
||||||
|
it('rewrites cid: images to placeholders with data-cid and back to data URLs', () => {
|
||||||
|
const { html, cids } = rewriteCidImagesForEditor('<p><img src="cid:pic@x" alt="p"></p>');
|
||||||
|
expect(cids).toEqual(['pic@x']);
|
||||||
|
expect(html).toContain('data-cid="pic@x"');
|
||||||
|
expect(html).not.toContain('cid:pic@x');
|
||||||
|
const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', 'data:image/png;base64,AAA']]));
|
||||||
|
expect(hydrated).toContain('src="data:image/png;base64,AAA"');
|
||||||
|
expect(hydrated).toContain('data-cid="pic@x"');
|
||||||
|
expect(rewriteInlineImages(hydrated).html).toContain('src="cid:pic@x"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildQuoteHeader, formatQuoteSender } from '../quote-header';
|
||||||
|
|
||||||
|
describe('quote header', () => {
|
||||||
|
it('renders Name <email> escaped in the reply line', () => {
|
||||||
|
const h = buildQuoteHeader({
|
||||||
|
mode: 'reply',
|
||||||
|
email: { from: { name: 'Alice', email: 'a@b.com' }, receivedAt: '2026-04-27T10:00:00Z' },
|
||||||
|
timeFormat: '24h',
|
||||||
|
unknownLabel: 'Unknown',
|
||||||
|
});
|
||||||
|
expect(h.wrapInBlockquote).toBe(true);
|
||||||
|
expect(h.html).toMatch(/^<div>On .+, Alice <a@b\.com> wrote:<br><\/div>$/);
|
||||||
|
expect(h.text).toMatch(/Alice <a@b\.com> wrote:\n$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses localized labels', () => {
|
||||||
|
const h = buildQuoteHeader({
|
||||||
|
mode: 'forward',
|
||||||
|
email: { from: { email: 'a@b.com' }, subject: 'S' },
|
||||||
|
timeFormat: '24h',
|
||||||
|
unknownLabel: 'Unbekannt',
|
||||||
|
labels: {
|
||||||
|
replyLine: 'Am {date} schrieb {from}:',
|
||||||
|
forwardedSeparator: '---------- Weitergeleitete Nachricht ----------',
|
||||||
|
fromLabel: 'Von',
|
||||||
|
dateLabel: 'Datum',
|
||||||
|
subjectLabel: 'Betreff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(h.wrapInBlockquote).toBe(false);
|
||||||
|
expect(h.html).toContain('Weitergeleitete Nachricht');
|
||||||
|
expect(h.html).toContain('Von: a@b.com<br>Datum: <br>Betreff: S');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses the date when unknown', () => {
|
||||||
|
const h = buildQuoteHeader({ mode: 'reply', email: { from: { email: 'a@b.com' } }, timeFormat: '24h', unknownLabel: 'U' });
|
||||||
|
expect(h.html).toBe('<div>a@b.com wrote:<br></div>');
|
||||||
|
expect(formatQuoteSender(undefined, 'U')).toBe('U');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidEmail,
|
||||||
|
splitRecipients,
|
||||||
|
parseRecipient,
|
||||||
|
parseRecipientList,
|
||||||
|
formatRecipient,
|
||||||
|
expandRecipients,
|
||||||
|
splitPastedRecipients,
|
||||||
|
parseMailtoUrl,
|
||||||
|
} from '../recipients';
|
||||||
|
|
||||||
|
describe('isValidEmail', () => {
|
||||||
|
it('accepts ordinary addresses', () => {
|
||||||
|
expect(isValidEmail('a@b.co')).toBe(true);
|
||||||
|
expect(isValidEmail('first.last+tag@sub.example.org')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects injection and malformed input', () => {
|
||||||
|
expect(isValidEmail('')).toBe(false);
|
||||||
|
expect(isValidEmail('a@b')).toBe(true);
|
||||||
|
expect(isValidEmail('a@b..c')).toBe(false);
|
||||||
|
expect(isValidEmail('a@b.c\r\nBcc: x@y.z')).toBe(false);
|
||||||
|
expect(isValidEmail('<a@b.c>')).toBe(false);
|
||||||
|
expect(isValidEmail('noat')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitRecipients', () => {
|
||||||
|
it('splits on commas outside quotes and angle brackets', () => {
|
||||||
|
expect(splitRecipients('"Doe, John" <j@x.com>, b@y.com')).toEqual(['"Doe, John" <j@x.com>', 'b@y.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers from an unclosed angle bracket', () => {
|
||||||
|
expect(splitRecipients('John <j@x.com, b@y.com')).toEqual(['John <j@x.com', 'b@y.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps RFC 5322 groups as one entry', () => {
|
||||||
|
expect(splitRecipients('Team: a@x.com, b@y.com;, c@z.com')).toEqual(['Team: a@x.com, b@y.com;', 'c@z.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRecipient', () => {
|
||||||
|
it('parses Name <email> and unquotes the name', () => {
|
||||||
|
expect(parseRecipient('"Doe, John" <j@x.com>')).toEqual({ name: 'Doe, John', email: 'j@x.com' });
|
||||||
|
expect(parseRecipient('Jane <jane@x.com>')).toEqual({ name: 'Jane', email: 'jane@x.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a bare address', () => {
|
||||||
|
expect(parseRecipient(' a@b.com ')).toEqual({ email: 'a@b.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a group into member chips', () => {
|
||||||
|
const r = parseRecipient('Team: a@x.com, "B" <b@y.com>;');
|
||||||
|
expect(r.email).toBe('');
|
||||||
|
expect(r.name).toBe('Team');
|
||||||
|
expect(r.group?.members.map((m) => m.email)).toEqual(['a@x.com', 'b@y.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through formatRecipient / parseRecipientList', () => {
|
||||||
|
const list = parseRecipientList([formatRecipient('Doe, John', 'j@x.com'), formatRecipient(undefined, 'b@y.com')].join(', '));
|
||||||
|
expect(list).toEqual([{ name: 'Doe, John', email: 'j@x.com' }, { email: 'b@y.com' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expandRecipients', () => {
|
||||||
|
it('expands groups and dedupes case-insensitively', () => {
|
||||||
|
const out = expandRecipients([
|
||||||
|
{ name: 'A', email: 'a@x.com' },
|
||||||
|
{ name: 'Team', email: '', group: { members: [{ email: 'A@x.com' }, { name: 'B', email: 'b@y.com' }] } },
|
||||||
|
]);
|
||||||
|
expect(out).toEqual([{ name: 'A', email: 'a@x.com' }, { name: 'B', email: 'b@y.com' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitPastedRecipients', () => {
|
||||||
|
it('keeps display names and splits bare dumps', () => {
|
||||||
|
const { valid, invalid } = splitPastedRecipients('"Doe, John" <j@x.com>; b@y.com c@z.com\nnot-an-address');
|
||||||
|
expect(valid).toEqual([{ name: 'Doe, John', email: 'j@x.com' }, { email: 'b@y.com' }, { email: 'c@z.com' }]);
|
||||||
|
expect(invalid).toEqual(['not-an-address']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a name with a missing closing bracket', () => {
|
||||||
|
const { valid } = splitPastedRecipients('Jane <jane@x.com');
|
||||||
|
expect(valid).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips addresses already present', () => {
|
||||||
|
const { valid } = splitPastedRecipients('a@x.com, b@y.com', ['A@x.com']);
|
||||||
|
expect(valid).toEqual([{ email: 'b@y.com' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps a fully quoted recipient', () => {
|
||||||
|
const { valid } = splitPastedRecipients('"Jane <jane@x.com>"');
|
||||||
|
expect(valid).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMailtoUrl', () => {
|
||||||
|
it('parses recipients, subject and body', () => {
|
||||||
|
expect(parseMailtoUrl('mailto:a@b.com,c@d.com?subject=Hi%20there&body=line1%0Aline2&cc=e@f.com')).toEqual({
|
||||||
|
to: ['a@b.com', 'c@d.com'],
|
||||||
|
cc: ['e@f.com'],
|
||||||
|
bcc: undefined,
|
||||||
|
subject: 'Hi there',
|
||||||
|
body: 'line1\nline2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-mailto or empty', () => {
|
||||||
|
expect(parseMailtoUrl('https://x')).toBeNull();
|
||||||
|
expect(parseMailtoUrl('mailto:')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { findReplyIdentityId, findDraftIdentityId, resolveReplyFrom, findComposeIdentityId } from '../reply-identity';
|
||||||
|
import type { Identity } from '../../api/types';
|
||||||
|
|
||||||
|
const identities: Identity[] = [
|
||||||
|
{ id: 'main', name: 'Me', email: 'me@example.com', mayDelete: false },
|
||||||
|
{ id: 'alias', name: 'Alias', email: 'me@example.com', mayDelete: true },
|
||||||
|
{ id: 'info', name: 'Info', email: 'info@example.com', mayDelete: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('findReplyIdentityId', () => {
|
||||||
|
it('matches exact address across to/cc/bcc', () => {
|
||||||
|
expect(findReplyIdentityId(identities, { bcc: [{ email: 'INFO@example.com' }] })).toBe('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the +tag-stripped address', () => {
|
||||||
|
expect(findReplyIdentityId(identities, { to: [{ email: 'info+news@example.com' }] })).toBe('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing matches', () => {
|
||||||
|
expect(findReplyIdentityId(identities, { to: [{ email: 'x@other.com' }] })).toBeNull();
|
||||||
|
expect(findReplyIdentityId([], { to: [{ email: 'me@example.com' }] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findComposeIdentityId', () => {
|
||||||
|
it('matches the account address', () => {
|
||||||
|
expect(findComposeIdentityId(identities, 'info+x@example.com')).toBe('info');
|
||||||
|
expect(findComposeIdentityId(identities, undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDraftIdentityId', () => {
|
||||||
|
it('disambiguates by name when two identities share an address', () => {
|
||||||
|
expect(findDraftIdentityId(identities, { email: 'me@example.com', name: 'Alias' })).toBe('alias');
|
||||||
|
expect(findDraftIdentityId(identities, { email: 'me@example.com', name: 'Someone' })).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveReplyFrom', () => {
|
||||||
|
it('returns the identity without override on an exact match', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'info@example.com' }] })).toEqual({ identityId: 'info' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proposes a catch-all From override on an owned domain', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'sales@example.com', name: 'Sales' }] })).toEqual({
|
||||||
|
identityId: 'main',
|
||||||
|
overrideEmail: 'sales@example.com',
|
||||||
|
overrideName: 'Sales',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for foreign domains', () => {
|
||||||
|
expect(resolveReplyFrom(identities, { to: [{ email: 'x@other.com' }] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
sanitizeSignatureHtml,
|
||||||
|
getPlainTextSignature,
|
||||||
|
appendPlainTextSignature,
|
||||||
|
plainTextBodyHasSignature,
|
||||||
|
plainTextBodyWithoutSignature,
|
||||||
|
buildEmbeddedSignatureHtml,
|
||||||
|
containsEmbeddedSignature,
|
||||||
|
spliceSignature,
|
||||||
|
stripEmbeddedSignature,
|
||||||
|
insertSignatureAboveQuote,
|
||||||
|
} from '../signature-utils';
|
||||||
|
|
||||||
|
describe('sanitizeSignatureHtml', () => {
|
||||||
|
it('keeps formatting, links and https images', () => {
|
||||||
|
const out = sanitizeSignatureHtml('<p><b>Jane</b> <a href="https://x.y">site</a> <img src="https://x.y/l.png" alt="l"></p>');
|
||||||
|
expect(out).toBe('<p><b>Jane</b> <a href="https://x.y">site</a> <img src="https://x.y/l.png" alt="l"></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops scripts, event handlers, javascript: links and http images', () => {
|
||||||
|
const out = sanitizeSignatureHtml('<p onclick="x()">a<script>b()</script><a href="javascript:1">l</a><img src="http://x/l.png"></p>');
|
||||||
|
expect(out).toBe('<p>a<a>l</a></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops disallowed tags but keeps their text', () => {
|
||||||
|
expect(sanitizeSignatureHtml('<h1>Hi</h1><ul><li>x</li></ul>')).toBe('Hix');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plain-text signature helpers', () => {
|
||||||
|
const sig = { textSignature: 'Jane\r\nACME \n' };
|
||||||
|
|
||||||
|
it('normalizes line breaks', () => {
|
||||||
|
expect(getPlainTextSignature(sig)).toBe('Jane\nACME');
|
||||||
|
expect(getPlainTextSignature({ htmlSignature: '<p>Jane</p><p>ACME</p>' })).toBe('Jane\n\nACME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends with and without separator', () => {
|
||||||
|
expect(appendPlainTextSignature('hi', sig)).toBe('hi\n\n-- \nJane\nACME');
|
||||||
|
expect(appendPlainTextSignature('hi', sig, { separator: false })).toBe('hi\n\nJane\nACME');
|
||||||
|
expect(appendPlainTextSignature('hi', null)).toBe('hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects and strips a trailing signature', () => {
|
||||||
|
const body = 'hello\n\n-- \nJane\nACME';
|
||||||
|
expect(plainTextBodyHasSignature(body, sig)).toBe(true);
|
||||||
|
expect(plainTextBodyWithoutSignature(body, sig)).toBe('hello');
|
||||||
|
expect(plainTextBodyWithoutSignature('other', sig)).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedded signature range', () => {
|
||||||
|
const identity = { textSignature: 'Jane <jane@x.y>' };
|
||||||
|
|
||||||
|
it('builds a marked range with the separator', () => {
|
||||||
|
const html = buildEmbeddedSignatureHtml(identity, { separator: true });
|
||||||
|
expect(html).toContain('data-signature-block="separator">-- </p>');
|
||||||
|
expect(html).toContain('Jane <jane@x.y>');
|
||||||
|
expect(html).toContain('data-signature-block="end"');
|
||||||
|
expect(containsEmbeddedSignature(html)).toBe(true);
|
||||||
|
expect(buildEmbeddedSignatureHtml({}, { separator: true })).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps the range on identity change and strips it', () => {
|
||||||
|
const a = buildEmbeddedSignatureHtml({ textSignature: 'A' }, { separator: true });
|
||||||
|
const b = buildEmbeddedSignatureHtml({ htmlSignature: '<b>B</b>' }, { separator: false });
|
||||||
|
const body = `<p>hello</p>${a}<div data-quoted-html="true">q</div>`;
|
||||||
|
const swapped = spliceSignature(body, b);
|
||||||
|
expect(swapped).toBe(`<p>hello</p>${b}<div data-quoted-html="true">q</div>`);
|
||||||
|
expect(stripEmbeddedSignature(swapped)).toBe('<p>hello</p><div data-quoted-html="true">q</div>');
|
||||||
|
expect(spliceSignature('<p>x</p>', a)).toBe(`<p>x</p>${a}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts above the quote marker', () => {
|
||||||
|
const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true });
|
||||||
|
const marker = '<div data-quoted-html="true">';
|
||||||
|
expect(insertSignatureAboveQuote(`<p><br></p>${marker}q</div>`, sig, marker)).toBe(`<p><br></p>${sig}${marker}q</div>`);
|
||||||
|
expect(insertSignatureAboveQuote('<p>x</p>', sig, marker)).toBe(`<p>x</p>${sig}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseSubAddress,
|
||||||
|
generateSubAddress,
|
||||||
|
suggestTagsForDomain,
|
||||||
|
getTagValidationError,
|
||||||
|
isValidSubAddressDelimiter,
|
||||||
|
} from '../sub-addressing';
|
||||||
|
|
||||||
|
describe('sub-addressing', () => {
|
||||||
|
it('parses the tag after the first delimiter', () => {
|
||||||
|
expect(parseSubAddress('user+shop+x@example.com')).toMatchObject({ baseUser: 'user', tag: 'shop+x', domain: 'example.com' });
|
||||||
|
expect(parseSubAddress('user@example.com').tag).toBeNull();
|
||||||
|
expect(parseSubAddress('user-shop@example.com', '-').tag).toBe('shop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates and replaces tags', () => {
|
||||||
|
expect(generateSubAddress('user@example.com', 'Shopping')).toBe('user+shopping@example.com');
|
||||||
|
expect(generateSubAddress('user+old@example.com', 'new')).toBe('user+new@example.com');
|
||||||
|
expect(generateSubAddress('user@example.com', '!!!')).toBe('user@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests tags for known and generic domains', () => {
|
||||||
|
expect(suggestTagsForDomain('amazon.de')).toEqual(['amazon', 'shopping', 'orders']);
|
||||||
|
expect(suggestTagsForDomain('mail.acme.io')).toEqual(['acme', 'newsletter', 'registration']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates tags and delimiters', () => {
|
||||||
|
expect(getTagValidationError('')).toBe('EMPTY');
|
||||||
|
expect(getTagValidationError('a'.repeat(31))).toBe('TOO_LONG');
|
||||||
|
expect(getTagValidationError('a b')).toBe('INVALID_CHARS');
|
||||||
|
expect(getTagValidationError('ok-1')).toBeNull();
|
||||||
|
expect(isValidSubAddressDelimiter('+')).toBe(true);
|
||||||
|
expect(isValidSubAddressDelimiter('@')).toBe(false);
|
||||||
|
expect(isValidSubAddressDelimiter('++')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
extractPlaceholders,
|
||||||
|
substitutePlaceholders,
|
||||||
|
hasUnresolvedPlaceholders,
|
||||||
|
getAutoFilledPlaceholders,
|
||||||
|
exportTemplates,
|
||||||
|
importTemplates,
|
||||||
|
templateBodyToHtml,
|
||||||
|
validateTemplateName,
|
||||||
|
} from '../template-utils';
|
||||||
|
|
||||||
|
describe('placeholders', () => {
|
||||||
|
it('extracts unique placeholder names', () => {
|
||||||
|
expect(extractPlaceholders('Hi {{recipient_name}}, {{date}} {{recipient_name}}')).toEqual(['recipient_name', 'date']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('substitutes known values and strips tags from them', () => {
|
||||||
|
expect(substitutePlaceholders('Hi {{a}} {{b}}', { a: '<b>x</b>' })).toBe('Hi x {{b}}');
|
||||||
|
expect(hasUnresolvedPlaceholders('Hi {{b}}')).toBe(true);
|
||||||
|
expect(hasUnresolvedPlaceholders('Hi')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-fills date, weekday and sender', () => {
|
||||||
|
const v = getAutoFilledPlaceholders({ senderName: 'Jane', locale: 'en' });
|
||||||
|
expect(v.sender_name).toBe('Jane');
|
||||||
|
expect(v.date.length).toBeGreaterThan(0);
|
||||||
|
expect(v.day_of_week.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates names', () => {
|
||||||
|
expect(validateTemplateName(' ')).toBe('empty');
|
||||||
|
expect(validateTemplateName('x'.repeat(201))).toBe('too_long');
|
||||||
|
expect(validateTemplateName('ok')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('export / import', () => {
|
||||||
|
const tpl = {
|
||||||
|
id: 't1', name: 'Hello', subject: 'S', body: 'B', category: 'c', isFavorite: true,
|
||||||
|
isHTML: false, defaultRecipients: { to: ['a@b.c'] }, identityId: 'id1',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('emits the webmail envelope', () => {
|
||||||
|
const parsed = JSON.parse(exportTemplates([tpl]));
|
||||||
|
expect(parsed.type).toBe('webmail-templates');
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
expect(typeof parsed.exportedAt).toBe('string');
|
||||||
|
expect(parsed.templates[0].defaultRecipients).toEqual({ to: ['a@b.c'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips isHTML / defaultRecipients / identityId with fresh ids', () => {
|
||||||
|
const { templates, errors } = importTemplates(exportTemplates([tpl]));
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0].id).not.toBe('t1');
|
||||||
|
expect(templates[0]).toMatchObject({ name: 'Hello', isHTML: false, identityId: 'id1', defaultRecipients: { to: ['a@b.c'] }, isFavorite: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects foreign envelopes and bad JSON', () => {
|
||||||
|
expect(importTemplates('{"type":"other","templates":[]}').errors).toEqual(['invalid_type']);
|
||||||
|
expect(importTemplates('nope').errors).toEqual(['invalid_json']);
|
||||||
|
expect(importTemplates('{"templates":[{"body":"x"}]}').errors).toEqual(['missing_template_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the legacy RN export without a type', () => {
|
||||||
|
expect(importTemplates('{"version":1,"templates":[{"name":"n","body":"b"}]}').templates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes plain-text bodies for the editor', () => {
|
||||||
|
expect(templateBodyToHtml({ body: 'a<b\nc', isHTML: false })).toBe('<p>a<b<br>c</p>');
|
||||||
|
expect(templateBodyToHtml({ body: '<p>x</p>', isHTML: true })).toBe('<p>x</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
+188
-54
@@ -3,6 +3,7 @@
|
|||||||
// All values are HTML-escaped before insertion into the document.
|
// All values are HTML-escaped before insertion into the document.
|
||||||
|
|
||||||
import { escapeHtml, stripDangerousTags } from './email-html';
|
import { escapeHtml, stripDangerousTags } from './email-html';
|
||||||
|
import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header';
|
||||||
import type { TimeFormat } from '../stores/settings-store';
|
import type { TimeFormat } from '../stores/settings-store';
|
||||||
|
|
||||||
export interface ReplyMeta {
|
export interface ReplyMeta {
|
||||||
@@ -19,39 +20,23 @@ export interface QuoteHeaderOptions {
|
|||||||
timeFormat?: TimeFormat;
|
timeFormat?: TimeFormat;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
unknownLabel?: string;
|
unknownLabel?: string;
|
||||||
|
labels?: QuoteHeaderLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeForHtmlBody(text: string): string {
|
function escapeForHtmlBody(text: string): string {
|
||||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Webmail's default quote header labels the sender by display name when there
|
|
||||||
// is one, falling back to the bare address — not "Name <addr>".
|
|
||||||
function senderName(from?: { email?: string; name?: string }, unknownLabel = 'Unknown'): string {
|
|
||||||
if (!from) return unknownLabel;
|
|
||||||
return from.name || from.email || unknownLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror the webmail quote-header date: locale- and 12/24h-aware, with a short
|
|
||||||
// weekday (lib/quote-header.ts → formatDateTime with weekday/year/month/day).
|
|
||||||
function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
|
||||||
return d.toLocaleString(intlLocale, {
|
|
||||||
weekday: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: timeFormat === '12h',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
|
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker attribute on the wrapper that holds the quoted original (header +
|
||||||
|
* body). The signature splice uses it to place the signature above the quote,
|
||||||
|
* and the attachment reminder strips it before keyword matching (#570).
|
||||||
|
*/
|
||||||
|
export const QUOTED_BLOCK_ATTR = 'data-quoted-html';
|
||||||
|
export const QUOTED_BLOCK_START = `<div ${QUOTED_BLOCK_ATTR}="true">`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||||
* paragraph for plain compose (so the contenteditable has a caret target).
|
* paragraph for plain compose (so the contenteditable has a caret target).
|
||||||
@@ -59,7 +44,8 @@ const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-
|
|||||||
* The reply/forward quote header mirrors the webmail default header
|
* The reply/forward quote header mirrors the webmail default header
|
||||||
* (`lib/quote-header.ts`): a reply gets "On <date>, <sender> wrote:" above a
|
* (`lib/quote-header.ts`): a reply gets "On <date>, <sender> wrote:" above a
|
||||||
* blockquoted body; a forward gets a From/Date/Subject block followed by the
|
* blockquoted body; a forward gets a From/Date/Subject block followed by the
|
||||||
* original body (not blockquoted).
|
* original body (not blockquoted). The quoted part is wrapped in a
|
||||||
|
* `data-quoted-html` div.
|
||||||
*/
|
*/
|
||||||
export function buildInitialHtml(
|
export function buildInitialHtml(
|
||||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
||||||
@@ -68,10 +54,7 @@ export function buildInitialHtml(
|
|||||||
): string {
|
): string {
|
||||||
if (!reply || mode === 'compose') return '<p><br></p>';
|
if (!reply || mode === 'compose') return '<p><br></p>';
|
||||||
|
|
||||||
const { timeFormat = '24h', locale, unknownLabel = 'Unknown' } = opts;
|
const { timeFormat = '24h', locale, unknownLabel = 'Unknown', labels } = opts;
|
||||||
const date = formatQuoteDate(reply.receivedAt, timeFormat, locale);
|
|
||||||
const fromStr = senderName(reply.from, unknownLabel);
|
|
||||||
const subject = reply.subject ?? '';
|
|
||||||
|
|
||||||
const quotedBody = reply.htmlBody
|
const quotedBody = reply.htmlBody
|
||||||
? stripDangerousTags(reply.htmlBody)
|
? stripDangerousTags(reply.htmlBody)
|
||||||
@@ -81,59 +64,144 @@ export function buildInitialHtml(
|
|||||||
|
|
||||||
if (!quotedBody) return '<p><br></p>';
|
if (!quotedBody) return '<p><br></p>';
|
||||||
|
|
||||||
if (mode === 'forward') {
|
const header = buildQuoteHeader({
|
||||||
// Forwarded message block (From/Date/Subject), body appended unquoted.
|
mode,
|
||||||
return [
|
email: { from: reply.from, subject: reply.subject, receivedAt: reply.receivedAt },
|
||||||
'<p><br></p>',
|
timeFormat,
|
||||||
'<div>---------- Forwarded message ----------<br>',
|
locale,
|
||||||
`From: ${escapeHtml(fromStr)}<br>`,
|
unknownLabel,
|
||||||
`Date: ${escapeHtml(date)}<br>`,
|
labels,
|
||||||
`Subject: ${escapeHtml(subject)}<br><br>`,
|
});
|
||||||
'</div>',
|
|
||||||
quotedBody,
|
|
||||||
].join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// reply / replyAll
|
const quoted = header.wrapInBlockquote
|
||||||
const headerLine = date
|
? `${header.html}<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`
|
||||||
? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:`
|
: `${header.html}${quotedBody}`;
|
||||||
: `${escapeHtml(fromStr)} wrote:`;
|
|
||||||
return [
|
return `<p><br></p>${QUOTED_BLOCK_START}${quoted}</div>`;
|
||||||
'<p><br></p>',
|
|
||||||
`<div>${headerLine}<br></div>`,
|
|
||||||
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
|
|
||||||
].join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip HTML tags and decode common entities for a plain-text fallback body.
|
* Strip HTML tags and decode common entities for a plain-text fallback body.
|
||||||
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
|
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
|
||||||
* have DOMParser in the RN runtime.
|
* have DOMParser in the RN runtime. Links keep their target as `text <href>`
|
||||||
|
* so a pasted link survives the text/plain alternative.
|
||||||
*/
|
*/
|
||||||
export function htmlToPlainText(html: string): string {
|
export function htmlToPlainText(html: string): string {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
let s = html;
|
let s = html;
|
||||||
|
// Drop non-content blocks entirely.
|
||||||
|
s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||||
|
// Links: "text <href>" unless the text already is the URL (or mailto target).
|
||||||
|
s = s.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_full, attrs: string, inner: string) => {
|
||||||
|
const hrefMatch = attrs.match(/\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
|
const href = (hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? '').trim();
|
||||||
|
const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!href) return inner;
|
||||||
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||||
|
if (!text || text === href || text === normalizedHref) return text || href;
|
||||||
|
// Angle brackets are restored after the tag strip below.
|
||||||
|
return `${text} \u0001${href}\u0002`;
|
||||||
|
});
|
||||||
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
||||||
s = s.replace(/<br\s*\/?>/gi, '\n');
|
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||||||
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
||||||
// newline so they stack tightly.
|
// newline so they stack tightly.
|
||||||
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
||||||
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
||||||
|
s = s.replace(/<\/(td|th)>/gi, '\t');
|
||||||
s = s.replace(/<li[^>]*>/gi, '• ');
|
s = s.replace(/<li[^>]*>/gi, '• ');
|
||||||
s = s.replace(/<[^>]+>/g, '');
|
s = s.replace(/<[^>]+>/g, '');
|
||||||
|
s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>');
|
||||||
// Decode the most common entities; leave the rest as-is.
|
// Decode the most common entities; leave the rest as-is.
|
||||||
s = s
|
s = s
|
||||||
.replace(/ /gi, ' ')
|
.replace(/ /gi, ' ')
|
||||||
.replace(/&/gi, '&')
|
|
||||||
.replace(/</gi, '<')
|
.replace(/</gi, '<')
|
||||||
.replace(/>/gi, '>')
|
.replace(/>/gi, '>')
|
||||||
.replace(/"/gi, '"')
|
.replace(/"/gi, '"')
|
||||||
.replace(/'/gi, "'");
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/&/gi, '&');
|
||||||
// Collapse runs of >2 newlines and trim trailing whitespace per line.
|
// Collapse runs of >2 newlines and trim trailing whitespace per line.
|
||||||
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
|
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
|
||||||
return s.trim();
|
return s.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a balanced `<tag …>…</tag>` block (nesting-aware) for every
|
||||||
|
* occurrence in `html`. Used to drop quoted content before keyword matching.
|
||||||
|
*/
|
||||||
|
function removeBalancedBlocks(html: string, openRe: RegExp, tag: string): string {
|
||||||
|
let out = html;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
const open = new RegExp(openRe.source, 'i');
|
||||||
|
// eslint-disable-next-line no-cond-assign
|
||||||
|
while ((m = open.exec(out)) !== null) {
|
||||||
|
const start = m.index;
|
||||||
|
let depth = 0;
|
||||||
|
let i = start;
|
||||||
|
const tokenRe = new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi');
|
||||||
|
tokenRe.lastIndex = start;
|
||||||
|
let end = -1;
|
||||||
|
let tok: RegExpExecArray | null;
|
||||||
|
// eslint-disable-next-line no-cond-assign
|
||||||
|
while ((tok = tokenRe.exec(out)) !== null) {
|
||||||
|
i = tok.index;
|
||||||
|
if (tok[0].startsWith('</')) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
end = i + tok[0].length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end === -1) {
|
||||||
|
out = out.slice(0, start);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out = out.slice(0, start) + out.slice(end);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text the user actually wrote: the body minus the quoted original
|
||||||
|
* (`data-quoted-html` island / blockquotes) and everything from the forwarded
|
||||||
|
* separator onward. Port of the webmail's `extractUserAuthoredText` (#570).
|
||||||
|
*/
|
||||||
|
export function extractUserAuthoredText(
|
||||||
|
body: string,
|
||||||
|
options: { plainTextMode?: boolean; forwardedSeparator?: string } = {},
|
||||||
|
): string {
|
||||||
|
const { plainTextMode = false, forwardedSeparator } = options;
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
if (plainTextMode) {
|
||||||
|
text = body
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !/^\s*>/.test(line))
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
let html = removeBalancedBlocks(body, new RegExp(`<div\\b[^>]*\\s${QUOTED_BLOCK_ATTR}\\b`), 'div');
|
||||||
|
html = removeBalancedBlocks(html, /<blockquote\b/, 'blockquote');
|
||||||
|
text = htmlToPlainText(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSeparator = forwardedSeparator?.trim();
|
||||||
|
if (trimmedSeparator) {
|
||||||
|
const pattern = trimmedSeparator
|
||||||
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\s+/g, '\\s+');
|
||||||
|
const match = text.match(new RegExp(pattern));
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
text = text.slice(0, match.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
||||||
* (and dropping the data-cid attribute). Returns the rewritten HTML and the
|
* (and dropping the data-cid attribute). Returns the rewritten HTML and the
|
||||||
@@ -159,3 +227,69 @@ export function rewriteInlineImages(html: string): { html: string; usedCids: str
|
|||||||
);
|
);
|
||||||
return { html: out, usedCids: Array.from(used) };
|
return { html: out, usedCids: Array.from(used) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
||||||
|
// being fetched from JMAP (webmail #163).
|
||||||
|
export const INLINE_IMAGE_PLACEHOLDER =
|
||||||
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites `<img src="cid:xxx">` references into
|
||||||
|
* `<img src="<placeholder>" data-cid="xxx">` so the editor can render the
|
||||||
|
* quoted original (a `cid:` URL would show a broken image) while still
|
||||||
|
* carrying the cid through edits. Returns the cids found.
|
||||||
|
*/
|
||||||
|
export function rewriteCidImagesForEditor(html: string): { html: string; cids: string[] } {
|
||||||
|
if (!html || html.indexOf('cid:') === -1) return { html, cids: [] };
|
||||||
|
const cids = new Set<string>();
|
||||||
|
const out = html.replace(
|
||||||
|
/<img\b([^>]*?)\ssrc=("cid:([^"]*)"|'cid:([^']*)')([^>]*)>/gi,
|
||||||
|
(_full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||||
|
const cid = (dq ?? sq ?? '').trim();
|
||||||
|
if (!cid) return _full;
|
||||||
|
cids.add(cid);
|
||||||
|
const rest = (before + after).replace(/\sdata-cid=("[^"]*"|'[^']*')/gi, '').trim();
|
||||||
|
return `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { html: out, cids: Array.from(cids) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the placeholder src on `<img data-cid="...">` elements with the
|
||||||
|
* resolved data URL once the inline blob has been fetched.
|
||||||
|
*/
|
||||||
|
export function replaceInlineImagePlaceholders(
|
||||||
|
html: string,
|
||||||
|
cidToDataUrl: Map<string, string>,
|
||||||
|
): string {
|
||||||
|
if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html;
|
||||||
|
return html.replace(
|
||||||
|
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
||||||
|
(full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||||
|
const cid = dq ?? sq ?? '';
|
||||||
|
const dataUrl = cidToDataUrl.get(cid);
|
||||||
|
if (!dataUrl) return full;
|
||||||
|
const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim();
|
||||||
|
return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sniffs the real image MIME type from a blob's leading magic bytes, returning
|
||||||
|
* null when the bytes aren't a recognizable image (webmail #543).
|
||||||
|
*/
|
||||||
|
export function sniffImageMime(bytes: Uint8Array): string | null {
|
||||||
|
if (!bytes || bytes.length < 4) return null;
|
||||||
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
||||||
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif';
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||||
|
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||||||
|
) return 'image/webp';
|
||||||
|
if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Default reply/forward quote header. Port of the webmail's lib/quote-header.ts
|
||||||
|
// (without the plugin transform, which has no RN runtime).
|
||||||
|
//
|
||||||
|
// Both the forward "From:" line and the reply "On … wrote:" line show the
|
||||||
|
// full sender incl. address ("Name <email>"), like Gmail/Outlook (#482). The
|
||||||
|
// interpolated values are HTML-escaped: an unescaped "<email>" would be parsed
|
||||||
|
// as a tag by the editor and silently dropped.
|
||||||
|
|
||||||
|
import { escapeHtml } from './email-html';
|
||||||
|
import type { TimeFormat } from '../stores/settings-store';
|
||||||
|
|
||||||
|
/** Localized label set the caller passes in (from the locale catalog). */
|
||||||
|
export interface QuoteHeaderLabels {
|
||||||
|
/** Reply line template with `{date}` and `{from}` placeholders. */
|
||||||
|
replyLine: string;
|
||||||
|
forwardedSeparator: string;
|
||||||
|
fromLabel: string;
|
||||||
|
dateLabel: string;
|
||||||
|
subjectLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_QUOTE_HEADER_LABELS: QuoteHeaderLabels = {
|
||||||
|
replyLine: 'On {date}, {from} wrote:',
|
||||||
|
forwardedSeparator: '---------- Forwarded message ----------',
|
||||||
|
fromLabel: 'From',
|
||||||
|
dateLabel: 'Date',
|
||||||
|
subjectLabel: 'Subject',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface QuoteHeader {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
wrapInBlockquote: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteHeaderArgs {
|
||||||
|
mode: 'reply' | 'replyAll' | 'forward';
|
||||||
|
email: {
|
||||||
|
from?: { email?: string; name?: string };
|
||||||
|
subject?: string;
|
||||||
|
receivedAt?: string;
|
||||||
|
};
|
||||||
|
timeFormat: TimeFormat;
|
||||||
|
locale?: string;
|
||||||
|
unknownLabel: string;
|
||||||
|
labels?: QuoteHeaderLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale- and 12/24h-aware date with a short weekday (webmail formatDateTime). */
|
||||||
|
export function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
||||||
|
try {
|
||||||
|
return d.toLocaleString(intlLocale, {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: timeFormat === '12h',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Name <email>" when both are known and distinct, else whichever exists. */
|
||||||
|
export function formatQuoteSender(
|
||||||
|
from: { email?: string; name?: string } | undefined,
|
||||||
|
unknownLabel: string,
|
||||||
|
): string {
|
||||||
|
if (!from) return unknownLabel;
|
||||||
|
if (from.name && from.email && from.name !== from.email) return `${from.name} <${from.email}>`;
|
||||||
|
return from.email || from.name || unknownLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillReplyLine(template: string, vars: { date: string; from: string }): string {
|
||||||
|
const line = template.replace('{date}', vars.date).replace('{from}', vars.from).replace('{sender}', vars.from);
|
||||||
|
// A missing date leaves "On , X wrote:" - collapse the dangling comma.
|
||||||
|
return vars.date ? line : line.replace(/^\S+\s*,\s*/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQuoteHeader(args: QuoteHeaderArgs): QuoteHeader {
|
||||||
|
const { mode, email, timeFormat, locale, unknownLabel } = args;
|
||||||
|
const labels = args.labels ?? DEFAULT_QUOTE_HEADER_LABELS;
|
||||||
|
const date = formatQuoteDate(email.receivedAt, timeFormat, locale);
|
||||||
|
const fromStrFull = formatQuoteSender(email.from, unknownLabel);
|
||||||
|
const subject = email.subject || '';
|
||||||
|
|
||||||
|
if (mode === 'forward') {
|
||||||
|
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||||
|
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
|
||||||
|
return { html, text, wrapInBlockquote: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `${fillReplyLine(labels.replyLine, { date, from: fromStrFull })}\n`;
|
||||||
|
const html = `<div>${fillReplyLine(labels.replyLine, { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></div>`;
|
||||||
|
return { html, text, wrapInBlockquote: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// Recipient parsing / validation for the composer. Port of the webmail's
|
||||||
|
// lib/validation.ts (isValidEmail, parseMailtoUrl) and the recipient helpers
|
||||||
|
// in lib/email-composer-utils.ts (splitRecipients, parseRecipient,
|
||||||
|
// splitPastedRecipients, expandRecipients). All DOM-free.
|
||||||
|
|
||||||
|
import { splitMailbox } from './rfc5322-mailbox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composer recipient. Display name is optional; email is required - except
|
||||||
|
* for contact-group chips, which carry their already-resolved members and an
|
||||||
|
* empty email. Group chips are expanded into their members when the message
|
||||||
|
* is sent or saved as a draft (see {@link expandRecipients}).
|
||||||
|
*/
|
||||||
|
export interface Recipient {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
group?: { members: Array<{ name?: string; email: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5322 compliant email validation with security enhancements.
|
||||||
|
*/
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
// Length check
|
||||||
|
if (!email || email.length > 254) return false;
|
||||||
|
|
||||||
|
// Security: Block control characters and header injection
|
||||||
|
if (/[\r\n\0<>]/.test(email)) return false;
|
||||||
|
|
||||||
|
// RFC 5322 compliant regex (simplified but secure)
|
||||||
|
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
|
if (!emailRegex.test(email)) return false;
|
||||||
|
|
||||||
|
// Additional checks
|
||||||
|
const [localPart, domain] = email.split('@');
|
||||||
|
|
||||||
|
// Local part max 64 chars
|
||||||
|
if (localPart.length > 64) return false;
|
||||||
|
|
||||||
|
// Domain validation
|
||||||
|
if (domain.length > 255) return false;
|
||||||
|
if (domain.startsWith('.') || domain.endsWith('.')) return false;
|
||||||
|
if (domain.includes('..')) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the angle run that is open at `from` closes before the next one
|
||||||
|
* opens. `from` is the index of the character under test (a separator, or the
|
||||||
|
* opening `<` itself); the scan starts after it either way. A `>` inside a
|
||||||
|
* quoted display name does not close anything, and a `<` with no `>` of its own
|
||||||
|
* (or whose `>` sits past a later `<`) can never be an address delimiter, so
|
||||||
|
* separators after it must stay separators.
|
||||||
|
*/
|
||||||
|
function angleRunCloses(value: string, from: number): boolean {
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = from + 1; i < value.length; i++) {
|
||||||
|
const ch = value[i];
|
||||||
|
if (ch === '"') inQuotes = !inQuotes;
|
||||||
|
else if (inQuotes) continue;
|
||||||
|
else if (ch === '>') return true;
|
||||||
|
else if (ch === '<') return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a recipient string into individual entries on any character in
|
||||||
|
* `separators`, treating those characters as literal when they sit inside a
|
||||||
|
* quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
|
||||||
|
* (`<a,b@x>`). Trims each part and drops empties.
|
||||||
|
*
|
||||||
|
* Defaults to comma-only, the (de)serialization boundary used by the composer
|
||||||
|
* state and mailto handling. Pasted lists pass a wider set (see
|
||||||
|
* {@link splitPastedRecipients}) because they also use `;` and line breaks.
|
||||||
|
*/
|
||||||
|
export function splitRecipients(value: string, separators = ','): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
let inAngle = false;
|
||||||
|
let inGroup = false;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const ch = value[i];
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
current += ch;
|
||||||
|
} else if (ch === '<' && !inQuotes) {
|
||||||
|
inAngle = true;
|
||||||
|
current += ch;
|
||||||
|
} else if (ch === '>' && !inQuotes) {
|
||||||
|
inAngle = false;
|
||||||
|
current += ch;
|
||||||
|
} else if (separators.includes(ch) && inAngle && !inQuotes && !inGroup && !angleRunCloses(value, i)) {
|
||||||
|
// An unclosed `<` would otherwise swallow every later separator, folding
|
||||||
|
// the whole list into one entry that is not an address at all. Inside a
|
||||||
|
// group the separators belong to the group, so `inGroup` still wins.
|
||||||
|
inAngle = false;
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) result.push(trimmed);
|
||||||
|
current = '';
|
||||||
|
} else if (ch === ':' && !inQuotes && !inAngle) {
|
||||||
|
// RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group,
|
||||||
|
// separators inside it included, as a single entry. A colon inside a
|
||||||
|
// display name is always quoted (see NAME_NEEDS_QUOTING), so a bare
|
||||||
|
// colon reliably opens a group.
|
||||||
|
inGroup = true;
|
||||||
|
current += ch;
|
||||||
|
} else if (ch === ';' && inGroup && !inQuotes && !inAngle) {
|
||||||
|
inGroup = false;
|
||||||
|
current += ch;
|
||||||
|
} else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) {
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) result.push(trimmed);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) result.push(trimmed);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display names containing any of these must be wrapped in a quoted-string so
|
||||||
|
// they survive comma-splitting at the serialization boundary and round-trip.
|
||||||
|
const NAME_NEEDS_QUOTING = /[,<>"@;:]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a recipient as a string. Bare email when there's no distinct name;
|
||||||
|
* otherwise `Name <email>`, RFC 5322 quoting the name when it contains a comma
|
||||||
|
* or other special character.
|
||||||
|
*/
|
||||||
|
export function formatRecipient(name: string | undefined, email: string): string {
|
||||||
|
const trimmedName = name?.trim();
|
||||||
|
if (!trimmedName || trimmedName === email) return email;
|
||||||
|
const quoted = NAME_NEEDS_QUOTING.test(trimmedName)
|
||||||
|
? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"`
|
||||||
|
: trimmedName;
|
||||||
|
return `${quoted} <${email}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
|
||||||
|
function unquoteName(name: string): string {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the first colon outside quotes/angle brackets, or -1. */
|
||||||
|
function findTopLevelColon(value: string): number {
|
||||||
|
let inQuotes = false;
|
||||||
|
let inAngle = false;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const ch = value[i];
|
||||||
|
if (ch === '"') inQuotes = !inQuotes;
|
||||||
|
else if (ch === '<' && !inQuotes) inAngle = true;
|
||||||
|
else if (ch === '>' && !inQuotes) inAngle = false;
|
||||||
|
else if (ch === ':' && !inQuotes && !inAngle) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
|
||||||
|
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
|
||||||
|
* RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is
|
||||||
|
* how contact groups round-trip through the composer's string boundaries.
|
||||||
|
*/
|
||||||
|
export function parseRecipient(s: string): Recipient {
|
||||||
|
const trimmed = s.trim();
|
||||||
|
if (trimmed.endsWith(';')) {
|
||||||
|
const colon = findTopLevelColon(trimmed);
|
||||||
|
if (colon !== -1) {
|
||||||
|
const members = splitRecipients(trimmed.slice(colon + 1, -1))
|
||||||
|
.map(parseRecipient)
|
||||||
|
.filter((m) => m.email && !m.group);
|
||||||
|
// Only accept the group form when it actually carries members - typed
|
||||||
|
// garbage like "Subject: hello;" stays a plain (invalid) recipient.
|
||||||
|
if (members.length > 0) {
|
||||||
|
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
||||||
|
if (angleMatch) {
|
||||||
|
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
|
||||||
|
}
|
||||||
|
return { email: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a serialized comma-separated recipient string into an array. */
|
||||||
|
export function parseRecipientList(value: string): Recipient[] {
|
||||||
|
return splitRecipients(value).map(parseRecipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a single composer recipient, using RFC 5322 group syntax for
|
||||||
|
* contact-group chips so they survive the composer's string boundaries.
|
||||||
|
*/
|
||||||
|
export function formatRecipientEntry(r: Recipient): string {
|
||||||
|
if (r.group) {
|
||||||
|
const name = r.name?.trim() || 'Group';
|
||||||
|
const quoted = NAME_NEEDS_QUOTING.test(name)
|
||||||
|
? `"${name.replace(/(["\\])/g, '\\$1')}"`
|
||||||
|
: name;
|
||||||
|
const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', ');
|
||||||
|
return `${quoted}: ${members};`;
|
||||||
|
}
|
||||||
|
return formatRecipient(r.name, r.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes a recipient array into a comma-separated string. */
|
||||||
|
export function formatRecipientList(recipients: Recipient[]): string {
|
||||||
|
return recipients.map(formatRecipientEntry).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands contact-group chips into their members for sending and
|
||||||
|
* draft-saving. Deduplicates case-insensitively by address across the whole
|
||||||
|
* list, keeping the first occurrence - an explicitly added individual wins
|
||||||
|
* over the same address arriving again via a group.
|
||||||
|
*/
|
||||||
|
export function expandRecipients(recipients: Recipient[]): Recipient[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Recipient[] = [];
|
||||||
|
for (const r of recipients) {
|
||||||
|
for (const entry of r.group ? r.group.members : [r]) {
|
||||||
|
const key = entry.email.trim().toLowerCase();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(entry.name ? { name: entry.name, email: entry.email } : { email: entry.email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level split of a pasted block into recipient entries on commas,
|
||||||
|
* semicolons and newlines (separators inside a quoted name or angle brackets
|
||||||
|
* stay literal).
|
||||||
|
*/
|
||||||
|
function splitPasteEntries(value: string): string[] {
|
||||||
|
return splitRecipients(value, ',;\n\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a whitespace/semicolon token of `value` is an address in its own right. */
|
||||||
|
function carriesAddress(value: string): boolean {
|
||||||
|
return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits typed or pasted text into recipient candidates and partitions them:
|
||||||
|
* valid email addresses become `Recipient`s (deduped case-insensitively
|
||||||
|
* against `existingEmails` and within the paste), and everything else is
|
||||||
|
* returned as `invalid` for the caller to drop back into the input field.
|
||||||
|
*
|
||||||
|
* Handles both structured and bare lists, preserving display names:
|
||||||
|
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
|
||||||
|
* `"Doe, John" <email>` entries are kept intact with their display name.
|
||||||
|
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
|
||||||
|
* semicolon/newline separated) split into one chip per address.
|
||||||
|
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
|
||||||
|
* validating, so an `a <a@x.com>` fragment still yields the address.
|
||||||
|
* - `Name <email` entries that lost their closing bracket keep both the name
|
||||||
|
* and the address instead of falling apart into bare words.
|
||||||
|
*/
|
||||||
|
export function splitPastedRecipients(
|
||||||
|
text: string,
|
||||||
|
existingEmails: string[] = [],
|
||||||
|
): { valid: Recipient[]; invalid: string[] } {
|
||||||
|
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
|
||||||
|
const valid: Recipient[] = [];
|
||||||
|
const invalid: string[] = [];
|
||||||
|
|
||||||
|
// Adds a recipient if its address is valid and unseen. Returns true when the
|
||||||
|
// entry is fully handled (valid or a known duplicate) so the caller can stop.
|
||||||
|
const tryAdd = (r: Recipient): boolean => {
|
||||||
|
if (!isValidEmail(r.email)) return false;
|
||||||
|
const key = r.email.toLowerCase();
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of splitPasteEntries(text)) {
|
||||||
|
// 1. Structured: `Name <email>`, a bare address, or the whole
|
||||||
|
// `Name <email>` wrapped in quotes (unwrap once and retry).
|
||||||
|
if (tryAdd(parseRecipient(entry))) continue;
|
||||||
|
const unwrapped = unquoteName(entry);
|
||||||
|
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
|
||||||
|
|
||||||
|
// 2. `Name <email` with the closing bracket lost - splitMailbox tolerates
|
||||||
|
// the missing `>`, so the display name survives the paste. It keeps the
|
||||||
|
// last angle run only, so everything ahead of it would become display
|
||||||
|
// name: skip this step when that prefix carries an address of its own
|
||||||
|
// (`a@x.com <b@y.com`) rather than silently swallowing it.
|
||||||
|
const lastOpenAngle = entry.lastIndexOf('<');
|
||||||
|
if (
|
||||||
|
lastOpenAngle !== -1 &&
|
||||||
|
!carriesAddress(entry.slice(0, lastOpenAngle)) &&
|
||||||
|
tryAdd(splitMailbox(entry))
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
// 3. Fallback: a bare-address run (`a@x.com b@y.com`) or a
|
||||||
|
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
|
||||||
|
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
|
||||||
|
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
|
||||||
|
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid, invalid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `mailto:` URL into composer prefill data. Returns null when the
|
||||||
|
* URL carries no valid recipient. Port of the webmail's parseMailtoUrl.
|
||||||
|
*/
|
||||||
|
export function parseMailtoUrl(
|
||||||
|
url: string,
|
||||||
|
): { to: string[]; cc?: string[]; bcc?: string[]; subject?: string; body?: string } | null {
|
||||||
|
if (!url || !/^mailto:/i.test(url)) return null;
|
||||||
|
|
||||||
|
const rest = url.slice(7);
|
||||||
|
const queryIndex = rest.indexOf('?');
|
||||||
|
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
|
||||||
|
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
|
||||||
|
|
||||||
|
const decode = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value.replace(/\+/g, '%20'));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const to = addressPart
|
||||||
|
.split(',')
|
||||||
|
.map((a) => decode(a).trim())
|
||||||
|
.filter((a) => isValidEmail(a));
|
||||||
|
|
||||||
|
const cc: string[] = [];
|
||||||
|
const bcc: string[] = [];
|
||||||
|
let subject: string | undefined;
|
||||||
|
let body: string | undefined;
|
||||||
|
for (const pair of query.split('&')) {
|
||||||
|
const eq = pair.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = pair.slice(0, eq).toLowerCase();
|
||||||
|
const value = decode(pair.slice(eq + 1));
|
||||||
|
if (key === 'subject') subject = value;
|
||||||
|
else if (key === 'body') body = value;
|
||||||
|
else if (key === 'to') {
|
||||||
|
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) to.push(a);
|
||||||
|
} else if (key === 'cc') {
|
||||||
|
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) cc.push(a);
|
||||||
|
} else if (key === 'bcc') {
|
||||||
|
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) bcc.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.length === 0 && cc.length === 0 && bcc.length === 0 && !subject && !body) return null;
|
||||||
|
return {
|
||||||
|
to,
|
||||||
|
cc: cc.length ? cc : undefined,
|
||||||
|
bcc: bcc.length ? bcc : undefined,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Identity selection for replies, drafts and new messages. Port of the
|
||||||
|
// webmail's lib/reply-identity.ts.
|
||||||
|
|
||||||
|
import type { Identity } from '../api/types';
|
||||||
|
|
||||||
|
interface ReplyRecipient {
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyRecipients {
|
||||||
|
to?: ReplyRecipient[];
|
||||||
|
cc?: ReplyRecipient[];
|
||||||
|
bcc?: ReplyRecipient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailAddress(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseEmailAddress(email: string): string {
|
||||||
|
const normalized = normalizeEmailAddress(email);
|
||||||
|
const atIndex = normalized.indexOf('@');
|
||||||
|
|
||||||
|
if (atIndex <= 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPart = normalized.slice(0, atIndex);
|
||||||
|
const domain = normalized.slice(atIndex + 1);
|
||||||
|
const plusIndex = localPart.indexOf('+');
|
||||||
|
|
||||||
|
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainOf(email: string): string {
|
||||||
|
const at = email.indexOf('@');
|
||||||
|
return at > 0 ? email.slice(at + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity that received the original message: exact address match over
|
||||||
|
* to/cc/bcc first, then with `+tag` sub-addressing stripped. Null when none
|
||||||
|
* matches (caller keeps the default identity).
|
||||||
|
*/
|
||||||
|
export function findReplyIdentityId(
|
||||||
|
identities: Identity[],
|
||||||
|
recipients?: ReplyRecipients,
|
||||||
|
): string | null {
|
||||||
|
if (identities.length === 0 || !recipients) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedAddresses = [
|
||||||
|
...(recipients.to || []),
|
||||||
|
...(recipients.cc || []),
|
||||||
|
...(recipients.bcc || []),
|
||||||
|
]
|
||||||
|
.map((recipient) => recipient.email?.trim())
|
||||||
|
.filter((email): email is string => Boolean(email));
|
||||||
|
|
||||||
|
if (receivedAddresses.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress));
|
||||||
|
const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email)));
|
||||||
|
if (exactIdentity) {
|
||||||
|
return exactIdentity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress));
|
||||||
|
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
||||||
|
|
||||||
|
return baseIdentity?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the identity to use for a NEW message started while viewing a specific
|
||||||
|
* mailbox/account. Matches the active mailbox's address to a configured
|
||||||
|
* identity (exact, then `+tag`-stripped). Returns `null` when no address is
|
||||||
|
* given or none matches, so the caller keeps the primary identity.
|
||||||
|
*/
|
||||||
|
export function findComposeIdentityId(
|
||||||
|
identities: Identity[],
|
||||||
|
accountEmail?: string | null,
|
||||||
|
): string | null {
|
||||||
|
const email = accountEmail?.trim();
|
||||||
|
if (identities.length === 0 || !email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = normalizeEmailAddress(email);
|
||||||
|
const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact);
|
||||||
|
if (exactIdentity) {
|
||||||
|
return exactIdentity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalizeBaseEmailAddress(email);
|
||||||
|
const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base);
|
||||||
|
|
||||||
|
return baseIdentity?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the identity a draft was composed with from its saved From. A draft
|
||||||
|
* stores only the From address+name, not an identityId, so when two identities
|
||||||
|
* share an address (a default + an alias with a different display name) the name
|
||||||
|
* has to disambiguate - an email-only match picks the wrong one. Falls back to
|
||||||
|
* email, then `+tag`-stripped email. Returns null when none match (caller keeps
|
||||||
|
* the default).
|
||||||
|
*/
|
||||||
|
export function findDraftIdentityId(
|
||||||
|
identities: Identity[],
|
||||||
|
from?: { email?: string | null; name?: string | null } | null,
|
||||||
|
): string | null {
|
||||||
|
const email = from?.email?.trim();
|
||||||
|
if (identities.length === 0 || !email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantEmail = normalizeEmailAddress(email);
|
||||||
|
const wantName = (from?.name ?? '').trim();
|
||||||
|
|
||||||
|
const nameAndEmail = identities.find(
|
||||||
|
(i) => normalizeEmailAddress(i.email) === wantEmail && (i.name ?? '').trim() === wantName,
|
||||||
|
);
|
||||||
|
if (nameAndEmail) {
|
||||||
|
return nameAndEmail.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = identities.find((i) => normalizeEmailAddress(i.email) === wantEmail);
|
||||||
|
if (exact) {
|
||||||
|
return exact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantBase = normalizeBaseEmailAddress(email);
|
||||||
|
const base = identities.find((i) => normalizeBaseEmailAddress(i.email) === wantBase);
|
||||||
|
return base?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyFromResolution {
|
||||||
|
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
|
||||||
|
identityId: string;
|
||||||
|
/**
|
||||||
|
* Override for the outgoing `From:` header. Populated when the incoming
|
||||||
|
* message was delivered to an address on a domain the user owns (by
|
||||||
|
* identity) but that isn't itself a configured identity - typical
|
||||||
|
* domain-catch-all deployments. When set, the composer should put this
|
||||||
|
* address (and `overrideName`) in the message's From header while sending
|
||||||
|
* through the chosen identity.
|
||||||
|
*/
|
||||||
|
overrideEmail?: string;
|
||||||
|
overrideName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the identity + optional header-From override for replying to a message.
|
||||||
|
*
|
||||||
|
* Decision order:
|
||||||
|
* 1. If a recipient address exactly matches an identity, reply as that
|
||||||
|
* identity with no override.
|
||||||
|
* 2. Else if a recipient matches an identity after stripping `+tag`
|
||||||
|
* sub-addressing, reply as that identity with no override.
|
||||||
|
* 3. Else if a recipient address is on a domain that one of the identities
|
||||||
|
* uses, treat that recipient as a catch-all alias: return the matching
|
||||||
|
* identity + the recipient as a header-From override.
|
||||||
|
* 4. Else return `null` (caller falls back to primary identity).
|
||||||
|
*/
|
||||||
|
export function resolveReplyFrom(
|
||||||
|
identities: Identity[],
|
||||||
|
recipients?: ReplyRecipients,
|
||||||
|
): ReplyFromResolution | null {
|
||||||
|
if (identities.length === 0 || !recipients) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const received: { email: string; name: string | undefined }[] = [
|
||||||
|
...(recipients.to || []),
|
||||||
|
...(recipients.cc || []),
|
||||||
|
...(recipients.bcc || []),
|
||||||
|
].flatMap((r) => {
|
||||||
|
const email = r.email?.trim();
|
||||||
|
if (!email) return [];
|
||||||
|
return [{ email, name: r.name?.trim() || undefined }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (received.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email)));
|
||||||
|
const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email)));
|
||||||
|
|
||||||
|
const exactIdentity = identities.find((i) =>
|
||||||
|
received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)),
|
||||||
|
);
|
||||||
|
if (exactIdentity) {
|
||||||
|
return { identityId: exactIdentity.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseIdentity = identities.find((i) =>
|
||||||
|
received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)),
|
||||||
|
);
|
||||||
|
if (baseIdentity) {
|
||||||
|
return { identityId: baseIdentity.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
|
||||||
|
|
||||||
|
const catchAll = received.find((r) => {
|
||||||
|
const email = normalizeEmailAddress(r.email);
|
||||||
|
if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ownedDomains.has(domainOf(email));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (catchAll) {
|
||||||
|
const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0];
|
||||||
|
return {
|
||||||
|
identityId: anchor.id,
|
||||||
|
overrideEmail: catchAll.email,
|
||||||
|
overrideName: catchAll.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Signature helpers for the composer. Port of the webmail's
|
||||||
|
// lib/signature-utils.ts plus the embedded-signature block builder from
|
||||||
|
// components/email/email-composer.tsx (buildEmbeddedSignatureHtml).
|
||||||
|
//
|
||||||
|
// The RN editor is a plain contenteditable (no Tiptap atom), so the signature
|
||||||
|
// is bracketed by `data-signature-block` marker paragraphs and is always
|
||||||
|
// editable. The markers let an identity switch swap just the signature
|
||||||
|
// without touching the draft text or the quoted message.
|
||||||
|
|
||||||
|
import { stripDangerousTags } from './email-html';
|
||||||
|
import { htmlToPlainText } from './compose-html';
|
||||||
|
|
||||||
|
export type SignatureSource = {
|
||||||
|
textSignature?: string;
|
||||||
|
htmlSignature?: string;
|
||||||
|
} | null | undefined;
|
||||||
|
|
||||||
|
export const SIGNATURE_RANGE_MARKER = 'data-signature-block';
|
||||||
|
|
||||||
|
const SIGNATURE_ALLOWED_TAGS = new Set([
|
||||||
|
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||||
|
]);
|
||||||
|
const SIGNATURE_ALLOWED_ATTRS = new Set([
|
||||||
|
'href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title',
|
||||||
|
'cellpadding', 'cellspacing', 'border', 'valign', 'align', 'bgcolor',
|
||||||
|
'colspan', 'rowspan',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an HTML signature for storage and for the outgoing message. A
|
||||||
|
* regex allowlist stands in for the webmail's DOMPurify config
|
||||||
|
* (SIGNATURE_SANITIZE_CONFIG): formatting, links, images and tables only; no
|
||||||
|
* script, no event handlers, no external URI schemes; images only over https:
|
||||||
|
* or as a base64 raster data: URI.
|
||||||
|
*/
|
||||||
|
export function sanitizeSignatureHtml(html: string): string {
|
||||||
|
if (!html?.trim()) return '';
|
||||||
|
const stripped = stripDangerousTags(html);
|
||||||
|
return stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (full, rawTag: string, rawAttrs: string) => {
|
||||||
|
const tag = rawTag.toLowerCase();
|
||||||
|
if (!SIGNATURE_ALLOWED_TAGS.has(tag)) return '';
|
||||||
|
if (full.startsWith('</')) return `</${tag}>`;
|
||||||
|
const attrs: string[] = [];
|
||||||
|
const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = attrRe.exec(rawAttrs)) !== null) {
|
||||||
|
const name = m[1].toLowerCase();
|
||||||
|
if (!SIGNATURE_ALLOWED_ATTRS.has(name)) continue;
|
||||||
|
const value = m[3] ?? m[4] ?? m[5] ?? '';
|
||||||
|
if (name === 'href' && !/^(https?:|mailto:|tel:)/i.test(value.trim())) continue;
|
||||||
|
if (name === 'src' && !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(value.trim())) {
|
||||||
|
// An image with a disallowed source is dropped entirely.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(value)) continue;
|
||||||
|
attrs.push(`${name}="${value.replace(/"/g, '"')}"`);
|
||||||
|
}
|
||||||
|
const selfClose = /\/\s*$/.test(rawAttrs) ? ' /' : '';
|
||||||
|
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}${selfClose}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignatureLineBreaks(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSignature(signature?: SignatureSource): boolean {
|
||||||
|
return !!(signature?.htmlSignature?.trim() || signature?.textSignature?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlainTextSignature(signature?: SignatureSource): string {
|
||||||
|
if (signature?.textSignature?.trim()) {
|
||||||
|
return normalizeSignatureLineBreaks(signature.textSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature?.htmlSignature?.trim()) {
|
||||||
|
return htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendPlainTextSignature(
|
||||||
|
body: string,
|
||||||
|
signature?: SignatureSource,
|
||||||
|
options: { separator?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const plainTextSignature = getPlainTextSignature(signature);
|
||||||
|
if (!plainTextSignature) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sep = options.separator === false ? '\n\n' : '\n\n-- \n';
|
||||||
|
return `${body}${sep}${plainTextSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a plain-text body already ends with the identity's signature.
|
||||||
|
*/
|
||||||
|
export function plainTextBodyHasSignature(
|
||||||
|
body: string,
|
||||||
|
signature?: SignatureSource,
|
||||||
|
): boolean {
|
||||||
|
const plainTextSignature = getPlainTextSignature(signature);
|
||||||
|
if (!plainTextSignature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return normalizeSignatureLineBreaks(body).endsWith(plainTextSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plain-text body with a trailing signature - and the `-- ` separator
|
||||||
|
* line in front of it - removed. Returns the body unchanged when it does not
|
||||||
|
* end with the identity's signature.
|
||||||
|
*/
|
||||||
|
export function plainTextBodyWithoutSignature(
|
||||||
|
body: string,
|
||||||
|
signature?: SignatureSource,
|
||||||
|
): string {
|
||||||
|
const plainTextSignature = getPlainTextSignature(signature);
|
||||||
|
if (!plainTextSignature) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
const normalized = normalizeSignatureLineBreaks(body);
|
||||||
|
if (!normalized.endsWith(plainTextSignature)) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
.slice(0, normalized.length - plainTextSignature.length)
|
||||||
|
.replace(/\n*(?:-- ?)?\n*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a signature to an HTML body, preserving rich formatting (quick
|
||||||
|
* reply path, no markers).
|
||||||
|
*/
|
||||||
|
export function appendHtmlSignature(
|
||||||
|
htmlBody: string,
|
||||||
|
signature?: SignatureSource,
|
||||||
|
options: { separator?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
|
||||||
|
|
||||||
|
if (signature?.htmlSignature?.trim()) {
|
||||||
|
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature?.textSignature?.trim()) {
|
||||||
|
return `${htmlBody}${sep}${escapeText(signature.textSignature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the embedded, editable signature range: a `-- ` separator paragraph
|
||||||
|
* (or an empty start marker), the signature, and an end marker. Returns ''
|
||||||
|
* when the identity has no signature.
|
||||||
|
*/
|
||||||
|
export function buildEmbeddedSignatureHtml(
|
||||||
|
identity: SignatureSource,
|
||||||
|
options: { separator: boolean },
|
||||||
|
): string {
|
||||||
|
const startMarker = options.separator
|
||||||
|
? `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
|
||||||
|
: `<p ${SIGNATURE_RANGE_MARKER}="start"></p>`;
|
||||||
|
const endMarker = `<p ${SIGNATURE_RANGE_MARKER}="end"><br></p>`;
|
||||||
|
if (identity?.htmlSignature?.trim()) {
|
||||||
|
return `${startMarker}<div ${SIGNATURE_RANGE_MARKER}="body">${sanitizeSignatureHtml(identity.htmlSignature)}</div>${endMarker}`;
|
||||||
|
}
|
||||||
|
if (identity?.textSignature?.trim()) {
|
||||||
|
return `${startMarker}<p ${SIGNATURE_RANGE_MARKER}="body">${escapeText(identity.textSignature)}</p>${endMarker}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNATURE_RANGE_RE = new RegExp(
|
||||||
|
`<p\\s+${SIGNATURE_RANGE_MARKER}=["'](?:separator|start)["'][^>]*>[\\s\\S]*?<p\\s+${SIGNATURE_RANGE_MARKER}=["']end["'][^>]*>(?:<br\\s*/?>)?</p>`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** True when the body carries an embedded signature range. */
|
||||||
|
export function containsEmbeddedSignature(html: string): boolean {
|
||||||
|
return SIGNATURE_RANGE_RE.test(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the embedded signature range with `nextSignatureHtml` (which may
|
||||||
|
* be '' to remove it). When the body has no range yet, the signature is
|
||||||
|
* appended at the end. Returns the body unchanged when nothing changes.
|
||||||
|
*/
|
||||||
|
export function spliceSignature(html: string, nextSignatureHtml: string): string {
|
||||||
|
if (SIGNATURE_RANGE_RE.test(html)) {
|
||||||
|
return html.replace(SIGNATURE_RANGE_RE, () => nextSignatureHtml);
|
||||||
|
}
|
||||||
|
if (!nextSignatureHtml) return html;
|
||||||
|
return `${html}${nextSignatureHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body without its embedded signature range. */
|
||||||
|
export function stripEmbeddedSignature(html: string): string {
|
||||||
|
return html.replace(SIGNATURE_RANGE_RE, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the signature range in front of the quoted block (`On … wrote:` /
|
||||||
|
* forwarded header) or, when there is none, at the end of the body.
|
||||||
|
*/
|
||||||
|
export function insertSignatureAboveQuote(html: string, signatureHtml: string, quoteStartMarker: string): string {
|
||||||
|
if (!signatureHtml) return html;
|
||||||
|
const idx = quoteStartMarker ? html.indexOf(quoteStartMarker) : -1;
|
||||||
|
if (idx === -1) return `${html}${signatureHtml}`;
|
||||||
|
return `${html.slice(0, idx)}${signatureHtml}${html.slice(idx)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Sub-addressing utilities for user{delimiter}tag@domain.com format.
|
||||||
|
* Works server-side automatically - no JMAP API calls needed.
|
||||||
|
* Port of the webmail's lib/sub-addressing.ts.
|
||||||
|
*
|
||||||
|
* The delimiter character is configurable per server (RFC 5233). Common
|
||||||
|
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_TAG_LENGTH = 30;
|
||||||
|
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
|
||||||
|
|
||||||
|
export const DEFAULT_SUB_ADDRESS_DELIMITER = '+';
|
||||||
|
export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const;
|
||||||
|
export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
|
||||||
|
|
||||||
|
export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset {
|
||||||
|
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
|
||||||
|
// delimiter must be exactly one of these - they're safe to embed in a local
|
||||||
|
// part and unambiguously separate the user from the tag.
|
||||||
|
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
|
||||||
|
|
||||||
|
export function isValidSubAddressDelimiter(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagValidationErrorCode =
|
||||||
|
| 'EMPTY'
|
||||||
|
| 'TOO_LONG'
|
||||||
|
| 'INVALID_CHARS'
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export interface ParsedAddress {
|
||||||
|
localPart: string;
|
||||||
|
baseUser: string;
|
||||||
|
tag: string | null;
|
||||||
|
domain: string;
|
||||||
|
fullAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an email address to extract the sub-address tag. The first occurrence
|
||||||
|
* of the delimiter in the local part separates the base user from the tag,
|
||||||
|
* matching the behavior of Postfix/qmail/Sieve.
|
||||||
|
*/
|
||||||
|
export function parseSubAddress(
|
||||||
|
email: string,
|
||||||
|
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
|
): ParsedAddress {
|
||||||
|
const [localPart, domain] = email.split('@');
|
||||||
|
|
||||||
|
if (!localPart || !domain) {
|
||||||
|
return {
|
||||||
|
localPart: localPart || '',
|
||||||
|
baseUser: localPart || '',
|
||||||
|
tag: null,
|
||||||
|
domain: domain || '',
|
||||||
|
fullAddress: email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const delimiterIndex = localPart.indexOf(delimiter);
|
||||||
|
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
return { localPart, baseUser: localPart, tag: null, domain, fullAddress: email };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUser = localPart.substring(0, delimiterIndex);
|
||||||
|
const tag = localPart.substring(delimiterIndex + delimiter.length);
|
||||||
|
|
||||||
|
return { localPart, baseUser, tag: tag || null, domain, fullAddress: email };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sub-addressed email.
|
||||||
|
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
|
||||||
|
*/
|
||||||
|
export function generateSubAddress(
|
||||||
|
baseEmail: string,
|
||||||
|
tag: string,
|
||||||
|
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||||
|
): string {
|
||||||
|
const [localPart, domain] = baseEmail.split('@');
|
||||||
|
|
||||||
|
if (!localPart || !domain || !tag) {
|
||||||
|
return baseEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip an existing tag if one is already present
|
||||||
|
const existingDelimiterIndex = localPart.indexOf(delimiter);
|
||||||
|
const cleanLocal = existingDelimiterIndex === -1
|
||||||
|
? localPart
|
||||||
|
: localPart.substring(0, existingDelimiterIndex);
|
||||||
|
|
||||||
|
// Sanitize tag (alphanumeric and dash only)
|
||||||
|
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
if (!cleanTag) {
|
||||||
|
return baseEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract domain from recipient email for tag suggestions. */
|
||||||
|
export function extractDomain(email: string): string | null {
|
||||||
|
const match = email.match(/@([^@]+)$/);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggest tags based on the recipient domain. */
|
||||||
|
export function suggestTagsForDomain(domain: string): string[] {
|
||||||
|
const domainLower = domain.toLowerCase();
|
||||||
|
|
||||||
|
const suggestions: Record<string, string[]> = {
|
||||||
|
'amazon.com': ['amazon', 'shopping', 'orders'],
|
||||||
|
'amazon.fr': ['amazon', 'shopping', 'orders'],
|
||||||
|
'amazon.de': ['amazon', 'shopping', 'orders'],
|
||||||
|
'amazon.co.uk': ['amazon', 'shopping', 'orders'],
|
||||||
|
'ebay.com': ['ebay', 'shopping'],
|
||||||
|
'ebay.fr': ['ebay', 'shopping'],
|
||||||
|
'paypal.com': ['paypal', 'payments'],
|
||||||
|
'facebook.com': ['facebook', 'social'],
|
||||||
|
'twitter.com': ['twitter', 'social'],
|
||||||
|
'x.com': ['twitter', 'social'],
|
||||||
|
'linkedin.com': ['linkedin', 'professional'],
|
||||||
|
'github.com': ['github', 'dev', 'notifications'],
|
||||||
|
'gitlab.com': ['gitlab', 'dev', 'notifications'],
|
||||||
|
'stackoverflow.com': ['stackoverflow', 'dev'],
|
||||||
|
'reddit.com': ['reddit', 'social'],
|
||||||
|
'netflix.com': ['netflix', 'entertainment'],
|
||||||
|
'spotify.com': ['spotify', 'music'],
|
||||||
|
'steam.com': ['steam', 'gaming'],
|
||||||
|
'discord.com': ['discord', 'gaming'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (suggestions[domainLower]) {
|
||||||
|
return suggestions[domainLower];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract main domain (e.g., "mail.google.com" -> "google")
|
||||||
|
const parts = domainLower.split('.');
|
||||||
|
const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||||
|
|
||||||
|
return [mainDomain, 'newsletter', 'registration'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate if a tag is safe to use. */
|
||||||
|
export function isValidTag(tag: string): boolean {
|
||||||
|
return TAG_REGEX.test(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation error code for an invalid tag. Returns an error code that
|
||||||
|
* should be translated by the calling component.
|
||||||
|
*/
|
||||||
|
export function getTagValidationError(tag: string): TagValidationErrorCode {
|
||||||
|
if (!tag) return 'EMPTY';
|
||||||
|
if (tag.length > MAX_TAG_LENGTH) return 'TOO_LONG';
|
||||||
|
if (!/^[a-zA-Z0-9-]+$/.test(tag)) return 'INVALID_CHARS';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MAX_TAG_LENGTH };
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// Template placeholders, export/import envelope and filtering. Port of the
|
||||||
|
// webmail's lib/template-utils.ts + lib/template-types.ts (DOM-free: the
|
||||||
|
// tag-stripping DOMPurify pass is replaced by a regex strip).
|
||||||
|
|
||||||
|
import type { EmailTemplate } from '../stores/templates-store';
|
||||||
|
import { generateUUID } from './uuid';
|
||||||
|
|
||||||
|
export const BUILT_IN_PLACEHOLDERS = [
|
||||||
|
'recipient_name',
|
||||||
|
'company',
|
||||||
|
'date',
|
||||||
|
'day_of_week',
|
||||||
|
'sender_name',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BuiltInPlaceholder = (typeof BUILT_IN_PLACEHOLDERS)[number];
|
||||||
|
|
||||||
|
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
||||||
|
const MAX_TEMPLATE_NAME_LENGTH = 200;
|
||||||
|
|
||||||
|
function stripTags(value: string): string {
|
||||||
|
return value.replace(/<[^>]*>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPlaceholders(text: string): string[] {
|
||||||
|
const matches = new Set<string>();
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g');
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
matches.add(match[1]);
|
||||||
|
}
|
||||||
|
return Array.from(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function substitutePlaceholders(
|
||||||
|
text: string,
|
||||||
|
values: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return text.replace(PLACEHOLDER_REGEX, (full, name: string) => {
|
||||||
|
if (values[name] === undefined) return full;
|
||||||
|
return stripTags(values[name]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUnresolvedPlaceholders(text: string): boolean {
|
||||||
|
return new RegExp(PLACEHOLDER_REGEX.source).test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTemplateName(name: string): 'empty' | 'too_long' | null {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return 'empty';
|
||||||
|
if (trimmed.length > MAX_TEMPLATE_NAME_LENGTH) return 'too_long';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillContext {
|
||||||
|
senderName?: string;
|
||||||
|
recipientName?: string;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoFilledPlaceholders(
|
||||||
|
context: AutoFillContext,
|
||||||
|
): Record<string, string> {
|
||||||
|
const now = new Date();
|
||||||
|
const locale = context.locale || 'en';
|
||||||
|
const intlLocale = locale === 'en' ? 'en-US' : locale;
|
||||||
|
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
values.date = now.toLocaleDateString(intlLocale, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
values.day_of_week = now.toLocaleDateString(intlLocale, { weekday: 'long' });
|
||||||
|
} catch {
|
||||||
|
values.date = now.toDateString();
|
||||||
|
values.day_of_week = now.toLocaleDateString(undefined, { weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.senderName) {
|
||||||
|
values.sender_name = context.senderName;
|
||||||
|
}
|
||||||
|
if (context.recipientName) {
|
||||||
|
values.recipient_name = context.recipientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaceholdersFromTemplate(template: Pick<EmailTemplate, 'subject' | 'body'>): string[] {
|
||||||
|
const combined = `${template.subject} ${template.body}`;
|
||||||
|
return extractPlaceholders(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBuiltInPlaceholder(name: string): boolean {
|
||||||
|
return (BUILT_IN_PLACEHOLDERS as readonly string[]).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTemplates(templates: EmailTemplate[], query: string): EmailTemplate[] {
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
return templates.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(lower) ||
|
||||||
|
t.subject.toLowerCase().includes(lower) ||
|
||||||
|
t.category.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportData {
|
||||||
|
version: 1;
|
||||||
|
type: 'webmail-templates';
|
||||||
|
exportedAt: string;
|
||||||
|
templates: EmailTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON in the webmail's export shape so a file round-trips between clients. */
|
||||||
|
export function exportTemplates(templates: EmailTemplate[]): string {
|
||||||
|
const data: ExportData = {
|
||||||
|
version: 1,
|
||||||
|
type: 'webmail-templates',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
templates,
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
templates: EmailTemplate[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeText(value: unknown): string {
|
||||||
|
return stripTags(String(value || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a templates export. Accepts the webmail envelope (`type:
|
||||||
|
* 'webmail-templates'`) and, for backwards compatibility, an older RN export
|
||||||
|
* that carried only `{ version, templates }`. Ids are regenerated so an
|
||||||
|
* import never overwrites an existing template.
|
||||||
|
*/
|
||||||
|
export function importTemplates(json: string): ImportResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return { templates: [], errors: ['invalid_json'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
return { templates: [], errors: ['invalid_format'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (data.type !== undefined && data.type !== 'webmail-templates') {
|
||||||
|
return { templates: [], errors: ['invalid_type'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.version !== undefined && data.version !== 1) {
|
||||||
|
return { templates: [], errors: ['unsupported_version'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.templates)) {
|
||||||
|
return { templates: [], errors: ['invalid_templates'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates: EmailTemplate[] = [];
|
||||||
|
for (const item of data.templates) {
|
||||||
|
if (typeof item !== 'object' || item === null) {
|
||||||
|
errors.push('invalid_template_entry');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = item as Record<string, unknown>;
|
||||||
|
if (typeof t.name !== 'string' || !t.name.trim()) {
|
||||||
|
errors.push('missing_template_name');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
templates.push({
|
||||||
|
id: generateUUID(),
|
||||||
|
name: sanitizeText(t.name),
|
||||||
|
subject: sanitizeText(t.subject),
|
||||||
|
body: t.isHTML ? String(t.body || '') : sanitizeText(t.body),
|
||||||
|
isHTML: Boolean(t.isHTML),
|
||||||
|
category: sanitizeText(t.category),
|
||||||
|
defaultRecipients: recipients && typeof recipients === 'object'
|
||||||
|
? {
|
||||||
|
to: Array.isArray(recipients.to) ? (recipients.to as unknown[]).map(String) : undefined,
|
||||||
|
cc: Array.isArray(recipients.cc) ? (recipients.cc as unknown[]).map(String) : undefined,
|
||||||
|
bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as unknown[]).map(String) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
identityId: typeof t.identityId === 'string' ? t.identityId : undefined,
|
||||||
|
isFavorite: Boolean(t.isFavorite),
|
||||||
|
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
|
||||||
|
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { templates, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a plain-text template body to composer HTML (escape + <br>). */
|
||||||
|
export function templateBodyToHtml(template: Pick<EmailTemplate, 'body' | 'isHTML'>): string {
|
||||||
|
if (template.isHTML) return template.body;
|
||||||
|
const escaped = template.body
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\r?\n/g, '<br>');
|
||||||
|
return `<p>${escaped}</p>`;
|
||||||
|
}
|
||||||
@@ -6,13 +6,22 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { exportTemplates, importTemplates as parseTemplateImport } from '../lib/template-utils';
|
||||||
|
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Body is HTML (inserted verbatim) rather than plain text. */
|
||||||
|
isHTML?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
|
defaultRecipients?: {
|
||||||
|
to?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
};
|
||||||
|
identityId?: string;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -25,7 +34,10 @@ interface TemplatesState {
|
|||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
|
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
addTemplate: (data: Pick<EmailTemplate, 'name' | 'subject' | 'body'> & Partial<Pick<EmailTemplate, 'category' | 'isFavorite'>>) => EmailTemplate;
|
addTemplate: (
|
||||||
|
data: Pick<EmailTemplate, 'name' | 'subject' | 'body'>
|
||||||
|
& Partial<Pick<EmailTemplate, 'category' | 'isFavorite' | 'isHTML' | 'defaultRecipients' | 'identityId'>>,
|
||||||
|
) => EmailTemplate;
|
||||||
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
|
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
|
||||||
deleteTemplate: (id: string) => void;
|
deleteTemplate: (id: string) => void;
|
||||||
exportAll: () => string;
|
exportAll: () => string;
|
||||||
@@ -71,6 +83,9 @@ export const useTemplatesStore = create<TemplatesState>((set, get) => ({
|
|||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
body: data.body,
|
body: data.body,
|
||||||
category: data.category ?? '',
|
category: data.category ?? '',
|
||||||
|
isHTML: data.isHTML,
|
||||||
|
defaultRecipients: data.defaultRecipients,
|
||||||
|
identityId: data.identityId,
|
||||||
isFavorite: data.isFavorite ?? false,
|
isFavorite: data.isFavorite ?? false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -95,44 +110,19 @@ export const useTemplatesStore = create<TemplatesState>((set, get) => ({
|
|||||||
persist(next);
|
persist(next);
|
||||||
},
|
},
|
||||||
|
|
||||||
exportAll: () => {
|
// Webmail export envelope (type: 'webmail-templates', exportedAt) so a
|
||||||
return JSON.stringify({ version: 1, templates: get().templates }, null, 2);
|
// file exported here imports on the web and vice versa.
|
||||||
},
|
exportAll: () => exportTemplates(get().templates),
|
||||||
|
|
||||||
importTemplates: (json) => {
|
importTemplates: (json) => {
|
||||||
try {
|
const result = parseTemplateImport(json);
|
||||||
const parsed = JSON.parse(json) as { templates?: unknown };
|
if (result.errors.length && result.templates.length === 0) {
|
||||||
if (!Array.isArray(parsed.templates)) {
|
return { count: 0, error: result.errors[0] };
|
||||||
return { count: 0, error: 'Invalid template file: expected `templates` array' };
|
|
||||||
}
|
|
||||||
const incoming: EmailTemplate[] = [];
|
|
||||||
for (const raw of parsed.templates) {
|
|
||||||
if (!raw || typeof raw !== 'object') continue;
|
|
||||||
const t = raw as Partial<EmailTemplate>;
|
|
||||||
if (typeof t.name !== 'string' || typeof t.body !== 'string') continue;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
incoming.push({
|
|
||||||
id: typeof t.id === 'string' ? t.id : genId(),
|
|
||||||
name: t.name,
|
|
||||||
subject: typeof t.subject === 'string' ? t.subject : '',
|
|
||||||
body: t.body,
|
|
||||||
category: typeof t.category === 'string' ? t.category : '',
|
|
||||||
isFavorite: !!t.isFavorite,
|
|
||||||
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
|
|
||||||
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const existing = get().templates;
|
|
||||||
const existingIds = new Set(existing.map((t) => t.id));
|
|
||||||
const merged = [
|
|
||||||
...existing,
|
|
||||||
...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)),
|
|
||||||
];
|
|
||||||
set({ templates: merged });
|
|
||||||
persist(merged);
|
|
||||||
return { count: incoming.length };
|
|
||||||
} catch (err) {
|
|
||||||
return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' };
|
|
||||||
}
|
}
|
||||||
|
if (result.templates.length === 0) return { count: 0 };
|
||||||
|
const merged = [...get().templates, ...result.templates];
|
||||||
|
set({ templates: merged });
|
||||||
|
persist(merged);
|
||||||
|
return { count: result.templates.length };
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user