Two of Pouya's rulings of 2026-08-31, committed as one tree at his instruction
("as-is") because AGENTS.md Change Log entry (ai) covers both and splitting it
would mean rewriting the record rather than moving it. This is a deliberate
departure from one-logical-change-per-commit, recorded here rather than left to
be inferred.
THE EYEBROW. `--text-eyebrow: 0.8125rem` added to tokens.css; `.eyebrow` in
global.css retargeted to it. One edit site, which is what the design system
claimed. 13px is not a rung on the modular ladder — it sits between xs and sm
deliberately, because uppercase mono at 0.18em tracking reads smaller than it
measures. `--text-2xs`'s comment stopped calling itself the eyebrow floor.
The rendered sweep (22 pages × 2 widths, 838 mono elements measured over CDP,
not grepped) found exactly one escape and one deliberate override:
- h2.footer-heading, 176 instances — an ESCAPED EYEBROW. Five declarations
byte-identical to `.eyebrow`, differing only in colour. Consolidated to
`class="eyebrow footer-heading"`; the scoped rule is now colour + margin.
The colour is load-bearing, not decorative: `.eyebrow`'s own `--text-meta`
on ink is 3.07:1 and fails.
- span.eyebrow.brand-tagline, 21 instances — HELD at `--text-2xs`. Measured:
at 13px the header grows 81 → 83.4px while `--header-h` is pinned at 81 and
drives `scroll-padding-top`; and with a seventh nav item the CTA lands past
`.header-inner`'s content edge by 42px at 1216, 18px at 1240, 26px at 1280
and 1440. Document overflow is 0 in all of those, so no page-level check
can see it. Insights is that seventh item.
Everything else mono-uppercase is a genuinely different component and was left:
the 0.06em `--tracking-wide` family, the 14px mixed-case designation strip, the
/bio/ print sheet, /contact/'s form labels.
Measured after: contrast unchanged on all 817 instances (11.09 / 8.11 / 5.47 /
5.01:1, all pass at 13px, which is still normal text and needs 4.5:1). Zero
document overflow and identical header geometry at 15 widths. Two eyebrows gain
a line below 414px — /'s hero, already wrapping at 320px before this, and
/insights/'s empty state at 320px only. Accepted, not re-tuned.
docs/02's type spec moved 11–12px → 13px and now enumerates the three 11px
carve-outs instead of implying there are none. The /type-scale/ proof sheet
(d) asked for no longer exists — deleted at build step 2 — so the spec prose is
the proof sheet now.
THE TTL. backend/intake/handler.mjs CONFIRMED to match `AttributeName=ttl`: it
writes `ttl` as a Number, in epoch seconds, at RETENTION_MONTHS = 24. Nothing
needed changing for the enable command.
Removed `|| 0` from the TTL computation. DynamoDB does not expire an item whose
TTL is more than five years past, so `ttl: 0` meant RETAINED FOREVER while
/legal/privacy/ promises deletion — a fallback whose failure mode was the exact
inverse of the claim it was protecting. Unreachable in practice, which is why
it would never have been noticed. A bad value now fails the write.
§7 records TTL as DISABLED at first verification, so the privacy policy's
automatic-deletion promise was unbacked from the moment it was written. §7 is
the only place that status lives; docs/05 and docs/06 carry the constraint and
cite §7, because round 2 of review caught this change set reproducing the SES
DKIM defect — five copies of a status that is about to be re-stamped.
Added, and these are the gate: `TODO(pouya)` on /legal/privacy/'s retention
section, §9 Q60, §12 R19. The page does not publish a period, it asserts a
MECHANISM — deleted by the database rather than by someone remembering — and
nothing in the toolchain can see that. check:claims is frozen with no pattern
for it and deploy does not read docs/06. The copy was NOT softened: it is about
to be true, and weakening a privacy commitment to make it defensible is the
move the rules forbid. What was missing was the gate, not the caveat.
Reviewed by adversarial-reviewer, two rounds (D19 cap), 15 findings, all
accepted, none declined. claims-auditor did not run — D20. Round 1's findings
were almost entirely in prose written that session, and round 2's blocking
finding plus its sharpest should-fix were both defects in round 1's own fixes.
public/pouya-lajevardi-bio.pdf is deliberately NOT in this commit. It was
regenerated and reverted: /bio/'s eyebrow sits inside `.no-print`, so the sheet
has no eyebrow at all, and `cmp -l` showed exactly 10 differing bytes, all in
/CreationDate and /ModDate.
Gates, every one read as an exit status and none through a pipe: check 0,
build 0 (22 pages), check:claims 0, og:proof 0, check:intake 0, lint 0,
minifier tripwire clean, TODO in dist 0. Lighthouse run twice with identical
output: perf 99 on / and 100 on the other 21, a11y 100, best practices 100,
SEO 100 on every indexable page, CLS 0.000, LCP 1.50–2.03s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
434 lines
18 KiB
JavaScript
434 lines
18 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 API Gateway rate limit and the validation below.
|
|
*
|
|
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
|
|
*
|
|
* - API Gateway throttling, 5 requests / 5 minutes per source IP (docs/05).
|
|
* Not expressible in handler code.
|
|
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
|
|
* - A dead-letter queue on this function and a CloudWatch alarm on DLQ depth
|
|
* >= 1 (docs/05). This handler writes to DynamoDB BEFORE sending mail so a
|
|
* DLQ replay cannot lose a submission.
|
|
* - The `ses-alerts` SNS email subscription is PENDING CONFIRMATION (§7, R9).
|
|
* Until it is confirmed the bounce and complaint alarms fire into nothing.
|
|
*/
|
|
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}`);
|
|
}
|
|
|
|
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 the DLQ replay has something to replay; if the
|
|
* write fails, nothing was accepted and the inquirer is told so.
|
|
*/
|
|
try {
|
|
await ddb.send(
|
|
new PutItemCommand({
|
|
TableName: TABLE,
|
|
Item: {
|
|
pk: { S: `INTAKE#${id}` },
|
|
sk: { 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.
|
|
sourceIp: { S: event.requestContext?.http?.sourceIp ?? 'unknown' },
|
|
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: {
|
|
Data: `Received ${now.toISOString()}\nRecord INTAKE#${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);
|
|
}
|