Email Notifications
Every outbound email in the app — password resets, one-time login codes, receipts, reminders, letters — goes through one small mail module (src/lib/email.ts) built on Gmail SMTP via nodemailer, or the Resend HTTPS API when RESEND_API_KEY is set (for hosts that block SMTP). This page catalogues what emails exist, when they’re sent, and how delivery is made resilient. See Membership-Applications and Membership-Letters for the two membership-specific emails, and Feedback-Widget for the (separate) public-feedback notification path.
Using it
Section titled “Using it”Most of these emails are triggered automatically by app actions — there is no “send email” button for most of them. The exceptions a staff member drives directly:
- Welcome Letter (
/welcome-letter) — see Membership-Letters. - Receipts — sent from a transaction’s detail page or in bulk from the accounting reports (single-receipt or batch send; batches skip any donor who hasn’t given email consent).
- Event reminders / payment reminders — event reminders are sent automatically (see below), with no manual trigger (see Event Reminders); payment reminders are manual only.
- Birthday / anniversary emails — an admin can send one manually from the relevant person/family record, or send a test copy to themselves from Settings to preview the current template.
An applicant or family member never needs to do anything to receive these emails beyond having a valid email address on file — delivery is entirely staff- or system-initiated.
How it works
Section titled “How it works”The send chokepoint
Section titled “The send chokepoint”sendEmail() and the type-specific send*Email() helpers (sendPasswordResetEmail, sendFamilyUpdateInviteEmail, sendWelcomeEmail, sendMembershipNotificationEmail, sendWelcomeLetterEmail, sendRegistrationConfirmationEmail, sendEventReminderEmail, sendPaymentReminderEmail, sendReceiptEmail, sendDgrReceiptEmail) all live in src/lib/email.ts and route every real send through a single internal function, sendMailWithRetry. No caller ever talks to the nodemailer transport directly. Retry behaviour depends on the provider.
With Gmail SMTP (the default), that chokepoint:
- Retries up to 3 times (short backoff) but only for errors that provably happened before the message reached the server (connection refused, DNS failure, TLS handshake failure, or a 4xx SMTP temporary-reject reply) — retrying after the server may have already accepted the message risks sending a duplicate.
- Treats certain socket errors (timeout, reset, broken pipe) as ambiguous — the message may or may not have gone out — and does not retry them; instead it fails the send and raises an “email delivery UNKNOWN” alert so a human checks before manually resending.
With Resend (RESEND_API_KEY set, src/lib/resendTransport.ts), each message carries one Idempotency-Key that is reused across its retries, so a retry can never deliver a duplicate. Because of that, Resend does retry timeouts, network errors, 429 (honouring Retry-After), 5xx replies, and 409 concurrent_idempotent_requests (the original request under that key is still being processed). Only a timeout/network error or an in-flight 409 leaves delivery uncertain: if one of those happened and the retries run out, the send is reported as “delivery UNKNOWN”. An exhausted run of 5xx/429 replies with no ambiguous attempt is reported as a plain failure.
With either provider, the chokepoint:
- On any exhausted/permanent failure, fires a best-effort alert email to the configured
ownerNotificationEmailaddress (swallowing its own errors so an alert failure can never mask or loop on the original one).
Each email is a React Email component (src/lib/emails/*.tsx) rendered to both HTML and a plain-text fallback.
What gets sent
Section titled “What gets sent”| Trigger | Notes | |
|---|---|---|
| One-time login code (OTP) | New/untrusted device sign-in | Sent from the auth layer directly (not through the send*Email catalogue above, but through the same retry-safe path). Locks out after repeated failures; a delivery failure rolls back the just-issued code so the user isn’t stuck behind a resend cooldown for a code that never arrived. |
| Password reset | “Forgot password” on the login page | Time-limited reset link. |
| Welcome / set-password | A staff account is created, or “resend welcome” is used | Includes a set-password link and a help link; body/subject are admin-customizable (see below). |
| Family self-update invite | Staff sends a family a secure link to review/update their own details | Admin-customizable body. |
| New membership application | A public application is submitted | Goes to the parish office (see Membership-Applications) with the completed application PDF attached; no PII in the notification body itself, just a link into the review inbox. |
| Welcome letter | Staff sends a new-member welcome letter | PDF attached; see Membership-Letters. |
| Event registration confirmation | A public event registration completes | Includes an .ics calendar attachment when applicable. |
| Event reminder | Automatic scheduled sweep only | Upcoming-event nudge to registrants. |
| Payment reminder | Manual staff send only | Nudges registrants with an unpaid balance; amount is deliberately kept out of the subject line (PII-in-logs concern). |
| Receipt | Manual single/batch send from Accounting | Admin-customizable intro/signoff; the transaction description is never put in the subject (it’s encrypted-at-rest PII, and subjects sit in plaintext in mail logs). |
| DGR (tax-deductible giving) receipt | Annual receipt generation | Attached PDF; admin-customizable subject/intro. |
| Birthday / anniversary blessing | Automatic daily sweep (opt-in per parish), or a manual per-person/bulk send | Skips anyone without email consent; the automatic sweep uses an idempotent claim so it can never double-send even if the cron fires twice. |
| Clearance compliance digest | Opt-in (CLEARANCE_DIGEST=true); then once a month (1st), to ADMIN and PASTOR users |
Names of people with expired, expiring (60 days), missing or unverified WWCC / Safe Ministry clearances, a link to /people/clearances, and a reminder to act on OCG barring alerts. Never includes WWC numbers or dates of birth. Skipped when nothing needs attention. A retry goes only to addresses not recorded as sent; ?force=1 on the manual route resends to everyone. |
| Owner delivery-failure alert | Any of the above exhausts its retries or hits an ambiguous socket error | Goes to ownerNotificationEmail; distinguishes a confirmed failure from an “unknown, don’t blindly resend” case. |
Scheduled sends
Section titled “Scheduled sends”Event reminders and birthday/anniversary emails are sent by the app’s in-process scheduler, on by default in production (IN_APP_CRON); the monthly clearance digest runs there too once CLEARANCE_DIGEST=true is set. On a host that sleeps or scales to zero, set IN_APP_CRON=false and call the bearer-authenticated /api/cron/* endpoints from an external scheduler instead — see Scheduled Jobs.
Customizable templates
Section titled “Customizable templates”Three of the emails (welcome, family-update invite, receipt) plus the birthday/anniversary blessings have their subject/intro/body/signoff text stored as editable EmailTemplate rows, changeable by an ADMIN under Settings → Email Templates / Settings → Birthday & Anniversary emails. Unknown {tokens} in admin-edited text are left as-is rather than silently blanked, so a typo is visible. These templates are boilerplate copy only — no PII is stored in them, and structural content (links, the receipt’s line-item table) is always sourced from real typed data, never from the admin’s free text. Each template tab keeps its unsaved edits while you switch between tabs, and the preview renders as soon as the tab loads; tax-receipt wording reset under Settings → Tax Receipt asks for confirmation first.
Configuration
Section titled “Configuration”| Env var | Purpose |
|---|---|
GMAIL_USER |
The Gmail address emails are sent from (also the church’s fallback contact address). Required at boot unless local dev sets DISABLE_OTP=true. |
GMAIL_APP_PASSWORD |
Gmail app password for that account (not the account’s normal login password) — see Gmail Setup. |
RESEND_API_KEY |
Optional. Switches all mail to the Resend HTTPS API (port 443) instead of Gmail SMTP — needed where the host blocks SMTP, e.g. Railway Free/Trial/Hobby. Makes the Gmail pair optional. See Gmail Setup. |
MAIL_FROM |
Required with RESEND_API_KEY: bare sender address on a Resend-verified domain. Replaces GMAIL_USER as the sender and fallback contact address. |
MEMBERSHIP_SECRETARY_EMAIL |
Fallback destination for the “new membership application” notification. Precedence: an ADMIN-set Settings value, then this env var, then GMAIL_USER. |
E2E_MOCK_EMAIL=true |
Test-only: swaps in an in-memory stub transport (logs the recipient only, never subject/body) so end-to-end tests don’t need real SMTP. Inert in production. |
ownerNotificationEmail and membershipSecretaryEmail are set under Settings → App Settings (ADMIN only), not as env vars, for a live deployment — the env vars above are fallbacks/first-boot defaults.