mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api
This commit is contained in:
@@ -26,17 +26,17 @@ Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the
|
||||
|
||||
### Sending / EmailSubmission
|
||||
|
||||
- [ ] **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/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
|
||||
|
||||
- [ ] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — `P1` — `rn-only-bug`
|
||||
- [x] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — fixed in 7f956d6 — `P1` — `rn-only-bug`
|
||||
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
|
||||
- What 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/<lang>/`.
|
||||
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
|
||||
|
||||
- [ ] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
|
||||
- [x] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
|
||||
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
|
||||
- What 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.
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { EmailAddress } from './types';
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
to: EmailAddress[];
|
||||
cc?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
subject: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string;
|
||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
},
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: email.from,
|
||||
to: email.to,
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
mailboxIds: { [sentMailboxId]: true },
|
||||
keywords: { $seen: true },
|
||||
};
|
||||
|
||||
if (email.htmlBody) {
|
||||
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
||||
emailCreate.bodyValues = { html: { value: email.htmlBody } };
|
||||
} else {
|
||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
|
||||
}
|
||||
|
||||
if (email.attachments?.length) {
|
||||
emailCreate.attachments = email.attachments.map((a) => ({
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: 'attachment',
|
||||
}));
|
||||
}
|
||||
|
||||
if (email.inReplyTo) {
|
||||
emailCreate['header:In-Reply-To:asText'] = email.inReplyTo;
|
||||
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
|
||||
}
|
||||
|
||||
await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: { 'sub-1': { emailId: '#draft', identityId } },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { 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('<p><br></p>');
|
||||
// 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('<blockquote');
|
||||
expect(html).toContain('line one<br>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 <href>', () => {
|
||||
expect(htmlToPlainText('<p>see <a href="https://x.y/z">docs</a></p>')).toBe('see docs <https://x.y/z>');
|
||||
});
|
||||
|
||||
it('keeps a bare URL link as the URL only', () => {
|
||||
expect(htmlToPlainText('<a href="https://x.y">https://x.y</a>')).toBe('https://x.y');
|
||||
});
|
||||
|
||||
it('drops the mailto: prefix when the text is the address', () => {
|
||||
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserAuthoredText', () => {
|
||||
it('drops the quoted island and blockquotes', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { name: 'Alice', email: 'a@b.com' },
|
||||
body: 'please find attached the invoice',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
const text = extractUserAuthoredText('<p>thanks</p>' + html);
|
||||
expect(text).toBe('thanks');
|
||||
});
|
||||
|
||||
it('cuts everything from the forwarded separator', () => {
|
||||
const text = extractUserAuthoredText(
|
||||
'<p>fyi</p><div>---------- Forwarded message ----------<br>From: x</div><p>attached</p>',
|
||||
{ forwardedSeparator: '---------- Forwarded message ----------' },
|
||||
);
|
||||
expect(text.trim()).toBe('fyi');
|
||||
});
|
||||
|
||||
it('drops quoted lines in plain-text mode', () => {
|
||||
expect(extractUserAuthoredText('mine\n> theirs attached', { plainTextMode: true })).toBe('mine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cid image hydration', () => {
|
||||
it('rewrites cid: images to placeholders with data-cid and back to data URLs', () => {
|
||||
const { html, cids } = rewriteCidImagesForEditor('<p><img src="cid:pic@x" alt="p"></p>');
|
||||
expect(cids).toEqual(['pic@x']);
|
||||
expect(html).toContain('data-cid="pic@x"');
|
||||
expect(html).not.toContain('cid:pic@x');
|
||||
const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', 'data:image/png;base64,AAA']]));
|
||||
expect(hydrated).toContain('src="data:image/png;base64,AAA"');
|
||||
expect(hydrated).toContain('data-cid="pic@x"');
|
||||
expect(rewriteInlineImages(hydrated).html).toContain('src="cid:pic@x"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildQuoteHeader, formatQuoteSender } from '../quote-header';
|
||||
|
||||
describe('quote header', () => {
|
||||
it('renders Name <email> escaped in the reply line', () => {
|
||||
const h = buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: { name: 'Alice', email: 'a@b.com' }, receivedAt: '2026-04-27T10:00:00Z' },
|
||||
timeFormat: '24h',
|
||||
unknownLabel: 'Unknown',
|
||||
});
|
||||
expect(h.wrapInBlockquote).toBe(true);
|
||||
expect(h.html).toMatch(/^<div>On .+, Alice <a@b\.com> wrote:<br><\/div>$/);
|
||||
expect(h.text).toMatch(/Alice <a@b\.com> wrote:\n$/);
|
||||
});
|
||||
|
||||
it('uses localized labels', () => {
|
||||
const h = buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: { email: 'a@b.com' }, subject: 'S' },
|
||||
timeFormat: '24h',
|
||||
unknownLabel: 'Unbekannt',
|
||||
labels: {
|
||||
replyLine: 'Am {date} schrieb {from}:',
|
||||
forwardedSeparator: '---------- Weitergeleitete Nachricht ----------',
|
||||
fromLabel: 'Von',
|
||||
dateLabel: 'Datum',
|
||||
subjectLabel: 'Betreff',
|
||||
},
|
||||
});
|
||||
expect(h.wrapInBlockquote).toBe(false);
|
||||
expect(h.html).toContain('Weitergeleitete Nachricht');
|
||||
expect(h.html).toContain('Von: a@b.com<br>Datum: <br>Betreff: S');
|
||||
});
|
||||
|
||||
it('collapses the date when unknown', () => {
|
||||
const h = buildQuoteHeader({ mode: 'reply', email: { from: { email: 'a@b.com' } }, timeFormat: '24h', unknownLabel: 'U' });
|
||||
expect(h.html).toBe('<div>a@b.com wrote:<br></div>');
|
||||
expect(formatQuoteSender(undefined, 'U')).toBe('U');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isValidEmail,
|
||||
splitRecipients,
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipient,
|
||||
expandRecipients,
|
||||
splitPastedRecipients,
|
||||
parseMailtoUrl,
|
||||
} from '../recipients';
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it('accepts ordinary addresses', () => {
|
||||
expect(isValidEmail('a@b.co')).toBe(true);
|
||||
expect(isValidEmail('first.last+tag@sub.example.org')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects injection and malformed input', () => {
|
||||
expect(isValidEmail('')).toBe(false);
|
||||
expect(isValidEmail('a@b')).toBe(true);
|
||||
expect(isValidEmail('a@b..c')).toBe(false);
|
||||
expect(isValidEmail('a@b.c\r\nBcc: x@y.z')).toBe(false);
|
||||
expect(isValidEmail('<a@b.c>')).toBe(false);
|
||||
expect(isValidEmail('noat')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitRecipients', () => {
|
||||
it('splits on commas outside quotes and angle brackets', () => {
|
||||
expect(splitRecipients('"Doe, John" <j@x.com>, b@y.com')).toEqual(['"Doe, John" <j@x.com>', 'b@y.com']);
|
||||
});
|
||||
|
||||
it('recovers from an unclosed angle bracket', () => {
|
||||
expect(splitRecipients('John <j@x.com, b@y.com')).toEqual(['John <j@x.com', 'b@y.com']);
|
||||
});
|
||||
|
||||
it('keeps RFC 5322 groups as one entry', () => {
|
||||
expect(splitRecipients('Team: a@x.com, b@y.com;, c@z.com')).toEqual(['Team: a@x.com, b@y.com;', 'c@z.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRecipient', () => {
|
||||
it('parses Name <email> and unquotes the name', () => {
|
||||
expect(parseRecipient('"Doe, John" <j@x.com>')).toEqual({ name: 'Doe, John', email: 'j@x.com' });
|
||||
expect(parseRecipient('Jane <jane@x.com>')).toEqual({ name: 'Jane', email: 'jane@x.com' });
|
||||
});
|
||||
|
||||
it('parses a bare address', () => {
|
||||
expect(parseRecipient(' a@b.com ')).toEqual({ email: 'a@b.com' });
|
||||
});
|
||||
|
||||
it('parses a group into member chips', () => {
|
||||
const r = parseRecipient('Team: a@x.com, "B" <b@y.com>;');
|
||||
expect(r.email).toBe('');
|
||||
expect(r.name).toBe('Team');
|
||||
expect(r.group?.members.map((m) => m.email)).toEqual(['a@x.com', 'b@y.com']);
|
||||
});
|
||||
|
||||
it('round-trips through formatRecipient / parseRecipientList', () => {
|
||||
const list = parseRecipientList([formatRecipient('Doe, John', 'j@x.com'), formatRecipient(undefined, 'b@y.com')].join(', '));
|
||||
expect(list).toEqual([{ name: 'Doe, John', email: 'j@x.com' }, { email: 'b@y.com' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecipients', () => {
|
||||
it('expands groups and dedupes case-insensitively', () => {
|
||||
const out = expandRecipients([
|
||||
{ name: 'A', email: 'a@x.com' },
|
||||
{ name: 'Team', email: '', group: { members: [{ email: 'A@x.com' }, { name: 'B', email: 'b@y.com' }] } },
|
||||
]);
|
||||
expect(out).toEqual([{ name: 'A', email: 'a@x.com' }, { name: 'B', email: 'b@y.com' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitPastedRecipients', () => {
|
||||
it('keeps display names and splits bare dumps', () => {
|
||||
const { valid, invalid } = splitPastedRecipients('"Doe, John" <j@x.com>; b@y.com c@z.com\nnot-an-address');
|
||||
expect(valid).toEqual([{ name: 'Doe, John', email: 'j@x.com' }, { email: 'b@y.com' }, { email: 'c@z.com' }]);
|
||||
expect(invalid).toEqual(['not-an-address']);
|
||||
});
|
||||
|
||||
it('recovers a name with a missing closing bracket', () => {
|
||||
const { valid } = splitPastedRecipients('Jane <jane@x.com');
|
||||
expect(valid).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||
});
|
||||
|
||||
it('skips addresses already present', () => {
|
||||
const { valid } = splitPastedRecipients('a@x.com, b@y.com', ['A@x.com']);
|
||||
expect(valid).toEqual([{ email: 'b@y.com' }]);
|
||||
});
|
||||
|
||||
it('unwraps a fully quoted recipient', () => {
|
||||
const { valid } = splitPastedRecipients('"Jane <jane@x.com>"');
|
||||
expect(valid).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMailtoUrl', () => {
|
||||
it('parses recipients, subject and body', () => {
|
||||
expect(parseMailtoUrl('mailto:a@b.com,c@d.com?subject=Hi%20there&body=line1%0Aline2&cc=e@f.com')).toEqual({
|
||||
to: ['a@b.com', 'c@d.com'],
|
||||
cc: ['e@f.com'],
|
||||
bcc: undefined,
|
||||
subject: 'Hi there',
|
||||
body: 'line1\nline2',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-mailto or empty', () => {
|
||||
expect(parseMailtoUrl('https://x')).toBeNull();
|
||||
expect(parseMailtoUrl('mailto:')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findReplyIdentityId, findDraftIdentityId, resolveReplyFrom, findComposeIdentityId } from '../reply-identity';
|
||||
import type { Identity } from '../../api/types';
|
||||
|
||||
const identities: Identity[] = [
|
||||
{ id: 'main', name: 'Me', email: 'me@example.com', mayDelete: false },
|
||||
{ id: 'alias', name: 'Alias', email: 'me@example.com', mayDelete: true },
|
||||
{ id: 'info', name: 'Info', email: 'info@example.com', mayDelete: true },
|
||||
];
|
||||
|
||||
describe('findReplyIdentityId', () => {
|
||||
it('matches exact address across to/cc/bcc', () => {
|
||||
expect(findReplyIdentityId(identities, { bcc: [{ email: 'INFO@example.com' }] })).toBe('info');
|
||||
});
|
||||
|
||||
it('falls back to the +tag-stripped address', () => {
|
||||
expect(findReplyIdentityId(identities, { to: [{ email: 'info+news@example.com' }] })).toBe('info');
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(findReplyIdentityId(identities, { to: [{ email: 'x@other.com' }] })).toBeNull();
|
||||
expect(findReplyIdentityId([], { to: [{ email: 'me@example.com' }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findComposeIdentityId', () => {
|
||||
it('matches the account address', () => {
|
||||
expect(findComposeIdentityId(identities, 'info+x@example.com')).toBe('info');
|
||||
expect(findComposeIdentityId(identities, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDraftIdentityId', () => {
|
||||
it('disambiguates by name when two identities share an address', () => {
|
||||
expect(findDraftIdentityId(identities, { email: 'me@example.com', name: 'Alias' })).toBe('alias');
|
||||
expect(findDraftIdentityId(identities, { email: 'me@example.com', name: 'Someone' })).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReplyFrom', () => {
|
||||
it('returns the identity without override on an exact match', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'info@example.com' }] })).toEqual({ identityId: 'info' });
|
||||
});
|
||||
|
||||
it('proposes a catch-all From override on an owned domain', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'sales@example.com', name: 'Sales' }] })).toEqual({
|
||||
identityId: 'main',
|
||||
overrideEmail: 'sales@example.com',
|
||||
overrideName: 'Sales',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for foreign domains', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'x@other.com' }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeSignatureHtml,
|
||||
getPlainTextSignature,
|
||||
appendPlainTextSignature,
|
||||
plainTextBodyHasSignature,
|
||||
plainTextBodyWithoutSignature,
|
||||
buildEmbeddedSignatureHtml,
|
||||
containsEmbeddedSignature,
|
||||
spliceSignature,
|
||||
stripEmbeddedSignature,
|
||||
insertSignatureAboveQuote,
|
||||
} from '../signature-utils';
|
||||
|
||||
describe('sanitizeSignatureHtml', () => {
|
||||
it('keeps formatting, links and https images', () => {
|
||||
const out = sanitizeSignatureHtml('<p><b>Jane</b> <a href="https://x.y">site</a> <img src="https://x.y/l.png" alt="l"></p>');
|
||||
expect(out).toBe('<p><b>Jane</b> <a href="https://x.y">site</a> <img src="https://x.y/l.png" alt="l"></p>');
|
||||
});
|
||||
|
||||
it('drops scripts, event handlers, javascript: links and http images', () => {
|
||||
const out = sanitizeSignatureHtml('<p onclick="x()">a<script>b()</script><a href="javascript:1">l</a><img src="http://x/l.png"></p>');
|
||||
expect(out).toBe('<p>a<a>l</a></p>');
|
||||
});
|
||||
|
||||
it('drops disallowed tags but keeps their text', () => {
|
||||
expect(sanitizeSignatureHtml('<h1>Hi</h1><ul><li>x</li></ul>')).toBe('Hix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain-text signature helpers', () => {
|
||||
const sig = { textSignature: 'Jane\r\nACME \n' };
|
||||
|
||||
it('normalizes line breaks', () => {
|
||||
expect(getPlainTextSignature(sig)).toBe('Jane\nACME');
|
||||
expect(getPlainTextSignature({ htmlSignature: '<p>Jane</p><p>ACME</p>' })).toBe('Jane\n\nACME');
|
||||
});
|
||||
|
||||
it('appends with and without separator', () => {
|
||||
expect(appendPlainTextSignature('hi', sig)).toBe('hi\n\n-- \nJane\nACME');
|
||||
expect(appendPlainTextSignature('hi', sig, { separator: false })).toBe('hi\n\nJane\nACME');
|
||||
expect(appendPlainTextSignature('hi', null)).toBe('hi');
|
||||
});
|
||||
|
||||
it('detects and strips a trailing signature', () => {
|
||||
const body = 'hello\n\n-- \nJane\nACME';
|
||||
expect(plainTextBodyHasSignature(body, sig)).toBe(true);
|
||||
expect(plainTextBodyWithoutSignature(body, sig)).toBe('hello');
|
||||
expect(plainTextBodyWithoutSignature('other', sig)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedded signature range', () => {
|
||||
const identity = { textSignature: 'Jane <jane@x.y>' };
|
||||
|
||||
it('builds a marked range with the separator', () => {
|
||||
const html = buildEmbeddedSignatureHtml(identity, { separator: true });
|
||||
expect(html).toContain('data-signature-block="separator">-- </p>');
|
||||
expect(html).toContain('Jane <jane@x.y>');
|
||||
expect(html).toContain('data-signature-block="end"');
|
||||
expect(containsEmbeddedSignature(html)).toBe(true);
|
||||
expect(buildEmbeddedSignatureHtml({}, { separator: true })).toBe('');
|
||||
});
|
||||
|
||||
it('swaps the range on identity change and strips it', () => {
|
||||
const a = buildEmbeddedSignatureHtml({ textSignature: 'A' }, { separator: true });
|
||||
const b = buildEmbeddedSignatureHtml({ htmlSignature: '<b>B</b>' }, { separator: false });
|
||||
const body = `<p>hello</p>${a}<div data-quoted-html="true">q</div>`;
|
||||
const swapped = spliceSignature(body, b);
|
||||
expect(swapped).toBe(`<p>hello</p>${b}<div data-quoted-html="true">q</div>`);
|
||||
expect(stripEmbeddedSignature(swapped)).toBe('<p>hello</p><div data-quoted-html="true">q</div>');
|
||||
expect(spliceSignature('<p>x</p>', a)).toBe(`<p>x</p>${a}`);
|
||||
});
|
||||
|
||||
it('inserts above the quote marker', () => {
|
||||
const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true });
|
||||
const marker = '<div data-quoted-html="true">';
|
||||
expect(insertSignatureAboveQuote(`<p><br></p>${marker}q</div>`, sig, marker)).toBe(`<p><br></p>${sig}${marker}q</div>`);
|
||||
expect(insertSignatureAboveQuote('<p>x</p>', sig, marker)).toBe(`<p>x</p>${sig}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseSubAddress,
|
||||
generateSubAddress,
|
||||
suggestTagsForDomain,
|
||||
getTagValidationError,
|
||||
isValidSubAddressDelimiter,
|
||||
} from '../sub-addressing';
|
||||
|
||||
describe('sub-addressing', () => {
|
||||
it('parses the tag after the first delimiter', () => {
|
||||
expect(parseSubAddress('user+shop+x@example.com')).toMatchObject({ baseUser: 'user', tag: 'shop+x', domain: 'example.com' });
|
||||
expect(parseSubAddress('user@example.com').tag).toBeNull();
|
||||
expect(parseSubAddress('user-shop@example.com', '-').tag).toBe('shop');
|
||||
});
|
||||
|
||||
it('generates and replaces tags', () => {
|
||||
expect(generateSubAddress('user@example.com', 'Shopping')).toBe('user+shopping@example.com');
|
||||
expect(generateSubAddress('user+old@example.com', 'new')).toBe('user+new@example.com');
|
||||
expect(generateSubAddress('user@example.com', '!!!')).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('suggests tags for known and generic domains', () => {
|
||||
expect(suggestTagsForDomain('amazon.de')).toEqual(['amazon', 'shopping', 'orders']);
|
||||
expect(suggestTagsForDomain('mail.acme.io')).toEqual(['acme', 'newsletter', 'registration']);
|
||||
});
|
||||
|
||||
it('validates tags and delimiters', () => {
|
||||
expect(getTagValidationError('')).toBe('EMPTY');
|
||||
expect(getTagValidationError('a'.repeat(31))).toBe('TOO_LONG');
|
||||
expect(getTagValidationError('a b')).toBe('INVALID_CHARS');
|
||||
expect(getTagValidationError('ok-1')).toBeNull();
|
||||
expect(isValidSubAddressDelimiter('+')).toBe(true);
|
||||
expect(isValidSubAddressDelimiter('@')).toBe(false);
|
||||
expect(isValidSubAddressDelimiter('++')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractPlaceholders,
|
||||
substitutePlaceholders,
|
||||
hasUnresolvedPlaceholders,
|
||||
getAutoFilledPlaceholders,
|
||||
exportTemplates,
|
||||
importTemplates,
|
||||
templateBodyToHtml,
|
||||
validateTemplateName,
|
||||
} from '../template-utils';
|
||||
|
||||
describe('placeholders', () => {
|
||||
it('extracts unique placeholder names', () => {
|
||||
expect(extractPlaceholders('Hi {{recipient_name}}, {{date}} {{recipient_name}}')).toEqual(['recipient_name', 'date']);
|
||||
});
|
||||
|
||||
it('substitutes known values and strips tags from them', () => {
|
||||
expect(substitutePlaceholders('Hi {{a}} {{b}}', { a: '<b>x</b>' })).toBe('Hi x {{b}}');
|
||||
expect(hasUnresolvedPlaceholders('Hi {{b}}')).toBe(true);
|
||||
expect(hasUnresolvedPlaceholders('Hi')).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-fills date, weekday and sender', () => {
|
||||
const v = getAutoFilledPlaceholders({ senderName: 'Jane', locale: 'en' });
|
||||
expect(v.sender_name).toBe('Jane');
|
||||
expect(v.date.length).toBeGreaterThan(0);
|
||||
expect(v.day_of_week.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates names', () => {
|
||||
expect(validateTemplateName(' ')).toBe('empty');
|
||||
expect(validateTemplateName('x'.repeat(201))).toBe('too_long');
|
||||
expect(validateTemplateName('ok')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('export / import', () => {
|
||||
const tpl = {
|
||||
id: 't1', name: 'Hello', subject: 'S', body: 'B', category: 'c', isFavorite: true,
|
||||
isHTML: false, defaultRecipients: { to: ['a@b.c'] }, identityId: 'id1',
|
||||
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
it('emits the webmail envelope', () => {
|
||||
const parsed = JSON.parse(exportTemplates([tpl]));
|
||||
expect(parsed.type).toBe('webmail-templates');
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(typeof parsed.exportedAt).toBe('string');
|
||||
expect(parsed.templates[0].defaultRecipients).toEqual({ to: ['a@b.c'] });
|
||||
});
|
||||
|
||||
it('round-trips isHTML / defaultRecipients / identityId with fresh ids', () => {
|
||||
const { templates, errors } = importTemplates(exportTemplates([tpl]));
|
||||
expect(errors).toEqual([]);
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].id).not.toBe('t1');
|
||||
expect(templates[0]).toMatchObject({ name: 'Hello', isHTML: false, identityId: 'id1', defaultRecipients: { to: ['a@b.c'] }, isFavorite: true });
|
||||
});
|
||||
|
||||
it('rejects foreign envelopes and bad JSON', () => {
|
||||
expect(importTemplates('{"type":"other","templates":[]}').errors).toEqual(['invalid_type']);
|
||||
expect(importTemplates('nope').errors).toEqual(['invalid_json']);
|
||||
expect(importTemplates('{"templates":[{"body":"x"}]}').errors).toEqual(['missing_template_name']);
|
||||
});
|
||||
|
||||
it('accepts the legacy RN export without a type', () => {
|
||||
expect(importTemplates('{"version":1,"templates":[{"name":"n","body":"b"}]}').templates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('escapes plain-text bodies for the editor', () => {
|
||||
expect(templateBodyToHtml({ body: 'a<b\nc', isHTML: false })).toBe('<p>a<b<br>c</p>');
|
||||
expect(templateBodyToHtml({ body: '<p>x</p>', isHTML: true })).toBe('<p>x</p>');
|
||||
});
|
||||
});
|
||||
+188
-54
@@ -3,6 +3,7 @@
|
||||
// All values are HTML-escaped before insertion into the document.
|
||||
|
||||
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, '<br>');
|
||||
}
|
||||
|
||||
// Webmail's default quote header labels the sender by display name when there
|
||||
// is one, falling back to the bare address — not "Name <addr>".
|
||||
function senderName(from?: { email?: string; name?: string }, unknownLabel = 'Unknown'): string {
|
||||
if (!from) return unknownLabel;
|
||||
return from.name || from.email || unknownLabel;
|
||||
}
|
||||
|
||||
// Mirror the webmail quote-header date: locale- and 12/24h-aware, with a short
|
||||
// weekday (lib/quote-header.ts → formatDateTime with weekday/year/month/day).
|
||||
function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
||||
return d.toLocaleString(intlLocale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
}
|
||||
|
||||
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
|
||||
|
||||
/**
|
||||
* Marker attribute on the wrapper that holds the quoted original (header +
|
||||
* body). The signature splice uses it to place the signature above the quote,
|
||||
* and the attachment reminder strips it before keyword matching (#570).
|
||||
*/
|
||||
export const QUOTED_BLOCK_ATTR = 'data-quoted-html';
|
||||
export const QUOTED_BLOCK_START = `<div ${QUOTED_BLOCK_ATTR}="true">`;
|
||||
|
||||
/**
|
||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||
* 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 <date>, <sender> 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 '<p><br></p>';
|
||||
|
||||
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 '<p><br></p>';
|
||||
|
||||
if (mode === 'forward') {
|
||||
// Forwarded message block (From/Date/Subject), body appended unquoted.
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>---------- Forwarded message ----------<br>',
|
||||
`From: ${escapeHtml(fromStr)}<br>`,
|
||||
`Date: ${escapeHtml(date)}<br>`,
|
||||
`Subject: ${escapeHtml(subject)}<br><br>`,
|
||||
'</div>',
|
||||
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 [
|
||||
'<p><br></p>',
|
||||
`<div>${headerLine}<br></div>`,
|
||||
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
const quoted = header.wrapInBlockquote
|
||||
? `${header.html}<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`
|
||||
: `${header.html}${quotedBody}`;
|
||||
|
||||
return `<p><br></p>${QUOTED_BLOCK_START}${quoted}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <href>`
|
||||
* 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 <href>" unless the text already is the URL (or mailto target).
|
||||
s = s.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_full, attrs: string, inner: string) => {
|
||||
const hrefMatch = attrs.match(/\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||
const href = (hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? '').trim();
|
||||
const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!href) return inner;
|
||||
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||
if (!text || text === href || text === normalizedHref) return text || href;
|
||||
// Angle brackets are restored after the tag strip below.
|
||||
return `${text} \u0001${href}\u0002`;
|
||||
});
|
||||
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
||||
s = s.replace(/<br\s*\/?>/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(/<li[^>]*>/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 `<tag …>…</tag>` block (nesting-aware) for every
|
||||
* occurrence in `html`. Used to drop quoted content before keyword matching.
|
||||
*/
|
||||
function removeBalancedBlocks(html: string, openRe: RegExp, tag: string): string {
|
||||
let out = html;
|
||||
let m: RegExpExecArray | null;
|
||||
const open = new RegExp(openRe.source, 'i');
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((m = open.exec(out)) !== null) {
|
||||
const start = m.index;
|
||||
let depth = 0;
|
||||
let i = start;
|
||||
const tokenRe = new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi');
|
||||
tokenRe.lastIndex = start;
|
||||
let end = -1;
|
||||
let tok: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((tok = tokenRe.exec(out)) !== null) {
|
||||
i = tok.index;
|
||||
if (tok[0].startsWith('</')) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + tok[0].length;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
if (end === -1) {
|
||||
out = out.slice(0, start);
|
||||
break;
|
||||
}
|
||||
out = out.slice(0, start) + out.slice(end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the user actually wrote: the body minus the quoted original
|
||||
* (`data-quoted-html` island / blockquotes) and everything from the forwarded
|
||||
* separator onward. Port of the webmail's `extractUserAuthoredText` (#570).
|
||||
*/
|
||||
export function extractUserAuthoredText(
|
||||
body: string,
|
||||
options: { plainTextMode?: boolean; forwardedSeparator?: string } = {},
|
||||
): string {
|
||||
const { plainTextMode = false, forwardedSeparator } = options;
|
||||
|
||||
let text: string;
|
||||
if (plainTextMode) {
|
||||
text = body
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*>/.test(line))
|
||||
.join('\n');
|
||||
} else {
|
||||
let html = removeBalancedBlocks(body, new RegExp(`<div\\b[^>]*\\s${QUOTED_BLOCK_ATTR}\\b`), 'div');
|
||||
html = removeBalancedBlocks(html, /<blockquote\b/, 'blockquote');
|
||||
text = htmlToPlainText(html);
|
||||
}
|
||||
|
||||
const trimmedSeparator = forwardedSeparator?.trim();
|
||||
if (trimmedSeparator) {
|
||||
const pattern = trimmedSeparator
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\s+/g, '\\s+');
|
||||
const match = text.match(new RegExp(pattern));
|
||||
if (match && match.index !== undefined) {
|
||||
text = text.slice(0, match.index);
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
||||
* (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 `<img src="cid:xxx">` references into
|
||||
* `<img src="<placeholder>" data-cid="xxx">` so the editor can render the
|
||||
* quoted original (a `cid:` URL would show a broken image) while still
|
||||
* carrying the cid through edits. Returns the cids found.
|
||||
*/
|
||||
export function rewriteCidImagesForEditor(html: string): { html: string; cids: string[] } {
|
||||
if (!html || html.indexOf('cid:') === -1) return { html, cids: [] };
|
||||
const cids = new Set<string>();
|
||||
const out = html.replace(
|
||||
/<img\b([^>]*?)\ssrc=("cid:([^"]*)"|'cid:([^']*)')([^>]*)>/gi,
|
||||
(_full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||
const cid = (dq ?? sq ?? '').trim();
|
||||
if (!cid) return _full;
|
||||
cids.add(cid);
|
||||
const rest = (before + after).replace(/\sdata-cid=("[^"]*"|'[^']*')/gi, '').trim();
|
||||
return `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||
},
|
||||
);
|
||||
return { html: out, cids: Array.from(cids) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched.
|
||||
*/
|
||||
export function replaceInlineImagePlaceholders(
|
||||
html: string,
|
||||
cidToDataUrl: Map<string, string>,
|
||||
): string {
|
||||
if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html;
|
||||
return html.replace(
|
||||
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
||||
(full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||
const cid = dq ?? sq ?? '';
|
||||
const dataUrl = cidToDataUrl.get(cid);
|
||||
if (!dataUrl) return full;
|
||||
const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim();
|
||||
return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniffs the real image MIME type from a blob's leading magic bytes, returning
|
||||
* null when the bytes aren't a recognizable image (webmail #543).
|
||||
*/
|
||||
export function sniffImageMime(bytes: Uint8Array): string | null {
|
||||
if (!bytes || bytes.length < 4) return null;
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif';
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||||
) return 'image/webp';
|
||||
if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Default reply/forward quote header. Port of the webmail's lib/quote-header.ts
|
||||
// (without the plugin transform, which has no RN runtime).
|
||||
//
|
||||
// Both the forward "From:" line and the reply "On … wrote:" line show the
|
||||
// full sender incl. address ("Name <email>"), like Gmail/Outlook (#482). The
|
||||
// interpolated values are HTML-escaped: an unescaped "<email>" would be parsed
|
||||
// as a tag by the editor and silently dropped.
|
||||
|
||||
import { escapeHtml } from './email-html';
|
||||
import type { TimeFormat } from '../stores/settings-store';
|
||||
|
||||
/** Localized label set the caller passes in (from the locale catalog). */
|
||||
export interface QuoteHeaderLabels {
|
||||
/** Reply line template with `{date}` and `{from}` placeholders. */
|
||||
replyLine: string;
|
||||
forwardedSeparator: string;
|
||||
fromLabel: string;
|
||||
dateLabel: string;
|
||||
subjectLabel: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_QUOTE_HEADER_LABELS: QuoteHeaderLabels = {
|
||||
replyLine: 'On {date}, {from} wrote:',
|
||||
forwardedSeparator: '---------- Forwarded message ----------',
|
||||
fromLabel: 'From',
|
||||
dateLabel: 'Date',
|
||||
subjectLabel: 'Subject',
|
||||
};
|
||||
|
||||
export interface QuoteHeader {
|
||||
html: string;
|
||||
text: string;
|
||||
wrapInBlockquote: boolean;
|
||||
}
|
||||
|
||||
export interface QuoteHeaderArgs {
|
||||
mode: 'reply' | 'replyAll' | 'forward';
|
||||
email: {
|
||||
from?: { email?: string; name?: string };
|
||||
subject?: string;
|
||||
receivedAt?: string;
|
||||
};
|
||||
timeFormat: TimeFormat;
|
||||
locale?: string;
|
||||
unknownLabel: string;
|
||||
labels?: QuoteHeaderLabels;
|
||||
}
|
||||
|
||||
/** Locale- and 12/24h-aware date with a short weekday (webmail formatDateTime). */
|
||||
export function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
||||
try {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
} catch {
|
||||
return d.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
/** "Name <email>" when both are known and distinct, else whichever exists. */
|
||||
export function formatQuoteSender(
|
||||
from: { email?: string; name?: string } | undefined,
|
||||
unknownLabel: string,
|
||||
): string {
|
||||
if (!from) return unknownLabel;
|
||||
if (from.name && from.email && from.name !== from.email) return `${from.name} <${from.email}>`;
|
||||
return from.email || from.name || unknownLabel;
|
||||
}
|
||||
|
||||
function fillReplyLine(template: string, vars: { date: string; from: string }): string {
|
||||
const line = template.replace('{date}', vars.date).replace('{from}', vars.from).replace('{sender}', vars.from);
|
||||
// A missing date leaves "On , X wrote:" - collapse the dangling comma.
|
||||
return vars.date ? line : line.replace(/^\S+\s*,\s*/, '');
|
||||
}
|
||||
|
||||
export function buildQuoteHeader(args: QuoteHeaderArgs): QuoteHeader {
|
||||
const { mode, email, timeFormat, locale, unknownLabel } = args;
|
||||
const labels = args.labels ?? DEFAULT_QUOTE_HEADER_LABELS;
|
||||
const date = formatQuoteDate(email.receivedAt, timeFormat, locale);
|
||||
const fromStrFull = formatQuoteSender(email.from, unknownLabel);
|
||||
const subject = email.subject || '';
|
||||
|
||||
if (mode === 'forward') {
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
const text = `${fillReplyLine(labels.replyLine, { date, from: fromStrFull })}\n`;
|
||||
const html = `<div>${fillReplyLine(labels.replyLine, { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></div>`;
|
||||
return { html, text, wrapInBlockquote: true };
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// Recipient parsing / validation for the composer. Port of the webmail's
|
||||
// lib/validation.ts (isValidEmail, parseMailtoUrl) and the recipient helpers
|
||||
// in lib/email-composer-utils.ts (splitRecipients, parseRecipient,
|
||||
// splitPastedRecipients, expandRecipients). All DOM-free.
|
||||
|
||||
import { splitMailbox } from './rfc5322-mailbox';
|
||||
|
||||
/**
|
||||
* A composer recipient. Display name is optional; email is required - except
|
||||
* for contact-group chips, which carry their already-resolved members and an
|
||||
* empty email. Group chips are expanded into their members when the message
|
||||
* is sent or saved as a draft (see {@link expandRecipients}).
|
||||
*/
|
||||
export interface Recipient {
|
||||
name?: string;
|
||||
email: string;
|
||||
group?: { members: Array<{ name?: string; email: string }> };
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 5322 compliant email validation with security enhancements.
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
// Length check
|
||||
if (!email || email.length > 254) return false;
|
||||
|
||||
// Security: Block control characters and header injection
|
||||
if (/[\r\n\0<>]/.test(email)) return false;
|
||||
|
||||
// RFC 5322 compliant regex (simplified but secure)
|
||||
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
if (!emailRegex.test(email)) return false;
|
||||
|
||||
// Additional checks
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
// Local part max 64 chars
|
||||
if (localPart.length > 64) return false;
|
||||
|
||||
// Domain validation
|
||||
if (domain.length > 255) return false;
|
||||
if (domain.startsWith('.') || domain.endsWith('.')) return false;
|
||||
if (domain.includes('..')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the angle run that is open at `from` closes before the next one
|
||||
* opens. `from` is the index of the character under test (a separator, or the
|
||||
* opening `<` itself); the scan starts after it either way. A `>` inside a
|
||||
* quoted display name does not close anything, and a `<` with no `>` of its own
|
||||
* (or whose `>` sits past a later `<`) can never be an address delimiter, so
|
||||
* separators after it must stay separators.
|
||||
*/
|
||||
function angleRunCloses(value: string, from: number): boolean {
|
||||
let inQuotes = false;
|
||||
for (let i = from + 1; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (inQuotes) continue;
|
||||
else if (ch === '>') return true;
|
||||
else if (ch === '<') return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a recipient string into individual entries on any character in
|
||||
* `separators`, treating those characters as literal when they sit inside a
|
||||
* quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
|
||||
* (`<a,b@x>`). Trims each part and drops empties.
|
||||
*
|
||||
* Defaults to comma-only, the (de)serialization boundary used by the composer
|
||||
* state and mailto handling. Pasted lists pass a wider set (see
|
||||
* {@link splitPastedRecipients}) because they also use `;` and line breaks.
|
||||
*/
|
||||
export function splitRecipients(value: string, separators = ','): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let inAngle = false;
|
||||
let inGroup = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += ch;
|
||||
} else if (ch === '<' && !inQuotes) {
|
||||
inAngle = true;
|
||||
current += ch;
|
||||
} else if (ch === '>' && !inQuotes) {
|
||||
inAngle = false;
|
||||
current += ch;
|
||||
} else if (separators.includes(ch) && inAngle && !inQuotes && !inGroup && !angleRunCloses(value, i)) {
|
||||
// An unclosed `<` would otherwise swallow every later separator, folding
|
||||
// the whole list into one entry that is not an address at all. Inside a
|
||||
// group the separators belong to the group, so `inGroup` still wins.
|
||||
inAngle = false;
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
current = '';
|
||||
} else if (ch === ':' && !inQuotes && !inAngle) {
|
||||
// RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group,
|
||||
// separators inside it included, as a single entry. A colon inside a
|
||||
// display name is always quoted (see NAME_NEEDS_QUOTING), so a bare
|
||||
// colon reliably opens a group.
|
||||
inGroup = true;
|
||||
current += ch;
|
||||
} else if (ch === ';' && inGroup && !inQuotes && !inAngle) {
|
||||
inGroup = false;
|
||||
current += ch;
|
||||
} else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display names containing any of these must be wrapped in a quoted-string so
|
||||
// they survive comma-splitting at the serialization boundary and round-trip.
|
||||
const NAME_NEEDS_QUOTING = /[,<>"@;:]/;
|
||||
|
||||
/**
|
||||
* Formats a recipient as a string. Bare email when there's no distinct name;
|
||||
* otherwise `Name <email>`, RFC 5322 quoting the name when it contains a comma
|
||||
* or other special character.
|
||||
*/
|
||||
export function formatRecipient(name: string | undefined, email: string): string {
|
||||
const trimmedName = name?.trim();
|
||||
if (!trimmedName || trimmedName === email) return email;
|
||||
const quoted = NAME_NEEDS_QUOTING.test(trimmedName)
|
||||
? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"`
|
||||
: trimmedName;
|
||||
return `${quoted} <${email}>`;
|
||||
}
|
||||
|
||||
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
|
||||
function unquoteName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Index of the first colon outside quotes/angle brackets, or -1. */
|
||||
function findTopLevelColon(value: string): number {
|
||||
let inQuotes = false;
|
||||
let inAngle = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (ch === '<' && !inQuotes) inAngle = true;
|
||||
else if (ch === '>' && !inQuotes) inAngle = false;
|
||||
else if (ch === ':' && !inQuotes && !inAngle) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
|
||||
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
|
||||
* RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is
|
||||
* how contact groups round-trip through the composer's string boundaries.
|
||||
*/
|
||||
export function parseRecipient(s: string): Recipient {
|
||||
const trimmed = s.trim();
|
||||
if (trimmed.endsWith(';')) {
|
||||
const colon = findTopLevelColon(trimmed);
|
||||
if (colon !== -1) {
|
||||
const members = splitRecipients(trimmed.slice(colon + 1, -1))
|
||||
.map(parseRecipient)
|
||||
.filter((m) => m.email && !m.group);
|
||||
// Only accept the group form when it actually carries members - typed
|
||||
// garbage like "Subject: hello;" stays a plain (invalid) recipient.
|
||||
if (members.length > 0) {
|
||||
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
|
||||
}
|
||||
}
|
||||
}
|
||||
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
||||
if (angleMatch) {
|
||||
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
|
||||
}
|
||||
return { email: trimmed };
|
||||
}
|
||||
|
||||
/** Parses a serialized comma-separated recipient string into an array. */
|
||||
export function parseRecipientList(value: string): Recipient[] {
|
||||
return splitRecipients(value).map(parseRecipient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single composer recipient, using RFC 5322 group syntax for
|
||||
* contact-group chips so they survive the composer's string boundaries.
|
||||
*/
|
||||
export function formatRecipientEntry(r: Recipient): string {
|
||||
if (r.group) {
|
||||
const name = r.name?.trim() || 'Group';
|
||||
const quoted = NAME_NEEDS_QUOTING.test(name)
|
||||
? `"${name.replace(/(["\\])/g, '\\$1')}"`
|
||||
: name;
|
||||
const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', ');
|
||||
return `${quoted}: ${members};`;
|
||||
}
|
||||
return formatRecipient(r.name, r.email);
|
||||
}
|
||||
|
||||
/** Serializes a recipient array into a comma-separated string. */
|
||||
export function formatRecipientList(recipients: Recipient[]): string {
|
||||
return recipients.map(formatRecipientEntry).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands contact-group chips into their members for sending and
|
||||
* draft-saving. Deduplicates case-insensitively by address across the whole
|
||||
* list, keeping the first occurrence - an explicitly added individual wins
|
||||
* over the same address arriving again via a group.
|
||||
*/
|
||||
export function expandRecipients(recipients: Recipient[]): Recipient[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Recipient[] = [];
|
||||
for (const r of recipients) {
|
||||
for (const entry of r.group ? r.group.members : [r]) {
|
||||
const key = entry.email.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(entry.name ? { name: entry.name, email: entry.email } : { email: entry.email });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level split of a pasted block into recipient entries on commas,
|
||||
* semicolons and newlines (separators inside a quoted name or angle brackets
|
||||
* stay literal).
|
||||
*/
|
||||
function splitPasteEntries(value: string): string[] {
|
||||
return splitRecipients(value, ',;\n\r');
|
||||
}
|
||||
|
||||
/** True when a whitespace/semicolon token of `value` is an address in its own right. */
|
||||
function carriesAddress(value: string): boolean {
|
||||
return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits typed or pasted text into recipient candidates and partitions them:
|
||||
* valid email addresses become `Recipient`s (deduped case-insensitively
|
||||
* against `existingEmails` and within the paste), and everything else is
|
||||
* returned as `invalid` for the caller to drop back into the input field.
|
||||
*
|
||||
* Handles both structured and bare lists, preserving display names:
|
||||
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
|
||||
* `"Doe, John" <email>` entries are kept intact with their display name.
|
||||
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
|
||||
* semicolon/newline separated) split into one chip per address.
|
||||
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
|
||||
* validating, so an `a <a@x.com>` fragment still yields the address.
|
||||
* - `Name <email` entries that lost their closing bracket keep both the name
|
||||
* and the address instead of falling apart into bare words.
|
||||
*/
|
||||
export function splitPastedRecipients(
|
||||
text: string,
|
||||
existingEmails: string[] = [],
|
||||
): { valid: Recipient[]; invalid: string[] } {
|
||||
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
|
||||
const valid: Recipient[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
// Adds a recipient if its address is valid and unseen. Returns true when the
|
||||
// entry is fully handled (valid or a known duplicate) so the caller can stop.
|
||||
const tryAdd = (r: Recipient): boolean => {
|
||||
if (!isValidEmail(r.email)) return false;
|
||||
const key = r.email.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const entry of splitPasteEntries(text)) {
|
||||
// 1. Structured: `Name <email>`, a bare address, or the whole
|
||||
// `Name <email>` wrapped in quotes (unwrap once and retry).
|
||||
if (tryAdd(parseRecipient(entry))) continue;
|
||||
const unwrapped = unquoteName(entry);
|
||||
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
|
||||
|
||||
// 2. `Name <email` with the closing bracket lost - splitMailbox tolerates
|
||||
// the missing `>`, so the display name survives the paste. It keeps the
|
||||
// last angle run only, so everything ahead of it would become display
|
||||
// name: skip this step when that prefix carries an address of its own
|
||||
// (`a@x.com <b@y.com`) rather than silently swallowing it.
|
||||
const lastOpenAngle = entry.lastIndexOf('<');
|
||||
if (
|
||||
lastOpenAngle !== -1 &&
|
||||
!carriesAddress(entry.slice(0, lastOpenAngle)) &&
|
||||
tryAdd(splitMailbox(entry))
|
||||
) continue;
|
||||
|
||||
// 3. Fallback: a bare-address run (`a@x.com b@y.com`) or a
|
||||
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
|
||||
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
|
||||
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
|
||||
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `mailto:` URL into composer prefill data. Returns null when the
|
||||
* URL carries no valid recipient. Port of the webmail's parseMailtoUrl.
|
||||
*/
|
||||
export function parseMailtoUrl(
|
||||
url: string,
|
||||
): { to: string[]; cc?: string[]; bcc?: string[]; subject?: string; body?: string } | null {
|
||||
if (!url || !/^mailto:/i.test(url)) return null;
|
||||
|
||||
const rest = url.slice(7);
|
||||
const queryIndex = rest.indexOf('?');
|
||||
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
|
||||
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
|
||||
|
||||
const decode = (value: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, '%20'));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const to = addressPart
|
||||
.split(',')
|
||||
.map((a) => decode(a).trim())
|
||||
.filter((a) => isValidEmail(a));
|
||||
|
||||
const cc: string[] = [];
|
||||
const bcc: string[] = [];
|
||||
let subject: string | undefined;
|
||||
let body: string | undefined;
|
||||
for (const pair of query.split('&')) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const key = pair.slice(0, eq).toLowerCase();
|
||||
const value = decode(pair.slice(eq + 1));
|
||||
if (key === 'subject') subject = value;
|
||||
else if (key === 'body') body = value;
|
||||
else if (key === 'to') {
|
||||
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) to.push(a);
|
||||
} else if (key === 'cc') {
|
||||
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) cc.push(a);
|
||||
} else if (key === 'bcc') {
|
||||
for (const a of value.split(',').map((s) => s.trim())) if (isValidEmail(a)) bcc.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
if (to.length === 0 && cc.length === 0 && bcc.length === 0 && !subject && !body) return null;
|
||||
return {
|
||||
to,
|
||||
cc: cc.length ? cc : undefined,
|
||||
bcc: bcc.length ? bcc : undefined,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Identity selection for replies, drafts and new messages. Port of the
|
||||
// webmail's lib/reply-identity.ts.
|
||||
|
||||
import type { Identity } from '../api/types';
|
||||
|
||||
interface ReplyRecipient {
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface ReplyRecipients {
|
||||
to?: ReplyRecipient[];
|
||||
cc?: ReplyRecipient[];
|
||||
bcc?: ReplyRecipient[];
|
||||
}
|
||||
|
||||
function normalizeEmailAddress(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBaseEmailAddress(email: string): string {
|
||||
const normalized = normalizeEmailAddress(email);
|
||||
const atIndex = normalized.indexOf('@');
|
||||
|
||||
if (atIndex <= 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const localPart = normalized.slice(0, atIndex);
|
||||
const domain = normalized.slice(atIndex + 1);
|
||||
const plusIndex = localPart.indexOf('+');
|
||||
|
||||
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
|
||||
}
|
||||
|
||||
function domainOf(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
return at > 0 ? email.slice(at + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity that received the original message: exact address match over
|
||||
* to/cc/bcc first, then with `+tag` sub-addressing stripped. Null when none
|
||||
* matches (caller keeps the default identity).
|
||||
*/
|
||||
export function findReplyIdentityId(
|
||||
identities: Identity[],
|
||||
recipients?: ReplyRecipients,
|
||||
): string | null {
|
||||
if (identities.length === 0 || !recipients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const receivedAddresses = [
|
||||
...(recipients.to || []),
|
||||
...(recipients.cc || []),
|
||||
...(recipients.bcc || []),
|
||||
]
|
||||
.map((recipient) => recipient.email?.trim())
|
||||
.filter((email): email is string => Boolean(email));
|
||||
|
||||
if (receivedAddresses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress));
|
||||
const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email)));
|
||||
if (exactIdentity) {
|
||||
return exactIdentity.id;
|
||||
}
|
||||
|
||||
const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress));
|
||||
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
||||
|
||||
return baseIdentity?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the identity to use for a NEW message started while viewing a specific
|
||||
* mailbox/account. Matches the active mailbox's address to a configured
|
||||
* identity (exact, then `+tag`-stripped). Returns `null` when no address is
|
||||
* given or none matches, so the caller keeps the primary identity.
|
||||
*/
|
||||
export function findComposeIdentityId(
|
||||
identities: Identity[],
|
||||
accountEmail?: string | null,
|
||||
): string | null {
|
||||
const email = accountEmail?.trim();
|
||||
if (identities.length === 0 || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exact = normalizeEmailAddress(email);
|
||||
const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact);
|
||||
if (exactIdentity) {
|
||||
return exactIdentity.id;
|
||||
}
|
||||
|
||||
const base = normalizeBaseEmailAddress(email);
|
||||
const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base);
|
||||
|
||||
return baseIdentity?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the identity a draft was composed with from its saved From. A draft
|
||||
* stores only the From address+name, not an identityId, so when two identities
|
||||
* share an address (a default + an alias with a different display name) the name
|
||||
* has to disambiguate - an email-only match picks the wrong one. Falls back to
|
||||
* email, then `+tag`-stripped email. Returns null when none match (caller keeps
|
||||
* the default).
|
||||
*/
|
||||
export function findDraftIdentityId(
|
||||
identities: Identity[],
|
||||
from?: { email?: string | null; name?: string | null } | null,
|
||||
): string | null {
|
||||
const email = from?.email?.trim();
|
||||
if (identities.length === 0 || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wantEmail = normalizeEmailAddress(email);
|
||||
const wantName = (from?.name ?? '').trim();
|
||||
|
||||
const nameAndEmail = identities.find(
|
||||
(i) => normalizeEmailAddress(i.email) === wantEmail && (i.name ?? '').trim() === wantName,
|
||||
);
|
||||
if (nameAndEmail) {
|
||||
return nameAndEmail.id;
|
||||
}
|
||||
|
||||
const exact = identities.find((i) => normalizeEmailAddress(i.email) === wantEmail);
|
||||
if (exact) {
|
||||
return exact.id;
|
||||
}
|
||||
|
||||
const wantBase = normalizeBaseEmailAddress(email);
|
||||
const base = identities.find((i) => normalizeBaseEmailAddress(i.email) === wantBase);
|
||||
return base?.id ?? null;
|
||||
}
|
||||
|
||||
export interface ReplyFromResolution {
|
||||
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
|
||||
identityId: string;
|
||||
/**
|
||||
* Override for the outgoing `From:` header. Populated when the incoming
|
||||
* message was delivered to an address on a domain the user owns (by
|
||||
* identity) but that isn't itself a configured identity - typical
|
||||
* domain-catch-all deployments. When set, the composer should put this
|
||||
* address (and `overrideName`) in the message's From header while sending
|
||||
* through the chosen identity.
|
||||
*/
|
||||
overrideEmail?: string;
|
||||
overrideName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the identity + optional header-From override for replying to a message.
|
||||
*
|
||||
* Decision order:
|
||||
* 1. If a recipient address exactly matches an identity, reply as that
|
||||
* identity with no override.
|
||||
* 2. Else if a recipient matches an identity after stripping `+tag`
|
||||
* sub-addressing, reply as that identity with no override.
|
||||
* 3. Else if a recipient address is on a domain that one of the identities
|
||||
* uses, treat that recipient as a catch-all alias: return the matching
|
||||
* identity + the recipient as a header-From override.
|
||||
* 4. Else return `null` (caller falls back to primary identity).
|
||||
*/
|
||||
export function resolveReplyFrom(
|
||||
identities: Identity[],
|
||||
recipients?: ReplyRecipients,
|
||||
): ReplyFromResolution | null {
|
||||
if (identities.length === 0 || !recipients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const received: { email: string; name: string | undefined }[] = [
|
||||
...(recipients.to || []),
|
||||
...(recipients.cc || []),
|
||||
...(recipients.bcc || []),
|
||||
].flatMap((r) => {
|
||||
const email = r.email?.trim();
|
||||
if (!email) return [];
|
||||
return [{ email, name: r.name?.trim() || undefined }];
|
||||
});
|
||||
|
||||
if (received.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email)));
|
||||
const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email)));
|
||||
|
||||
const exactIdentity = identities.find((i) =>
|
||||
received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)),
|
||||
);
|
||||
if (exactIdentity) {
|
||||
return { identityId: exactIdentity.id };
|
||||
}
|
||||
|
||||
const baseIdentity = identities.find((i) =>
|
||||
received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)),
|
||||
);
|
||||
if (baseIdentity) {
|
||||
return { identityId: baseIdentity.id };
|
||||
}
|
||||
|
||||
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
|
||||
|
||||
const catchAll = received.find((r) => {
|
||||
const email = normalizeEmailAddress(r.email);
|
||||
if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) {
|
||||
return false;
|
||||
}
|
||||
return ownedDomains.has(domainOf(email));
|
||||
});
|
||||
|
||||
if (catchAll) {
|
||||
const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0];
|
||||
return {
|
||||
identityId: anchor.id,
|
||||
overrideEmail: catchAll.email,
|
||||
overrideName: catchAll.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Signature helpers for the composer. Port of the webmail's
|
||||
// lib/signature-utils.ts plus the embedded-signature block builder from
|
||||
// components/email/email-composer.tsx (buildEmbeddedSignatureHtml).
|
||||
//
|
||||
// The RN editor is a plain contenteditable (no Tiptap atom), so the signature
|
||||
// is bracketed by `data-signature-block` marker paragraphs and is always
|
||||
// editable. The markers let an identity switch swap just the signature
|
||||
// without touching the draft text or the quoted message.
|
||||
|
||||
import { stripDangerousTags } from './email-html';
|
||||
import { htmlToPlainText } from './compose-html';
|
||||
|
||||
export type SignatureSource = {
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
export const SIGNATURE_RANGE_MARKER = 'data-signature-block';
|
||||
|
||||
const SIGNATURE_ALLOWED_TAGS = new Set([
|
||||
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
]);
|
||||
const SIGNATURE_ALLOWED_ATTRS = new Set([
|
||||
'href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title',
|
||||
'cellpadding', 'cellspacing', 'border', 'valign', 'align', 'bgcolor',
|
||||
'colspan', 'rowspan',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitize an HTML signature for storage and for the outgoing message. A
|
||||
* regex allowlist stands in for the webmail's DOMPurify config
|
||||
* (SIGNATURE_SANITIZE_CONFIG): formatting, links, images and tables only; no
|
||||
* script, no event handlers, no external URI schemes; images only over https:
|
||||
* or as a base64 raster data: URI.
|
||||
*/
|
||||
export function sanitizeSignatureHtml(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
const stripped = stripDangerousTags(html);
|
||||
return stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (full, rawTag: string, rawAttrs: string) => {
|
||||
const tag = rawTag.toLowerCase();
|
||||
if (!SIGNATURE_ALLOWED_TAGS.has(tag)) return '';
|
||||
if (full.startsWith('</')) return `</${tag}>`;
|
||||
const attrs: string[] = [];
|
||||
const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = attrRe.exec(rawAttrs)) !== null) {
|
||||
const name = m[1].toLowerCase();
|
||||
if (!SIGNATURE_ALLOWED_ATTRS.has(name)) continue;
|
||||
const value = m[3] ?? m[4] ?? m[5] ?? '';
|
||||
if (name === 'href' && !/^(https?:|mailto:|tel:)/i.test(value.trim())) continue;
|
||||
if (name === 'src' && !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(value.trim())) {
|
||||
// An image with a disallowed source is dropped entirely.
|
||||
return '';
|
||||
}
|
||||
if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(value)) continue;
|
||||
attrs.push(`${name}="${value.replace(/"/g, '"')}"`);
|
||||
}
|
||||
const selfClose = /\/\s*$/.test(rawAttrs) ? ' /' : '';
|
||||
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}${selfClose}>`;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSignatureLineBreaks(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function hasSignature(signature?: SignatureSource): boolean {
|
||||
return !!(signature?.htmlSignature?.trim() || signature?.textSignature?.trim());
|
||||
}
|
||||
|
||||
export function getPlainTextSignature(signature?: SignatureSource): string {
|
||||
if (signature?.textSignature?.trim()) {
|
||||
return normalizeSignatureLineBreaks(signature.textSignature);
|
||||
}
|
||||
|
||||
if (signature?.htmlSignature?.trim()) {
|
||||
return htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function appendPlainTextSignature(
|
||||
body: string,
|
||||
signature?: SignatureSource,
|
||||
options: { separator?: boolean } = {},
|
||||
): string {
|
||||
const plainTextSignature = getPlainTextSignature(signature);
|
||||
if (!plainTextSignature) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const sep = options.separator === false ? '\n\n' : '\n\n-- \n';
|
||||
return `${body}${sep}${plainTextSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a plain-text body already ends with the identity's signature.
|
||||
*/
|
||||
export function plainTextBodyHasSignature(
|
||||
body: string,
|
||||
signature?: SignatureSource,
|
||||
): boolean {
|
||||
const plainTextSignature = getPlainTextSignature(signature);
|
||||
if (!plainTextSignature) {
|
||||
return false;
|
||||
}
|
||||
return normalizeSignatureLineBreaks(body).endsWith(plainTextSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain-text body with a trailing signature - and the `-- ` separator
|
||||
* line in front of it - removed. Returns the body unchanged when it does not
|
||||
* end with the identity's signature.
|
||||
*/
|
||||
export function plainTextBodyWithoutSignature(
|
||||
body: string,
|
||||
signature?: SignatureSource,
|
||||
): string {
|
||||
const plainTextSignature = getPlainTextSignature(signature);
|
||||
if (!plainTextSignature) {
|
||||
return body;
|
||||
}
|
||||
const normalized = normalizeSignatureLineBreaks(body);
|
||||
if (!normalized.endsWith(plainTextSignature)) {
|
||||
return body;
|
||||
}
|
||||
return normalized
|
||||
.slice(0, normalized.length - plainTextSignature.length)
|
||||
.replace(/\n*(?:-- ?)?\n*$/, '');
|
||||
}
|
||||
|
||||
function escapeText(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a signature to an HTML body, preserving rich formatting (quick
|
||||
* reply path, no markers).
|
||||
*/
|
||||
export function appendHtmlSignature(
|
||||
htmlBody: string,
|
||||
signature?: SignatureSource,
|
||||
options: { separator?: boolean } = {},
|
||||
): string {
|
||||
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
|
||||
|
||||
if (signature?.htmlSignature?.trim()) {
|
||||
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
|
||||
}
|
||||
|
||||
if (signature?.textSignature?.trim()) {
|
||||
return `${htmlBody}${sep}${escapeText(signature.textSignature)}`;
|
||||
}
|
||||
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the embedded, editable signature range: a `-- ` separator paragraph
|
||||
* (or an empty start marker), the signature, and an end marker. Returns ''
|
||||
* when the identity has no signature.
|
||||
*/
|
||||
export function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureSource,
|
||||
options: { separator: boolean },
|
||||
): string {
|
||||
const startMarker = options.separator
|
||||
? `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
|
||||
: `<p ${SIGNATURE_RANGE_MARKER}="start"></p>`;
|
||||
const endMarker = `<p ${SIGNATURE_RANGE_MARKER}="end"><br></p>`;
|
||||
if (identity?.htmlSignature?.trim()) {
|
||||
return `${startMarker}<div ${SIGNATURE_RANGE_MARKER}="body">${sanitizeSignatureHtml(identity.htmlSignature)}</div>${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature?.trim()) {
|
||||
return `${startMarker}<p ${SIGNATURE_RANGE_MARKER}="body">${escapeText(identity.textSignature)}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const SIGNATURE_RANGE_RE = new RegExp(
|
||||
`<p\\s+${SIGNATURE_RANGE_MARKER}=["'](?:separator|start)["'][^>]*>[\\s\\S]*?<p\\s+${SIGNATURE_RANGE_MARKER}=["']end["'][^>]*>(?:<br\\s*/?>)?</p>`,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** True when the body carries an embedded signature range. */
|
||||
export function containsEmbeddedSignature(html: string): boolean {
|
||||
return SIGNATURE_RANGE_RE.test(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the embedded signature range with `nextSignatureHtml` (which may
|
||||
* be '' to remove it). When the body has no range yet, the signature is
|
||||
* appended at the end. Returns the body unchanged when nothing changes.
|
||||
*/
|
||||
export function spliceSignature(html: string, nextSignatureHtml: string): string {
|
||||
if (SIGNATURE_RANGE_RE.test(html)) {
|
||||
return html.replace(SIGNATURE_RANGE_RE, () => nextSignatureHtml);
|
||||
}
|
||||
if (!nextSignatureHtml) return html;
|
||||
return `${html}${nextSignatureHtml}`;
|
||||
}
|
||||
|
||||
/** The body without its embedded signature range. */
|
||||
export function stripEmbeddedSignature(html: string): string {
|
||||
return html.replace(SIGNATURE_RANGE_RE, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the signature range in front of the quoted block (`On … wrote:` /
|
||||
* forwarded header) or, when there is none, at the end of the body.
|
||||
*/
|
||||
export function insertSignatureAboveQuote(html: string, signatureHtml: string, quoteStartMarker: string): string {
|
||||
if (!signatureHtml) return html;
|
||||
const idx = quoteStartMarker ? html.indexOf(quoteStartMarker) : -1;
|
||||
if (idx === -1) return `${html}${signatureHtml}`;
|
||||
return `${html.slice(0, idx)}${signatureHtml}${html.slice(idx)}`;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Sub-addressing utilities for user{delimiter}tag@domain.com format.
|
||||
* Works server-side automatically - no JMAP API calls needed.
|
||||
* Port of the webmail's lib/sub-addressing.ts.
|
||||
*
|
||||
* The delimiter character is configurable per server (RFC 5233). Common
|
||||
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
|
||||
*/
|
||||
|
||||
const MAX_TAG_LENGTH = 30;
|
||||
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
|
||||
|
||||
export const DEFAULT_SUB_ADDRESS_DELIMITER = '+';
|
||||
export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const;
|
||||
export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
|
||||
|
||||
export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset {
|
||||
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
|
||||
// delimiter must be exactly one of these - they're safe to embed in a local
|
||||
// part and unambiguously separate the user from the tag.
|
||||
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
|
||||
|
||||
export function isValidSubAddressDelimiter(value: unknown): value is string {
|
||||
return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value);
|
||||
}
|
||||
|
||||
export type TagValidationErrorCode =
|
||||
| 'EMPTY'
|
||||
| 'TOO_LONG'
|
||||
| 'INVALID_CHARS'
|
||||
| null;
|
||||
|
||||
export interface ParsedAddress {
|
||||
localPart: string;
|
||||
baseUser: string;
|
||||
tag: string | null;
|
||||
domain: string;
|
||||
fullAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email address to extract the sub-address tag. The first occurrence
|
||||
* of the delimiter in the local part separates the base user from the tag,
|
||||
* matching the behavior of Postfix/qmail/Sieve.
|
||||
*/
|
||||
export function parseSubAddress(
|
||||
email: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): ParsedAddress {
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
if (!localPart || !domain) {
|
||||
return {
|
||||
localPart: localPart || '',
|
||||
baseUser: localPart || '',
|
||||
tag: null,
|
||||
domain: domain || '',
|
||||
fullAddress: email,
|
||||
};
|
||||
}
|
||||
|
||||
const delimiterIndex = localPart.indexOf(delimiter);
|
||||
|
||||
if (delimiterIndex === -1) {
|
||||
return { localPart, baseUser: localPart, tag: null, domain, fullAddress: email };
|
||||
}
|
||||
|
||||
const baseUser = localPart.substring(0, delimiterIndex);
|
||||
const tag = localPart.substring(delimiterIndex + delimiter.length);
|
||||
|
||||
return { localPart, baseUser, tag: tag || null, domain, fullAddress: email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sub-addressed email.
|
||||
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
|
||||
*/
|
||||
export function generateSubAddress(
|
||||
baseEmail: string,
|
||||
tag: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): string {
|
||||
const [localPart, domain] = baseEmail.split('@');
|
||||
|
||||
if (!localPart || !domain || !tag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
// Strip an existing tag if one is already present
|
||||
const existingDelimiterIndex = localPart.indexOf(delimiter);
|
||||
const cleanLocal = existingDelimiterIndex === -1
|
||||
? localPart
|
||||
: localPart.substring(0, existingDelimiterIndex);
|
||||
|
||||
// Sanitize tag (alphanumeric and dash only)
|
||||
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
|
||||
if (!cleanTag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
|
||||
}
|
||||
|
||||
/** Extract domain from recipient email for tag suggestions. */
|
||||
export function extractDomain(email: string): string | null {
|
||||
const match = email.match(/@([^@]+)$/);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** Suggest tags based on the recipient domain. */
|
||||
export function suggestTagsForDomain(domain: string): string[] {
|
||||
const domainLower = domain.toLowerCase();
|
||||
|
||||
const suggestions: Record<string, string[]> = {
|
||||
'amazon.com': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.fr': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.de': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.co.uk': ['amazon', 'shopping', 'orders'],
|
||||
'ebay.com': ['ebay', 'shopping'],
|
||||
'ebay.fr': ['ebay', 'shopping'],
|
||||
'paypal.com': ['paypal', 'payments'],
|
||||
'facebook.com': ['facebook', 'social'],
|
||||
'twitter.com': ['twitter', 'social'],
|
||||
'x.com': ['twitter', 'social'],
|
||||
'linkedin.com': ['linkedin', 'professional'],
|
||||
'github.com': ['github', 'dev', 'notifications'],
|
||||
'gitlab.com': ['gitlab', 'dev', 'notifications'],
|
||||
'stackoverflow.com': ['stackoverflow', 'dev'],
|
||||
'reddit.com': ['reddit', 'social'],
|
||||
'netflix.com': ['netflix', 'entertainment'],
|
||||
'spotify.com': ['spotify', 'music'],
|
||||
'steam.com': ['steam', 'gaming'],
|
||||
'discord.com': ['discord', 'gaming'],
|
||||
};
|
||||
|
||||
if (suggestions[domainLower]) {
|
||||
return suggestions[domainLower];
|
||||
}
|
||||
|
||||
// Extract main domain (e.g., "mail.google.com" -> "google")
|
||||
const parts = domainLower.split('.');
|
||||
const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||
|
||||
return [mainDomain, 'newsletter', 'registration'];
|
||||
}
|
||||
|
||||
/** Validate if a tag is safe to use. */
|
||||
export function isValidTag(tag: string): boolean {
|
||||
return TAG_REGEX.test(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation error code for an invalid tag. Returns an error code that
|
||||
* should be translated by the calling component.
|
||||
*/
|
||||
export function getTagValidationError(tag: string): TagValidationErrorCode {
|
||||
if (!tag) return 'EMPTY';
|
||||
if (tag.length > MAX_TAG_LENGTH) return 'TOO_LONG';
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(tag)) return 'INVALID_CHARS';
|
||||
return null;
|
||||
}
|
||||
|
||||
export { MAX_TAG_LENGTH };
|
||||
@@ -0,0 +1,217 @@
|
||||
// Template placeholders, export/import envelope and filtering. Port of the
|
||||
// webmail's lib/template-utils.ts + lib/template-types.ts (DOM-free: the
|
||||
// tag-stripping DOMPurify pass is replaced by a regex strip).
|
||||
|
||||
import type { EmailTemplate } from '../stores/templates-store';
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
export const BUILT_IN_PLACEHOLDERS = [
|
||||
'recipient_name',
|
||||
'company',
|
||||
'date',
|
||||
'day_of_week',
|
||||
'sender_name',
|
||||
] as const;
|
||||
|
||||
export type BuiltInPlaceholder = (typeof BUILT_IN_PLACEHOLDERS)[number];
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
||||
const MAX_TEMPLATE_NAME_LENGTH = 200;
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
export function extractPlaceholders(text: string): string[] {
|
||||
const matches = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g');
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
matches.add(match[1]);
|
||||
}
|
||||
return Array.from(matches);
|
||||
}
|
||||
|
||||
export function substitutePlaceholders(
|
||||
text: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
return text.replace(PLACEHOLDER_REGEX, (full, name: string) => {
|
||||
if (values[name] === undefined) return full;
|
||||
return stripTags(values[name]);
|
||||
});
|
||||
}
|
||||
|
||||
export function hasUnresolvedPlaceholders(text: string): boolean {
|
||||
return new RegExp(PLACEHOLDER_REGEX.source).test(text);
|
||||
}
|
||||
|
||||
export function validateTemplateName(name: string): 'empty' | 'too_long' | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return 'empty';
|
||||
if (trimmed.length > MAX_TEMPLATE_NAME_LENGTH) return 'too_long';
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AutoFillContext {
|
||||
senderName?: string;
|
||||
recipientName?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function getAutoFilledPlaceholders(
|
||||
context: AutoFillContext,
|
||||
): Record<string, string> {
|
||||
const now = new Date();
|
||||
const locale = context.locale || 'en';
|
||||
const intlLocale = locale === 'en' ? 'en-US' : locale;
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
try {
|
||||
values.date = now.toLocaleDateString(intlLocale, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
values.day_of_week = now.toLocaleDateString(intlLocale, { weekday: 'long' });
|
||||
} catch {
|
||||
values.date = now.toDateString();
|
||||
values.day_of_week = now.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
}
|
||||
|
||||
if (context.senderName) {
|
||||
values.sender_name = context.senderName;
|
||||
}
|
||||
if (context.recipientName) {
|
||||
values.recipient_name = context.recipientName;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getPlaceholdersFromTemplate(template: Pick<EmailTemplate, 'subject' | 'body'>): string[] {
|
||||
const combined = `${template.subject} ${template.body}`;
|
||||
return extractPlaceholders(combined);
|
||||
}
|
||||
|
||||
export function isBuiltInPlaceholder(name: string): boolean {
|
||||
return (BUILT_IN_PLACEHOLDERS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
export function filterTemplates(templates: EmailTemplate[], query: string): EmailTemplate[] {
|
||||
const lower = query.toLowerCase();
|
||||
return templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(lower) ||
|
||||
t.subject.toLowerCase().includes(lower) ||
|
||||
t.category.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportData {
|
||||
version: 1;
|
||||
type: 'webmail-templates';
|
||||
exportedAt: string;
|
||||
templates: EmailTemplate[];
|
||||
}
|
||||
|
||||
/** JSON in the webmail's export shape so a file round-trips between clients. */
|
||||
export function exportTemplates(templates: EmailTemplate[]): string {
|
||||
const data: ExportData = {
|
||||
version: 1,
|
||||
type: 'webmail-templates',
|
||||
exportedAt: new Date().toISOString(),
|
||||
templates,
|
||||
};
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
templates: EmailTemplate[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function sanitizeText(value: unknown): string {
|
||||
return stripTags(String(value || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a templates export. Accepts the webmail envelope (`type:
|
||||
* 'webmail-templates'`) and, for backwards compatibility, an older RN export
|
||||
* that carried only `{ version, templates }`. Ids are regenerated so an
|
||||
* import never overwrites an existing template.
|
||||
*/
|
||||
export function importTemplates(json: string): ImportResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return { templates: [], errors: ['invalid_json'] };
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return { templates: [], errors: ['invalid_format'] };
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
|
||||
if (data.type !== undefined && data.type !== 'webmail-templates') {
|
||||
return { templates: [], errors: ['invalid_type'] };
|
||||
}
|
||||
|
||||
if (data.version !== undefined && data.version !== 1) {
|
||||
return { templates: [], errors: ['unsupported_version'] };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.templates)) {
|
||||
return { templates: [], errors: ['invalid_templates'] };
|
||||
}
|
||||
|
||||
const templates: EmailTemplate[] = [];
|
||||
for (const item of data.templates) {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
errors.push('invalid_template_entry');
|
||||
continue;
|
||||
}
|
||||
|
||||
const t = item as Record<string, unknown>;
|
||||
if (typeof t.name !== 'string' || !t.name.trim()) {
|
||||
errors.push('missing_template_name');
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
templates.push({
|
||||
id: generateUUID(),
|
||||
name: sanitizeText(t.name),
|
||||
subject: sanitizeText(t.subject),
|
||||
body: t.isHTML ? String(t.body || '') : sanitizeText(t.body),
|
||||
isHTML: Boolean(t.isHTML),
|
||||
category: sanitizeText(t.category),
|
||||
defaultRecipients: recipients && typeof recipients === 'object'
|
||||
? {
|
||||
to: Array.isArray(recipients.to) ? (recipients.to as unknown[]).map(String) : undefined,
|
||||
cc: Array.isArray(recipients.cc) ? (recipients.cc as unknown[]).map(String) : undefined,
|
||||
bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as unknown[]).map(String) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
identityId: typeof t.identityId === 'string' ? t.identityId : undefined,
|
||||
isFavorite: Boolean(t.isFavorite),
|
||||
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
|
||||
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
|
||||
});
|
||||
}
|
||||
|
||||
return { templates, errors };
|
||||
}
|
||||
|
||||
/** Convert a plain-text template body to composer HTML (escape + <br>). */
|
||||
export function templateBodyToHtml(template: Pick<EmailTemplate, 'body' | 'isHTML'>): string {
|
||||
if (template.isHTML) return template.body;
|
||||
const escaped = template.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\r?\n/g, '<br>');
|
||||
return `<p>${escaped}</p>`;
|
||||
}
|
||||
@@ -6,13 +6,22 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import 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<void>;
|
||||
addTemplate: (data: Pick<EmailTemplate, 'name' | 'subject' | 'body'> & Partial<Pick<EmailTemplate, 'category' | 'isFavorite'>>) => EmailTemplate;
|
||||
addTemplate: (
|
||||
data: Pick<EmailTemplate, 'name' | 'subject' | 'body'>
|
||||
& Partial<Pick<EmailTemplate, 'category' | 'isFavorite' | 'isHTML' | 'defaultRecipients' | 'identityId'>>,
|
||||
) => EmailTemplate;
|
||||
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
|
||||
deleteTemplate: (id: string) => void;
|
||||
exportAll: () => string;
|
||||
@@ -71,6 +83,9 @@ export const useTemplatesStore = create<TemplatesState>((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<TemplatesState>((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<EmailTemplate>;
|
||||
if (typeof t.name !== 'string' || typeof t.body !== 'string') continue;
|
||||
const now = new Date().toISOString();
|
||||
incoming.push({
|
||||
id: typeof t.id === 'string' ? t.id : genId(),
|
||||
name: t.name,
|
||||
subject: typeof t.subject === 'string' ? t.subject : '',
|
||||
body: t.body,
|
||||
category: typeof t.category === 'string' ? t.category : '',
|
||||
isFavorite: !!t.isFavorite,
|
||||
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
|
||||
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
|
||||
});
|
||||
}
|
||||
const existing = get().templates;
|
||||
const existingIds = new Set(existing.map((t) => t.id));
|
||||
const merged = [
|
||||
...existing,
|
||||
...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)),
|
||||
];
|
||||
set({ templates: merged });
|
||||
persist(merged);
|
||||
return { count: incoming.length };
|
||||
} catch (err) {
|
||||
return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' };
|
||||
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 };
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user