From 42ab246cb97ea03f02cf24e8a0487578ec3e7e97 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:12:15 +0200 Subject: [PATCH] feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api --- docs/parity/04-composer-send.md | 22 +- src/api/submission.ts | 64 ---- src/lib/__tests__/compose-html.test.ts | 64 +++- src/lib/__tests__/quote-header.test.ts | 41 +++ src/lib/__tests__/recipients.test.ts | 114 +++++++ src/lib/__tests__/reply-identity.test.ts | 56 ++++ src/lib/__tests__/signature-utils.test.ts | 81 +++++ src/lib/__tests__/sub-addressing.test.ts | 37 +++ src/lib/__tests__/template-utils.test.ts | 75 +++++ src/lib/compose-html.ts | 242 ++++++++++---- src/lib/quote-header.ts | 102 ++++++ src/lib/recipients.ts | 377 ++++++++++++++++++++++ src/lib/reply-identity.ts | 229 +++++++++++++ src/lib/signature-utils.ts | 228 +++++++++++++ src/lib/sub-addressing.ts | 167 ++++++++++ src/lib/template-utils.ts | 217 +++++++++++++ src/stores/templates-store.ts | 64 ++-- 17 files changed, 2009 insertions(+), 171 deletions(-) delete mode 100644 src/api/submission.ts create mode 100644 src/lib/__tests__/quote-header.test.ts create mode 100644 src/lib/__tests__/recipients.test.ts create mode 100644 src/lib/__tests__/reply-identity.test.ts create mode 100644 src/lib/__tests__/signature-utils.test.ts create mode 100644 src/lib/__tests__/sub-addressing.test.ts create mode 100644 src/lib/__tests__/template-utils.test.ts create mode 100644 src/lib/quote-header.ts create mode 100644 src/lib/recipients.ts create mode 100644 src/lib/reply-identity.ts create mode 100644 src/lib/signature-utils.ts create mode 100644 src/lib/sub-addressing.ts create mode 100644 src/lib/template-utils.ts diff --git a/docs/parity/04-composer-send.md b/docs/parity/04-composer-send.md index 168a9d7a..85f17144 100644 --- a/docs/parity/04-composer-send.md +++ b/docs/parity/04-composer-send.md @@ -26,17 +26,17 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the ### 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 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/': true, 'mailboxIds/': 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 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`). -- [ ] **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 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. @@ -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. - 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 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`. -- [ ] **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 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. -- [ ] **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 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`. @@ -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. - 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 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). @@ -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. - 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 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/`. -- [ ] **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 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`. @@ -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. - 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 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. @@ -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//`. - Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name ` (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 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. diff --git a/src/api/submission.ts b/src/api/submission.ts deleted file mode 100644 index f9154138..00000000 --- a/src/api/submission.ts +++ /dev/null @@ -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 { - const accountId = jmapClient.accountId; - const emailCreate: Record = { - 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], - ); -} diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index aa828938..3874b6f1 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -1,5 +1,8 @@ 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', () => { it('returns empty paragraph for plain compose', () => { @@ -18,9 +21,9 @@ describe('buildInitialHtml', () => { receivedAt: '2026-04-27T10:00:00Z', }); expect(html).toContain('


'); - // Default header labels the sender by name only (matches webmail #295). - expect(html).toMatch(/On .+, Alice wrote:/); - expect(html).not.toContain('a@b.com'); + // Header shows the full sender incl. address, escaped (webmail #482). + expect(html).toMatch(/On .+, Alice <a@b.com> wrote:/); + expect(html).toContain('data-quoted-html'); expect(html).toContain('line two'); }); @@ -52,7 +55,7 @@ describe('buildInitialHtml', () => { receivedAt: '2026-04-27T10:00:00Z', }); 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('forwarded body'); }); @@ -133,3 +136,54 @@ describe('rewriteInlineImages', () => { expect(out.usedCids).toEqual(['dup']); }); }); + +describe('htmlToPlainText links', () => { + it('renders a link as text ', () => { + expect(htmlToPlainText('

see docs

')).toBe('see docs '); + }); + + it('keeps a bare URL link as the URL only', () => { + expect(htmlToPlainText('https://x.y')).toBe('https://x.y'); + }); + + it('drops the mailto: prefix when the text is the address', () => { + expect(htmlToPlainText('a@b.c')).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('

thanks

' + html); + expect(text).toBe('thanks'); + }); + + it('cuts everything from the forwarded separator', () => { + const text = extractUserAuthoredText( + '

fyi

---------- Forwarded message ----------
From: x

attached

', + { 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

'); + 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"'); + }); +}); diff --git a/src/lib/__tests__/quote-header.test.ts b/src/lib/__tests__/quote-header.test.ts new file mode 100644 index 00000000..c9b20618 --- /dev/null +++ b/src/lib/__tests__/quote-header.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { buildQuoteHeader, formatQuoteSender } from '../quote-header'; + +describe('quote header', () => { + it('renders Name 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(/^
On .+, Alice <a@b\.com> wrote:
<\/div>$/); + expect(h.text).toMatch(/Alice 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
Datum:
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('
a@b.com wrote:
'); + expect(formatQuoteSender(undefined, 'U')).toBe('U'); + }); +}); diff --git a/src/lib/__tests__/recipients.test.ts b/src/lib/__tests__/recipients.test.ts new file mode 100644 index 00000000..168efc25 --- /dev/null +++ b/src/lib/__tests__/recipients.test.ts @@ -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('')).toBe(false); + expect(isValidEmail('noat')).toBe(false); + }); +}); + +describe('splitRecipients', () => { + it('splits on commas outside quotes and angle brackets', () => { + expect(splitRecipients('"Doe, John" , b@y.com')).toEqual(['"Doe, John" ', 'b@y.com']); + }); + + it('recovers from an unclosed angle bracket', () => { + expect(splitRecipients('John { + 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 and unquotes the name', () => { + expect(parseRecipient('"Doe, John" ')).toEqual({ name: 'Doe, John', email: 'j@x.com' }); + expect(parseRecipient('Jane ')).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" ;'); + 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" ; 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 { + 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 "'); + 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(); + }); +}); diff --git a/src/lib/__tests__/reply-identity.test.ts b/src/lib/__tests__/reply-identity.test.ts new file mode 100644 index 00000000..981890fa --- /dev/null +++ b/src/lib/__tests__/reply-identity.test.ts @@ -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(); + }); +}); diff --git a/src/lib/__tests__/signature-utils.test.ts b/src/lib/__tests__/signature-utils.test.ts new file mode 100644 index 00000000..ab8e8434 --- /dev/null +++ b/src/lib/__tests__/signature-utils.test.ts @@ -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('

Jane site l

'); + expect(out).toBe('

Jane site l

'); + }); + + it('drops scripts, event handlers, javascript: links and http images', () => { + const out = sanitizeSignatureHtml('

al

'); + expect(out).toBe('

al

'); + }); + + it('drops disallowed tags but keeps their text', () => { + expect(sanitizeSignatureHtml('

Hi

  • x
')).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: '

Jane

ACME

' })).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 ' }; + + it('builds a marked range with the separator', () => { + const html = buildEmbeddedSignatureHtml(identity, { separator: true }); + expect(html).toContain('data-signature-block="separator">--

'); + 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' }, { separator: false }); + const body = `

hello

${a}
q
`; + const swapped = spliceSignature(body, b); + expect(swapped).toBe(`

hello

${b}
q
`); + expect(stripEmbeddedSignature(swapped)).toBe('

hello

q
'); + expect(spliceSignature('

x

', a)).toBe(`

x

${a}`); + }); + + it('inserts above the quote marker', () => { + const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true }); + const marker = '
'; + expect(insertSignatureAboveQuote(`


${marker}q
`, sig, marker)).toBe(`


${sig}${marker}q
`); + expect(insertSignatureAboveQuote('

x

', sig, marker)).toBe(`

x

${sig}`); + }); +}); diff --git a/src/lib/__tests__/sub-addressing.test.ts b/src/lib/__tests__/sub-addressing.test.ts new file mode 100644 index 00000000..103b3849 --- /dev/null +++ b/src/lib/__tests__/sub-addressing.test.ts @@ -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); + }); +}); diff --git a/src/lib/__tests__/template-utils.test.ts b/src/lib/__tests__/template-utils.test.ts new file mode 100644 index 00000000..fb04a3f5 --- /dev/null +++ b/src/lib/__tests__/template-utils.test.ts @@ -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: 'x' })).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: 'aa<b
c

'); + expect(templateBodyToHtml({ body: '

x

', isHTML: true })).toBe('

x

'); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index 295c689f..9460d19b 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -3,6 +3,7 @@ // All values are HTML-escaped before insertion into the document. import { escapeHtml, stripDangerousTags } from './email-html'; +import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header'; import type { TimeFormat } from '../stores/settings-store'; export interface ReplyMeta { @@ -19,39 +20,23 @@ export interface QuoteHeaderOptions { timeFormat?: TimeFormat; locale?: string; unknownLabel?: string; + labels?: QuoteHeaderLabels; } function escapeForHtmlBody(text: string): string { return escapeHtml(text).replace(/\n/g, '
'); } -// Webmail's default quote header labels the sender by display name when there -// is one, falling back to the bare address — not "Name ". -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'; +/** + * 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 = `
`; + /** * Build the seed HTML for a new compose / reply / forward. Returns an empty * 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 * (`lib/quote-header.ts`): a reply gets "On , wrote:" above a * 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( mode: 'compose' | 'reply' | 'replyAll' | 'forward', @@ -68,10 +54,7 @@ export function buildInitialHtml( ): string { if (!reply || mode === 'compose') return '


'; - const { timeFormat = '24h', locale, unknownLabel = 'Unknown' } = opts; - const date = formatQuoteDate(reply.receivedAt, timeFormat, locale); - const fromStr = senderName(reply.from, unknownLabel); - const subject = reply.subject ?? ''; + const { timeFormat = '24h', locale, unknownLabel = 'Unknown', labels } = opts; const quotedBody = reply.htmlBody ? stripDangerousTags(reply.htmlBody) @@ -81,59 +64,144 @@ export function buildInitialHtml( if (!quotedBody) return '


'; - if (mode === 'forward') { - // Forwarded message block (From/Date/Subject), body appended unquoted. - return [ - '


', - '
---------- Forwarded message ----------
', - `From: ${escapeHtml(fromStr)}
`, - `Date: ${escapeHtml(date)}
`, - `Subject: ${escapeHtml(subject)}

`, - '
', - quotedBody, - ].join(''); - } + const header = buildQuoteHeader({ + mode, + email: { from: reply.from, subject: reply.subject, receivedAt: reply.receivedAt }, + timeFormat, + locale, + unknownLabel, + labels, + }); - // reply / replyAll - const headerLine = date - ? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:` - : `${escapeHtml(fromStr)} wrote:`; - return [ - '


', - `
${headerLine}
`, - `
${quotedBody}
`, - ].join(''); + const quoted = header.wrapInBlockquote + ? `${header.html}
${quotedBody}
` + : `${header.html}${quotedBody}`; + + return `


${QUOTED_BLOCK_START}${quoted}
`; } /** * Strip HTML tags and decode common entities for a plain-text fallback body. * 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 ` + * so a pasted link survives the text/plain alternative. */ export function htmlToPlainText(html: string): string { if (!html) return ''; let s = html; + // Drop non-content blocks entirely. + s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, ''); + // Links: "text " unless the text already is the URL (or mailto target). + s = s.replace(/]*)>([\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
as newlines before stripping tags. s = s.replace(//gi, '\n'); // Paragraph-level closes get a blank line; list/heading/cell closes a single // newline so they stack tightly. s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n'); s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n'); + s = s.replace(/<\/(td|th)>/gi, '\t'); s = s.replace(/]*>/gi, '• '); s = s.replace(/<[^>]+>/g, ''); + s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>'); // Decode the most common entities; leave the rest as-is. s = s .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. s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n'); return s.trim(); } +/** + * Remove a balanced `…` 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(' !/^\s*>/.test(line)) + .join('\n'); + } else { + let html = removeBalancedBlocks(body, new RegExp(`]*\\s${QUOTED_BLOCK_ATTR}\\b`), 'div'); + html = removeBalancedBlocks(html, /` with `` * (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) }; } + +// 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 `` references into + * `` 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(); + const out = html.replace( + /]*?)\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 ``; + }, + ); + return { html: out, cids: Array.from(cids) }; +} + +/** + * Replace the placeholder src on `` elements with the + * resolved data URL once the inline blob has been fetched. + */ +export function replaceInlineImagePlaceholders( + html: string, + cidToDataUrl: Map, +): string { + if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html; + return html.replace( + /]*?)\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 ``; + }, + ); +} + +/** + * 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; +} diff --git a/src/lib/quote-header.ts b/src/lib/quote-header.ts new file mode 100644 index 00000000..7c2a2703 --- /dev/null +++ b/src/lib/quote-header.ts @@ -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 "), like Gmail/Outlook (#482). The +// interpolated values are HTML-escaped: an unescaped "" 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 " 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 = `
${labels.forwardedSeparator}
${labels.fromLabel}: ${escapeHtml(fromStrFull)}
${labels.dateLabel}: ${escapeHtml(date)}
${labels.subjectLabel}: ${escapeHtml(subject)}

`; + return { html, text, wrapInBlockquote: false }; + } + + const text = `${fillReplyLine(labels.replyLine, { date, from: fromStrFull })}\n`; + const html = `
${fillReplyLine(labels.replyLine, { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
`; + return { html, text, wrapInBlockquote: true }; +} diff --git a/src/lib/recipients.ts b/src/lib/recipients.ts new file mode 100644 index 00000000..21848ab2 --- /dev/null +++ b/src/lib/recipients.ts @@ -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" `) or angle brackets + * (``). 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 `, 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 `, `"Quoted, Name" `, + * 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(); + 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 "` (the whole recipient quoted), `Name `, and + * `"Doe, John" ` 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 (``) is unwrapped before + * validating, so an `a ` fragment still yields the address. + * - `Name 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 `, a bare address, or the whole + // `Name ` 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 `, 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 ` fragment where only the 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, + }; +} diff --git a/src/lib/reply-identity.ts b/src/lib/reply-identity.ts new file mode 100644 index 00000000..20c68f0b --- /dev/null +++ b/src/lib/reply-identity.ts @@ -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; +} diff --git a/src/lib/signature-utils.ts b/src/lib/signature-utils.ts new file mode 100644 index 00000000..963585bc --- /dev/null +++ b/src/lib/signature-utils.ts @@ -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('`; + 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(/\n/g, '
'); +} + +/** + * 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 ? '

' : '

--
'; + + 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 + ? `

--

` + : `

`; + const endMarker = `


`; + if (identity?.htmlSignature?.trim()) { + return `${startMarker}
${sanitizeSignatureHtml(identity.htmlSignature)}
${endMarker}`; + } + if (identity?.textSignature?.trim()) { + return `${startMarker}

${escapeText(identity.textSignature)}

${endMarker}`; + } + return ''; +} + +const SIGNATURE_RANGE_RE = new RegExp( + `]*>[\\s\\S]*?]*>(?:)?

`, + '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)}`; +} diff --git a/src/lib/sub-addressing.ts b/src/lib/sub-addressing.ts new file mode 100644 index 00000000..726efd58 --- /dev/null +++ b/src/lib/sub-addressing.ts @@ -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 = { + '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 }; diff --git a/src/lib/template-utils.ts b/src/lib/template-utils.ts new file mode 100644 index 00000000..2fea0181 --- /dev/null +++ b/src/lib/template-utils.ts @@ -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(); + 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 { + 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 { + const now = new Date(); + const locale = context.locale || 'en'; + const intlLocale = locale === 'en' ? 'en-US' : locale; + + const values: Record = {}; + 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): 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; + + 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; + if (typeof t.name !== 'string' || !t.name.trim()) { + errors.push('missing_template_name'); + continue; + } + + const recipients = t.defaultRecipients as Record | 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 +
). */ +export function templateBodyToHtml(template: Pick): string { + if (template.isHTML) return template.body; + const escaped = template.body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r?\n/g, '
'); + return `

${escaped}

`; +} diff --git a/src/stores/templates-store.ts b/src/stores/templates-store.ts index 6c3927c2..07d6c9b9 100644 --- a/src/stores/templates-store.ts +++ b/src/stores/templates-store.ts @@ -6,13 +6,22 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { exportTemplates, importTemplates as parseTemplateImport } from '../lib/template-utils'; export interface EmailTemplate { id: string; name: string; subject: string; body: string; + /** Body is HTML (inserted verbatim) rather than plain text. */ + isHTML?: boolean; category: string; + defaultRecipients?: { + to?: string[]; + cc?: string[]; + bcc?: string[]; + }; + identityId?: string; isFavorite: boolean; createdAt: string; updatedAt: string; @@ -25,7 +34,10 @@ interface TemplatesState { hydrated: boolean; hydrate: () => Promise; - addTemplate: (data: Pick & Partial>) => EmailTemplate; + addTemplate: ( + data: Pick + & Partial>, + ) => EmailTemplate; updateTemplate: (id: string, updates: Partial>) => void; deleteTemplate: (id: string) => void; exportAll: () => string; @@ -71,6 +83,9 @@ export const useTemplatesStore = create((set, get) => ({ subject: data.subject, body: data.body, category: data.category ?? '', + isHTML: data.isHTML, + defaultRecipients: data.defaultRecipients, + identityId: data.identityId, isFavorite: data.isFavorite ?? false, createdAt: now, updatedAt: now, @@ -95,44 +110,19 @@ export const useTemplatesStore = create((set, get) => ({ persist(next); }, - exportAll: () => { - return JSON.stringify({ version: 1, templates: get().templates }, null, 2); - }, + // Webmail export envelope (type: 'webmail-templates', exportedAt) so a + // file exported here imports on the web and vice versa. + exportAll: () => exportTemplates(get().templates), importTemplates: (json) => { - try { - const parsed = JSON.parse(json) as { templates?: unknown }; - if (!Array.isArray(parsed.templates)) { - 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; - 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' }; + const result = parseTemplateImport(json); + if (result.errors.length && result.templates.length === 0) { + return { count: 0, error: result.errors[0] }; } + if (result.templates.length === 0) return { count: 0 }; + const merged = [...get().templates, ...result.templates]; + set({ templates: merged }); + persist(merged); + return { count: result.templates.length }; }, }));