Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ resource "aws_lambda_function" "content_sanitizer" {
handler = "content-sanitizer.handler"
runtime = "nodejs24.x"
memory_size = 128
timeout = 10
timeout = 60
publish = true

filename = data.archive_file.lambda_stub.output_path
Expand Down
18 changes: 13 additions & 5 deletions src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,23 @@ export function createApp({ threadDb, resourceDb, accountDb, exchangesDb, auditD
? await c.req.raw.clone().text()
: undefined;

await next();
// Fires on its own clock at the 25s mark regardless of whether next() ever settles —
// a stuck downstream handler would never reach code after `await next()`, since the
// await itself never returns. This is a plain timer, not a check after the await,
// precisely so it still fires even if the request hangs all the way to Lambda timeout.
const slowRequestTimer = setTimeout(() => {
logger.track("Request exceeded 25s — at risk of Lambda timeout.", { code: "api.slow_request", method: c.req.method, path: c.req.path, elapsedMs: Date.now() - start });
}, 25_000);

try {
await next();
} finally {
clearTimeout(slowRequestTimer);
}

const elapsed = Date.now() - start;
const status = c.res.status;

if (elapsed > 25_000) {
logger.track("Request exceeded 25s — at risk of Lambda timeout.", { code: "api.slow_request", method: c.req.method, path: c.req.path, status, elapsedMs: elapsed });
}

const logData: Record<string, unknown> = {
code: "api.request",
method: c.req.method,
Expand Down
50 changes: 41 additions & 9 deletions src/isolated/content-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { simpleParser } from "mailparser";
import { Window } from "happy-dom";
import { sanitizeHtml } from "./html-sanitizer.js";
import { extractAssets, type ExtractedAsset } from "./asset-extractor.js";
import type { Logger } from "../logger.js";

// Lambda timeout is 60s (see deploy/compute.tf). Emitting a TRACK log past this
// threshold surfaces invocations that are close to timing out, before they start
// actually failing — mirrors the api.slow_request pattern in src/api/app.ts.
const SLOW_INVOCATION_THRESHOLD_MS = 50_000;

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -155,25 +161,39 @@ async function uploadViaPresignedPost(
// ContentSanitizeError instead of an opaque Lambda "Unhandled" FunctionError — the
// processor can then surface the real message/type instead of just "Unhandled".
export async function handler(event: ContentSanitizeRequest): Promise<ContentSanitizeResponse | ContentSanitizeError> {
const start = Date.now();
let logger: Logger | undefined;
if (event.invocationId) {
const { RequestLogger } = await import("../logger.js");
const requestLogger = new RequestLogger();
requestLogger.startInvocation(event.invocationId);
requestLogger.info("content-sanitizer.invoked", { code: "content_sanitizer.invoked", invocationId: event.invocationId, accountId: event.accountId });
logger = requestLogger;
}

// Fires on its own clock at the 50s mark regardless of whether processEmail ever
// settles — a stuck fetch/parse would never reach a `finally` after `await`, since
// the await itself never returns. This is a plain timer, not a Promise.race, precisely
// so it still fires even if the awaited work hangs all the way to the Lambda timeout.
const slowInvocationTimer = setTimeout(() => {
logger?.track("Content sanitizer invocation exceeded 50s — at risk of Lambda timeout.", { code: "content_sanitizer.slow_invocation", elapsedMs: Date.now() - start, accountId: event.accountId });
}, SLOW_INVOCATION_THRESHOLD_MS);

try {
return await processEmail(event);
return await processEmail(event, logger);
} catch (e) {
return {
success: false,
error: { message: e instanceof Error ? e.message : String(e), type: "internal_error" },
};
} finally {
clearTimeout(slowInvocationTimer);
}
}

async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanitizeResponse | ContentSanitizeError> {
if (event.invocationId) {
const { RequestLogger } = await import("../logger.js");
const logger = new RequestLogger();
logger.startInvocation(event.invocationId);
logger.info("content-sanitizer.invoked", { code: "content_sanitizer.invoked", invocationId: event.invocationId, accountId: event.accountId });
}

async function processEmail(event: ContentSanitizeRequest, logger?: Logger): Promise<ContentSanitizeResponse | ContentSanitizeError> {
// 1. Fetch raw MIME via presignedGetUrl
logger?.trackPoint("fetch_mime_start");
let rawMime: Buffer;
try {
const response = await fetch(event.presignedGetUrl);
Expand All @@ -184,6 +204,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
};
}
rawMime = Buffer.from(await response.arrayBuffer());
logger?.trackPoint("fetch_mime_complete", { sizeBytes: rawMime.length });
} catch (e) {
return {
success: false,
Expand All @@ -192,9 +213,11 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
}

// 2. Parse with mailparser
logger?.trackPoint("mime_parse_start");
let parsed;
try {
parsed = await simpleParser(rawMime);
logger?.trackPoint("mime_parse_complete");
} catch (e) {
return {
success: false,
Expand Down Expand Up @@ -227,6 +250,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
error: { message: `Total attachment size ${totalAttachmentSize} bytes exceeds limit of 25MB`, type: "limits_exceeded" },
};
}
logger?.trackPoint("attachment_limits_validated", { attachmentCount: attachments.length, totalAttachmentSize });

// 5. Build CID map for inline images and upload real attachments to S3
let uploadIndex = 0;
Expand Down Expand Up @@ -262,6 +286,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit

uploadIndex++;
}
logger?.trackPoint("attachments_processed", { attachmentRefCount: attachmentRefs.length, inlineImageCount: inlineImages.length });

// 6. Sanitize HTML and inline CID images
let htmlBody: string | undefined;
Expand All @@ -271,6 +296,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
const htmlInput = typeof parsed.html === "string" ? parsed.html : "";
const sanitized = sanitizeHtml(htmlInput);
htmlBody = sanitized.html.replace(/cid:([^"'\s>]+)/g, (_, id: string) => cidMap[id] ?? "");
logger?.trackPoint("html_sanitized", { htmlLength: htmlInput.length });

// Extract links from raw HTML using a DOM parser (before sanitization strips tracking pixels etc.)
const linkDoc = new Window({ url: "about:blank" }).document;
Expand All @@ -291,6 +317,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
const text = anchorText && anchorText !== href ? anchorText : null;
extractedLinks.push({ url: href, text });
}
logger?.trackPoint("links_extracted_from_html", { linkCount: extractedLinks.length });
}

// Fallback: extract bare URLs from text body ONLY when no HTML part exists.
Expand All @@ -312,6 +339,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
// message would be stored with no visible body at all. Render the plain text as
// escaped, pre-formatted HTML so it flows through the existing display pipeline.
htmlBody = textToHtml(parsed.text);
logger?.trackPoint("links_extracted_from_text_fallback", { linkCount: extractedLinks.length });
}

// 9. Build response
Expand All @@ -331,13 +359,16 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
}
}
}
logger?.trackPoint("headers_extracted", { headerCount: Object.keys(headers).length });

// 7. Extract scannable assets (QR codes, PKPass barcodes) — best-effort
let extractedAssets: ExtractedAsset[] = [];
try {
extractedAssets = await extractAssets(inlineImages, attachmentsWithBytes);
logger?.trackPoint("assets_extracted", { assetCount: extractedAssets.length });
} catch {
// extraction failure must never fail the sanitizer
logger?.trackPoint("assets_extraction_failed");
}

const result: ContentSanitizeResponse = {
Expand Down Expand Up @@ -372,5 +403,6 @@ async function processEmail(event: ContentSanitizeRequest): Promise<ContentSanit
result.parsed.links = extractedLinks;
}

logger?.trackPoint("response_built");
return result;
}