mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
// Build the initial HTML body for a new compose / reply / forward, mirroring
|
|
// what the webmail composer (`components/email/email-composer.tsx`) produces.
|
|
// 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 {
|
|
from?: { email?: string; name?: string };
|
|
to?: Array<{ email?: string; name?: string }>;
|
|
cc?: Array<{ email?: string; name?: string }>;
|
|
subject?: string;
|
|
body?: string; // plain-text body (preferred when htmlBody absent)
|
|
htmlBody?: string;
|
|
receivedAt?: string;
|
|
}
|
|
|
|
export interface QuoteHeaderOptions {
|
|
timeFormat?: TimeFormat;
|
|
locale?: string;
|
|
unknownLabel?: string;
|
|
labels?: QuoteHeaderLabels;
|
|
}
|
|
|
|
function escapeForHtmlBody(text: string): string {
|
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
|
}
|
|
|
|
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).
|
|
*
|
|
* 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). The quoted part is wrapped in a
|
|
* `data-quoted-html` div.
|
|
*/
|
|
export function buildInitialHtml(
|
|
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
|
reply?: ReplyMeta | null,
|
|
opts: QuoteHeaderOptions = {},
|
|
): string {
|
|
if (!reply || mode === 'compose') return '<p><br></p>';
|
|
|
|
const { timeFormat = '24h', locale, unknownLabel = 'Unknown', labels } = opts;
|
|
|
|
const quotedBody = reply.htmlBody
|
|
? stripDangerousTags(reply.htmlBody)
|
|
: reply.body
|
|
? escapeForHtmlBody(reply.body)
|
|
: '';
|
|
|
|
if (!quotedBody) return '<p><br></p>';
|
|
|
|
const header = buildQuoteHeader({
|
|
mode,
|
|
email: { from: reply.from, subject: reply.subject, receivedAt: reply.receivedAt },
|
|
timeFormat,
|
|
locale,
|
|
unknownLabel,
|
|
labels,
|
|
});
|
|
|
|
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. Links keep their target as `text <href>`
|
|
* so a pasted link survives the text/plain alternative.
|
|
*/
|
|
export function htmlToPlainText(html: string): string {
|
|
if (!html) return '';
|
|
let s = html;
|
|
// Drop non-content blocks entirely.
|
|
s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
// Links: "text <href>" unless the text already is the URL (or mailto target).
|
|
s = s.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_full, attrs: string, inner: string) => {
|
|
const hrefMatch = attrs.match(/\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
|
const href = (hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? '').trim();
|
|
const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
if (!href) return inner;
|
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
|
if (!text || text === href || text === normalizedHref) return text || href;
|
|
// Angle brackets are restored after the tag strip below.
|
|
return `${text} \u0001${href}\u0002`;
|
|
});
|
|
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
|
s = s.replace(/<br\s*\/?>/gi, '\n');
|
|
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
|
// newline so they stack tightly.
|
|
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
|
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
|
s = s.replace(/<\/(td|th)>/gi, '\t');
|
|
s = s.replace(/<li[^>]*>/gi, '• ');
|
|
s = s.replace(/<[^>]+>/g, '');
|
|
s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>');
|
|
// Decode the most common entities; leave the rest as-is.
|
|
s = s
|
|
.replace(/ /gi, ' ')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/'/gi, "'")
|
|
.replace(/&/gi, '&');
|
|
// 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
|
|
* set of CIDs actually referenced so the sender can include exactly the
|
|
* needed inline parts.
|
|
*/
|
|
export function rewriteInlineImages(html: string): { html: string; usedCids: string[] } {
|
|
if (!html || !/data-cid=/i.test(html)) return { html, usedCids: [] };
|
|
const used = new Set<string>();
|
|
const out = html.replace(
|
|
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
|
(_full, before: string, _quoted: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
|
const cid = dq ?? sq ?? '';
|
|
if (!cid) return _full;
|
|
used.add(cid);
|
|
// Drop any existing src= and replace with cid: form.
|
|
const merged = (before + after)
|
|
.replace(/\ssrc=("[^"]*"|'[^']*')/gi, '')
|
|
.trim();
|
|
const space = merged ? ' ' + merged : '';
|
|
return `<img src="cid:${cid}"${space}>`;
|
|
},
|
|
);
|
|
return { html: out, usedCids: Array.from(used) };
|
|
}
|
|
|
|
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
|
// being fetched from JMAP (webmail #163).
|
|
export const INLINE_IMAGE_PLACEHOLDER =
|
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
|
|
/**
|
|
* Rewrites `<img src="cid:xxx">` references into
|
|
* `<img src="<placeholder>" data-cid="xxx">` so the editor can render the
|
|
* quoted original (a `cid:` URL would show a broken image) while still
|
|
* carrying the cid through edits. Returns the cids found.
|
|
*/
|
|
export function rewriteCidImagesForEditor(html: string): { html: string; cids: string[] } {
|
|
if (!html || html.indexOf('cid:') === -1) return { html, cids: [] };
|
|
const cids = new Set<string>();
|
|
const out = html.replace(
|
|
/<img\b([^>]*?)\ssrc=("cid:([^"]*)"|'cid:([^']*)')([^>]*)>/gi,
|
|
(_full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
|
const cid = (dq ?? sq ?? '').trim();
|
|
if (!cid) return _full;
|
|
cids.add(cid);
|
|
const rest = (before + after).replace(/\sdata-cid=("[^"]*"|'[^']*')/gi, '').trim();
|
|
return `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
|
},
|
|
);
|
|
return { html: out, cids: Array.from(cids) };
|
|
}
|
|
|
|
/**
|
|
* Replace the placeholder src on `<img data-cid="...">` elements with the
|
|
* resolved data URL once the inline blob has been fetched.
|
|
*/
|
|
export function replaceInlineImagePlaceholders(
|
|
html: string,
|
|
cidToDataUrl: Map<string, string>,
|
|
): string {
|
|
if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html;
|
|
return html.replace(
|
|
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
|
(full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
|
const cid = dq ?? sq ?? '';
|
|
const dataUrl = cidToDataUrl.get(cid);
|
|
if (!dataUrl) return full;
|
|
const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim();
|
|
return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '"')}"${rest ? ' ' + rest : ''}>`;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sniffs the real image MIME type from a blob's leading magic bytes, returning
|
|
* null when the bytes aren't a recognizable image (webmail #543).
|
|
*/
|
|
export function sniffImageMime(bytes: Uint8Array): string | null {
|
|
if (!bytes || bytes.length < 4) return null;
|
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
|
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif';
|
|
if (
|
|
bytes.length >= 12 &&
|
|
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
) return 'image/webp';
|
|
if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp';
|
|
return null;
|
|
}
|