Compare commits
55 Commits
3eb2f13dad
...
letterbox-
| Author | SHA1 | Date | |
|---|---|---|---|
| 75d4fe49e2 | |||
| 9f2016940b | |||
| ba9cc0127b | |||
| ce17c4a7d8 | |||
| c8850404b8 | |||
| 638e94b4ae | |||
| 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 | |||
| e2bf4d890f | |||
| e9584785a8 | |||
| 7a4d2abdd5 | |||
| b764d725b1 | |||
| 7bac98762c | |||
| 2bedd92e1a | |||
| da72c09fa3 | |||
| 38c1942ebb | |||
| 05a7386dd1 | |||
| 477ffe8d82 | |||
| 5d80f32b49 | |||
| ae76bdf9a5 | |||
| 50e3c77e49 | |||
| e85a505775 | |||
| 86ea5a13f3 | |||
| a30bff925f | |||
| 6fdfbb1ee2 | |||
| 561316ddd4 | |||
| 495e495888 | |||
| ddb4c812ce | |||
| 1aaf914ac5 | |||
| 982b5dae2f | |||
| 8807c1b1f5 |
1338
Cargo.lock
generated
1338
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ authors = ["Bill Thiede <git@xinu.tv>"]
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
publish = ["xinu"]
|
publish = ["xinu"]
|
||||||
version = "0.17.25"
|
version = "0.17.29"
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|||||||
@@ -14,26 +14,29 @@ version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ammonia = "4.1.0"
|
ammonia = "4.1.0"
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
|
askama = { version = "0.14.0", features = ["derive"] }
|
||||||
async-graphql = { version = "7", features = ["log"] }
|
async-graphql = { version = "7", features = ["log"] }
|
||||||
async-graphql-axum = "7.0.16"
|
async-graphql-axum = "7.0.16"
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
axum = { version = "0.8.3", features = ["ws"] }
|
axum = { version = "0.8.3", features = ["ws"] }
|
||||||
axum-macros = "0.5.0"
|
axum-macros = "0.5.0"
|
||||||
build-info = "0.0.40"
|
build-info = "0.0.41"
|
||||||
cacher = { version = "0.2.0", registry = "xinu" }
|
cacher = { version = "0.2.0", registry = "xinu" }
|
||||||
chrono = "0.4.40"
|
chrono = "0.4.40"
|
||||||
clap = { version = "4.5.37", features = ["derive"] }
|
clap = { version = "4.5.37", features = ["derive"] }
|
||||||
css-inline = "0.14.4"
|
css-inline = "0.17.0"
|
||||||
|
flate2 = "1.1.2"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
headers = "0.4.0"
|
headers = "0.4.0"
|
||||||
html-escape = "0.2.13"
|
html-escape = "0.2.13"
|
||||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.25", registry = "xinu" }
|
letterbox-notmuch = { path = "../notmuch", version = "0.17.29", registry = "xinu" }
|
||||||
letterbox-shared = { path = "../shared", version = "0.17.25", registry = "xinu" }
|
letterbox-shared = { path = "../shared", version = "0.17.29", registry = "xinu" }
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
lol_html = "2.3.0"
|
lol_html = "2.3.0"
|
||||||
mailparse = "0.16.1"
|
mailparse = "0.16.1"
|
||||||
maplit = "1.0.2"
|
maplit = "1.0.2"
|
||||||
memmap = "0.7.0"
|
memmap = "0.7.0"
|
||||||
|
quick-xml = { version = "0.38.1", features = ["serialize"] }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12.15", features = ["blocking"] }
|
reqwest = { version = "0.12.15", features = ["blocking"] }
|
||||||
scraper = "0.23.1"
|
scraper = "0.23.1"
|
||||||
@@ -50,9 +53,10 @@ urlencoding = "2.1.3"
|
|||||||
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
||||||
#xtracing = { path = "../../xtracing" }
|
#xtracing = { path = "../../xtracing" }
|
||||||
xtracing = { version = "0.3.2", registry = "xinu" }
|
xtracing = { version = "0.3.2", registry = "xinu" }
|
||||||
|
zip = "4.3.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.40"
|
build-info-build = "0.0.41"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
#default = [ "tantivy" ]
|
#default = [ "tantivy" ]
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ fn main() {
|
|||||||
// Calling `build_info_build::build_script` collects all data and makes it available to `build_info::build_info!`
|
// Calling `build_info_build::build_script` collects all data and makes it available to `build_info::build_info!`
|
||||||
// and `build_info::format!` in the main program.
|
// and `build_info::format!` in the main program.
|
||||||
build_info_build::build_script();
|
build_info_build::build_script();
|
||||||
|
println!("cargo:rerun-if-changed=templates");
|
||||||
}
|
}
|
||||||
|
|||||||
2
server/migrations/20250623193718_disable-feed.down.sql
Normal file
2
server/migrations/20250623193718_disable-feed.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
ALTER TABLE feed DROP COLUMN IF EXISTS disabled;
|
||||||
2
server/migrations/20250623193718_disable-feed.up.sql
Normal file
2
server/migrations/20250623193718_disable-feed.up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
ALTER TABLE feed ADD disabled boolean;
|
||||||
@@ -39,4 +39,6 @@ pub enum ServerError {
|
|||||||
QueryParseError(#[from] QueryParserError),
|
QueryParseError(#[from] QueryParserError),
|
||||||
#[error("impossible: {0}")]
|
#[error("impossible: {0}")]
|
||||||
InfaillibleError(#[from] Infallible),
|
InfaillibleError(#[from] Infallible),
|
||||||
|
#[error("askama error: {0}")]
|
||||||
|
AskamaError(#[from] askama::Error),
|
||||||
}
|
}
|
||||||
|
|||||||
338
server/src/nm.rs
338
server/src/nm.rs
@@ -1,14 +1,19 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
fs::File,
|
fs::File,
|
||||||
|
io::Cursor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
use letterbox_notmuch::Notmuch;
|
use letterbox_notmuch::Notmuch;
|
||||||
use letterbox_shared::{compute_color, Rule};
|
use letterbox_shared::{compute_color, Rule};
|
||||||
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||||
use memmap::MmapOptions;
|
use memmap::MmapOptions;
|
||||||
|
use quick_xml::de::from_str as xml_from_str;
|
||||||
use sqlx::{types::Json, PgPool};
|
use sqlx::{types::Json, PgPool};
|
||||||
use tracing::{error, info, info_span, instrument, warn};
|
use tracing::{error, info, info_span, instrument, warn};
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
compute_offset_limit,
|
compute_offset_limit,
|
||||||
@@ -20,6 +25,7 @@ use crate::{
|
|||||||
linkify_html, InlineStyle, Query, SanitizeHtml, Transformer,
|
linkify_html, InlineStyle, Query, SanitizeHtml, Transformer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const APPLICATION_ZIP: &'static str = "application/zip";
|
||||||
const IMAGE_JPEG: &'static str = "image/jpeg";
|
const IMAGE_JPEG: &'static str = "image/jpeg";
|
||||||
const IMAGE_PJPEG: &'static str = "image/pjpeg";
|
const IMAGE_PJPEG: &'static str = "image/pjpeg";
|
||||||
const IMAGE_PNG: &'static str = "image/png";
|
const IMAGE_PNG: &'static str = "image/png";
|
||||||
@@ -447,6 +453,7 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Ser
|
|||||||
MULTIPART_MIXED => extract_mixed(m, part_addr),
|
MULTIPART_MIXED => extract_mixed(m, part_addr),
|
||||||
MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr),
|
MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr),
|
||||||
MULTIPART_RELATED => extract_related(m, part_addr),
|
MULTIPART_RELATED => extract_related(m, part_addr),
|
||||||
|
APPLICATION_ZIP => extract_zip(m),
|
||||||
_ => extract_unhandled(m),
|
_ => extract_unhandled(m),
|
||||||
};
|
};
|
||||||
if let Err(err) = ret {
|
if let Err(err) = ret {
|
||||||
@@ -456,6 +463,71 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Ser
|
|||||||
ret
|
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> {
|
fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"Unhandled body content type:\n{}\n{}",
|
"Unhandled body content type:\n{}\n{}",
|
||||||
@@ -525,6 +597,7 @@ fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Se
|
|||||||
MULTIPART_RELATED,
|
MULTIPART_RELATED,
|
||||||
TEXT_HTML,
|
TEXT_HTML,
|
||||||
TEXT_PLAIN,
|
TEXT_PLAIN,
|
||||||
|
APPLICATION_GZIP,
|
||||||
];
|
];
|
||||||
let mut unhandled_types: Vec<_> = m
|
let mut unhandled_types: Vec<_> = m
|
||||||
.subparts
|
.subparts
|
||||||
@@ -568,6 +641,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)),
|
mt => parts.push(unhandled_html(MULTIPART_MIXED, mt)),
|
||||||
}
|
}
|
||||||
part_addr.pop();
|
part_addr.pop();
|
||||||
@@ -1103,3 +1177,267 @@ fn find_tags<'a, 'b>(rules: &'a [Rule], headers: &'b [MailHeader]) -> (bool, Has
|
|||||||
}
|
}
|
||||||
return (matched_rule, add_tags);
|
return (matched_rule, add_tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this helper function to parse the DMARC XML and summarize it.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct FormattedDateRange {
|
||||||
|
pub begin: String,
|
||||||
|
pub end: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedReportMetadata {
|
||||||
|
pub org_name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub report_id: String,
|
||||||
|
pub date_range: Option<FormattedDateRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedRecord {
|
||||||
|
pub source_ip: String,
|
||||||
|
pub count: String,
|
||||||
|
pub header_from: String,
|
||||||
|
pub disposition: String,
|
||||||
|
pub dkim: String,
|
||||||
|
pub spf: String,
|
||||||
|
pub auth_results: Option<FormattedAuthResults>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedAuthResults {
|
||||||
|
pub dkim: Vec<FormattedAuthDKIM>,
|
||||||
|
pub spf: Vec<FormattedAuthSPF>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedAuthDKIM {
|
||||||
|
pub domain: String,
|
||||||
|
pub result: String,
|
||||||
|
pub selector: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedAuthSPF {
|
||||||
|
pub domain: String,
|
||||||
|
pub result: String,
|
||||||
|
pub scope: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedPolicyPublished {
|
||||||
|
pub domain: String,
|
||||||
|
pub adkim: String,
|
||||||
|
pub aspf: String,
|
||||||
|
pub p: String,
|
||||||
|
pub sp: String,
|
||||||
|
pub pct: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedFeedback {
|
||||||
|
pub report_metadata: Option<FormattedReportMetadata>,
|
||||||
|
pub policy_published: Option<FormattedPolicyPublished>,
|
||||||
|
pub record: Option<Vec<FormattedRecord>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Feedback {
|
||||||
|
pub report_metadata: Option<ReportMetadata>,
|
||||||
|
pub policy_published: Option<PolicyPublished>,
|
||||||
|
pub record: Option<Vec<Record>>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct ReportMetadata {
|
||||||
|
pub org_name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub report_id: Option<String>,
|
||||||
|
pub date_range: Option<DateRange>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct DateRange {
|
||||||
|
pub begin: Option<u64>,
|
||||||
|
pub end: Option<u64>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct PolicyPublished {
|
||||||
|
pub domain: Option<String>,
|
||||||
|
pub adkim: Option<String>,
|
||||||
|
pub aspf: Option<String>,
|
||||||
|
pub p: Option<String>,
|
||||||
|
pub sp: Option<String>,
|
||||||
|
pub pct: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Record {
|
||||||
|
pub row: Option<Row>,
|
||||||
|
pub identifiers: Option<Identifiers>,
|
||||||
|
pub auth_results: Option<AuthResults>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Row {
|
||||||
|
pub source_ip: Option<String>,
|
||||||
|
pub count: Option<u64>,
|
||||||
|
pub policy_evaluated: Option<PolicyEvaluated>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct PolicyEvaluated {
|
||||||
|
pub disposition: Option<String>,
|
||||||
|
pub dkim: Option<String>,
|
||||||
|
pub spf: Option<String>,
|
||||||
|
pub reason: Option<Vec<Reason>>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Reason {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub reason_type: Option<String>,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct Identifiers {
|
||||||
|
pub header_from: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct AuthResults {
|
||||||
|
pub dkim: Option<Vec<AuthDKIM>>,
|
||||||
|
pub spf: Option<Vec<AuthSPF>>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct AuthDKIM {
|
||||||
|
pub domain: Option<String>,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub selector: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct AuthSPF {
|
||||||
|
pub domain: Option<String>,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dmarc_report.html")]
|
||||||
|
pub struct DmarcReportTemplate<'a> {
|
||||||
|
pub report: &'a FormattedFeedback,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this helper function to parse the DMARC XML and summarize it.
|
||||||
|
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
|
||||||
|
let feedback: Feedback = xml_from_str(xml)
|
||||||
|
.map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {}", e)))?;
|
||||||
|
|
||||||
|
let formatted_report_metadata = feedback.report_metadata.map(|meta| {
|
||||||
|
let date_range = meta.date_range.map(|dr| FormattedDateRange {
|
||||||
|
begin: match Utc.timestamp_opt(dr.begin.unwrap_or(0) as i64, 0) {
|
||||||
|
chrono::LocalResult::Single(d) => Some(d),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
end: match Utc.timestamp_opt(dr.end.unwrap_or(0) as i64, 0) {
|
||||||
|
chrono::LocalResult::Single(d) => Some(d),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
});
|
||||||
|
FormattedReportMetadata {
|
||||||
|
org_name: meta.org_name.unwrap_or_else(|| "".to_string()),
|
||||||
|
email: meta.email.unwrap_or_else(|| "".to_string()),
|
||||||
|
report_id: meta.report_id.unwrap_or_else(|| "".to_string()),
|
||||||
|
date_range,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let formatted_record = feedback.record.map(|records| {
|
||||||
|
records
|
||||||
|
.into_iter()
|
||||||
|
.map(|rec| {
|
||||||
|
let auth_results = rec.auth_results.map(|auth| {
|
||||||
|
let dkim = auth
|
||||||
|
.dkim
|
||||||
|
.map(|dkims| {
|
||||||
|
dkims
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| FormattedAuthDKIM {
|
||||||
|
domain: d.domain.unwrap_or_else(|| "".to_string()),
|
||||||
|
result: d.result.unwrap_or_else(|| "".to_string()),
|
||||||
|
selector: d.selector.unwrap_or_else(|| "".to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| Vec::new());
|
||||||
|
|
||||||
|
let spf = auth
|
||||||
|
.spf
|
||||||
|
.map(|spfs| {
|
||||||
|
spfs.into_iter()
|
||||||
|
.map(|s| FormattedAuthSPF {
|
||||||
|
domain: s.domain.unwrap_or_else(|| "".to_string()),
|
||||||
|
result: s.result.unwrap_or_else(|| "".to_string()),
|
||||||
|
scope: s.scope.unwrap_or_else(|| "".to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| Vec::new());
|
||||||
|
|
||||||
|
FormattedAuthResults { dkim, spf }
|
||||||
|
});
|
||||||
|
|
||||||
|
FormattedRecord {
|
||||||
|
source_ip: rec
|
||||||
|
.row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.source_ip.clone())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
count: rec
|
||||||
|
.row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.count.map(|c| c.to_string()))
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
header_from: rec
|
||||||
|
.identifiers
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|i| i.header_from.clone())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
disposition: rec
|
||||||
|
.row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.policy_evaluated.as_ref())
|
||||||
|
.and_then(|p| p.disposition.clone())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
dkim: rec
|
||||||
|
.row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.policy_evaluated.as_ref())
|
||||||
|
.and_then(|p| p.dkim.clone())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
spf: rec
|
||||||
|
.row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.policy_evaluated.as_ref())
|
||||||
|
.and_then(|p| p.spf.clone())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
auth_results,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let formatted_policy_published =
|
||||||
|
feedback
|
||||||
|
.policy_published
|
||||||
|
.map(|pol| FormattedPolicyPublished {
|
||||||
|
domain: pol.domain.unwrap_or_else(|| "".to_string()),
|
||||||
|
adkim: pol.adkim.unwrap_or_else(|| "".to_string()),
|
||||||
|
aspf: pol.aspf.unwrap_or_else(|| "".to_string()),
|
||||||
|
p: pol.p.unwrap_or_else(|| "".to_string()),
|
||||||
|
sp: pol.sp.unwrap_or_else(|| "".to_string()),
|
||||||
|
pct: pol.pct.unwrap_or_else(|| "".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let formatted_feedback = FormattedFeedback {
|
||||||
|
report_metadata: formatted_report_metadata,
|
||||||
|
policy_published: formatted_policy_published,
|
||||||
|
record: formatted_record,
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = DmarcReportTemplate {
|
||||||
|
report: &formatted_feedback,
|
||||||
|
};
|
||||||
|
let html = template.render()?;
|
||||||
|
Ok(html)
|
||||||
|
}
|
||||||
|
|||||||
7
server/src/templates.rs
Normal file
7
server/src/templates.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dmarc_report.html")]
|
||||||
|
pub struct DmarcReportTemplate<'a> {
|
||||||
|
pub feedback: &'a crate::nm::Feedback,
|
||||||
|
}
|
||||||
89
server/templates/dmarc_report.html
Normal file
89
server/templates/dmarc_report.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>DMARC Report</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if report.report_metadata.is_some() %}
|
||||||
|
{% let meta = report.report_metadata.as_ref().unwrap() %}
|
||||||
|
<b>Reporter:</b> {{ meta.org_name }}<br>
|
||||||
|
<b>Contact:</b> {{ meta.email }}<br>
|
||||||
|
<b>Report ID:</b> {{ meta.report_id }}<br>
|
||||||
|
{% if meta.date_range.is_some() %}
|
||||||
|
{% let dr = meta.date_range.as_ref().unwrap() %}
|
||||||
|
<b>Date range:</b>
|
||||||
|
{{ dr.begin }}
|
||||||
|
to
|
||||||
|
{{ dr.end }}
|
||||||
|
<br>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if report.policy_published.is_some() %}
|
||||||
|
{% let pol = report.policy_published.as_ref().unwrap() %}
|
||||||
|
<b>Policy Published:</b>
|
||||||
|
<ul>
|
||||||
|
<li>Domain: {{ pol.domain }}</li>
|
||||||
|
<li>ADKIM: {{ pol.adkim }}</li>
|
||||||
|
<li>ASPF: {{ pol.aspf }}</li>
|
||||||
|
<li>Policy: {{ pol.p }}</li>
|
||||||
|
<li>Subdomain Policy: {{ pol.sp }}</li>
|
||||||
|
<li>Percent: {{ pol.pct }}</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if report.record.is_some() %}
|
||||||
|
<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 report.record.as_ref().unwrap() %}
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.source_ip }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.count }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.header_from }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.disposition }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.dkim }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.spf }}</td>
|
||||||
|
<td style="border:1px solid #bbb;padding:4px 8px;">
|
||||||
|
{% if rec.auth_results.is_some() %}
|
||||||
|
{% let auth = rec.auth_results.as_ref().unwrap() %}
|
||||||
|
{% for dkimres in auth.dkim %}
|
||||||
|
<span style="white-space:nowrap;">
|
||||||
|
DKIM: domain=<b>{{ dkimres.domain }}</b>
|
||||||
|
selector=<b>{{ dkimres.selector }}</b>
|
||||||
|
result=<b>{{ dkimres.result }}</b>
|
||||||
|
</span><br>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for spfres in auth.spf %}
|
||||||
|
<span style="white-space:nowrap;">
|
||||||
|
SPF: domain=<b>{{ spfres.domain }}</b>
|
||||||
|
scope=<b>{{ spfres.scope }}</b>
|
||||||
|
result=<b>{{ spfres.result }}</b>
|
||||||
|
</span><br>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if report.report_metadata.is_none() && report.policy_published.is_none() && report.record.is_none() %}
|
||||||
|
<p>No DMARC summary found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,8 +11,8 @@ version.workspace = true
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
build-info = "0.0.40"
|
build-info = "0.0.41"
|
||||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.25", registry = "xinu" }
|
letterbox-notmuch = { path = "../notmuch", version = "0.17.29", registry = "xinu" }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
sqlx = "0.8.5"
|
sqlx = "0.8.5"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ repository.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.40"
|
build-info-build = "0.0.41"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3.50"
|
wasm-bindgen-test = "0.3.50"
|
||||||
@@ -28,12 +28,12 @@ graphql_client = "0.14.0"
|
|||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
gloo-net = { version = "0.6.0", features = ["json", "serde_json"] }
|
gloo-net = { version = "0.6.0", features = ["json", "serde_json"] }
|
||||||
human_format = "1.1.0"
|
human_format = "1.1.0"
|
||||||
build-info = "0.0.40"
|
build-info = "0.0.41"
|
||||||
wasm-bindgen = "=0.2.100"
|
wasm-bindgen = "=0.2.100"
|
||||||
uuid = { version = "1.16.0", features = [
|
uuid = { version = "1.16.0", features = [
|
||||||
"js",
|
"js",
|
||||||
] } # direct dep to set js feature, prevents Rng issues
|
] } # direct dep to set js feature, prevents Rng issues
|
||||||
letterbox-shared = { version = "0.17.9", registry = "xinu" }
|
letterbox-shared = { path = "../shared/", version = "0.17.29", registry = "xinu" }
|
||||||
seed_hooks = { version = "0.4.1", registry = "xinu" }
|
seed_hooks = { version = "0.4.1", registry = "xinu" }
|
||||||
strum_macros = "0.27.1"
|
strum_macros = "0.27.1"
|
||||||
gloo-console = "0.3.0"
|
gloo-console = "0.3.0"
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
<link data-trunk rel="css" href="static/vars.css" />
|
<link data-trunk rel="css" href="static/vars.css" />
|
||||||
<link data-trunk rel="tailwind-css" href="./src/tailwind.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/overrides.css" />
|
||||||
|
<link data-trunk rel="css" href="static/email-specific.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section id="app"></section>
|
<section id="app"></section>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1025,7 +1025,7 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
|
|||||||
],
|
],
|
||||||
IF!(open =>
|
IF!(open =>
|
||||||
div![
|
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 {
|
match &msg.body {
|
||||||
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
|
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
|
||||||
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
|
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
|
||||||
|
|||||||
@@ -63,13 +63,6 @@ use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
|||||||
use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
||||||
use web_sys::CloseEvent;
|
use web_sys::CloseEvent;
|
||||||
|
|
||||||
/// Message from the server to the client.
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct ServerMessage {
|
|
||||||
pub id: usize,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Message from the client to the server.
|
/// Message from the client to the server.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ClientMessage {
|
pub struct ClientMessage {
|
||||||
|
|||||||
11
web/static/email-specific.css
Normal file
11
web/static/email-specific.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.mail-thread .content.from-noreply-news-bloomberg-com a {
|
||||||
|
background-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content.from-noreply-news-bloomberg-com h2 {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.mail-thread .content.from-dmarcreport-microsoft-com div {
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
|
||||||
|
}
|
||||||
@@ -57,15 +57,6 @@ html {
|
|||||||
margin-left: 2em;
|
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 */
|
/* Hackaday figures have unreadable black on dark grey */
|
||||||
.news-post figcaption.wp-caption-text {
|
.news-post figcaption.wp-caption-text {
|
||||||
background-color: initial !important;
|
background-color: initial !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user