Files
mailks/email_build.rs
T
zhenyi 5fa7a82548 chore(project): initialize project with core configuration and dependencies
- Add .gitignore and .env.example files for project setup
- Create build script for proto compilation with tonic-prost
- Generate Cargo.lock with all project dependencies
- Configure project structure and ignore patterns for development environment
2026-06-07 22:46:30 +08:00

289 lines
9.1 KiB
Rust

use lettre::{
Address, AsyncSmtpTransport, Message, Tokio1Executor,
message::{
Attachment as LettreAttachment, Body, Mailbox, MultiPart, SinglePart,
header::{ContentType, HeaderName, HeaderValue},
},
transport::smtp::{self, authentication::Credentials, extension::ClientId},
};
use crate::{
config::{SmtpConfig, SmtpTls},
error::EmailError,
pb::email::v1::{Attachment, EmailAddress, EmailPriority, SendEmailRequest},
};
pub(crate) fn build_mailer(
config: &SmtpConfig,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, EmailError> {
let mut builder = match config.tls {
SmtpTls::None => {
smtp::AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
}
SmtpTls::StartTls => {
smtp::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
.map_err(|e| EmailError::BuildTransport(e.to_string()))?
}
SmtpTls::Tls => smtp::AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.map_err(|e| EmailError::BuildTransport(e.to_string()))?,
}
.port(config.port)
.timeout(Some(config.timeout));
match (&config.username, &config.password) {
(Some(u), Some(p)) => {
builder = builder.credentials(Credentials::new(u.clone(), p.clone()));
}
(None, None) => {}
_ => return Err(EmailError::IncompleteCredentials),
}
if let Some(name) = config.helo_name.as_deref().and_then(non_empty) {
builder = builder.hello_name(ClientId::Domain(name.to_owned()));
}
Ok(builder.build())
}
pub(crate) fn build_body(
builder: lettre::message::MessageBuilder,
request: &SendEmailRequest,
) -> Result<Message, EmailError> {
match select_body_multipart(request) {
BodyKind::Both(mp) => builder
.multipart(mp)
.map_err(|e| EmailError::BuildMessage(e.to_string())),
BodyKind::Html(sp) => builder
.singlepart(sp)
.map_err(|e| EmailError::BuildMessage(e.to_string())),
BodyKind::Plain(sp) => builder
.singlepart(sp)
.map_err(|e| EmailError::BuildMessage(e.to_string())),
}
}
pub(crate) fn build_mixed_body(request: &SendEmailRequest) -> MultiPart {
let mixed = MultiPart::mixed();
match select_body_multipart(request) {
BodyKind::Both(mp) => mixed.multipart(mp),
BodyKind::Html(sp) => mixed.singlepart(sp),
BodyKind::Plain(sp) => mixed.singlepart(sp),
}
}
enum BodyKind {
Both(MultiPart),
Html(SinglePart),
Plain(SinglePart),
}
fn select_body_multipart(request: &SendEmailRequest) -> BodyKind {
if !request.text_body.is_empty() && !request.html_body.is_empty() {
BodyKind::Both(MultiPart::alternative_plain_html(
request.text_body.clone(),
request.html_body.clone(),
))
} else if !request.html_body.is_empty() {
BodyKind::Html(SinglePart::html(request.html_body.clone()))
} else {
BodyKind::Plain(SinglePart::plain(request.text_body.clone()))
}
}
pub(crate) fn build_single_attachment(
index: usize,
att: &Attachment,
) -> Result<SinglePart, EmailError> {
let filename = if att.filename.trim().is_empty() {
format!("attachment-{index}")
} else {
att.filename.clone()
};
if att.data.is_empty() && !att.url.trim().is_empty() {
return Err(EmailError::UnsupportedAttachmentUrl {
filename,
url: att.url.clone(),
});
}
let ct: ContentType = if att.content_type.trim().is_empty() {
"application/octet-stream"
.parse()
.expect("valid static mime")
} else {
att.content_type
.parse()
.map_err(|e: lettre::message::header::ContentTypeErr| {
EmailError::InvalidContentType {
filename: filename.clone(),
value: att.content_type.clone(),
reason: e.to_string(),
}
})?
};
Ok(LettreAttachment::new(filename).body(Body::new(att.data.clone()), ct))
}
pub(crate) fn mailbox_from_email_address(
field: &'static str,
value: &EmailAddress,
) -> Result<Mailbox, EmailError> {
mailbox_from_parts(field, &value.email, non_empty(&value.name))
}
pub(crate) fn mailbox_from_parts(
field: &'static str,
email: &str,
name: Option<&str>,
) -> Result<Mailbox, EmailError> {
let address = email
.trim()
.parse::<Address>()
.map_err(|e| EmailError::InvalidAddress {
field,
value: email.to_owned(),
reason: e.to_string(),
})?;
Ok(Mailbox::new(name.map(ToOwned::to_owned), address))
}
pub(crate) fn apply_custom_headers(
mut builder: lettre::message::MessageBuilder,
request: &SendEmailRequest,
) -> Result<lettre::message::MessageBuilder, EmailError> {
for (name, value) in &request.headers {
if non_empty(name).is_none() {
continue;
}
if is_managed_header(name) {
return Err(EmailError::ForbiddenHeader { name: name.clone() });
}
if value.contains(['\r', '\n']) {
return Err(EmailError::InvalidHeader {
name: name.clone(),
reason: "header value must not contain CR or LF".to_owned(),
});
}
let hn =
HeaderName::new_from_ascii(name.clone()).map_err(|e| EmailError::InvalidHeader {
name: name.clone(),
reason: e.to_string(),
})?;
builder = builder.raw_header(HeaderValue::new(hn, value.clone()));
}
Ok(builder)
}
pub(crate) fn apply_priority_headers(
builder: lettre::message::MessageBuilder,
priority: i32,
) -> lettre::message::MessageBuilder {
match EmailPriority::try_from(priority).unwrap_or(EmailPriority::Unspecified) {
EmailPriority::High => builder
.raw_header(hv("X-Priority", "1"))
.raw_header(hv("Importance", "high")),
EmailPriority::Low => builder
.raw_header(hv("X-Priority", "5"))
.raw_header(hv("Importance", "low")),
EmailPriority::Normal | EmailPriority::Unspecified => builder,
}
}
fn hv(name: &'static str, value: &str) -> HeaderValue {
HeaderValue::new(HeaderName::new_from_ascii_str(name), value.to_owned())
}
fn is_managed_header(name: &str) -> bool {
matches!(
name.trim().to_ascii_lowercase().as_str(),
"from"
| "to"
| "cc"
| "bcc"
| "subject"
| "reply-to"
| "date"
| "mime-version"
| "content-type"
| "content-transfer-encoding"
| "content-disposition"
)
}
pub(crate) fn non_empty(value: &str) -> Option<&str> {
let v = value.trim();
(!v.is_empty()).then_some(v)
}
pub fn build_message_from_parts(
config: &SmtpConfig,
request: &SendEmailRequest,
) -> Result<Message, EmailError> {
if request.to.is_empty() {
return Err(EmailError::MissingRecipients);
}
if request.text_body.is_empty() && request.html_body.is_empty() {
return Err(EmailError::BuildMessage(
"email must have at least a text or HTML body".to_owned(),
));
}
let from = resolve_sender(config, request)?;
let mut builder = Message::builder()
.from(from)
.subject(request.subject.clone());
for r in &request.to {
builder = builder.to(mailbox_from_email_address("to", r)?);
}
for r in &request.cc {
builder = builder.cc(mailbox_from_email_address("cc", r)?);
}
for r in &request.bcc {
builder = builder.bcc(mailbox_from_email_address("bcc", r)?);
}
if let Some(reply_to) = resolve_reply_to(config, request)? {
builder = builder.reply_to(reply_to);
}
builder = apply_custom_headers(builder, request)?;
builder = apply_priority_headers(builder, request.priority);
if request.attachments.is_empty() {
return build_body(builder, request);
}
let mut mixed = build_mixed_body(request);
for (i, att) in request.attachments.iter().enumerate() {
mixed = mixed.singlepart(build_single_attachment(i, att)?);
}
builder
.multipart(mixed)
.map_err(|e| EmailError::BuildMessage(e.to_string()))
}
fn resolve_sender(config: &SmtpConfig, request: &SendEmailRequest) -> Result<Mailbox, EmailError> {
if let Some(from) = &request.from
&& !from.email.trim().is_empty()
{
if !config.allow_request_from {
return Err(EmailError::RequestSenderDisabled);
}
return mailbox_from_email_address("from", from);
}
let email = config
.from_email
.as_deref()
.filter(|v| !v.trim().is_empty())
.ok_or(EmailError::MissingSender)?;
mailbox_from_parts("from", email, config.from_name.as_deref())
}
fn resolve_reply_to(
config: &SmtpConfig,
request: &SendEmailRequest,
) -> Result<Option<Mailbox>, EmailError> {
let email = non_empty(&request.reply_to).or(config.reply_to.as_deref());
email
.map(|v| mailbox_from_parts("reply_to", v, None))
.transpose()
}