feat(channels): wire up email channel (IMAP/SMTP) into config and registration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-15 10:58:30 -05:00 committed by GitHub
parent efe7ae53ce
commit ced4d70814
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 48 additions and 162 deletions

150
Cargo.lock generated
View file

@ -390,16 +390,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -595,21 +585,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -627,9 +602,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -637,21 +612,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -660,21 +635,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.31" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
@ -683,7 +658,6 @@ dependencies = [
"futures-task", "futures-task",
"memchr", "memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils",
"slab", "slab",
] ]
@ -1154,12 +1128,9 @@ dependencies = [
"email-encoding", "email-encoding",
"email_address", "email_address",
"fastrand", "fastrand",
"futures-util",
"hostname",
"httpdate", "httpdate",
"idna", "idna",
"mime", "mime",
"native-tls",
"nom 8.0.0", "nom 8.0.0",
"percent-encoding", "percent-encoding",
"quoted_printable", "quoted_printable",
@ -1275,23 +1246,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -1356,50 +1310,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -1785,38 +1695,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"

View file

@ -64,7 +64,7 @@ console = "0.15"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink"] } futures-util = { version = "0.3", default-features = false, features = ["sink"] }
hostname = "0.4.2" hostname = "0.4.2"
lettre = { version = "0.11.19", features = ["smtp-transport", "rustls-tls"] } lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2" mail-parser = "0.11.2"
rustls-pki-types = "1.14.0" rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4" tokio-rustls = "0.26.4"

View file

@ -1,5 +1,5 @@
# ── Stage 1: Build ──────────────────────────────────────────── # ── Stage 1: Build ────────────────────────────────────────────
FROM rust:1.83-slim AS builder FROM rust:1.93-slim-bookworm AS builder
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
@ -8,8 +8,8 @@ COPY src/ src/
RUN cargo build --release --locked && \ RUN cargo build --release --locked && \
strip target/release/zeroclaw strip target/release/zeroclaw
# ── Stage 2: Runtime (distroless nonroot — no shell, no OS, tiny, UID 65534) ── # ── Stage 2: Runtime (distroless, runs as root for /data write access) ──
FROM gcr.io/distroless/cc-debian12:nonroot FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw
@ -32,9 +32,6 @@ ENV ZEROCLAW_WORKSPACE=/data/workspace
# Example: # Example:
# docker run -e API_KEY=sk-... -e PROVIDER=openrouter zeroclaw/zeroclaw # docker run -e API_KEY=sk-... -e PROVIDER=openrouter zeroclaw/zeroclaw
# Explicitly set non-root user (distroless:nonroot defaults to 65534, but be explicit)
USER 65534:65534
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["zeroclaw"] ENTRYPOINT ["zeroclaw"]

View file

@ -11,6 +11,7 @@ pub mod whatsapp;
pub use cli::CliChannel; pub use cli::CliChannel;
pub use discord::DiscordChannel; pub use discord::DiscordChannel;
pub use email_channel::EmailChannel;
pub use imessage::IMessageChannel; pub use imessage::IMessageChannel;
pub use irc::IrcChannel; pub use irc::IrcChannel;
pub use matrix::MatrixChannel; pub use matrix::MatrixChannel;
@ -256,6 +257,7 @@ pub fn handle_command(command: crate::ChannelCommands, config: &Config) -> Resul
("iMessage", config.channels_config.imessage.is_some()), ("iMessage", config.channels_config.imessage.is_some()),
("Matrix", config.channels_config.matrix.is_some()), ("Matrix", config.channels_config.matrix.is_some()),
("WhatsApp", config.channels_config.whatsapp.is_some()), ("WhatsApp", config.channels_config.whatsapp.is_some()),
("Email", config.channels_config.email.is_some()),
("IRC", config.channels_config.irc.is_some()), ("IRC", config.channels_config.irc.is_some()),
] { ] {
println!(" {} {name}", if configured { "" } else { "" }); println!(" {} {name}", if configured { "" } else { "" });
@ -363,6 +365,10 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
)); ));
} }
if let Some(ref email_cfg) = config.channels_config.email {
channels.push(("Email", Arc::new(EmailChannel::new(email_cfg.clone()))));
}
if let Some(ref irc) = config.channels_config.irc { if let Some(ref irc) = config.channels_config.irc {
channels.push(( channels.push((
"IRC", "IRC",
@ -548,6 +554,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
))); )));
} }
if let Some(ref email_cfg) = config.channels_config.email {
channels.push(Arc::new(EmailChannel::new(email_cfg.clone())));
}
if let Some(ref irc) = config.channels_config.irc { if let Some(ref irc) = config.channels_config.irc {
channels.push(Arc::new(IrcChannel::new( channels.push(Arc::new(IrcChannel::new(
irc.server.clone(), irc.server.clone(),

View file

@ -541,6 +541,7 @@ pub struct ChannelsConfig {
pub imessage: Option<IMessageConfig>, pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>, pub matrix: Option<MatrixConfig>,
pub whatsapp: Option<WhatsAppConfig>, pub whatsapp: Option<WhatsAppConfig>,
pub email: Option<crate::channels::email_channel::EmailConfig>,
pub irc: Option<IrcConfig>, pub irc: Option<IrcConfig>,
} }
@ -555,6 +556,7 @@ impl Default for ChannelsConfig {
imessage: None, imessage: None,
matrix: None, matrix: None,
whatsapp: None, whatsapp: None,
email: None,
irc: None, irc: None,
} }
} }
@ -889,6 +891,7 @@ mod tests {
imessage: None, imessage: None,
matrix: None, matrix: None,
whatsapp: None, whatsapp: None,
email: None,
irc: None, irc: None,
}, },
memory: MemoryConfig::default(), memory: MemoryConfig::default(),
@ -1102,6 +1105,7 @@ default_temperature = 0.7
allowed_users: vec!["@u:m".into()], allowed_users: vec!["@u:m".into()],
}), }),
whatsapp: None, whatsapp: None,
email: None,
irc: None, irc: None,
}; };
let toml_str = toml::to_string_pretty(&c).unwrap(); let toml_str = toml::to_string_pretty(&c).unwrap();
@ -1259,6 +1263,7 @@ channel_id = "C123"
app_secret: None, app_secret: None,
allowed_numbers: vec!["+1".into()], allowed_numbers: vec!["+1".into()],
}), }),
email: None,
irc: None, irc: None,
}; };
let toml_str = toml::to_string_pretty(&c).unwrap(); let toml_str = toml::to_string_pretty(&c).unwrap();

View file

@ -210,6 +210,8 @@ fn has_supervised_channels(config: &Config) -> bool {
|| config.channels_config.slack.is_some() || config.channels_config.slack.is_some()
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.whatsapp.is_some()
|| config.channels_config.email.is_some()
} }
#[cfg(test)] #[cfg(test)]

View file

@ -129,7 +129,8 @@ pub fn run_wizard() -> Result<Config> {
|| config.channels_config.discord.is_some() || config.channels_config.discord.is_some()
|| config.channels_config.slack.is_some() || config.channels_config.slack.is_some()
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some(); || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some();
if has_channels && config.api_key.is_some() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -184,7 +185,8 @@ pub fn run_channels_repair_wizard() -> Result<Config> {
|| config.channels_config.discord.is_some() || config.channels_config.discord.is_some()
|| config.channels_config.slack.is_some() || config.channels_config.slack.is_some()
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some(); || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some();
if has_channels && config.api_key.is_some() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -1114,6 +1116,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
imessage: None, imessage: None,
matrix: None, matrix: None,
whatsapp: None, whatsapp: None,
email: None,
irc: None, irc: None,
}; };
@ -1891,6 +1894,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
if config.whatsapp.is_some() { if config.whatsapp.is_some() {
active.push("WhatsApp"); active.push("WhatsApp");
} }
if config.email.is_some() {
active.push("Email");
}
if config.irc.is_some() { if config.irc.is_some() {
active.push("IRC"); active.push("IRC");
} }
@ -2346,7 +2352,8 @@ fn print_summary(config: &Config) {
|| config.channels_config.discord.is_some() || config.channels_config.discord.is_some()
|| config.channels_config.slack.is_some() || config.channels_config.slack.is_some()
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some(); || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some();
println!(); println!();
println!( println!(
@ -2408,6 +2415,9 @@ fn print_summary(config: &Config) {
if config.channels_config.matrix.is_some() { if config.channels_config.matrix.is_some() {
channels.push("Matrix"); channels.push("Matrix");
} }
if config.channels_config.email.is_some() {
channels.push("Email");
}
if config.channels_config.webhook.is_some() { if config.channels_config.webhook.is_some() {
channels.push("Webhook"); channels.push("Webhook");
} }

View file

@ -50,10 +50,7 @@ impl AnthropicProvider {
.map(str::trim) .map(str::trim)
.filter(|k| !k.is_empty()) .filter(|k| !k.is_empty())
.map(ToString::to_string), .map(ToString::to_string),
<<<<<<< HEAD
=======
base_url, base_url,
>>>>>>> origin/main
client: Client::builder() client: Client::builder()
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
@ -95,11 +92,7 @@ impl Provider for AnthropicProvider {
let mut request = self let mut request = self
.client .client
<<<<<<< HEAD
.post("https://api.anthropic.com/v1/messages")
=======
.post(format!("{}/v1/messages", self.base_url)) .post(format!("{}/v1/messages", self.base_url))
>>>>>>> origin/main
.header("anthropic-version", "2023-06-01") .header("anthropic-version", "2023-06-01")
.header("content-type", "application/json") .header("content-type", "application/json")
.json(&request); .json(&request);
@ -136,20 +129,14 @@ mod tests {
let p = AnthropicProvider::new(Some("sk-ant-test123")); let p = AnthropicProvider::new(Some("sk-ant-test123"));
assert!(p.credential.is_some()); assert!(p.credential.is_some());
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123")); assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
<<<<<<< HEAD
=======
assert_eq!(p.base_url, "https://api.anthropic.com"); assert_eq!(p.base_url, "https://api.anthropic.com");
>>>>>>> origin/main
} }
#[test] #[test]
fn creates_without_key() { fn creates_without_key() {
let p = AnthropicProvider::new(None); let p = AnthropicProvider::new(None);
assert!(p.credential.is_none()); assert!(p.credential.is_none());
<<<<<<< HEAD
=======
assert_eq!(p.base_url, "https://api.anthropic.com"); assert_eq!(p.base_url, "https://api.anthropic.com");
>>>>>>> origin/main
} }
#[test] #[test]
@ -163,8 +150,6 @@ mod tests {
let p = AnthropicProvider::new(Some(" sk-ant-test123 ")); let p = AnthropicProvider::new(Some(" sk-ant-test123 "));
assert!(p.credential.is_some()); assert!(p.credential.is_some());
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123")); assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
<<<<<<< HEAD
=======
} }
#[test] #[test]
@ -184,7 +169,6 @@ mod tests {
fn default_base_url_when_none_provided() { fn default_base_url_when_none_provided() {
let p = AnthropicProvider::with_base_url(None, None); let p = AnthropicProvider::with_base_url(None, None);
assert_eq!(p.base_url, "https://api.anthropic.com"); assert_eq!(p.base_url, "https://api.anthropic.com");
>>>>>>> origin/main
} }
#[tokio::test] #[tokio::test]