Files
Pouya LajevardiandClaude Opus 5 3c3ba5dc6e
Build and deploy / build-and-deploy (push) Failing after 4s
feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the
spam observation and four mitigations), in one change set.

D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already
published, so /fees/'s "Every figure is on this page" is true as written rather
than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the
rule INTERIM against R5, and R5 now carries it back, because a derived price
moves silently when a rate moves.

D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his
wording verbatim on /legal/privacy/ and /contact/. The clause that raised the
finding promised to DISCLOSE a conflicts check's outcome, which the attestation
does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed.

Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and
signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label
that tells a human not to tick it) and scoring that LABELS and never rejects:
nothing is dropped, nothing new is stored, and only the operator notification
changes. Q65 opens the WAF cost call.

The timing floor could not be built: there is no timing check and never has
been. docs/05 carries it struck, and every mechanism that would give a real
per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66.

configure.mjs gains section 5 — a custom origin request policy forwarding
CloudFront-Viewer-Address on /api/*. Written, dry-run against the live
distribution, NOT applied. It reads the handler's own header reads and refuses
to run if the whitelist omits one.

And reading the live account to do it found four AGENTS.md §7 rows saying the
intake backend was undeployed, two days after it went live — corrected against
get-function-configuration, get-routes, get-stage, get-policy and the deployed
zip, which was downloaded and read.

Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has
run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent
refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own
repairs. Stopped at two per D19.

Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0
· check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations
killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category
below 95 · configure.mjs dry run 0, nothing written.

Nothing deployed and nothing applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 10:06:37 -04:00

599 lines
26 KiB
JavaScript

/**
* The intake handler. Spec: docs/05-backend-spec.md. Resource names, region,
* table and SES state: AGENTS.md §7 — this file reads them from the environment
* and does not restate them.
*
* ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and
* `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built
* `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT
* DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on
* this file can say: an edit made in that belief ships to a form real inquirers
* are using. Changes here reach production on the next `docs/09` Part 5 run.
*
* ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check
* below. `docs/09` §7.1 is the only valid route probe; a 403 without that header
* is not evidence about the route. It has been misread as one twice.
*
* ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ─────────────────────────────
*
* The site ships ZERO JavaScript (AGENTS.md §7, and it is not "minimal" — none).
* So the form is a plain HTML POST, and this handler answers with **303 See
* Other** and a `Location` on the site. That gives, with no script anywhere:
*
* - a working form with JavaScript disabled, which is the failure this whole
* project exists to fix;
* - no JSON response rendered as a raw page, which is what a plain POST to an
* API Gateway JSON endpoint shows the user;
* - no double submission on refresh, because the browser lands on a GET.
*
* docs/05's definition of done asks that the form "degrades to a mailto:
* fallback with JavaScript disabled". It does not need to: there is nothing to
* degrade FROM, because the form never used script. The email address is
* published on /contact/ regardless.
*
* ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ──────────────────────────────
*
* ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled
* *"raise the timing floor"* after the first real spam. There is no floor to
* raise — the check has never existed — and the reason below is unchanged by
* the spam arriving: it is a property of a CDN-cached static page, not of how
* hard anyone has tried. What CAN carry a per-visitor clock is named in
* `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66.
*
* **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.**
* docs/05 asks to "reject submissions completed in under 3 seconds". It cannot
* be done here and implementing it would produce a control that does nothing:
* the check needs to know when the form was SERVED to that visitor, and
* /contact/ is a static file cached at the CloudFront edge. A build-time
* timestamp is the same value for every visitor and is hours or days old, so
* `now - served` is always large — the check would pass for a bot exactly as it
* passes for a human. A per-visitor token needs either a dynamic origin or
* client-side script, and the site has neither by design.
*
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
* records of what a control that exists on paper and not in fact costs here. So
* it is omitted, said out loud, and the load is carried by the TWO honeypots,
* the Origin check, the aggregate API Gateway route throttle and the validation
* below — plus, since 2026-09-04, a score that LABELS and never rejects.
* (Aggregate, not per-IP — see above; the earlier wording here said "rate
* limit" and let the reader supply the stronger meaning.)
*
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
*
* - An AGGREGATE API Gateway route throttle. NOT per source IP: API Gateway
* throttling is per route and per stage across all callers, so docs/05's
* "5 requests / 5 minutes per source IP" is struck — per-IP needs AWS WAF.
* Never describe what ships as per-IP. docs/09 Part 6.3.
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
* - CloudWatch alarms on Lambda `Errors` and on API Gateway 5xx for this
* route. NOT a dead-letter queue: `DeadLetterConfig` is used only for
* ASYNCHRONOUS invocations, API Gateway invokes synchronously, so a DLQ here
* would sit at depth 0 for ever and an alarm on it would be a permanently
* green light. docs/05 §Notification carries the replacement.
* - The `ses-alerts` SNS email subscription is CONFIRMED (§7) — R9 closed
* 2026-09-01, so the bounce and complaint alarms reach someone.
*/
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { randomUUID } from 'node:crypto';
/* The field table and BOTH honeypot names live in their own module so that
`npm run check:intake` can import them without this file's module-scope
`requireEnv()` calls running. See fields.mjs for why there are two tables. */
import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs';
/* Scoring lives in its own module so it can be unit-tested — this file throws at
import without a configured environment, so it cannot be. `node
backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP:
`docs/09` Part 5.1 packages it explicitly, and a cold start would fail with
ERR_MODULE_NOT_FOUND if it were left out. */
import {
isPossibleSpam,
scoreSubmission,
SPAM_THRESHOLD,
} from './spam-score.mjs';
/* Region comes from the Lambda runtime, which sets AWS_REGION to the function's
own region — the one §7 records. Not hardcoded: a second copy of a fact §7
owns is the copy that goes stale. */
const ddb = new DynamoDBClient({});
const ses = new SESv2Client({});
const TABLE = requireEnv('INTAKE_TABLE');
const SITE_ORIGIN = requireEnv('SITE_ORIGIN');
const NOTIFY_TO = requireEnv('NOTIFY_TO');
const MAIL_FROM = requireEnv('MAIL_FROM');
/** 24 months, docs/05 §Retention. It must match /legal/privacy/ exactly.
* ⚠️ Writing this attribute is NOT the mechanism — TTL must be enabled on the
* table, and AGENTS.md §7 records whether it is. */
const RETENTION_MONTHS = 24;
/** The public commitment, §4 and Q27. It must read identically here, on
* /contact/, and in any bio. Injected rather than typed so one edit moves all
* three; the deploy step sets it from `CONTACT.responseTime`. */
const RESPONSE_TIME = requireEnv('RESPONSE_TIME');
/**
* ⚠️ INJECTED FOR EXACTLY THE REASON ABOVE, AND IT WAS HAND-TYPED UNTIL
* 2026-08-31. The confirmation email spelled the no-retainer notice out in
* prose, which made it a **fourth** hand-copy of `NO_RETAINER_NOTICE` — and the
* copy **dropped the fourth clause the constant carries**, *"and does not itself
* create a conflict check"*, which `docs/01` §`/contact/` requires. It also used
* a hyphen where the constant uses an en dash.
*
* The reasoning three lines above applied to it identically and was not applied.
* `npm run check:intake` compares field tables only, so a future softening of
* the constant would never have reached this email and nothing would have
* failed — the silent-drift shape §4 flags for the whole commitment class.
* Found by `adversarial-reviewer`. The deploy step sets it from
* `NO_RETAINER_NOTICE` in `src/data/site.ts`.
*
* ⚠️ AND THAT SENTENCE USED TO END "`docs/06` names it", WHICH IT DID NOT.
* This variable became a `requireEnv` and reached no document — so the
* deployment list said five variables while this file required six, and the
* function would have thrown at cold start on every invocation. **The comment
* asserting the documentation existed is what made it invisible.** `docs/06` and
* `docs/05` now name all six. Found by `adversarial-reviewer` round 2.
*/
const NO_RETAINER_NOTICE = requireEnv('NO_RETAINER_NOTICE');
function requireEnv(name) {
const value = process.env[name];
if (!value) {
// Fail at cold start, not per request: a function missing configuration
// should not accept a submission it cannot store.
throw new Error(`intake handler: ${name} is not set`);
}
return value;
}
/**
* Strip HTML before storage and before anything enters an email body (docs/05).
*
* NOT AN HTML SANITISER, AND IT DOES NOT NEED TO BE — every field is stored and
* rendered as PLAIN TEXT, never as markup, so the job is to make a value
* incapable of becoming markup later, not to allow safe markup now.
*
* ⚠️ AND FOR THAT REASON IT STRIPS ANGLE BRACKETS RATHER THAN ENTITY-ENCODING
* THEM. The first version of this function escaped `&` to `&amp;`, which is
* correct only when the sink is HTML: both sinks here are plain text, so the
* reader of the confirmation email would have received the five literal
* characters `&amp;` wherever they had typed an ampersand. Encoding for the
* wrong sink is a defect wearing the costume of a protection.
*/
function toPlainText(value) {
return (
value
// Control characters, including the CR/LF that would let a value forge a
// header line in an email, and the C1 range.
//
// `no-control-regex` is disabled ON PURPOSE and with the reason: that rule
// exists to catch a control character that reached a pattern by ACCIDENT,
// usually a mis-escaped literal. Here the control range IS the thing being
// matched, and it is the one part of this function that stops a submitted
// value from forging an email header. Rewriting it as a charCodeAt filter
// to satisfy the linter would make the intent less legible, not more.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
// Angle brackets removed rather than entity-encoded. The destination is
// plain text — a DynamoDB string attribute and a text/plain email body —
// so `&amp;` would REACH THE READER as the five characters "&amp;", which
// is a defect rather than a protection. Encoding is right when the sink is
// HTML; here the requirement is only that the value can never become
// markup if it is later put into one, and no `<` satisfies that
// permanently. Nothing else in the value is altered.
.replace(/[<>]/g, '')
.replace(/[ \t]{2,}/g, ' ')
.trim()
);
}
/**
* Email validation, server side. Deliberately structural rather than clever:
* one @, something either side, a dot in the domain, no whitespace, no angle
* brackets, within the RFC 5321 length. A regex that tries to implement RFC 5322
* rejects real addresses, and the confirmation email in D18 is the real check —
* if it does not arrive, the address was wrong whatever a regex said.
*/
function looksLikeEmail(value) {
return (
value.length <= 254 &&
/^[^\s@<>]+@[^\s@<>.]+(\.[^\s@<>.]+)+$/.test(value) &&
!value.includes('..')
);
}
function parseBody(event) {
const raw = event.isBase64Encoded
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
: (event.body ?? '');
const type = headerOf(event, 'content-type') ?? '';
if (type.includes('application/x-www-form-urlencoded')) {
return Object.fromEntries(new URLSearchParams(raw));
}
// JSON is accepted so the endpoint stays testable with curl, and because a
// future island could post JSON without changing this handler.
if (type.includes('application/json')) {
const parsed = JSON.parse(raw);
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error('body is not an object');
}
return parsed;
}
throw new Error(`unsupported content-type: ${type}`);
}
/**
* ⚠️ THE UNFORGEABLE VALUE, AND NOT THE USEFUL ONE. `requestContext.http
* .sourceIp` is the TCP peer, which behind the CloudFront behaviour that routes
* /api/* is a CloudFront EDGE — so this records AWS rather than the inquirer.
*
* IT READ `x-forwarded-for` FOR ONE REVISION AND THAT WAS WORSE. CloudFront
* APPENDS the viewer address to a client-supplied XFF rather than replacing it,
* so the leftmost entry is whatever the client sent: a submission with
* `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. That turns a field held for abuse
* investigation into one that can be made to name an uninvolved third party, and
* /legal/privacy/ promises the record holds "your IP address". A forgeable value
* presented as an identification is worse than an honest useless one.
*
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which
* CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request
* policy on the /api/* behaviour — the managed AllViewerAndCloudFrontHeaders
* forwards Host, which 403s every request at API Gateway.
*
* ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5,
* Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL
* DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet
* acted on*. What the record holds is published field by field on
* /legal/privacy/, so storing a different address is a DISCLOSURE change
* governed by `docs/09` §7.2's decision table — an infrastructure change
* forwards a header; only a privacy-policy change may store one. Do not "fix"
* this from any header without that measurement and that edit.
*/
function viewerIp(event) {
return event.requestContext?.http?.sourceIp ?? 'unknown';
}
function headerOf(event, name) {
const headers = event.headers ?? {};
// API Gateway HTTP API lowercases header keys; a direct invoke or a test
// harness may not, so this does not assume it.
const hit = Object.keys(headers).find((k) => k.toLowerCase() === name);
return hit ? headers[hit] : undefined;
}
const redirect = (path) => ({
statusCode: 303,
headers: {
Location: `${SITE_ORIGIN}${path}`,
// A redirect that a CDN or a browser caches would send the next visitor
// straight to the confirmation page without submitting anything.
'Cache-Control': 'no-store',
},
body: '',
});
const SUCCESS = '/contact/received/';
const FAILURE = '/contact/could-not-send/';
export async function handler(event) {
/**
* ORIGIN CHECK, AND IT IS THE CONTROL CORS IS USUALLY MISTAKEN FOR. A form
* POST is a top-level navigation: it is exempt from CORS preflight, so an
* `Access-Control-Allow-Origin` setting on the endpoint does not stop another
* site from posting a form here. Checking the header does.
*
* Firefox omits `Origin` on some same-origin form navigations, so `Referer` is
* accepted as a fallback — both must MATCH the site origin when present, and
* a request with neither is refused.
*/
const origin = headerOf(event, 'origin');
const referer = headerOf(event, 'referer');
const originOk = origin
? origin === SITE_ORIGIN
: referer
? referer.startsWith(`${SITE_ORIGIN}/`)
: false;
if (!originOk) {
return {
statusCode: 403,
headers: { 'Cache-Control': 'no-store' },
body: '',
};
}
let body;
try {
body = parseBody(event);
} catch {
return redirect(FAILURE);
}
/**
* THE HONEYPOT GETS THE SUCCESS PAGE, NOT AN ERROR. Telling a bot it was
* detected is how the next version of the bot stops filling the field. A
* human cannot reach this field — it is `display: none`, `tabindex="-1"` and
* `aria-hidden` — so a non-empty value is not a mistake anyone made.
*/
/* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can
arrive as `true` or `1` rather than a string — and `typeof === 'string'`
let exactly that through both traps for one round. `String(v).trim()`
catches every non-empty shape and still treats absence as a pass. */
if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') {
/* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION
AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing
on real visitors — a stylesheet that 404s, an autofiller, a template edit
that unhides the wrapper — is indistinguishable from quiet weeks, and the
only signal is inquiries that were never mentioned again. The FIELD NAME
only: the value is whatever a bot chose and nothing about the submission
is kept, which is what makes this safe to log at all. */
console.warn('intake: discarded by honeypot', { field: HONEYPOT });
return redirect(SUCCESS);
}
/**
* THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no
* person can see; an unchecked box sends nothing at all, so a VALUE arrives
* only because something ticked it. The value itself is not compared —
* `=1`, `=yes` and `=on` are all a tick — only that there is one.
*
* Same silent SUCCESS as above, and for the same reason.
*
* ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and
* they fail differently:
*
* - Requiring the field to ARRIVE would turn every dropped-field path — an
* extension, a proxy, a template edit — into a lost inquiry reported as
* sent.
* - Trapping on mere PRESENCE (`!== undefined`) would catch a form
* serialiser that emits `updates_optin=` for a hidden checkbox without
* reading its checked state. That is rare and it is not impossible, and
* the cost of being wrong is a real legal inquiry discarded in silence.
*
* So the test is the same shape as the honeypot above — a non-empty value —
* while the BEHAVIOUR it catches is the opposite one. That is the distinction
* that matters: filling text fields versus ticking boxes, not `undefined`
* versus `''`.
*/
if (
body[DECOY_CHECKBOX] !== undefined &&
String(body[DECOY_CHECKBOX]).trim() !== ''
) {
console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX });
return redirect(SUCCESS);
}
const clean = {};
const errors = [];
for (const field of FIELDS) {
const rawValue = body[field.name];
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
if (value === '') {
if (field.required) errors.push(`${field.name} is required`);
continue;
}
// REJECT over the cap rather than truncating (docs/05). A silently
// truncated matter summary is a file read wrongly.
if (field.max && value.length > field.max) {
errors.push(`${field.name} exceeds ${field.max} characters`);
continue;
}
if (field.options && !field.options.includes(value)) {
errors.push(`${field.name} is not one of the offered values`);
continue;
}
if (field.name === 'email' && !looksLikeEmail(value)) {
errors.push('email is not a well-formed address');
continue;
}
clean[field.name] = toPlainText(value);
}
// Explicit, unchecked by default, and required (docs/05). An unchecked box
// sends no value at all, so absence is the failure case.
if (body.consent !== 'on' && body.consent !== 'true') {
errors.push('consent was not given');
}
if (errors.length > 0) {
// Logged for the operator, never returned to the caller: an error list is a
// description of the validation rules, which is a gift to whoever is
// probing them.
console.warn('intake rejected', { errors });
return redirect(FAILURE);
}
const now = new Date();
const id = randomUUID();
/* NO `|| 0` FALLBACK (removed 2026-08-31): DynamoDB will not expire an item
whose TTL is more than five years past, so `ttl: 0` means RETAINED FOREVER
while /legal/privacy/ promises deletion. Let a bad value fail the write. */
const ttl = Math.floor(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth() + RETENTION_MONTHS,
now.getUTCDate(),
now.getUTCHours(),
now.getUTCMinutes(),
now.getUTCSeconds(),
) / 1000,
);
/**
* DYNAMODB FIRST, THEN MAIL — docs/05: "SES failure must never lose the
* submission." The order is the whole guarantee. If SES fails after this
* write, the record exists and a resend has something to resend; if the write
* fails, nothing was accepted and the inquirer is told so. (This said "the DLQ
* replay" — there is no DLQ and there cannot usefully be one on a
* synchronously invoked function; see the note at the top of this file.)
*/
try {
await ddb.send(
new PutItemCommand({
TableName: TABLE,
Item: {
/* ⚠️ `submissionId` IS THE TABLE'S PARTITION KEY AND THERE IS NO SORT
KEY. A DynamoDB key schema cannot be altered after creation, so this
attribute name is fixed by the table `AGENTS.md` §7 names, not
chosen here — and an item missing it fails the whole write with
`ValidationException`, which this function converts into the failure
page. Verify against `describe-table` before changing either name;
`submittedAt` is an ordinary attribute and is free. */
submissionId: { S: id },
submittedAt: { S: now.toISOString() },
ttl: { N: String(ttl) },
// Abuse investigation only (docs/05). Named so a later reader does not
// repurpose them: they are not analytics and not part of the reply.
/* Behind CloudFront this is the EDGE address, not the inquirer's.
See `viewerIp()` — and read it before changing this. */
sourceIp: { S: viewerIp(event) },
userAgent: {
S: (headerOf(event, 'user-agent') ?? 'unknown').slice(0, 400),
},
consentAt: { S: now.toISOString() },
...Object.fromEntries(
Object.entries(clean).map(([k, v]) => [k, { S: v }]),
),
},
}),
);
} catch (error) {
console.error('intake: DynamoDB write failed', error);
return redirect(FAILURE);
}
// `f.label`, not `f.name` — see the note on `label` in fields.mjs. The
// notification to the operator gets the same rendering: one shape, so the two
// messages cannot describe the same submission differently.
const summaryLines = FIELDS.filter((f) => clean[f.name] !== undefined)
.map((f) => `${f.label}: ${clean[f.name]}`)
.join('\n');
/**
* SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04.
*
* ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF
* ORDERING. Nothing below can decline a submission: by the time it runs, the
* write has already succeeded and the only remaining question is what the
* OPERATOR's subject line says. There is deliberately no branch here that can
* reach `redirect(FAILURE)`.
*
* ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is
* unchanged. A real inquirer wrongly scored must never be told that a machine
* thought they were a bot.
*/
/* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception
here would escape `handler`, API Gateway would answer 500, and the inquirer
would see a failure for a submission ALREADY WRITTEN to the table — a path
that costs an inquiry, decided by a labelling function. Pouya's constraint
is that nothing but a honeypot may cost one, so the scorer is allowed to
fail and the submission is not. Unlabelled is the safe default. */
let spam = { score: 0, signals: [] };
try {
spam = scoreSubmission(clean);
} catch (error) {
console.error('intake: spam scoring failed; sending unlabelled', {
id,
error,
});
}
const flagged = isPossibleSpam(spam);
const notificationBody = [
`Received ${now.toISOString()}`,
`submissionId ${id}`,
...(flagged
? [
'',
`Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` +
`Signals: ${spam.signals.join('; ')}.`,
]
: []),
'',
summaryLines,
'',
].join('\n');
/**
* TWO EMAILS — D18, and the second one is why the form beats a mailto: link.
* `Promise.allSettled`, not `Promise.all`: the record is already stored, so a
* failure on either message must be logged rather than lost, and one failing
* must not prevent the other from being attempted.
*/
const results = await Promise.allSettled([
ses.send(
new SendEmailCommand({
FromEmailAddress: MAIL_FROM,
Destination: { ToAddresses: [NOTIFY_TO] },
// Replyable to the inquirer (docs/05), which is what makes the
// notification usable without copying an address out of it.
ReplyToAddresses: [clean.email],
Content: {
Simple: {
/* The prefix is what Pouya filters on in Gmail, so it is the
FIRST thing in the subject and it is a fixed string. Do not make
it conditional on anything else, and do not vary its wording. */
Subject: {
Data:
`${flagged ? '[Possible spam] ' : ''}` +
`Intake — ${clean.name} (${clean.practiceArea})`,
},
Body: {
// The body carries the bare submissionId, because it is the
// partition key: that line gets pasted into the console to find
// the record, so it must be the key and not a rendering of it.
Text: { Data: notificationBody },
},
},
},
}),
),
ses.send(
new SendEmailCommand({
FromEmailAddress: MAIL_FROM,
Destination: { ToAddresses: [clean.email] },
Content: {
Simple: {
Subject: { Data: 'Your inquiry has been received' },
Body: {
Text: {
Data: [
`Thank you — your inquiry has been received.`,
``,
RESPONSE_TIME,
``,
NO_RETAINER_NOTICE,
``,
`What you sent:`,
``,
summaryLines,
``,
`How this information is handled, and how to ask for it to be`,
`deleted: ${SITE_ORIGIN}/legal/privacy/`,
``,
].join('\n'),
},
},
},
},
}),
),
]);
results.forEach((result, i) => {
if (result.status === 'rejected') {
console.error(
`intake: SES send ${i === 0 ? 'notification' : 'confirmation'} failed`,
{ id, reason: result.reason },
);
}
});
// The submission is stored. Mail failures are an operator problem, not the
// inquirer's, and telling them it failed would invite a second submission of
// a record that already exists.
return redirect(SUCCESS);
}