Transactional Email
Baseleg sends transactional email via Resend. All email logic lives in apps/web/src/lib/email.ts. Auth-triggered emails are wired through Better Auth hooks in apps/web/src/lib/auth.ts.
Configuration
| Variable | Required | Description |
|---|---|---|
RESEND_API_KEY | Yes (for email) | Resend API key. If absent, all email calls are silently skipped. |
RESEND_FROM_EMAIL | No | From address. Defaults to onboarding@resend.dev (Resend shared domain). Set to a verified sender for production. |
Both variables are set as Cloudflare Pages secrets and documented in apps/web/.dev.vars.example.
Email inventory
| Function | Trigger | Subject |
|---|---|---|
sendInvitationEmail | Admin creates a new member | ”You’ve been invited to Baseleg” |
sendPasswordResetRequestEmail | Member submits /forgot-password | ”Reset your Baseleg password” |
sendPasswordChangedEmail | Member successfully resets password | ”Your Baseleg password has been changed” |
sendBookingConfirmationEmail | Staff creates a booking | ”Booking confirmed — {registration} on {date}” |
All emails use the same brandedEmail HTML template (dark navy header with logo, white card body, warm off-white background).
Delivery guarantees
Emails are fire-and-forget from the perspective of the domain: every call site wraps the send in .catch(() => {}). A delivery failure never blocks the primary action (member creation, booking creation, password reset). This follows the domain rule in the Notifications context that failed notifications must not block originating events.
Workflow: Member invitation
When a staff member creates a new member account, the system generates a random placeholder password, creates the auth user, then immediately issues a password-reset token with mode=invite in the redirect URL. Better Auth’s sendResetPassword hook detects the invite mode and sends the invitation email instead of the generic reset email. The member lands on the same /reset-password page to set their own password.
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Admin</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Fills in member name, email, roles — no password field</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">POST /members/new</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Server</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Creates Member + auth user with random placeholder password</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">registerMember() + signUpEmail()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Server</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Issues a password-reset token with <code style="font-size:0.7rem">mode=invite</code> in redirectTo</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">requestPasswordReset()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Resend</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Delivers invitation email with "Set up your account" CTA</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">sendInvitationEmail()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Member</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Clicks link → <code style="font-size:0.7rem">/reset-password?mode=invite&token=…</code> → sets password → signed in</div>
</div>
sendResetPassword hook checks whether the token URL contains mode%3Dinvite (URL-encoded). If present → invitation email. If absent → password reset email. The placeholder password is never revealed.
Workflow: Password reset
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Member</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Submits email on <code style="font-size:0.7rem">/forgot-password</code></div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">POST /api/auth/request-password-reset</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Resend</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Delivers password reset email (always shown as success to prevent enumeration)</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">sendPasswordResetRequestEmail()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Member</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Clicks link → <code style="font-size:0.7rem">/reset-password?token=…</code> → sets new password</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,var(--docs-secondary) 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:var(--docs-secondary);margin-bottom:4px">Resend</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Delivers password-changed security notification</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">sendPasswordChangedEmail() via onPasswordReset hook</div>
</div>
Workflow: Booking confirmation
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,#22c55e 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:#15803d;margin-bottom:4px">Staff / Admin</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Creates booking on <code style="font-size:0.7rem">/schedule/new</code> — selects member, aircraft, time</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">POST /schedule/new</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,#22c55e 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:#15803d;margin-bottom:4px">Server</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Validates rules (member active, aircraft available, no conflicts), persists booking</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">createBooking()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,#22c55e 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:#15803d;margin-bottom:4px">Resend</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Delivers confirmation to member — aircraft, date/time, duration, optional instructor</div>
<div style="margin-top:6px;font-size:0.7rem;font-family:monospace;color:color-mix(in srgb,var(--docs-primary) 55%,transparent)">sendBookingConfirmationEmail()</div>
</div>
<div style="display:flex;align-items:center;padding:0 8px;color:color-mix(in srgb,var(--docs-primary) 35%,transparent);font-size:1.1rem">→</div>
<div style="flex:1;border:1px solid var(--docs-border);border-radius:8px;padding:12px 14px;background:color-mix(in srgb,#22c55e 6%,#ffffff)">
<div style="font-weight:700;font-size:0.78rem;color:#15803d;margin-bottom:4px">Member</div>
<div style="font-size:0.78rem;color:var(--docs-primary);line-height:1.5">Receives email with booking summary and "View booking" link</div>
</div>
Adding a new email
- Add a
sendXxxEmail(env, opts)function toapps/web/src/lib/email.tsusing thebrandedEmailhelper. - Call it at the appropriate point in an Astro route handler. Wrap in
.catch(() => {})— email must never break the primary action. - If the email needs
RESEND_API_KEYorRESEND_FROM_EMAIL, they are already in scope via theenvargument passed fromAstro.locals.runtime.env. - Update the email inventory table above and any relevant bounded-context docs.