Skip to content

Commit b55c27f

Browse files
committed
Add email forwarding to external SMTP servers
1 parent 31d5061 commit b55c27f

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ chrono = { version = "0.4", features = ["serde"] }
3434
futures-util = "0.3"
3535
base64 = "0.22"
3636
rusqlite = { version = "0.32", features = ["bundled"] }
37+
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
3738

3839
[dev-dependencies]
3940
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A minimal SMTP sink for local development and testing. Receives emails via SMTP
1717
- **Attachment parsing and download** via API
1818
- **SQLite persistence** (optional) - survive restarts
1919
- **Search/filter API** - query by from, to, subject, date
20+
- **Email forwarding** - relay to external SMTP server
2021

2122
## Installation
2223

@@ -90,6 +91,13 @@ smtp-sink --tls --tls-key ./key.pem --tls-cert ./cert.pem
9091

9192
# With SQLite persistence (emails survive restart)
9293
smtp-sink --db ./emails.db
94+
95+
# Forward all emails to another server
96+
smtp-sink --forward-host smtp.example.com --forward-port 587 --forward-tls \
97+
--forward-username user --forward-password pass
98+
99+
# Forward with recipient override (catch-all redirect)
100+
smtp-sink --forward-host smtp.example.com --forward-to dev@example.com
93101
```
94102

95103
## Options
@@ -108,6 +116,14 @@ smtp-sink --db ./emails.db
108116
--auth-username <USER> Username for SMTP AUTH
109117
--auth-password <PASS> Password for SMTP AUTH
110118
--db <PATH> SQLite database path for persistence
119+
--forward-host <HOST> Forward emails to this SMTP host
120+
--forward-port <PORT> Forward SMTP port (default: 587/465)
121+
--forward-tls Use STARTTLS when forwarding
122+
--forward-implicit-tls Use implicit TLS (SMTPS) when forwarding
123+
--forward-username <U> SMTP username for forwarding
124+
--forward-password <P> SMTP password for forwarding
125+
--forward-to <ADDR> Override all recipients (catch-all redirect)
126+
--forward-from <ADDR> Override sender when forwarding
111127
```
112128

113129
## API

src/forward.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
//! Email forwarding to external SMTP servers.
2+
3+
use lettre::message::Mailbox;
4+
use lettre::transport::smtp::authentication::Credentials;
5+
use lettre::transport::smtp::client::{Tls, TlsParameters};
6+
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
7+
use std::sync::Arc;
8+
use tokio::sync::mpsc;
9+
use tracing::{debug, error, info};
10+
11+
use crate::email::MailRecord;
12+
13+
/// Configuration for email forwarding.
14+
#[derive(Debug, Clone)]
15+
pub struct ForwardConfig {
16+
/// SMTP host to forward to
17+
pub host: String,
18+
/// SMTP port (default: 25 or 587)
19+
pub port: u16,
20+
/// Use TLS (STARTTLS or implicit)
21+
pub tls: bool,
22+
/// Use implicit TLS (SMTPS)
23+
pub implicit_tls: bool,
24+
/// Username for SMTP AUTH
25+
pub username: Option<String>,
26+
/// Password for SMTP AUTH
27+
pub password: Option<String>,
28+
/// Rewrite all recipients to this address
29+
pub to_override: Option<String>,
30+
/// Rewrite sender to this address
31+
pub from_override: Option<String>,
32+
}
33+
34+
impl ForwardConfig {
35+
/// Check if forwarding is configured.
36+
#[must_use]
37+
pub const fn is_enabled(&self) -> bool {
38+
!self.host.is_empty()
39+
}
40+
}
41+
42+
/// Email forwarder that sends emails to an external SMTP server.
43+
pub struct Forwarder {
44+
config: ForwardConfig,
45+
}
46+
47+
impl Forwarder {
48+
/// Create a new forwarder with the given config.
49+
/// Returns the forwarder and a handle for submitting emails.
50+
#[must_use]
51+
pub fn new(config: ForwardConfig) -> (Arc<Self>, ForwardHandle) {
52+
let (tx, rx) = mpsc::channel(100);
53+
let forwarder = Arc::new(Self { config });
54+
55+
// Spawn the forwarding task
56+
let forwarder_clone = Arc::clone(&forwarder);
57+
tokio::spawn(async move {
58+
forwarder_clone.run(rx).await;
59+
});
60+
61+
let handle = ForwardHandle { tx };
62+
(forwarder, handle)
63+
}
64+
65+
async fn run(self: Arc<Self>, mut rx: mpsc::Receiver<MailRecord>) {
66+
info!(
67+
"Email forwarder started, forwarding to {}:{}",
68+
self.config.host, self.config.port
69+
);
70+
71+
while let Some(email) = rx.recv().await {
72+
if let Err(e) = self.forward_email(&email).await {
73+
error!("Failed to forward email {}: {}", email.id, e);
74+
} else {
75+
debug!("Forwarded email {} to {}", email.id, self.config.host);
76+
}
77+
}
78+
}
79+
80+
async fn forward_email(&self, email: &MailRecord) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
81+
// Build the email message
82+
let from: Mailbox = self
83+
.config
84+
.from_override
85+
.as_ref()
86+
.unwrap_or(&email.from)
87+
.parse()
88+
.map_err(|_| format!("Invalid from address: {}", email.from))?;
89+
90+
// Determine recipients
91+
let recipients: Vec<String> = self.config.to_override.as_ref().map_or_else(
92+
|| email.to.clone(),
93+
|override_to| vec![override_to.clone()],
94+
);
95+
96+
if recipients.is_empty() {
97+
return Err("No recipients".into());
98+
}
99+
100+
// Build message
101+
let mut builder = Message::builder()
102+
.from(from)
103+
.subject(email.subject.as_deref().unwrap_or("(no subject)"));
104+
105+
for recipient in &recipients {
106+
let mailbox: Mailbox = recipient
107+
.parse()
108+
.map_err(|_| format!("Invalid recipient: {recipient}"))?;
109+
builder = builder.to(mailbox);
110+
}
111+
112+
// Add Date header
113+
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&email.date) {
114+
builder = builder.date(std::time::SystemTime::from(dt));
115+
}
116+
117+
// Create the message with body
118+
let message = if let Some(ref html) = email.html {
119+
if let Some(ref text) = email.text {
120+
builder.multipart(
121+
lettre::message::MultiPart::alternative()
122+
.singlepart(
123+
lettre::message::SinglePart::builder()
124+
.header(lettre::message::header::ContentType::TEXT_PLAIN)
125+
.body(text.clone()),
126+
)
127+
.singlepart(
128+
lettre::message::SinglePart::builder()
129+
.header(lettre::message::header::ContentType::TEXT_HTML)
130+
.body(html.clone()),
131+
),
132+
)?
133+
} else {
134+
builder.header(lettre::message::header::ContentType::TEXT_HTML).body(html.clone())?
135+
}
136+
} else if let Some(ref text) = email.text {
137+
builder.header(lettre::message::header::ContentType::TEXT_PLAIN).body(text.clone())?
138+
} else {
139+
builder.body(String::new())?
140+
};
141+
142+
// Build SMTP transport
143+
let transport = self.build_transport()?;
144+
145+
// Send the email
146+
transport.send(message).await?;
147+
148+
Ok(())
149+
}
150+
151+
fn build_transport(
152+
&self,
153+
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error + Send + Sync>> {
154+
let mut builder = if self.config.implicit_tls {
155+
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.config.host)?
156+
} else if self.config.tls {
157+
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.config.host)?
158+
} else {
159+
let tls_params = TlsParameters::builder(self.config.host.clone())
160+
.dangerous_accept_invalid_certs(true)
161+
.build()?;
162+
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.config.host)
163+
.tls(Tls::Opportunistic(tls_params))
164+
};
165+
166+
builder = builder.port(self.config.port);
167+
168+
if let (Some(ref user), Some(ref pass)) = (&self.config.username, &self.config.password) {
169+
builder = builder.credentials(Credentials::new(user.clone(), pass.clone()));
170+
}
171+
172+
Ok(builder.build())
173+
}
174+
}
175+
176+
/// Handle for submitting emails to be forwarded.
177+
#[derive(Clone)]
178+
pub struct ForwardHandle {
179+
tx: mpsc::Sender<MailRecord>,
180+
}
181+
182+
impl ForwardHandle {
183+
/// Queue an email for forwarding.
184+
pub async fn forward(&self, email: MailRecord) {
185+
if let Err(e) = self.tx.send(email).await {
186+
error!("Failed to queue email for forwarding: {}", e);
187+
}
188+
}
189+
}

src/lib.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
//! SMTP sink library for receiving and exposing emails via HTTP.
22
33
mod email;
4+
mod forward;
45
mod http;
56
mod smtp;
67
mod sqlite_store;
78
mod store;
89
mod tls;
910

1011
pub use email::{Attachment, MailRecord};
12+
pub use forward::{ForwardConfig, ForwardHandle, Forwarder};
1113
pub use smtp::SmtpConfig;
1214
pub use sqlite_store::SqliteStore;
1315
pub use store::{EmailQuery, EmailStorage, EmailStore, MemoryStore};
@@ -41,6 +43,22 @@ pub struct SinkOptions {
4143
pub auth_password: Option<String>,
4244
/// `SQLite` database path for persistence
4345
pub db_path: Option<String>,
46+
/// Forward emails to external SMTP server
47+
pub forward_host: Option<String>,
48+
/// Forward SMTP port
49+
pub forward_port: Option<u16>,
50+
/// Use TLS for forwarding
51+
pub forward_tls: bool,
52+
/// Use implicit TLS for forwarding
53+
pub forward_implicit_tls: bool,
54+
/// Forward SMTP username
55+
pub forward_username: Option<String>,
56+
/// Forward SMTP password
57+
pub forward_password: Option<String>,
58+
/// Override all recipients when forwarding
59+
pub forward_to: Option<String>,
60+
/// Override sender when forwarding
61+
pub forward_from: Option<String>,
4462
}
4563

4664
/// Running server handles.
@@ -116,6 +134,25 @@ pub async fn start_sink(opts: SinkOptions) -> std::io::Result<RunningServers> {
116134
http_addr.port()
117135
);
118136

137+
// Set up email forwarding if configured
138+
let forward_handle: Option<ForwardHandle> = if let Some(ref host) = opts.forward_host {
139+
let forward_config = ForwardConfig {
140+
host: host.clone(),
141+
port: opts.forward_port.unwrap_or(if opts.forward_implicit_tls { 465 } else { 587 }),
142+
tls: opts.forward_tls,
143+
implicit_tls: opts.forward_implicit_tls,
144+
username: opts.forward_username.clone(),
145+
password: opts.forward_password.clone(),
146+
to_override: opts.forward_to.clone(),
147+
from_override: opts.forward_from.clone(),
148+
};
149+
let (_, handle) = Forwarder::new(forward_config);
150+
println!("Forwarding emails to {}:{}", host, opts.forward_port.unwrap_or(587));
151+
Some(handle)
152+
} else {
153+
None
154+
};
155+
119156
// Build SMTP config
120157
let smtp_config = SmtpConfig {
121158
whitelist: whitelist.clone(),
@@ -127,6 +164,7 @@ pub async fn start_sink(opts: SinkOptions) -> std::io::Result<RunningServers> {
127164
auth_required: opts.auth_required,
128165
auth_username: opts.auth_username.clone(),
129166
auth_password: opts.auth_password.clone(),
167+
forward_handle,
130168
};
131169

132170
// Start SMTP server task

src/main.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ struct Cli {
6060
/// `SQLite` database path for persistence (e.g., ./emails.db)
6161
#[arg(long)]
6262
db: Option<String>,
63+
64+
/// Forward emails to this SMTP host
65+
#[arg(long)]
66+
forward_host: Option<String>,
67+
68+
/// Forward SMTP port (default: 587 for STARTTLS, 465 for implicit TLS)
69+
#[arg(long)]
70+
forward_port: Option<u16>,
71+
72+
/// Use STARTTLS when forwarding
73+
#[arg(long)]
74+
forward_tls: bool,
75+
76+
/// Use implicit TLS (SMTPS) when forwarding
77+
#[arg(long)]
78+
forward_implicit_tls: bool,
79+
80+
/// SMTP username for forwarding
81+
#[arg(long)]
82+
forward_username: Option<String>,
83+
84+
/// SMTP password for forwarding
85+
#[arg(long)]
86+
forward_password: Option<String>,
87+
88+
/// Override all recipients when forwarding (catch-all redirect)
89+
#[arg(long)]
90+
forward_to: Option<String>,
91+
92+
/// Override sender when forwarding
93+
#[arg(long)]
94+
forward_from: Option<String>,
6395
}
6496

6597
#[tokio::main]
@@ -89,6 +121,14 @@ async fn main() -> std::io::Result<()> {
89121
auth_username: cli.auth_username,
90122
auth_password: cli.auth_password,
91123
db_path: cli.db,
124+
forward_host: cli.forward_host,
125+
forward_port: cli.forward_port,
126+
forward_tls: cli.forward_tls,
127+
forward_implicit_tls: cli.forward_implicit_tls,
128+
forward_username: cli.forward_username,
129+
forward_password: cli.forward_password,
130+
forward_to: cli.forward_to,
131+
forward_from: cli.forward_from,
92132
};
93133

94134
let servers = start_sink(opts).await?;

src/smtp.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! SMTP server implementation with AUTH and STARTTLS support.
22
33
use crate::email::MailRecord;
4+
use crate::forward::ForwardHandle;
45
use crate::store::EmailStorage;
56
use base64::prelude::*;
67
use mail_parser::{MessageParser, MimeHeaders};
@@ -23,6 +24,7 @@ pub struct SmtpConfig {
2324
pub auth_required: bool,
2425
pub auth_username: Option<String>,
2526
pub auth_password: Option<String>,
27+
pub forward_handle: Option<ForwardHandle>,
2628
}
2729

2830
/// Run the SMTP server (plain text, with optional STARTTLS).
@@ -407,6 +409,12 @@ where
407409

408410
let data = read_data(&mut stream.inner).await?;
409411
let record = parse_email(&data, session);
412+
413+
// Forward email if configured
414+
if let Some(ref fwd) = config.forward_handle {
415+
fwd.forward(record.clone()).await;
416+
}
417+
410418
store.push(record);
411419
session.reset();
412420

0 commit comments

Comments
 (0)