_private/qwestly-docs/Features/emails/email-system-overview.md

Email System Overview

How emails are sent across the Qwestly ecosystem — covering four apps: candidate/, candidate-catalog/, qwestly-hire/, and api-python/.


Overview

What email platform do we use?

All outbound email goes through SendGrid — the third-party email service provider. Our apps never send email directly; they either call api-python (which holds the SendGrid API key) or, in the case of qwestly-hire, use the @sendgrid/mail SDK directly.

What kinds of emails do we send?

There are three email channels:

Transactional emails — sent on-demand in response to a user or admin action. These go through sendTemplateEmail() and are delivered immediately. Examples: warm outreach, LinkedIn profile suggestions, verification codes, welcome emails, networking intros. These are the bulk of our email volume.

Marketing journey emails — not sent directly by our code. Instead, a contact is added to a SendGrid marketing list, and SendGrid's journey automation delivers the email on its own schedule. These are used for reminder sequences where timing isn't critical. Examples: CP-waitlist-reminder (approved waitlist users who haven't created an account), CP-incomplete-card-1 (candidates who started signup but haven't finished their card).

In-code template emails — one special case where the HTML body is built programmatically rather than rendered from a SendGrid template. qwestly-card-email constructs a styled Qwestly Card email in TypeScript and calls sendEmail() (raw send) instead of sendTemplateEmail().

How do email templates work?

Each email is associated with a SendGrid dynamic template identified by a d-xxx ID. The template contains the HTML layout with Handlebars variables like {{first_name}} or {{cta_url}}. Our apps never manage the template HTML — it lives in SendGrid's dashboard. We just pass the template ID and the merge variables.

The mapping between internal template IDs (like CP-90-seconds-welcome) and SendGrid template IDs (like d-4f0004b57d844ebab98c3970979b4d3c) lives in a template registry in each app — a single source of truth for sender name, subject line, required variables, and category. Adding a new email type means: create it in SendGrid, then add one entry to the registry.

How does authentication work?

There are two patterns:

  1. candidate/ and candidate-catalog/ → api-python → SendGrid. These Node apps authenticate to api-python via a shared QWESTLY_SERVICE_API_KEY (sent as Authorization: Bearer). api-python then authenticates to SendGrid with the real SENDGRID_API_KEY. This means the Node apps never hold or leak a SendGrid key.

  2. qwestly-hire/ → SendGrid directly. It has its own SENDGRID_API_KEY env var and uses @sendgrid/mail to send. This is an older pattern — it does not route through api-python.

When does each app send emails?

  • candidate/ — all candidate-facing transactional emails: waitlist lifecycle (join, approve, decline, remind), work-email and LinkedIn verification, welcome emails, incomplete-card reminders, account claim invitations, peer networking, and admin-triggered LinkedIn profile suggestions.
  • candidate-catalog/ — prospect outreach from the catalog: warm outreach (single and bulk) and first-engagement emails with a Qwestly Card preview.
  • qwestly-hire/ — employer-facing emails: hiring manager notification emails with candidate executive summaries, and work-email verification for employer users.

How do recipients unsubscribe?

We use SendGrid ASM (Advanced Suppression Manager) to give recipients one-click unsubscribe from candidate-marketing emails. Currently, two specific emails have ASM hooked up:

  1. Warm outreach email (candidate-warm-invite) in candidate-catalog/ — the first email a prospect receives when an admin reaches out from the catalog. ASM group ID 29620 is baked into the central sendTemplateEmail payload.

  2. LinkedIn profile suggestion email (CP-linkedin-profile-suggestion) in candidate/ — sent by an admin with an AI-generated LinkedIn profile suggestion. Uses the same ASM group ID 29620, also baked into the central sendTemplateEmail.

Both apps wire the ASM group the same way — as a global block in their shared sendTemplateEmail function. Every email sent through that function carries:

"asm": {
  "group_id": 29620,
  "groups_to_display": [29620]
}

Because they share the same group ID, if a recipient unsubscribes from a warm outreach email, they are also unsubscribed from LinkedIn profile suggestion emails (and any future email using this group). Unsubscribe preferences are managed entirely in SendGrid; there is no per-app unsubscribe list.

To add ASM to a new email type, no per-template code change is needed — it's already inherited from sendTemplateEmail. If a new app or a new send path needs ASM, it must either route through the existing sendTemplateEmail or add the asm block manually before calling the api-python /api/mail/send-template endpoint.

Note: qwestly-hire/ emails do not set an ASM group, so they use SendGrid's default unsubscribe behavior (list-unsubscribe header, not group-level suppression).

How is the system tested?

Each app has a sandbox mode that prevents real delivery. When enabled, the email payload is logged to the console (or, in qwestly-hire's case, sent to SendGrid with sandbox_mode: true so SendGrid validates it without delivering). This is controlled by the SENDGRID_SANDBOX (or EMAIL_SANDBOX) env var. In sandbox mode, the full payload is printed — recipient, template, sender, merge data — so developers can inspect what would be sent.

Which data is stored?

Emails are logged in MongoDB's sent_emails collection in two places:

  • candidate-catalog logs warm outreach sends (recipient, template, timestamp, sender, categories).
  • qwestly-hire logs hiring manager notifications (job, recipient, template, candidate summaries, categories).

The candidate app does not log sent emails to MongoDB for most transactional sends — it relies on the caller or the front-end to handle confirmation.


Architecture

┌──────────────────┐     QWESTLY_SERVICE_API_KEY     ┌──────────────┐     SENDGRID_API_KEY     ┌──────────┐
│  candidate/       │ ──────────────────────────────→ │  api-python/ │ ─────────────────────→ │ SendGrid │
│  candidate-catalog/│                                │  (FastAPI)   │                        │   API    │
│                   │                                │              │                        └──────────┘
│  qwestly-hire/    │ ──── (direct @sendgrid/mail) ──→              │
└──────────────────┘                                └──────────────┘
  • candidate/ and candidate-catalog/ never hold a SendGrid API key. They call api-python endpoints over HTTP, authenticated via a shared QWESTLY_SERVICE_API_KEY.
  • qwestly-hire/ uses @sendgrid/mail directly — it has its own SENDGRID_API_KEY env var and calls the SendGrid API directly.
  • api-python/ is the only service that holds the production SENDGRID_API_KEY. It exposes REST endpoints that the other apps call.

1. api-python — The SendGrid Gateway

App root: api-python/

Key files

File Role
api/routes/internal/sendgrid.py Route definitions + Pydantic request/response schemas
lib/integrations/sendgrid_outbound.py Low-level SendGrid v3 HTTP calls via httpx
lib/auth/service_gateway.py require_qwestly_service dependency

Endpoints

POST /api/mail/send-template

Sends a SendGrid dynamic template.

Auth: QWESTLY_SERVICE_API_KEY via Authorization: Bearer, Authorization: ApiKey, or X-API-Key. 401 on mismatch, 503 if key unconfigured.

Request body:

Field Type Required Notes
email str? * Single recipient. Mutually exclusive with recipients. May be "Name <addr>" format.
recipients list[str]? * Multiple recipients, one SendGrid send, shared body.
sendgrid_template_id str yes e.g. d-e80eca2fd6994983a6d40fc97c6a2a81
sender_name str no Default "Qwestly"
sender_email str? no Falls back to SENDGRID_FROM_EMAIL env var
reply_to_email str? no Sets reply_to on SendGrid payload
dynamic_template_data dict[str, str] no Handlebars variables for the template
categories list[str]? no SendGrid categories
sandbox bool? no Overrides env sandbox; if null, uses SENDGRID_SANDBOX / EMAIL_SANDBOX env
user_id str? no Passed as custom_args.user_id in the personalization block
asm AsmConfig? no Unsubscribe group config (see below)

AsmConfig schema:

class AsmConfig(BaseModel):
    group_id: int           # e.g. 29620 for Candidate outreach
    groups_to_display: list[int] = []  # defaults to [group_id]

SendGrid API callsend_dynamic_template() in sendgrid_outbound.py:

POST https://api.sendgrid.com/v3/mail/send
Authorization: Bearer {SENDGRID_API_KEY}

{
  "personalizations": [{
    "to": [{"email": "addr", "name": "Name"}],
    "dynamic_template_data": {...},
    "custom_args": {"user_id": "..."}
  }],
  "from": {"email": "sender@example.com", "name": "Sender Name"},
  "template_id": "d-...",
  "mail_settings": {"sandbox_mode": {"enable": true/false}},
  "asm": {"group_id": 29620, "groups_to_display": [29620]},  # if provided
  "categories": [...],   # if provided
  "reply_to": {"email": "..."}  # if provided
}

Accepts 200 or 202 from SendGrid. 502 on SendGrid error.

POST /api/mail/send-raw

Sends a raw (non-template) email. Same auth, same SendGrid endpoint but uses content array instead of template_id.

Field Type Required
email str yes
mail_from / from_email / from str? no (falls back to SENDGRID_FROM_EMAIL)
subject str yes
text str yes
html str yes
categories list[str]? no
user_id str? no

GET /api/mail/activity

Queries sent emails via SendGrid's Email Activity API.

Param Type Required Default
template_id str yes
recipient_email str yes
limit int no 5 (max 100)

Requires the SendGrid Email Activity API paid add-on. Queries GET https://api.sendgrid.com/v3/messages with filter template_id="{id}" AND to_email="{email}".

PUT /api/marketing/contacts

Upserts a contact to a SendGrid marketing list.

Field Type Required
email str yes
list_id str? no
custom_fields dict[str, str]? no
first_name str? no
last_name str? no

Custom field names are mapped server-side to SendGrid custom field IDs (e.g. recipient_first_namee16_T).

POST /api/marketing/remove-from-list

Removes a contact from a marketing list.

Field Type Required
email str yes
list_id str yes

2. candidate/ App

App root: candidate/

Gateway

File: src/services/sendgrid.ts

The sendTemplateEmail function is the single send gateway. Full signature:

async function sendTemplateEmail<T extends Record<string, string>>(
  templateId: EmailTemplateId,
  {
    email: string | string[],
    senderName?: string,              // default "Qwestly"
    senderEmail?: string,             // default template.defaultSenderEmail → SENDGRID_FROM_EMAIL
    dynamicTemplateData?: T,          // default {}
    replyToEmail?: string,
    categories?: string[],
    sandbox?: boolean,
    userId?: string,
    throwOnGatewayFailure?: boolean,  // default false
  }
): Promise<void>

Flow:

  1. Looks up template metadata from the registry (getTemplate). Fails if not found.
  2. Normalizes recipients. Extracts name from "Name <email>" format.
  3. Resolves sender via cascade: senderName || template.defaultSenderName || "Qwestly".
  4. Checks sandbox: if sandbox ?? isEmailSandboxEnabled() is true, logs payload to console and skips API call.
  5. Builds payload and calls candidatePythonGatewayFetch("/api/mail/send-template", ...).

Payload sent to api-python:

{
  "email": "addr@example.com",
  "sendgrid_template_id": "d-...",
  "sender_name": "Qwestly",
  "sender_email": "hello@qwestly.com",
  "dynamic_template_data": { "first_name": "Jane" },
  "asm": { "group_id": 29620, "groups_to_display": [29620] },
  "categories": ["candidate-90-sec-welcome"],
  "reply_to_email": "...",
  "user_id": "..."
}

The asm block is always attached (group ID 29620 — Candidate marketing unsubscribe group).

Sandbox: candidate/src/lib/email-sandbox.ts. Reads NEXT_PUBLIC_SENDGRID_SANDBOX or SENDGRID_SANDBOX env vars.

Template registry

File: src/services/email/templates.ts

16 registered templates:

Template ID SendGrid ID Sender Subject Category Context
CP-linkedin-profile-suggestion d-e80eca2fd6994983a6d40fc97c6a2a81 Adam at Qwestly Your suggested LinkedIn profile candidate-linkedin-profile-suggestion candidate
CP-waitlist-joined d-fb35bec192c84b0384d742e6c95730ac (fallback) {{first_name}}, you've been added to the Qwestly waitlist waitlist-confirm waitlist
CP-waitlist-approval d-e8189292c30245a08ff15f70a79eb8b7 Qwestly Thanks for signing up for Qwestly waitlist-approve-epd waitlist
CP-waitlist-approval-nonEPD d-4b2a5da08b664c8b95d710ca74e221e6 Qwestly Thanks for signing up for Qwestly waitlist-approve-not-epd waitlist
CP-waitlist-declined d-de6c0b00f9f14da181173b256938cf73 Qwestly Update on your Qwestly waitlist application waitlist-decline waitlist
CP-waitlist-reminder 426c4c82-2c56-43fb-9f8b-ba6bf6a2f7d7 Adam at Qwestly No prep needed - just 15 minutes waitlist-approved-no-acct waitlist
CP-verify-work-email d-f1797b6165d4414b856c2bb6741291a7 (fallback) Verify your work email work-email-verification verification
CP-verify-linkedin d-9d235ceedfeb4bf984cbb06d3ec7aabd (fallback) Verify your LinkedIn account linkedin-verification verification
CP-incomplete-card-1 5105fbfc-fe2d-42db-a62e-ef91137a3c48 Adam at Qwestly No prep needed - just 15 minutes candidate-incomplete-card-1 candidate
CP-incomplete-card-2 d-524718ad186f42958b637549dcb85d76 Adam at Qwestly What's holding you back? candidate-incomplete-card-2 candidate
CP-90-seconds-welcome d-4f0004b57d844ebab98c3970979b4d3c Qwestly Welcome to Qwestly candidate-90-sec-welcome candidate
qwestly-card-email ⭐⭐ (in-code HTML) Adam at Qwestly Qwestly Card candidate-completed-card candidate
CP-claim-invitation d-ecb8ad7ee26046de9a0043dd6335059e Qwestly Claim your Qwestly account candidate-claim-invitation candidate
CP-networking-target-ask d-f2996a794ec24fb79f3359dd29786bf4 Qwestly {{first_name}}, someone on Qwestly asked for an informal intro candidate-networking-target-ask candidate
CP-networking-seeker-confirm d-a0113c62f1de4911a015577080b7022c Qwestly {{first_name}}, confirm your intro on Qwestly candidate-networking-seeker-confirm candidate
CP-networking-dual-intro d-24a59017620645f1aff5b63c8de442b5 Qwestly Intro: {{seeker_first_name}} and {{target_first_name}} (Qwestly) candidate-networking-dual-intro candidate
  • isMarketingJourney: true — these templates use SendGrid marketing journey automation. The caller adds the contact to a SendGrid list; SendGrid's automation sends the email asynchronously.
  • ⭐⭐ isInCodeTemplate: trueqwestly-card-email renders HTML in-code via buildQwestlyCardEmailContent() rather than using a SendGrid template.

Config constants

File: src/config/site.ts

Constant Value Purpose
SENDGRID_FROM_EMAIL "hello@qwestly.com" Default from address
SENDGRID_SUPPORT_EMAIL "support@qwestly.com" Support address
SENDGRID_NO_REPLY_EMAIL "no-reply@qwestly.com" Used for no-reply emails
SENDGRID_ASM_GROUP_ID 29620 ASM unsubscribe group — applied to every email
SENDGRID_LIST_WAITLIST_NO_ACCOUNT afa4f88d-... Approved waitlist, no account yet
SENDGRID_LIST_ACCOUNT_NO_CARD f5508d77-... Account created, no card
SENDGRID_LIST_90_SEC_ACCT_BUT_NO_CARD 6f164107-... 90-sec signup, account no card
SENDGRID_LIST_90_SEC_CARD_BUT_NOT_PUBLISHED d680fcb8-... Card created, not published
SENDGRID_LIST_90_SEC_CARD_NO_INTERVIEW 243a90c2-... Card published, no interview
SENDGRID_CARD_INTERVIEW_BUT_NOT_PUBLISHED edb53b2e-... Interview done, card not published

All call sites

# Template ID Trigger File Notes
1 CP-waitlist-joined Candidate signs up on waitlist services/auth/waitlist.ts:176
2 CP-waitlist-approval Admin approves EPD waitlist entry services/auth/waitlist.ts:414
3 CP-waitlist-approval-nonEPD Admin approves non-EPD waitlist entry services/auth/waitlist.ts:414 Same code path, template varies by role
4 CP-waitlist-declined Admin declines waitlist entry routes/admin/waitlist/route.ts:355
5 CP-waitlist-reminder Marketing journey automation (list add via addToSendGridList) Not sent directly
6 CP-verify-work-email Candidate requests work email verification services/email/work-email-verification.service.ts:21
7 CP-verify-linkedin Admin/LinkedIn verification flow services/email/linkedin-verification.service.ts:17
8 CP-incomplete-card-1 Marketing journey automation (list add via addToSendGridList) Not sent directly
9 CP-incomplete-card-2 Triggered after card-1 sent services/email/marketing-email-template-service.ts:132
10 CP-90-seconds-welcome Candidate signs up (direct, acquisition=direct) services/auth/post-signup-candidate-created.ts:114
11 qwestly-card-email ⭐⭐ User shares their Qwestly card services/email/templates.ts (in-code) Built via in-code HTML
12 CP-claim-invitation Provisioned account created services/email/claim-invite.service.ts:20
13 CP-linkedin-profile-suggestion Admin sends LinkedIn suggestion routes/.../linkedin_profile_suggestions/.../route.ts:487
14 CP-networking-target-ask Seeker requests intro to target services/networking/networking-email.service.ts:286
15 CP-networking-seeker-confirm Target says yes, seeker must confirm services/networking/networking-email.service.ts:334
16 CP-networking-dual-intro Both parties agreed services/networking/networking-email.service.ts:361 Sent to both recipients

3. candidate-catalog/ App

App root: candidate-catalog/

Gateway

File: src/services/sendgrid.ts

Simpler than candidate/ — only supports single recipients (bare email string, no "Name <email>" format).

async function sendTemplateEmail<T extends Record<string, string | undefined>>(
  templateId: EmailTemplateId,
  {
    email: string,
    senderName?: string,           // default "Qwestly"
    senderEmail?: string,          // default "hello@qwestly.com"
    dynamicTemplateData?: T,
    replyToEmail?: string,
    sandbox?: boolean,
    categories?: string[],
  }
)

REST client: src/lib/api-python-client.ts — reads PYTHON_API_BASE_URL and QWESTLY_SERVICE_API_KEY, authorizes with Authorization: Bearer, 10-second timeout.

Payload sent to api-python:

{
  "email": "recipient@example.com",
  "sendgrid_template_id": "d-...",
  "sender_name": "Mikelle",
  "sender_email": "mikelle@qwestly.com",
  "dynamic_template_data": { "first_name": "Jane", "cta_url": "..." },
  "sandbox": false,
  "asm": { "group_id": 29620, "groups_to_display": [29620] },
  "reply_to_email": "...",
  "categories": ["candidate-warm-invite"]
}

Template registry

File: src/services/email/templates.ts

Template ID SendGrid ID Subject Sender
candidate-first-engagement d-f411c6e1a9464076898b2e81f1bc955b We built this for you (want to make it better?) (configurable)
candidate-warm-invite d-f120eebbdb4a443fad521da09faa239d Warm outreach (configurable)

Both SendGrid template IDs can be overridden via env vars (SENDGRID_TEMPLATE_CANDIDATE_FIRST_ENGAGEMENT, SENDGRID_TEMPLATE_CANDIDATE_WARM_INVITE).

Config constants

File: src/config/site.ts

Same pattern as candidate/: SENDGRID_FROM_EMAIL, SENDGRID_SUPPORT_EMAIL, SENDGRID_NO_REPLY_EMAIL, SENDGRID_ASM_GROUP_ID = 29620, SENDGRID_LIST_FIRST_ENGAGEMENT.

All call sites

Template ID Trigger File Notes
candidate-warm-invite POST /api/warm-outreach/send (single) src/app/api/warm-outreach/send/route.ts Sender: Mikelle mikelle@qwestly.com. CTA links to app.qwestly.com/signup with invite token.
candidate-warm-invite POST /api/warm-outreach/send-bulk (batch, max 50) src/app/api/warm-outreach/send-bulk/route.ts Same template. Audits sends to MongoDB sent_emails.
candidate-first-engagement POST /api/candidates/[id]/send-engagement-email src/app/api/candidates/[id]/send-engagement-email/route.ts Generates Qwestly Card, sends preview. Also adds contact to "First engagement" SendGrid list for automation journey.

Audit log

File: src/services/sent-emails.ts

After every warm outreach send, a record is inserted into MongoDB's sent_emails collection with recipient, template ID, sent timestamp, categories, and sender info.


4. qwestly-hire/ App

App root: qwestly-hire/

Key difference: This app calls SendGrid directly via @sendgrid/mail NPM package — no Python gateway involved.

Dependencies

Package Version Used for
@sendgrid/mail ^8.1.6 Sending emails
@sendgrid/client ^8.1.6 Server-side template rendering/preview

Email service

File: src/services/email.service.ts

Two exported functions:

sendVerificationEmail({ email, name, code, verificationUrl })

  • Template: d-87e5f1b155924ace8e230781858fed29
  • From: process.env.SENDGRID_FROM_EMAIL || 'noreply@qwestly.com'
  • Dynamic data: first_name, verification_url, code
  • Sandbox: Controlled by SENDGRID_SANDBOX env var

sendHiringManagerNotificationEmail({ email, firstName, jobTitle, executiveSummaries })

  • Template: d-aa73d1add943443dba8c57ee8dba0e1c
  • From: 'hello@qwestly.com' (hardcoded)
  • Dynamic data: first_name, job_title, executive_summaries (raw markdown), executive_summaries_html (combined styled HTML), executive_summaries_html_array, executive_summaries_count
  • Uses markdownToStyledHTML() helper to convert markdown to email-safe inline-styled HTML

All call sites

# Email Trigger File Notes
1 Verification Admin clicks "Send Verification Email" on user detail page src/app/(private)/admin/users/[id]/actions.ts:83 Secure code stored in MongoDB verification_codes, 7-day expiry
2 Verification Admin clicks "Send Verification" from users list table src/app/(private)/admin/users/client-wrapper.tsx:93 Reuses server action above
3 HM Notification Admin POST to /api/admin/jobs/{id}/notify-hm src/app/api/admin/jobs/[[...slug]]/route.ts:657 Filters to unseen, not-yet-emailed candidates. Saves SentEmail record.

Email preview

The admin route also supports GET /api/admin/jobs/{id}/email-preview — fetches the SendGrid template, renders it server-side with Handlebars and candidate data, returns the rendered HTML for preview in an iframe.

Email audit trail

File: src/models/SentEmail.ts — MongoDB collection sent_emails. Fields: job_id, user_id, created_at, to_email, to_name, from_email, from_name, subject, categories, template_id, template_name, html, candidates_count, candidate_ids. Only saved for HM notifications.


5. ASM (Unsubscribe Groups)

All emails sent through candidate/ and candidate-catalog/ carry ASM group ID 29620 in every payload:

"asm": {
  "group_id": 29620,
  "groups_to_display": [29620]
}

This is baked into the central sendTemplateEmail function in both apps — not per-call-site.

  • api-python passes the asm block through to SendGrid's POST /v3/mail/send verbatim within body["asm"].
  • If groups_to_display is empty, api-python defaults it to [group_id].

qwestly-hire/ does not set ASM groups — it sends raw dynamic template payloads without an asm block.


6. Sandbox Mode

All four apps support sandbox mode to prevent accidental sends in development:

App Mechanism Env var
candidate/ isEmailSandboxEnabled() → logs payload, skips API call NEXT_PUBLIC_SENDGRID_SANDBOX or SENDGRID_SANDBOX
candidate-catalog/ SENDGRID_SANDBOX === "1" in sendTemplateEmail SENDGRID_SANDBOX
qwestly-hire/ SENDGRID_SANDBOX === "1" — sets mailSettings.sandboxMode.enable=true on the SendGrid payload SENDGRID_SANDBOX
api-python/ is_email_sandbox_enabled() — reads env at request time, sets sandbox_mode on the SendGrid payload SENDGRID_SANDBOX or EMAIL_SANDBOX

In sandbox mode, SendGrid validates the payload and returns 202 but does not deliver the email.


7. Marketing List / Journey Emails

Some templates use SendGrid marketing journey automation rather than direct API sends:

Template ID App SendGrid List ID When contact is added
CP-waitlist-reminder candidate SENDGRID_LIST_WAITLIST_NO_ACCOUNT When waitlist entry is approved and no account exists yet
CP-incomplete-card-1 candidate SENDGRID_LIST_90_SEC_ACCT_BUT_NO_CARD When 90-sec account created without card
candidate-first-engagement candidate-catalog SENDGRID_LIST_FIRST_ENGAGEMENT When first engagement email is sent (dual: direct send + list add)

The app calls addToSendGridList(email, listId) which routes through api-python's PUT /api/marketing/contacts. SendGrid's journey automation takes over from there.


8. SendGrid List IDs

All active marketing list UUIDs across apps:

Constant UUID App Purpose
SENDGRID_LIST_FIRST_ENGAGEMENT f8c29401-5e36-4545-921d-0bf8fb7fdf1c candidate-catalog First engagement (Qwestly Card preview sent)
SENDGRID_LIST_WAITLIST_NO_ACCOUNT afa4f88d-c2ce-409a-b00f-c4e7f376feeb candidate Approved waitlist, no account
SENDGRID_LIST_ACCOUNT_NO_CARD f5508d77-3503-4dcf-bda6-6615c61dde5e candidate Account created, no card
SENDGRID_LIST_90_SEC_ACCT_BUT_NO_CARD 6f164107-9b61-422a-8da9-ec3267f73d13 candidate 90-sec signup flow
SENDGRID_LIST_90_SEC_CARD_BUT_NOT_PUBLISHED d680fcb8-3899-410d-b852-778b0223fc84 candidate Card created, not yet published
SENDGRID_LIST_90_SEC_CARD_NO_INTERVIEW 243a90c2-b27f-443e-bdd5-1c0ef8db2149 candidate Card published, no interview complete
SENDGRID_CARD_INTERVIEW_BUT_NOT_PUBLISHED edb53b2e-ca04-4d3e-b5c5-86250dc0d733 candidate Interview done, card not published

9. Environment Variables Summary

Variable Used by Purpose
QWESTLY_SERVICE_API_KEY candidate, candidate-catalog, api-python Shared auth between Node apps and api-python gateway
SENDGRID_API_KEY qwestly-hire, api-python SendGrid API key (never in candidate/ or candidate-catalog/)
SENDGRID_SANDBOX All "1" enables sandbox mode
EMAIL_SANDBOX api-python Alternate sandbox flag
SENDGRID_FROM_EMAIL All Default from address
PYTHON_API_BASE_URL candidate-catalog api-python deployment URL
AGENT_API_URL candidate Agent api-python service URL
NEXT_PUBLIC_SENDGRID_SANDBOX candidate Client-side sandbox flag
SENDGRID_TEMPLATE_CANDIDATE_FIRST_ENGAGEMENT candidate-catalog Override SendGrid template ID
SENDGRID_TEMPLATE_CANDIDATE_WARM_INVITE candidate-catalog Override SendGrid template ID
PROD_URL / APP_BASE_URL All Base URL for links in email bodies