Email inbox and replies
Outbound campaign email is tracked on EmailCampaignTarget. Individual messages — outbound and
inbound — are stored in Emails, which backs the in-app inbox at /emails.
The Emails model
| Field | Meaning |
|---|---|
|
Shared by every message in a conversation; a new UUID starts a new thread |
|
|
|
The Resend |
|
Parent message id, for reply correlation |
|
|
|
|
|
Content |
|
Set by the Resend |
|
In-app read state — deliberately separate from |
|
Archive state |
|
Which account sent or received it |
|
Correlation to the contact graph |
opened and readAt are separate because they answer different questions: opened is the
recipient’s pixel firing, readAt is whether an operator has read the reply.
Inbound correlation
Resend delivers replies as email.received. The data.email_id on that event is the new message
id, not the outbound resendEmailId stored on the target, so the reply must be matched heuristically.
EmailReceivedHandler (lib/events/handlers/resend/email-received.ts):
-
Strip
Re:/Fwd:prefixes from the subject. -
Match the cleaned subject against
EmailVariation.subjectamong targets that share the sender’s address, falling back where the subject is missing. -
Upsert an
Activityrow for the reply. -
Increment
receivedCounton the sending account. -
When a target and a
fromaddress were both resolved, publishEmail.IncomingCategorisetoINCOMING_EMAIL_SQS_QUEUE_URL:
{
"event_type": "Email.IncomingCategorise",
"campaign_id": "<uuid>",
"contact_email": "<reply-from>",
"inbound_subject": "…",
"inbound_text": "<plain text, HTML stripped>"
}
htmlToPlainText in lib/repositories/emails-repository.ts produces the plain-text body. A
downstream service classifies the reply and writes back EmailCampaignTarget.category,
categorisedAt and categoryModel.
Correlating on subject rather than a message id is a compromise: it is why Reply-To is derived per
sending account, narrowing the candidate set to targets that used that account.
Categories
EmailTargetCategory: interested, not_interested, question, out_of_office,
unsubscribe, wrong_person, other.
These drive triage. interested and question need a human; out_of_office should be retried
later; wrong_person and unsubscribe are suppression signals.
Recipients can be filtered by category on the campaign targets endpoint, where category=none
selects uncategorised recipients and category=all disables the filter.
The inbox
GET /api/emails supports:
| Parameter | Meaning |
|---|---|
|
Pagination, max 100 |
|
|
|
|
|
Free text over subject, body and addresses |
|
|
|
|
|
Restrict to one campaign |
|
|
Each row returns reply_count (messages in the thread) and is_read (derived from readAt), plus
the resolved campaign name and contact name.
GET /api/emails/{id} returns the full thread; PATCH /api/emails/{id} updates read, archive and
category state. POST /api/emails sends a one-off email — used for manual replies — writing both an
Emails row and an Activity row.