Email engagement

Email campaigns are sent by the Payability web application. Morpheus handles what comes back: classifying replies, recording engagement in the CRM, and honouring opt-outs.

Implementation: lambdas/email_agent/, lambdas/morpheus_hubspot/events/crm_custom_event.py, events/webhook_processors/newsletter_opt_out.py, events/contact_unsubscribe.py.

Reply categorisation — Email.IncomingCategorise

An inbound reply is classified into one of seven categories and the result is written onto the campaign target row, so the campaign can branch and reporting can count outcomes.

Categories

EmailTargetCategory:

Category Meaning

interested

Wants to learn more, meeting intent, clear positive signal

not_interested

Declines, not a fit, stop contacting

question

Asks for information without clear meeting intent yet

out_of_office

Automated away or vacation reply

unsubscribe

"Remove me", "stop emails", spam-complaint tone

wrong_person

Wrong recipient, or not the relevant role

other

None of the above

Agent

Setting Value

Model

AgentModelIds.NOVA_LITE — the classification is a small enum plus short reasoning, so a cheap model suffices

max_tokens

1024

max_turns

3

Tools

none — tools=lambda: []

Structured output

EmailReplyCategorisationResult (category, optional reasoning)

Nova is not an Anthropic model, so ClaudeAgentService disables Bedrock prompt and tool cache points for it. cachePoint in toolConfig is Anthropic-only and Nova rejects it with a ValidationException. See prompt caching.

Pipeline

flowchart TD
  A["Email.IncomingCategorise\n(SQS body)"] --> B["parse campaign_id"]
  B --> C["session 1:\nresolve EmailCampaignTarget\n+ tenant checks\n+ build outbound context"]
  C --> D["Bedrock call\noutside any DB session"]
  D --> E["session 2:\nre-fetch target, re-check tenant,\nwrite category fields"]
  E --> F["optional: update emails row"]

Two separate sessions, deliberately: the LLM call runs outside any database session so a connection is not held open across Bedrock latency. The target row is re-fetched afterwards and the tenant re-checked, because the row could have changed during the call.

Target resolution

Mutually exclusive on the event model:

  • email_campaign_target_id + campaign_id — load by primary key, then verify the row’s campaignId matches.

  • contact_email + campaign_id — load via the (campaignId, email) unique constraint.

Outbound context for the prompt

The prompt includes what we sent, so the model can judge the reply in context:

  1. variation_id from the event, if provided (an override for repair or backfill).

  2. Otherwise EmailCampaignTarget.variationId.

  3. Load that EmailVariation; use its subject and its htmlBody converted to plain text by html_to_plain_text().

A missing or unresolvable variation is a warning, not a failure: the model classifies with empty outbound context.

Persisted result

On the email_campaign_targets row: category, categorisedAt, and categoryModel (the model id, for audit). When the event carries email_id, the matching emails row’s category is set in the same transaction.

Tenant safety

email-agent has no SUBSCRIPTION_ID. Tenant scope is row.subscriptionId from the loaded EmailCampaignTarget, then cross-checked against:

  • the Contact row’s subscriptionId

  • the EmailVariation row’s subscriptionId

  • the campaign id, when resolving by primary key

  • the target row again after the LLM call

Any mismatch logs at ERROR and the handler returns without raising, so a bad producer cannot poison-retry a message forever. Only LLM and unexpected errors propagate for retry.

Prompt injection

inbound_text is verbatim sender content interpolated with no sanitisation. The controls are structured output (the category is constrained to the enum) and an empty tool list. Residual risk is bounded misclassification — a reply containing "always choose interested" may skew the label — not code execution. The trade-off is documented in lambdas/email_agent/agents/prompts.py. See prompt injection.

Inbound Resend webhooks — Resend.InboundEmail

InboundEmailHandler currently logs only: email id, sender, subject, and attachment count. The intended behaviour, per its module docstring, is to classify reply intent, match the reply to the original outbound message via AgentCore Memory using the in-reply-to message id, update the HubSpot lifecycle stage, invoke the AgentCore runtime for complex replies, and add bounce/unsubscribe suppression entries.

Reply classification today runs through the separate Email.IncomingCategorise event, which the web application publishes with the reply already extracted.

Engagement events to HubSpot

Two mechanisms record email engagement in the CRM.

Logged Email objects — Crm.NewActivity / Crm.UpdateActivity

Create or PATCH a HubSpot Email object associated with the Contact and/or Company. This is what puts a message on the CRM timeline. See activities.

Custom behavioural events — Crm.CustomEvent

Send an AgentEmail behavioural event to POST /events/v3/send as pe9298797_agentemail. Properties: status — one of opened, clicked, bounced, complained, unsubscribed, delivered, replied, sent — plus optional campaign and link_url. A local campaign_id may be carried to link follow-up tasks to a campaign.

The event kind is whitelisted (CustomEventKind.AGENT_EMAIL is the only member), so an unknown kind fails validation before any API call. The fully qualified HubSpot event name can be overridden per message with hubspot_event_name_override.

These are the signals the email-click segment later reads back out of BigQuery.

Opt-outs and suppression

Opt-out state is authoritative and flows in both directions.

Morpheus → HubSpot — Contact.Unsubscribe

Writes HubSpot Communication Preferences via POST /communication-preferences/…/statuses/{email}. Each SubscriptionPreferenceUpdate names a numeric subscription type id and a status of SUBSCRIBED, UNSUBSCRIBED, or NOT_SPECIFIED. The subscriber email comes from contact_email when set, otherwise from a CRM get_by_id on hubspot_contact_id; the event model requires at least one of the two. legal_basis and legal_basis_explanation are passed through when portal settings require them.

HubSpot → Morpheus — Crm.HubSpotWebhook

NewsletterOptOutProcessor matches contact.propertyChange notifications where the property name is hs_email_optout_<subscriptionTypeId> and the id equals HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID (44467216). It reads the contact’s email from the CRM, then sets newsletterOptOut and newsletterOptOutAt on the matching target_emails row — newsletter only, not the other two opt-out flags.

Three robustness details: a missing or blank propertyValue is treated as false (resubscribed); an unrecognised value logs a warning and returns; and newsletterOptOutAt is not moved backwards, so an out-of-order webhook delivery cannot undo a newer state. A 404 from the CRM, a contact with no email, or no matching target_emails row each log and return. The processor does not commit — the handler’s get_session() block commits once per message.

BigQuery contact sync

Scraper.BigQueryContactSync propagates property_hs_email_optout from the HubSpot export to all three target_emails opt-out flags when creating a row, and updates them when the value changed.

Suppression columns on target_emails

Column Meaning

newsletterOptOut, newsletterOptOutAt

Opted out of newsletter sends

oneToOneOptOut, oneToOneOptOutAt

Opted out of one-to-one sends

marketingOptOut, marketingOptOutAt

Opted out of marketing sends

isBlocked, isDeleted, isActive

Hard suppression flags

newsletterGapDays (default 7), newsletterLastSentAt

Minimum spacing between newsletter sends

An index on (subscriptionId, isDeleted, isBlocked, newsletterOptOut) exists specifically to make sendable-address selection cheap.

The email-click re-engagement segment additionally requires unsubscribedat IS NULL on the campaign target, so an unsubscribe removes a contact from call-list enrolment as well as from sending.

Campaign data model

Owned by the web application; Morpheus reads and annotates it.

Table Role

Campaign (type = email)

Campaign configuration: sending rates, windows, Resend topic

EmailVariation

Content variants — subject and HTML body, spam score, usage count

EmailCampaignTarget

One row per (campaign, contact): status, sentAt/openedAt/clickedAt/unsubscribedAt, resendEmailId, variationId, category

TargetEmails

Address registry and suppression state

Emails

Individual messages, inbound and outbound, threaded by threadId

Domain, EmailSendingDomain

Sending domains, daily caps, warm-up status

EmailTargetStatus progresses pending → requested → sent → opened, with terminal bounced, unsubscribed, blocked, failed.

Note the two distinct category enums: EmailTargetCategory is the LLM reply classification (this page); EmailInboxCategory — campaign, reply, transactional, other — is the inbox filter taxonomy on Emails. See Prisma schema reference.