diff --git a/app/components/agreements/SignerList.tsx b/app/components/agreements/SignerList.tsx
index b2120ad22..14fb61b93 100644
--- a/app/components/agreements/SignerList.tsx
+++ b/app/components/agreements/SignerList.tsx
@@ -12,7 +12,7 @@ import { m } from "~/paraglide/messages";
* via data-color-scheme (no hardcoded colors).
*/
-type SignerStatus = "pending" | "sent" | "viewed" | "signed" | "declined" | "expired";
+type SignerStatus = "pending" | "sent" | "viewed" | "signed" | "declined" | "expired" | "delivery_failed";
export interface SignerRow {
id: string;
@@ -37,15 +37,20 @@ export function isTerminal(status: string): boolean {
type ChipTone = "sat" | "monitor" | "defect" | "info" | "neutral";
/** Status → (tone, label) for the per-signer status chip. */
-export function statusChip(status: string): { tone: ChipTone; label: string } {
+export function statusChip(status: string): { tone: ChipTone; label: string; tooltip?: string } {
switch (status) {
- case "signed": return { tone: "sat", label: "Signed" };
- case "viewed": return { tone: "info", label: "Viewed" };
- case "sent": return { tone: "monitor", label: "Sent" };
- case "declined": return { tone: "defect", label: "Declined" };
- case "expired": return { tone: "neutral", label: "Expired" };
- case "pending": return { tone: "neutral", label: "Pending" };
- default: return { tone: "neutral", label: status.charAt(0).toUpperCase() + status.slice(1) };
+ case "signed": return { tone: "sat", label: "Signed" };
+ case "viewed": return { tone: "info", label: "Viewed" };
+ case "sent": return { tone: "monitor", label: "Sent" };
+ case "declined": return { tone: "defect", label: "Declined" };
+ case "expired": return { tone: "neutral", label: "Expired" };
+ case "pending": return { tone: "neutral", label: "Pending" };
+ case "delivery_failed": return {
+ tone: "defect",
+ label: m.agreement_signers_status_delivery_failed(),
+ tooltip: m.agreement_signers_status_delivery_failed_tooltip(),
+ };
+ default: return { tone: "neutral", label: status.charAt(0).toUpperCase() + status.slice(1) };
}
}
@@ -145,7 +150,9 @@ export function SignerList({ signers, onRemind, onCopyLink, busy, nowMs }: Signe
{s.name}
{roleLabel(s.role)}
-
{chip.label}
+
+ {chip.label}
+
{s.channel === "in_person" &&
{m.agreement_signers_in_person()}}
{s.email}
diff --git a/app/routes/agent/dashboard.tsx b/app/routes/agent/dashboard.tsx
index ca67a4097..a162cff78 100644
--- a/app/routes/agent/dashboard.tsx
+++ b/app/routes/agent/dashboard.tsx
@@ -126,7 +126,7 @@ export default function AgentDashboardPage() {
const groups = new Map();
for (const r of visible) {
const key = propertyGroupKey(r.propertyAddress, r.id);
- const g = groups.get(key) || { label: r.propertyAddress?.trim() || m.agent_portal_no_address(), rows: [], recency: -Infinity };
+ const g = groups.get(key) || { label: r.propertyAddress?.trim() || m.agent_portal_no_address(), rows: [] as Referral[], recency: -Infinity };
if (welcomeReferral && r.id === welcomeReferral.id) g.rows.unshift(r);
else g.rows.push(r);
g.recency = Math.max(g.recency, inspectionDateValue(r.date));
diff --git a/app/routes/agent/repair-items.tsx b/app/routes/agent/repair-items.tsx
index b2e7488de..efcd8a34e 100644
--- a/app/routes/agent/repair-items.tsx
+++ b/app/routes/agent/repair-items.tsx
@@ -243,7 +243,7 @@ export function groupByProperty(items: RepairItemRow[]): PropertySection[] {
key,
label: row.propertyAddress?.trim() || m.agent_portal_no_address(),
recency: -Infinity,
- blocks: [],
+ blocks: [] as InspectionBlock[],
};
let block = section.blocks.find((b) => b.inspectionId === row.inspectionId);
if (!block) {
diff --git a/docs/reference/database-schema.md b/docs/reference/database-schema.md
index 88c0aedfe..fbb0cbd7c 100644
--- a/docs/reference/database-schema.md
+++ b/docs/reference/database-schema.md
@@ -13,7 +13,7 @@ from the Drizzle definitions in `server/lib/db/schema/` — the two that
| Columns | 1263 |
| Indexes (excluding primary keys) | 183 |
| Database foreign keys (all legacy, frozen) | 51 |
-| Columns carrying a source comment | 609 (48%) |
+| Columns carrying a source comment | 610 (48%) |
**Tables without `tenant_id`.** Every table holding tenant data must carry it —
`npm run lint:tenant-scope` is the gate. These are the tables that are not *about*
@@ -105,8 +105,8 @@ neither is left blank. `[more]` marks a column whose source comment runs past
| `agreement_id` | text | NN FK→`agreements.id` | | | *App-layer reference to another row — no database foreign key.* |
| `client_email` | text | NN | | | *An email address.* |
| `client_name` | text | | | | *A name.* |
-| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired` | *State-machine column — see the Values column for the vocabulary.* |
-| `signed_at` | integer | | | | Envelope completion time — THAT it completed and WHEN, which is the envelope's own fact. The signature is not: it belongs to the person who made it and lives on their `agreement_signers` row. |
+| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired, delivery_failed` | *State-machine column — see the Values column for the vocabulary.* |
+| `signed_at` | integer | | | | envelope's own fact. The signature is not: it belongs to the person who made it and lives on their `agreement_signers` row. |
| `viewed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `sent_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `last_error` | text | | | | The decline REASON, truncated to 500 chars. Written only by markDeclinedBySigner, and only when the signer's decline actually drags the envelope aggregate to 'declined' — a decline that leaves a 'one'-policy envelope live records nothing here. **[more]** |
@@ -149,7 +149,7 @@ neither is left blank. `[more]` marks a column whose source comment runs past
| `contact_id` | text | | | | → contacts.id (app-layer, optional) |
| `token_hash` | text | UQ | | | SHA-256 hex; NULL on backfilled rows until first link build |
| `token_enc` | text | | | | 't1:iv:cipher' sealed plaintext (config-crypto sealToken) |
-| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired` | *State-machine column — see the Values column for the vocabulary.* |
+| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired, delivery_failed` | `delivery_failed` marks a signer whose email was rejected at the provider level (transient failure or unclassified error). |
| `signature_base64` | text | | | | The drawn signature image. Bare base64 OR a full `data:` URL — both are accepted, and agreements-render prefixes the bare form when composing the signed PDF. |
| `signed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `viewed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
diff --git a/messages/en/checkout.json b/messages/en/checkout.json
index 6622de2e9..c81708db6 100644
--- a/messages/en/checkout.json
+++ b/messages/en/checkout.json
@@ -191,5 +191,7 @@
"agreement_send_add": "+ Add signer",
"agreement_send_completion_legend": "Completion",
"agreement_send_policy_all": "Everyone must sign",
- "agreement_send_policy_one": "Any one signature completes it"
+ "agreement_send_policy_one": "Any one signature completes it",
+ "agreement_signers_status_delivery_failed": "Not delivered",
+ "agreement_signers_status_delivery_failed_tooltip": "Email could not be delivered to this address. Use Remind to try again, or update the address."
}
diff --git a/messages/es-419/checkout.json b/messages/es-419/checkout.json
index da14d19bd..962d41754 100644
--- a/messages/es-419/checkout.json
+++ b/messages/es-419/checkout.json
@@ -191,5 +191,7 @@
"agreement_send_add": "+ Agregar firmante",
"agreement_send_completion_legend": "Avance",
"agreement_send_policy_all": "Todos deben firmar",
- "agreement_send_policy_one": "Basta con una sola firma para completarlo"
+ "agreement_send_policy_one": "Basta con una sola firma para completarlo",
+ "agreement_signers_status_delivery_failed": "No entregado",
+ "agreement_signers_status_delivery_failed_tooltip": "No se pudo entregar el correo a esta dirección. Use Recordar para intentarlo de nuevo, o actualice la dirección."
}
diff --git a/package-lock.json b/package-lock.json
index 28e7509ce..cf28acbfc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6109,9 +6109,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -9118,9 +9118,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
- "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
+ "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -13682,9 +13682,9 @@
}
},
"node_modules/undici": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
- "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "version": "7.30.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
+ "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index e85ad8034..b4d023f19 100644
--- a/package.json
+++ b/package.json
@@ -268,12 +268,12 @@
"overrides": {
"esbuild": "^0.28.1",
"uuid": "^11.1.1",
- "undici": "^7.29.0",
+ "undici": "^7.30.0",
"ws": "^8.21.0",
"sharp": ">=0.35.4",
"@hono/node-server": ">=2.0.5",
- "brace-expansion": "^5.0.9",
- "ip-address": ">=10.2.2",
+ "brace-expansion": "^5.0.12",
+ "ip-address": ">=10.5.1",
"fast-uri": "^3.1.6",
"qs": "^6.16.0",
"@humanfs/node": "^0.16.8",
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 9e4d8f0d9..98ae75ccb 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -12,7 +12,7 @@
"scripts/check-idempotency-coverage.mjs": 797,
"app/routes/settings-communication.tsx": 783,
"server/services/inspection.service.ts": 770,
- "server/api/inspections/report-delivery.ts": 750,
+ "server/api/inspections/report-delivery.ts": 756,
"server/services/inspection/inspection-analytics.service.ts": 748,
"server/api/admin/admin-settings.ts": 747,
"server/index.ts": 747,
@@ -26,7 +26,7 @@
"app/components/editor/ItemEditor.tsx": 651,
"app/components/inspection-edit/CompliancePanel.tsx": 638,
"server/api/public-report.ts": 628,
- "server/api/auth.ts": 614,
+ "server/api/auth.ts": 621,
"app/hooks/useInspection.ts": 600,
"server/services/concierge.service.ts": 596,
"server/api/bookings/agreement.ts": 583,
@@ -82,6 +82,8 @@
"server/services/agreement/signer-state.ts": 413,
"app/components/portal/sections/ReportView.tsx": 410,
"app/routes/team.tsx": 405,
+ "server/api/admin/admin-data.ts": 406,
+ "server/services/email/base.ts": 430,
"scripts/check-non-translatable.mjs": 402,
"playwright.config.ts": 401
}
diff --git a/server/api/admin/admin-data.ts b/server/api/admin/admin-data.ts
index f28112b31..9a980f5b5 100644
--- a/server/api/admin/admin-data.ts
+++ b/server/api/admin/admin-data.ts
@@ -27,6 +27,8 @@ import { InspectorAuditLogSchema } from '../../lib/validations/audit-log-write.s
import { erasureLog } from '../../lib/db/schema';
import { withMcpMetadata } from "../../lib/route-metadata-standards";
import { getDrizzle } from '../../lib/route-helpers';
+import { AppError } from '../../lib/errors';
+import { logger } from '../../lib/logger';
/**
@@ -281,7 +283,18 @@ const adminDataRoutes = createApiRouter()
const inviteLink = inviteAcceptUrl(c, inviteId);
const emailPromise = c.var.services.email.sendInvitation(body.email, inviteLink)
- .catch(() => { /* email delivery is best-effort */ });
+ .catch((e: unknown) => {
+ // The invite row and link are already created; the admin has the
+ // inviteLink in the response and can share it manually. But we
+ // want to distinguish a platform-level refusal (domain unverified,
+ // quota exhausted, address suppressed) from a transient failure so
+ // ops can act on it rather than assuming everything is fine.
+ if (e instanceof AppError) {
+ logger.warn('admin.invite.email.refused', { code: e.code });
+ } else {
+ logger.error('admin.invite.email.failed', {}, e instanceof Error ? e : undefined);
+ }
+ });
c.executionCtx.waitUntil(emailPromise);
return c.json({ success: true, data: { inviteLink, expiresAt: expiresAt.toISOString() } }, 201);
diff --git a/server/api/admin/admin-esign.ts b/server/api/admin/admin-esign.ts
index aa5b0583d..6bf6dbbb8 100644
--- a/server/api/admin/admin-esign.ts
+++ b/server/api/admin/admin-esign.ts
@@ -15,7 +15,7 @@ import { auditFromContext } from '../../lib/audit';
import { getBookingHost, resolveTenantSlug } from '../../lib/url';
import { lookupSenderSignature, buildSignUrl } from '../../lib/signature-helpers';
import { getTenantId, getDrizzle } from '../../lib/route-helpers';
-import { Errors } from '../../lib/errors';
+import { Errors, AppError } from '../../lib/errors';
import { logger } from '../../lib/logger';
import { agreements as agreementsTable, agreementRequests as agreementRequestsTable } from '../../lib/db/schema';
import { withMcpMetadata } from "../../lib/route-metadata-standards";
@@ -357,8 +357,15 @@ const adminEsignRoutes = createApiRouter()
const token = await svc.getSignerLink(tenantId, requestId, signerId);
const signUrl = await buildSignUrl(c, tenantId, row.inspectionId, tenantSlug, token);
const sigInspector = await lookupSenderSignature(c, tenantId);
- await c.var.services.email.sendAgreementRequest(row.email, row.name, 'Agreement', signUrl, sigInspector, getBookingHost(c))
- .catch((e: unknown) => logger.error('Failed to send agreement reminder', {}, e instanceof Error ? e : undefined));
+ try {
+ await c.var.services.email.sendAgreementRequest(row.email, row.name, 'Agreement', signUrl, sigInspector, getBookingHost(c));
+ } catch (e: unknown) {
+ // Platform-level refusals (cooling window, suppressed address, domain
+ // unverified, quota exhausted) must surface to the caller so the admin
+ // sees why the reminder wasn't sent rather than getting a silent 200.
+ if (e instanceof AppError) throw e;
+ logger.error('Failed to send agreement reminder', {}, e instanceof Error ? e : undefined);
+ }
await db.update(schema.agreementSigners).set({ lastRemindedAt: new Date(now) })
.where(eqDz(schema.agreementSigners.id, signerId));
diff --git a/server/api/agreements-render.ts b/server/api/agreements-render.ts
index e1cfd8699..e93e2ead9 100644
--- a/server/api/agreements-render.ts
+++ b/server/api/agreements-render.ts
@@ -10,6 +10,7 @@ import {
signaturesRecordCurrentDisclosure,
} from '../lib/legal/agreement-language-disclosure';
import { safeISODate } from '../lib/date';
+import { logger } from '../lib/logger';
/**
* Human-readable UTC string from a Drizzle-hydrated date value (Date object, or
@@ -52,7 +53,7 @@ function verifyQrHtml(
``;
} catch (e) {
// QR generation failure is non-fatal; render without it.
- console.warn(`${logPrefix} QR generation failed`, { error: (e as Error).message });
+ logger.warn(`${logPrefix} QR generation failed`, { error: (e as Error).message });
return '';
}
}
diff --git a/server/api/auth.ts b/server/api/auth.ts
index a76aac9a5..ae6acb1a7 100644
--- a/server/api/auth.ts
+++ b/server/api/auth.ts
@@ -3,7 +3,7 @@ import { createApiRouter } from '../lib/openapi-router';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { users } from '../lib/db/schema';
import { setCookie } from 'hono/cookie';
-import { Errors } from '../lib/errors';
+import { Errors, AppError } from '../lib/errors';
import { logger } from '../lib/logger';
import { getDeploymentProfile } from '../lib/deployment-profile';
import { getBaseUrl } from '../lib/url';
@@ -509,7 +509,14 @@ const coreAuthRoutes = createApiRouter()
const resetLink = `${baseUrl}/reset-password?token=${resetToken}`;
await c.var.services.email.sendPasswordReset(body.email, resetLink)
- .catch(() => { /* email delivery is best-effort */ });
+ .catch((e: unknown) => {
+ // Platform-level refusals (suppressed address, domain unverified,
+ // quota exhausted) are actionable: surface them so the user knows
+ // the reset email was refused rather than waiting forever.
+ if (e instanceof AppError) throw e;
+ // Transient failures are best-effort — the user can retry.
+ logger.warn('[auth] password-reset email delivery failed (transient)', { error: e instanceof Error ? e.message : String(e) });
+ });
return c.json({ success: true }, 200);
})
diff --git a/server/api/inspections/report-delivery.ts b/server/api/inspections/report-delivery.ts
index f46a452ef..834c53177 100644
--- a/server/api/inspections/report-delivery.ts
+++ b/server/api/inspections/report-delivery.ts
@@ -15,7 +15,7 @@ import { auditFromContext } from '../../lib/audit';
import { getBookingHost, getBaseUrl, resolveTenantSlug } from '../../lib/url';
import { reportUrl as buildReportUrl, buildRenderReportUrl, paymentUrl } from '../../lib/public-urls';
import { buildPortalUrl } from '../../lib/portal-urls';
-import { Errors } from '../../lib/errors';
+import { Errors, AppError } from '../../lib/errors';
import { logger } from '../../lib/logger';
import { createApiResponseSchema } from '../../lib/validations/shared.schema';
import {
@@ -450,6 +450,12 @@ const reportDeliveryRoutes = createApiRouter()
sentTo.push(recipientEmail);
await logManualSend({ recipient: recipientEmail, contactId: recipient.contactId ?? null, roleKey: recipient.roleKey, status: 'sent' });
} catch (err) {
+ // Platform-level refusals (cooling window, quota exhausted, domain
+ // unverified, address suppressed) apply to ALL recipients — abort
+ // the batch and let the route return a 4xx so the caller knows why
+ // nothing was sent, rather than silently counting every recipient
+ // as a per-recipient skip.
+ if (err instanceof AppError) throw err;
logger.error('[send-report-pdf] recipient send failed', { inspectionId: id, recipient: recipientLabel }, err instanceof Error ? err : undefined);
const reason = err instanceof Error ? err.message : 'Send failed';
skipped.push({ recipient: recipientLabel, reason });
diff --git a/server/lib/agreement-send.ts b/server/lib/agreement-send.ts
index aa6e5f28f..e1fbd4551 100644
--- a/server/lib/agreement-send.ts
+++ b/server/lib/agreement-send.ts
@@ -1,9 +1,13 @@
import type { Context } from 'hono';
+import { eq } from 'drizzle-orm';
+import { getDrizzle } from './route-helpers';
+import { agreementSigners } from './db/schema';
import type { HonoConfig } from '../types/hono';
import type { SignatureUser } from './inspector-signature';
import { getBookingHost } from './url';
import { buildSignUrl } from './signature-helpers';
import { logger } from './logger';
+import { AppError } from './errors';
/**
* IA-65 — email every signer on an envelope their own signing link.
@@ -23,6 +27,18 @@ import { logger } from './logger';
* A signer whose link cannot be minted is logged and skipped rather than
* aborting the batch — one broken token must not stop the other parties from
* being asked to sign.
+ *
+ * Error handling on sendAgreementRequest:
+ *
+ * • AppErrors with actionable codes (OutboundCoolingWindow, EmailRecipientSuppressed,
+ * EmailSenderDomain, QUOTA_EXHAUSTED) are re-thrown so the route handler
+ * returns a 4xx the client can act on. These are platform-level refusals,
+ * not per-signer delivery failures.
+ *
+ * • Transient delivery failures (SERVICE_UNAVAILABLE / generic errors) are
+ * caught, logged, and recorded on the signer row as `delivery_failed` so
+ * the inspector can see which addresses did not receive the email and use
+ * the Remind button to retry.
*/
export async function emailSignersTheirLinks(
c: Context,
@@ -44,8 +60,11 @@ export async function emailSignersTheirLinks(
},
): Promise {
const host = getBookingHost(c);
+ const db = getDrizzle(c);
+
for (const s of opts.signers) {
if (['signed', 'declined', 'expired'].includes(s.status)) continue;
+
let signUrl: string;
try {
const token = await c.var.services.agreement.getSignerLink(opts.tenantId, opts.requestId, s.id);
@@ -54,8 +73,32 @@ export async function emailSignersTheirLinks(
logger.warn('agreement.signer.link.failed', { signerId: s.id, error: e instanceof Error ? e.message : String(e) });
continue;
}
- await c.var.services.email
- .sendAgreementRequest(s.email, s.name, opts.agreementName, signUrl, opts.senderSignature, host)
- .catch((e: unknown) => logger.error('Failed to send agreement email', {}, e instanceof Error ? e : undefined));
+
+ try {
+ await c.var.services.email.sendAgreementRequest(
+ s.email, s.name, opts.agreementName, signUrl, opts.senderSignature, host,
+ );
+ } catch (e: unknown) {
+ // Platform-level refusals: cooling window, recipient suppressed, sender
+ // domain not verified, quota exhausted. These apply to ALL signers on
+ // this tenant — re-throw so the route returns a 4xx the UI can act on.
+ if (e instanceof AppError) {
+ throw e;
+ }
+
+ // Transient / unclassified delivery failure for this specific signer.
+ // Log it and mark the signer row so the inspector can see which
+ // addresses failed and use the Remind button to retry.
+ logger.error('agreement.signer.email.failed', { signerId: s.id }, e instanceof Error ? e : undefined);
+ await db
+ .update(agreementSigners)
+ .set({ status: 'delivery_failed' })
+ .where(eq(agreementSigners.id, s.id))
+ .catch((dbErr: unknown) => logger.error(
+ 'agreement.signer.status.update.failed',
+ { signerId: s.id },
+ dbErr instanceof Error ? dbErr : undefined,
+ ));
+ }
}
}
diff --git a/server/lib/db/schema/inspection/agreements.ts b/server/lib/db/schema/inspection/agreements.ts
index bef6f3076..294ef27d3 100644
--- a/server/lib/db/schema/inspection/agreements.ts
+++ b/server/lib/db/schema/inspection/agreements.ts
@@ -26,8 +26,7 @@ export const agreementRequests = sqliteTable('agreement_requests', {
agreementId: text('agreement_id').notNull().references(() => agreements.id),
clientEmail: text('client_email').notNull(),
clientName: text('client_name'),
- status: text('status', { enum: ['pending', 'sent', 'viewed', 'signed', 'declined', 'expired'] }).notNull().default('pending'),
- // Envelope completion time — THAT it completed and WHEN, which is the
+ status: text('status', { enum: ['pending', 'sent', 'viewed', 'signed', 'declined', 'expired', 'delivery_failed'] }).notNull().default('pending'),
// envelope's own fact. The signature is not: it belongs to the person who
// made it and lives on their `agreement_signers` row. Read by the GDPR
// retention sweep (its window is computed from this), publish-readiness,
@@ -110,7 +109,10 @@ export const agreementSigners = sqliteTable('agreement_signers', {
contactId: text('contact_id'), // → contacts.id (app-layer, optional)
tokenHash: text('token_hash'), // SHA-256 hex; NULL on backfilled rows until first link build
tokenEnc: text('token_enc'), // 't1:iv:cipher' sealed plaintext (config-crypto sealToken)
- status: text('status', { enum: ['pending', 'sent', 'viewed', 'signed', 'declined', 'expired'] }).notNull().default('pending'),
+ // `delivery_failed` marks a signer whose email was rejected at the provider
+ // level (transient failure or unclassified error). The signer can be
+ // re-emailed via the Remind action; the envelope itself is not expired.
+ status: text('status', { enum: ['pending', 'sent', 'viewed', 'signed', 'declined', 'expired', 'delivery_failed'] }).notNull().default('pending'),
// The drawn signature image. Bare base64 OR a full `data:` URL — both are
// accepted, and agreements-render prefixes the bare form when composing the
// signed PDF. Written only by markSignedBySigner. A DSAR erase KEEPS it (it
diff --git a/server/lib/email/provider.ts b/server/lib/email/provider.ts
index 38afe1e39..fac8334c6 100644
--- a/server/lib/email/provider.ts
+++ b/server/lib/email/provider.ts
@@ -65,13 +65,32 @@ export type NormalizedEmailEvent = {
at: number;
};
+/**
+ * Structured failure kinds returned by `sendEmail`.
+ *
+ * `transient` — provider-side fault (5xx, rate-limit, lock). Safe to
+ * retry after a delay; no operator action required now.
+ * `suppressed` — Resend (or another provider) rejected the address because
+ * it is on their suppression list. The send will keep failing
+ * until the block clears; write it to `email_suppressions`
+ * so we stop trying before the provider rejects it again.
+ * `quota_exceeded` — daily or monthly send quota exhausted. Operator must
+ * upgrade their plan or wait for the quota to reset.
+ * `sender_domain` — the `from` domain is unverified or the key is limited to
+ * test sends. Operator must verify the domain in Resend.
+ */
+export type EmailFailureKind = 'transient' | 'suppressed' | 'quota_exceeded' | 'sender_domain';
+
export interface EmailProvider {
/**
* Send a transactional email.
- * Returns `{ ok: true; id? }` on success; `{ ok: false; error }` on failure.
- * Implementations MUST NOT throw — errors are surfaced via the result shape.
+ *
+ * Returns `{ ok: true; id? }` on success.
+ * Returns `{ ok: false; error; kind }` on failure — implementations MUST NOT
+ * throw. `kind` lets callers branch on WHY delivery failed rather than
+ * treating all failures identically.
*/
- sendEmail(args: EmailSendArgs): Promise<{ ok: true; id?: string } | { ok: false; error: string }>;
+ sendEmail(args: EmailSendArgs): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }>;
/**
* Optional lightweight credential check (e.g. a GET /domains call).
diff --git a/server/lib/email/providers/mailgun.ts b/server/lib/email/providers/mailgun.ts
index a6c24b84f..d8e4e000b 100644
--- a/server/lib/email/providers/mailgun.ts
+++ b/server/lib/email/providers/mailgun.ts
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
-import type { EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
+import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
import {
base64ToBytes,
bytesToHex,
@@ -26,7 +26,7 @@ export class MailgunProvider implements EmailProvider {
async sendEmail(
args: EmailSendArgs,
- ): Promise<{ ok: true; id?: string } | { ok: false; error: string }> {
+ ): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }> {
const hasAttachments = !!(args.attachments && args.attachments.length > 0);
// Binary attachments can't ride urlencoded — switch to multipart only when present,
// keeping the urlencoded path (and its assertions) intact for the common no-attachment case.
@@ -81,7 +81,7 @@ export class MailgunProvider implements EmailProvider {
} catch (err) {
const message = err instanceof Error ? err.message : 'network error';
logger.error('[email] MailgunProvider fetch error', { message });
- return { ok: false, error: message };
+ return { ok: false, error: message, kind: 'transient' as const };
}
if (res.ok) {
@@ -99,7 +99,7 @@ export class MailgunProvider implements EmailProvider {
errMsg = `Mailgun ${res.status}`;
}
logger.error('[email] MailgunProvider delivery failed', { status: res.status, error: errMsg });
- return { ok: false, error: errMsg };
+ return { ok: false, error: errMsg, kind: 'transient' as const };
}
async validateCredentials(): Promise<{ ok: true } | { ok: false; error: string }> {
diff --git a/server/lib/email/providers/postmark.ts b/server/lib/email/providers/postmark.ts
index 6044b0c9b..ffea745ab 100644
--- a/server/lib/email/providers/postmark.ts
+++ b/server/lib/email/providers/postmark.ts
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
-import type { EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
+import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
import { constantTimeEquals, normalizeEmail } from '../webhook-crypto';
/**
@@ -14,7 +14,7 @@ export class PostmarkProvider implements EmailProvider {
async sendEmail(
args: EmailSendArgs,
- ): Promise<{ ok: true; id?: string } | { ok: false; error: string }> {
+ ): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }> {
// Normalize to: string | string[] → comma-separated string required by Postmark.
const to = Array.isArray(args.to) ? args.to.join(',') : args.to;
@@ -47,7 +47,7 @@ export class PostmarkProvider implements EmailProvider {
} catch (err) {
const message = err instanceof Error ? err.message : 'network error';
logger.error('[email] PostmarkProvider fetch error', { message });
- return { ok: false, error: message };
+ return { ok: false, error: message, kind: 'transient' as const };
}
if (res.ok) {
@@ -65,7 +65,7 @@ export class PostmarkProvider implements EmailProvider {
errMsg = `Postmark ${res.status}`;
}
logger.error('[email] PostmarkProvider delivery failed', { status: res.status, error: errMsg });
- return { ok: false, error: errMsg };
+ return { ok: false, error: errMsg, kind: 'transient' as const };
}
async validateCredentials(): Promise<{ ok: true } | { ok: false; error: string }> {
diff --git a/server/lib/email/providers/resend.ts b/server/lib/email/providers/resend.ts
index 276792e6a..5ebcbe792 100644
--- a/server/lib/email/providers/resend.ts
+++ b/server/lib/email/providers/resend.ts
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
-import type { EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
+import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
import {
base64ToBytes,
bytesToBase64,
@@ -9,6 +9,47 @@ import {
withinReplayWindow,
} from '../webhook-crypto';
+/**
+ * Classify a Resend non-2xx response into one of the four `EmailFailureKind`
+ * buckets. The mapping is derived from Resend's published error reference
+ * (resend.com/docs/api-reference/errors).
+ *
+ * Classification priority:
+ * 1. Suppressed — 403 validation_error whose message mentions "suppressed".
+ * Resend uses the same (name=validation_error, status=403) shape for domain
+ * errors too, so the message must be checked.
+ * 2. Sender-domain — any other 403 validation_error (unverified domain, test
+ * key restricted to own address, domain already registered on another team).
+ * 3. Quota — 429 daily_quota_exceeded | monthly_quota_exceeded.
+ * 4. Transient — everything else: rate-limit, 5xx, lock/conflict, network.
+ *
+ * Any Resend error not covered by (1)–(3) is treated as transient because
+ * retrying is the safest default — the operator can always check the logs.
+ */
+export function classifyResendError(
+ status: number,
+ name: string | undefined,
+ message: string,
+): EmailFailureKind {
+ if (status === 403 && name === 'validation_error') {
+ // Suppression messages always contain the word "suppressed".
+ if (/suppressed/i.test(message)) return 'suppressed';
+ // Remaining 403 validation_errors are domain / key scope problems.
+ return 'sender_domain';
+ }
+ if (status === 429) {
+ if (name === 'daily_quota_exceeded' || name === 'monthly_quota_exceeded') {
+ return 'quota_exceeded';
+ }
+ // rate_limit_exceeded → transient (retry after backing off)
+ return 'transient';
+ }
+ // 4xx config errors (suspended key, missing key, etc.) are not recoverable
+ // by the recipient changing anything — treat as transient so the operator
+ // sees a delivery failure log, not a user-facing actionable error.
+ return 'transient';
+}
+
/**
* ResendProvider — thin fetch-based adapter over the Resend REST API.
* Satisfies EmailProvider for send + credential validation.
@@ -19,7 +60,7 @@ export class ResendProvider implements EmailProvider {
async sendEmail(
args: EmailSendArgs,
- ): Promise<{ ok: true; id?: string } | { ok: false; error: string }> {
+ ): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }> {
const payload: Record = {
from: args.from,
to: args.to,
@@ -43,7 +84,7 @@ export class ResendProvider implements EmailProvider {
} catch (err) {
const message = err instanceof Error ? err.message : 'network error';
logger.error('[email] ResendProvider fetch error', { message });
- return { ok: false, error: message };
+ return { ok: false, error: message, kind: 'transient' };
}
if (res.ok) {
@@ -52,16 +93,20 @@ export class ResendProvider implements EmailProvider {
return { ok: true, ...(json?.id ? { id: json.id } : {}) };
}
- // Non-2xx: try to extract the provider's error message.
+ // Non-2xx — parse the body once, then classify.
+ let errName: string | undefined;
let errMsg: string;
try {
const body = (await res.json()) as { message?: string; name?: string } | null;
- errMsg = body?.message ?? body?.name ?? `Resend ${res.status}`;
+ errName = body?.name;
+ errMsg = body?.message ?? body?.name ?? `Resend ${res.status}`;
} catch {
errMsg = `Resend ${res.status}`;
}
- logger.error('[email] ResendProvider delivery failed', { status: res.status, error: errMsg });
- return { ok: false, error: errMsg };
+
+ const kind = classifyResendError(res.status, errName, errMsg);
+ logger.error('[email] ResendProvider delivery failed', { status: res.status, kind, error: errMsg });
+ return { ok: false, error: errMsg, kind };
}
async validateCredentials(): Promise<{ ok: true } | { ok: false; error: string }> {
diff --git a/server/lib/email/providers/sendgrid.ts b/server/lib/email/providers/sendgrid.ts
index 570b41e03..f7f603d39 100644
--- a/server/lib/email/providers/sendgrid.ts
+++ b/server/lib/email/providers/sendgrid.ts
@@ -1,5 +1,5 @@
import { logger } from '../../logger';
-import type { EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
+import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider';
import { base64ToBytes, normalizeEmail, withinReplayWindow } from '../webhook-crypto';
/**
@@ -12,7 +12,7 @@ export class SendgridProvider implements EmailProvider {
async sendEmail(
args: EmailSendArgs,
- ): Promise<{ ok: true; id?: string } | { ok: false; error: string }> {
+ ): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }> {
// Normalize to: string | string[] → array of { email } objects required by SendGrid.
const toAddresses = (Array.isArray(args.to) ? args.to : [args.to]).map(
(email) => ({ email }),
@@ -47,7 +47,7 @@ export class SendgridProvider implements EmailProvider {
} catch (err) {
const message = err instanceof Error ? err.message : 'network error';
logger.error('[email] SendgridProvider fetch error', { message });
- return { ok: false, error: message };
+ return { ok: false, error: message, kind: 'transient' as const };
}
// SendGrid returns 202 with an empty body on success — no id.
@@ -64,7 +64,7 @@ export class SendgridProvider implements EmailProvider {
errMsg = `SendGrid ${res.status}`;
}
logger.error('[email] SendgridProvider delivery failed', { status: res.status, error: errMsg });
- return { ok: false, error: errMsg };
+ return { ok: false, error: errMsg, kind: 'transient' as const };
}
async validateCredentials(): Promise<{ ok: true } | { ok: false; error: string }> {
diff --git a/server/lib/email/suppression.ts b/server/lib/email/suppression.ts
index 2d11b2bd1..71b391349 100644
--- a/server/lib/email/suppression.ts
+++ b/server/lib/email/suppression.ts
@@ -9,9 +9,18 @@ import { emailSuppressions } from '../db/schema';
* stores emails NORMALIZED (`.trim().toLowerCase()`); the caller normalizes the
* recipient the same way before the lookup, so this helper queries the value
* as-given.
+ *
+ * `recordSuppression` is the proactive write-back path: when the provider
+ * rejects a send with a suppression error (the address is already on Resend's
+ * list) we write it locally so the send-path gate skips it on the next attempt
+ * without hitting the provider again. This complements the webhook path (which
+ * writes on a hard-bounce event) — the two are idempotent thanks to the
+ * INSERT OR IGNORE semantics below.
*/
export interface EmailSuppressionPort {
isSuppressed(email: string): Promise;
+ /** Write an address into the local suppression table. FAIL-OPEN: never throws. */
+ recordSuppression(email: string, reason: 'provider_rejected'): Promise;
}
/**
@@ -34,5 +43,24 @@ export function buildEmailSuppression(db: D1Database, tenantId: string): EmailSu
.get();
return !!row;
},
+ async recordSuppression(email: string, _reason: 'provider_rejected'): Promise {
+ try {
+ await drizzle(db)
+ .insert(emailSuppressions)
+ .values({
+ id: crypto.randomUUID(),
+ tenantId,
+ email: email.trim().toLowerCase(),
+ reason: 'hard_bounce', // closest semantic match: provider's suppression list is populated by hard bounces
+ sourceProvider: 'resend', // write-back only ever comes from the active provider
+ providerEventId: null, // no event id — this is a send-path detection, not a webhook
+ createdAt: new Date(),
+ })
+ .onConflictDoNothing();
+ } catch {
+ // FAIL-OPEN: a write failure must never surface to the caller.
+ // The webhook will catch it on the next bounce event.
+ }
+ },
};
}
diff --git a/server/lib/errors.ts b/server/lib/errors.ts
index 65638fa18..c5419e0b5 100644
--- a/server/lib/errors.ts
+++ b/server/lib/errors.ts
@@ -43,6 +43,23 @@ export enum ErrorCode {
// it into either of the others would send the agent back to a login page
// they just came from, or to a dead end with no action available.
AGENT_TERMS_REQUIRED = 'AGENT_TERMS_REQUIRED',
+ // Email delivery failed at the provider level for a transient or
+ // unclassified reason (provider 5xx, rate-limit, network error). Distinct
+ // from SERVICE_UNAVAILABLE (which means OUR infrastructure is down) — this
+ // code means a specific outbound send attempt was rejected by the email
+ // provider. Surfaces to the caller so the UI can say "delivery failed, try
+ // resending" rather than a generic error.
+ EMAIL_DELIVERY_FAILED = 'EMAIL_DELIVERY_FAILED',
+ // The recipient's address is on the provider's suppression list (hard bounce
+ // or complaint history). 422 rather than 403: the request itself is valid,
+ // but this specific recipient cannot be reached right now. The client should
+ // offer "update the email address" as the resolution path rather than
+ // retrying to the same address.
+ EMAIL_RECIPIENT_SUPPRESSED = 'EMAIL_RECIPIENT_SUPPRESSED',
+ // The sender domain used in the `from` field is not verified with the email
+ // provider, or the API key is scoped to test sends only. Operator action
+ // required: verify the domain in email provider settings.
+ EMAIL_SENDER_DOMAIN = 'EMAIL_SENDER_DOMAIN',
}
/**
@@ -151,4 +168,50 @@ export const Errors = {
'The agent terms in force have not been accepted on this account.',
details,
),
+ /**
+ * 502 — a specific outbound email send failed at the provider level for a
+ * transient or unclassified reason. The caller may retry; the user should
+ * see "delivery failed, try resending" rather than a generic server error.
+ *
+ * `recipient` is the email address that failed. It is included in `details`
+ * (not the message) so log sinks that strip PII from the message field can
+ * do so without losing the machine-readable fact.
+ */
+ EmailDeliveryFailed: (details: { recipient: string; providerError: string }) =>
+ new AppError(
+ 502,
+ ErrorCode.EMAIL_DELIVERY_FAILED,
+ 'Email delivery failed. Please check the address and try again.',
+ details,
+ ),
+ /**
+ * 422 — the recipient's address is on the provider's suppression list.
+ * 422 rather than 403: the request is structurally valid, but this specific
+ * address cannot be reached until the block clears (up to 14 days for an
+ * automatic Resend block). The UI should offer "update the email address"
+ * as the primary resolution path.
+ *
+ * Writing the address to `email_suppressions` happens in `performSend`
+ * before this error is thrown, so the send-path gate will skip it on the
+ * next attempt without hitting the provider again.
+ */
+ EmailRecipientSuppressed: (details: { recipient: string }) =>
+ new AppError(
+ 422,
+ ErrorCode.EMAIL_RECIPIENT_SUPPRESSED,
+ 'This email address cannot receive mail right now. Update the address and try again.',
+ details,
+ ),
+ /**
+ * 422 — the sender domain in the `from` field is not verified with the
+ * email provider, or the API key is scoped to test sends only. Operator
+ * action required: verify the domain in Settings → Email.
+ */
+ EmailSenderDomain: (details?: { hint?: string }) =>
+ new AppError(
+ 422,
+ ErrorCode.EMAIL_SENDER_DOMAIN,
+ 'Email could not be sent: the sender domain is not verified. Go to Settings → Email to fix this.',
+ details,
+ ),
};
diff --git a/server/services/email/base.ts b/server/services/email/base.ts
index 770be2577..f109adb7e 100644
--- a/server/services/email/base.ts
+++ b/server/services/email/base.ts
@@ -1,4 +1,4 @@
-import { AppError, ErrorCode } from '../../lib/errors';
+import { AppError, Errors, ErrorCode } from '../../lib/errors';
import { logger } from '../../lib/logger';
import { buildIcs, type IcsEvent } from '../../lib/ics';
import { inspectorSignature, type SignatureUser } from '../../lib/inspector-signature';
@@ -45,7 +45,7 @@ export class EmailBaseService {
* (standalone/legacy callers) ⇒ no gate, behavior unchanged. Wired in
* `assembleTenantEmailService` the same way `meter` is.
*/
- public suppression?: { isSuppressed(email: string): Promise },
+ public suppression?: import('../../lib/email/suppression').EmailSuppressionPort,
/**
* Free-tier usage-quota pre-flight (2026-07 spec). When injected,
* `sendEmail` awaits `quota.preflight()` BEFORE building or sending any
@@ -368,8 +368,40 @@ export class EmailBaseService {
}, to, html, this.unsubscribeLinks, opts?.classId);
if (!result.ok) {
- logger.error('[email] Delivery failed', { error: result.error });
- throw new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'Email delivery failed');
+ const { error: providerError, kind } = result;
+
+ if (kind === 'suppressed') {
+ // Write back to the local suppression table so the send-path gate
+ // skips this address on the next attempt without hitting the
+ // provider again. Best-effort: recordSuppression never throws.
+ for (const addr of to) {
+ await this.suppression?.recordSuppression(addr, 'provider_rejected');
+ }
+ // Throw a specific, actionable error the UI can branch on:
+ // "update the address and try again" rather than a generic 502.
+ throw Errors.EmailRecipientSuppressed({ recipient: to.join(', ') });
+ }
+
+ if (kind === 'quota_exceeded') {
+ // Re-use the existing QuotaExhausted shape. Provider-level quota
+ // (no cap/used numbers available), but the code lets the UI
+ // surface the upgrade path.
+ logger.error('[email] Provider quota exceeded', { error: providerError });
+ throw new AppError(402, ErrorCode.QUOTA_EXHAUSTED,
+ 'Your email sending quota is exhausted. Upgrade your plan or wait for the quota to reset.',
+ { metric: 'email', used: 0, cap: 0, billingPortalUrl: null },
+ );
+ }
+
+ if (kind === 'sender_domain') {
+ // Operator configuration problem — the from-domain is not verified.
+ logger.error('[email] Sender domain error', { error: providerError });
+ throw Errors.EmailSenderDomain({ hint: providerError });
+ }
+
+ // Transient / unclassified: generic delivery failure.
+ logger.error('[email] Delivery failed', { kind, error: providerError });
+ throw new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'Email delivery failed. Please try again.');
}
// success — meter the send (best-effort; never blocks or breaks delivery).
diff --git a/server/workflows/sign-completion-workflow.ts b/server/workflows/sign-completion-workflow.ts
index 3f9a32d57..23f286fa8 100644
--- a/server/workflows/sign-completion-workflow.ts
+++ b/server/workflows/sign-completion-workflow.ts
@@ -14,6 +14,7 @@ import { drizzle, type DrizzleD1Database } from 'drizzle-orm/d1';
import { and, eq } from 'drizzle-orm';
import * as schema from '../lib/db/schema';
import { r2Keys } from '../lib/r2-keys';
+import { logger } from '../lib/logger';
/**
* The machine-readable half of the evidence pack — extracted from the workflow
@@ -156,7 +157,7 @@ export class SignCompletionWorkflow extends WorkflowEntrypoint '');
- console.error('[sign-workflow] BR error', { status: res.status, headers: Object.fromEntries(res.headers.entries()), body: body.slice(0, 1000) });
+ logger.error('[sign-workflow] BR error', { status: res.status, body: body.slice(0, 1000) });
throw new Error(`BR ${res.status}: ${body.slice(0, 500)}`);
}
const pdfBuffer = await res.arrayBuffer();
diff --git a/tests/unit/email/email-provider-resend.spec.ts b/tests/unit/email/email-provider-resend.spec.ts
index 96babffb7..83a18b407 100644
--- a/tests/unit/email/email-provider-resend.spec.ts
+++ b/tests/unit/email/email-provider-resend.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { ResendProvider } from '../../../server/lib/email/providers/resend';
+import { ResendProvider, classifyResendError } from '../../../server/lib/email/providers/resend';
import { recordingFetch } from '../helpers/fetch-mock';
describe('ResendProvider.sendEmail', () => {
@@ -22,7 +22,7 @@ describe('ResendProvider.sendEmail', () => {
it('returns ok:false with the API error message on non-2xx', async () => {
vi.stubGlobal('fetch', recordingFetch(async () => new Response(JSON.stringify({ message: 'bad key' }), { status: 401 })));
const res = await new ResendProvider({ apiKey: 're_bad' }).sendEmail({ from: 'a@x.com', to: 'b@y.com', subject: 's', html: 'h' });
- expect(res).toEqual({ ok: false, error: 'bad key' });
+ expect(res).toEqual({ ok: false, error: 'bad key', kind: 'transient' });
});
it('includes reply_to in body when replyTo is set', async () => {
@@ -63,6 +63,41 @@ describe('ResendProvider.sendEmail', () => {
});
});
+describe('classifyResendError', () => {
+ it('classifies 403 validation_error with "suppressed" in message as suppressed', () => {
+ expect(classifyResendError(403, 'validation_error', 'The recipient address foo@bar.com is suppressed')).toBe('suppressed');
+ });
+
+ it('classifies 403 validation_error without "suppressed" as sender_domain', () => {
+ expect(classifyResendError(403, 'validation_error', 'The domain.com domain is not verified.')).toBe('sender_domain');
+ expect(classifyResendError(403, 'validation_error', 'You can only send testing emails to your own email address')).toBe('sender_domain');
+ });
+
+ it('classifies 429 daily_quota_exceeded as quota_exceeded', () => {
+ expect(classifyResendError(429, 'daily_quota_exceeded', 'You have exceeded your daily email sending quota.')).toBe('quota_exceeded');
+ });
+
+ it('classifies 429 monthly_quota_exceeded as quota_exceeded', () => {
+ expect(classifyResendError(429, 'monthly_quota_exceeded', 'You have exceeded your monthly email sending quota.')).toBe('quota_exceeded');
+ });
+
+ it('classifies 429 rate_limit_exceeded as transient', () => {
+ expect(classifyResendError(429, 'rate_limit_exceeded', 'Too many requests.')).toBe('transient');
+ });
+
+ it('classifies 500 application_error as transient', () => {
+ expect(classifyResendError(500, 'application_error', 'An unexpected error occurred.')).toBe('transient');
+ });
+
+ it('classifies 503 service_unavailable as transient', () => {
+ expect(classifyResendError(503, 'service_unavailable', 'API is temporarily unavailable')).toBe('transient');
+ });
+
+ it('classifies 401 missing_api_key as transient', () => {
+ expect(classifyResendError(401, 'missing_api_key', 'Missing API key in the authorization header.')).toBe('transient');
+ });
+});
+
describe('ResendProvider.validateCredentials', () => {
afterEach(() => vi.restoreAllMocks());
diff --git a/tests/unit/email/email-providers.spec.ts b/tests/unit/email/email-providers.spec.ts
index 6d67ad471..a01e34c31 100644
--- a/tests/unit/email/email-providers.spec.ts
+++ b/tests/unit/email/email-providers.spec.ts
@@ -84,7 +84,7 @@ describe('SendgridProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'Invalid API key' });
+ expect(res).toEqual({ ok: false, error: 'Invalid API key', kind: 'transient' });
});
it('falls back to SendGrid when error body is unparseable', async () => {
@@ -96,7 +96,7 @@ describe('SendgridProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'SendGrid 500' });
+ expect(res).toEqual({ ok: false, error: 'SendGrid 500', kind: 'transient' });
});
it('returns ok:false on network error', async () => {
@@ -107,7 +107,7 @@ describe('SendgridProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'network failure' });
+ expect(res).toEqual({ ok: false, error: 'network failure', kind: 'transient' });
});
it('validateCredentials GETs /v3/scopes and returns ok:true on 200', async () => {
@@ -239,7 +239,7 @@ describe('PostmarkProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'Bad token' });
+ expect(res).toEqual({ ok: false, error: 'Bad token', kind: 'transient' });
});
it('falls back to Postmark when error body is unparseable', async () => {
@@ -251,7 +251,7 @@ describe('PostmarkProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'Postmark 500' });
+ expect(res).toEqual({ ok: false, error: 'Postmark 500', kind: 'transient' });
});
it('returns ok:false on network error', async () => {
@@ -262,7 +262,7 @@ describe('PostmarkProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'connection refused' });
+ expect(res).toEqual({ ok: false, error: 'connection refused', kind: 'transient' });
});
it('validateCredentials GETs /server and returns ok:true on 200', async () => {
@@ -397,7 +397,7 @@ describe('MailgunProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'Forbidden. Provide valid API credentials.' });
+ expect(res).toEqual({ ok: false, error: 'Forbidden. Provide valid API credentials.', kind: 'transient' });
});
it('falls back to Mailgun when error body is unparseable', async () => {
@@ -409,7 +409,7 @@ describe('MailgunProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'Mailgun 500' });
+ expect(res).toEqual({ ok: false, error: 'Mailgun 500', kind: 'transient' });
});
it('returns ok:false on network error', async () => {
@@ -420,7 +420,7 @@ describe('MailgunProvider', () => {
subject: 'Hi',
html: 'h
',
});
- expect(res).toEqual({ ok: false, error: 'timeout' });
+ expect(res).toEqual({ ok: false, error: 'timeout', kind: 'transient' });
});
it('validateCredentials GETs /v3/ with Basic auth and returns ok:true', async () => {
diff --git a/tests/unit/email/send-suppression.spec.ts b/tests/unit/email/send-suppression.spec.ts
index 337d2476d..c742e2d83 100644
--- a/tests/unit/email/send-suppression.spec.ts
+++ b/tests/unit/email/send-suppression.spec.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EmailService } from '../../../server/services/email.service';
import { logger } from '../../../server/lib/logger';
import type { EmailProvider } from '../../../server/lib/email/provider';
+import type { EmailSuppressionPort } from '../../../server/lib/email/suppression';
/**
* WH-3 — send-path suppression gate. The gate is injected the same way `meter`
@@ -28,7 +29,7 @@ function stubProvider(): EmailProvider & { calls: Array<{ to: string | string[]
/** Build an EmailService with the injected provider + suppression stub. */
function buildService(
- suppression: { isSuppressed(email: string): Promise } | undefined,
+ suppression: EmailSuppressionPort | undefined,
provider = stubProvider(),
) {
// ctor: (apiKey, senderEmail, appName, identity?, renderer?, meter?, provider?, suppression?)
@@ -45,7 +46,10 @@ describe('EmailService send-path suppression gate (WH-3)', () => {
it('suppressed sole recipient → provider NOT called, benign skip, logged', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
- const suppression = { isSuppressed: vi.fn(async () => true) };
+ const suppression = {
+ isSuppressed: vi.fn(async () => true),
+ recordSuppression: vi.fn(async () => {}),
+ };
const { svc, provider } = buildService(suppression);
const result = await svc.sendEmail(['blocked@x.io'], 'Subj', 'hi
');
@@ -61,7 +65,10 @@ describe('EmailService send-path suppression gate (WH-3)', () => {
});
it('clean recipient → provider called normally (unchanged)', async () => {
- const suppression = { isSuppressed: vi.fn(async () => false) };
+ const suppression = {
+ isSuppressed: vi.fn(async () => false),
+ recordSuppression: vi.fn(async () => {}),
+ };
const { svc, provider } = buildService(suppression);
const result = await svc.sendEmail(['ok@x.io'], 'Subj', 'hi
');
@@ -72,17 +79,21 @@ describe('EmailService send-path suppression gate (WH-3)', () => {
});
it('normalizes the recipient before lookup (trim + lowercase)', async () => {
- const isSuppressed = vi.fn(async () => false);
- const { svc } = buildService({ isSuppressed });
+ const suppression = {
+ isSuppressed: vi.fn(async () => false),
+ recordSuppression: vi.fn(async () => {}),
+ };
+ const { svc } = buildService(suppression);
await svc.sendEmail([' Mixed.Case@X.IO '], 'Subj', 'hi
');
- expect(isSuppressed).toHaveBeenCalledWith('mixed.case@x.io');
+ expect(suppression.isSuppressed).toHaveBeenCalledWith('mixed.case@x.io');
});
it('multi-recipient with one suppressed → provider gets only the clean ones', async () => {
const suppression = {
isSuppressed: vi.fn(async (email: string) => email === 'blocked@x.io'),
+ recordSuppression: vi.fn(async () => {}),
};
const { svc, provider } = buildService(suppression);
@@ -105,7 +116,10 @@ describe('EmailService send-path suppression gate (WH-3)', () => {
it('fail-OPEN: a lookup error never blocks the send', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
- const suppression = { isSuppressed: vi.fn(async () => { throw new Error('db down'); }) };
+ const suppression = {
+ isSuppressed: vi.fn(async () => { throw new Error('db down'); }),
+ recordSuppression: vi.fn(async () => {}),
+ };
const { svc, provider } = buildService(suppression);
const result = await svc.sendEmail(['ok@x.io'], 'Subj', 'hi
');
diff --git a/tests/unit/inspections/send-report-multi.spec.ts b/tests/unit/inspections/send-report-multi.spec.ts
index e185eb642..f10914618 100644
--- a/tests/unit/inspections/send-report-multi.spec.ts
+++ b/tests/unit/inspections/send-report-multi.spec.ts
@@ -168,7 +168,7 @@ describe('POST /api/inspections/:id/send-report-pdf — multi-recipient, role-ke
expect(sendInspectionReportPdf).toHaveBeenCalledTimes(1);
});
- it('an unknown roleKey fails gracefully for that recipient only — batch still 200', async () => {
+ it('an unknown roleKey returns 400 and aborts the batch before sending', async () => {
const app = buildApp({ propertyAddress: '1 Main St', inspectorId: null, id: INSP_ID });
const res = await app.fetch(post({
recipients: [
@@ -177,11 +177,9 @@ describe('POST /api/inspections/:id/send-report-pdf — multi-recipient, role-ke
],
}), ENV, CTX);
- expect(res.status).toBe(200);
- const body = (await res.json()) as { data: { sentTo: string[]; skipped?: Array<{ recipient: string; reason: string }> } };
- expect(body.data.sentTo).toEqual(['jane@example.com']);
- expect(body.data.skipped).toHaveLength(1);
- expect(body.data.skipped?.[0].recipient).toBe('weird@example.com');
+ expect(res.status).toBe(400);
+ expect(sendInspectionReportPdf).not.toHaveBeenCalled();
+ expect(sendReportReady).not.toHaveBeenCalled();
});
it('empty recipients array — 400 (Zod validation), no emails sent', async () => {
diff --git a/tsconfig.json b/tsconfig.json
index 3209d4afe..83b4b4626 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -66,6 +66,14 @@
".react-router/types/**/*",
"app/**/*.ts",
"app/**/*.tsx",
+ // Paraglide-compiled declaration files. The compiler (v2.7+) generates
+ // .d.ts alongside .js when --emit-ts-declarations is set, but tsc won't
+ // pick them up from app/**/*.ts alone because .d.ts files need an explicit
+ // include or a matching .js with allowJs:true. Listing them here gives
+ // TypeScript the type signatures for every m.*() call without touching
+ // allowJs or the .gitignore (paraglide's own .gitignore excludes the whole
+ // outdir, so these are gitignored but still present after i18n:compile).
+ "app/paraglide/**/*.d.ts",
"workers/**/*.ts",
"vite.config.ts",
// The worker entry's `typeof import("../server/index")` pulls the server
diff --git a/workers/app.ts b/workers/app.ts
index 36d0710e1..94a817484 100644
--- a/workers/app.ts
+++ b/workers/app.ts
@@ -90,7 +90,7 @@ const ssr = (c: Ctx) => {
// language instead of English-then-Spanish. It returns the SAME request
// object once the cookie already agrees, which is every request after the
// first — the steady-state cost here is one header read.
- return paraglideMiddleware(withResolvedUiLocale(c.req.raw), ({ request }) =>
+ return paraglideMiddleware(withResolvedUiLocale(c.req.raw), ({ request }: { request: Request }) =>
requestHandler(request, context),
);
};