Build and deploy / build-and-deploy (push) Failing after 4s
Five items of Pouya's production run, 2026-09-01.
Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.
Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.
Four launch blockers found by reading the running system:
- handler.mjs wrote pk/sk; the live table's key is submissionId with no
sort key, so every submission would have failed validation silently
- the Lambda invoke permission is scoped to the old route path
- 22 of 23 pages 403 without the router function
- there was no 404 page; src/pages/404.astro adds it
Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.
Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
481 lines
21 KiB
JavaScript
481 lines
21 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 NOT DEPLOYED. Written at build step 8; nothing on this project
|
|
* deploys before cutover (D11). AGENTS.md §7 records that a hand-built
|
|
* `adr-intake-handler` already exists in the console, created before this repo,
|
|
* and this file REPLACES it rather than describing it. docs/06's cutover
|
|
* checklist carries the deployment steps and the CloudFront `/api/*` behaviour
|
|
* the form depends on. Until both are done the form on /contact/ posts into
|
|
* nothing, which is why that page also publishes the email address.
|
|
*
|
|
* ── 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 ──────────────────────────────
|
|
*
|
|
* **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 honeypot, the
|
|
* Origin check, the aggregate API Gateway route throttle and the validation
|
|
* below. (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 the honeypot name 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 { FIELDS, HONEYPOT } from './fields.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 `&`, 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 `&` 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 `&` would REACH THE READER as the five characters "&", 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 — but reaching it needs a CUSTOM origin
|
|
* request policy on the /api/* behaviour (the managed
|
|
* AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API
|
|
* Gateway, which is why AllViewerExceptHostHeader was chosen). That is an
|
|
* infrastructure change, and `docs/09` Part 7.2 measures what this field
|
|
* actually contains at cutover rather than reasoning about the proxy chain —
|
|
* with a decision table for each outcome. Do not "fix" this from the header
|
|
* again without that measurement.
|
|
*/
|
|
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.
|
|
*/
|
|
if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') {
|
|
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');
|
|
|
|
/**
|
|
* 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: {
|
|
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` },
|
|
Body: {
|
|
Text: {
|
|
// The bare id, because it is the partition key: this line is
|
|
// what gets pasted into the console to find the record, so it
|
|
// must be the key and not a rendering of it.
|
|
Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
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);
|
|
}
|