feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api

This commit is contained in:
Linus Rath
2026-08-29 18:12:15 +02:00
parent 2bf2e89813
commit 42ab246cb9
17 changed files with 2009 additions and 171 deletions
-64
View File
@@ -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],
);
}
+59 -5
View File
@@ -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 &lt;a@b.com&gt; 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 &lt;b@c.com&gt;');
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"');
});
});
+41
View File
@@ -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 &lt;a@b\.com&gt; 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');
});
});
+114
View File
@@ -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();
});
});
+56
View File
@@ -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();
});
});
+81
View File
@@ -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 &lt;jane@x.y&gt;');
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}`);
});
});
+37
View File
@@ -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);
});
});
+75
View File
@@ -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&lt;b<br>c</p>');
expect(templateBodyToHtml({ body: '<p>x</p>', isHTML: true })).toBe('<p>x</p>');
});
});
+188 -54
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'");
.replace(/&#39;/gi, "'")
.replace(/&#x27;/gi, "'")
.replace(/&amp;/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, '&quot;')}"${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, '&quot;')}"${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;
}
+102
View File
@@ -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 };
}
+377
View File
@@ -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,
};
}
+229
View File
@@ -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;
}
+228
View File
@@ -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, '&quot;')}"`);
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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)}`;
}
+167
View File
@@ -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 };
+217
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\r?\n/g, '<br>');
return `<p>${escaped}</p>`;
}
+27 -37
View File
@@ -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 };
},
}));