EmailAgentQueue events

Queue: morpheus-sam-email-agent — a standard queue, not FIFO. Consumer: email-agent.

Routing key: the event_type SQS message attribute. A record without it logs a warning and is skipped.

Because the queue is standard, there are no message groups, no ordering guarantee, and no MessageDeduplicationId. Producers use publish_to_standard_queue() (lambdas/phone_agent/services/sqs_publisher.py) rather than morpheus_common.publish_event(), which sets FIFO-only parameters.

Email.IncomingCategorise

Model: IncomingCategoriseEvent (lambdas/email_agent/models/incoming_categorise.py). Producer: the Payability web application.

class IncomingCategoriseEvent(BaseModel):
    model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)

    event_type: Literal["Email.IncomingCategorise"] = "Email.IncomingCategorise"
    campaign_id: str                          # required — the campaign's UUID

    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             # correlation only
    email_id: str | None                      # emails row to update alongside

    @model_validator(mode="after")
    def exactly_one_resolver(self): ...

exactly_one_resolver requires exactly one of contact_email or email_campaign_target_id — not both, not neither. A payload with both is rejected at validation, before the handler runs.

Field notes:

Field Notes

campaign_id

The campaign you already have in application context. Cross-checked against the resolved target’s campaignId.

variation_id

Usually omitted. The handler reads the sent template from EmailCampaignTarget.variationId; set this only to override a missing or wrong column (backfill or repair).

email_activity_id

Id of an inbound email activity row already created elsewhere. For log correlation only — it is not used to resolve the target.

email_id

Primary key of the emails row for this message. When set, emails.category is written in the same transaction as the target update.

Tenant scope

Not in the payload. It comes from EmailCampaignTarget.subscriptionId after the handler loads the row. See tenant safety.

Examples

Resolve by email:

{
  "event_type": "Email.IncomingCategorise",
  "campaign_id": "REPLACE_CAMPAIGN_UUID",
  "contact_email": "prospect@example.com",
  "inbound_subject": "Re: Your note",
  "email_id": "REPLACE_EMAIL_UUID",
  "inbound_text": "Yes — can we do a call Tuesday afternoon?"
}

Resolve by target id:

{
  "event_type": "Email.IncomingCategorise",
  "campaign_id": "REPLACE_CAMPAIGN_UUID",
  "email_campaign_target_id": "REPLACE_EMAIL_CAMPAIGN_TARGET_UUID",
  "inbound_subject": "Re: Outreach",
  "inbound_text": "Please remove me from your list.",
  "email_id": "REPLACE_EMAIL_UUID",
  "email_activity_id": null
}

Consumer behaviour and the seven categories: Email engagement.

Resend.InboundEmail

Model: ResendEmailReceivedEvent (lambdas/email_agent/models/events.py). Producer: a Resend webhook relay.

class ResendAttachment(BaseModel):
    model_config = ConfigDict(extra="ignore")
    id: str
    filename: str
    content_type: str
    content_disposition: str = ""
    content_id: str = ""

class ResendEmailReceivedData(BaseModel):
    model_config = ConfigDict(extra="ignore")
    email_id: str
    created_at: str
    from_: str = Field(alias="from")      # "from" is a Python keyword
    to: list[str] = []
    cc: list[str] = []
    bcc: list[str] = []
    message_id: str = ""
    subject: str = ""
    attachments: list[ResendAttachment] = []

class ResendEmailReceivedEvent(BaseModel):
    model_config = ConfigDict(extra="ignore")
    type: str
    created_at: str
    data: ResendEmailReceivedData

extra="ignore" throughout, so Resend can add fields without breaking validation.

The handler currently logs the email id, sender, subject, and attachment count, and does nothing else. Intended behaviour is listed in its module docstring — see email-agent.

Email.SendInternal

Published by phone-agent during stage-1 task escalation. No handler is registered.

{
  "event_type": "Email.SendInternal",
  "subscription_id": "b191a905-5d19-4fa0-941b-d8eb81c0c477",
  "task_id": "…",
  "sales_rep_email": "rep@example.com",
  "sales_rep_name": "Example Rep",
  "contact_id": "…",
  "due_at": "2026-08-19T14:00:00+00:00",
  "notes": "…"
}

The dispatcher logs "No handler registered for SQS event_type" and deletes the message. Stage-1 escalation therefore notifies by Slack DM only. To implement it: add a handler and model, register the key, and the existing producer works unchanged. See escalation ladder.

Local invocation

Sample files under lambdas/email_agent/events/samples/ are the inner SQS message body, not the full Lambda event. Wrap one to invoke locally:

{
  "Records": [
    {
      "messageId": "test-msg-1",
      "eventSource": "aws:sqs",
      "body": "{ …escaped JSON from a sample file… }",
      "messageAttributes": {
        "event_type": { "stringValue": "Email.IncomingCategorise", "dataType": "String" }
      }
    }
  ]
}

Replace every REPLACE_* placeholder before sending to a real queue.

Failure handling

maxReceiveCount: 1 — a handler failure sends the message to morpheus-sam-email-agent-dlq, retained 14 days. Queue VisibilityTimeout is 180 s against the consumer’s 120 s timeout.

Failure Fate

Missing event_type attribute

skipped, deleted

Unregistered event_type

skipped, deleted

Malformed JSON body

DLQ

ValidationError — including both or neither resolver

DLQ

Tenant mismatch, missing row, bad UUID

deleted (the handler logs and returns)

Bedrock error, or the model returning no structured output

DLQ