use anyhow::{Context, Result};
use lettre::{
message::Message as LettreMessage,
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
};
use crate::config::Config;
use crate::store::MailStore;
pub const MAIL_SUBJECT: &str = "[mailrs_mail]";
pub async fn send_smtp(cfg: &Config, to: &str, body: &str) -> Result<()> {
let from_addr = match &cfg.display_name {
Some(name) => format!("{} <{}>", name, cfg.identity),
None => cfg.identity.clone(),
};
let email = LettreMessage::builder()
.from(from_addr.parse().context("Invalid from address")?)
.to(to.parse().context("Invalid to address")?)
.subject(MAIL_SUBJECT)
.body(body.to_string())
.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) = if let Some(bytes) = raw.body() {
let body_text = extract_body_text(bytes);
let mid = extract_message_id(bytes);
(body_text, mid)
} else {
(String::new(), None)
};
if body.is_empty() {
continue;
}
store.deliver(&from_addr, &body, display_name.as_deref(), uid, message_id)?;
}
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 {
part.ctype.params.get("filename").is_some()
}
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
}
}
}
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);
}
}