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
17 changes: 12 additions & 5 deletions apps/site/app/api/email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,18 @@ disable/redact request bodies and recipient-bearing telemetry/traces. Avoid even
destinations that retain addresses; use aggregate SES reputation/bounce/complaint
metrics instead.

The only Axiom event is `{ event: "email_requested", route: "/api/email",
_time: "..." }` for a valid, non-honeypot, rate-admitted submission. It receives
no request data: no email, IP, IP digest, headers, user agent or provider error.
Ingest runs via Next's `after()`, once, without retries; absent config and
ingest failures are silent and do not affect sending.
Every request emits one wide event (`lib/log`, shared with the release routes):
deployment context, `status_code`, `outcome`, `duration_ms`, and why it ended:
`bot` (`human`/`bot`/`verified_bot`/`unavailable`), `rejection`
(`origin_mismatch`, `content_type`, `unreadable_body`, `honeypot`,
`invalid_syntax`, `rate_limited`), `validation`
(`accepted`/`invalid`/`uncertain`/`unavailable`), `failure`
(`botid_unavailable`, `validation_<reason>`, `email_unconfigured` with
`missing_config` variable names, `send_failed`), `error` (error type and HTTP
status only) and `delivery` (`sent`/`preview`). It never carries the email
address, its domain, an IP, headers, the body or any error message. It is sent
to Axiom once via `after()` and written to the function log with the ingest
result; logging never affects sending.

Validation accepts plain ASCII addresses with a dotted domain, caps addresses
at 254 characters (64 for the local part), and rejects display names, empty
Expand Down
18 changes: 0 additions & 18 deletions apps/site/app/api/email/_lib/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
import { SendEmailCommand, type SESv2ClientConfig } from "@aws-sdk/client-sesv2";
import { createEmailHandler } from "./index";
import { createRateLimit, windowMs } from "./rate-limit";
import { logEmailRequested } from "./telemetry";
import { createTransport, type EmailEnv, type SesFactory } from "./transport";
import { validEmail } from "./validation";
import { fakeValidator } from "./validator";
Expand Down Expand Up @@ -280,20 +279,3 @@ describe("abuse guard", () => {
expect(validEmail(email)).toBe(false);
});
});

test("Axiom event contains only a request fact, route and timestamp; failures are silent", async () => {
const bodies: string[] = [];
await logEmailRequested(
{ NODE_ENV: "production", AXIOM_TOKEN: "fake", AXIOM_DATASET: "site events" },
async (url, init) => {
expect(url).toBe("https://api.axiom.co/v1/ingest/site%20events");
bodies.push(
(await new Response(init.body).text()).replace(/"_time":"[^"]+"/, '"_time":"timestamp"')
);
throw new Error("private provider payload");
}
);
expect(bodies).toEqual([
JSON.stringify([{ event: "email_requested", route: "/api/email", _time: "timestamp" }]),
]);
});
106 changes: 106 additions & 0 deletions apps/site/app/api/email/_lib/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test";
import type { WideEvent } from "../../../../lib/log";
import { createEmailHandler } from "./index";
import { EmailValidationError, type EmailValidator } from "./validator";

const email = "private-recipient@example.com";

type Deps = Partial<Parameters<typeof createEmailHandler>[0]>;

function submit(deps: Deps = {}) {
const handler = createEmailHandler({
checkBot: async () => ({ isBot: false, isVerifiedBot: false }),
validator: { validate: async () => true },
transport: () => ({ preview: false, send: async () => {} }),
rateLimit: () => 0,
...deps,
});

const event: WideEvent = {};

return handler(
new Request("https://polaris.lux.dev/api/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, website: "" }),
}),
event
).then((response) => ({ status: response.status, event }));
}

const throwing = (error: Error): EmailValidator => ({
validate: async () => {
throw error;
},
});

describe("email wide event", () => {
test.each<[string, Deps, number, WideEvent]>([
[
"BotID failure",
{
checkBot: async () => {
throw new Error(email);
},
},
503,
{ bot: "unavailable", failure: "botid_unavailable", error: { type: "Error" } },
],
[
"validator provider error",
{
validator: throwing(
new EmailValidationError("provider", { type: "InvalidIdentityToken", http_status: 400 })
),
},
503,
{
bot: "human",
validation: "unavailable",
failure: "validation_provider",
error: { type: "InvalidIdentityToken", http_status: 400 },
},
],
[
"uncertain verdict",
{ validator: throwing(new EmailValidationError("uncertain")) },
503,
{ validation: "uncertain", failure: "validation_uncertain" },
],
[
"invalid recipient",
{ validator: { validate: async () => false } },
422,
{ validation: "invalid" },
],
[
"missing configuration",
{ transport: () => null, configProblems: () => ["AWS_ROLE_ARN"] },
503,
{ validation: "accepted", failure: "email_unconfigured", missing_config: ["AWS_ROLE_ARN"] },
],
[
"SES send failure",
{
transport: () => ({
preview: false,
send: async () => {
throw Object.assign(new Error(`Rejected ${email}`), {
name: "MessageRejected",
$metadata: { httpStatusCode: 400 },
});
},
}),
},
503,
{ failure: "send_failed", error: { type: "MessageRejected", http_status: 400 } },
],
["delivered", {}, 200, { bot: "human", validation: "accepted", delivery: "sent" }],
])("%s records why, never the address", async (_, deps, status, fields) => {
const result = await submit(deps);

expect(result.status).toBe(status);
expect(result.event).toMatchObject(fields);
expect(JSON.stringify(result.event)).not.toContain("private-recipient");
});
});
188 changes: 137 additions & 51 deletions apps/site/app/api/email/_lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { errorInfo, type WideEvent } from "../../../../lib/log";
import type { EmailTransport } from "./transport";
import type { BotCheck } from "./bot";
import { unavailableValidator, type EmailValidator } from "./validator";
import { Option } from "effect";
import {
decodeEmailValidationError,
EmailValidationError,
unavailableValidator,
type EmailValidator,
} from "./validator";
import { readSubmission, validEmail } from "./validation";

type Dependencies = {
checkBot: BotCheck;
validator?: EmailValidator;
transport: () => EmailTransport | null;
rateLimit: (headers: Headers) => number;
requested: () => void;
/** Names of missing or invalid settings, recorded when the transport is unavailable. */
configProblems?: () => readonly string[];
requested?: () => void;
};

function reply(status: number, message: string, headers?: Readonly<Record<string, string>>) {
Expand All @@ -18,76 +27,153 @@ function reply(status: number, message: string, headers?: Readonly<Record<string
);
}

export function createEmailHandler({
checkBot,
validator = unavailableValidator,
transport,
rateLimit,
requested,
}: Dependencies) {
return async (request: Request): Promise<Response> => {
try {
const verification = await checkBot(request);
/** Bot detection first; any detection failure fails closed. */
async function botGate(checkBot: BotCheck, request: Request, event: WideEvent) {
try {
const verification = await checkBot(request);

if (verification.isBot || verification.isVerifiedBot)
return reply(403, "This request was blocked. Please try again from your browser.");
} catch {
return reply(503, "Email is unavailable. Please try again later.");
}
if (verification.isVerifiedBot) event.bot = "verified_bot";
else event.bot = verification.isBot ? "bot" : "human";

if (verification.isBot || verification.isVerifiedBot)
return reply(403, "This request was blocked. Please try again from your browser.");
} catch (error) {
event.bot = "unavailable";
event.failure = "botid_unavailable";
event.error = errorInfo(error);

return reply(503, "Email is unavailable. Please try again later.");
}

return null;
}

function requestGate(request: Request, event: WideEvent) {
const origin = request.headers.get("origin");
const publicUrl = new URL(request.url);
const host = request.headers.get("host");

// Next can normalize the internal URL to localhost in development; Host is the browser's authority.
if (host) publicUrl.host = host;

const origin = request.headers.get("origin");
const publicUrl = new URL(request.url);
const host = request.headers.get("host");
if (origin && origin !== publicUrl.origin) {
event.rejection = "origin_mismatch";

// Next can normalize the internal URL to localhost in development; Host is the browser's authority.
if (host) publicUrl.host = host;
return reply(403, "Send this form from the Polaris site.");
}

if (origin && origin !== publicUrl.origin)
return reply(403, "Send this form from the Polaris site.");
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") {
event.rejection = "content_type";

if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") {
return reply(415, "Send the email form as JSON.");
return reply(415, "Send the email form as JSON.");
}

return null;
}

function validationFailure(failure: EmailValidationError, event: WideEvent) {
event.validation = failure.reason === "uncertain" ? "uncertain" : "unavailable";
event.failure = `validation_${failure.reason}`;

if (failure.cause_info) event.error = failure.cause_info;
}

async function deliver(
deps: Dependencies,
validator: EmailValidator,
email: string,
event: WideEvent
) {
try {
const accepted = await validator.validate(email);
event.validation = accepted ? "accepted" : "invalid";

if (!accepted) return reply(422, "Use another email address.");
} catch (error) {
validationFailure(
Option.getOrElse(
decodeEmailValidationError(error),
() => new EmailValidationError("provider", errorInfo(error))
),
event
);

return reply(503, "The email could not be sent. Please try again later.");
}

const sender = deps.transport();

if (!sender) {
event.failure = "email_unconfigured";
event.missing_config = deps.configProblems?.() ?? [];

return reply(503, "Email is not available yet. Please try again later.");
}

try {
await sender.send(email);
} catch (error) {
event.failure = "send_failed";
event.error = errorInfo(error);

return reply(503, "The email could not be sent. Please try again later.");
}

event.delivery = sender.preview ? "preview" : "sent";

return Response.json(
{},
{
headers: {
"Cache-Control": "no-store",
"X-Polaris-Email-Preview": sender.preview ? "1" : "0",
},
}
);
}

export function createEmailHandler(deps: Dependencies) {
const validator = deps.validator ?? unavailableValidator;

return async (request: Request, event: WideEvent = {}): Promise<Response> => {
const blocked = (await botGate(deps.checkBot, request, event)) ?? requestGate(request, event);

if (blocked) return blocked;

let submission;

try {
submission = await readSubmission(request);
} catch {
event.rejection = "unreadable_body";

return reply(400, "Enter a valid email address.");
}

if (submission.website !== "") return reply(200, "Check your inbox for the Mac download.");
if (submission.website !== "") {
event.rejection = "honeypot";

if (!validEmail(submission.email)) return reply(400, "Enter a valid email address.");
return reply(200, "Check your inbox for the Mac download.");
}

const retryAfter = rateLimit(request.headers);
if (!validEmail(submission.email)) {
event.rejection = "invalid_syntax";

return reply(400, "Enter a valid email address.");
}

const retryAfter = deps.rateLimit(request.headers);

if (retryAfter) {
event.rejection = "rate_limited";

if (retryAfter)
return reply(429, "Too many requests. Try again in 15 minutes.", {
"Retry-After": String(retryAfter),
});
requested();

try {
if (!(await validator.validate(submission.email)))
return reply(422, "Use another email address.");
const sender = transport();

if (!sender) return reply(503, "Email is not available yet. Please try again later.");
await sender.send(submission.email);

return Response.json(
{},
{
headers: {
"Cache-Control": "no-store",
"X-Polaris-Email-Preview": sender.preview ? "1" : "0",
},
}
);
} catch {
return reply(503, "The email could not be sent. Please try again later.");
}

deps.requested?.();

return deliver(deps, validator, submission.email, event);
};
}
Loading
Loading