use lettre::{
message::{Mailbox, MultiPart, SinglePart},
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct MailConfig {
pub smtp_server: String,
pub username: String,
pub auth_code: String,
pub from_name: String,
}
#[derive(Debug, Clone)]
pub struct MailMessage {
to: Option<String>,
subject: Option<String>,
body: Option<String>,
is_html: bool,
attachments: Vec<(PathBuf, Option<String>)>,
}
impl MailMessage {
pub fn new() -> Self {
Self {
to: None,
subject: None,
body: None,
is_html: false,
attachments: vec![],
}
}
pub fn to(mut self, to: impl Into<String>) -> Self {
self.to = Some(to.into());
self
}
pub fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into());
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn html(mut self, is_html: bool) -> Self {
self.is_html = is_html;
self
}
pub fn attach(mut self, path: impl Into<PathBuf>) -> Self {
self.attachments.push((path.into(), None));
self
}
pub async fn send(self, config: &MailConfig) -> Result<(), Box<dyn std::error::Error>> {
let from: Mailbox = config.from_name.parse()
.map_err(|e| format!("无效的发件人地址 '{}': {}", config.from_name, e))?;
let to: Mailbox = self.to.ok_or("缺少收件人")?.parse()
.map_err(|e| format!("无效的收件人地址: {}", e))?;
let subject = self.subject.ok_or("缺少主题")?;
let body = self.body.ok_or("缺少正文")?;
let email = if self.attachments.is_empty() {
let mut builder = Message::builder()
.from(from.clone())
.to(to.clone())
.subject(subject);
if self.is_html {
builder = builder.header(lettre::message::header::ContentType::TEXT_HTML);
}
builder.body(body)?
} else {
let mut multipart = MultiPart::mixed()
.singlepart(if self.is_html {
SinglePart::html(body)
} else {
SinglePart::plain(body)
});
for (path, mime_override) in &self.attachments {
let data = std::fs::read(path)
.map_err(|e| format!("读取附件失败 '{}': {}", path.display(), e))?;
let filename = path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let mime = mime_override.clone()
.unwrap_or_else(|| guess_mime(path));
let part = SinglePart::builder()
.header(lettre::message::header::ContentType::parse(&mime)?)
.header(lettre::message::header::ContentDisposition::attachment(&filename))
.body(data);
multipart = multipart.singlepart(part);
}
Message::builder()
.from(from.clone())
.to(to.clone())
.subject(subject)
.multipart(multipart)?
};
let creds = lettre::transport::smtp::authentication::Credentials::new(
config.username.clone(),
config.auth_code.clone(),
);
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_server)?
.credentials(creds)
.port(465)
.build();
mailer.send(email).await?;
Ok(())
}
}
fn guess_mime(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()) {
Some("mp4") => "video/mp4",
Some("mkv") => "video/x-matroska",
Some("webm") => "video/webm",
Some("avi") => "video/x-msvideo",
Some("mov") => "video/quicktime",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("flac") => "audio/flac",
Some("ogg") => "audio/ogg",
Some("pdf") => "application/pdf",
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz") => "application/gzip",
Some("txt") => "text/plain",
Some("html") | Some("htm") => "text/html",
Some("json") => "application/json",
_ => "application/octet-stream",
}.into()
}
impl Default for MailMessage {
fn default() -> Self {
Self::new()
}
}
impl MailConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
let data = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&data)?)
}
}