mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeReplyThreadingHeaders,
|
||||
stripMessageIdBrackets,
|
||||
} from '../email-threading';
|
||||
|
||||
describe('stripMessageIdBrackets', () => {
|
||||
it('strips surrounding angle brackets', () => {
|
||||
expect(stripMessageIdBrackets('<abc@example.com>')).toBe('abc@example.com');
|
||||
});
|
||||
|
||||
it('handles whitespace and missing brackets', () => {
|
||||
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
|
||||
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
|
||||
expect(stripMessageIdBrackets('abc@example.com>')).toBe('abc@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeReplyThreadingHeaders', () => {
|
||||
it('returns null when the parent has no Message-ID', () => {
|
||||
expect(computeReplyThreadingHeaders(undefined)).toBeNull();
|
||||
expect(computeReplyThreadingHeaders({})).toBeNull();
|
||||
expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull();
|
||||
expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => {
|
||||
const result = computeReplyThreadingHeaders({
|
||||
messageId: '<root@example.com>',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
inReplyTo: ['root@example.com'],
|
||||
references: ['root@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('appends the parent to existing References per RFC 5322', () => {
|
||||
const result = computeReplyThreadingHeaders({
|
||||
messageId: '<msg-2@example.com>',
|
||||
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
inReplyTo: ['msg-2@example.com'],
|
||||
references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('de-duplicates if the parent already appears in References', () => {
|
||||
const result = computeReplyThreadingHeaders({
|
||||
messageId: '<msg-1@example.com>',
|
||||
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||
});
|
||||
expect(result?.references).toEqual([
|
||||
'msg-0@example.com',
|
||||
'msg-1@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts bare Message-IDs without angle brackets', () => {
|
||||
const result = computeReplyThreadingHeaders({
|
||||
messageId: 'msg-2@example.com',
|
||||
references: ['msg-1@example.com'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
inReplyTo: ['msg-2@example.com'],
|
||||
references: ['msg-1@example.com', 'msg-2@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
// JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we
|
||||
// don't crash on that shape even though most call sites pass a string.
|
||||
it('accepts an array-shaped messageId per JMAP spec', () => {
|
||||
const result = computeReplyThreadingHeaders({
|
||||
messageId: ['<msg-2@example.com>'],
|
||||
references: ['<msg-1@example.com>'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
inReplyTo: ['msg-2@example.com'],
|
||||
references: ['msg-1@example.com', 'msg-2@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for an empty messageId array', () => {
|
||||
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildReplyRecipients, isSelfSent } from '../reply-recipients';
|
||||
|
||||
const OWN = ['me@example.com', 'info@example.com'];
|
||||
|
||||
const emails = (list: { email?: string }[]) => list.map((r) => r.email);
|
||||
|
||||
describe('buildReplyRecipients', () => {
|
||||
describe('received message', () => {
|
||||
const received = {
|
||||
from: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }],
|
||||
cc: [{ email: 'dave@other.com' }],
|
||||
};
|
||||
|
||||
it('replies to the sender', () => {
|
||||
const { to, cc } = buildReplyRecipients(received, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(cc).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers the Reply-To header over From', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...received, replyToAddresses: [{ email: 'list@other.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['list@other.com']);
|
||||
});
|
||||
|
||||
it('reply-all keeps the other recipients and drops our own address', () => {
|
||||
const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
|
||||
expect(emails(cc)).toEqual(['dave@other.com']);
|
||||
});
|
||||
|
||||
it('reply-all drops our own address even with +tag sub-addressing', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] },
|
||||
'replyAll',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('self-sent message (#703)', () => {
|
||||
const sent = {
|
||||
from: [{ email: 'me@example.com', name: 'Me' }],
|
||||
to: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
cc: [{ email: 'carol@other.com' }],
|
||||
};
|
||||
|
||||
it('replies to the original recipient, not to ourselves', () => {
|
||||
const { to, cc } = buildReplyRecipients(sent, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(cc).toEqual([]);
|
||||
});
|
||||
|
||||
it('reply-all restores the original To and Cc', () => {
|
||||
const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(emails(cc)).toEqual(['carol@other.com']);
|
||||
});
|
||||
|
||||
it('recognises the sending identity through +tag sub-addressing', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, from: [{ email: 'me+project@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
});
|
||||
|
||||
it('ignores our own Reply-To header so the reply leaves our mailbox', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, replyToAddresses: [{ email: 'info@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
});
|
||||
|
||||
it('honours an EXTERNAL Reply-To even on a self-sent message (#776)', () => {
|
||||
// Contact-form / helpdesk pattern: the mail arrives From our own address
|
||||
// (so it looks self-sent) but Reply-To carries the real correspondent.
|
||||
// The reply must reach them, not loop back to our own To.
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, to: [{ email: 'me@example.com' }], replyToAddresses: [{ email: 'customer@external.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['customer@external.com']);
|
||||
});
|
||||
|
||||
it('reply-all with an external Reply-To routes To to the Reply-To (#776)', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, replyToAddresses: [{ email: 'customer@external.com' }] },
|
||||
'replyAll',
|
||||
OWN,
|
||||
);
|
||||
// Reply-To leads; the other original recipients (bob) are still included,
|
||||
// our own address dropped.
|
||||
expect(emails(to)).toEqual(['customer@external.com', 'bob@other.com']);
|
||||
});
|
||||
|
||||
it('keeps a self-addressed recipient we chose ourselves', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, to: [{ email: 'info@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['info@example.com']);
|
||||
});
|
||||
|
||||
it('falls back to the sender when there is no visible recipient (Bcc-only)', () => {
|
||||
const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['me@example.com']);
|
||||
});
|
||||
|
||||
it('keeps the display names of the original recipients', () => {
|
||||
const { to } = buildReplyRecipients(sent, 'reply', OWN);
|
||||
expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' });
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing without a source message', () => {
|
||||
expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] });
|
||||
});
|
||||
|
||||
it('treats a message as foreign when no identity matches', () => {
|
||||
expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false);
|
||||
expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true);
|
||||
expect(isSelfSent({ from: [] }, OWN)).toBe(false);
|
||||
expect(isSelfSent(undefined, OWN)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripSubjectPrefixes,
|
||||
buildReplySubject,
|
||||
buildForwardSubject,
|
||||
buildComposeTabTitle,
|
||||
} from '../subject-prefix';
|
||||
|
||||
describe('stripSubjectPrefixes', () => {
|
||||
it('strips a chain of mixed-language prefixes', () => {
|
||||
expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips the Outlook [N] and Eudora *N counters', () => {
|
||||
expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('is case-insensitive and idempotent', () => {
|
||||
expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips a Cyrillic token and an ASCII-colon Chinese token', () => {
|
||||
expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('回复: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips a token followed by a full-width colon (CJK clients)', () => {
|
||||
expect(stripSubjectPrefixes('回复:foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('回覆:foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('Re:foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('still does not strip a bare single-letter "R:"', () => {
|
||||
expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024');
|
||||
});
|
||||
|
||||
it('returns "" for empty / null / undefined and leaves clean subjects alone', () => {
|
||||
expect(stripSubjectPrefixes('')).toBe('');
|
||||
expect(stripSubjectPrefixes(null)).toBe('');
|
||||
expect(stripSubjectPrefixes(undefined)).toBe('');
|
||||
expect(stripSubjectPrefixes('foo')).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReplySubject / buildForwardSubject', () => {
|
||||
it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => {
|
||||
expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo');
|
||||
expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo');
|
||||
});
|
||||
|
||||
it('prepends to a clean subject and returns the bare prefix for empty input', () => {
|
||||
expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo');
|
||||
expect(buildReplySubject('', 'AW:')).toBe('AW:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComposeTabTitle', () => {
|
||||
const prefixes = { replyPrefix: 'Re:', forwardPrefix: 'Fwd:', fallback: 'New message' };
|
||||
|
||||
it('never inherits the selected email subject for a fresh compose (#856)', () => {
|
||||
expect(buildComposeTabTitle({ mode: 'compose', sourceSubject: 'Invoice August 2026', ...prefixes }))
|
||||
.toBe('New message');
|
||||
});
|
||||
|
||||
it('uses the draft subject for a fresh compose when one exists (mailto, draft edit)', () => {
|
||||
expect(buildComposeTabTitle({ mode: 'compose', draftSubject: 'Lunch?', sourceSubject: 'Invoice August 2026', ...prefixes }))
|
||||
.toBe('Lunch?');
|
||||
});
|
||||
|
||||
it('derives reply and forward titles from the source subject', () => {
|
||||
expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: 'Invoice August 2026', ...prefixes }))
|
||||
.toBe('Re: Invoice August 2026');
|
||||
expect(buildComposeTabTitle({ mode: 'replyAll', sourceSubject: 'AW: Invoice', ...prefixes }))
|
||||
.toBe('Re: Invoice');
|
||||
expect(buildComposeTabTitle({ mode: 'forward', sourceSubject: 'Invoice', ...prefixes }))
|
||||
.toBe('Fwd: Invoice');
|
||||
});
|
||||
|
||||
it('prefers a resumed draft subject over the source and falls back when both are blank', () => {
|
||||
expect(buildComposeTabTitle({ mode: 'reply', draftSubject: 'Re: edited', sourceSubject: 'Original', ...prefixes }))
|
||||
.toBe('Re: edited');
|
||||
expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: ' ', ...prefixes }))
|
||||
.toBe('New message');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* RFC 5322 §3.6.4 reply threading. Port of the webmail's lib/email-threading.ts.
|
||||
*
|
||||
* Computes the In-Reply-To and References headers an outgoing reply must
|
||||
* carry so MUAs can stitch the conversation back together.
|
||||
*
|
||||
* In-Reply-To = parent.Message-ID
|
||||
* References = parent.References (if any) + parent.Message-ID
|
||||
*
|
||||
* Bare msg-ids only - angle brackets are stripped because JMAP RFC 8621
|
||||
* §4.1.2.3 stores Message-IDs without them. The JMAP *object id* of the parent
|
||||
* is never a valid msg-id and must not be used here.
|
||||
*/
|
||||
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
export interface ParentThreadingInfo {
|
||||
// JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null; accept a
|
||||
// bare string too for callers that flattened it.
|
||||
messageId?: string | string[] | null;
|
||||
references?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ReplyThreadingHeaders {
|
||||
inReplyTo: string[];
|
||||
references: string[];
|
||||
}
|
||||
|
||||
export function stripMessageIdBrackets(id: string): string {
|
||||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||
}
|
||||
|
||||
export function computeReplyThreadingHeaders(
|
||||
parent: ParentThreadingInfo | undefined | null,
|
||||
): ReplyThreadingHeaders | null {
|
||||
const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId;
|
||||
const parentId = rawId ? stripMessageIdBrackets(rawId) : '';
|
||||
if (!parentId) return null;
|
||||
|
||||
const ancestors = (parent?.references ?? [])
|
||||
.map(stripMessageIdBrackets)
|
||||
.filter(Boolean);
|
||||
|
||||
// De-dupe while preserving order; the parent's id closes the chain.
|
||||
const seen = new Set<string>();
|
||||
const references: string[] = [];
|
||||
for (const id of [...ancestors, parentId]) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
references.push(id);
|
||||
}
|
||||
|
||||
return { inReplyTo: [parentId], references };
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-ID for outgoing mail (bare msg-id, no angle brackets, per RFC 8621
|
||||
* §4.1.2.3). Without one the server synthesizes it from its OS hostname,
|
||||
* which leaks internal names into headers - an anti-spam signal and an
|
||||
* information disclosure. Use the sender's domain instead.
|
||||
*/
|
||||
export function generateMessageId(fromEmail: string): string {
|
||||
const at = fromEmail.lastIndexOf('@');
|
||||
const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost';
|
||||
return `${Date.now().toString(36)}.${generateUUID()}@${domain}`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface ReplyAddress {
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ReplySource {
|
||||
from?: ReplyAddress[];
|
||||
/** Addresses from the original message's Reply-To header. */
|
||||
replyToAddresses?: ReplyAddress[];
|
||||
to?: ReplyAddress[];
|
||||
cc?: ReplyAddress[];
|
||||
}
|
||||
|
||||
export interface ReplyRecipientsResult {
|
||||
to: ReplyAddress[];
|
||||
cc: ReplyAddress[];
|
||||
}
|
||||
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBase(email: string): string {
|
||||
const normalized = normalize(email);
|
||||
const at = normalized.indexOf('@');
|
||||
if (at <= 0) return normalized;
|
||||
|
||||
const local = normalized.slice(0, at);
|
||||
const domain = normalized.slice(at + 1);
|
||||
const plus = local.indexOf('+');
|
||||
|
||||
return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `email` belong to the user? Matches exactly first, then with `+tag`
|
||||
* sub-addressing stripped (info+news@ is still info@).
|
||||
*/
|
||||
function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean {
|
||||
if (!email?.trim()) return false;
|
||||
const exact = normalize(email);
|
||||
if (ownEmails.some((own) => normalize(own) === exact)) return true;
|
||||
const base = normalizeBase(email);
|
||||
return ownEmails.some((own) => normalizeBase(own) === base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a message the user themself sent? True when the From address is one
|
||||
* of their own identities - the case that shows up when browsing a thread and
|
||||
* replying to your own last message.
|
||||
*/
|
||||
export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean {
|
||||
return isOwnAddress(source?.from?.[0]?.email, ownEmails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the To/Cc a reply should open with.
|
||||
*
|
||||
* Normal case: reply goes to the Reply-To header if the original carried one,
|
||||
* else to From (RFC 5322). Reply-all adds the other original recipients,
|
||||
* minus the user's own addresses.
|
||||
*
|
||||
* Self-sent case (#703): replying to your own message inside a thread must
|
||||
* continue the conversation, not mail yourself. Gmail and Thunderbird address
|
||||
* the reply to the message's original recipients instead, so that's what we do
|
||||
* - the original To for reply, plus the original Cc for reply-all. Those
|
||||
* addresses were the user's own choice, so they're kept verbatim (no self-
|
||||
* filtering) and the Reply-To header is ignored, since answering your own
|
||||
* Reply-To would land the mail back in your inbox again.
|
||||
*
|
||||
* A self-sent message with no visible recipients (Bcc-only) has nothing to
|
||||
* continue to, so it falls back to the normal behaviour.
|
||||
*/
|
||||
export function buildReplyRecipients(
|
||||
source: ReplySource | undefined,
|
||||
mode: 'reply' | 'replyAll',
|
||||
ownEmails: string[],
|
||||
): ReplyRecipientsResult {
|
||||
if (!source) return { to: [], cc: [] };
|
||||
|
||||
const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email));
|
||||
|
||||
const replyToList = withEmail(source.replyToAddresses);
|
||||
const externalReplyTo = replyToList.filter((r) => !isOwnAddress(r.email, ownEmails));
|
||||
|
||||
if (!externalReplyTo.length && isSelfSent(source, ownEmails)) {
|
||||
const originalTo = withEmail(source.to);
|
||||
if (originalTo.length > 0) {
|
||||
return {
|
||||
to: originalTo,
|
||||
cc: mode === 'replyAll' ? withEmail(source.cc) : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const replyTarget = replyToList.length
|
||||
? replyToList
|
||||
: (source.from?.[0]?.email ? [source.from[0]] : []);
|
||||
|
||||
if (mode === 'reply') {
|
||||
return { to: replyTarget, cc: [] };
|
||||
}
|
||||
|
||||
const others = (list: ReplyAddress[] | undefined) =>
|
||||
withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails));
|
||||
|
||||
return {
|
||||
to: [...replyTarget, ...others(source.to)],
|
||||
cc: others(source.cc),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Parsing helpers for RFC 5322 mailboxes (`Display Name <addr@example.com>`).
|
||||
* Port of the webmail's lib/rfc5322-mailbox.ts.
|
||||
*
|
||||
* Address data reaches us from places that do not keep the display name and
|
||||
* the address apart: vCard `FN` fields that hold the whole mailbox, Sent-folder
|
||||
* headers, `Identity.name` on some servers. Handing such a string back to a
|
||||
* server as a structured display name produces a doubled, invalid mailbox
|
||||
* (`"Jane <j@x.com>" <j@x.com>`), which mail servers then re-parse into a
|
||||
* broken envelope recipient (#672). These helpers reduce any of those forms to
|
||||
* a clean name/address pair.
|
||||
*/
|
||||
|
||||
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
|
||||
export function unquoteDisplayName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1').trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a display name to just the name: unquotes it and drops an address it
|
||||
* carries inline, whether or not the angle brackets are balanced
|
||||
* (`Jane <j@x.com>`, `Jane<j@x.com` → `Jane`). Line breaks are collapsed so a
|
||||
* pasted name can never inject a header.
|
||||
*/
|
||||
export function sanitizeDisplayName(name: string | undefined | null): string {
|
||||
if (!name) return '';
|
||||
const flat = name.replace(/[\r\n\t]+/g, ' ');
|
||||
return unquoteDisplayName(unquoteDisplayName(flat).replace(/\s*<[^<>]*>?\s*$/, ''));
|
||||
}
|
||||
|
||||
/** The addr-spec a mailbox string carries, or '' when it has none. */
|
||||
function extractAddrSpec(value: string): string {
|
||||
// Prefer the last angle-bracketed run - a display name that embeds an
|
||||
// address puts an earlier, redundant copy in front of the real one. The
|
||||
// closing bracket is optional so a truncated `Jane<j@x.com` still resolves.
|
||||
const angle = value.match(/<([^<>]*)>?\s*$/);
|
||||
if (angle) return angle[1].trim();
|
||||
// No brackets: the whole string is the address, if it looks like one at all.
|
||||
return value.includes('@') ? value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an RFC 5322 mailbox into its display name and addr-spec. Accepts the
|
||||
* malformed shapes above; the returned name never contains an address and the
|
||||
* returned email is always a bare addr-spec. `name` is omitted when there is
|
||||
* no display name distinct from the address.
|
||||
*/
|
||||
export function splitMailbox(value: string): { name?: string; email: string } {
|
||||
const trimmed = value.trim();
|
||||
const email = extractAddrSpec(trimmed);
|
||||
if (!email) return { email: trimmed };
|
||||
|
||||
// Everything ahead of the address is the display name.
|
||||
const nameSource = trimmed.slice(0, trimmed.lastIndexOf(email)).replace(/<\s*$/, '');
|
||||
const name = sanitizeDisplayName(nameSource);
|
||||
return name && name !== email ? { name, email } : { email };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Reply / forward subject prefix handling.
|
||||
//
|
||||
// Real-world email subjects accumulate prefixes across clients and languages:
|
||||
// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL
|
||||
// commonly-used reply/forward markers - not just the current locale's, since
|
||||
// inbound messages may come from any locale. Failing to strip a foreign-locale
|
||||
// prefix means the user's locale prefix gets *added on top* and the subject
|
||||
// chain keeps growing.
|
||||
//
|
||||
// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per
|
||||
// language. Includes a handful of legacy short-forms (R:, Fw:) that some
|
||||
// mobile clients still emit.
|
||||
|
||||
const REPLY_TOKENS = [
|
||||
"Re", // English, Italian, French (also generic ISO)
|
||||
"RE", // Outlook variant
|
||||
"AW", // German (Antwort)
|
||||
"Antw", // German verbose
|
||||
"Sv", // Danish / Swedish / Norwegian (Svar)
|
||||
"Yn", // Turkish (Yanit)
|
||||
"Yanit", // Turkish verbose
|
||||
"Odp", // Polish (Odpowiedz)
|
||||
"Ответ", // Russian
|
||||
"Resp", // Spanish/Portuguese variant
|
||||
"Vá", // Hungarian
|
||||
"回复", // Chinese
|
||||
"回覆", // Chinese traditional
|
||||
"답장", // Korean
|
||||
// NB: deliberately no bare "R" token — a single letter would strip the first
|
||||
// word of legitimate subjects like "R: budget 2024". The full "Re" covers
|
||||
// the common Italian/English case anyway.
|
||||
];
|
||||
|
||||
const FORWARD_TOKENS = [
|
||||
"Fwd", // English standard
|
||||
"Fw", // English short / Polish / German short
|
||||
"WG", // German (Weitergeleitet)
|
||||
"Tr", // French (Transfert)
|
||||
"Vs", // Danish (Videresend)
|
||||
"Enc", // Portuguese (Encaminhar)
|
||||
"ENC", // Portuguese caps
|
||||
"Rv", // Spanish (Reenviar)
|
||||
"RV", // Spanish caps
|
||||
"Rvf", // Spanish variant
|
||||
"Inol", // Italian (Inoltro)
|
||||
// NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
|
||||
"PD", // Polish (Przekazane Dalej)
|
||||
"PR", // Czech (Preposlat)
|
||||
"İlt", // Turkish (Ilet)
|
||||
"Ilt", // Turkish ASCII
|
||||
"Пересл", // Russian (Peresylka)
|
||||
"Пер", // Russian short
|
||||
"转发", // Chinese
|
||||
"轉寄", // Chinese traditional
|
||||
"전달", // Korean
|
||||
];
|
||||
|
||||
// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
|
||||
// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
|
||||
// regex composable for stripping multiple prefixes in a row.
|
||||
function buildPrefixRegex(tokens: string[]): RegExp {
|
||||
// Escape regex specials in tokens (none currently, but be defensive)
|
||||
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
|
||||
// shorter prefixes (e.g. "Пер") during alternation matching.
|
||||
escaped.sort((a, b) => b.length - a.length);
|
||||
// Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK
|
||||
// mail clients emit after a localized prefix (e.g. "回复:foo").
|
||||
return new RegExp(
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`,
|
||||
"i",
|
||||
);
|
||||
}
|
||||
|
||||
const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
|
||||
|
||||
/**
|
||||
* Strip any leading sequence of reply/forward prefixes (across languages) from
|
||||
* a subject line. Idempotent and safe for empty input.
|
||||
*
|
||||
* Examples:
|
||||
* stripSubjectPrefixes("Re: AW: WG: foo") === "foo"
|
||||
* stripSubjectPrefixes("Re[2]: foo") === "foo"
|
||||
* stripSubjectPrefixes("RE: Re: foo") === "foo"
|
||||
* stripSubjectPrefixes("foo") === "foo"
|
||||
* stripSubjectPrefixes("") === ""
|
||||
*/
|
||||
export function stripSubjectPrefixes(subject: string | undefined | null): string {
|
||||
if (!subject) return "";
|
||||
let s = subject;
|
||||
// Bounded loop: in practice you never see more than ~10 prefixes; the bound
|
||||
// protects against pathological input. Each iteration must consume input.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const next = s.replace(ANY_PREFIX_RE, "");
|
||||
if (next === s) break;
|
||||
s = next;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reply subject with the given locale-aware prefix. Strips any
|
||||
* pre-existing prefixes (in any language) first so chains don't accumulate.
|
||||
*
|
||||
* buildReplySubject("AW: WG: foo", "Re:") === "Re: foo"
|
||||
* buildReplySubject("foo", "AW:") === "AW: foo"
|
||||
* buildReplySubject("", "AW:") === "AW:"
|
||||
*/
|
||||
export function buildReplySubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a forward subject. Same logic as buildReplySubject but conceptually
|
||||
* separate for clarity at the call site.
|
||||
*/
|
||||
export function buildForwardSubject(subject: string | undefined | null, prefix: string): string {
|
||||
const stripped = stripSubjectPrefixes(subject);
|
||||
return stripped ? `${prefix} ${stripped}` : prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Working title for a compose tab. Reply and forward derive it from the
|
||||
* message they act on; a fresh compose must NOT inherit the subject of
|
||||
* whatever email happens to be selected in the list (#856) - it starts on
|
||||
* the fallback ("New message") until a draft subject exists.
|
||||
*/
|
||||
export function buildComposeTabTitle(opts: {
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
draftSubject?: string | null;
|
||||
sourceSubject?: string | null;
|
||||
replyPrefix: string;
|
||||
forwardPrefix: string;
|
||||
fallback: string;
|
||||
}): string {
|
||||
const draft = opts.draftSubject?.trim() ?? '';
|
||||
const source = opts.mode === 'compose' ? '' : (opts.sourceSubject?.trim() ?? '');
|
||||
const base = draft || source;
|
||||
if (!base) return opts.fallback;
|
||||
if (opts.mode === 'reply' || opts.mode === 'replyAll') return buildReplySubject(base, opts.replyPrefix);
|
||||
if (opts.mode === 'forward') return buildForwardSubject(base, opts.forwardPrefix);
|
||||
return base;
|
||||
}
|
||||
Reference in New Issue
Block a user