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

handler.lambda_handler

Timeout

120 s (queue VisibilityTimeout is 180 s)

Memory

512 MB

Reserved concurrency

5

SQS concurrency

MaximumConcurrency: 5, BatchSize: 1

Layers

common, data-layer, llm

Registered events

event_type Handler Model Status

Email.IncomingCategorise

IncomingEmailCategoriseHandler

IncomingCategoriseEvent

live

Resend.InboundEmail

InboundEmailHandler

ResendEmailReceivedEvent

logging stub

The routing key comes from the event_type SQS message attribute. A record without it logs a warning and is skipped.

phone-agent publishes Email.SendInternal to this queue during stage-1 task escalation, but no handler is registered for it. The dispatcher logs "No handler registered for SQS event_type" and deletes the message. See escalation ladder.

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:

  1. variation_id from the event, if provided.

  2. Otherwise EmailCampaignTarget.variationId.

  3. Load that EmailVariation; use its subject, and its htmlBody converted by html_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

AgentModelIds.NOVA_LITE

max_tokens

1024

max_turns

3

Tools

none — tools=lambda: []

Structured output

EmailReplyCategorisationResult — category (enum) + optional reasoning

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.

Persisted result

On the email_campaign_targets row: category, categorisedAt, categoryModel (the model id). When email_id is set, that emails row’s category is set in the same transaction.

Tenant safety

This function deliberately has no SUBSCRIPTION_ID environment variable — the queue is multi-tenant by design. Tenant scope is derived from data:

  1. Load EmailCampaignTarget; row.subscriptionId becomes the scope.

  2. Verify the Contact (when present) has the same subscriptionId.

  3. Verify the EmailVariation (when present) has the same subscriptionId.

  4. Verify the campaign id matches, when resolving by primary key.

  5. 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 campaign_id or other UUID

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

emails row named by email_id not found

log, return

LLM error, or any unexpected exception

propagate → batchItemFailures → DLQ

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

AGENTCORE_SECRET_ARN

(template)

Shared secret

LOG_LEVEL

INFO

Secrets read: DATABASE_URL, plus whatever EmailAgentSecrets declares. No SUBSCRIPTION_ID — see tenant safety.

IAM

  • SQSPollerPolicy on EmailAgentQueue

  • secretsmanager:GetSecretValue on AgentcoreSecret

  • bedrock:InvokeModel and InvokeModelWithResponseStream on *

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

Tests

cd lambdas && uv run pytest tests/unit/test_email_agent_incoming_categorise.py -v

Operating notes

  • The queue and its outputs are commented out in the SAM template’s Outputs section, so the queue URL must be read from the console or from aws sqs get-queue-url --queue-name morpheus-sam-email-agent.

  • EmailAgentQueue is a standard queue, not FIFO — no message groups, no content-based deduplication.

  • Nova rejects Anthropic cache points, which ClaudeAgentService handles by disabling them for non-Anthropic models. See prompt caching.