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

threadId

Shared by every message in a conversation; a new UUID starts a new thread

direction

EmailDirection — sent or received

externalId

The Resend email_id; unique per (subscriptionId, externalId)

inReplyToExternalId

Parent message id, for reply correlation

inboxCategory

EmailInboxCategory — the in-app folder

category

EmailTargetCategory — the LLM classification, mirrored from the campaign target

toEmail, fromEmail, subject, body, bodyHtml

Content

opened

Set by the Resend email.opened pixel event

readAt

In-app read state — deliberately separate from opened

archivedAt

Archive state

sendingDomainId

Which account sent or received it

contactId, campaignId, emailCampaignTargetId

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):

  1. Strip Re: / Fwd: prefixes from the subject.

  2. Match the cleaned subject against EmailVariation.subject among targets that share the sender’s address, falling back where the subject is missing.

  3. Upsert an Activity row for the reply.

  4. Increment receivedCount on the sending account.

  5. When a target and a from address were both resolved, publish Email.IncomingCategorise to INCOMING_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

page, page_size

Pagination, max 100

direction

all, or a specific EmailDirection

inbox_category

all, or a specific EmailInboxCategory

search

Free text over subject, body and addresses

include_archived

true / 1 to include archived messages

sending_domain

all, none (no sendingDomainId), or an account UUID

campaign_id

Restrict to one campaign

target_category

all, or an EmailTargetCategory

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.

Contact-scoped views

GET /api/contacts/{id}/emails lists a contact’s messages and PATCH /api/contacts/{id}/emails/{emailId} updates one, powering the email section of the contact detail page.