Raxol.Gateway.Adapter implementation for email, both directions.
Outbound: send_message/3 submits a plain-text message over SMTP, which is
what makes Raxol.Gateway.Delivery's {:home, route} mode useful for cron
and background results without any platform approval process.
Inbound: normalize_event/1 parses a raw RFC822 message into
{:ok, route, %{text: body}} so an email thread becomes a per-chat session
like any other platform. The adapter is a pure translator; the transport that
actually pulls mail off a mailbox lives in Raxol.Gateway.Adapter.Email.Inbox
(sink-agnostic, injectable), and reply threading state lives in
Raxol.Gateway.Adapter.Email.ThreadStore. Neither is bundled here so the
transport (IMAP/POP/Gmail API/SMTP listener) stays the deployment's choice.
Requires the optional :gen_smtp dependency for MIME composition, SMTP
submission (outbound), and parsing (:mimemail, inbound). Without it,
send_message/3 returns {:error, :gen_smtp_not_available} and
normalize_event/1 returns :ignore.
Connection
connect/1 requires :relay (the SMTP server) and :from (the sender
address) and fails fast without them. Everything else is optional:
:port,:username,:password,:ssl,:tls,:tls_options,:auth,:hostname,:timeout,:retries,:no_mx_lookups- passed through to:gen_smtp_clientuntouched:subject- the Subject header for un-threaded sends (default "Raxol Gateway"); a threaded reply derivesRe: <original>instead:thread_lookup- a 1-arity(route -> meta | {:ok, meta} | nil)resolving the inbound message a reply threads against, wheremetais the:emailmapnormalize_event/1surfaced (:message_id,:references,:subject).ThreadStore.thread_lookup_fn/1builds one; absent, replies carry only their own generatedMessage-ID.:message_id_fn- a 0-arity override generating the outboundMessage-ID; the default derives one from the:fromdomain:send_fn- a 2-arity(email_tuple, relay_opts) -> receipt | {:error, ...}override for tests or alternative submission paths; the default calls:gen_smtp_client.send_blocking/2
The connection is stateless; the returned handle is the validated
option list. Routes address a mailbox: chat_id is the address
(platform: :email, chat_type: :dm, chat_id: "ops@example.com").
Inbound
normalize_event/1 accepts a raw RFC822 binary (or %{rfc822: binary}),
parses it with :mimemail.decode, and routes on the normalized sender
address (chat_type: :dm, chat_id = the lower-cased address, display name
and angle brackets stripped). The event is
%{text: body, email: meta} where body is the first text/plain part
with quoted history trimmed and meta carries :message_id, :in_reply_to,
:references, :subject, and :from for threading and session wiring.
A message with no parseable sender or no text/plain part, non-mail input,
or (when :mimemail is absent) any input, returns :ignore. Parsing never
raises: a malformed message is :ignore, not a crash.
Security: the sender address is unauthenticated
The From header is trivially forgeable, and this adapter does NOT verify
SPF/DKIM/DMARC. The route's chat_id/user_id are the parsed sender address,
so any authorization keyed on them (Raxol.Gateway.Pairing.authorize/2,
allowlists) is only as trustworthy as the mail path that produced the message.
Run inbound email behind an MTA that rejects or authenticates spoofed senders,
and treat a raw-inbound feed with no upstream authentication as anonymous. The
agent runs tools and can spend, so a spoofed From reaching a paired session
is a privilege escalation. If you need in-process verification, gate the feed's
:on_message on the parsed Authentication-Results header before routing.
Outbound
The rendered reply becomes the text/plain body (charset utf-8,
quoted-printable), one message per send - email has no chat-style
length limit, so nothing is chunked. Every send carries a generated
Message-ID; when :thread_lookup resolves the inbound message being
replied to, the send also sets In-Reply-To and References (and a
Re:-prefixed Subject) so mail clients keep the conversation together.
Empty or whitespace-only replies are a no-op. Failures emit
[:raxol_gateway, :email_adapter, :error] telemetry with the classified
reason only; the conn (which may carry the SMTP password) is never logged.