search: email address search + address-first ranking; inline images; folder i18n; link security
Search (PG backend):
- '@' terms match address substrings (from/to/cc), so a partial email like
"dyrektor@wfdif" resolves and narrows predictably as you type
- index each address split into parts, so a bare domain/local word ("wfdif")
finds the address (indexed as one lexeme otherwise); dotted terms fall back to
an address substring
- rank sender/recipient name+address matches above body/subject-only ones
(addr_hit tier, then recency); cursor extended to keep keyset pagination correct
- scripts/reindex_address_parts.py backfills address tokens into existing rows
Inline images:
- extract attachments via a full MIME walk instead of shallow iter_attachments(),
which missed inline images nested in mixed>related>alternative (cid: rendered
broken); has_attachments counts only real (non-cid) attachments
- scripts/backfill_inline_attachments.py stores previously-missed inline images
Folder-name i18n (SPEC §12 M2):
- accent/punctuation-insensitive, multi-delimiter (/ \ .) special-use inference
across ~13 languages; curated names (no substring false positives)
Link security (reading panel):
- links never navigate the message iframe: a parent-side click interceptor opens
http/https in a new tab (noopener) and mailto in the composer (recipient
pre-filled); all other schemes blocked
Backend 292 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -12,7 +12,13 @@ import { Attachments } from "./Attachments";
|
||||
// measure the content to auto-size the frame — so the message shows in full and
|
||||
// only the thread panel scrolls — and (b) send the session cookie so inline
|
||||
// (cid:) images authenticate and load.
|
||||
export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
export function MessageBody({
|
||||
message,
|
||||
onMailto,
|
||||
}: {
|
||||
message: MessageEnvelope;
|
||||
onMailto?: (email: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
// Seed from the server's trusted-sender flag so an already-approved sender
|
||||
@@ -20,6 +26,8 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
const [allowed, setAllowed] = useState(message.images_allowed);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const onMailtoRef = useRef(onMailto);
|
||||
onMailtoRef.current = onMailto;
|
||||
const [height, setHeight] = useState(80);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
@@ -73,11 +81,31 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
[resize],
|
||||
);
|
||||
|
||||
// Links must NEVER navigate the message iframe (security: a click would replace
|
||||
// the message body, and could be used for clickjacking/phishing). The frame is
|
||||
// same-origin, so the parent intercepts clicks here: http/https open in a new
|
||||
// tab (noopener), mailto opens the composer, and every other scheme is blocked.
|
||||
const handleFrameClick = useCallback((e: Event) => {
|
||||
const el = e.target as Element | null;
|
||||
const anchor = el?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
const raw = (anchor.getAttribute("href") || "").trim();
|
||||
e.preventDefault();
|
||||
if (/^mailto:/i.test(raw)) {
|
||||
const email = decodeURIComponent(raw.replace(/^mailto:/i, "").split("?")[0]).trim();
|
||||
if (email) onMailtoRef.current?.(email);
|
||||
} else if (/^https?:\/\//i.test(raw)) {
|
||||
window.open(anchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
// else: relative / cid: / javascript: / data: / tel: — blocked, no navigation.
|
||||
}, []);
|
||||
|
||||
const onLoad = () => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
injectQuoteToggle(doc);
|
||||
resize();
|
||||
doc.addEventListener("click", handleFrameClick);
|
||||
// Re-measure as images finish loading (they change the content height).
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
if (!img.complete) img.addEventListener("load", resize, { once: true });
|
||||
|
||||
@@ -218,6 +218,7 @@ function MessageItem({
|
||||
alsoIn,
|
||||
onToggle,
|
||||
onReply,
|
||||
onMailto,
|
||||
onToggleRead,
|
||||
onPrint,
|
||||
onDelete,
|
||||
@@ -235,6 +236,7 @@ function MessageItem({
|
||||
alsoIn: string[];
|
||||
onToggle: () => void;
|
||||
onReply: (kind: string) => void;
|
||||
onMailto: (email: string) => void;
|
||||
onToggleRead: () => void;
|
||||
onPrint: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -385,7 +387,7 @@ function MessageItem({
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-3">
|
||||
<MessageBody message={message} />
|
||||
<MessageBody message={message} onMailto={onMailto} />
|
||||
</div>
|
||||
{showReplyBar && canReply && (
|
||||
<div className="flex gap-2 border-t border-app-border px-4 py-3">
|
||||
@@ -479,6 +481,16 @@ export function ThreadView({
|
||||
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
||||
};
|
||||
|
||||
// A mailto: link in the message body opens a fresh composer to that address,
|
||||
// sent from the account this message belongs to.
|
||||
const openMailto = (msg: MessageEnvelope, email: string) => {
|
||||
setExpanded((prev) => new Set(prev).add(msg.id));
|
||||
setComposing({
|
||||
anchorId: msg.id,
|
||||
init: { accountId: msg.account_id, kind: "new", to: email, subject: "" },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -583,6 +595,7 @@ export function ThreadView({
|
||||
alsoIn={alsoIn}
|
||||
onToggle={() => toggle(message.id)}
|
||||
onReply={(kind) => openReply(message, kind)}
|
||||
onMailto={(email) => openMailto(message, email)}
|
||||
onToggleRead={() => toggleRead(message)}
|
||||
onPrint={() => void printMessages(subject, [message])}
|
||||
onDelete={() => deleteMessage(message)}
|
||||
|
||||
Reference in New Issue
Block a user