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 |
|---|---|
|
Wants to learn more, meeting intent, clear positive signal |
|
Declines, not a fit, stop contacting |
|
Asks for information without clear meeting intent yet |
|
Automated away or vacation reply |
|
"Remove me", "stop emails", spam-complaint tone |
|
Wrong recipient, or not the relevant role |
|
None of the above |
Agent
| Setting | Value |
|---|---|
Model |
|
|
1024 |
|
3 |
Tools |
none — |
Structured output |
|
|
Nova is not an Anthropic model, so |
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’scampaignIdmatches. -
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:
-
variation_idfrom the event, if provided (an override for repair or backfill). -
Otherwise
EmailCampaignTarget.variationId. -
Load that
EmailVariation; use itssubjectand itshtmlBodyconverted to plain text byhtml_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
Contactrow’ssubscriptionId -
the
EmailVariationrow’ssubscriptionId -
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
AgentEmailbehavioural event toPOST /events/v3/sendaspe9298797_agentemail. Properties:status— one ofopened,clicked,bounced,complained,unsubscribed,delivered,replied,sent— plus optionalcampaignandlink_url. A localcampaign_idmay be carried to link follow-up tasks to a campaign.The event kind is whitelisted (
CustomEventKind.AGENT_EMAILis the only member), so an unknown kind fails validation before any API call. The fully qualified HubSpot event name can be overridden per message withhubspot_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}. EachSubscriptionPreferenceUpdatenames a numeric subscription type id and a status ofSUBSCRIBED,UNSUBSCRIBED, orNOT_SPECIFIED. The subscriber email comes fromcontact_emailwhen set, otherwise from a CRMget_by_idonhubspot_contact_id; the event model requires at least one of the two.legal_basisandlegal_basis_explanationare passed through when portal settings require them. - HubSpot → Morpheus —
Crm.HubSpotWebhook -
NewsletterOptOutProcessormatchescontact.propertyChangenotifications where the property name ishs_email_optout_<subscriptionTypeId>and the id equalsHUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID(44467216). It reads the contact’s email from the CRM, then setsnewsletterOptOutandnewsletterOptOutAton the matchingtarget_emailsrow — newsletter only, not the other two opt-out flags.Three robustness details: a missing or blank
propertyValueis treated asfalse(resubscribed); an unrecognised value logs a warning and returns; andnewsletterOptOutAtis 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 matchingtarget_emailsrow each log and return. The processor does not commit — the handler’sget_session()block commits once per message. - BigQuery contact sync
-
Scraper.BigQueryContactSyncpropagatesproperty_hs_email_optoutfrom the HubSpot export to all threetarget_emailsopt-out flags when creating a row, and updates them when the value changed.
Suppression columns on target_emails
| Column | Meaning |
|---|---|
|
Opted out of newsletter sends |
|
Opted out of one-to-one sends |
|
Opted out of marketing sends |
|
Hard suppression flags |
|
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 configuration: sending rates, windows, Resend topic |
|
Content variants — subject and HTML body, spam score, usage count |
|
One row per (campaign, contact): status, |
|
Address registry and suppression state |
|
Individual messages, inbound and outbound, threaded by |
|
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.