_private/qwestly-docs/Features/emails/email-system-overview.md
Table of Contents
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:
-
candidate/ and candidate-catalog/ → api-python → SendGrid. These Node apps authenticate to api-python via a shared
QWESTLY_SERVICE_API_KEY(sent asAuthorization: Bearer). api-python then authenticates to SendGrid with the realSENDGRID_API_KEY. This means the Node apps never hold or leak a SendGrid key. -
qwestly-hire/ → SendGrid directly. It has its own
SENDGRID_API_KEYenv var and uses@sendgrid/mailto 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:
-
Warm outreach email (
candidate-warm-invite) incandidate-catalog/— the first email a prospect receives when an admin reaches out from the catalog. ASM group ID29620is baked into the centralsendTemplateEmailpayload. -
LinkedIn profile suggestion email (
CP-linkedin-profile-suggestion) incandidate/— sent by an admin with an AI-generated LinkedIn profile suggestion. Uses the same ASM group ID29620, also baked into the centralsendTemplateEmail.
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-pythonendpoints over HTTP, authenticated via a sharedQWESTLY_SERVICE_API_KEY. - qwestly-hire/ uses
@sendgrid/maildirectly — it has its ownSENDGRID_API_KEYenv 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 call — send_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_name → e16_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:
- Looks up template metadata from the registry (
getTemplate). Fails if not found. - Normalizes recipients. Extracts name from
"Name <email>"format. - Resolves sender via cascade:
senderName || template.defaultSenderName || "Qwestly". - Checks sandbox: if
sandbox ?? isEmailSandboxEnabled()is true, logs payload to console and skips API call. - 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: true—qwestly-card-emailrenders HTML in-code viabuildQwestlyCardEmailContent()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_SANDBOXenv 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
| # | 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-pythonpasses theasmblock through to SendGrid'sPOST /v3/mail/sendverbatim withinbody["asm"].- If
groups_to_displayis 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 |