Compare commits
90 Commits
letterbox-
...
letterbox-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| fa23658ef0 | |||
| f175faed98 | |||
| 8971c16117 | |||
| fbecf564b5 | |||
| e5643c6fd0 | |||
| a8734269f7 | |||
| cab4e571f3 | |||
| 4d6c6af7d9 | |||
| cf08831ed1 | |||
| e1509c5978 | |||
| 13db8e6f1f | |||
| 136a837fa4 | |||
| 1ea058c664 | |||
| f4c11c5b3f | |||
| 8dc8f3a0f8 | |||
| 7b9450b65b | |||
| b5de0719dd | |||
| 58da28a19b | |||
| 75ad27ec2f | |||
| f904fa0001 | |||
| b94596bf65 | |||
| aa24599921 | |||
| c81a8c1cd3 | |||
| 7c3cfec3d1 | |||
| a2920fde3b | |||
| 8bc449ae6e | |||
| 0febd0535a | |||
| a9e00a54e4 | |||
| 6811c689ff | |||
| 8ba6b3d0b0 | |||
| a7c5585e80 | |||
| 4ef4d49113 | |||
| f8af303110 | |||
| fa5aac34ba | |||
| b58556254e | |||
| e365ced7dd | |||
| 93d569fb14 | |||
| f86a5f464d |
2050
Cargo.lock
generated
2050
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"
|
||||
license = "UNLICENSED"
|
||||
publish = ["xinu"]
|
||||
version = "0.17.21"
|
||||
version = "0.17.28"
|
||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
||||
|
||||
[profile.dev]
|
||||
|
||||
@@ -214,9 +214,8 @@ use std::{
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
/// # Number of seconds since the Epoch
|
||||
pub type UnixTime = isize;
|
||||
@@ -718,6 +717,13 @@ impl Notmuch {
|
||||
cmd.args(args);
|
||||
info!("{:?}", &cmd);
|
||||
let out = cmd.output()?;
|
||||
if !out.stderr.is_empty() {
|
||||
warn!(
|
||||
"{:?}: STDERR:\n{}",
|
||||
&cmd,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(out.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,26 +14,29 @@ version.workspace = true
|
||||
[dependencies]
|
||||
ammonia = "4.1.0"
|
||||
anyhow = "1.0.98"
|
||||
askama = { version = "0.14.0", features = ["derive"] }
|
||||
async-graphql = { version = "7", features = ["log"] }
|
||||
async-graphql-axum = "7.0.16"
|
||||
async-trait = "0.1.88"
|
||||
axum = { version = "0.8.3", features = ["ws"] }
|
||||
axum-macros = "0.5.0"
|
||||
build-info = "0.0.40"
|
||||
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.14.4"
|
||||
css-inline = "0.17.0"
|
||||
flate2 = "1.1.2"
|
||||
futures = "0.3.31"
|
||||
headers = "0.4.0"
|
||||
html-escape = "0.2.13"
|
||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.21", registry = "xinu" }
|
||||
letterbox-shared = { path = "../shared", version = "0.17.21", registry = "xinu" }
|
||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.28", registry = "xinu" }
|
||||
letterbox-shared = { path = "../shared", version = "0.17.28", registry = "xinu" }
|
||||
linkify = "0.10.0"
|
||||
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,9 +53,10 @@ 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.40"
|
||||
build-info-build = "0.0.41"
|
||||
|
||||
[features]
|
||||
#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!`
|
||||
// and `build_info::format!` in the main program.
|
||||
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),
|
||||
#[error("impossible: {0}")]
|
||||
InfaillibleError(#[from] Infallible),
|
||||
#[error("askama error: {0}")]
|
||||
AskamaError(#[from] askama::Error),
|
||||
}
|
||||
|
||||
@@ -352,6 +352,9 @@ fn slurp_contents_selectors() -> HashMap<String, Vec<Selector>> {
|
||||
"natwelch.com".to_string() => vec![
|
||||
Selector::parse("article div.prose").unwrap(),
|
||||
],
|
||||
"seiya.me".to_string() => vec![
|
||||
Selector::parse("header + div").unwrap(),
|
||||
],
|
||||
"rustacean-station.org".to_string() => vec![
|
||||
Selector::parse("article").unwrap(),
|
||||
],
|
||||
|
||||
348
server/src/nm.rs
348
server/src/nm.rs
@@ -1,14 +1,19 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::File,
|
||||
io::Cursor,
|
||||
};
|
||||
|
||||
use askama::Template;
|
||||
use chrono::{TimeZone, Utc};
|
||||
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 +25,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 +453,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 +463,71 @@ 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 +597,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 +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)),
|
||||
}
|
||||
part_addr.pop();
|
||||
@@ -976,12 +1050,10 @@ pub async fn label_unprocessed(
|
||||
*/
|
||||
info!("Loaded {} rules", rules.len());
|
||||
|
||||
let ids = if let Some(limit) = limit {
|
||||
let limit = limit.max(ids.len());
|
||||
&ids[..limit]
|
||||
} else {
|
||||
&ids[..]
|
||||
};
|
||||
let limit = limit.unwrap_or(ids.len());
|
||||
let limit = limit.min(ids.len());
|
||||
let ids = &ids[..limit];
|
||||
|
||||
let mut add_mutations = HashMap::new();
|
||||
let mut rm_mutations = HashMap::new();
|
||||
for id in ids {
|
||||
@@ -1105,3 +1177,267 @@ 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.
|
||||
#[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
|
||||
|
||||
[dependencies]
|
||||
build-info = "0.0.40"
|
||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.21", registry = "xinu" }
|
||||
build-info = "0.0.41"
|
||||
letterbox-notmuch = { path = "../notmuch", version = "0.17.28", registry = "xinu" }
|
||||
regex = "1.11.1"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
sqlx = "0.8.5"
|
||||
|
||||
@@ -9,7 +9,7 @@ repository.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
build-info-build = "0.0.40"
|
||||
build-info-build = "0.0.41"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.50"
|
||||
@@ -28,12 +28,12 @@ graphql_client = "0.14.0"
|
||||
thiserror = "2.0.12"
|
||||
gloo-net = { version = "0.6.0", features = ["json", "serde_json"] }
|
||||
human_format = "1.1.0"
|
||||
build-info = "0.0.40"
|
||||
build-info = "0.0.41"
|
||||
wasm-bindgen = "=0.2.100"
|
||||
uuid = { version = "1.16.0", features = [
|
||||
"js",
|
||||
] } # direct dep to set js feature, prevents Rng issues
|
||||
letterbox-shared = { version = "0.17.9", registry = "xinu" }
|
||||
letterbox-shared = { path = "../shared/", version = "0.17.28", registry = "xinu" }
|
||||
seed_hooks = { version = "0.4.1", registry = "xinu" }
|
||||
strum_macros = "0.27.1"
|
||||
gloo-console = "0.3.0"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// - it's useful when you want to check your code with `cargo make verify`
|
||||
// but some rules are too "annoying" or are not applicable for your case.)
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
// Until https://github.com/rust-lang/rust/issues/138762 is addressed in dependencies
|
||||
#![allow(wasm_c_abi)]
|
||||
|
||||
use log::Level;
|
||||
use seed::App;
|
||||
|
||||
@@ -72,10 +72,6 @@ fn on_url_changed(old: &Url, mut new: Url) -> Msg {
|
||||
if did_change {
|
||||
messages.push(Msg::ScrollToTop)
|
||||
}
|
||||
info!(
|
||||
"url changed\nold '{old}'\nnew '{new}', history {}",
|
||||
history().length().unwrap_or(0)
|
||||
);
|
||||
let hpp = new.remaining_hash_path_parts();
|
||||
let msg = match hpp.as_slice() {
|
||||
["t", tid] => Msg::ShowThreadRequest {
|
||||
@@ -553,7 +549,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
});
|
||||
}
|
||||
Msg::ScrollToTop => {
|
||||
info!("scrolling to the top");
|
||||
web_sys::window().unwrap().scroll_to_with_x_and_y(0., 0.);
|
||||
}
|
||||
Msg::WindowScrolled => {
|
||||
@@ -619,6 +614,36 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
orders.send_msg(Msg::CatchupRequest { query });
|
||||
}
|
||||
Msg::CatchupKeepUnread => {
|
||||
if let Some(thread_id) = current_thread_id(&model.context) {
|
||||
if let Context::ThreadResult {
|
||||
thread:
|
||||
ShowThreadQueryThread::EmailThread(ShowThreadQueryThreadOnEmailThread {
|
||||
messages,
|
||||
..
|
||||
}),
|
||||
..
|
||||
} = &model.context
|
||||
{
|
||||
//orders.send_msg(Msg::SetUnread(thread_id, false));
|
||||
let unread_messages: Vec<_> = messages
|
||||
.iter()
|
||||
.filter(|msg| msg.tags.iter().any(|t| t == "unread"))
|
||||
.map(|msg| &msg.id)
|
||||
.collect();
|
||||
if unread_messages.is_empty() {
|
||||
// All messages are read, so mark them all unread
|
||||
orders.send_msg(Msg::SetUnread(thread_id, true));
|
||||
} else {
|
||||
// Do nothing if there are some messages unread
|
||||
}
|
||||
} else {
|
||||
// News post, not email, just mark unread
|
||||
orders.send_msg(Msg::SetUnread(thread_id, true));
|
||||
};
|
||||
} else {
|
||||
// This shouldn't happen
|
||||
warn!("no current thread_id");
|
||||
}
|
||||
orders.send_msg(Msg::CatchupNext);
|
||||
}
|
||||
Msg::CatchupMarkAsRead => {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{collections::VecDeque, rc::Rc};
|
||||
|
||||
use letterbox_shared::WebsocketMessage;
|
||||
use log::{error, info};
|
||||
use log::{debug, error};
|
||||
use seed::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -63,13 +63,6 @@ use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
||||
use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
||||
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.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ClientMessage {
|
||||
@@ -122,13 +115,13 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::WebSocketOpened => {
|
||||
model.web_socket_reconnector = None;
|
||||
info!("WebSocket connection is open now");
|
||||
debug!("WebSocket connection is open now");
|
||||
}
|
||||
Msg::TextMessageReceived(msg) => {
|
||||
model.updates.push_back(msg);
|
||||
}
|
||||
Msg::WebSocketClosed(close_event) => {
|
||||
info!(
|
||||
debug!(
|
||||
r#"==================
|
||||
WebSocket connection was closed:
|
||||
Clean: {0}
|
||||
@@ -148,7 +141,7 @@ Reason: {2}
|
||||
}
|
||||
}
|
||||
Msg::WebSocketFailed => {
|
||||
info!("WebSocket failed");
|
||||
debug!("WebSocket failed");
|
||||
if model.web_socket_reconnector.is_none() {
|
||||
model.web_socket_reconnector = Some(
|
||||
orders.stream_with_handle(streams::backoff(None, Msg::ReconnectWebSocket)),
|
||||
@@ -156,7 +149,7 @@ Reason: {2}
|
||||
}
|
||||
}
|
||||
Msg::ReconnectWebSocket(retries) => {
|
||||
info!("Reconnect attempt: {}", retries);
|
||||
debug!("Reconnect attempt: {}", retries);
|
||||
model.web_socket = create_websocket(&model.ws_url, orders).unwrap();
|
||||
}
|
||||
Msg::SendMessage(msg) => {
|
||||
@@ -177,16 +170,16 @@ fn create_websocket(url: &str, orders: &impl Orders<Msg>) -> Result<EventClient,
|
||||
|
||||
let send = msg_sender.clone();
|
||||
client.set_on_connection(Some(Box::new(move |client: &EventClient| {
|
||||
info!("{:#?}", client.status);
|
||||
debug!("{:#?}", client.status);
|
||||
let msg = match *client.status.borrow() {
|
||||
ConnectionStatus::Connecting => {
|
||||
info!("Connecting...");
|
||||
debug!("Connecting...");
|
||||
None
|
||||
}
|
||||
ConnectionStatus::Connected => Some(Msg::WebSocketOpened),
|
||||
ConnectionStatus::Error => Some(Msg::WebSocketFailed),
|
||||
ConnectionStatus::Disconnected => {
|
||||
info!("Disconnected");
|
||||
debug!("Disconnected");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -195,7 +188,7 @@ fn create_websocket(url: &str, orders: &impl Orders<Msg>) -> Result<EventClient,
|
||||
|
||||
let send = msg_sender.clone();
|
||||
client.set_on_close(Some(Box::new(move |ev| {
|
||||
info!("WS: Connection closed");
|
||||
debug!("WS: Connection closed");
|
||||
send(Some(Msg::WebSocketClosed(ev)));
|
||||
})));
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -76,6 +67,11 @@ html {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.news-post.site-seiya-me figure>pre,
|
||||
.news-post.site-seiya-me figure>pre>code {
|
||||
background-color: black !important;
|
||||
}
|
||||
|
||||
.news-post.site-slashdot .story-byline {
|
||||
display: block !important;
|
||||
height: initial !important;
|
||||
|
||||
Reference in New Issue
Block a user