Files
ihasmail-inbuxa/web/src/lib/address.ts
T
jcoffey-dev dfe885a921 Close the smaller gaps from the security review
Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.

Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.

On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.

Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
2026-09-16 08:47:23 -07:00

162 lines
5.0 KiB
TypeScript

import type { EmailAddress } from "@/jmap/types";
import { withoutBidiControls } from "@/lib/text/text";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
export function isValidEmail(s: string): boolean {
return EMAIL_RE.test(s.trim());
}
/**
* Parse a free-form recipient string ("Ann <[email protected]>, [email protected]; \"C, D\" <c@z>")
* into a list of EmailAddress. Lenient by design.
*/
export function parseAddressList(input: string): EmailAddress[] {
const out: EmailAddress[] = [];
let buf = "";
let inQuote = false;
let inAngle = false;
const flush = () => {
const a = parseOne(buf);
if (a) out.push(a);
buf = "";
};
for (const ch of input) {
if (ch === '"' && !inAngle) inQuote = !inQuote;
if (ch === "<" && !inQuote) inAngle = true;
if (ch === ">" && !inQuote) inAngle = false;
if ((ch === "," || ch === ";" || ch === "\n") && !inQuote && !inAngle) {
flush();
continue;
}
buf += ch;
}
flush();
return out;
}
export function parseOne(raw: string): EmailAddress | null {
const s = raw.trim();
if (!s) return null;
const m = /^(.*?)\s*<([^<>]+)>\s*$/.exec(s);
if (m) {
let name = m[1]!.trim();
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, "$1");
return { name: name || null, email: m[2]!.trim() };
}
return { name: null, email: s.replace(/^<|>$/g, "") };
}
export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return "";
const clean = a.name ? withoutBidiControls(a.name) : "";
if (!clean) return a.email;
const needsQuote = /[,;<>"()\\]/.test(clean);
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
return `${name} <${a.email}>`;
}
export function formatAddressList(list: EmailAddress[] | null | undefined): string {
return (list ?? []).map(formatAddress).join(", ");
}
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback;
const name = a.name ? withoutBidiControls(a.name).trim() : "";
if (name) return name;
return a.email || fallback;
}
export function shortName(a: EmailAddress | null | undefined): string {
const n = displayName(a, "");
if (!n) return "";
if (n.includes("@")) return n.split("@")[0]!;
return n.split(/\s+/)[0]!;
}
export function initials(a: EmailAddress | { name?: string | null; email?: string } | string | null | undefined): string {
const name = typeof a === "string" ? a : a?.name || a?.email || "";
const parts = name
.replace(/[<>"]/g, "")
.split(/[\s._@-]+/)
.filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
const PALETTE = [
"#0f766e", "#b45309", "#7c3aed", "#be185d", "#1d4ed8", "#047857",
"#c2410c", "#4338ca", "#a21caf", "#0e7490", "#b91c1c", "#15803d",
];
export function avatarColor(seed: string | null | undefined): string {
const s = (seed ?? "").toLowerCase();
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length]!;
}
export function sameAddress(a: string | null | undefined, b: string | null | undefined): boolean {
return (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
}
export function uniqueAddresses(list: EmailAddress[]): EmailAddress[] {
const seen = new Set<string>();
const out: EmailAddress[] = [];
for (const a of list) {
const k = a.email.trim().toLowerCase();
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(a);
}
return out;
}
export function domainOf(email: string): string {
const i = email.lastIndexOf("@");
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
}
export interface MailtoFields {
to: EmailAddress[];
cc: EmailAddress[];
bcc: EmailAddress[];
subject: string;
body: string;
}
/**
* Parse a `mailto:` URL (RFC 6068) into composer fields.
*
* Recipients may sit in the path, in `to=`, or both; headers other than
* to/cc/bcc/subject/body are ignored. Percent-encoding is undone leniently —
* a malformed escape yields the raw text rather than throwing.
*/
export function parseMailto(url: string): MailtoFields {
const withoutScheme = url.replace(/^mailto:/i, "");
const q = withoutScheme.indexOf("?");
const path = q === -1 ? withoutScheme : withoutScheme.slice(0, q);
const params = new URLSearchParams(q === -1 ? "" : withoutScheme.slice(q + 1));
const header = (name: string) => {
for (const [k, v] of params) if (k.toLowerCase() === name) return v;
return "";
};
const addresses = (raw: string) => (raw.trim() ? parseAddressList(decode(raw)) : []);
return {
to: [...addresses(path), ...addresses(header("to"))],
cc: addresses(header("cc")),
bcc: addresses(header("bcc")),
subject: decode(header("subject")),
body: decode(header("body")),
};
}
function decode(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, " "));
} catch {
return s;
}
}