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), ); };