use anyhow::{bail, Context, Result};
use base64::Engine;
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::{Attachment as StoreAttachment, MailStore};
pub const MAIL_SUBJECT: &str = "[mailrs_mail]";
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 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 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 25 MB limit: {}", path);
}
let filename = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "attachment".to_string());
let ct = ContentType::parse("application/octet-stream").unwrap();
mp = mp.singlepart(Attachment::new(filename).body(data, ct));
}
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_set = imap_session
.uid_search(format!("SUBJECT \"{}\"", MAIL_SUBJECT))
.context("IMAP SEARCH failed")?;
if uids_set.is_empty() {
imap_session.logout().ok();
return Ok(());
}
let mut uids: Vec<u32> = uids_set.into_iter().collect();
uids.sort();
if uids.len() > 50 {
uids = uids.into_iter().rev().take(50).collect::<Vec<_>>();
uids.reverse(); }
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 uid = raw.uid;
let (body, message_id, attachments) = if let Some(bytes) = raw.body() {
let body_text = extract_body_text(bytes);
let mid = extract_message_id(bytes);
let atts = extract_attachments(bytes);
(body_text, mid, atts)
} else {
(String::new(), None, Vec::new())
};
if body.is_empty() && attachments.is_empty() {
continue;
}
store.deliver(&from_addr, &body, display_name.as_deref(), uid, message_id, attachments)?;
}
imap_session.logout().ok();
Ok(())
}
pub async fn imap_move_to_trash(cfg: &Config, uid: u32) -> 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 session = client
.login(&cfg.imap.username, &cfg.imap.password)
.map_err(|(err, _)| err)
.context("IMAP login failed")?;
session
.select(&cfg.imap.inbox_folder)
.context("IMAP SELECT failed")?;
let uid_str = uid.to_string();
session
.uid_copy(&uid_str, &cfg.imap.trash_folder)
.context("IMAP UID COPY to trash failed")?;
session
.uid_store(&uid_str, "+FLAGS (\\Deleted)")
.context("IMAP UID STORE \\Deleted failed")?;
session.expunge().context("IMAP EXPUNGE failed")?;
session.logout().ok();
Ok(())
}
pub fn extract_body_text_pub(raw: &[u8]) -> String {
let text = String::from_utf8_lossy(raw);
let boundary = text.lines().find_map(|line| {
let line = line.trim();
if line.starts_with("--") && line.len() > 2 && !line.ends_with("--") {
Some(line[2..].to_string())
} else {
None
}
});
if let Some(b) = boundary {
let wrapped = format!(
"From: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n{}",
b, text
);
let result = extract_body_text(wrapped.as_bytes());
if !result.is_empty() {
return result;
}
}
text.trim().to_string()
}
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/") && !is_attachment(part) {
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" && !is_attachment(sp) {
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 is_attachment(part: &mailparse::ParsedMail) -> bool {
let cd = part.get_content_disposition();
if cd.disposition == mailparse::DispositionType::Attachment {
return true;
}
if cd.params.contains_key("filename") {
return true;
}
part.ctype.params.contains_key("name") || part.ctype.params.contains_key("filename")
}
fn attachment_filename(part: &mailparse::ParsedMail) -> String {
let cd = part.get_content_disposition();
if let Some(f) = cd.params.get("filename") {
return f.clone();
}
if let Some(n) = part.ctype.params.get("name") {
return n.clone();
}
if let Some(f) = part.ctype.params.get("filename") {
return f.clone();
}
"attachment.bin".to_string()
}
fn extract_attachments(raw: &[u8]) -> Vec<StoreAttachment> {
match mailparse::parse_mail(raw) {
Ok(parsed) => collect_attachments_from_part(&parsed),
Err(_) => Vec::new(),
}
}
fn collect_attachments_from_part(part: &mailparse::ParsedMail) -> Vec<StoreAttachment> {
let mut attachments = Vec::new();
for sp in &part.subparts {
attachments.extend(collect_attachments_from_part(sp));
}
if part.subparts.is_empty() && is_attachment(part) {
if let Ok(data) = part.get_body_raw() {
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
attachments.push(StoreAttachment {
filename: attachment_filename(part),
size: data.len() as u64,
mime_type: part.ctype.mimetype.clone(),
data: encoded,
});
}
}
attachments
}
fn extract_message_id(raw: &[u8]) -> Option<String> {
match mailparse::parse_mail(raw) {
Ok(parsed) => parsed
.headers
.iter()
.find(|h| h.get_key_ref().eq_ignore_ascii_case("message-id"))
.map(|h| h.get_value().trim().to_string()),
Err(_) => {
let text = String::from_utf8_lossy(raw);
for line in text.lines() {
if line.to_ascii_lowercase().starts_with("message-id:") {
return Some(line[11..].trim().to_string());
}
if line.trim().is_empty() {
break;
}
}
None
}
}
}
pub fn guess_mime(path: &str) -> &'static str {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"txt" | "log" | "text" => "text/plain",
"md" => "text/markdown",
"csv" => "text/csv",
"html" | "htm" => "text/html",
"json" => "application/json",
"xml" => "application/xml",
"pdf" => "application/pdf",
"zip" => "application/zip",
"gz" | "tgz" => "application/gzip",
"tar" => "application/x-tar",
"7z" => "application/x-7z-compressed",
"rar" => "application/vnd.rar",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
_ => "application/octet-stream",
}
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_migration() {
let raw = "--000000000000626b900653087fa1\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nRem sama sana duydu=C4=9Fum duygular...\r\n\r\n--000000000000626b900653087fa1\r\nContent-Type: text/html; charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<div>Rem sama sana duydu=C4=9Fum duygular...</div>\r\n\r\n--000000000000626b900653087fa1--\r\n";
let result = extract_body_text_pub(raw.as_bytes());
assert_eq!(result, "Rem sama sana duyduğum duygular...", "got: {:?}", result);
}
#[test]
fn test_body_vs_text_attachment() {
let raw = "From: a@b.com\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"BOUND\"\r\n\r\n--BOUND\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThis is the real body.\r\n--BOUND\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\nek metin testi\r\n--BOUND--\r\n";
let body = extract_body_text(raw.as_bytes());
assert_eq!(body, "This is the real body.", "body got: {:?}", body);
let atts = extract_attachments(raw.as_bytes());
assert_eq!(atts.len(), 1, "expected exactly one attachment");
assert_eq!(atts[0].filename, "notes.txt");
let decoded = base64::engine::general_purpose::STANDARD
.decode(&atts[0].data)
.unwrap();
assert_eq!(String::from_utf8_lossy(&decoded).trim(), "ek metin testi");
}
}