use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailIdentity {
pub address: String,
pub display_name: String,
}
impl EmailIdentity {
pub fn new(address: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
address: address.into(),
display_name: display_name.into(),
}
}
pub fn formatted(&self) -> String {
format!("{} <{}>", self.display_name, self.address)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailMessage {
pub id: Option<String>,
pub from: String,
pub to: String,
#[serde(default)]
pub cc: Vec<String>,
pub subject: String,
pub body_text: String,
pub body_html: Option<String>,
pub timestamp: Option<DateTime<Utc>>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub read: bool,
pub thread_id: Option<String>,
}
impl EmailMessage {
pub fn new(
from: &EmailIdentity,
to: impl Into<String>,
subject: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
id: None,
from: from.formatted(),
to: to.into(),
cc: vec![],
subject: subject.into(),
body_text: body.into(),
body_html: None,
timestamp: Some(Utc::now()),
headers: HashMap::new(),
read: false,
thread_id: None,
}
}
pub fn with_html(mut self, html: impl Into<String>) -> Self {
self.body_html = Some(html.into());
self
}
pub fn with_cc(mut self, cc: Vec<String>) -> Self {
self.cc = cc;
self
}
pub fn with_reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.headers.insert("Reply-To".to_string(), reply_to.into());
self
}
}
#[async_trait]
pub trait EmailProvider: Send + Sync + 'static {
async fn send(&self, message: EmailMessage) -> Result<String, EmailError>;
async fn poll(&self, identity: &EmailIdentity) -> Result<Vec<EmailMessage>, EmailError>;
async fn mark_read(&self, message_id: &str) -> Result<(), EmailError>;
}
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("email API request failed: {0}")]
Http(String),
#[error("email API error ({status}): {body}")]
Api {
status: u16,
body: String,
},
#[error("failed to parse email API response: {0}")]
Parse(String),
#[error("not supported by this provider: {0}")]
Unsupported(String),
}
#[derive(Debug, Clone)]
pub enum HttpEmailBackend {
Mailgun {
domain: String,
},
Postmark,
Resend,
Custom {
url: String,
},
}
#[derive(Debug, Clone)]
pub struct HttpEmailConfig {
pub backend: HttpEmailBackend,
pub api_key: String,
pub inbox_url: Option<String>,
pub extra_headers: HashMap<String, String>,
}
impl HttpEmailConfig {
pub fn mailgun(domain: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
backend: HttpEmailBackend::Mailgun {
domain: domain.into(),
},
api_key: api_key.into(),
inbox_url: None,
extra_headers: HashMap::new(),
}
}
pub fn postmark(server_token: impl Into<String>) -> Self {
Self {
backend: HttpEmailBackend::Postmark,
api_key: server_token.into(),
inbox_url: None,
extra_headers: HashMap::new(),
}
}
pub fn resend(api_key: impl Into<String>) -> Self {
Self {
backend: HttpEmailBackend::Resend,
api_key: api_key.into(),
inbox_url: None,
extra_headers: HashMap::new(),
}
}
pub fn custom(send_url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
backend: HttpEmailBackend::Custom {
url: send_url.into(),
},
api_key: api_key.into(),
inbox_url: None,
extra_headers: HashMap::new(),
}
}
pub fn with_inbox_url(mut self, url: impl Into<String>) -> Self {
self.inbox_url = Some(url.into());
self
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.insert(name.into(), value.into());
self
}
}
pub struct HttpEmailProvider {
config: HttpEmailConfig,
client: reqwest::Client,
}
impl HttpEmailProvider {
pub fn new(config: HttpEmailConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
fn _auth_header(&self) -> String {
match &self.config.backend {
HttpEmailBackend::Postmark => self.config.api_key.clone(),
_ => format!("Bearer {}", self.config.api_key),
}
}
async fn send_mailgun(
&self,
domain: &str,
message: &EmailMessage,
) -> Result<String, EmailError> {
let url = format!("https://api.mailgun.net/v3/{domain}/messages");
let mut form = vec![
("from", message.from.clone()),
("to", message.to.clone()),
("subject", message.subject.clone()),
("text", message.body_text.clone()),
];
if let Some(html) = &message.body_html {
form.push(("html", html.clone()));
}
for cc in &message.cc {
form.push(("cc", cc.clone()));
}
let resp = self
.client
.post(&url)
.basic_auth("api", Some(&self.config.api_key))
.form(&form)
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(EmailError::Api { status, body });
}
let v: serde_json::Value =
serde_json::from_str(&body).map_err(|e| EmailError::Parse(e.to_string()))?;
Ok(v["id"].as_str().unwrap_or("").to_string())
}
async fn send_postmark(&self, message: &EmailMessage) -> Result<String, EmailError> {
#[derive(Serialize)]
struct PostmarkBody<'a> {
#[serde(rename = "From")]
from: &'a str,
#[serde(rename = "To")]
to: &'a str,
#[serde(rename = "Subject")]
subject: &'a str,
#[serde(rename = "TextBody")]
text_body: &'a str,
#[serde(rename = "HtmlBody", skip_serializing_if = "Option::is_none")]
html_body: Option<&'a str>,
}
let body = PostmarkBody {
from: &message.from,
to: &message.to,
subject: &message.subject,
text_body: &message.body_text,
html_body: message.body_html.as_deref(),
};
let resp = self
.client
.post("https://api.postmarkapp.com/email")
.header("X-Postmark-Server-Token", &self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(EmailError::Api { status, body: text });
}
let v: serde_json::Value =
serde_json::from_str(&text).map_err(|e| EmailError::Parse(e.to_string()))?;
Ok(v["MessageID"].as_str().unwrap_or("").to_string())
}
async fn send_resend(&self, message: &EmailMessage) -> Result<String, EmailError> {
#[derive(Serialize)]
struct ResendBody<'a> {
from: &'a str,
to: Vec<&'a str>,
subject: &'a str,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<&'a str>,
}
let body = ResendBody {
from: &message.from,
to: vec![message.to.as_str()],
subject: &message.subject,
text: &message.body_text,
html: message.body_html.as_deref(),
};
let resp = self
.client
.post("https://api.resend.com/emails")
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(EmailError::Api { status, body: text });
}
let v: serde_json::Value =
serde_json::from_str(&text).map_err(|e| EmailError::Parse(e.to_string()))?;
Ok(v["id"].as_str().unwrap_or("").to_string())
}
async fn send_custom(&self, url: &str, message: &EmailMessage) -> Result<String, EmailError> {
let mut req = self
.client
.post(url)
.bearer_auth(&self.config.api_key)
.json(message);
for (k, v) in &self.config.extra_headers {
req = req.header(k.as_str(), v.as_str());
}
let resp = req
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(EmailError::Api { status, body: text });
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
return Ok(v["id"].as_str().unwrap_or(&text).to_string());
}
Ok(text)
}
}
#[async_trait]
impl EmailProvider for HttpEmailProvider {
async fn send(&self, message: EmailMessage) -> Result<String, EmailError> {
match &self.config.backend {
HttpEmailBackend::Mailgun { domain } => {
let domain = domain.clone();
self.send_mailgun(&domain, &message).await
}
HttpEmailBackend::Postmark => self.send_postmark(&message).await,
HttpEmailBackend::Resend => self.send_resend(&message).await,
HttpEmailBackend::Custom { url } => {
let url = url.clone();
self.send_custom(&url, &message).await
}
}
}
async fn poll(&self, identity: &EmailIdentity) -> Result<Vec<EmailMessage>, EmailError> {
let url = self.config.inbox_url.as_deref().ok_or_else(|| {
EmailError::Unsupported(
"inbox polling requires inbox_url to be set in HttpEmailConfig".to_string(),
)
})?;
let resp = self
.client
.get(url)
.bearer_auth(&self.config.api_key)
.query(&[("to", &identity.address)])
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
if status < 200 || status >= 300 {
return Err(EmailError::Api { status, body: text });
}
serde_json::from_str::<Vec<EmailMessage>>(&text)
.map_err(|e| EmailError::Parse(e.to_string()))
}
async fn mark_read(&self, message_id: &str) -> Result<(), EmailError> {
let base = self.config.inbox_url.as_deref().ok_or_else(|| {
EmailError::Unsupported(
"mark_read requires inbox_url to be set in HttpEmailConfig".to_string(),
)
})?;
let url = format!("{base}/{message_id}/read");
let resp = self
.client
.post(&url)
.bearer_auth(&self.config.api_key)
.send()
.await
.map_err(|e| EmailError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if status < 200 || status >= 300 {
let body = resp.text().await.unwrap_or_default();
return Err(EmailError::Api { status, body });
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_identity_formatted() {
let id = EmailIdentity::new("agent@example.com", "My Agent");
assert_eq!(id.formatted(), "My Agent <agent@example.com>");
}
#[test]
fn email_message_builder() {
let from = EmailIdentity::new("agent@example.com", "Agent");
let msg = EmailMessage::new(&from, "user@example.com", "Hello", "Hi there")
.with_html("<p>Hi there</p>")
.with_reply_to("noreply@example.com");
assert_eq!(msg.from, "Agent <agent@example.com>");
assert_eq!(msg.to, "user@example.com");
assert_eq!(msg.subject, "Hello");
assert_eq!(msg.body_text, "Hi there");
assert_eq!(msg.body_html.as_deref(), Some("<p>Hi there</p>"));
assert_eq!(msg.headers.get("Reply-To").unwrap(), "noreply@example.com");
}
#[test]
fn http_config_mailgun() {
let cfg = HttpEmailConfig::mailgun("mg.example.com", "key-123");
matches!(cfg.backend, HttpEmailBackend::Mailgun { .. });
assert_eq!(cfg.api_key, "key-123");
}
#[test]
fn http_config_custom_with_inbox() {
let cfg = HttpEmailConfig::custom("https://api.agentmail.to/send", "tok-abc")
.with_inbox_url("https://api.agentmail.to/inbox");
assert_eq!(
cfg.inbox_url.as_deref(),
Some("https://api.agentmail.to/inbox")
);
}
#[test]
fn provider_construction() {
let cfg = HttpEmailConfig::resend("re_abc123");
let _provider = HttpEmailProvider::new(cfg);
}
}