Skip to content

Commit aa17c32

Browse files
authored
Merge pull request #6 from Lay3rLabs/dkim
DKIM verification
2 parents b2d7b90 + 6eb1b4c commit aa17c32

File tree

8 files changed

+589
-61
lines changed

8 files changed

+589
-61
lines changed

Cargo.lock

Lines changed: 463 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,17 @@ futures = "0.3.31"
3838

3939
# Email
4040
imap = {version = "2.4.1", default-features = false}
41-
imap-proto = "0.16.6"
42-
mail-parser = "0.11.1"
41+
mailparse = "0.16.1"
42+
cfdkim = { git = "https://github.com/Lay3rLabs/dkim-wasi.git", features = ["wasi-resolver"]}
4343

4444
# Security
4545
zeroize = "1.8.2"
4646

4747
# Misc
4848
cfg-if = "1.0.4"
49+
50+
# Logging
51+
sloggers = "2.2.0"
52+
53+
[dev-dependencies]
54+
tokio = {version = "1.48.0", features = ["full"]}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Imap component
22

3+
This uses DKIM verification from our fork of Cloudflare's DKIM library: https://github.com/Lay3rLabs/dkim-wasi
4+
35
## Getting Started
46

57
1. Set your credentials

src/email.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod auth;
22
mod parser;
3+
pub mod verify;
34

45
use futures::StreamExt;
56
use imap::Session;
@@ -59,6 +60,6 @@ pub async fn read_next_email() -> AppResult<Option<EmailMessage>> {
5960
.ok_or(AppError::FailedToFetchEmail(latest_uid))?;
6061

6162
Ok(Some(
62-
EmailMessage::parse(&fetch).map_err(AppError::MessageParse)?,
63+
EmailMessage::parse(&fetch).map_err(AppError::AnyMessageParse)?,
6364
))
6465
}

src/email/parser.rs

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,64 @@ use std::borrow::Cow;
22

33
use anyhow::{Context, Result};
44
use imap::{types::Fetch, Session};
5-
use mail_parser::{HeaderValue, Message, MessageParser};
5+
use mailparse::*;
6+
7+
use crate::error::AppResult;
68

7-
#[derive(Debug)]
89
pub struct EmailMessage {
9-
// 1) Original sender best-effort (From / Resent-From / Sender / envelope)
10-
original_sender: String,
10+
// 1) "Original sender" best-effort (From / Resent-From / Sender / envelope)
11+
pub original_sender: String,
1112
// 2) All DKIM-Signature header values (there can be multiple)
1213
dkim_signatures: Vec<String>,
1314
// 3) Subject (decoded)
1415
subject: Option<String>,
1516
// 4) Body (best-effort: prefer text/plain part; fall back to full text)
16-
body_text: Option<String>,
17+
pub body_text: Option<String>,
18+
// 5) Raw email bytes (can be parsed on demand)
19+
pub raw_bytes: Vec<u8>,
20+
}
21+
22+
impl std::fmt::Debug for EmailMessage {
23+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24+
f.debug_struct("EmailMessage")
25+
.field("original_sender", &self.original_sender)
26+
.field("dkim_signatures", &self.dkim_signatures)
27+
.field("subject", &self.subject)
28+
.field(
29+
"body_text",
30+
&self
31+
.body_text
32+
.as_ref()
33+
.map(|s| Cow::Owned(format!("{}...", &s[..s.len().min(30)])))
34+
.unwrap_or(Cow::Borrowed("None")),
35+
)
36+
.field("raw_bytes_len", &self.raw_bytes.len())
37+
.finish()
38+
}
1739
}
1840

1941
impl EmailMessage {
2042
pub fn parse(f: &Fetch) -> anyhow::Result<Self> {
21-
let msg = MessageParser::new()
22-
.parse(f.body().context("Missing email body")?)
23-
.ok_or_else(|| anyhow::anyhow!("Failed to parse email message"))?;
43+
let body_bytes = f.body().context("Missing email body")?;
44+
let msg = parse_mail(body_bytes)
45+
.map_err(|e| anyhow::anyhow!("Failed to parse email message: {:?}", e))?;
2446

25-
let subject = msg.subject();
47+
let subject = msg
48+
.headers
49+
.iter()
50+
.find(|h| h.get_key().eq_ignore_ascii_case("Subject"))
51+
.map(|h| h.get_value());
2652

2753
// best effort original sender extraction
2854
let original_sender = msg
29-
.header("Resent-From")
30-
.or_else(|| msg.header("From"))
31-
.or_else(|| msg.header("Sender"))
32-
.and_then(|h| match h {
33-
HeaderValue::Address(addr) => addr.first().map(|a| a.address()).flatten(),
34-
_ => h.as_text(),
55+
.headers
56+
.iter()
57+
.find(|h| {
58+
h.get_key().eq_ignore_ascii_case("Resent-From")
59+
|| h.get_key().eq_ignore_ascii_case("From")
60+
|| h.get_key().eq_ignore_ascii_case("Sender")
3561
})
36-
.map(|s| s.to_string())
62+
.map(|h| h.get_value())
3763
.or_else(|| {
3864
f.envelope()
3965
.and_then(|env| {
@@ -51,18 +77,26 @@ impl EmailMessage {
5177

5278
// there can be multiple signatures
5379
let dkim_signatures = msg
54-
.header_values("DKIM-Signature")
55-
.flat_map(|h| h.as_text())
80+
.headers
81+
.iter()
82+
.filter(|h| h.get_key().eq_ignore_ascii_case("DKIM-Signature"))
83+
.map(|h| h.get_value())
5684
.collect::<Vec<_>>();
5785

5886
// prefer text/plain part; fall back to html text
59-
let body_text = msg.body_text(0).or_else(|| msg.body_html(0));
87+
let body_text = msg.get_body().ok();
6088

6189
Ok(Self {
6290
subject: subject.map(|s| s.to_string()),
63-
original_sender,
91+
original_sender: original_sender.to_string(),
6492
dkim_signatures: dkim_signatures.into_iter().map(|s| s.to_string()).collect(),
6593
body_text: body_text.map(|s| s.to_string()),
94+
raw_bytes: body_bytes.to_vec(),
6695
})
6796
}
97+
98+
/// Parse the raw email bytes and return a ParsedMail
99+
pub fn get_parsed(&self) -> AppResult<ParsedMail> {
100+
Ok(parse_mail(&self.raw_bytes)?)
101+
}
68102
}

src/email/verify.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use std::sync::Arc;
2+
3+
use sloggers::{null::NullLoggerBuilder, Build};
4+
5+
use crate::{
6+
email::parser::EmailMessage,
7+
error::{AppError, AppResult},
8+
};
9+
10+
pub async fn verify_email(email: &EmailMessage) -> AppResult<()> {
11+
let resolver = cfdkim::dns::wasi::WasiCloudflareLookup {};
12+
13+
let from_domain = email
14+
.original_sender
15+
.split('@')
16+
.nth(1)
17+
.ok_or_else(|| AppError::CannotExtractDomain(email.original_sender.clone()))?;
18+
19+
cfdkim::verify_email_with_resolver(
20+
&NullLoggerBuilder.build()?,
21+
from_domain,
22+
&email.get_parsed()?,
23+
Arc::new(resolver),
24+
)
25+
.await?;
26+
27+
Ok(())
28+
}

src/error.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,23 @@ pub enum AppError {
2525
FailedToFetchEmail(u32),
2626

2727
#[error("{0:?}")]
28-
MessageParse(anyhow::Error),
28+
AnyMessageParse(anyhow::Error),
29+
30+
#[error("{0:?}")]
31+
MessageParse(#[from] mailparse::MailParseError),
2932

3033
#[error("Authorization: {0:?}")]
3134
Auth(anyhow::Error),
35+
36+
#[error("DKIM: {0:?}")]
37+
Dkim(#[from] cfdkim::DKIMError),
38+
39+
#[error("DKIM Result: {0}")]
40+
DkimResult(String),
41+
42+
#[error("Cannot extract domain: {0}")]
43+
CannotExtractDomain(String),
44+
45+
#[error("slog: {0:?}")]
46+
Slog(#[from] sloggers::Error),
3247
}

src/lib.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ mod email;
55
mod error;
66

77
use anyhow::bail;
8+
use cfdkim::verify_email_with_resolver;
89

9-
use crate::wavs::operator::input::TriggerData;
10+
use crate::{email::verify::verify_email, wavs::operator::input::TriggerData};
1011

1112
// this is needed just to make the ide/compiler happy... we're _always_ compiling to wasm32-wasi
1213
wit_bindgen::generate!({
@@ -41,8 +42,21 @@ async fn inner(trigger_action: TriggerAction) -> anyhow::Result<Option<WasmRespo
4142
let data = std::str::from_utf8(&data)?;
4243
match data {
4344
"read-mail" => {
44-
let email = email::read_next_email().await?;
45+
let email = match email::read_next_email().await? {
46+
Some(email) => email,
47+
None => {
48+
println!("No new email found.");
49+
return Ok(None);
50+
}
51+
};
52+
4553
println!("{:#?}", email);
54+
55+
let verification_result = verify_email(&email).await;
56+
match verification_result {
57+
Ok(_) => println!("Email verification succeeded."),
58+
Err(e) => println!("Email verification failed: {:?}", e),
59+
}
4660
}
4761
_ => {
4862
bail!("Unknown command: {}", data);

0 commit comments

Comments
 (0)