use anyhow::{bail, Context, Result};
use lettre::{
message::{header::ContentType, Attachment, Message as LettreMessage, MultiPart, SinglePart},
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
};
use crate::config::Config;
use crate::store::MailStore;
pub const MAIL_SUBJECT: &str = "[mailrs_mail]";
pub const MAX_ATTACHMENT_BYTES: u64 = 10 * 1024 * 1024;
pub async fn send_smtp(cfg: &Config, to: &str, body: &str, attachments: &[String]) -> Result<()> {
let from_addr = match &cfg.display_name {
Some(name) => format!("{} <{}>", name, cfg.identity),
None => cfg.identity.clone(),
};
let builder = LettreMessage::builder()
.from(from_addr.parse().context("Invalid from address")?)
.to(to.parse().context("Invalid to address")?)
.subject(MAIL_SUBJECT);
let email = if attachments.is_empty() {
builder
.body(body.to_string())
.context("Failed to build email")?
} else {
let text_ct = ContentType::parse("text/plain; charset=utf-8")
.context("Invalid content type")?;
let mut mp = MultiPart::mixed().singlepart(SinglePart::plain(body.to_string()));
for path in attachments {
let data = std::fs::read(path)
.with_context(|| format!("Cannot read attachment: {}", path))?;
if data.len() as u64 > MAX_ATTACHMENT_BYTES {
bail!("Attachment exceeds 10 MB limit: {}", path);
}
let filename = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "attachment.txt".to_string());
mp = mp.singlepart(Attachment::new(filename).body(data, text_ct.clone()));
}
builder.multipart(mp).context("Failed to build email")?
};
let creds = Credentials::new(
cfg.smtp.username.clone(),
cfg.smtp.password.clone(),
);
let transport: AsyncSmtpTransport<Tokio1Executor> = if cfg.smtp.tls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp.host)
.context("SMTP STARTTLS relay failed")?
.port(cfg.smtp.port)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp.host)
.port(cfg.smtp.port)
.credentials(creds)
.build()
};
transport
.send(email)
.await
.context("SMTP send failed")?;
Ok(())
}
pub async fn fetch_imap(cfg: &Config, store: &MailStore) -> Result<()> {
use std::net::TcpStream;
use native_tls::TlsConnector;
use imap::Client;
let domain = cfg.imap.host.clone();
let port = cfg.imap.port;
let tls = TlsConnector::builder()
.build()
.context("TLS connector failed")?;
let addr = format!("{}:{}", domain, port);
let stream = TcpStream::connect(&addr)
.context("TCP connection failed")?;
let tls_stream = tls.connect(&domain, stream)
.context("IMAP TLS handshake failed")?;
let client = Client::new(tls_stream);
let mut imap_session = client
.login(&cfg.imap.username, &cfg.imap.password)
.map_err(|(err, _)| err)
.context("IMAP login failed")?;
imap_session
.select(&cfg.imap.inbox_folder)
.context("IMAP SELECT failed")?;
let uids = imap_session
.uid_search(format!("UNSEEN SUBJECT \"{}\"", MAIL_SUBJECT))
.context("IMAP SEARCH failed")?;
if uids.is_empty() {
imap_session.logout().ok();
return Ok(());
}
let uid_set: String = uids
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
let messages = imap_session
.uid_fetch(&uid_set, "(RFC822 ENVELOPE)")
.context("IMAP FETCH failed")?;
for raw in messages.iter() {
let (from_addr, display_name) = if let Some(envelope) = raw.envelope() {
parse_envelope_from(envelope)
} else {
("unknown@unknown".to_string(), None)
};
let body = if let Some(bytes) = raw.body() {
extract_body_text(bytes)
} else {
String::new()
};
if !body.is_empty() {
store.deliver(&from_addr, &body, display_name.as_deref())?;
}
}
imap_session.logout().ok();
Ok(())
}
fn extract_body_text(raw: &[u8]) -> String {
match mailparse::parse_mail(raw) {
Ok(parsed) => extract_text_from_part(&parsed)
.map(|s| s.trim().to_string())
.unwrap_or_default(),
Err(_) => {
let text = String::from_utf8_lossy(raw);
if let Some(pos) = text.find("\r\n\r\n") {
text[pos + 4..].trim().to_string()
} else if let Some(pos) = text.find("\n\n") {
text[pos + 2..].trim().to_string()
} else {
text.trim().to_string()
}
}
}
}
fn extract_text_from_part(part: &mailparse::ParsedMail) -> Option<String> {
if part.subparts.is_empty() {
if part.ctype.mimetype.starts_with("text/") {
if let Ok(body) = part.get_body() {
if !body.trim().is_empty() {
return Some(body);
}
}
}
return None;
}
for sp in &part.subparts {
if sp.ctype.mimetype == "text/plain" {
if let Ok(body) = sp.get_body() {
if !body.trim().is_empty() {
return Some(body);
}
}
}
}
for sp in &part.subparts {
if let Some(body) = extract_text_from_part(sp) {
return Some(body);
}
}
None
}
fn parse_envelope_from(envelope: &imap_proto::types::Envelope) -> (String, Option<String>) {
if let Some(addresses) = &envelope.from {
if let Some(addr) = addresses.first() {
let mailbox = addr
.mailbox
.as_ref()
.map(|b| String::from_utf8_lossy(b).to_string())
.unwrap_or_default();
let host = addr
.host
.as_ref()
.map(|b| String::from_utf8_lossy(b).to_string())
.unwrap_or_default();
let name = addr.name.as_ref().map(|b| String::from_utf8_lossy(b).to_string());
return (format!("{}@{}", mailbox, host), name);
}
}
("unknown@unknown".to_string(), None)
}