email-agent
Physical name: morpheus-sam-email-agent. Source: lambdas/email_agent/.
Consumes inbound email events from EmailAgentQueue. Its live capability is
classifying reply intent; the raw Resend inbound handler is a logging stub.
| Aspect | Value |
|---|---|
Handler |
|
Timeout |
120 s (queue |
Memory |
512 MB |
Reserved concurrency |
5 |
SQS concurrency |
|
Layers |
common, data-layer, llm |
Registered events
event_type |
Handler | Model | Status |
|---|---|---|---|
|
|
|
live |
|
|
|
logging stub |
The routing key comes from the event_type SQS message attribute. A record without
it logs a warning and is skipped.
|
|
Email.IncomingCategorise
Full rules: Email engagement.
Payload
class IncomingCategoriseEvent(BaseModel):
event_type: Literal["Email.IncomingCategorise"]
campaign_id: str # required
contact_email: str | None # resolve target via (campaignId, email)
email_campaign_target_id: str | None # or resolve by primary key
inbound_subject: str = ""
inbound_text: str # required, min_length=1
variation_id: str | None # override; normally read from the target row
email_activity_id: str | None # optional id of an already-persisted CRM email
email_id: str | None # optional emails row to update alongside
contact_email and email_campaign_target_id are mutually exclusive resolution paths;
exactly one is expected.
Two sessions, one LLM call between them
with get_session() as session: # session 1
target = self._resolve_campaign_target(...) # load + campaign check
sub_id = target.subscriptionId # tenant scope from the row
self._verify_contact_aligns_with_target(...)
outbound = self._resolve_outbound_prompt_context(...)
target_id = target.id
llm = run_reply_categorisation(...) # NO session held
self._persist_categorisation(...) # session 2: re-fetch, re-check, write
The LLM call runs outside any session so a database connection is not held open across Bedrock latency. The target row is re-fetched afterwards and the tenant re-checked, because it could have changed during the call.
Outbound context
To judge a reply, the model needs to see what was sent:
-
variation_idfrom the event, if provided. -
Otherwise
EmailCampaignTarget.variationId. -
Load that
EmailVariation; use itssubject, and itshtmlBodyconverted byhtml_to_plain_text().
A missing or unresolvable variation is a warning — the model classifies with empty
outbound context. Only a variation whose subscriptionId mismatches is fatal.
Categorisation agent
agents/reply_categorisation.py:
| Setting | Value |
|---|---|
Model |
|
|
1024 |
|
3 |
Tools |
none — |
Structured output |
|
Built inside @lru_cache(maxsize=1) with deferred morpheus_llm imports, so the module
is importable without the Lambda layer.
run_reply_categorisation() raises RuntimeError when structured_output is None,
so a model that fails to call the output tool becomes a retryable failure rather than a
silent no-op.
Categories: interested, not_interested, question, out_of_office, unsubscribe,
wrong_person, other.
Tenant safety
This function deliberately has no SUBSCRIPTION_ID environment variable — the queue
is multi-tenant by design. Tenant scope is derived from data:
-
Load
EmailCampaignTarget;row.subscriptionIdbecomes the scope. -
Verify the
Contact(when present) has the samesubscriptionId. -
Verify the
EmailVariation(when present) has the samesubscriptionId. -
Verify the campaign id matches, when resolving by primary key.
-
After the LLM call, re-fetch the target and verify the subscription again.
Every mismatch logs at ERROR and the handler returns without raising, so the message
is deleted. A bad producer cannot poison-retry indefinitely.
| Failure | Outcome |
|---|---|
Malformed |
log, return (message deleted) |
Target not found |
log, return |
Campaign mismatch on the target row |
log, return |
Contact or variation subscription mismatch |
log, return |
Target disappeared during the LLM call |
log, return |
|
log, return |
LLM error, or any unexpected exception |
propagate → |
Resend.InboundEmail
InboundEmailHandler logs the email id, sender, subject, and attachment count, and does
nothing else. Its module docstring lists the intended behaviour: classify reply intent,
match to the outbound sequence 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.
The event model (ResendEmailReceivedEvent) is complete — typed envelope, data payload,
attachments — so only the handler body is missing.
Prompt injection
inbound_text is verbatim sender content, interpolated with no sanitisation. The
controls are structured output (category constrained to the enum) and an empty tool
list. Residual risk is bounded misclassification, not code execution. Documented in
agents/prompts.py and in prompt injection.
Configuration
| Setting | Default | Purpose |
|---|---|---|
|
(template) |
Shared secret |
|
|
Secrets read: DATABASE_URL, plus whatever EmailAgentSecrets declares. No
SUBSCRIPTION_ID — see tenant safety.
IAM
-
SQSPollerPolicyonEmailAgentQueue -
secretsmanager:GetSecretValueonAgentcoreSecret -
bedrock:InvokeModelandInvokeModelWithResponseStreamon*
Documentation in the source tree
-
lambdas/email_agent/CLAUDE.md— function-specific guidance for AI coding agents -
lambdas/email_agent/events/samples/README.md— when to use each sample payload -
Samples:
email_incoming_categorise_by_email.json,email_incoming_categorise_by_target_id.json
Operating notes
-
The queue and its outputs are commented out in the SAM template’s
Outputssection, so the queue URL must be read from the console or fromaws sqs get-queue-url --queue-name morpheus-sam-email-agent. -
EmailAgentQueueis a standard queue, not FIFO — no message groups, no content-based deduplication. -
Nova rejects Anthropic cache points, which
ClaudeAgentServicehandles by disabling them for non-Anthropic models. See prompt caching.