src/privacy.ts
type PrivacyAccount = { email: string; name?: string };
type RecipientHeaders = { to?: string; cc?: string };
export type PrivacyRange = {
start: number;
end: number;
// "hidden" covers text the matcher could not check; it is never revealable.
kind: "address" | "name" | "hidden";
value: string;
};
export type PrivacySegment = {
start: number;
end: number;
text: string;
kind: "text" | PrivacyRange["kind"];
value?: string;
};
const addressToken = /[^\s<>()"',;:]+/g;
const wordCharacter = /[\p{L}\p{M}\p{N}_]/u;
const cleanName = (name: string) => name.trim().replace(/\s+/gu, " ");
// Headers are already mailparser-formatted. Quotes protect commas and escapes.
function recipientNames(header: string, emails: Set<string>): string[] {
const names: string[] = [];
let start = 0;
let angle = -1;
let quoted = false;
let escaped = false;
for (let index = 0; index < header.length; index++) {
const character = header[index];
if (escaped) { escaped = false; continue; }
if (quoted && character === "\\") { escaped = true; continue; }
if (character === '"' && angle < 0) { quoted = !quoted; continue; }
if (quoted) continue;
if (character === "<") angle = index;
else if (character === ">" && angle >= 0) {
if (emails.has(header.slice(angle + 1, index).trim().toLowerCase())) {
let name = header.slice(start, angle).trim();
if (name.startsWith('"') && name.endsWith('"'))
name = name.slice(1, -1).replace(/\\(.)/g, "$1");
if (name) names.push(name);
}
start = index + 1;
angle = -1;
} else if (angle < 0 && (character === ":" || character === ";")) {
start = index + 1;
} else if (angle < 0 && character === ",") {
const preceding = header.slice(start, index).trim();
// A comma inside an unquoted surname stays in the phrase.
if (!preceding || preceding.includes("@")) start = index + 1;
}
}
return names;
}
// Use one message's headers, not a mailbox-wide alias dictionary.
export function collectPrivateNames(
accounts: readonly PrivacyAccount[],
recipients: RecipientHeaders = {},
): string[] {
const emails = new Set(accounts.map((account) => account.email.trim().toLowerCase()));
const candidates = [
...accounts.map((account) => account.name || ""),
...recipientNames(recipients.to || "", emails),
...recipientNames(recipients.cc || "", emails),
];
const names = new Map<string, string>();
for (const candidate of candidates) {
const name = cleanName(candidate);
if (name && /\p{L}/u.test(name)) names.set(name.toLowerCase(), name);
}
// Greetings often use only the first name or surname from the same recipient.
for (const name of [...names.values()]) {
for (const part of name.split(/[\s,]+/u)) {
if (/^\p{L}[\p{L}\p{M}’'-]*$/u.test(part) && !/^(mr|mrs|ms|mx|dr)$/i.test(part))
names.set(part.toLowerCase(), part);
}
}
return [...names.values()];
}
// Map decoded URL bytes back to source offsets, including malformed UTF-8.
function decodedText(text: string) {
let decoded = "";
const offsets: [number, number][] = [];
let last = 0;
const append = (start: number, end: number) => {
for (let index = start; index < end; index++) {
decoded += text[index];
offsets.push([index, index + 1]);
}
};
for (const match of text.matchAll(/(?:%[\da-f]{2})+/gi)) {
append(last, match.index);
for (let position = 0; position < match[0].length;) {
const byte = parseInt(match[0].slice(position + 1, position + 3), 16);
let width = byte >= 0xc2 && byte <= 0xdf ? 2 :
byte >= 0xe0 && byte <= 0xef ? 3 : byte >= 0xf0 && byte <= 0xf4 ? 4 : 1;
let value: string;
try { value = decodeURIComponent(match[0].slice(position, position + width * 3)); }
catch { value = "\ufffd"; width = 1; }
const start = match.index + position;
const end = start + width * 3;
decoded += value;
for (let index = 0; index < value.length; index++) offsets.push([start, end]);
position += width * 3;
}
last = match.index + match[0].length;
}
append(last, text.length);
return { decoded, offsets };
}
const PRIVATE_NAMES_MAX = 64, PRIVATE_NAME_LENGTH_MAX = 160, PRIVATE_NAME_WORDS_MAX = 16;
// Names the bounded matcher can check, deduplicated case-insensitively because
// matching ignores case. Returns null when any name exceeds the limits.
function matchableNames(names: readonly string[]): string[] | null {
const unique = new Map<string, string>();
for (const name of names.map(cleanName)) {
const words = name.split(/[\s+]+/u).filter(Boolean);
if (!words.length) continue;
if (name.length > PRIVATE_NAME_LENGTH_MAX || words.length > PRIVATE_NAME_WORDS_MAX) return null;
unique.set(folded(name), name);
}
return unique.size > PRIVATE_NAMES_MAX ? null : [...unique.values()];
}
// Lowercase with both sigma forms folded together, as /iu matching does.
// Same-length replacement keeps offsets aligned.
function folded(text: string) { return text.toLowerCase().replace(/ς/g, "σ"); }
// Offsets use UTF-16, like String.slice. Values are sensitive and never for attrs.
export function privateRanges(text: string, names: readonly string[] = []): PrivacyRange[] {
const candidates = matchableNames(names);
// Candidate size is bounded so matching cannot grow with crafted input. When
// a name falls outside what the matcher covers, hide the whole text without a
// reveal control, since one click would otherwise expose every private value.
if (!candidates) return text ? [{ start: 0, end: text.length, kind: "hidden", value: "" }] : [];
const { decoded, offsets } = text.includes("%") ? decodedText(text) : { decoded: text, offsets: null };
const ranges: PrivacyRange[] = [];
const add = (start: number, end: number, kind: PrivacyRange["kind"], value: string) => {
ranges.push({ start: offsets ? offsets[start][0] : start,
end: offsets ? offsets[end - 1][1] : end, kind, value });
};
// Scan delimiter-separated tokens once; an unanchored email regex is quadratic on long words.
for (const match of decoded.matchAll(addressToken)) {
const at = match[0].lastIndexOf("@");
if (at < 1) continue;
const suffix = [...match[0].slice(at + 1).matchAll(/\.[a-z]{2,}/gi)].at(-1);
if (!suffix) continue;
const length = at + 1 + suffix.index + suffix[0].length;
add(match.index, match.index + length, "address", match[0].slice(0, length));
}
const lower = folded(decoded);
// toLowerCase keeps offsets only when no character expands; otherwise the
// bounded expression preserves the Unicode case-folded match positions.
const aligned = lower.length === decoded.length;
const withinBounds = (start: number, end: number) => {
const before = Array.from(decoded.slice(Math.max(0, start - 2), start)).at(-1) || "";
const after = String.fromCodePoint(decoded.codePointAt(end) || 0);
return !wordCharacter.test(before) && !wordCharacter.test(after);
};
for (const name of candidates) {
// Words never contain a separator character, so matching stays linear.
const words = name.split(/[\s+]+/u).filter(Boolean);
const lowered = words.map(folded);
if (aligned) {
const first = lowered[0];
let at = 0;
while (at <= lower.length - first.length) {
const start = lower.indexOf(first, at);
if (start < 0) break;
at = start + 1;
let end = start + first.length, matched = true;
for (let index = 1; index < lowered.length; index++) {
const gap = end;
while (end < lower.length && /[\s+]/.test(lower[end])) end++;
if (end === gap || !lower.startsWith(lowered[index], end)) { matched = false; break; }
end += lowered[index].length;
}
if (matched && withinBounds(start, end)) add(start, end, "name", name);
}
continue;
}
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s+]+");
for (const match of decoded.matchAll(new RegExp(pattern, "giu"))) {
const start = match.index;
const end = start + match[0].length;
if (withinBounds(start, end)) add(start, end, "name", name);
}
}
ranges.sort((left, right) => left.start - right.start || right.end - left.end);
const merged: PrivacyRange[] = [];
for (const range of ranges) {
const previous = merged.at(-1);
if (previous && range.start < previous.end) {
previous.end = Math.max(previous.end, range.end);
// A name overlap must not become an address-only click-to-reveal token.
if (range.kind === "name") { previous.kind = "name"; previous.value = range.value; }
} else merged.push({ ...range });
}
return merged;
}
export function segmentPrivateText(text: string, names: readonly string[] = []): PrivacySegment[] {
const segments: PrivacySegment[] = [];
let last = 0;
for (const range of privateRanges(text, names)) {
if (range.start > last)
segments.push({ start: last, end: range.start, text: text.slice(last, range.start), kind: "text" });
segments.push({ ...range, text: text.slice(range.start, range.end) });
last = range.end;
}
if (last < text.length)
segments.push({ start: last, end: text.length, text: text.slice(last), kind: "text" });
return segments;
}
export function redactPrivateText(text: string, names: readonly string[] = []): string {
return segmentPrivateText(text, names)
.map((segment) => segment.kind === "text" ? segment.text : "••••••••").join("");
}
// Pass decoded visible text leaves in order, with explicit block separators.
// Returned offsets are local to each leaf; reveal keys keep the whole source token.
export function segmentPrivateParts(parts: readonly string[], names: readonly string[] = []): PrivacySegment[][] {
const segments = segmentPrivateText(parts.join(""), names)
.map((segment) => segment.kind === "text" || segment.kind === "hidden" ? segment : { ...segment, value: segment.text });
let offset = 0;
let cursor = 0;
return parts.map((part) => {
const end = offset + part.length;
const result: PrivacySegment[] = [];
while (cursor < segments.length && segments[cursor].end <= offset) cursor++;
for (let index = cursor; index < segments.length && segments[index].start < end; index++) {
const segment = segments[index];
const start = Math.max(segment.start, offset) - offset;
const stop = Math.min(segment.end, end) - offset;
if (start < stop)
result.push({ ...segment, start, end: stop, text: part.slice(start, stop) });
}
offset = end;
return result;
});
}