Baseleg Docs

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

VariableRequiredDescription
RESEND_API_KEYYes (for email)Resend API key. If absent, all email calls are silently skipped.
RESEND_FROM_EMAILNoFrom 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

FunctionTriggerSubject
sendInvitationEmailAdmin creates a new member”You’ve been invited to Baseleg”
sendPasswordResetRequestEmailMember submits /forgot-password”Reset your Baseleg password”
sendPasswordChangedEmailMember successfully resets password”Your Baseleg password has been changed”
sendBookingConfirmationEmailStaff 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>
Invite detection: Better Auth's 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

  1. Add a sendXxxEmail(env, opts) function to apps/web/src/lib/email.ts using the brandedEmail helper.
  2. Call it at the appropriate point in an Astro route handler. Wrap in .catch(() => {}) — email must never break the primary action.
  3. If the email needs RESEND_API_KEY or RESEND_FROM_EMAIL, they are already in scope via the env argument passed from Astro.locals.runtime.env.
  4. Update the email inventory table above and any relevant bounded-context docs.