5fa7a82548
- 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
289 lines
9.1 KiB
Rust
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()
|
|
}
|