From a114f225c653da589513cf2b61a7bd43bb97ceb4 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 15:20:21 -0800 Subject: [PATCH 1/5] feat: add email messaging adapter and setup docs --- Cargo.lock | 179 ++- Cargo.toml | 6 + docs/content/docs/(configuration)/config.mdx | 25 +- docs/content/docs/(deployment)/roadmap.mdx | 1 - docs/content/docs/(messaging)/email-setup.mdx | 128 ++ docs/content/docs/(messaging)/messaging.mdx | 9 +- docs/content/docs/(messaging)/meta.json | 2 +- interface/src/api/client.ts | 12 + interface/src/components/ChannelEditModal.tsx | 2 +- .../src/components/ChannelSettingCard.tsx | 218 ++- interface/src/routes/Settings.tsx | 2 +- src/api/bindings.rs | 126 ++ src/api/messaging.rs | 76 +- src/config.rs | 200 +++ src/conversation/channels.rs | 20 + src/main.rs | 13 + src/messaging.rs | 3 +- src/messaging/email.rs | 1344 +++++++++++++++++ src/messaging/target.rs | 59 + 19 files changed, 2402 insertions(+), 23 deletions(-) create mode 100644 docs/content/docs/(messaging)/email-setup.mdx create mode 100644 src/messaging/email.rs diff --git a/Cargo.lock b/Cargo.lock index 3de2d4ca5..3afdf1dfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.6" @@ -345,7 +351,7 @@ dependencies = [ "chrono", "comfy-table", "half", - "lexical-core", + "lexical-core 1.0.6", "num-traits", "ryu", ] @@ -409,7 +415,7 @@ dependencies = [ "half", "indexmap 2.13.0", "itoa", - "lexical-core", + "lexical-core 1.0.6", "memchr", "num-traits", "ryu", @@ -636,7 +642,7 @@ dependencies = [ "aligned", "anyhow", "arg_enum_proc_macro", - "arrayvec", + "arrayvec 0.7.6", "log", "num-rational", "num-traits", @@ -654,7 +660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" dependencies = [ "anyhow", - "arrayvec", + "arrayvec 0.7.6", "log", "nom 8.0.0", "num-rational", @@ -667,7 +673,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", ] [[package]] @@ -849,7 +855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.6", "cc", "cfg-if", "constant_time_eq 0.4.2", @@ -943,7 +949,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -983,6 +989,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + [[package]] name = "built" version = "0.8.0" @@ -1135,6 +1147,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chromiumoxide" version = "0.8.0" @@ -1225,6 +1247,16 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2425,7 +2457,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "proc-macro2", "quote", "strsim 0.10.0", @@ -2671,6 +2703,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "emojis" version = "0.8.0" @@ -3594,6 +3642,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -4076,6 +4135,31 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imap" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d" +dependencies = [ + "base64 0.13.1", + "bufstream", + "chrono", + "imap-proto", + "lazy_static", + "native-tls", + "nom 5.1.3", + "regex", +] + +[[package]] +name = "imap-proto" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a6def1d5ac8975d70b3fd101d57953fe3278ef2ee5d7816cba54b1d1dfc22f" +dependencies = [ + "nom 5.1.3", +] + [[package]] name = "imgref" version = "1.12.0" @@ -5002,12 +5086,53 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.2", + "tokio", + "tokio-native-tls", + "url", +] + [[package]] name = "levenshtein_automata" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec 0.5.2", + "bitflags 1.3.2", + "cfg-if", + "ryu", + "static_assertions", +] + [[package]] name = "lexical-core" version = "1.0.6" @@ -5278,6 +5403,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "mailparse" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + [[package]] name = "matchers" version = "0.2.0" @@ -5576,6 +5712,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "lexical-core 0.7.6", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -6712,6 +6859,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -6840,7 +6993,7 @@ dependencies = [ "aligned-vec", "arbitrary", "arg_enum_proc_macro", - "arrayvec", + "arrayvec 0.7.6", "av-scenechange", "av1-grain", "bitstream-io", @@ -7550,7 +7703,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7952,7 +8105,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "async-trait", "base64 0.22.1", "bitflags 2.10.0", @@ -8263,13 +8416,17 @@ dependencies = [ "futures", "hex", "ignore", + "imap", "indoc", "lance-index", "lancedb", + "lettre", "libc", + "mailparse", "mime_guess", "minijinja", "moka", + "native-tls", "notify", "open", "opentelemetry", diff --git a/Cargo.toml b/Cargo.toml index d3c86ab36..b611c57d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,12 @@ teloxide = { version = "0.17", default-features = false, features = ["rustls"] } # Twitch twitch-irc = { version = "5.0", default-features = false, features = ["transport-tcp-rustls-webpki-roots", "refreshing-token-rustls-webpki-roots"] } +# Email +imap = "2.4" +lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } +mailparse = "0.16" +native-tls = "0.2" + # Stream utilities tokio-stream = "0.1" diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 098b3dd12..0c9c9a7a0 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -593,6 +593,29 @@ If a configured timezone is invalid, Spacebot logs a warning and falls back to s | `token` | string | None | Bot token from @BotFather (or `env:VAR_NAME`). Falls back to `TELEGRAM_BOT_TOKEN` env var | | `dm_allowed_users` | string[] | [] | User IDs allowed to DM the bot. Empty = DMs from anyone accepted | +### `[messaging.email]` + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | bool | false | Enable Email adapter | +| `imap_host` | string | None | IMAP host (or `env:VAR_NAME`) | +| `imap_port` | integer | 993 | IMAP port | +| `imap_username` | string | None | IMAP username (or `env:VAR_NAME`) | +| `imap_password` | string | None | IMAP password (or `env:VAR_NAME`) | +| `imap_use_tls` | bool | true | Use direct TLS for IMAP | +| `smtp_host` | string | None | SMTP host (or `env:VAR_NAME`) | +| `smtp_port` | integer | 587 | SMTP port | +| `smtp_username` | string | None | SMTP username (or `env:VAR_NAME`) | +| `smtp_password` | string | None | SMTP password (or `env:VAR_NAME`) | +| `smtp_use_starttls` | bool | true | Use STARTTLS for SMTP | +| `from_address` | string | None | Sender address for outgoing replies (or `env:VAR_NAME`) | +| `from_name` | string | None | Optional sender display name | +| `poll_interval_secs` | integer | 30 | How often to check for new email | +| `folders` | string[] | `["INBOX"]` | IMAP folders to poll | +| `allowed_senders` | string[] | `[]` | Optional allowlist for inbound senders (empty = all) | +| `max_body_bytes` | integer | 262144 | Max inbound body bytes before truncation | +| `max_attachment_bytes` | integer | 10485760 | Max attachment bytes to process metadata for | + ### `[messaging.webhook]` | Key | Type | Default | Description | @@ -608,7 +631,7 @@ Routes platform conversations to agents. Checked in order; first match wins. Unm | Key | Type | Default | Description | |-----|------|---------|-------------| | `agent_id` | string | **required** | Which agent handles matched messages | -| `channel` | string | **required** | Platform name (`discord`, `webhook`) | +| `channel` | string | **required** | Platform name (`discord`, `telegram`, `email`, `webhook`) | | `guild_id` | string | None | Discord guild filter | | `chat_id` | string | None | Telegram chat filter | | `channel_ids` | string[] | [] | Discord channel ID filter (includes threads in those channels) | diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index b4460cd98..7ab637699 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -97,7 +97,6 @@ Per-agent token usage and cost tracking with budget enforcement. Session, daily, ### Additional Channel Adapters -- **Email** — IMAP polling for inbound, SMTP for outbound. Each email thread maps to a conversation. - **WhatsApp** — Meta Cloud API. Hosted instances receive webhooks via the platform proxy. Self-hosted users point the callback URL at their own reverse proxy or Tailscale funnel. - **Matrix** — decentralized chat protocol. Bridges to self-hosted Matrix/Element deployments. - **iMessage** — macOS-only, AppleScript bridge. Personal use on self-hosted Mac instances. diff --git a/docs/content/docs/(messaging)/email-setup.mdx b/docs/content/docs/(messaging)/email-setup.mdx new file mode 100644 index 000000000..1f11efd9c --- /dev/null +++ b/docs/content/docs/(messaging)/email-setup.mdx @@ -0,0 +1,128 @@ +--- +title: Email Setup +description: Connect Spacebot to an inbox with IMAP + SMTP. +--- + +# Email Setup + +Connect Spacebot to any mailbox that supports IMAP and SMTP. Inbound messages are polled over IMAP, and replies are sent over SMTP. + +You need: + +- an IMAP host, username, and password +- an SMTP host, username, and password +- a sender address (`from_address`) for outbound replies + +Most providers require an app password when MFA is enabled. + +## Step 1: Prepare mailbox access + +In your mail provider settings: + +1. Enable IMAP access for the mailbox. +2. Create an app password (recommended) for IMAP/SMTP. +3. Confirm IMAP and SMTP hosts/ports. + +Typical defaults are IMAP `993` (TLS) and SMTP `587` (STARTTLS). + +## Step 2: Add Email credentials to Spacebot + + + + +1. Open **Settings** -> **Messaging Platforms**. +2. Expand the **Email** card. +3. Enter IMAP and SMTP credentials. +4. Set **From Address** (and optional **From Name**). +5. Click **Connect**. + + + + +```toml +[messaging.email] +enabled = true + +imap_host = "imap.example.com" +imap_port = 993 +imap_username = "inbox@example.com" +imap_password = "env:EMAIL_IMAP_PASSWORD" +imap_use_tls = true + +smtp_host = "smtp.example.com" +smtp_port = 587 +smtp_username = "inbox@example.com" +smtp_password = "env:EMAIL_SMTP_PASSWORD" +smtp_use_starttls = true + +from_address = "bot@example.com" +from_name = "Spacebot" + +poll_interval_secs = 30 +folders = ["INBOX"] +allowed_senders = [] +``` + +Credentials support `env:VAR_NAME` references. + + + + +## Step 3: Add a binding + +Add an Email binding to route inbound mail to an agent. + +```toml +[[bindings]] +agent_id = "main" +channel = "email" +``` + +If no Email binding exists, inbound email falls back to your default agent. + +## Thread behavior + +Spacebot keeps one conversation per email thread. It uses `References`, `In-Reply-To`, and `Message-ID` headers to map replies back to the correct conversation. + +Outbound replies include the correct threading headers so responses stay in the same mail thread in clients like Gmail and Outlook. + +## Filtering inbound senders + +Use `allowed_senders` to restrict who can trigger the bot. + +```toml +[messaging.email] +allowed_senders = ["@example.com", "vip@customer.com", "partner.org"] +``` + +- `"@example.com"` allows the whole domain +- `"vip@customer.com"` allows one exact sender +- `"partner.org"` is treated as a domain rule (`@partner.org`) + +## Folders and polling + +Poll multiple folders by setting `folders`: + +```toml +[messaging.email] +folders = ["INBOX", "Support", "Escalations"] +poll_interval_secs = 30 +``` + +Use a longer interval if your provider rate limits IMAP polling. + +## Verify it's working + +1. Send an email to the configured mailbox from an allowed sender. +2. Confirm a new channel appears in Spacebot with a subject-based name. +3. Reply in the same thread and confirm Spacebot replies in-thread. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Adapter won't connect to IMAP | Wrong host/port or IMAP disabled | Recheck provider IMAP settings; verify `imap_host`, `imap_port`, TLS mode | +| SMTP send fails | Bad SMTP credentials or blocked auth | Use provider app password; verify `smtp_host`, `smtp_port`, STARTTLS | +| No inbound messages | Folder not polled or sender blocked | Add folder to `folders`; check `allowed_senders` rules | +| Bot ignores automated mails | Auto-response headers detected | Expected behavior; Spacebot skips auto-generated mail loops | +| Replies create a new thread | Missing/rewritten message headers upstream | Check provider/forwarder preserves `Message-ID`, `In-Reply-To`, `References` | diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index cd4764bca..a8c7ca9a9 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -1,6 +1,6 @@ --- title: Messaging -description: How Spacebot connects to Discord, Slack, Telegram, Twitch, and webhooks. +description: How Spacebot connects to Discord, Slack, Telegram, Twitch, Email, and webhooks. --- # Messaging @@ -15,8 +15,8 @@ Spacebot connects to chat platforms so your agent can talk to people where they | [Slack](/docs/slack-setup) | Supported | Bot token + app token via Socket Mode | | [Telegram](/docs/telegram-setup) | Supported | Bot token via BotFather | | [Twitch](/docs/twitch-setup) | Supported | OAuth token via Twitch IRC | +| [Email](/docs/email-setup) | Supported | IMAP polling + SMTP replies | | Webhook | Supported | HTTP endpoint for programmatic access | -| Email | Coming soon | IMAP/SMTP | | WhatsApp | Coming soon | Meta Cloud API | | Matrix | Coming soon | Decentralized chat protocol | | iMessage | Coming soon | macOS only | @@ -41,7 +41,7 @@ Go to **Settings** → **Bindings** tab to create and manage bindings. Each binding specifies: - Which **agent** handles the messages -- Which **platform** (Discord, Slack, Telegram) +- Which **platform** (Discord, Slack, Telegram, Twitch, Email) - Optionally which **server/workspace/chat** to scope it to - Optionally which **channels** within that server @@ -121,6 +121,7 @@ Each chat context maps to its own Spacebot conversation with isolated history: | Slack | Each channel, each thread, each DM | | Telegram | Each chat (group, DM, or channel) | | Twitch | Each channel | +| Email | Each email thread | | Webhook | Each unique conversation ID in the request | Threads are first-class on Discord and Slack — a thread gets its own conversation, separate from the parent channel. @@ -141,4 +142,4 @@ curl -X POST http://localhost:18789/webhook \ ## Hot Reloading -Changes to bindings and permissions (channel filters, DM allowed users) take effect within a couple seconds — no restart needed. Token changes require a restart, or you can re-save from the dashboard which reconnects automatically. +Changes to bindings and permissions (channel filters, DM allowed users) take effect within a couple seconds — no restart needed. Token and credential changes are applied by reconnecting the adapter. diff --git a/docs/content/docs/(messaging)/meta.json b/docs/content/docs/(messaging)/meta.json index 24c0c1a7b..3e5764b49 100644 --- a/docs/content/docs/(messaging)/meta.json +++ b/docs/content/docs/(messaging)/meta.json @@ -1,4 +1,4 @@ { "title": "Messaging", - "pages": ["messaging", "discord-setup", "slack-setup", "telegram-setup", "twitch-setup"] + "pages": ["messaging", "discord-setup", "slack-setup", "telegram-setup", "twitch-setup", "email-setup"] } diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 1802af304..4dbb23ed6 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -957,6 +957,7 @@ export interface MessagingStatusResponse { telegram: PlatformStatus; webhook: PlatformStatus; twitch: PlatformStatus; + email: PlatformStatus; } export interface BindingInfo { @@ -987,6 +988,17 @@ export interface CreateBindingRequest { discord_token?: string; slack_bot_token?: string; slack_app_token?: string; + telegram_token?: string; + email_imap_host?: string; + email_imap_port?: number; + email_imap_username?: string; + email_imap_password?: string; + email_smtp_host?: string; + email_smtp_port?: number; + email_smtp_username?: string; + email_smtp_password?: string; + email_from_address?: string; + email_from_name?: string; twitch_username?: string; twitch_oauth_token?: string; twitch_client_id?: string; diff --git a/interface/src/components/ChannelEditModal.tsx b/interface/src/components/ChannelEditModal.tsx index e290a321b..e29a72251 100644 --- a/interface/src/components/ChannelEditModal.tsx +++ b/interface/src/components/ChannelEditModal.tsx @@ -18,7 +18,7 @@ import { import {PlatformIcon} from "@/lib/platformIcons"; import {TagInput} from "@/components/TagInput"; -type Platform = "discord" | "slack" | "telegram" | "twitch" | "webhook"; +type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; interface ChannelEditModalProps { platform: Platform; diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index d44b9801f..264febc41 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -22,7 +22,7 @@ import {TagInput} from "@/components/TagInput"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faChevronDown} from "@fortawesome/free-solid-svg-icons"; -type Platform = "discord" | "slack" | "telegram" | "twitch" | "webhook"; +type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; interface ChannelSettingCardProps { platform: Platform; @@ -213,6 +213,39 @@ export function ChannelSettingCard({ twitch_client_secret: credentialInputs.twitch_client_secret?.trim(), twitch_refresh_token: credentialInputs.twitch_refresh_token?.trim(), }; + } else if (platform === "email") { + if ( + !credentialInputs.email_imap_host?.trim() || + !credentialInputs.email_imap_username?.trim() || + !credentialInputs.email_imap_password?.trim() || + !credentialInputs.email_smtp_host?.trim() || + !credentialInputs.email_smtp_username?.trim() || + !credentialInputs.email_smtp_password?.trim() || + !credentialInputs.email_from_address?.trim() + ) + return; + + const parsedImapPort = Number.parseInt( + credentialInputs.email_imap_port?.trim() ?? "", + 10, + ); + const parsedSmtpPort = Number.parseInt( + credentialInputs.email_smtp_port?.trim() ?? "", + 10, + ); + + request.platform_credentials = { + email_imap_host: credentialInputs.email_imap_host.trim(), + email_imap_port: Number.isFinite(parsedImapPort) && parsedImapPort > 0 ? parsedImapPort : undefined, + email_imap_username: credentialInputs.email_imap_username.trim(), + email_imap_password: credentialInputs.email_imap_password.trim(), + email_smtp_host: credentialInputs.email_smtp_host.trim(), + email_smtp_port: Number.isFinite(parsedSmtpPort) && parsedSmtpPort > 0 ? parsedSmtpPort : undefined, + email_smtp_username: credentialInputs.email_smtp_username.trim(), + email_smtp_password: credentialInputs.email_smtp_password.trim(), + email_from_address: credentialInputs.email_from_address.trim(), + email_from_name: credentialInputs.email_from_name?.trim() || undefined, + }; } saveCreds.mutate(request); } @@ -708,6 +741,189 @@ function CredentialsSection({ )} + {platform === "email" && ( + <> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_imap_host: e.target.value, + }) + } + placeholder="imap.example.com" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_imap_port: e.target.value, + }) + } + placeholder="993" + /> +
+
+
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_imap_username: e.target.value, + }) + } + placeholder="inbox@example.com" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_imap_password: e.target.value, + }) + } + placeholder={configured ? "Enter new password to update" : "App password"} + /> +
+
+
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_smtp_host: e.target.value, + }) + } + placeholder="smtp.example.com" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_smtp_port: e.target.value, + }) + } + placeholder="587" + /> +
+
+
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_smtp_username: e.target.value, + }) + } + placeholder="inbox@example.com" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_smtp_password: e.target.value, + }) + } + placeholder={configured ? "Enter new password to update" : "App password"} + onKeyDown={(e) => { + if (e.key === "Enter") onSave(); + }} + /> +
+
+
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_from_address: e.target.value, + }) + } + placeholder="bot@example.com" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + email_from_name: e.target.value, + }) + } + placeholder="Spacebot" + /> +
+
+

+ Use provider app passwords where possible. If ports are omitted, Spacebot defaults to IMAP 993 and SMTP 587. +

+ + )} + {platform === "webhook" && (

Webhook receiver requires no additional credentials. diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 00c6e894f..b0156e7f3 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -796,11 +796,11 @@ function ChannelsSection() { { platform: "slack" as const, name: "Slack", description: "Slack bot integration" }, { platform: "telegram" as const, name: "Telegram", description: "Telegram bot integration" }, { platform: "twitch" as const, name: "Twitch", description: "Twitch chat integration" }, + { platform: "email" as const, name: "Email", description: "IMAP polling for inbound, SMTP for outbound" }, { platform: "webhook" as const, name: "Webhook", description: "HTTP webhook receiver" }, ] as const; const COMING_SOON = [ - { platform: "email", name: "Email", description: "IMAP polling for inbound, SMTP for outbound" }, { platform: "whatsapp", name: "WhatsApp", description: "Meta Cloud API integration" }, { platform: "matrix", name: "Matrix", description: "Decentralized chat protocol" }, { platform: "imessage", name: "iMessage", description: "macOS-only AppleScript bridge" }, diff --git a/src/api/bindings.rs b/src/api/bindings.rs index eeb118a24..dfad82285 100644 --- a/src/api/bindings.rs +++ b/src/api/bindings.rs @@ -61,6 +61,26 @@ pub(super) struct PlatformCredentials { #[serde(default)] telegram_token: Option, #[serde(default)] + email_imap_host: Option, + #[serde(default)] + email_imap_port: Option, + #[serde(default)] + email_imap_username: Option, + #[serde(default)] + email_imap_password: Option, + #[serde(default)] + email_smtp_host: Option, + #[serde(default)] + email_smtp_port: Option, + #[serde(default)] + email_smtp_username: Option, + #[serde(default)] + email_smtp_password: Option, + #[serde(default)] + email_from_address: Option, + #[serde(default)] + email_from_name: Option, + #[serde(default)] twitch_username: Option, #[serde(default)] twitch_oauth_token: Option, @@ -193,6 +213,7 @@ pub(super) async fn create_binding( let mut new_discord_token: Option = None; let mut new_slack_tokens: Option<(String, String)> = None; let mut new_telegram_token: Option = None; + let mut new_email_configured = false; let mut new_twitch_creds: Option<(String, String)> = None; if let Some(credentials) = &request.platform_credentials { @@ -255,6 +276,93 @@ pub(super) async fn create_binding( telegram["token"] = toml_edit::value(token.as_str()); new_telegram_token = Some(token.clone()); } + + let email_imap_host = credentials + .email_imap_host + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_imap_username = credentials + .email_imap_username + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_imap_password = credentials + .email_imap_password + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_smtp_host = credentials + .email_smtp_host + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_smtp_username = credentials + .email_smtp_username + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_smtp_password = credentials + .email_smtp_password + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let email_from_address = credentials + .email_from_address + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + + if !email_imap_host.is_empty() + && !email_imap_username.is_empty() + && !email_imap_password.is_empty() + && !email_smtp_host.is_empty() + && !email_smtp_username.is_empty() + && !email_smtp_password.is_empty() + && !email_from_address.is_empty() + { + if doc.get("messaging").is_none() { + doc["messaging"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + let messaging = doc["messaging"] + .as_table_mut() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + if !messaging.contains_key("email") { + messaging["email"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + let email = messaging["email"] + .as_table_mut() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + email["enabled"] = toml_edit::value(true); + email["imap_host"] = toml_edit::value(email_imap_host); + email["imap_port"] = + toml_edit::value(i64::from(credentials.email_imap_port.unwrap_or(993))); + email["imap_username"] = toml_edit::value(email_imap_username); + email["imap_password"] = toml_edit::value(email_imap_password); + email["smtp_host"] = toml_edit::value(email_smtp_host); + email["smtp_port"] = + toml_edit::value(i64::from(credentials.email_smtp_port.unwrap_or(587))); + email["smtp_username"] = toml_edit::value(email_smtp_username); + email["smtp_password"] = toml_edit::value(email_smtp_password); + email["from_address"] = toml_edit::value(email_from_address); + + if let Some(from_name) = &credentials.email_from_name { + let from_name = from_name.trim(); + if !from_name.is_empty() { + email["from_name"] = toml_edit::value(from_name); + } + } + + new_email_configured = true; + } + if let Some(username) = &credentials.twitch_username { let oauth_token = credentials.twitch_oauth_token.as_deref().unwrap_or(""); let client_id = credentials.twitch_client_id.as_deref().unwrap_or(""); @@ -466,6 +574,24 @@ pub(super) async fn create_binding( } } + if new_email_configured { + let Some(email_config) = new_config.messaging.email.as_ref() else { + tracing::error!("email config missing despite credentials being provided"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + match crate::messaging::email::EmailAdapter::from_config(email_config) { + Ok(adapter) => { + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, "failed to hot-start email adapter"); + } + } + Err(error) => { + tracing::error!(%error, "failed to build email adapter"); + } + } + } + if let Some((username, oauth_token)) = new_twitch_creds { let Some(twitch_config) = new_config.messaging.twitch.as_ref() else { tracing::error!("twitch config missing despite credentials being provided"); diff --git a/src/api/messaging.rs b/src/api/messaging.rs index d0dd973aa..4dc4afede 100644 --- a/src/api/messaging.rs +++ b/src/api/messaging.rs @@ -17,6 +17,7 @@ pub(super) struct MessagingStatusResponse { discord: PlatformStatus, slack: PlatformStatus, telegram: PlatformStatus, + email: PlatformStatus, webhook: PlatformStatus, twitch: PlatformStatus, } @@ -38,7 +39,7 @@ pub(super) async fn messaging_status( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); - let (discord, slack, telegram, webhook, twitch) = if config_path.exists() { + let (discord, slack, telegram, email, webhook, twitch) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await .map_err(|error| { @@ -107,6 +108,62 @@ pub(super) async fn messaging_status( enabled: false, }); + let email_status = doc + .get("messaging") + .and_then(|m| m.get("email")) + .map(|email| { + let has_imap_host = email + .get("imap_host") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_imap_username = email + .get("imap_username") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_imap_password = email + .get("imap_password") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_smtp_host = email + .get("smtp_host") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_smtp_username = email + .get("smtp_username") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_smtp_password = email + .get("smtp_password") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_from_address = email + .get("from_address") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + + let configured = has_imap_host + && has_imap_username + && has_imap_password + && has_smtp_host + && has_smtp_username + && has_smtp_password + && has_from_address; + + let enabled = email + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + PlatformStatus { + configured, + enabled: configured && enabled, + } + }) + .unwrap_or(PlatformStatus { + configured: false, + enabled: false, + }); + let telegram_status = doc .get("messaging") .and_then(|m| m.get("telegram")) @@ -153,6 +210,7 @@ pub(super) async fn messaging_status( discord_status, slack_status, telegram_status, + email_status, webhook_status, twitch_status, ) @@ -166,6 +224,7 @@ pub(super) async fn messaging_status( default.clone(), default.clone(), default.clone(), + default.clone(), default, ) }; @@ -174,6 +233,7 @@ pub(super) async fn messaging_status( discord, slack, telegram, + email, webhook, twitch, })) @@ -381,6 +441,20 @@ pub(super) async fn toggle_platform( } } } + "email" => { + if let Some(email_config) = &new_config.messaging.email { + match crate::messaging::email::EmailAdapter::from_config(email_config) { + Ok(adapter) => { + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, "failed to start email adapter on toggle"); + } + } + Err(error) => { + tracing::error!(%error, "failed to build email adapter on toggle"); + } + } + } + } "webhook" => { if let Some(webhook_config) = &new_config.messaging.webhook { let adapter = crate::messaging::webhook::WebhookAdapter::new( diff --git a/src/config.rs b/src/config.rs index badc1b522..be28b32e9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1281,6 +1281,7 @@ pub struct MessagingConfig { pub discord: Option, pub slack: Option, pub telegram: Option, + pub email: Option, pub webhook: Option, pub twitch: Option, } @@ -1501,6 +1502,53 @@ impl std::fmt::Debug for TelegramConfig { } } +#[derive(Clone)] +pub struct EmailConfig { + pub enabled: bool, + pub imap_host: String, + pub imap_port: u16, + pub imap_username: String, + pub imap_password: String, + pub imap_use_tls: bool, + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_username: String, + pub smtp_password: String, + pub smtp_use_starttls: bool, + pub from_address: String, + pub from_name: Option, + pub poll_interval_secs: u64, + pub folders: Vec, + pub allowed_senders: Vec, + pub max_body_bytes: usize, + pub max_attachment_bytes: usize, +} + +impl std::fmt::Debug for EmailConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailConfig") + .field("enabled", &self.enabled) + .field("imap_host", &self.imap_host) + .field("imap_port", &self.imap_port) + .field("imap_username", &self.imap_username) + .field("imap_password", &"[REDACTED]") + .field("imap_use_tls", &self.imap_use_tls) + .field("smtp_host", &self.smtp_host) + .field("smtp_port", &self.smtp_port) + .field("smtp_username", &self.smtp_username) + .field("smtp_password", &"[REDACTED]") + .field("smtp_use_starttls", &self.smtp_use_starttls) + .field("from_address", &self.from_address) + .field("from_name", &self.from_name) + .field("poll_interval_secs", &self.poll_interval_secs) + .field("folders", &self.folders) + .field("allowed_senders", &self.allowed_senders) + .field("max_body_bytes", &self.max_body_bytes) + .field("max_attachment_bytes", &self.max_attachment_bytes) + .finish() + } +} + /// Hot-reloadable Telegram permission filters. /// /// Shared with the Telegram adapter via `Arc>` for hot-reloading. @@ -2088,6 +2136,7 @@ struct TomlMessagingConfig { discord: Option, slack: Option, telegram: Option, + email: Option, webhook: Option, twitch: Option, } @@ -2131,6 +2180,38 @@ struct TomlTelegramConfig { dm_allowed_users: Vec, } +#[derive(Deserialize)] +struct TomlEmailConfig { + #[serde(default)] + enabled: bool, + imap_host: Option, + #[serde(default = "default_email_imap_port")] + imap_port: u16, + imap_username: Option, + imap_password: Option, + #[serde(default = "default_email_imap_use_tls")] + imap_use_tls: bool, + smtp_host: Option, + #[serde(default = "default_email_smtp_port")] + smtp_port: u16, + smtp_username: Option, + smtp_password: Option, + #[serde(default = "default_email_smtp_use_starttls")] + smtp_use_starttls: bool, + from_address: Option, + from_name: Option, + #[serde(default = "default_email_poll_interval_secs")] + poll_interval_secs: u64, + #[serde(default = "default_email_folders")] + folders: Vec, + #[serde(default)] + allowed_senders: Vec, + #[serde(default = "default_email_max_body_bytes")] + max_body_bytes: usize, + #[serde(default = "default_email_max_attachment_bytes")] + max_attachment_bytes: usize, +} + #[derive(Deserialize)] struct TomlWebhookConfig { #[serde(default)] @@ -2163,6 +2244,38 @@ fn default_webhook_bind() -> String { "127.0.0.1".into() } +fn default_email_imap_port() -> u16 { + 993 +} + +fn default_email_imap_use_tls() -> bool { + true +} + +fn default_email_smtp_port() -> u16 { + 587 +} + +fn default_email_smtp_use_starttls() -> bool { + true +} + +fn default_email_poll_interval_secs() -> u64 { + 30 +} + +fn default_email_folders() -> Vec { + vec!["INBOX".to_string()] +} + +fn default_email_max_body_bytes() -> usize { + 256 * 1024 +} + +fn default_email_max_attachment_bytes() -> usize { + 10 * 1024 * 1024 +} + #[derive(Deserialize)] struct TomlBinding { agent_id: String, @@ -3792,6 +3905,78 @@ impl Config { dm_allowed_users: t.dm_allowed_users, }) }), + email: toml.messaging.email.and_then(|email| { + let imap_host = email + .imap_host + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_IMAP_HOST").ok())?; + let imap_username = email + .imap_username + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_IMAP_USERNAME").ok())?; + let imap_password = email + .imap_password + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_IMAP_PASSWORD").ok())?; + + let smtp_host = email + .smtp_host + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_SMTP_HOST").ok())?; + let smtp_username = email + .smtp_username + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_SMTP_USERNAME").ok()) + .unwrap_or_else(|| imap_username.clone()); + let smtp_password = email + .smtp_password + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_SMTP_PASSWORD").ok()) + .unwrap_or_else(|| imap_password.clone()); + + let from_address = email + .from_address + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_FROM_ADDRESS").ok()) + .unwrap_or_else(|| smtp_username.clone()); + let from_name = email + .from_name + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("EMAIL_FROM_NAME").ok()); + + Some(EmailConfig { + enabled: email.enabled, + imap_host, + imap_port: email.imap_port, + imap_username, + imap_password, + imap_use_tls: email.imap_use_tls, + smtp_host, + smtp_port: email.smtp_port, + smtp_username, + smtp_password, + smtp_use_starttls: email.smtp_use_starttls, + from_address, + from_name, + poll_interval_secs: email.poll_interval_secs, + folders: if email.folders.is_empty() { + vec!["INBOX".to_string()] + } else { + email.folders + }, + allowed_senders: email.allowed_senders, + max_body_bytes: email.max_body_bytes, + max_attachment_bytes: email.max_attachment_bytes, + }) + }), webhook: toml.messaging.webhook.map(|w| WebhookConfig { enabled: w.enabled, port: w.port, @@ -4479,6 +4664,21 @@ pub fn spawn_file_watcher( } } + // Email: start if enabled and not already running + if let Some(email_config) = &config.messaging.email + && email_config.enabled && !manager.has_adapter("email").await { + match crate::messaging::email::EmailAdapter::from_config(email_config) { + Ok(adapter) => { + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, "failed to hot-start email adapter from config change"); + } + } + Err(error) => { + tracing::error!(%error, "failed to build email adapter from config change"); + } + } + } + // Twitch: start if enabled and not already running if let Some(twitch_config) = &config.messaging.twitch && twitch_config.enabled && !manager.has_adapter("twitch").await { diff --git a/src/conversation/channels.rs b/src/conversation/channels.rs index 8bf7534bd..f1ada6c12 100644 --- a/src/conversation/channels.rs +++ b/src/conversation/channels.rs @@ -240,6 +240,10 @@ fn extract_display_name( .get("display_name") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + "email" => metadata + .get("email_subject") + .and_then(|v| v.as_str()) + .map(|subject| format!("Email: {subject}")), "portal" => Some("portal:chat".to_string()), _ => None, } @@ -285,6 +289,22 @@ fn extract_platform_meta( meta.insert("twitch_channel".to_string(), value.clone()); } } + "email" => { + for key in [ + "email_from", + "email_reply_to", + "email_to", + "email_subject", + "email_message_id", + "email_in_reply_to", + "email_references", + "email_thread_key", + ] { + if let Some(value) = metadata.get(key) { + meta.insert(key.to_string(), value.clone()); + } + } + } _ => {} } diff --git a/src/main.rs b/src/main.rs index 4a3066d16..55eaba035 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1509,6 +1509,19 @@ async fn initialize_agents( new_messaging_manager.register(adapter).await; } + if let Some(email_config) = &config.messaging.email + && email_config.enabled + { + match spacebot::messaging::email::EmailAdapter::from_config(email_config) { + Ok(adapter) => { + new_messaging_manager.register(adapter).await; + } + Err(error) => { + tracing::error!(%error, "failed to build email adapter"); + } + } + } + if let Some(webhook_config) = &config.messaging.webhook && webhook_config.enabled { diff --git a/src/messaging.rs b/src/messaging.rs index 87ca5e796..63d2eea6d 100644 --- a/src/messaging.rs +++ b/src/messaging.rs @@ -1,6 +1,7 @@ -//! Messaging adapters (Discord, Slack, Telegram, Twitch, Webhook, WebChat). +//! Messaging adapters (Discord, Slack, Telegram, Twitch, Email, Webhook, WebChat). pub mod discord; +pub mod email; pub mod manager; pub mod slack; pub mod target; diff --git a/src/messaging/email.rs b/src/messaging/email.rs new file mode 100644 index 000000000..757bee5fe --- /dev/null +++ b/src/messaging/email.rs @@ -0,0 +1,1344 @@ +//! Email messaging adapter using IMAP polling and SMTP delivery. + +use crate::config::EmailConfig; +use crate::messaging::traits::{HistoryMessage, InboundStream, Messaging}; +use crate::{InboundMessage, MessageContent, OutboundResponse}; + +use anyhow::Context as _; +use chrono::{TimeZone as _, Utc}; +use lettre::message::header::ContentType; +use lettre::message::{Attachment as EmailAttachment, Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use mailparse::{DispositionType, MailAddr, MailHeaderMap}; +use regex::Regex; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tokio::sync::{RwLock, mpsc, watch}; +use tokio::task::JoinHandle; + +const EMAIL_MAX_RETRY_BACKOFF_SECS: u64 = 300; + +type ImapSession = imap::Session>; + +#[derive(Clone)] +struct EmailPollConfig { + imap_host: String, + imap_port: u16, + imap_username: String, + imap_password: String, + imap_use_tls: bool, + from_address: String, + smtp_username: String, + folders: Vec, + poll_interval: Duration, + allowed_senders: Vec, + max_body_bytes: usize, +} + +struct HistoryEntry { + timestamp: chrono::DateTime, + message: HistoryMessage, +} + +/// Email adapter state. +pub struct EmailAdapter { + imap_host: String, + imap_port: u16, + imap_username: String, + imap_password: String, + imap_use_tls: bool, + smtp_host: String, + smtp_port: u16, + smtp_username: String, + smtp_use_starttls: bool, + from_address: String, + from_name: Option, + folders: Vec, + poll_interval: Duration, + allowed_senders: Vec, + max_body_bytes: usize, + max_attachment_bytes: usize, + smtp_transport: AsyncSmtpTransport, + shutdown_tx: Arc>>>, + poll_task: Arc>>>, +} + +impl std::fmt::Debug for EmailAdapter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailAdapter") + .field("imap_host", &self.imap_host) + .field("imap_port", &self.imap_port) + .field("imap_username", &self.imap_username) + .field("imap_password", &"[REDACTED]") + .field("imap_use_tls", &self.imap_use_tls) + .field("smtp_host", &self.smtp_host) + .field("smtp_port", &self.smtp_port) + .field("smtp_username", &self.smtp_username) + .field("smtp_use_starttls", &self.smtp_use_starttls) + .field("from_address", &self.from_address) + .field("from_name", &self.from_name) + .field("folders", &self.folders) + .field("poll_interval", &self.poll_interval) + .field("allowed_senders", &self.allowed_senders) + .field("max_body_bytes", &self.max_body_bytes) + .field("max_attachment_bytes", &self.max_attachment_bytes) + .finish() + } +} + +impl EmailAdapter { + pub fn from_config(config: &EmailConfig) -> crate::Result { + let folders = config + .folders + .iter() + .map(|folder| folder.trim().to_string()) + .filter(|folder| !folder.is_empty()) + .collect::>(); + + let folders = if folders.is_empty() { + vec!["INBOX".to_string()] + } else { + folders + }; + + let smtp_transport = build_smtp_transport(config)?; + + Ok(Self { + imap_host: config.imap_host.clone(), + imap_port: config.imap_port, + imap_username: config.imap_username.clone(), + imap_password: config.imap_password.clone(), + imap_use_tls: config.imap_use_tls, + smtp_host: config.smtp_host.clone(), + smtp_port: config.smtp_port, + smtp_username: config.smtp_username.clone(), + smtp_use_starttls: config.smtp_use_starttls, + from_address: config.from_address.clone(), + from_name: config.from_name.clone(), + folders, + poll_interval: Duration::from_secs(config.poll_interval_secs.max(5)), + allowed_senders: config.allowed_senders.clone(), + max_body_bytes: config.max_body_bytes.max(1024), + max_attachment_bytes: config.max_attachment_bytes.max(1024), + smtp_transport, + shutdown_tx: Arc::new(RwLock::new(None)), + poll_task: Arc::new(RwLock::new(None)), + }) + } + + fn poll_config(&self) -> EmailPollConfig { + EmailPollConfig { + imap_host: self.imap_host.clone(), + imap_port: self.imap_port, + imap_username: self.imap_username.clone(), + imap_password: self.imap_password.clone(), + imap_use_tls: self.imap_use_tls, + from_address: self.from_address.clone(), + smtp_username: self.smtp_username.clone(), + folders: self.folders.clone(), + poll_interval: self.poll_interval, + allowed_senders: self.allowed_senders.clone(), + max_body_bytes: self.max_body_bytes, + } + } + + fn from_mailbox(&self) -> crate::Result { + let from_address: Address = self + .from_address + .parse() + .with_context(|| format!("invalid email from_address '{}'", self.from_address))?; + Ok(Mailbox::new(self.from_name.clone(), from_address)) + } + + async fn send_email( + &self, + recipient: &str, + subject: &str, + body: String, + in_reply_to: Option, + references: Vec, + attachment: Option<(String, Vec, String)>, + ) -> crate::Result<()> { + let recipient_mailbox = parse_mailbox(recipient) + .with_context(|| format!("invalid recipient address '{recipient}'"))?; + + let mut builder = Message::builder() + .from(self.from_mailbox()?) + .to(recipient_mailbox) + .subject(subject.to_string()); + + if let Some(in_reply_to) = in_reply_to { + let in_reply_to = format_message_id_for_header(&in_reply_to); + if !in_reply_to.is_empty() { + builder = builder.in_reply_to(in_reply_to); + } + } + + for reference in references { + let reference = format_message_id_for_header(&reference); + if !reference.is_empty() { + builder = builder.references(reference); + } + } + + let message = if let Some((filename, data, mime_type)) = attachment { + let content_type = ContentType::parse(&mime_type).unwrap_or(ContentType::TEXT_PLAIN); + let attachment = EmailAttachment::new(filename).body(data, content_type); + let multipart = MultiPart::mixed() + .singlepart(SinglePart::plain(body)) + .singlepart(attachment); + builder + .multipart(multipart) + .context("failed to build multipart email")? + } else { + builder.body(body).context("failed to build email body")? + }; + + self.smtp_transport + .send(message) + .await + .context("failed to send email")?; + + Ok(()) + } +} + +impl Messaging for EmailAdapter { + fn name(&self) -> &str { + "email" + } + + async fn start(&self) -> crate::Result { + if self.poll_task.read().await.is_some() { + return Err(anyhow::anyhow!("email adapter already started").into()); + } + + let (inbound_tx, inbound_rx) = mpsc::channel(256); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + + *self.shutdown_tx.write().await = Some(shutdown_tx); + + let poll_config = self.poll_config(); + + let poll_task = tokio::spawn(async move { + let mut retry_backoff = Duration::from_secs(5); + + loop { + if *shutdown_rx.borrow() { + break; + } + + let config = poll_config.clone(); + let poll_result = + tokio::task::spawn_blocking(move || poll_inbox_once(&config)).await; + + let mut had_error = false; + + match poll_result { + Ok(Ok(messages)) => { + retry_backoff = Duration::from_secs(5); + for message in messages { + if inbound_tx.send(message).await.is_err() { + tracing::warn!( + "email inbound channel closed, stopping adapter loop" + ); + return; + } + } + } + Ok(Err(error)) => { + had_error = true; + tracing::warn!(%error, "email poll cycle failed"); + } + Err(error) => { + had_error = true; + tracing::warn!(%error, "email poll task panicked"); + } + } + + let sleep_duration = if had_error { + let current = retry_backoff; + retry_backoff = + (retry_backoff * 2).min(Duration::from_secs(EMAIL_MAX_RETRY_BACKOFF_SECS)); + current + } else { + poll_config.poll_interval + }; + + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + _ = tokio::time::sleep(sleep_duration) => {} + } + } + + tracing::info!("email adapter loop stopped"); + }); + + *self.poll_task.write().await = Some(poll_task); + + let stream = tokio_stream::wrappers::ReceiverStream::new(inbound_rx); + Ok(Box::pin(stream)) + } + + async fn respond( + &self, + message: &InboundMessage, + response: OutboundResponse, + ) -> crate::Result<()> { + let mut context = reply_context_from_message(message)?; + + match response { + OutboundResponse::Text(text) => { + self.send_email( + &context.recipient, + &context.subject, + text, + context.in_reply_to, + context.references, + None, + ) + .await?; + } + OutboundResponse::RichMessage { text, .. } => { + self.send_email( + &context.recipient, + &context.subject, + text, + context.in_reply_to, + context.references, + None, + ) + .await?; + } + OutboundResponse::ThreadReply { thread_name, text } => { + if !thread_name.trim().is_empty() { + context.subject = normalize_reply_subject(&thread_name); + } + self.send_email( + &context.recipient, + &context.subject, + text, + context.in_reply_to, + context.references, + None, + ) + .await?; + } + OutboundResponse::File { + filename, + data, + mime_type, + caption, + } => { + let mut body = caption.unwrap_or_else(|| format!("Attached file: {filename}")); + if body.trim().is_empty() { + body = format!("Attached file: {filename}"); + } + + self.send_email( + &context.recipient, + &context.subject, + body, + context.in_reply_to, + context.references, + Some((filename, data, mime_type)), + ) + .await?; + } + OutboundResponse::Reaction(_) + | OutboundResponse::RemoveReaction(_) + | OutboundResponse::Status(_) => {} + OutboundResponse::Ephemeral { text, .. } => { + self.send_email( + &context.recipient, + &context.subject, + text, + context.in_reply_to, + context.references, + None, + ) + .await?; + } + OutboundResponse::ScheduledMessage { text, .. } => { + self.send_email( + &context.recipient, + &context.subject, + text, + context.in_reply_to, + context.references, + None, + ) + .await?; + } + OutboundResponse::StreamStart + | OutboundResponse::StreamChunk(_) + | OutboundResponse::StreamEnd => {} + } + + Ok(()) + } + + async fn broadcast(&self, target: &str, response: OutboundResponse) -> crate::Result<()> { + let recipient = normalize_email_target(target) + .ok_or_else(|| anyhow::anyhow!("invalid email target '{target}'"))?; + + match response { + OutboundResponse::Text(text) => { + self.send_email(&recipient, "Spacebot message", text, None, Vec::new(), None) + .await?; + } + OutboundResponse::RichMessage { text, .. } => { + self.send_email(&recipient, "Spacebot message", text, None, Vec::new(), None) + .await?; + } + OutboundResponse::File { + filename, + data, + mime_type, + caption, + } => { + let body = caption.unwrap_or_else(|| format!("Attached file: {filename}")); + self.send_email( + &recipient, + "Spacebot message", + body, + None, + Vec::new(), + Some((filename, data, mime_type)), + ) + .await?; + } + OutboundResponse::ThreadReply { text, .. } + | OutboundResponse::Ephemeral { text, .. } + | OutboundResponse::ScheduledMessage { text, .. } => { + self.send_email(&recipient, "Spacebot message", text, None, Vec::new(), None) + .await?; + } + OutboundResponse::Reaction(_) + | OutboundResponse::RemoveReaction(_) + | OutboundResponse::Status(_) + | OutboundResponse::StreamStart + | OutboundResponse::StreamChunk(_) + | OutboundResponse::StreamEnd => {} + } + + Ok(()) + } + + async fn fetch_history( + &self, + message: &InboundMessage, + limit: usize, + ) -> crate::Result> { + if limit == 0 { + return Ok(Vec::new()); + } + + let references = message + .metadata + .get("email_references") + .and_then(json_value_to_string) + .map(|value| extract_message_ids(&value)) + .unwrap_or_default(); + + let in_reply_to = message + .metadata + .get("email_in_reply_to") + .and_then(json_value_to_string) + .and_then(|value| extract_message_ids(&value).into_iter().next()); + + let mut message_ids = references; + if let Some(in_reply_to) = in_reply_to + && !message_ids.contains(&in_reply_to) + { + message_ids.push(in_reply_to); + } + + let current_message_id = message + .metadata + .get("email_message_id") + .and_then(json_value_to_string) + .map(|value| normalize_message_id(&value)); + + message_ids.retain(|message_id| { + current_message_id + .as_ref() + .is_none_or(|current| current != message_id) + }); + + if message_ids.is_empty() { + return Ok(Vec::new()); + } + + let poll_config = self.poll_config(); + + let history = tokio::task::spawn_blocking(move || { + fetch_history_from_imap(&poll_config, message_ids, limit) + }) + .await + .context("email history task failed")??; + + Ok(history) + } + + async fn health_check(&self) -> crate::Result<()> { + let poll_config = self.poll_config(); + tokio::task::spawn_blocking(move || { + let mut session = open_imap_session(&poll_config)?; + let folder = poll_config + .folders + .first() + .cloned() + .unwrap_or_else(|| "INBOX".to_string()); + session + .select(&folder) + .with_context(|| format!("failed to select IMAP folder '{folder}'"))?; + session.logout().ok(); + anyhow::Ok(()) + }) + .await + .context("email IMAP health check task failed")??; + + let smtp_ok = self + .smtp_transport + .test_connection() + .await + .context("SMTP health check failed")?; + if !smtp_ok { + return Err(anyhow::anyhow!("SMTP server rejected test connection").into()); + } + + Ok(()) + } + + async fn shutdown(&self) -> crate::Result<()> { + if let Some(shutdown_tx) = self.shutdown_tx.write().await.take() { + let _ = shutdown_tx.send(true); + } + + if let Some(poll_task) = self.poll_task.write().await.take() + && let Err(error) = poll_task.await + { + tracing::warn!(%error, "email poll task join failed during shutdown"); + } + + self.smtp_transport.shutdown().await; + + tracing::info!("email adapter shut down"); + Ok(()) + } +} + +fn build_smtp_transport(config: &EmailConfig) -> crate::Result> { + let builder = if config.smtp_use_starttls { + AsyncSmtpTransport::::starttls_relay(&config.smtp_host) + .with_context(|| format!("invalid SMTP host '{}'", config.smtp_host))? + } else { + AsyncSmtpTransport::::builder_dangerous(&config.smtp_host) + }; + + Ok(builder + .port(config.smtp_port) + .credentials(Credentials::new( + config.smtp_username.clone(), + config.smtp_password.clone(), + )) + .build()) +} + +fn poll_inbox_once(config: &EmailPollConfig) -> anyhow::Result> { + let mut session = open_imap_session(config)?; + let mut inbound_messages = Vec::new(); + + for folder in &config.folders { + if let Err(error) = session.select(folder) { + tracing::warn!(folder, %error, "failed to select IMAP folder"); + continue; + } + + let message_uids = session + .uid_search("UNSEEN") + .with_context(|| format!("failed to search unseen messages in folder '{folder}'"))?; + + for uid in message_uids { + let uid_sequence = uid.to_string(); + + let fetches = match session.uid_fetch(&uid_sequence, "(UID RFC822)") { + Ok(fetches) => fetches, + Err(error) => { + tracing::warn!(folder, uid, %error, "failed to fetch unseen email"); + continue; + } + }; + + for fetch in &fetches { + let Some(raw_email) = fetch.body() else { + continue; + }; + + let current_uid = fetch.uid.unwrap_or(uid); + match parse_inbound_email(raw_email, folder, current_uid, config) { + Ok(Some(inbound_message)) => inbound_messages.push(inbound_message), + Ok(None) => {} + Err(error) => { + tracing::warn!(folder, uid = current_uid, %error, "failed to parse inbound email"); + } + } + } + + if let Err(error) = session.uid_store(&uid_sequence, "+FLAGS (\\Seen)") { + tracing::warn!(folder, uid, %error, "failed to mark email as seen"); + } + } + } + + session.logout().ok(); + + Ok(inbound_messages) +} + +fn open_imap_session(config: &EmailPollConfig) -> anyhow::Result { + let tls = native_tls::TlsConnector::builder() + .build() + .context("failed to build TLS connector for IMAP")?; + + let client = if config.imap_use_tls { + imap::connect( + (config.imap_host.as_str(), config.imap_port), + config.imap_host.as_str(), + &tls, + ) + .with_context(|| { + format!( + "failed to connect to IMAP server '{}:{}'", + config.imap_host, config.imap_port + ) + })? + } else { + imap::connect_starttls( + (config.imap_host.as_str(), config.imap_port), + config.imap_host.as_str(), + &tls, + ) + .with_context(|| { + format!( + "failed to connect to IMAP server '{}:{}' with STARTTLS", + config.imap_host, config.imap_port + ) + })? + }; + + let session = client + .login(config.imap_username.as_str(), config.imap_password.as_str()) + .map_err(|error| anyhow::anyhow!(error.0)) + .context("failed to authenticate to IMAP server")?; + + Ok(session) +} + +fn parse_inbound_email( + raw_email: &[u8], + folder: &str, + uid: u32, + config: &EmailPollConfig, +) -> anyhow::Result> { + let parsed = mailparse::parse_mail(raw_email).context("failed to parse MIME email")?; + let headers = parsed.headers.as_slice(); + + if is_auto_generated_email(headers) { + return Ok(None); + } + + let from_header = headers.get_first_value("From").unwrap_or_default(); + let Some((sender_email, sender_name)) = parse_primary_mailbox(&from_header) else { + return Ok(None); + }; + + if is_own_sender(&sender_email, config) { + return Ok(None); + } + + if !is_allowed_sender(&sender_email, &config.allowed_senders) { + return Ok(None); + } + + let reply_to_email = headers + .get_first_value("Reply-To") + .and_then(|value| parse_primary_mailbox(&value).map(|(address, _)| address)) + .unwrap_or_else(|| sender_email.clone()); + + let to_header = headers.get_first_value("To"); + let subject = headers + .get_first_value("Subject") + .unwrap_or_else(|| "(no subject)".to_string()); + + let message_id = headers + .get_first_value("Message-ID") + .map(|value| normalize_message_id(&value)) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| format!("generated-{}-{}", uid, uuid::Uuid::new_v4())); + + let in_reply_to = headers + .get_first_value("In-Reply-To") + .and_then(|value| extract_message_ids(&value).into_iter().next()); + + let references = headers + .get_first_value("References") + .map(|value| extract_message_ids(&value)) + .unwrap_or_default(); + + let thread_key = derive_thread_key( + &references, + in_reply_to.as_deref(), + Some(message_id.as_str()), + &subject, + &sender_email, + ); + + let account_key = sanitize_account_key(&config.from_address); + let conversation_id = format!("email:{account_key}:{thread_key}"); + + let (mut body_text, attachment_names) = + extract_text_and_attachments(&parsed, config.max_body_bytes); + if !attachment_names.is_empty() { + body_text.push_str("\n\nAttachments: "); + body_text.push_str(&attachment_names.join(", ")); + } + + let timestamp = headers + .get_first_value("Date") + .and_then(|value| mailparse::dateparse(&value).ok()) + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) + .unwrap_or_else(Utc::now); + + let mut metadata = HashMap::new(); + metadata.insert( + "email_from".into(), + serde_json::Value::String(sender_email.clone()), + ); + metadata.insert( + "email_reply_to".into(), + serde_json::Value::String(reply_to_email), + ); + if let Some(to_header) = to_header { + metadata.insert("email_to".into(), serde_json::Value::String(to_header)); + } + metadata.insert( + "email_subject".into(), + serde_json::Value::String(subject.clone()), + ); + metadata.insert( + "email_message_id".into(), + serde_json::Value::String(message_id.clone()), + ); + if let Some(in_reply_to) = in_reply_to { + metadata.insert( + "email_in_reply_to".into(), + serde_json::Value::String(in_reply_to), + ); + } + if !references.is_empty() { + metadata.insert( + "email_references".into(), + serde_json::Value::String(references.join(" ")), + ); + } + metadata.insert( + "email_folder".into(), + serde_json::Value::String(folder.to_string()), + ); + metadata.insert( + "email_uid".into(), + serde_json::Value::Number(serde_json::Number::from(uid)), + ); + metadata.insert( + "email_thread_key".into(), + serde_json::Value::String(thread_key), + ); + metadata.insert( + "sender_display_name".into(), + serde_json::Value::String(sender_name.clone().unwrap_or_else(|| sender_email.clone())), + ); + + let formatted_author = sender_name.map_or_else( + || sender_email.clone(), + |name| format!("{name} <{sender_email}>"), + ); + + Ok(Some(InboundMessage { + id: message_id, + source: "email".into(), + conversation_id, + sender_id: sender_email, + agent_id: None, + content: MessageContent::Text(body_text), + timestamp, + metadata, + formatted_author: Some(formatted_author), + })) +} + +fn reply_context_from_message(message: &InboundMessage) -> anyhow::Result { + let recipient = message + .metadata + .get("email_reply_to") + .and_then(json_value_to_string) + .or_else(|| { + message + .metadata + .get("email_from") + .and_then(json_value_to_string) + }) + .context("missing recipient metadata for email reply")?; + + let subject = message + .metadata + .get("email_subject") + .and_then(json_value_to_string) + .map(|value| normalize_reply_subject(&value)) + .unwrap_or_else(|| "Re: Spacebot reply".to_string()); + + let in_reply_to = message + .metadata + .get("email_message_id") + .and_then(json_value_to_string) + .map(|value| normalize_message_id(&value)) + .filter(|value| !value.is_empty()); + + let mut references = message + .metadata + .get("email_references") + .and_then(json_value_to_string) + .map(|value| extract_message_ids(&value)) + .unwrap_or_default(); + + if let Some(in_reply_to) = &in_reply_to + && !references.contains(in_reply_to) + { + references.push(in_reply_to.clone()); + } + + Ok(EmailReplyContext { + recipient, + subject, + in_reply_to, + references, + }) +} + +fn fetch_history_from_imap( + config: &EmailPollConfig, + message_ids: Vec, + limit: usize, +) -> anyhow::Result> { + let mut session = open_imap_session(config)?; + let mut seen_message_ids = HashSet::new(); + let mut entries = Vec::new(); + + for folder in &config.folders { + if entries.len() >= limit { + break; + } + + if let Err(error) = session.select(folder) { + tracing::warn!(folder, %error, "failed to select IMAP folder for history backfill"); + continue; + } + + for message_id in &message_ids { + if entries.len() >= limit { + break; + } + + let search_id = format_message_id_for_header(message_id); + if search_id.is_empty() { + continue; + } + + let criterion = format!("HEADER Message-ID \"{search_id}\""); + let uids = match session.uid_search(&criterion) { + Ok(uids) => uids, + Err(error) => { + tracing::warn!(folder, message_id, %error, "failed IMAP history search"); + continue; + } + }; + + for uid in uids { + if entries.len() >= limit { + break; + } + + let fetches = match session.uid_fetch(uid.to_string(), "(UID RFC822)") { + Ok(fetches) => fetches, + Err(error) => { + tracing::warn!(folder, uid, %error, "failed IMAP history fetch"); + continue; + } + }; + + for fetch in &fetches { + let Some(raw_email) = fetch.body() else { + continue; + }; + + let parsed = match mailparse::parse_mail(raw_email) { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!(folder, uid, %error, "failed to parse history email MIME"); + continue; + } + }; + + let headers = parsed.headers.as_slice(); + let normalized_message_id = headers + .get_first_value("Message-ID") + .map(|value| normalize_message_id(&value)) + .filter(|value| !value.is_empty()); + + let Some(normalized_message_id) = normalized_message_id else { + continue; + }; + + if !seen_message_ids.insert(normalized_message_id) { + continue; + } + + let from_header = headers.get_first_value("From").unwrap_or_default(); + let (sender_email, sender_name) = parse_primary_mailbox(&from_header) + .unwrap_or_else(|| (from_header.clone(), None)); + + let is_bot = sender_email.eq_ignore_ascii_case(&config.from_address) + || sender_email.eq_ignore_ascii_case(&config.smtp_username); + + let author = sender_name.unwrap_or(sender_email); + let (body, _) = extract_text_and_attachments(&parsed, config.max_body_bytes); + + let timestamp = headers + .get_first_value("Date") + .and_then(|value| mailparse::dateparse(&value).ok()) + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) + .unwrap_or_else(Utc::now); + + entries.push(HistoryEntry { + timestamp, + message: HistoryMessage { + author, + content: body, + is_bot, + }, + }); + } + } + } + } + + session.logout().ok(); + + entries.sort_by_key(|entry| entry.timestamp); + entries.truncate(limit); + + Ok(entries.into_iter().map(|entry| entry.message).collect()) +} + +fn is_auto_generated_email(headers: &[mailparse::MailHeader<'_>]) -> bool { + let auto_submitted = headers + .get_first_value("Auto-Submitted") + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if !auto_submitted.is_empty() && auto_submitted != "no" { + return true; + } + + let precedence = headers + .get_first_value("Precedence") + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if matches!(precedence.as_str(), "bulk" | "junk" | "list" | "auto_reply") { + return true; + } + + headers.get_first_value("X-Autoreply").is_some() + || headers.get_first_value("X-Autorespond").is_some() +} + +fn is_own_sender(sender_email: &str, config: &EmailPollConfig) -> bool { + sender_email.eq_ignore_ascii_case(&config.from_address) + || sender_email.eq_ignore_ascii_case(&config.imap_username) + || sender_email.eq_ignore_ascii_case(&config.smtp_username) +} + +fn is_allowed_sender(sender_email: &str, allowed_senders: &[String]) -> bool { + if allowed_senders.is_empty() { + return true; + } + + let sender_email = sender_email.trim().to_ascii_lowercase(); + + allowed_senders.iter().any(|rule| { + let rule = rule.trim().to_ascii_lowercase(); + if rule.is_empty() { + return false; + } + + if rule.starts_with('@') { + return sender_email.ends_with(&rule); + } + + if rule.contains('@') { + return sender_email == rule; + } + + sender_email.ends_with(&format!("@{rule}")) + }) +} + +fn parse_primary_mailbox(value: &str) -> Option<(String, Option)> { + let addresses = mailparse::addrparse(value).ok()?.into_inner(); + for address in addresses { + match address { + MailAddr::Single(single) => { + return Some((single.addr, single.display_name)); + } + MailAddr::Group(group) => { + if let Some(single) = group.addrs.into_iter().next() { + return Some((single.addr, single.display_name)); + } + } + } + } + None +} + +fn parse_mailbox(value: &str) -> anyhow::Result { + if let Ok(mailbox) = value.parse::() { + return Ok(mailbox); + } + + let (address, display_name) = parse_primary_mailbox(value) + .with_context(|| format!("failed to parse email address '{value}'"))?; + let address: Address = address + .parse() + .with_context(|| format!("invalid email address '{address}'"))?; + Ok(Mailbox::new(display_name, address)) +} + +fn extract_text_and_attachments( + parsed: &mailparse::ParsedMail<'_>, + max_body_bytes: usize, +) -> (String, Vec) { + let mut plain_text_parts = Vec::new(); + let mut html_parts = Vec::new(); + let mut attachment_names = Vec::new(); + + collect_parts( + parsed, + &mut plain_text_parts, + &mut html_parts, + &mut attachment_names, + ); + + let mut body_text = if !plain_text_parts.is_empty() { + plain_text_parts.join("\n\n") + } else if !html_parts.is_empty() { + html_to_text(&html_parts.join("\n\n")) + } else { + parsed.get_body().unwrap_or_default() + }; + + body_text = body_text.replace("\r\n", "\n").trim().to_string(); + if body_text.is_empty() { + body_text = "(No message body)".to_string(); + } + + if body_text.len() > max_body_bytes { + body_text = format!( + "{}\n\n[Message truncated due to size limit]", + truncate_to_bytes(&body_text, max_body_bytes) + ); + } + + attachment_names.sort(); + attachment_names.dedup(); + + (body_text, attachment_names) +} + +fn collect_parts( + part: &mailparse::ParsedMail<'_>, + plain_text_parts: &mut Vec, + html_parts: &mut Vec, + attachment_names: &mut Vec, +) { + if part.subparts.is_empty() { + let disposition = part.get_content_disposition(); + let filename = disposition + .params + .get("filename") + .cloned() + .or_else(|| part.ctype.params.get("name").cloned()); + let is_attachment = + matches!(disposition.disposition, DispositionType::Attachment) || filename.is_some(); + + if let Some(filename) = filename { + attachment_names.push(filename); + } + + if is_attachment { + return; + } + + let mime_type = part.ctype.mimetype.to_ascii_lowercase(); + if mime_type.starts_with("text/plain") { + if let Ok(body) = part.get_body() + && !body.trim().is_empty() + { + plain_text_parts.push(body); + } + } else if mime_type.starts_with("text/html") { + if let Ok(body) = part.get_body() + && !body.trim().is_empty() + { + html_parts.push(body); + } + } + return; + } + + for subpart in &part.subparts { + collect_parts(subpart, plain_text_parts, html_parts, attachment_names); + } +} + +fn html_to_text(html: &str) -> String { + let without_tags = html_tag_regex().replace_all(html, " "); + let decoded = without_tags + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'"); + + decoded.split_whitespace().collect::>().join(" ") +} + +fn html_tag_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("valid HTML tag regex")) +} + +fn normalize_reply_subject(subject: &str) -> String { + let subject = subject.trim(); + if subject.is_empty() { + return "Re: Spacebot reply".to_string(); + } + + if subject.to_ascii_lowercase().starts_with("re:") { + subject.to_string() + } else { + format!("Re: {subject}") + } +} + +fn extract_message_ids(value: &str) -> Vec { + mailparse::msgidparse(value) + .map(|ids| { + ids.iter() + .map(|id| normalize_message_id(id.as_str())) + .filter(|id| !id.is_empty()) + .collect::>() + }) + .unwrap_or_default() +} + +fn normalize_message_id(value: &str) -> String { + value + .trim() + .trim_start_matches('<') + .trim_end_matches('>') + .trim() + .to_string() +} + +fn format_message_id_for_header(message_id: &str) -> String { + let message_id = normalize_message_id(message_id); + if message_id.is_empty() { + String::new() + } else { + format!("<{message_id}>") + } +} + +fn derive_thread_key( + references: &[String], + in_reply_to: Option<&str>, + message_id: Option<&str>, + subject: &str, + sender_email: &str, +) -> String { + let seed = references + .first() + .cloned() + .or_else(|| in_reply_to.map(normalize_message_id)) + .or_else(|| message_id.map(normalize_message_id)) + .unwrap_or_else(|| { + format!( + "{}:{}", + subject.trim().to_ascii_lowercase(), + sender_email.trim().to_ascii_lowercase() + ) + }); + + let mut hasher = Sha256::new(); + hasher.update(seed.as_bytes()); + let digest = hasher.finalize(); + hex::encode(digest)[..24].to_string() +} + +fn sanitize_account_key(value: &str) -> String { + let mut result = String::new(); + for character in value.trim().to_ascii_lowercase().chars() { + if character.is_ascii_alphanumeric() { + result.push(character); + } else { + result.push('_'); + } + } + + let result = result.trim_matches('_').to_string(); + if result.is_empty() { + "default".to_string() + } else { + result + } +} + +fn normalize_email_target(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Some((address, _)) = parse_primary_mailbox(value) { + return Some(address); + } + + let value = value.strip_prefix("email:").unwrap_or(value).trim(); + if value.contains('@') && !value.contains(char::is_whitespace) { + Some(value.to_string()) + } else { + None + } +} + +fn truncate_to_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + + let mut cutoff = max_bytes; + while cutoff > 0 && !value.is_char_boundary(cutoff) { + cutoff -= 1; + } + + let mut truncated = value[..cutoff].to_string(); + truncated.push_str("..."); + truncated +} + +fn json_value_to_string(value: &serde_json::Value) -> Option { + if let Some(text) = value.as_str() { + return Some(text.to_string()); + } + if let Some(number) = value.as_i64() { + return Some(number.to_string()); + } + if let Some(number) = value.as_u64() { + return Some(number.to_string()); + } + None +} + +struct EmailReplyContext { + recipient: String, + subject: String, + in_reply_to: Option, + references: Vec, +} + +#[cfg(test)] +mod tests { + use super::{ + derive_thread_key, extract_message_ids, normalize_email_target, normalize_reply_subject, + parse_primary_mailbox, + }; + + #[test] + fn parse_primary_mailbox_parses_display_name() { + let parsed = parse_primary_mailbox("Alice Example "); + assert_eq!( + parsed, + Some(( + "alice@example.com".to_string(), + Some("Alice Example".to_string()) + )) + ); + } + + #[test] + fn extract_message_ids_strips_angle_brackets() { + let ids = extract_message_ids(" "); + assert_eq!(ids, vec!["root@example.com", "child@example.com"]); + } + + #[test] + fn normalize_email_target_accepts_prefixed_target() { + assert_eq!( + normalize_email_target("email:alice@example.com"), + Some("alice@example.com".to_string()) + ); + } + + #[test] + fn normalize_reply_subject_preserves_existing_prefix() { + assert_eq!( + normalize_reply_subject("Re: Existing thread"), + "Re: Existing thread" + ); + assert_eq!( + normalize_reply_subject("Existing thread"), + "Re: Existing thread" + ); + } + + #[test] + fn derive_thread_key_prefers_root_reference() { + let from_references = derive_thread_key( + &[ + "root@example.com".to_string(), + "child@example.com".to_string(), + ], + Some("reply@example.com"), + Some("current@example.com"), + "Subject", + "sender@example.com", + ); + let from_root_only = derive_thread_key( + &["root@example.com".to_string()], + None, + None, + "Different subject", + "other@example.com", + ); + + assert_eq!(from_references, from_root_only); + } +} diff --git a/src/messaging/target.rs b/src/messaging/target.rs index c60e2d0a4..5baa8a3f4 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -101,6 +101,20 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option { + let reply_to = channel + .platform_meta + .as_ref() + .and_then(|meta| meta.get("email_reply_to")) + .and_then(json_value_to_string); + let from = channel + .platform_meta + .as_ref() + .and_then(|meta| meta.get("email_from")) + .and_then(json_value_to_string); + + reply_to.or(from)? + } _ => return None, }; @@ -123,6 +137,7 @@ fn normalize_target(adapter: &str, raw_target: &str) -> Option { "slack" => normalize_slack_target(trimmed), "telegram" => normalize_telegram_target(trimmed), "twitch" => normalize_twitch_target(trimmed), + "email" => normalize_email_target(trimmed), _ => Some(trimmed.to_string()), } } @@ -195,6 +210,26 @@ fn normalize_twitch_target(raw_target: &str) -> Option { } } +fn normalize_email_target(raw_target: &str) -> Option { + let target = strip_repeated_prefix(raw_target, "email").trim(); + if target.is_empty() { + return None; + } + + if let Some((_, address)) = target.rsplit_once('<') { + let address = address.trim_end_matches('>').trim(); + if address.contains('@') && !address.contains(char::is_whitespace) { + return Some(address.to_string()); + } + } + + if target.contains('@') && !target.contains(char::is_whitespace) { + return Some(target.to_string()); + } + + None +} + fn strip_repeated_prefix<'a>(raw_target: &'a str, adapter: &str) -> &'a str { let mut target = raw_target; let prefix = format!("{adapter}:"); @@ -283,4 +318,28 @@ mod tests { }) ); } + + #[test] + fn parse_email_target_with_prefix() { + let parsed = parse_delivery_target("email:alice@example.com"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "email".to_string(), + target: "alice@example.com".to_string(), + }) + ); + } + + #[test] + fn parse_email_target_with_display_name() { + let parsed = parse_delivery_target("email:Alice "); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "email".to_string(), + target: "alice@example.com".to_string(), + }) + ); + } } From 12682079328370f8717e0f4c521330273b884a56 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 15:34:33 -0800 Subject: [PATCH 2/5] fix: address clippy warnings in email adapter --- src/messaging/email.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/messaging/email.rs b/src/messaging/email.rs index 757bee5fe..51a688c87 100644 --- a/src/messaging/email.rs +++ b/src/messaging/email.rs @@ -145,7 +145,7 @@ impl EmailAdapter { } } - fn from_mailbox(&self) -> crate::Result { + fn sender_mailbox(&self) -> crate::Result { let from_address: Address = self .from_address .parse() @@ -166,7 +166,7 @@ impl EmailAdapter { .with_context(|| format!("invalid recipient address '{recipient}'"))?; let mut builder = Message::builder() - .from(self.from_mailbox()?) + .from(self.sender_mailbox()?) .to(recipient_mailbox) .subject(subject.to_string()); @@ -1102,12 +1102,11 @@ fn collect_parts( { plain_text_parts.push(body); } - } else if mime_type.starts_with("text/html") { - if let Ok(body) = part.get_body() - && !body.trim().is_empty() - { - html_parts.push(body); - } + } else if mime_type.starts_with("text/html") + && let Ok(body) = part.get_body() + && !body.trim().is_empty() + { + html_parts.push(body); } return; } From 3022a8f8e8af98925495e802d894f19adbc9665c Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 15:50:11 -0800 Subject: [PATCH 3/5] fix: harden email adapter validation and delivery safety --- docs/content/docs/(configuration)/config.mdx | 2 +- .../src/components/ChannelSettingCard.tsx | 42 ++++++--- src/api/bindings.rs | 2 - src/api/messaging.rs | 21 +---- src/config.rs | 72 ++++++---------- src/messaging/email.rs | 85 +++++++++++++++---- src/messaging/target.rs | 24 +++++- 7 files changed, 151 insertions(+), 97 deletions(-) diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 0c9c9a7a0..140b1fd2d 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -631,7 +631,7 @@ Routes platform conversations to agents. Checked in order; first match wins. Unm | Key | Type | Default | Description | |-----|------|---------|-------------| | `agent_id` | string | **required** | Which agent handles matched messages | -| `channel` | string | **required** | Platform name (`discord`, `telegram`, `email`, `webhook`) | +| `channel` | string | **required** | Platform name (`discord`, `slack`, `telegram`, `twitch`, `email`, `webhook`) | | `guild_id` | string | None | Discord guild filter | | `chat_id` | string | None | Telegram chat filter | | `channel_ids` | string[] | [] | Discord channel ID filter (includes threads in those channels) | diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index 264febc41..55dae6906 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -217,32 +217,46 @@ export function ChannelSettingCard({ if ( !credentialInputs.email_imap_host?.trim() || !credentialInputs.email_imap_username?.trim() || - !credentialInputs.email_imap_password?.trim() || + !credentialInputs.email_imap_password || !credentialInputs.email_smtp_host?.trim() || !credentialInputs.email_smtp_username?.trim() || - !credentialInputs.email_smtp_password?.trim() || + !credentialInputs.email_smtp_password || !credentialInputs.email_from_address?.trim() ) return; - const parsedImapPort = Number.parseInt( - credentialInputs.email_imap_port?.trim() ?? "", - 10, - ); - const parsedSmtpPort = Number.parseInt( - credentialInputs.email_smtp_port?.trim() ?? "", - 10, - ); + const parsePort = (rawPort?: string): number | undefined => { + const value = rawPort?.trim(); + if (!value) return undefined; + if (!/^\d+$/.test(value)) return Number.NaN; + + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) + return Number.NaN; + + return port; + }; + + const parsedImapPort = parsePort(credentialInputs.email_imap_port); + const parsedSmtpPort = parsePort(credentialInputs.email_smtp_port); + + if (Number.isNaN(parsedImapPort) || Number.isNaN(parsedSmtpPort)) { + setMessage({ + text: "Ports must be integers between 1 and 65535.", + type: "error", + }); + return; + } request.platform_credentials = { email_imap_host: credentialInputs.email_imap_host.trim(), - email_imap_port: Number.isFinite(parsedImapPort) && parsedImapPort > 0 ? parsedImapPort : undefined, + email_imap_port: parsedImapPort, email_imap_username: credentialInputs.email_imap_username.trim(), - email_imap_password: credentialInputs.email_imap_password.trim(), + email_imap_password: credentialInputs.email_imap_password, email_smtp_host: credentialInputs.email_smtp_host.trim(), - email_smtp_port: Number.isFinite(parsedSmtpPort) && parsedSmtpPort > 0 ? parsedSmtpPort : undefined, + email_smtp_port: parsedSmtpPort, email_smtp_username: credentialInputs.email_smtp_username.trim(), - email_smtp_password: credentialInputs.email_smtp_password.trim(), + email_smtp_password: credentialInputs.email_smtp_password, email_from_address: credentialInputs.email_from_address.trim(), email_from_name: credentialInputs.email_from_name?.trim() || undefined, }; diff --git a/src/api/bindings.rs b/src/api/bindings.rs index dfad82285..55ec0d735 100644 --- a/src/api/bindings.rs +++ b/src/api/bindings.rs @@ -293,7 +293,6 @@ pub(super) async fn create_binding( .email_imap_password .as_deref() .unwrap_or("") - .trim() .to_string(); let email_smtp_host = credentials .email_smtp_host @@ -311,7 +310,6 @@ pub(super) async fn create_binding( .email_smtp_password .as_deref() .unwrap_or("") - .trim() .to_string(); let email_from_address = credentials .email_from_address diff --git a/src/api/messaging.rs b/src/api/messaging.rs index 4dc4afede..d1ac6b02b 100644 --- a/src/api/messaging.rs +++ b/src/api/messaging.rs @@ -128,26 +128,9 @@ pub(super) async fn messaging_status( .get("smtp_host") .and_then(|v| v.as_str()) .is_some_and(|s| !s.is_empty()); - let has_smtp_username = email - .get("smtp_username") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()); - let has_smtp_password = email - .get("smtp_password") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()); - let has_from_address = email - .get("from_address") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()); - let configured = has_imap_host - && has_imap_username - && has_imap_password - && has_smtp_host - && has_smtp_username - && has_smtp_password - && has_from_address; + let configured = + has_imap_host && has_imap_username && has_imap_password && has_smtp_host; let enabled = email .get("enabled") diff --git a/src/config.rs b/src/config.rs index be28b32e9..216a2f9a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1530,19 +1530,19 @@ impl std::fmt::Debug for EmailConfig { .field("enabled", &self.enabled) .field("imap_host", &self.imap_host) .field("imap_port", &self.imap_port) - .field("imap_username", &self.imap_username) + .field("imap_username", &"[REDACTED]") .field("imap_password", &"[REDACTED]") .field("imap_use_tls", &self.imap_use_tls) .field("smtp_host", &self.smtp_host) .field("smtp_port", &self.smtp_port) - .field("smtp_username", &self.smtp_username) + .field("smtp_username", &"[REDACTED]") .field("smtp_password", &"[REDACTED]") .field("smtp_use_starttls", &self.smtp_use_starttls) - .field("from_address", &self.from_address) + .field("from_address", &"[REDACTED]") .field("from_name", &self.from_name) .field("poll_interval_secs", &self.poll_interval_secs) .field("folders", &self.folders) - .field("allowed_senders", &self.allowed_senders) + .field("allowed_senders", &"[REDACTED]") .field("max_body_bytes", &self.max_body_bytes) .field("max_attachment_bytes", &self.max_attachment_bytes) .finish() @@ -3906,51 +3906,35 @@ impl Config { }) }), email: toml.messaging.email.and_then(|email| { - let imap_host = email - .imap_host - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_IMAP_HOST").ok())?; - let imap_username = email - .imap_username - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_IMAP_USERNAME").ok())?; - let imap_password = email - .imap_password - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_IMAP_PASSWORD").ok())?; + let imap_host = std::env::var("EMAIL_IMAP_HOST") + .ok() + .or_else(|| email.imap_host.as_deref().and_then(resolve_env_value))?; + let imap_username = std::env::var("EMAIL_IMAP_USERNAME") + .ok() + .or_else(|| email.imap_username.as_deref().and_then(resolve_env_value))?; + let imap_password = std::env::var("EMAIL_IMAP_PASSWORD") + .ok() + .or_else(|| email.imap_password.as_deref().and_then(resolve_env_value))?; - let smtp_host = email - .smtp_host - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_SMTP_HOST").ok())?; - let smtp_username = email - .smtp_username - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_SMTP_USERNAME").ok()) + let smtp_host = std::env::var("EMAIL_SMTP_HOST") + .ok() + .or_else(|| email.smtp_host.as_deref().and_then(resolve_env_value))?; + let smtp_username = std::env::var("EMAIL_SMTP_USERNAME") + .ok() + .or_else(|| email.smtp_username.as_deref().and_then(resolve_env_value)) .unwrap_or_else(|| imap_username.clone()); - let smtp_password = email - .smtp_password - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_SMTP_PASSWORD").ok()) + let smtp_password = std::env::var("EMAIL_SMTP_PASSWORD") + .ok() + .or_else(|| email.smtp_password.as_deref().and_then(resolve_env_value)) .unwrap_or_else(|| imap_password.clone()); - let from_address = email - .from_address - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_FROM_ADDRESS").ok()) + let from_address = std::env::var("EMAIL_FROM_ADDRESS") + .ok() + .or_else(|| email.from_address.as_deref().and_then(resolve_env_value)) .unwrap_or_else(|| smtp_username.clone()); - let from_name = email - .from_name - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("EMAIL_FROM_NAME").ok()); + let from_name = std::env::var("EMAIL_FROM_NAME") + .ok() + .or_else(|| email.from_name.as_deref().and_then(resolve_env_value)); Some(EmailConfig { enabled: email.enabled, diff --git a/src/messaging/email.rs b/src/messaging/email.rs index 51a688c87..a1a274901 100644 --- a/src/messaging/email.rs +++ b/src/messaging/email.rs @@ -71,18 +71,18 @@ impl std::fmt::Debug for EmailAdapter { f.debug_struct("EmailAdapter") .field("imap_host", &self.imap_host) .field("imap_port", &self.imap_port) - .field("imap_username", &self.imap_username) + .field("imap_username", &"[REDACTED]") .field("imap_password", &"[REDACTED]") .field("imap_use_tls", &self.imap_use_tls) .field("smtp_host", &self.smtp_host) .field("smtp_port", &self.smtp_port) - .field("smtp_username", &self.smtp_username) + .field("smtp_username", &"[REDACTED]") .field("smtp_use_starttls", &self.smtp_use_starttls) - .field("from_address", &self.from_address) + .field("from_address", &"[REDACTED]") .field("from_name", &self.from_name) .field("folders", &self.folders) .field("poll_interval", &self.poll_interval) - .field("allowed_senders", &self.allowed_senders) + .field("allowed_senders", &"[REDACTED]") .field("max_body_bytes", &self.max_body_bytes) .field("max_attachment_bytes", &self.max_attachment_bytes) .finish() @@ -185,6 +185,15 @@ impl EmailAdapter { } let message = if let Some((filename, data, mime_type)) = attachment { + if data.len() > self.max_attachment_bytes { + return Err(anyhow::anyhow!( + "attachment '{filename}' exceeds max_attachment_bytes ({} > {})", + data.len(), + self.max_attachment_bytes + ) + .into()); + } + let content_type = ContentType::parse(&mime_type).unwrap_or(ContentType::TEXT_PLAIN); let attachment = EmailAttachment::new(filename).body(data, content_type); let multipart = MultiPart::mixed() @@ -366,7 +375,13 @@ impl Messaging for EmailAdapter { ) .await?; } - OutboundResponse::ScheduledMessage { text, .. } => { + OutboundResponse::ScheduledMessage { text, post_at } => { + tracing::warn!( + post_at, + recipient = %context.recipient, + subject = %context.subject, + "email adapter does not support scheduled delivery; sending immediately" + ); self.send_email( &context.recipient, &context.subject, @@ -416,8 +431,16 @@ impl Messaging for EmailAdapter { .await?; } OutboundResponse::ThreadReply { text, .. } - | OutboundResponse::Ephemeral { text, .. } - | OutboundResponse::ScheduledMessage { text, .. } => { + | OutboundResponse::Ephemeral { text, .. } => { + self.send_email(&recipient, "Spacebot message", text, None, Vec::new(), None) + .await?; + } + OutboundResponse::ScheduledMessage { text, post_at } => { + tracing::warn!( + post_at, + recipient = %recipient, + "email adapter does not support scheduled delivery; sending immediately" + ); self.send_email(&recipient, "Spacebot message", text, None, Vec::new(), None) .await?; } @@ -520,7 +543,7 @@ impl Messaging for EmailAdapter { async fn shutdown(&self) -> crate::Result<()> { if let Some(shutdown_tx) = self.shutdown_tx.write().await.take() { - let _ = shutdown_tx.send(true); + shutdown_tx.send(true).ok(); } if let Some(poll_task) = self.poll_task.write().await.take() @@ -541,7 +564,8 @@ fn build_smtp_transport(config: &EmailConfig) -> crate::Result::starttls_relay(&config.smtp_host) .with_context(|| format!("invalid SMTP host '{}'", config.smtp_host))? } else { - AsyncSmtpTransport::::builder_dangerous(&config.smtp_host) + AsyncSmtpTransport::::relay(&config.smtp_host) + .with_context(|| format!("invalid SMTP host '{}'", config.smtp_host))? }; Ok(builder @@ -578,23 +602,36 @@ fn poll_inbox_once(config: &EmailPollConfig) -> anyhow::Result inbound_messages.push(inbound_message), Ok(None) => {} Err(error) => { + should_mark_seen = false; tracing::warn!(folder, uid = current_uid, %error, "failed to parse inbound email"); } } } - if let Err(error) = session.uid_store(&uid_sequence, "+FLAGS (\\Seen)") { - tracing::warn!(folder, uid, %error, "failed to mark email as seen"); + if should_mark_seen { + if let Err(error) = session.uid_store(&uid_sequence, "+FLAGS (\\Seen)") { + tracing::warn!(folder, uid, %error, "failed to mark email as seen"); + } + } else { + tracing::debug!(folder, uid, "leaving email unseen for retry"); } } } @@ -857,12 +894,14 @@ fn fetch_history_from_imap( break; } - let search_id = format_message_id_for_header(message_id); - if search_id.is_empty() { + let Some(criterion) = build_message_id_search_criterion(message_id) else { + tracing::debug!( + message_id, + "skipping unsafe message id for IMAP history search" + ); continue; - } + }; - let criterion = format!("HEADER Message-ID \"{search_id}\""); let uids = match session.uid_search(&criterion) { Ok(uids) => uids, Err(error) => { @@ -1176,6 +1215,20 @@ fn format_message_id_for_header(message_id: &str) -> String { } } +fn build_message_id_search_criterion(message_id: &str) -> Option { + let search_id = format_message_id_for_header(message_id); + if search_id.is_empty() + || search_id + .chars() + .any(|character| character == '\r' || character == '\n') + { + return None; + } + + let escaped = search_id.replace('\\', "\\\\").replace('"', "\\\""); + Some(format!("HEADER Message-ID \"{escaped}\"")) +} + fn derive_thread_key( references: &[String], in_reply_to: Option<&str>, diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 5baa8a3f4..68aba7831 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -113,7 +113,10 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option return None, }; @@ -342,4 +345,23 @@ mod tests { }) ); } + + #[test] + fn resolve_email_target_falls_back_when_reply_to_invalid() { + let mut channel = test_channel_info("email:acct:thread", "email"); + channel.platform_meta = Some(serde_json::json!({ + "email_reply_to": "not-an-email", + "email_from": "valid@example.com" + })); + + let resolved = resolve_broadcast_target(&channel); + + assert_eq!( + resolved, + Some(super::BroadcastTarget { + adapter: "email".to_string(), + target: "valid@example.com".to_string(), + }) + ); + } } From 6d25a94d18ca8ce41b7438785e2216402fda830b Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 16:14:41 -0800 Subject: [PATCH 4/5] feat: add adapter-aware inbound-only email channel behavior --- docs/content/docs/(messaging)/email-setup.mdx | 10 +++ docs/content/docs/(messaging)/messaging.mdx | 4 +- prompts/en/adapters/email.md.j2 | 11 ++++ prompts/en/channel.md.j2 | 6 ++ src/agent/channel.rs | 65 ++++++++++++++++++- src/prompts/engine.rs | 22 +++++++ src/prompts/text.rs | 3 + src/tools.rs | 40 +++++++----- src/tools/reply.rs | 10 ++- tests/context_dump.rs | 2 + 10 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 prompts/en/adapters/email.md.j2 diff --git a/docs/content/docs/(messaging)/email-setup.mdx b/docs/content/docs/(messaging)/email-setup.mdx index 1f11efd9c..621a25521 100644 --- a/docs/content/docs/(messaging)/email-setup.mdx +++ b/docs/content/docs/(messaging)/email-setup.mdx @@ -80,6 +80,16 @@ channel = "email" If no Email binding exists, inbound email falls back to your default agent. +## Email intake behavior + +Email channels are treated as intake streams, not always-on chat threads. + +- Spacebot triages inbound mail first. +- Inbound email channels are reply-disabled by default (no automatic outbound email response). +- For non-spam mail, it is encouraged to persist key memory (sender, subject, commitments, deadlines, urgency). +- For urgent mail, it can escalate to another active channel using cross-channel messaging. +- If you need to send an email intentionally, initiate it from another channel using cross-channel tooling. + ## Thread behavior Spacebot keeps one conversation per email thread. It uses `References`, `In-Reply-To`, and `Message-ID` headers to map replies back to the correct conversation. diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index a8c7ca9a9..0da705458 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -25,11 +25,13 @@ Spacebot connects to chat platforms so your agent can talk to people where they 1. You connect a platform by adding your tokens in the dashboard or config 2. Spacebot opens a persistent connection to that platform -3. When someone sends a message, Spacebot receives it, thinks, and replies +3. When someone sends a message, Spacebot receives it and decides whether to reply, skip, or delegate work 4. Each conversation (channel, thread, DM) gets its own isolated history You can connect multiple platforms at the same time. An agent on Discord and Slack simultaneously is just two bindings pointing at the same agent. +For Email specifically, Spacebot treats inbound mail as intake-first by default: triage, memory capture for meaningful non-spam messages, and escalation to other channels for urgent items. Inbound email channels do not auto-reply. + ## Bindings Bindings route messages from a platform to a specific agent. A binding says "messages from this place go to this agent." diff --git a/prompts/en/adapters/email.md.j2 b/prompts/en/adapters/email.md.j2 new file mode 100644 index 000000000..fe0a6353d --- /dev/null +++ b/prompts/en/adapters/email.md.j2 @@ -0,0 +1,11 @@ +You are processing inbound email, not a live chat. + +- Default behavior is intake + triage, not immediate reply. +- Do NOT send an outbound email reply from this channel. Email channels are inbound-only. +- For every non-spam email, branch and persist useful memory: sender, subject, key facts, commitments, deadlines, and urgency. +- If the email is spam, automated junk, or irrelevant noise, skip it and do not persist memory. +- If the email is urgent or time-sensitive for the user, use `send_message_to_another_channel` to notify them in an active channel. +- If the user wants an actual outbound email sent, that should be initiated intentionally from another channel. +- If there are actionable follow-ups, create or update tasks through a branch. + +Think of this channel as an intake processor that captures signal and escalates when needed. diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index 62843da3a..894f33ec4 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -106,6 +106,12 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be 10. One worker per task. Never spawn multiple workers for the same request. If a worker is already handling something, wait for it to finish or route follow-ups to it. Check your status block before spawning. 11. On Discord and Slack, prefer rich responses when output is structured or multi-part (task outcomes, summaries, comparisons, checklists, incident/debug updates, plans). Use `reply` with `cards`/interactive elements (Discord) or `blocks` (Slack) instead of plain text walls when it improves clarity. +{%- if adapter_prompt %} +## Adapter Guidance + +{{ adapter_prompt }} +{%- endif %} + {%- if skills_prompt %} {{ skills_prompt }} {%- endif %} diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 71229d2a5..d514cfc00 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -145,6 +145,8 @@ pub struct Channel { pub self_tx: mpsc::Sender, /// Conversation ID from the first message (for synthetic re-trigger messages). pub conversation_id: Option, + /// Adapter source captured from the first non-system message. + pub source_adapter: Option, /// Conversation context (platform, channel name, server) captured from the first message. pub conversation_context: Option, /// Context monitor that triggers background compaction. @@ -257,6 +259,7 @@ impl Channel { response_tx, self_tx, conversation_id: None, + source_adapter: None, conversation_context: None, compactor, message_count: 0, @@ -284,6 +287,21 @@ impl Channel { .unwrap_or(self.deps.agent_id.as_ref()) } + fn current_adapter(&self) -> Option<&str> { + self.source_adapter + .as_deref() + .or_else(|| { + self.conversation_id + .as_deref() + .and_then(|conversation_id| conversation_id.split(':').next()) + }) + .filter(|adapter| !adapter.is_empty()) + } + + fn suppress_plaintext_fallback(&self) -> bool { + matches!(self.current_adapter(), Some("email")) + } + /// Run the channel event loop. pub async fn run(mut self) -> Result<()> { tracing::info!(channel_id = %self.id, "channel started"); @@ -493,6 +511,13 @@ impl Channel { self.conversation_id = Some(first.conversation_id.clone()); } + if self.source_adapter.is_none() + && let Some(first) = messages.first() + && first.source != "system" + { + self.source_adapter = Some(first.source.clone()); + } + // Capture conversation context from the first message if self.conversation_context.is_none() && let Some(first) = messages.first() @@ -684,6 +709,10 @@ impl Channel { let org_context = self.build_org_context(&prompt_engine); + let adapter_prompt = self + .current_adapter() + .and_then(|adapter| prompt_engine.render_channel_adapter_prompt(adapter)); + let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; prompt_engine.render_channel_prompt_with_links( @@ -696,6 +725,7 @@ impl Channel { coalesce_hint, available_channels, org_context, + adapter_prompt, ) } @@ -717,6 +747,10 @@ impl Channel { self.conversation_id = Some(message.conversation_id.clone()); } + if self.source_adapter.is_none() && message.source != "system" { + self.source_adapter = Some(message.source.clone()); + } + let (raw_text, attachments) = match &message.content { crate::MessageContent::Text(text) => (text.clone(), Vec::new()), crate::MessageContent::Media { text, attachments } => { @@ -967,6 +1001,10 @@ impl Channel { let org_context = self.build_org_context(&prompt_engine); + let adapter_prompt = self + .current_adapter() + .and_then(|adapter| prompt_engine.render_channel_adapter_prompt(adapter)); + let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; prompt_engine.render_channel_prompt_with_links( @@ -979,6 +1017,7 @@ impl Channel { None, // coalesce_hint - only set for batched messages available_channels, org_context, + adapter_prompt, ) } @@ -1001,6 +1040,7 @@ impl Channel { )> { let skip_flag = crate::tools::new_skip_flag(); let replied_flag = crate::tools::new_replied_flag(); + let allow_direct_reply = !self.suppress_plaintext_fallback(); if let Err(error) = crate::tools::add_channel_tools( &self.tool_server, @@ -1011,6 +1051,7 @@ impl Channel { replied_flag.clone(), self.deps.cron_tool.clone(), self.send_agent_message_tool.clone(), + allow_direct_reply, ) .await { @@ -1102,7 +1143,9 @@ impl Channel { ); } - if let Err(error) = crate::tools::remove_channel_tools(&self.tool_server).await { + if let Err(error) = + crate::tools::remove_channel_tools(&self.tool_server, allow_direct_reply).await + { tracing::warn!(%error, "failed to remove channel tools"); } @@ -1127,6 +1170,8 @@ impl Channel { Ok(response) => { let skipped = skip_flag.load(std::sync::atomic::Ordering::Relaxed); let replied = replied_flag.load(std::sync::atomic::Ordering::Relaxed); + let suppress_plaintext_fallback = self.suppress_plaintext_fallback(); + let adapter = self.current_adapter().unwrap_or("unknown"); if skipped && is_retrigger { // The LLM skipped on a retrigger turn. This means a worker @@ -1140,6 +1185,12 @@ impl Channel { channel_id = %self.id, "blocked retrigger fallback output containing structured or tool syntax" ); + } else if suppress_plaintext_fallback { + tracing::info!( + channel_id = %self.id, + adapter, + "suppressing retrigger plaintext fallback for adapter; explicit reply tool call required" + ); } else { tracing::info!( channel_id = %self.id, @@ -1193,6 +1244,12 @@ impl Channel { channel_id = %self.id, "blocked retrigger output containing structured or tool syntax" ); + } else if suppress_plaintext_fallback { + tracing::info!( + channel_id = %self.id, + adapter, + "suppressing retrigger plaintext output for adapter; explicit reply tool call required" + ); } else { tracing::info!( channel_id = %self.id, @@ -1239,6 +1296,12 @@ impl Channel { channel_id = %self.id, "blocked fallback output containing structured or tool syntax" ); + } else if suppress_plaintext_fallback { + tracing::info!( + channel_id = %self.id, + adapter, + "suppressing plaintext fallback for adapter; explicit reply tool call required" + ); } else { let extracted = extract_reply_from_tool_syntax(text); let source = self diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index e21a60ef2..361164846 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -69,6 +69,12 @@ impl PromptEngine { crate::prompts::text::get("cortex_profile"), )?; + // Adapter-specific prompt fragments + env.add_template( + "adapters/email", + crate::prompts::text::get("adapters/email"), + )?; + // Fragment templates env.add_template( "fragments/worker_capabilities", @@ -428,9 +434,23 @@ impl PromptEngine { coalesce_hint, available_channels, None, + None, ) } + /// Render optional adapter-specific channel guidance. + pub fn render_channel_adapter_prompt(&self, adapter: &str) -> Option { + let template_name = match adapter { + "email" => "adapters/email", + _ => return None, + }; + + self.render_static(template_name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + /// Render the cortex chat system prompt with optional channel context. pub fn render_cortex_chat_prompt( &self, @@ -473,6 +493,7 @@ impl PromptEngine { coalesce_hint: Option, available_channels: Option, org_context: Option, + adapter_prompt: Option, ) -> Result { self.render( "channel", @@ -486,6 +507,7 @@ impl PromptEngine { coalesce_hint => coalesce_hint, available_channels => available_channels, org_context => org_context, + adapter_prompt => adapter_prompt, }, ) } diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 00a9c3d23..e7fded748 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -65,6 +65,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "ingestion") => include_str!("../../prompts/en/ingestion.md.j2"), ("en", "cortex_chat") => include_str!("../../prompts/en/cortex_chat.md.j2"), + // Adapter-specific prompt fragments + ("en", "adapters/email") => include_str!("../../prompts/en/adapters/email.md.j2"), + // Fragment Templates ("en", "fragments/worker_capabilities") => { include_str!("../../prompts/en/fragments/worker_capabilities.md.j2") diff --git a/src/tools.rs b/src/tools.rs index 2e63cf9a9..9ecd5c3e1 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -238,25 +238,28 @@ pub async fn add_channel_tools( replied_flag: RepliedFlag, cron_tool: Option, send_agent_message_tool: Option, + allow_direct_reply: bool, ) -> Result<(), rig::tool::server::ToolServerError> { let conversation_id = conversation_id.into(); - let agent_display_name = state - .deps - .agent_names - .get(state.deps.agent_id.as_ref()) - .cloned() - .unwrap_or_else(|| state.deps.agent_id.to_string()); - handle - .add_tool(ReplyTool::new( - response_tx.clone(), - conversation_id.clone(), - state.conversation_logger.clone(), - state.channel_id.clone(), - replied_flag.clone(), - agent_display_name, - )) - .await?; + if allow_direct_reply { + let agent_display_name = state + .deps + .agent_names + .get(state.deps.agent_id.as_ref()) + .cloned() + .unwrap_or_else(|| state.deps.agent_id.to_string()); + handle + .add_tool(ReplyTool::new( + response_tx.clone(), + conversation_id.clone(), + state.conversation_logger.clone(), + state.channel_id.clone(), + replied_flag.clone(), + agent_display_name, + )) + .await?; + } handle.add_tool(BranchTool::new(state.clone())).await?; handle.add_tool(SpawnWorkerTool::new(state.clone())).await?; handle.add_tool(RouteTool::new(state.clone())).await?; @@ -306,8 +309,11 @@ fn default_delivery_target_for_conversation(conversation_id: &str) -> Option Result<(), rig::tool::server::ToolServerError> { - handle.remove_tool(ReplyTool::NAME).await?; + if allow_direct_reply { + handle.remove_tool(ReplyTool::NAME).await?; + } handle.remove_tool(BranchTool::NAME).await?; handle.remove_tool(SpawnWorkerTool::NAME).await?; handle.remove_tool(RouteTool::NAME).await?; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index f90f2e891..1f698527c 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -327,9 +327,17 @@ impl Tool for ReplyTool { "required": ["content"] }); + let source = self.conversation_id.split(':').next().unwrap_or("unknown"); + let mut description = crate::prompts::text::get("tools/reply").to_string(); + if source == "email" { + description.push_str( + " In email conversations this sends an actual outbound email to the sender. Use only when an explicit reply is required; otherwise prefer branch + skip.", + ); + } + ToolDefinition { name: Self::NAME.to_string(), - description: crate::prompts::text::get("tools/reply").to_string(), + description, parameters, } } diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 18a55691d..d97925e4b 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -221,6 +221,7 @@ async fn dump_channel_context() { replied_flag, None, None, + true, ) .await .expect("failed to add channel tools"); @@ -439,6 +440,7 @@ async fn dump_all_contexts() { replied_flag, None, None, + true, ) .await .expect("failed to add channel tools"); From 04738ead56c7cb2512df515871e567e2d6c015af Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 16:20:46 -0800 Subject: [PATCH 5/5] feat: support explicit email targets for cross-channel sends --- docs/content/docs/(messaging)/email-setup.mdx | 9 ++ docs/content/docs/(messaging)/messaging.mdx | 2 +- .../en/tools/send_message_description.md.j2 | 2 +- src/tools/send_message_to_another_channel.rs | 98 ++++++++++++++++++- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/(messaging)/email-setup.mdx b/docs/content/docs/(messaging)/email-setup.mdx index 621a25521..22c142839 100644 --- a/docs/content/docs/(messaging)/email-setup.mdx +++ b/docs/content/docs/(messaging)/email-setup.mdx @@ -90,6 +90,15 @@ Email channels are treated as intake streams, not always-on chat threads. - For urgent mail, it can escalate to another active channel using cross-channel messaging. - If you need to send an email intentionally, initiate it from another channel using cross-channel tooling. +## Intentional outbound email from another channel + +When the Email adapter is configured, you can intentionally send email from a non-email channel (for example, Telegram or Discord) using cross-channel messaging. + +- explicit format: `email:alice@example.com` +- bare address also works: `alice@example.com` + +This keeps email channels inbound-only while still allowing deliberate outbound send workflows. + ## Thread behavior Spacebot keeps one conversation per email thread. It uses `References`, `In-Reply-To`, and `Message-ID` headers to map replies back to the correct conversation. diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index 0da705458..11d3087be 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -30,7 +30,7 @@ Spacebot connects to chat platforms so your agent can talk to people where they You can connect multiple platforms at the same time. An agent on Discord and Slack simultaneously is just two bindings pointing at the same agent. -For Email specifically, Spacebot treats inbound mail as intake-first by default: triage, memory capture for meaningful non-spam messages, and escalation to other channels for urgent items. Inbound email channels do not auto-reply. +For Email specifically, Spacebot treats inbound mail as intake-first by default: triage, memory capture for meaningful non-spam messages, and escalation to other channels for urgent items. Inbound email channels do not auto-reply. When the Email adapter is configured, intentional outbound email can still be initiated from other channels using an explicit target such as `email:alice@example.com`. ## Bindings diff --git a/prompts/en/tools/send_message_description.md.j2 b/prompts/en/tools/send_message_description.md.j2 index 5aa4aa9b3..69d427adb 100644 --- a/prompts/en/tools/send_message_description.md.j2 +++ b/prompts/en/tools/send_message_description.md.j2 @@ -1 +1 @@ -Send a message to a DIFFERENT channel than the one you are currently in. Use this for cross-channel delivery — reminders, notifications, or when the user asks you to post something in another channel or DM them. Do NOT use this to reply in the current conversation — use the `reply` tool for that. Target channels by name or ID from the available channels in your context. \ No newline at end of file +Send a message to a DIFFERENT channel than the one you are currently in. Use this for cross-channel delivery — reminders, notifications, or when the user asks you to post something in another channel or DM them. Do NOT use this to reply in the current conversation — use the `reply` tool for that. Target channels by name or ID from the available channels in your context. diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index 0ee550b11..4b55e2068 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -66,16 +66,30 @@ impl Tool for SendMessageTool { type Output = SendMessageOutput; async fn definition(&self, _prompt: String) -> ToolDefinition { + let email_adapter_available = self.messaging_manager.has_adapter("email").await; + + let mut description = + crate::prompts::text::get("tools/send_message_to_another_channel").to_string(); + let mut target_description = "The target channel name, channel ID, or user identifier. Use a channel name like 'general' or a full channel ID from the available channels list.".to_string(); + + if email_adapter_available { + description.push_str( + " Email delivery is enabled: for intentional outbound email you may target `email:alice@example.com` (or bare `alice@example.com`).", + ); + target_description.push_str( + " With email enabled, explicit email targets are also allowed: `email:alice@example.com` or `alice@example.com`.", + ); + } + ToolDefinition { name: Self::NAME.to_string(), - description: crate::prompts::text::get("tools/send_message_to_another_channel") - .to_string(), + description, parameters: serde_json::json!({ "type": "object", "properties": { "target": { "type": "string", - "description": "The target channel name, channel ID, or user identifier. Use a channel name like 'general' or a full channel ID from the available channels list." + "description": target_description }, "message": { "type": "string", @@ -94,6 +108,29 @@ impl Tool for SendMessageTool { "send_message_to_another_channel tool called" ); + if let Some(explicit_target) = parse_explicit_email_target(&args.target) { + self.messaging_manager + .broadcast( + &explicit_target.adapter, + &explicit_target.target, + crate::OutboundResponse::Text(args.message), + ) + .await + .map_err(|error| SendMessageError(format!("failed to send message: {error}")))?; + + tracing::info!( + adapter = %explicit_target.adapter, + broadcast_target = %explicit_target.target, + "message sent via explicit target" + ); + + return Ok(SendMessageOutput { + success: true, + target: explicit_target.target, + platform: explicit_target.adapter, + }); + } + let channel = self .channel_store .find_by_name(&args.target) @@ -101,7 +138,7 @@ impl Tool for SendMessageTool { .map_err(|error| SendMessageError(format!("failed to search channels: {error}")))? .ok_or_else(|| { SendMessageError(format!( - "no channel found matching '{}'. Use a channel name or ID from the available channels list.", + "no channel found matching '{}'. Use a channel name/ID from the available channels list or an explicit email target like email:alice@example.com.", args.target )) })?; @@ -138,3 +175,56 @@ impl Tool for SendMessageTool { }) } } + +fn parse_explicit_email_target(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some(parsed) = crate::messaging::target::parse_delivery_target(trimmed) { + return (parsed.adapter == "email").then_some(parsed); + } + + if !trimmed.contains('@') { + return None; + } + + crate::messaging::target::parse_delivery_target(&format!("email:{trimmed}")) +} + +#[cfg(test)] +mod tests { + use super::parse_explicit_email_target; + + #[test] + fn parses_prefixed_email_target() { + let target = parse_explicit_email_target("email:alice@example.com").expect("email target"); + assert_eq!(target.adapter, "email"); + assert_eq!(target.target, "alice@example.com"); + } + + #[test] + fn parses_bare_email_target() { + let target = parse_explicit_email_target("alice@example.com").expect("email target"); + assert_eq!(target.adapter, "email"); + assert_eq!(target.target, "alice@example.com"); + } + + #[test] + fn parses_display_name_email_target() { + let target = parse_explicit_email_target("Alice ").expect("email"); + assert_eq!(target.adapter, "email"); + assert_eq!(target.target, "alice@example.com"); + } + + #[test] + fn ignores_non_email_prefixed_target() { + assert!(parse_explicit_email_target("discord:123").is_none()); + } + + #[test] + fn ignores_channel_name_target() { + assert!(parse_explicit_email_target("general").is_none()); + } +}