Compare commits
26 Commits
letterbox-
...
d0f4716d83
| Author | SHA1 | Date | |
|---|---|---|---|
| d0f4716d83 | |||
| 59e35062e7 | |||
| 43827b4d87 | |||
| b29e92cd9c | |||
| 42bea43de9 | |||
| 4048edde11 | |||
| 90768d0d1b | |||
| 70e6271ca3 | |||
| 0bda21e5e9 | |||
| f987b4e4b4 | |||
| a873ec9208 | |||
| d8d26e1f59 | |||
| 1322dde5c5 | |||
| a2147081e8 | |||
| 8c6a24e400 | |||
| 8a08d97930 | |||
| d24a851cd7 | |||
| f6ff597f66 | |||
| 387d133f09 | |||
| a9674e8b7b | |||
| 457f9ac1c2 | |||
| d62759565f | |||
| 4fd97700f7 | |||
| 99b9a88663 | |||
| 56e6036892 | |||
| 232e436378 |
667
Cargo.lock
generated
667
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,8 @@ build-info = "0.0.41"
|
||||
cacher = { version = "0.2.0", registry = "xinu" }
|
||||
chrono = "0.4.40"
|
||||
clap = { version = "4.5.37", features = ["derive"] }
|
||||
css-inline = "0.16.0"
|
||||
css-inline = "0.17.0"
|
||||
flate2 = "1.1.2"
|
||||
futures = "0.3.31"
|
||||
headers = "0.4.0"
|
||||
html-escape = "0.2.13"
|
||||
@@ -34,6 +35,7 @@ lol_html = "2.3.0"
|
||||
mailparse = "0.16.1"
|
||||
maplit = "1.0.2"
|
||||
memmap = "0.7.0"
|
||||
quick-xml = { version = "0.38.1", features = ["serialize"] }
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.15", features = ["blocking"] }
|
||||
scraper = "0.23.1"
|
||||
@@ -50,6 +52,7 @@ urlencoding = "2.1.3"
|
||||
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
||||
#xtracing = { path = "../../xtracing" }
|
||||
xtracing = { version = "0.3.2", registry = "xinu" }
|
||||
zip = "4.3.0"
|
||||
|
||||
[build-dependencies]
|
||||
build-info-build = "0.0.41"
|
||||
|
||||
263
server/src/nm.rs
263
server/src/nm.rs
@@ -1,14 +1,17 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::File,
|
||||
io::Cursor,
|
||||
};
|
||||
|
||||
use letterbox_notmuch::Notmuch;
|
||||
use letterbox_shared::{compute_color, Rule};
|
||||
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||
use memmap::MmapOptions;
|
||||
use quick_xml::de::from_str as xml_from_str;
|
||||
use sqlx::{types::Json, PgPool};
|
||||
use tracing::{error, info, info_span, instrument, warn};
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::{
|
||||
compute_offset_limit,
|
||||
@@ -20,6 +23,7 @@ use crate::{
|
||||
linkify_html, InlineStyle, Query, SanitizeHtml, Transformer,
|
||||
};
|
||||
|
||||
const APPLICATION_ZIP: &'static str = "application/zip";
|
||||
const IMAGE_JPEG: &'static str = "image/jpeg";
|
||||
const IMAGE_PJPEG: &'static str = "image/pjpeg";
|
||||
const IMAGE_PNG: &'static str = "image/png";
|
||||
@@ -447,6 +451,7 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Ser
|
||||
MULTIPART_MIXED => extract_mixed(m, part_addr),
|
||||
MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr),
|
||||
MULTIPART_RELATED => extract_related(m, part_addr),
|
||||
APPLICATION_ZIP => extract_zip(m),
|
||||
_ => extract_unhandled(m),
|
||||
};
|
||||
if let Err(err) = ret {
|
||||
@@ -456,6 +461,69 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Ser
|
||||
ret
|
||||
}
|
||||
|
||||
const APPLICATION_GZIP: &'static str = "application/gzip";
|
||||
|
||||
fn extract_zip(m: &ParsedMail) -> Result<Body, ServerError> {
|
||||
if let Ok(zip_bytes) = m.get_body_raw() {
|
||||
if let Ok(mut archive) = ZipArchive::new(Cursor::new(&zip_bytes)) {
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(mut file) = archive.by_index(i) {
|
||||
let name = file.name().to_lowercase();
|
||||
// Google DMARC reports are typically named like "google.com!example.com!...xml"
|
||||
// and may or may not contain "dmarc" in the filename.
|
||||
if name.ends_with(".xml") && (name.contains("dmarc") || name.starts_with("google.com!")) {
|
||||
let mut xml = String::new();
|
||||
use std::io::Read;
|
||||
if file.read_to_string(&mut xml).is_ok() {
|
||||
match parse_dmarc_report(&xml) {
|
||||
Ok(report) => {
|
||||
return Ok(Body::html(format!(
|
||||
"<div class=\"dmarc-report\">Google DMARC report summary:<br>{}</div>",
|
||||
report
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(Body::html(format!(
|
||||
"<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no DMARC report found, fall through to unhandled
|
||||
extract_unhandled(m)
|
||||
}
|
||||
|
||||
fn extract_gzip(m: &ParsedMail) -> Result<Body, ServerError> {
|
||||
if let Ok(gz_bytes) = m.get_body_raw() {
|
||||
let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]);
|
||||
let mut xml = String::new();
|
||||
use std::io::Read;
|
||||
if decoder.read_to_string(&mut xml).is_ok() {
|
||||
match parse_dmarc_report(&xml) {
|
||||
Ok(report) => {
|
||||
return Ok(Body::html(format!(
|
||||
"<div class=\"dmarc-report\">Microsoft DMARC report summary:<br>{}</div>",
|
||||
report
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(Body::html(format!(
|
||||
"<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extract_unhandled(m)
|
||||
}
|
||||
|
||||
fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
|
||||
let msg = format!(
|
||||
"Unhandled body content type:\n{}\n{}",
|
||||
@@ -525,6 +593,7 @@ fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Se
|
||||
MULTIPART_RELATED,
|
||||
TEXT_HTML,
|
||||
TEXT_PLAIN,
|
||||
APPLICATION_GZIP,
|
||||
];
|
||||
let mut unhandled_types: Vec<_> = m
|
||||
.subparts
|
||||
@@ -568,6 +637,7 @@ fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Se
|
||||
)));
|
||||
}
|
||||
}
|
||||
APPLICATION_GZIP => parts.push(extract_gzip(sp)?),
|
||||
mt => parts.push(unhandled_html(MULTIPART_MIXED, mt)),
|
||||
}
|
||||
part_addr.pop();
|
||||
@@ -1103,3 +1173,196 @@ fn find_tags<'a, 'b>(rules: &'a [Rule], headers: &'b [MailHeader]) -> (bool, Has
|
||||
}
|
||||
return (matched_rule, add_tags);
|
||||
}
|
||||
|
||||
// Add this helper function to parse the DMARC XML and summarize it.
|
||||
fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Feedback {
|
||||
report_metadata: Option<ReportMetadata>,
|
||||
policy_published: Option<PolicyPublished>,
|
||||
record: Option<Vec<Record>>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ReportMetadata {
|
||||
org_name: Option<String>,
|
||||
email: Option<String>,
|
||||
report_id: Option<String>,
|
||||
date_range: Option<DateRange>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct DateRange {
|
||||
begin: Option<u64>,
|
||||
end: Option<u64>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct PolicyPublished {
|
||||
domain: Option<String>,
|
||||
adkim: Option<String>,
|
||||
aspf: Option<String>,
|
||||
p: Option<String>,
|
||||
sp: Option<String>,
|
||||
pct: Option<String>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Record {
|
||||
row: Option<Row>,
|
||||
identifiers: Option<Identifiers>,
|
||||
auth_results: Option<AuthResults>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Row {
|
||||
source_ip: Option<String>,
|
||||
count: Option<u64>,
|
||||
policy_evaluated: Option<PolicyEvaluated>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct PolicyEvaluated {
|
||||
disposition: Option<String>,
|
||||
dkim: Option<String>,
|
||||
spf: Option<String>,
|
||||
reason: Option<Vec<Reason>>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Reason {
|
||||
#[serde(rename = "type")]
|
||||
reason_type: Option<String>,
|
||||
comment: Option<String>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Identifiers {
|
||||
header_from: Option<String>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AuthResults {
|
||||
dkim: Option<Vec<AuthDKIM>>,
|
||||
spf: Option<Vec<AuthSPF>>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AuthDKIM {
|
||||
domain: Option<String>,
|
||||
result: Option<String>,
|
||||
selector: Option<String>,
|
||||
}
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AuthSPF {
|
||||
domain: Option<String>,
|
||||
result: Option<String>,
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
let feedback: Feedback = xml_from_str(xml)
|
||||
.map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {e}")))?;
|
||||
let mut summary = String::new();
|
||||
if let Some(meta) = feedback.report_metadata {
|
||||
if let Some(org) = meta.org_name {
|
||||
summary += &format!("<b>Reporter:</b> {}<br>", org);
|
||||
}
|
||||
if let Some(email) = meta.email {
|
||||
summary += &format!("<b>Contact:</b> {}<br>", email);
|
||||
}
|
||||
if let Some(rid) = meta.report_id {
|
||||
summary += &format!("<b>Report ID:</b> {}<br>", rid);
|
||||
}
|
||||
if let Some(dr) = meta.date_range {
|
||||
if let (Some(begin), Some(end)) = (dr.begin, dr.end) {
|
||||
use chrono::{NaiveDateTime, TimeZone, Utc};
|
||||
let begin_dt = Utc.timestamp_opt(begin as i64, 0).single();
|
||||
let end_dt = Utc.timestamp_opt(end as i64, 0).single();
|
||||
summary += &format!("<b>Date range:</b> {} to {}<br>",
|
||||
begin_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(begin.to_string()),
|
||||
end_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(end.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pol) = feedback.policy_published {
|
||||
summary += "<b>Policy Published:</b><ul>";
|
||||
if let Some(domain) = pol.domain {
|
||||
summary += &format!("<li>Domain: {}</li>", domain);
|
||||
}
|
||||
if let Some(adkim) = pol.adkim {
|
||||
summary += &format!("<li>ADKIM: {}</li>", adkim);
|
||||
}
|
||||
if let Some(aspf) = pol.aspf {
|
||||
summary += &format!("<li>ASPF: {}</li>", aspf);
|
||||
}
|
||||
if let Some(p) = pol.p {
|
||||
summary += &format!("<li>Policy: {}</li>", p);
|
||||
}
|
||||
if let Some(sp) = pol.sp {
|
||||
summary += &format!("<li>Subdomain Policy: {}</li>", sp);
|
||||
}
|
||||
if let Some(pct) = pol.pct {
|
||||
summary += &format!("<li>Percent: {}</li>", pct);
|
||||
}
|
||||
summary += "</ul>";
|
||||
}
|
||||
if let Some(records) = feedback.record {
|
||||
summary += "<b>Records:</b><table style=\"border-collapse:collapse;width:100%;font-size:0.95em;\"><thead><tr style=\"background:#f0f0f0;\"><th style=\"border:1px solid #bbb;padding:4px 8px;\">Source IP</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">Count</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">Header From</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">Disposition</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">DKIM</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">SPF</th><th style=\"border:1px solid #bbb;padding:4px 8px;\">Auth Results</th></tr></thead><tbody>";
|
||||
for rec in records {
|
||||
let mut row_html = String::new();
|
||||
let mut source_ip = String::new();
|
||||
let mut count = String::new();
|
||||
let mut header_from = String::new();
|
||||
let mut disposition = String::new();
|
||||
let mut dkim = String::new();
|
||||
let mut spf = String::new();
|
||||
if let Some(r) = &rec.row {
|
||||
if let Some(ref s) = r.source_ip {
|
||||
source_ip = s.clone();
|
||||
}
|
||||
if let Some(c) = r.count {
|
||||
count = c.to_string();
|
||||
}
|
||||
if let Some(ref pe) = r.policy_evaluated {
|
||||
if let Some(ref disp) = pe.disposition {
|
||||
disposition = disp.clone();
|
||||
}
|
||||
if let Some(ref d) = pe.dkim {
|
||||
dkim = d.clone();
|
||||
}
|
||||
if let Some(ref s) = pe.spf {
|
||||
spf = s.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ids) = &rec.identifiers {
|
||||
if let Some(ref hf) = ids.header_from {
|
||||
header_from = hf.clone();
|
||||
}
|
||||
}
|
||||
row_html += &format!("<tr><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">{}</td><td style=\"border:1px solid #bbb;padding:4px 8px;\">",
|
||||
source_ip, count, header_from, disposition, dkim, spf);
|
||||
// Auth Results
|
||||
let mut auths = String::new();
|
||||
if let Some(auth) = &rec.auth_results {
|
||||
if let Some(dkims) = &auth.dkim {
|
||||
for dkimres in dkims {
|
||||
auths += &format!("<span style=\"white-space:nowrap;\">DKIM: domain=<b>{}</b> selector=<b>{}</b> result=<b>{}</b></span><br>",
|
||||
dkimres.domain.as_deref().unwrap_or(""),
|
||||
dkimres.selector.as_deref().unwrap_or(""),
|
||||
dkimres.result.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(spfs) = &auth.spf {
|
||||
for spfres in spfs {
|
||||
auths += &format!("<span style=\"white-space:nowrap;\">SPF: domain=<b>{}</b> scope=<b>{}</b> result=<b>{}</b></span><br>",
|
||||
spfres.domain.as_deref().unwrap_or(""),
|
||||
spfres.scope.as_deref().unwrap_or(""),
|
||||
spfres.result.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
row_html += &auths;
|
||||
row_html += "</td></tr>";
|
||||
summary += &row_html;
|
||||
}
|
||||
summary += "</tbody></table>";
|
||||
}
|
||||
if summary.is_empty() {
|
||||
summary = "No DMARC summary found.".to_string();
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
<link data-trunk rel="css" href="static/vars.css" />
|
||||
<link data-trunk rel="tailwind-css" href="./src/tailwind.css" />
|
||||
<link data-trunk rel="css" href="static/overrides.css" />
|
||||
<link data-trunk rel="css" href="static/email-specific.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section id="app"></section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1025,7 +1025,7 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
|
||||
],
|
||||
IF!(open =>
|
||||
div![
|
||||
C!["content", "bg-white", "text-black", "p-4", "min-w-full", "w-0","overflow-x-auto", from],
|
||||
C!["content", "bg-white", "text-black", "p-4", "min-w-full", "w-0","overflow-x-auto", from.map(|f|format!("from-{f}"))],
|
||||
match &msg.body {
|
||||
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
|
||||
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
|
||||
|
||||
@@ -57,15 +57,6 @@ html {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.mail-thread .content .noreply-news-bloomberg-com a {
|
||||
background-color: initial !important;
|
||||
}
|
||||
|
||||
.mail-thread .content .noreply-news-bloomberg-com h2 {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Hackaday figures have unreadable black on dark grey */
|
||||
.news-post figcaption.wp-caption-text {
|
||||
background-color: initial !important;
|
||||
|
||||
Reference in New Issue
Block a user