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
This commit is contained in:
+288
@@ -0,0 +1,288 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user