use std::fmt::{Debug, Display};
use ::candid::{Decode, Encode, Principal};
use anyhow::Context as _;
use async_trait::async_trait;
use derive_new::new;
use ic_agent::Agent;
use mail_parser::MessageParser;
use tracing::debug;
use crate::smtp::ic::{
candid::{Header, Message, SmtpRequest, SmtpResponse},
delivery_agent::IcSmtpDeliveryAgentError,
};
pub mod candid;
pub mod delivery_agent;
#[async_trait]
pub trait ExecutesIcSmtpRequest: Send + Sync + Debug {
async fn canister_request(
&self,
canister_id: Principal,
request: SmtpRequest,
validate: bool,
) -> Result<SmtpResponse, IcSmtpDeliveryAgentError>;
}
#[derive(new, Debug)]
pub struct IcSmtpRequestExecutor(Agent);
#[async_trait]
impl ExecutesIcSmtpRequest for IcSmtpRequestExecutor {
async fn canister_request(
&self,
canister_id: Principal,
ic_smtp_request: SmtpRequest,
validate: bool,
) -> Result<SmtpResponse, IcSmtpDeliveryAgentError> {
debug!("{self}: {canister_id}: sending IC SMTP request: '{ic_smtp_request:?}'");
let arg = Encode!(&ic_smtp_request).context("unable to encode SMTP request")?;
let resp = if validate {
self.0
.query(&canister_id, "smtp_request_validate")
.with_arg(arg)
.call()
.await?
} else {
self.0
.update(&canister_id, "smtp_request")
.with_arg(arg)
.call_and_wait()
.await?
};
let ic_smtp_response =
Decode!(&resp, SmtpResponse).context("unable to decode SMTP response")?;
debug!("{self}: {canister_id}: got IC SMTP response: '{ic_smtp_response:?}'");
Ok(ic_smtp_response)
}
}
impl Display for IcSmtpRequestExecutor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "IcSmtpRequestExecutor")
}
}
pub fn parse_email(raw: &[u8]) -> Result<Message, IcSmtpDeliveryAgentError> {
let parsed = MessageParser::new()
.parse(raw)
.filter(|p| p.headers().iter().any(|h| !h.name.is_other()))
.ok_or(IcSmtpDeliveryAgentError::Parser(
"No parsable message found".into(),
))?;
let headers = parsed
.headers_raw()
.map(|(k, v)| Header {
name: k.into(),
value: v.into(),
})
.collect::<Vec<_>>();
let body_offset = parsed.root_part().offset_body as usize;
if body_offset >= raw.len() {
return Err(IcSmtpDeliveryAgentError::Parser(
"Incorrect body offset".into(),
));
}
let msg = Message {
headers,
body: raw[body_offset..].into(),
};
Ok(msg)
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
#[test]
fn test_parser() {
let raw = indoc! {r#"
From: Some One <someone@example.com>
To: John Doe <john@doe.com>
MIME-Version: 1.0
Content-Type: multipart/mixed;
boundary="XXXXboundary text"
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=newsletter2.foo.bar; s=elaine; t=1779173482;
bh=P1hWhNvLxYPQvK4IuGO72BKkVgfo5OkCVlIHCyLXvmI=;
h=Date:X-CSA-Complaints:To:From:Reply-To:Subject:Feedback-ID:
CFBL-Feedback-ID:CFBL-Address:List-Unsubscribe:
List-Unsubscribe-Post;
b=Eh4/u+8dKXri3jwPO1s6Zk6PwV2h5H6y0PGPn/FLVo/LhwlJbfGStSFLBja4nll8f
J5xqDmnlbijjqjXMODiIXPTmqYrfGbbcS5WSCmOyFKhdwGqlAkOOlAXTRkju7QkbtO
E5MpnYd4kPHnRC0MuyetIMr6CuQxrR2BGKq4LWB0=
This is a multipart message in MIME format.
--XXXXboundary text
Content-Type: text/plain
this is the body text
--XXXXboundary text
Content-Type: text/plain;
Content-Disposition: attachment;
filename="test.txt"
this is the attachment text
--XXXXboundary text--
"#};
let msg = parse_email(raw.as_bytes()).unwrap();
assert!(
msg.headers
.iter()
.any(|x| x.name == "From" && x.value == " Some One <someone@example.com>\n")
);
assert!(
msg.headers
.iter()
.any(|x| x.name == "To" && x.value == " John Doe <john@doe.com>\n")
);
assert!(
msg.headers
.iter()
.any(|x| x.name == "MIME-Version" && x.value == " 1.0\n")
);
assert!(msg.headers.iter().any(|x| x.name == "Content-Type"
&& x.value == " multipart/mixed;\n boundary=\"XXXXboundary text\"\n"));
let dkim_header = [
" v=1; a=rsa-sha256; c=relaxed/relaxed;",
" d=newsletter2.foo.bar; s=elaine; t=1779173482;",
" bh=P1hWhNvLxYPQvK4IuGO72BKkVgfo5OkCVlIHCyLXvmI=;",
" h=Date:X-CSA-Complaints:To:From:Reply-To:Subject:Feedback-ID:",
" CFBL-Feedback-ID:CFBL-Address:List-Unsubscribe:",
" List-Unsubscribe-Post;",
" b=Eh4/u+8dKXri3jwPO1s6Zk6PwV2h5H6y0PGPn/FLVo/LhwlJbfGStSFLBja4nll8f",
" J5xqDmnlbijjqjXMODiIXPTmqYrfGbbcS5WSCmOyFKhdwGqlAkOOlAXTRkju7QkbtO",
" E5MpnYd4kPHnRC0MuyetIMr6CuQxrR2BGKq4LWB0=\n",
]
.join("\n");
assert!(
msg.headers
.iter()
.any(|x| { x.name == "DKIM-Signature" && x.value == dkim_header })
);
let body = indoc! {r#"
This is a multipart message in MIME format.
--XXXXboundary text
Content-Type: text/plain
this is the body text
--XXXXboundary text
Content-Type: text/plain;
Content-Disposition: attachment;
filename="test.txt"
this is the attachment text
--XXXXboundary text--
"#};
assert_eq!(msg.body, body.as_bytes());
assert!(matches!(
parse_email(&[]).unwrap_err(),
IcSmtpDeliveryAgentError::Parser(_)
));
let raw = indoc! {r#"
X-Header-1: Foo
X-Header-2: Bar
This is a multipart message in MIME format.
"#};
assert!(matches!(
parse_email(raw.as_bytes()).unwrap_err(),
IcSmtpDeliveryAgentError::Parser(_)
));
}
}