use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::application::service::mail_server_query_service::split_address;
use crate::domain::event::{record_channel, stage_bus_event};
use crate::infrastructure::persistence::alias_resolution_repository::AliasResolutionRepository;
use crate::infrastructure::persistence::inbound_repository::InboundRepository;
use crate::infrastructure::persistence::message_pipeline_repository::{
MessagePipelineRepository, NewMailMessageRow,
};
const GATEWAY_OPS_CHANNEL: &str = "mail.gateway_ops";
#[derive(Debug, thiserror::Error)]
pub enum MailInboundError {
#[error("inbound auth failed")]
Auth,
#[error("invalid payload: {0}")]
Invalid(String),
#[error("db: {0}")]
Db(#[from] sqlx::Error),
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct InboundMessage {
pub message_id: Option<String>,
pub from: String,
pub to: Vec<String>,
pub subject: Option<String>,
pub body_html: String,
pub in_reply_to: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InboundOutcome {
Routed {
message_row: Uuid,
model: Option<String>,
res_id: Option<Uuid>,
},
Duplicate { existing: Uuid },
Rejected,
Dropped,
}
pub struct MailInboundService {
pool: sqlx::PgPool,
}
impl MailInboundService {
pub fn new(pool: sqlx::PgPool) -> Self {
Self { pool }
}
pub async fn process_inbound(
&self,
server_id: Uuid,
token: &str,
msg: &InboundMessage,
) -> Result<InboundOutcome, MailInboundError> {
if msg.from.trim().is_empty() || msg.to.is_empty() {
return Err(MailInboundError::Invalid("from and to are required".into()));
}
let default_thread_model = {
let conn = &mut self.pool.acquire().await?;
let server = InboundRepository::find_server(conn, server_id)
.await?
.ok_or(MailInboundError::Auth)?;
if !server.active {
return Err(MailInboundError::Auth);
}
let presented = sha256_hex(token);
if !const_eq(&presented, &server.token_hash.to_ascii_lowercase()) {
return Err(MailInboundError::Auth);
}
server.default_thread_model
};
let mut tx = self.pool.begin().await?;
let outcome = self
.process_in_tx(&mut tx, server_id, default_thread_model.as_deref(), msg)
.await?;
tx.commit().await?;
Ok(outcome)
}
async fn process_in_tx(
&self,
tx: &mut sqlx::PgConnection,
server_id: Uuid,
default_thread_model: Option<&str>,
msg: &InboundMessage,
) -> Result<InboundOutcome, MailInboundError> {
if let Some(mid) = &msg.message_id {
if let Some(existing) = InboundRepository::find_by_message_id(tx, mid).await? {
return Ok(InboundOutcome::Duplicate { existing });
}
}
if !InboundRepository::sender_allowed(tx, server_id, &msg.from).await? {
stage_bus_event(
tx,
"InboundEmailRejected",
"FetchmailServer",
server_id,
GATEWAY_OPS_CHANNEL.to_string(),
"InboundEmailRejected",
serde_json::json!({
"server_id": server_id,
"email_from": msg.from,
"message_id": msg.message_id,
}),
)
.await?;
return Ok(InboundOutcome::Rejected);
}
let (model, res_id) = self.route(tx, default_thread_model, msg).await?;
let Some(model) = model else {
stage_bus_event(
tx,
"InboundEmailDropped",
"FetchmailServer",
server_id,
GATEWAY_OPS_CHANNEL.to_string(),
"InboundEmailDropped",
serde_json::json!({
"server_id": server_id,
"email_from": msg.from,
"to": msg.to,
"reason": "no_parent_no_alias_no_default_model",
}),
)
.await?;
return Ok(InboundOutcome::Dropped);
};
let id = Uuid::new_v4();
let mut race_lost = false;
MessagePipelineRepository::insert_mail_message(
tx,
&NewMailMessageRow {
id,
subject: msg.subject.as_deref(),
body: &msg.body_html,
message_type: "email",
subtype_id: None,
is_internal: false,
author_id: None,
author_guest_id: None,
email_from: Some(&msg.from),
message_id: msg.message_id.as_deref(),
reply_to: None,
model: Some(&model),
res_id,
record_name: None,
},
)
.await
.or_else(|e| match e {
sqlx::Error::Database(ref db)
if db.code().map(|c| c.into_owned()) == Some("23505".into())
&& msg.message_id.is_some() =>
{
race_lost = true;
Ok(())
}
other => Err(other),
})?;
if race_lost {
let mid = msg.message_id.as_deref().unwrap_or_default();
let existing = InboundRepository::find_by_message_id(tx, mid).await?.unwrap_or(id);
return Ok(InboundOutcome::Duplicate { existing });
}
let channel_key = match res_id {
Some(rid) => record_channel(&model, rid),
None => GATEWAY_OPS_CHANNEL.to_string(),
};
stage_bus_event(
tx,
"InboundEmailRouted",
"MailMessage",
id,
channel_key,
"InboundEmailRouted",
serde_json::json!({
"message_row": id,
"model": model,
"res_id": res_id,
"email_from": msg.from,
"server_id": server_id,
}),
)
.await?;
InboundRepository::touch_server_success(tx, server_id, chrono::Utc::now()).await?;
Ok(InboundOutcome::Routed { message_row: id, model: Some(model), res_id })
}
async fn route(
&self,
tx: &mut sqlx::PgConnection,
default_thread_model: Option<&str>,
msg: &InboundMessage,
) -> Result<(Option<String>, Option<Uuid>), MailInboundError> {
if let Some(parent_id) = &msg.in_reply_to {
if let Some((model, res_id)) =
InboundRepository::find_parent_thread(tx, parent_id).await?
{
return Ok((model, res_id));
}
}
for addr in &msg.to {
let Some((local, domain)) = split_address(addr) else { continue };
let Some(domain_id) = InboundRepository::find_domain_id_by_name(tx, &domain).await?
else {
continue;
};
let Some(alias) =
AliasResolutionRepository::resolve(tx, &local, Some(domain_id)).await?
else {
continue;
};
let target =
alias.alias_force_thread_id.or(alias.alias_parent_thread_id);
if let Some(target) = target {
let model = InboundRepository::learn_thread_model(tx, target)
.await?
.or_else(|| default_thread_model.map(str::to_string));
if let Some(model) = model {
return Ok((Some(model), Some(target)));
}
}
}
Ok((default_thread_model.map(str::to_string), None))
}
}
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn const_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
use super::{const_eq, sha256_hex};
#[test]
fn sha256_hex_matches_known_vector() {
assert_eq!(
sha256_hex("token"),
"3c469e9d6c5875d37a43f353d4f88e61fcf812c66eee3457465a40b0da4153e0"
);
}
#[test]
fn const_eq_is_length_safe() {
assert!(const_eq("abc", "abc"));
assert!(!const_eq("abc", "abd"));
assert!(!const_eq("abc", "abcd"));
}
}