Repository navigation
fix(email): structured error handling, delivery_failed status, and type fixes - #364
Merged
Merged
Conversation
Three layers of fixes for outbound email reliability: Layer 1 - Provider (resend.ts / provider.ts) - Add EmailFailureKind union: suppressed | quota_exceeded | sender_domain | transient - classifyResendError() maps all Resend error codes to one of the four kinds - sendEmail return type carries kind so callers can branch on why delivery failed Layer 2 - Base service (base.ts / suppression.ts / errors.ts) - New error codes: EMAIL_RECIPIENT_SUPPRESSED (422), EMAIL_SENDER_DOMAIN (422), EMAIL_DELIVERY_FAILED (502) with actionable user-facing messages - performSend branches on kind: suppressed writes to email_suppressions then throws 422; quota_exceeded throws 402; sender_domain throws 422; transient 502 - suppression.ts adds recordSuppression() so a provider-rejected address is written locally without waiting for the webhook Layer 3 - Call sites - agreement-send.ts: replace blanket .catch() with try/catch that rethrows AppError (platform refusals surface to UI) and writes delivery_failed status to the signer row for transient per-recipient failures - admin-esign.ts reminder: same pattern - rethrow AppError, log transient - auth.ts password-reset: rethrow AppError so user sees 4xx, not silence - report-delivery.ts batch: AppError aborts the whole batch instead of being counted as a per-recipient skip - admin-data.ts invite: distinguish AppError (warn + code) from transient (error) DB schema - Add delivery_failed to agreement_requests and agreement_signers status enums UI / i18n - SignerList: add delivery_failed chip (red, defect tone) with tooltip - messages/en + es-419/checkout.json: two new keys for the chip label/tooltip - delivery_failed signers keep Remind button active (non-terminal) Housekeeping - Replace all console.warn/error/info in server code with structured logger (agreements-render.ts, sign-completion-workflow.ts) - Add 8 new classifyResendError unit tests covering all error kind branches
- postmark/sendgrid/mailgun: add EmailFailureKind to import, update
sendEmail return type signature, add kind:'transient' to all ok:false
returns in sendEmail (validateCredentials returns unchanged)
- base.ts: replace inline suppression type with EmailSuppressionPort
so recordSuppression() is visible to TypeScript
- auth.ts: fix logger.warn() call — was passing 3 args, signature takes 2;
changed to logger.warn(msg, { error: e.message })
- workers/app.ts: add explicit Request type to paraglideMiddleware callback
destructuring to fix TS7031 implicit any
- agent/dashboard.tsx: add 'as Referral[]' to rows:[] to fix never[] inference
- agent/repair-items.tsx: add 'as InspectionBlock[]' to blocks:[] same fix
All non-paraglide type errors cleared (tsconfig.api.json and tsconfig.json
both exit 0).
…tsconfig paraglide-js v2.7+ emits .d.ts files alongside .js when --emit-ts-declarations is set (fixes issue #566). The outdir's own .gitignore excludes everything with '*', so the files are never committed but are present on disk after i18n:compile. tsc's app/**/*.ts glob doesn't pick up .d.ts files without a corresponding .js and allowJs:true. Adding an explicit 'app/paraglide/**/*.d.ts' include gives TypeScript the message function signatures without touching allowJs or the paraglide .gitignore. Result: all TS7016 'implicitly has any type' errors on ~/paraglide/messages imports are resolved. tsconfig.json type-check now exits 0 with zero errors.
…ate file size baseline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
Author
|
Local UI verification completed against PR head cfe07c2. The inspection detail page rendered the new delivery_failed signer state as a red “Not delivered” chip with its explanatory tooltip; Remind and Copy link remained available. I used a temporary local D1 status change and restored the fixture to viewed afterward; no reminder email was sent. SignerList web tests passed (9/9). All PR CI checks are passing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Classify email-provider failures and surface actionable errors instead of silently swallowing platform refusals.
Before: every
.catch()onsendEmaildiscarded the error — cooling windows, suppressed addresses, quota exhaustion, and unverified sender domains all returned 200 OK with no feedback.After: each failure kind produces a distinct outcome the caller can act on.
What changed
Provider layer (
resend.ts/provider.ts)EmailFailureKindunion:suppressed | quota_exceeded | sender_domain | transientclassifyResendError()maps all Resend API error codes to one of the four kindssendEmailreturn type carrieskindso callers can branch on why delivery failedBase service (
base.ts/suppression.ts)suppressed→ writes toemail_suppressions, throwsEMAIL_RECIPIENT_SUPPRESSED(422)quota_exceeded→ throws 402sender_domain→ throws 422transient→ throwsEMAIL_DELIVERY_FAILED(502), caller may retryCall sites
agreement-send.ts— rethrowAppError; writedelivery_failedto the signer row for transient per-recipient failuresadmin-esign.tsreminder — same patternauth.tspassword-reset — rethrowAppErrorso the user sees a 4xx, not silencereport-delivery.tsbatch —AppErroraborts the whole batch instead of being counted as a per-recipient skipadmin-data.tsinvite — distinguishAppError(warn + code) from transient (error)DB / UI
delivery_failedtoagreement_requestsandagreement_signersstatus enumsSignerList: red chip with tooltip; Remind button stays active (non-terminal)enandes-419Housekeeping
console.warn/errorin server code with structured loggerbrace-expansion>= 5.0.12,undici>= 7.30.0,ip-addressSSRFTest plan
npm run type-checkpassesnpm run lintpasses (including advisory gate — 0 high)npm run test:unitpasses — 8 newclassifyResendErrorunit tests addednpm run devRelated