Email — Technical Reference
This page covers the data models, background processing engines, and tracking infrastructure behind the Email module.
Data Models
sender_identities
Stores verified domains and subdomains used for sending and reply routing.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
domain | text | Root domain (e.g., acme.com) |
email_address | text | Sending email address (e.g., hello@mail.acme.com) |
resend_domain_id | text | Domain identifier registered with Resend |
dns_verified | boolean | Flag indicating DKIM/SPF verification status |
verified_at | timestamptz | When domain verification completed |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
campaigns
Defines automated drip sequences.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
name | text | Campaign name |
status | text | 'draft', 'active', 'paused', or 'archived' (CHECK constraint) |
created_by | uuid | FK → user_profiles.id |
updated_by | uuid | FK → user_profiles.id |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
campaign_steps
Individual emails within a campaign sequence.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
campaign_id | uuid | FK → campaigns.id (ON DELETE CASCADE) |
sequence_order | integer | Display order in composer |
timing_mode | text | 'delay' (after enrollment) or 'before_event' (countdown to event) |
timing_value_hours | integer | Interval in hours |
email_subject | text | Email subject line (supports merge fields) |
email_body_html | text | Rich text HTML email body (supports merge fields) |
sender_identity_id | uuid | Optional FK → sender_identities.id (custom step sender) |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
campaign_enrollments
Tracks contact positions inside campaigns.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
contact_id | uuid | FK → contacts.id (ON DELETE CASCADE) |
campaign_id | uuid | FK → campaigns.id (ON DELETE CASCADE) |
current_step_id | uuid | FK → campaign_steps.id (next step to send) |
trigger_event_id | uuid | FK → events.id (optional, for date-based events) |
status | text | 'active', 'processing', 'completed', 'skipped_unsubscribed', 'cancelled', or 'failed' |
next_action_at | timestamptz | Scheduled timestamp of the next step |
retry_count | integer | Failed attempts count (max 3 before marking 'failed') |
last_error | text | Diagnostic error message of last failure |
enrolled_at | timestamptz | Enrollment timestamp |
completed_at | timestamptz | Completion timestamp |
cancelled_at | timestamptz | Cancellation timestamp |
broadcasts
One-time email campaigns sent to targeted audiences.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
sender_identity_id | uuid | FK → sender_identities.id (optional) |
subject | text | Email subject line |
body_html | text | Email body content |
target_tags | uuid[] | Target tags (include filter) |
target_events | uuid[] | Target events (include filter) |
scheduled_for | timestamptz | Optional scheduled delivery time |
status | text | 'draft', 'scheduled', 'sending', or 'sent' |
sent_at | timestamptz | Dispatched timestamp |
sent_count | integer | Number of successful dispatches |
created_by | uuid | FK → user_profiles.id |
created_at | timestamptz | Record creation timestamp |
updated_at | timestamptz | Last updated timestamp |
email_sends
Unified log of all outgoing emails.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
contact_id | uuid | FK → contacts.id (ON DELETE CASCADE) |
sender_identity_id | uuid | FK → sender_identities.id |
send_type | text | 'campaign', 'broadcast', or 'transactional' |
campaign_step_id | uuid | FK → campaign_steps.id (optional) |
broadcast_id | uuid | FK → broadcasts.id (optional) |
resend_message_id | text | Message identifier returned by Resend |
subject | text | The sent subject line |
status | text | 'sent', 'delivered', 'opened', 'clicked', 'bounced', or 'complained' |
sent_at | timestamptz | Dispatched timestamp |
opened_at | timestamptz | First opened timestamp |
clicked_at | timestamptz | First clicked timestamp |
tracked_links
Links wrapped for native engagement tracking.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key (used in tracking redirect URLs) |
email_send_id | uuid | FK → email_sends.id (ON DELETE CASCADE) |
workspace_id | uuid | FK → workspaces.id (ON DELETE CASCADE) |
contact_id | uuid | FK → contacts.id (ON DELETE CASCADE) |
original_url | text | Original target URL |
click_count | integer | Direct click counter |
first_clicked_at | timestamptz | First click timestamp |
last_clicked_at | timestamptz | Most recent click timestamp |
created_at | timestamptz | Record creation timestamp |
The Sweeper Engines
Email sending in Gordon CRM is decoupled into asynchronous background processing via scheduled workers.
Campaign Sweeper
The campaign runner runs on a periodic CRON cycle:
- Queries for
activecampaign enrollments wherenext_action_at <= NOW(). - Locks rows by updating status to
'processing'(usingUPDATE ... RETURNINGfor concurrency safety). - Validates against the Tiered Suppression Engine:
- Marketing Emails: Skips and sets status to
'skipped_unsubscribed'ifis_subscribed = falseorunsubscribed_atis set, or if the contact has any active suppression (bounce,complaint,unsubscribe,manual). - Transactional Emails: Bypasses soft suppressions and general opt-outs. It only skips if the contact has an active hard suppression (
bounce,complaint, ormanual).
- Marketing Emails: Skips and sets status to
- Calculates Next Steps: Uses the Universal Step Calculator to schedule the subsequent step. The calculator computes timing targets for all remaining steps:
- Delay step:
enrolled_at + timing_value_hours - Before-event step:
event_start_time - timing_value_hoursIt then sorts them, scheduling the earliest one.
- Delay step:
- Dispatches: Injects merge fields, handles tracking wrapping, injects unsubscribe headers and links (only for
'marketing'emails), appends the transactional subscribe footer (only for transactional emails sent to soft-suppressed/opted-out contacts if enabled in settings), pre-insertsemail_sendswith status'pending', sends via Resend, and updates status to'sent'(or'failed'on error). Overdue steps within a 5-minute tolerance window remain eligible to prevent skips.
Broadcast Sweeper
The broadcast runner dispatches scheduled or queued dispatches:
- Queries for broadcasts with status
'sending'or'scheduled'wherescheduled_for <= NOW(). - Locks row status to
'processing'. - Resolves Target Audience: Calculates the recipient list by applying includes (all contacts, tag array, event array) and excludes (tag array, event array).
- Marketing Broadcasts: Filters out any contact that has
is_subscribed = false,unsubscribed_atset, or any active suppression. - Transactional Broadcasts: Bypasses soft suppressions and only filters out contacts with active hard suppressions (
bounce,complaint,manual).
- Marketing Broadcasts: Filters out any contact that has
- Pre-inserts Logs: Creates
email_sendsentries set to'pending'in bulk. - Batch Sends: Injects unsubscribe headers/links (marketing) or appends subscribe footers (transactional, for opted-out contacts), and sends via the Resend Batch API in chunks of 100 emails per batch.
- Finalizes: Updates status to
'sent'(withsent_countandsent_at) or'failed'with error messages.
First-Party Tracking Engine
Gordon CRM utilizes native link-wrapping and pixel tracking hosted on a branded tracking subdomain (e.g., links.yourdomain.com) rather than relying on Resend's default tracking.
Tracking Precedence Matrix
The campaign and broadcast engines route tracking decisions based on category settings:
| Category | disable_tracking | Link Wrapping | Open Pixel | Unsubscribe Header | is_subscribed Gate | Transactional Footer |
|---|---|---|---|---|---|---|
| Marketing | false (default) | ✅ | ✅ | ✅ Required | ✅ Enforced | N/A |
| Marketing | true | ❌ | ❌ | ✅ Required | ✅ Enforced | N/A |
| Transactional | N/A | ❌ | ❌ | ❌ Omitted | ❌ Bypassed | ✅ Optional (Opted-out only) |
Note on Transactional Footers: If the recipient is opted out of marketing (has a soft opt-out status) and
subscribe_footer_enabledistruein the workspace settings, a subscribe footer with a custom message and action link is appended to transactional emails.
Link Wrapping Algorithm
If tracking is active, the system scans the HTML body for <a> tags and substitutes the links:
- Regex Scan: Parses
<a href="...">elements. - Skips Restrictions: Ignores unsubscribe endpoints (
/unsubscribe/or/api/unsubscribe),mailto:links,tel:links, internal anchors (#), and Naked URLs (e.g.<a href="http://google.com">google.com</a>) where the link text resembles a URL. This text-to-href mismatch check prevents Gmail and Outlook phishing flags. - Database Log: Creates a
tracked_linksrow for every unique target. - URL Rewrite: Replaces the link destination with
https://links.yourdomain.com/api/c/{tracked_link_id}.
Open and Click Endpoints
- Open tracking pixel (
/api/o/[id]): Appends a transparent 1x1 GIF tracking image (/api/o/{email_send_id}) before</body>. Loading this endpoint setsopened_aton the targetemail_sendslog and returns the image. - Click redirect (
/api/c/[id]): Clicking a wrapped link records the click count and timestamps intracked_linksandemail_sends, then responds with a302 Redirectto the original destination.
Status Precedence
Email status only advances forward based on the following ordinal precedence hierarchy:
| Status | Precedence Value |
|---|---|
pending | -1 |
sent | 0 |
delivered | 1 |
opened | 2 |
clicked | 3 |
bounced | 99 (Terminal Override) |
complained | 99 (Terminal Override) |
Sender Identity Resolution
The shared resolveSenderIdentity utility resolves the "from" address for campaigns, broadcasts, agreements, and other system emails by walking the following fallback chain:
- Explicit Sender: Evaluates any explicit
sender_identity_idspecified on the campaign step or broadcast. The sender identity must be verified (dns_verified = true). - Workspace Default: Falls back to the first verified sender identity created for the workspace.
- Dedicated Fallback Domain: Generates a virtual sender address using the slugified workspace name and a short hash of the workspace ID:
{slugified-workspace-name}-{short-workspace-uuid-hash}@[FALLBACK_SENDER_DOMAIN], routing replies directly to the workspace owner's email.
DNS & Verification Architecture
Subdomain Sending Isolation
The system registers a dedicated mail subdomain (e.g., mail.acme.com) for sending, isolating root domain reputation. Verification requires DKIM and SPF records.
Tracking Subdomain DNS Setup
Branded click and open tracking uses a CNAME record mapping links.yourdomain.com to Vercel. The target value is retrieved dynamically from the Vercel API. If the API is unreachable, the DNS setup page defaults to cname.vercel-dns.com.
Fallback Sender Mechanics
For workspaces without custom verified senders, a virtual sender address is generated:
{slugified-workspace-name}-{short-workspace-uuid-hash}@mail.gordoncrm.comReplies are routed directly to the workspace owner's email. Fallback tracking redirects through the FALLBACK_TRACKING_DOMAIN environment variable.
Server Actions
getBroadcastStats
The getBroadcastStats server action aggregates metrics from the email_sends table for a specific broadcast to supply the list view accordion:
export async function getBroadcastStats(broadcastId: string): Promise<{
data?: {
total: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
openRate: number | null;
clickRate: number | null;
};
error?: string;
}>It dynamically queries email_sends for status, opened_at, and clicked_at timestamps, filtering by broadcast_id and send_type = 'broadcast'.
getBroadcasts
The getBroadcasts server action retrieves a paginated, filtered, and sorted list of broadcasts for the active workspace.
export interface BroadcastListParams {
page?: number;
pageSize?: number;
search?: string;
status?: string;
category?: string;
sortColumn?: string;
sortDir?: "asc" | "desc";
}Filtering & Sorting Parameters:
status: Optional string to filter by dispatch status ('draft','scheduled','processing','sent', or'failed').category: Optional string to filter by category ('marketing'or'transactional').sortColumn: Specifies the column to sort by. Only allowlisted columns are queried:"category": Category badge."status": Dispatch status."created_at"(default): Broadcast creation timestamp."sent_count"(Recipients): Number of sent recipients.
sortDir: The sort direction ("asc"or"desc"). Defaults to"desc".
sendTestEmail
The sendTestEmail server action dispatches a single test email. It allows users to preview campaign steps or broadcasts before launching them.
export async function sendTestEmail(params: {
workspaceId: string;
subject: string;
bodyHtml: string;
recipientEmail: string;
previewContactId?: string;
category: "marketing" | "transactional";
appendFooter?: boolean;
}): Promise<{ success?: boolean; error?: string }>Key Operations:
- Merge-Tag Personalization: If a
previewContactIdis supplied, the action queries the database for the preview contact's details and compiles the subject and body using thecompileTemplate()merge-field resolver. - Unsubscribe Link Injection: If the email
categoryis'marketing', the system wraps tracking links and appends the standard unsubscribe link. - Transactional Subscribe Footer Injection: If the
categoryis'transactional', the contact is currently opted out of marketing, and theappendFooterparameter is true, the system appends the workspace's transactional subscribe footer. - Dispatch: Sends the email to
recipientEmailusing the resolved workspace sender identity via Resend.
undoSendBroadcast
The undoSendBroadcast server action allows administrators to cancel a queued broadcast send within a short grace period (typically 15 seconds after clicking Send Now) or before the background sweeper begins delivery.
export async function undoSendBroadcast(
broadcastId: string
): Promise<{ success?: boolean; error?: string }>Key Operations:
- Atomic Status Check: Validates that the target broadcast's status is
'sending'. If the status is not'sending', or if the background sweeper has already commenced delivery (sending out the first batch of emails), the action fails with an error to prevent inconsistent delivery states. - Status Reversion: Reverts the broadcast status column back to
'draft'. - Pre-Insert Cleanup: Hard-deletes any pending
email_sendslog rows that were created during the audience resolution stage, ensuring that no emails are sent in subsequent sweeper runs. - Cache Revalidation: Calls
revalidatePath()to refresh dashboard list and detail page displays immediately.
UI & Display Architecture
Contact Detail Emails History
The Emails section on the Contact Detail page queries email_sends to render the message history:
- Campaign Email Subject Display: For campaign-type emails, it renders the actual email subject (
email_sends.subject) rather than the parent campaign's name, making multi-step campaigns visually distinguishable. A link to the parent campaign remains available. - Broadcast Email Subject Display: For broadcast-type/transactional emails, it renders the subject line.
Performance & Hydration Optimization
Server-Side Pre-fetching & Hydration Splits
- Campaigns Split:
- Server Component (
campaigns/page.tsx): Pre-fetches page 1 campaign directory listings on the server. - Client Component (
campaigns-client.tsx): Handles campaign lists rendering, filters, and step creation forms. - Eager Mount Check: Conditionally mounts the sheet and dialogs (
ResponsiveSheet/AlertDialog) to defer loading.
- Server Component (
- Broadcasts Split:
- Server Component (
broadcasts/page.tsx): Parallel pre-fetches page 1 broadcasts, workspace tags, and events. - Client Component (
broadcasts-client.tsx): Manages pagination, sorting, status/category filters, and lazy metrics calculations. - Debounced Search: Restricts search queries with a 300ms input debounce buffer to prevent redundant API load.
- Server Component (
- State gates: Both pages use
isFirstQueryRefmount guards andinitialWorkspaceIdRefhooks to sync state transitions cleanly across workspaces.
Row-Level Security (RLS)
All tables enforce tenant-level isolation using the get_user_workspace_ids() helper.
| Table | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
sender_identities | Workspace users | Admin+ | Admin+ | Admin+ |
campaigns | Workspace users | All members | All members | Admin+ |
campaign_steps | Workspace users | All members | All members | Admin+ |
campaign_enrollments | Workspace users | All members | All members | Admin+ |
broadcasts | Workspace users | All members | All members | Admin+ |
email_sends | Workspace users | All members | All members | Admin+ |
tracked_links | Workspace users | All members | All members | Admin+ |