Raxol.Gateway.Adapter.Email (RaxolGateway v0.2.0)

Copy Markdown View Source

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_client untouched
  • :subject - the Subject header for un-threaded sends (default "Raxol Gateway"); a threaded reply derives Re: <original> instead
  • :thread_lookup - a 1-arity (route -> meta | {:ok, meta} | nil) resolving the inbound message a reply threads against, where meta is the :email map normalize_event/1 surfaced (:message_id, :references, :subject). ThreadStore.thread_lookup_fn/1 builds one; absent, replies carry only their own generated Message-ID.

  • :message_id_fn - a 0-arity override generating the outbound Message-ID; the default derives one from the :from domain
  • :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.