Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ members = [
"workers/approval-tiers",
"workers/bridge",
"workers/channel-email",
"workers/channel-linkedin",
"workers/channel-reddit",
"workers/channel-signal",
"workers/channel-slack",
"workers/channel-teams",
"workers/channel-twitch",
"workers/channel-webex",
"workers/channel-whatsapp",
"workers/coordination",
Expand Down Expand Up @@ -55,6 +58,7 @@ members = [
version = "0.0.1"
edition = "2024"
license = "Apache-2.0"
rust-version = "1.88"

[workspace.dependencies]
iii-sdk = "=0.11.4-next.4"
Expand All @@ -64,6 +68,7 @@ serde_json = "1"
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
subtle = "2"
uuid = { version = "1", features = ["v4"] }
anyhow = "1"
tracing = "0.1"
Expand Down
3 changes: 3 additions & 0 deletions workers/channel-email/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "agentos-channel-email"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[[bin]]
name = "agentos-channel-email"
Expand All @@ -16,4 +17,6 @@ serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
sha2.workspace = true
hex.workspace = true
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
70 changes: 53 additions & 17 deletions workers/channel-email/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use serde_json::{Value, json};

/// Get a secret from `vault::get` first, falling back to env var, mirroring
/// the pattern used by the other channel adapters.
async fn get_secret(iii: &III, key: &str) -> String {
let result = iii
.trigger(TriggerRequest {
function_id: "vault::get".to_string(),
payload: json!({ "key": key }),
action: None,
timeout_ms: None,
})
.await;
if let Ok(value) = result
&& let Some(v) = value.get("value").and_then(|v| v.as_str())
&& !v.is_empty()
{
return v.to_string();
}
std::env::var(key).unwrap_or_default()
}

/// Hash a user identifier so logs keep correlation context without leaking PII.
fn redact(value: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(value.as_bytes());
format!("sha256:{}", hex::encode(&digest[..8]))
}

/// Resolve which agent should handle a given email recipient.
/// Mirrors `resolveAgent(sdk, "email", to)` from src/channels/email.ts.
async fn resolve_agent(iii: &III, channel: &str, channel_id: &str) -> String {
Expand All @@ -25,16 +52,20 @@ async fn resolve_agent(iii: &III, channel: &str, channel_id: &str) -> String {
"default".to_string()
}

/// Build an SMTP transport from env (SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS).
fn build_transport() -> Result<AsyncSmtpTransport<Tokio1Executor>, IIIError> {
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string());
let port: u16 = std::env::var("SMTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(587);
let secure = std::env::var("SMTP_SECURE").map(|s| s == "true").unwrap_or(false);
let user = std::env::var("SMTP_USER").unwrap_or_default();
let pass = std::env::var("SMTP_PASS").unwrap_or_default();
/// Build an SMTP transport, reading SMTP_* values from the vault first and
/// falling back to env. Returns the transport plus the resolved sender (`from`).
async fn build_transport(
iii: &III,
) -> Result<(AsyncSmtpTransport<Tokio1Executor>, String), IIIError> {
let host = {
let v = get_secret(iii, "SMTP_HOST").await;
if v.is_empty() { "localhost".to_string() } else { v }
};
let port_raw = get_secret(iii, "SMTP_PORT").await;
let port: u16 = port_raw.parse().unwrap_or(587);
let secure = get_secret(iii, "SMTP_SECURE").await == "true";
let user = get_secret(iii, "SMTP_USER").await;
let pass = get_secret(iii, "SMTP_PASS").await;

let mut builder = if secure {
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
Expand All @@ -44,13 +75,13 @@ fn build_transport() -> Result<AsyncSmtpTransport<Tokio1Executor>, IIIError> {
};
builder = builder.port(port);
if !user.is_empty() {
builder = builder.credentials(Credentials::new(user, pass));
builder = builder.credentials(Credentials::new(user.clone(), pass));
}
Ok(builder.build())
Ok((builder.build(), user))
}

async fn send_mail(to: &str, subject: &str, text: &str) -> Result<(), IIIError> {
let from = std::env::var("SMTP_USER").unwrap_or_default();
async fn send_mail(iii: &III, to: &str, subject: &str, text: &str) -> Result<(), IIIError> {
let (transport, from) = build_transport(iii).await?;
if from.is_empty() {
return Err(IIIError::Handler("SMTP_USER not configured".into()));
}
Expand All @@ -60,7 +91,6 @@ async fn send_mail(to: &str, subject: &str, text: &str) -> Result<(), IIIError>
.subject(subject)
.body(text.to_string())
.map_err(|e| IIIError::Handler(format!("message build: {e}")))?;
let transport = build_transport()?;
transport
.send(email)
.await
Expand Down Expand Up @@ -102,8 +132,14 @@ async fn handle_webhook(iii: &III, req: Value) -> Result<Value, IIIError> {
let reply = chat.get("content").and_then(|v| v.as_str()).unwrap_or("");
let reply_subject = format!("Re: {subject}");

if let Err(e) = send_mail(from, &reply_subject, reply).await {
tracing::error!(to = %from, error = %e, "failed to send email reply");
if !reply.trim().is_empty()
&& let Err(e) = send_mail(iii, from, &reply_subject, reply).await
{
tracing::error!(
to_hash = %redact(from),
error = %e,
"failed to send email reply"
);
Comment on lines +135 to +142
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redaction is bypassed by logging full error text

Line 140 logs %e directly. Error strings in this path can include user identifiers (addresses) from parsing/SMTP responses, which defeats the redaction goal and creates a privacy/compliance risk.

🔐 Safer logging change
     if !reply.trim().is_empty()
         && let Err(e) = send_mail(iii, from, &reply_subject, reply).await
     {
         tracing::error!(
             to_hash = %redact(from),
-            error = %e,
+            error_kind = "send_mail_failed",
             "failed to send email reply"
         );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/channel-email/src/main.rs` around lines 135 - 142, The current
tracing::error call logs the full error (%e) which can contain user identifiers
and bypass redaction; change the logging to avoid emitting raw error text—either
pass a redacted string (e.g. redact(&e.to_string()) or redact_error(&e)) or log
a non-sensitive static message plus an error kind/variant (e.g. e.kind() or a
mapped enum) from the send_mail result; update the error site where
send_mail(..).await is matched (the tracing::error block in the reply-send
branch) to use the redacted/sanitized value or structured fields that do not
include raw addresses or SMTP responses. Ensure you reference send_mail, the
tracing::error call, and the redact helper (or implement a redact_error helper)
so the logged output never directly formats the error Display.

}

// Fire-and-forget audit (mirrors TriggerAction.Void()).
Expand Down
4 changes: 4 additions & 0 deletions workers/channel-linkedin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "agentos-channel-linkedin"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[[bin]]
name = "agentos-channel-linkedin"
Expand All @@ -16,4 +17,7 @@ serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
hmac.workspace = true
sha2.workspace = true
hex.workspace = true
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
Loading
Loading