Email models
EmailCampaignTarget
Table email_campaign_targets. One contact enrolled in one email campaign.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Required — imports upsert the contact first |
|
|
|
|
|
Required FK to |
|
|
Cached from the contact at import time, so the recipient list renders without a join |
|
|
default |
|
|
default |
|
|
|
|
|
Set at Resend hand-off, refreshed by |
|
|
First occurrence only for open and click |
|
|
Correlation key for outbound webhooks |
|
|
Which variation was sent |
|
|
Which account sent it |
|
|
Lease stamp; lock window is 2 days ( |
|
|
default |
|
|
LLM reply classification |
|
|
|
|
|
Audit: which model or label produced |
Unique: (campaignId, contactId) and (campaignId, email) — one enrollment per person per
campaign, while the same address may be a recipient of many campaigns.
Indexes: (campaignId, status), (campaignId, status, queuedAt) for the leasing query,
(campaignId, sentAt), emailId, subscriptionId, contactId, email, resendEmailId.
Relations: campaign, contact, subscription, variation, targetEmail, emails,
contactSequences.
A click sets clickedAt but does not set a status — there is no clicked value in
EmailTargetStatus. Filter recipients on has_clicked, not on status.
EmailVariation
Table email_variations. Subject plus HTML body for a campaign, or a reusable template.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Created programmatically; synced on create and update. Null on templates until they are copied into a campaign |
|
|
Always |
|
|
|
|
|
|
|
|
Lifetime sends; a statistic, never a gate |
|
|
Reserved for future scheduling |
Every send and selection query filters by campaignId, so a template can never be sent directly.
UNSUBSCRIBE_LINK is injected automatically at send time and is applied last, so no user-defined
variable can overwrite it.
Domain
Table domains. A Resend-verified sending domain.
| Field | Type | Notes |
|---|---|---|
|
|
Unique with |
|
|
e.g. |
|
|
Resend domain id |
|
|
Mirror of the Resend status at link time |
|
|
Shared cap across all its accounts; |
|
|
|
|
|
Only accounts under an active domain may send |
|
|
Lifetime, never reset by the cron |
Unique: (subscriptionId, name).
The domain has no daily counter of its own — its usage today is the sum of its accounts'
dailySentCount for those whose dailySentDate is today (UTC).
Each Resend subdomain is its own verified domain with its own id, so sales.payability.com is a
separate Domain row rather than a child of payability.com.
EmailSendingDomain
Table email_sending_domains. A concrete From address ("email account" in the UI).
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Parent |
|
|
Host, must match the parent |
|
|
e.g. |
|
|
|
|
|
Only active accounts are selected |
|
|
Per-account cap; |
|
|
Incremented at Resend hand-off; counts only for |
|
|
UTC day |
|
|
Incremented with |
|
|
Lifetime, incremented on |
|
|
On |
|
|
On the first |
|
|
On the first |
|
|
On |
|
|
On |
dailySentCount and sentCount count different moments on purpose: the first is bumped when the
message is handed to Resend (so the cap reflects committed sends immediately), the second when
Resend confirms it sent. email.delivered increments neither, because email.sent already did.
Emails
Table emails. Individual messages, inbound and outbound.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Which account sent or received |
|
|
Correlation into the contact graph |
|
|
Shared by every message in a conversation |
|
|
|
|
|
In-app folder |
|
|
LLM classification mirrored from the campaign target |
|
|
Resend |
|
|
Parent id for reply correlation |
|
|
|
|
|
Plain text |
|
|
|
|
|
The recipient’s tracking pixel fired |
|
|
An operator read it in the app |
|
|
|
|
|
Unique: (subscriptionId, externalId).
Indexes: (subscriptionId, createdAt DESC) for the inbox list,
(subscriptionId, threadId, createdAt) for thread rendering, (subscriptionId, archivedAt),
toEmail, fromEmail, contactId, emailCampaignTargetId.
Relations: subscription, sendingDomain, contact, campaign, emailCampaignTarget,
contactSequenceSteps.
opened and readAt are separate because they describe different actors — the recipient and the
operator.