real email

This commit is contained in:
jaberjaber23
2026-02-28 02:24:58 +03:00
parent 7bd0185695
commit a9124c34f2
11 changed files with 854 additions and 39 deletions
Generated
+279 -2
View File
@@ -159,6 +159,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -180,6 +189,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -424,6 +439,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
@@ -527,6 +548,12 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bufstream"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -696,6 +723,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 = "chrono"
version = "0.4.43"
@@ -710,6 +747,16 @@ dependencies = [
"windows-link 0.2.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"
@@ -891,7 +938,7 @@ dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1581,6 +1628,22 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[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 = "embed-resource"
version = "3.0.6"
@@ -1785,6 +1848,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1792,7 +1864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1806,6 +1878,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2354,6 +2432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
@@ -2416,6 +2495,17 @@ dependencies = [
"digest",
]
[[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 = "html5ever"
version = "0.29.1"
@@ -2710,6 +2800,31 @@ dependencies = [
"png 0.18.1",
]
[[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 = "indexmap"
version = "1.9.3"
@@ -2991,6 +3106,48 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[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",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"cfg-if",
"ryu",
"static_assertions",
]
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -3134,6 +3291,17 @@ dependencies = [
"libc",
]
[[package]]
name = "mailparse"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
dependencies = [
"charset",
"data-encoding",
"quoted_printable",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -3279,6 +3447,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3327,6 +3512,26 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "5.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b"
dependencies = [
"lexical-core",
"memchr",
"version_check",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
@@ -3707,6 +3912,10 @@ dependencies = [
"futures",
"hex",
"hmac",
"imap",
"lettre",
"mailparse",
"native-tls",
"openfang-types",
"reqwest 0.12.28",
"serde",
@@ -3987,12 +4196,50 @@ dependencies = [
"uuid",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"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 2.0.117",
]
[[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]]
name = "option-ext"
version = "0.2.0"
@@ -4533,6 +4780,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulley-interpreter"
version = "41.0.3"
@@ -4662,6 +4919,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"
@@ -5159,6 +5422,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -5784,6 +6048,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
+7 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.1.7"
version = "0.1.8"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -122,6 +122,12 @@ argon2 = "0.5"
# Lightweight regex
regex-lite = "0.1"
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
# Testing
tokio-test = "0.4"
tempfile = "3"
@@ -1580,12 +1580,18 @@ pub async fn start_channel_bridge_with_config(
let mut started_names = Vec::new();
for (adapter, _) in adapters {
let name = adapter.name().to_string();
// Register adapter in kernel so agents can use `channel_send` tool
kernel
.channel_adapters
.insert(name.clone(), adapter.clone());
match manager.start_adapter(adapter).await {
Ok(()) => {
info!("{name} channel bridge started");
started_names.push(name);
}
Err(e) => {
// Remove from kernel map if start failed
kernel.channel_adapters.remove(&name);
error!("Failed to start {name} bridge: {e}");
}
}
+33 -1
View File
@@ -7690,10 +7690,42 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
// ---------------------------------------------------------------------------
/// GET /api/approvals — List pending approval requests.
///
/// Transforms field names to match the dashboard template expectations:
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pending = state.kernel.approval_manager.list_pending();
let total = pending.len();
Json(serde_json::json!({"approvals": pending, "total": total}))
// Resolve agent names for display
let registry_agents = state.kernel.registry.list();
let approvals: Vec<serde_json::Value> = pending
.into_iter()
.map(|a| {
let agent_name = registry_agents
.iter()
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
.map(|ag| ag.name.as_str())
.unwrap_or(&a.agent_id);
serde_json::json!({
"id": a.id,
"agent_id": a.agent_id,
"agent_name": agent_name,
"tool_name": a.tool_name,
"description": a.description,
"action_summary": a.action_summary,
"action": a.action_summary,
"risk_level": a.risk_level,
"requested_at": a.requested_at,
"created_at": a.requested_at,
"timeout_secs": a.timeout_secs,
"status": "pending"
})
})
.collect();
Json(serde_json::json!({"approvals": approvals, "total": total}))
}
/// POST /api/approvals — Create a manual approval request (for external systems).
@@ -1,5 +1,15 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
</div>
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
<!-- Sidebar -->
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
+30
View File
@@ -87,6 +87,10 @@ function toolIcon(toolName) {
// Alpine.js global store
document.addEventListener('alpine:init', function() {
// Restore saved API key on load
var savedKey = localStorage.getItem('openfang-api-key');
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
Alpine.store('app', {
agents: [],
connected: false,
@@ -99,6 +103,7 @@ document.addEventListener('alpine:init', function() {
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -146,6 +151,30 @@ document.addEventListener('alpine:init', function() {
dismissOnboarding() {
this.showOnboarding = false;
localStorage.setItem('openfang-onboarded', 'true');
},
async checkAuth() {
try {
await OpenFangAPI.get('/api/providers');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && e.message.indexOf('401') >= 0) {
this.showAuthPrompt = true;
}
}
},
submitApiKey(key) {
if (!key || !key.trim()) return;
OpenFangAPI.setAuthToken(key.trim());
localStorage.setItem('openfang-api-key', key.trim());
this.showAuthPrompt = false;
this.refreshAgents();
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
});
});
@@ -237,6 +266,7 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
},
+5
View File
@@ -27,5 +27,10 @@ sha2 = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+359 -35
View File
@@ -1,18 +1,32 @@
//! Email channel adapter (IMAP + SMTP).
//!
//! Polls IMAP for new emails and sends responses via SMTP.
//! Polls IMAP for new emails and sends responses via SMTP using `lettre`.
//! Uses the subject line for agent routing (e.g., "\[coder\] Fix this bug").
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
use async_trait::async_trait;
use chrono::Utc;
use dashmap::DashMap;
use futures::Stream;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use lettre::Tokio1Executor;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
/// Reply context for email threading (In-Reply-To / Subject continuity).
#[derive(Debug, Clone)]
struct ReplyCtx {
subject: String,
message_id: String,
}
/// Email channel adapter using IMAP for receiving and SMTP for sending.
pub struct EmailAdapter {
/// IMAP server host.
@@ -21,7 +35,7 @@ pub struct EmailAdapter {
imap_port: u16,
/// SMTP server host.
smtp_host: String,
/// SMTP port (587 for STARTTLS).
/// SMTP port (587 for STARTTLS, 465 for implicit TLS).
smtp_port: u16,
/// Email address (used for both IMAP and SMTP).
username: String,
@@ -36,6 +50,8 @@ pub struct EmailAdapter {
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Tracks reply context per sender for email threading.
reply_ctx: Arc<DashMap<String, ReplyCtx>>,
}
impl EmailAdapter {
@@ -69,16 +85,17 @@ impl EmailAdapter {
allowed_senders,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
reply_ctx: Arc::new(DashMap::new()),
}
}
/// Check if a sender is in the allowlist (empty = allow all). Used in tests.
#[allow(dead_code)]
fn is_allowed_sender(&self, sender: &str) -> bool {
self.allowed_senders.is_empty() || self.allowed_senders.iter().any(|s| sender.contains(s))
}
/// Extract agent name from subject line brackets, e.g., "[coder] Fix the bug" -> Some("coder")
#[allow(dead_code)]
fn extract_agent_from_subject(subject: &str) -> Option<String> {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -93,7 +110,6 @@ impl EmailAdapter {
}
/// Strip the agent tag from a subject line.
#[allow(dead_code)]
fn strip_agent_tag(subject: &str) -> String {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -103,6 +119,162 @@ impl EmailAdapter {
}
subject.to_string()
}
/// Build an async SMTP transport for sending emails.
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
} else {
// STARTTLS (port 587 or other)
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
};
Ok(transport)
}
}
/// Extract `user@domain` from a potentially formatted email string like `"Name <user@domain>"`.
fn extract_email_addr(raw: &str) -> String {
let raw = raw.trim();
if let Some(start) = raw.find('<') {
if let Some(end) = raw.find('>') {
if end > start {
return raw[start + 1..end].trim().to_string();
}
}
}
raw.to_string()
}
/// Get a specific header value from a parsed email.
fn get_header(parsed: &mailparse::ParsedMail<'_>, name: &str) -> Option<String> {
parsed
.headers
.iter()
.find(|h| h.get_key().eq_ignore_ascii_case(name))
.map(|h| h.get_value())
}
/// Extract the text/plain body from a parsed email (handles multipart).
fn extract_text_body(parsed: &mailparse::ParsedMail<'_>) -> String {
if parsed.subparts.is_empty() {
return parsed.get_body().unwrap_or_default();
}
// Walk subparts looking for text/plain
for part in &parsed.subparts {
let ct = part.ctype.mimetype.to_lowercase();
if ct == "text/plain" {
return part.get_body().unwrap_or_default();
}
}
// Fallback: first subpart body
parsed
.subparts
.first()
.and_then(|p| p.get_body().ok())
.unwrap_or_default()
}
/// Fetch unseen emails from IMAP using blocking I/O.
/// Returns a Vec of (from_addr, subject, message_id, body).
fn fetch_unseen_emails(
host: &str,
port: u16,
username: &str,
password: &str,
folders: &[String],
) -> Result<Vec<(String, String, String, String)>, String> {
let tls = native_tls::TlsConnector::builder()
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let mut session = client
.login(username, password)
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
let mut results = Vec::new();
for folder in folders {
if let Err(e) = session.select(folder) {
warn!(folder, error = %e, "IMAP SELECT failed, skipping folder");
continue;
}
let uids = match session.uid_search("UNSEEN") {
Ok(uids) => uids,
Err(e) => {
warn!(folder, error = %e, "IMAP SEARCH UNSEEN failed");
continue;
}
};
if uids.is_empty() {
debug!(folder, "No unseen emails");
continue;
}
// Fetch in batches of up to 50 to avoid huge responses
let uid_list: Vec<u32> = uids.into_iter().take(50).collect();
let uid_set: String = uid_list
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
let fetches = match session.uid_fetch(&uid_set, "RFC822") {
Ok(f) => f,
Err(e) => {
warn!(folder, error = %e, "IMAP FETCH failed");
continue;
}
};
for fetch in fetches.iter() {
let body_bytes = match fetch.body() {
Some(b) => b,
None => continue,
};
let parsed = match mailparse::parse_mail(body_bytes) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Failed to parse email");
continue;
}
};
let from = get_header(&parsed, "From").unwrap_or_default();
let subject = get_header(&parsed, "Subject").unwrap_or_default();
let message_id = get_header(&parsed, "Message-ID").unwrap_or_default();
let text_body = extract_text_body(&parsed);
let from_addr = extract_email_addr(&from);
results.push((from_addr, subject, message_id, text_body));
}
// Mark fetched messages as Seen
if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Seen)") {
warn!(error = %e, "Failed to mark emails as Seen");
}
}
let _ = session.logout();
Ok(results)
}
#[async_trait]
@@ -119,25 +291,23 @@ impl ChannelAdapter for EmailAdapter {
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let poll_interval = self.poll_interval;
let _allowed_senders = self.allowed_senders.clone();
let imap_host = self.imap_host.clone();
let imap_port = self.imap_port;
let _username = self.username.clone();
let _password = self.password.clone();
let _folders = self.folders.clone();
let username = self.username.clone();
let password = self.password.clone();
let folders = self.folders.clone();
let allowed_senders = self.allowed_senders.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
let reply_ctx = self.reply_ctx.clone();
info!(
"Starting email adapter (IMAP: {}:{}, polling every {:?})",
imap_host, imap_port, poll_interval
"Starting email adapter (IMAP: {}:{}, SMTP: {}:{}, polling every {:?})",
imap_host, imap_port, self.smtp_host, self.smtp_port, poll_interval
);
tokio::spawn(async move {
// Email polling is blocking I/O, so we'll use spawn_blocking
// For now, implement as a polling loop with placeholder
// Full IMAP implementation requires the `imap` crate
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
@@ -147,14 +317,83 @@ impl ChannelAdapter for EmailAdapter {
_ = tokio::time::sleep(poll_interval) => {}
}
// Placeholder: In a full implementation, this would:
// 1. Connect to IMAP server via TLS
// 2. Select each folder
// 3. Search for UNSEEN messages
// 4. Fetch and parse each message (From, Subject, Body)
// 5. Convert to ChannelMessage
// 6. Mark as seen
debug!("Email poll cycle (IMAP {}:{})", imap_host, imap_port);
// IMAP operations are blocking I/O — run in spawn_blocking
let host = imap_host.clone();
let port = imap_port;
let user = username.clone();
let pass = password.clone();
let fldrs = folders.clone();
let emails = tokio::task::spawn_blocking(move || {
fetch_unseen_emails(&host, port, &user, pass.as_str(), &fldrs)
})
.await;
let emails = match emails {
Ok(Ok(emails)) => emails,
Ok(Err(e)) => {
error!("IMAP poll error: {e}");
continue;
}
Err(e) => {
error!("IMAP spawn_blocking panic: {e}");
continue;
}
};
for (from_addr, subject, message_id, body) in emails {
// Check allowed senders
if !allowed_senders.is_empty()
&& !allowed_senders.iter().any(|s| from_addr.contains(s))
{
debug!(from = %from_addr, "Email from non-allowed sender, skipping");
continue;
}
// Store reply context for threading
if !message_id.is_empty() {
reply_ctx.insert(
from_addr.clone(),
ReplyCtx {
subject: subject.clone(),
message_id: message_id.clone(),
},
);
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
let text = if clean_subject.is_empty() {
body.trim().to_string()
} else {
format!("Subject: {clean_subject}\n\n{}", body.trim())
};
let msg = ChannelMessage {
channel: ChannelType::Email,
platform_message_id: message_id.clone(),
sender: ChannelUser {
platform_id: from_addr.clone(),
display_name: from_addr.clone(),
openfang_user: None,
},
content: ChannelContent::Text(text),
target_agent: None, // Routing handled by bridge AgentRouter
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: std::collections::HashMap::new(),
};
if tx.send(msg).await.is_err() {
info!("Email channel receiver dropped, stopping poll");
return;
}
}
}
});
@@ -168,22 +407,71 @@ impl ChannelAdapter for EmailAdapter {
) -> Result<(), Box<dyn std::error::Error>> {
match content {
ChannelContent::Text(text) => {
// Placeholder: In a full implementation, this would:
// 1. Build email (From, To, Subject, Body) using lettre
// 2. Connect to SMTP server via STARTTLS
// 3. Send the email
// Parse recipient address
let to_addr = extract_email_addr(&user.platform_id);
let to_mailbox: Mailbox = to_addr
.parse()
.map_err(|e| format!("Invalid recipient email '{}': {}", to_addr, e))?;
let from_mailbox: Mailbox = self
.username
.parse()
.map_err(|e| format!("Invalid sender email '{}': {}", self.username, e))?;
// Extract subject from text body convention: "Subject: ...\n\n..."
let (subject, body) = if text.starts_with("Subject: ") {
if let Some(pos) = text.find("\n\n") {
let subj = text[9..pos].trim().to_string();
let body = text[pos + 2..].to_string();
(subj, body)
} else {
("OpenFang Reply".to_string(), text)
}
} else {
// Check reply context for subject continuity
let subj = self
.reply_ctx
.get(&to_addr)
.map(|ctx| format!("Re: {}", ctx.subject))
.unwrap_or_else(|| "OpenFang Reply".to_string());
(subj, text)
};
// Build email message
let mut builder = lettre::Message::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject(&subject);
// Add In-Reply-To header for threading
if let Some(ctx) = self.reply_ctx.get(&to_addr) {
if !ctx.message_id.is_empty() {
builder = builder.in_reply_to(ctx.message_id.clone());
}
}
let email = builder
.body(body)
.map_err(|e| format!("Failed to build email: {e}"))?;
// Send via SMTP
let transport = self.build_smtp_transport().await?;
transport
.send(email)
.await
.map_err(|e| format!("SMTP send failed: {e}"))?;
info!(
"Would send email to {}: {} chars",
user.platform_id,
text.len()
);
debug!(
"SMTP: {}:{} -> {}",
self.smtp_host, self.smtp_port, user.platform_id
to = %to_addr,
subject = %subject,
"Email sent successfully via SMTP"
);
}
_ => {
info!("Unsupported email content type for {}", user.platform_id);
warn!(
"Unsupported email content type for {}, only text is supported",
user.platform_id
);
}
}
Ok(())
@@ -274,4 +562,40 @@ mod tests {
);
assert_eq!(EmailAdapter::strip_agent_tag("No brackets"), "No brackets");
}
#[test]
fn test_extract_email_addr() {
assert_eq!(
extract_email_addr("John Doe <john@example.com>"),
"john@example.com"
);
assert_eq!(extract_email_addr("user@example.com"), "user@example.com");
assert_eq!(extract_email_addr("<user@test.com>"), "user@test.com");
}
#[test]
fn test_subject_extraction_from_body() {
let text = "Subject: Test Subject\n\nThis is the body.";
assert!(text.starts_with("Subject: "));
let pos = text.find("\n\n").unwrap();
let subject = &text[9..pos];
let body = &text[pos + 2..];
assert_eq!(subject, "Test Subject");
assert_eq!(body, "This is the body.");
}
#[test]
fn test_reply_ctx_threading() {
let ctx_map: DashMap<String, ReplyCtx> = DashMap::new();
ctx_map.insert(
"user@test.com".to_string(),
ReplyCtx {
subject: "Original Subject".to_string(),
message_id: "<msg-123@test.com>".to_string(),
},
);
let ctx = ctx_map.get("user@test.com").unwrap();
assert_eq!(ctx.subject, "Original Subject");
assert_eq!(ctx.message_id, "<msg-123@test.com>");
}
}
+39
View File
@@ -127,6 +127,8 @@ pub struct OpenFangKernel {
pub booted_at: std::time::Instant,
/// WhatsApp Web gateway child process PID (for shutdown cleanup).
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
self_handle: OnceLock<Weak<OpenFangKernel>>,
}
@@ -874,6 +876,7 @@ impl OpenFangKernel {
peer_node: None,
booted_at: std::time::Instant::now(),
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
channel_adapters: dashmap::DashMap::new(),
self_handle: OnceLock::new(),
};
@@ -4712,6 +4715,42 @@ impl KernelHandle for OpenFangKernel {
.map(|(url, _)| url.clone())
}
async fn send_channel_message(
&self,
channel: &str,
recipient: &str,
message: &str,
) -> Result<String, String> {
let adapter = self
.channel_adapters
.get(channel)
.ok_or_else(|| {
let available: Vec<String> = self
.channel_adapters
.iter()
.map(|e| e.key().clone())
.collect();
format!(
"Channel '{}' not found. Available channels: {:?}",
channel, available
)
})?
.clone();
let user = openfang_channels::types::ChannelUser {
platform_id: recipient.to_string(),
display_name: recipient.to_string(),
openfang_user: None,
};
adapter
.send(&user, openfang_channels::types::ChannelContent::Text(message.to_string()))
.await
.map_err(|e| format!("Channel send failed: {e}"))?;
Ok(format!("Message sent to {} via {}", recipient, channel))
}
async fn spawn_agent_checked(
&self,
manifest_toml: &str,
@@ -172,6 +172,18 @@ pub trait KernelHandle: Send + Sync {
None
}
/// Send a message to a user on a named channel adapter (e.g., "email", "telegram").
/// Returns a confirmation string on success.
async fn send_channel_message(
&self,
channel: &str,
recipient: &str,
message: &str,
) -> Result<String, String> {
let _ = (channel, recipient, message);
Err("Channel send not available".to_string())
}
/// Spawn an agent with capability inheritance enforcement.
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
/// that every capability in the child manifest is covered by `parent_caps`.
@@ -293,6 +293,9 @@ pub async fn execute_tool(
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
"cron_cancel" => tool_cron_cancel(input, kernel).await,
// Channel send tool (proactive outbound messaging)
"channel_send" => tool_channel_send(input, kernel).await,
// Persistent process tools
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
"process_poll" => tool_process_poll(input, process_manager).await,
@@ -909,6 +912,21 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["job_id"]
}),
},
// --- Channel send tool (proactive outbound messaging) ---
ToolDefinition {
name: "channel_send".to_string(),
description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
"message": { "type": "string", "description": "The message body to send" }
},
"required": ["channel", "recipient", "message"]
}),
},
// --- Hand tools (curated autonomous capability packages) ---
ToolDefinition {
name: "hand_list".to_string(),
@@ -2001,6 +2019,60 @@ async fn tool_cron_cancel(
Ok(format!("Cron job '{job_id}' cancelled."))
}
// ---------------------------------------------------------------------------
// Channel send tool (proactive outbound messaging via configured adapters)
// ---------------------------------------------------------------------------
async fn tool_channel_send(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let channel = input["channel"]
.as_str()
.ok_or("Missing 'channel' parameter")?
.trim()
.to_lowercase();
let recipient = input["recipient"]
.as_str()
.ok_or("Missing 'recipient' parameter")?
.trim();
let message = input["message"]
.as_str()
.ok_or("Missing 'message' parameter")?;
if recipient.is_empty() {
return Err("Recipient cannot be empty".to_string());
}
if message.is_empty() {
return Err("Message cannot be empty".to_string());
}
// For email channels, validate email format and prepend subject
let final_message = if channel == "email" {
// Basic email format validation
if !recipient.contains('@') || !recipient.contains('.') {
return Err(format!("Invalid email address: '{recipient}'"));
}
// Prepend subject if provided
if let Some(subject) = input["subject"].as_str() {
if !subject.is_empty() {
format!("Subject: {subject}\n\n{message}")
} else {
message.to_string()
}
} else {
message.to_string()
}
} else {
message.to_string()
};
kh.send_channel_message(&channel, recipient, &final_message)
.await
}
// ---------------------------------------------------------------------------
// Hand tools (delegated to kernel via KernelHandle trait)
// ---------------------------------------------------------------------------
@@ -2953,6 +3025,8 @@ mod tests {
assert!(names.contains(&"cron_create"));
assert!(names.contains(&"cron_list"));
assert!(names.contains(&"cron_cancel"));
// 1 channel send tool
assert!(names.contains(&"channel_send"));
// 4 hand tools
assert!(names.contains(&"hand_list"));
assert!(names.contains(&"hand_activate"));