1use crate::budget::{Budget, Ledger, MailLine, MailState, Period, Reserve, Verdict};
12use crate::money::Money;
13use crate::payments::PaymentKind;
14use crate::card::Card;
15use crate::projects::ProjectStore;
16use crate::config::{Account, AccountKind, Config, Priority};
17use crate::folders::{Folder, Role};
18use crate::maildir;
19use crate::trust;
20use crate::i18n::{self, Translator};
21use crate::porch::{self, Lane, Reason, Triaged};
22use crate::reading::{self, Html, Part, Person};
23use crate::trust::{AuthResults, Outcome, Trust};
24use crate::window;
25use jiff::Zoned;
26use jiff::Timestamp;
27use jiff::tz::TimeZone;
28use serde::Serialize;
29use std::collections::{BTreeMap, BTreeSet};
30
31const PREVIEW: usize = 160;
33
34#[derive(Debug, Serialize)]
35pub struct PorchView {
36 pub open: bool,
38 pub closed: Option<String>,
40 pub right_now: Vec<CodeView>,
43 pub summary: String,
45 pub status: String,
47 pub lanes: Vec<LaneView>,
49 pub all: bool,
51}
52
53#[derive(Debug, Serialize)]
54pub struct CodeView {
55 pub key: String,
56 pub title: String,
58 pub code: Option<String>,
60 pub validity: Option<String>,
62 pub warning: Option<String>,
64}
65
66#[derive(Debug, Serialize)]
67pub struct LaneView {
68 pub key: String,
69 pub title: String,
70 pub folded: bool,
72 pub counted: bool,
74 pub about: String,
76 pub rules: Vec<String>,
78 pub settings: String,
80 pub items: Vec<ItemView>,
81}
82
83#[derive(Debug, Serialize)]
84pub struct ItemView {
85 pub key: String,
87 pub account: Option<String>,
88 pub sender: String,
89 pub address: String,
90 pub trust: String,
92 pub trust_level: &'static str,
94 pub subject: String,
95 pub date: String,
96 pub preview: String,
97 pub reasons: Vec<String>,
99 pub attachments: Vec<String>,
100 pub screener: bool,
102 pub spam: bool,
105 pub review: bool,
107 pub moved: bool,
109 pub spam_word: String,
112 pub important: bool,
114 pub checks: String,
116 pub topic: String,
118 pub tone: String,
119 pub hidden: bool,
121 pub summary: String,
123}
124
125pub fn porch(items: &[Triaged], config: &Config, store: Option<&ProjectStore>, tr: &Translator, now: &Zoned, opened_anyway: bool) -> PorchView {
128 let right_now = codes(items, tr);
129 let open = opened_anyway || config.windows.is_empty() || window::current(&config.windows, now).is_some();
130 if !open {
131 let mut args = i18n::args();
132 let next = window::next_opening(&config.windows, now);
133 args.set("when", next.map_or_else(String::new, |z| tr.when(&z)));
134 let closed = Some(tr.text("porch-closed", Some(&args)));
135 return PorchView { open, closed, right_now, summary: String::new(), status: String::new(), lanes: Vec::new(), all: false };
136 }
137 let status = match window::current(&config.windows, now) {
138 Some((_, closing)) => {
139 let mut args = i18n::args();
140 args.set("time", closing.strftime("%H:%M").to_string());
141 tr.text("porch-open-until", Some(&args))
142 }
143 None if !config.windows.is_empty() => tr.text("porch-opened-anyway", None),
144 None => String::new(),
145 };
146 let order = tr.text("rule-order", None);
147 let hidden = |key: &str| key.strip_prefix("project:").is_some_and(|id| config.porch.hidden_projects.iter().any(|h| h == id));
148 let lanes = lanes(config, store, tr)
149 .into_iter()
150 .filter(|l| !hidden(&l.key))
151 .map(|LaneInfo { key, title, lane, folded, about, mut rules, counted }| {
152 let mut ranked: Vec<&Triaged> = items.iter().filter(|t| t.lane == lane).collect();
154 ranked.sort_by_key(|t| (t.priority, t.assessment.as_ref().map(|a| a.topic), std::cmp::Reverse(t.card.date.unwrap_or(0))));
155 let folded = folded && (lane == Lane::Hostile || !ranked.iter().any(|t| t.priority == Priority::Above));
157 rules.push(order.clone());
158 let settings = format!("lane:{key}");
159 LaneView { key, title, folded, counted, about, rules, settings, items: ranked.into_iter().map(|t| item_view(t, store, tr)).collect() }
160 })
161 .filter(|l| !l.items.is_empty())
162 .collect();
163 PorchView { open, closed: None, right_now, summary: tr.summary(&porch::summarise(items), store), status, lanes, all: false }
164}
165
166pub struct LaneInfo {
168 pub key: String,
170 pub title: String,
171 pub lane: Lane,
172 pub folded: bool,
174 pub about: String,
176 pub rules: Vec<String>,
178 pub counted: bool,
180}
181
182pub fn lanes(config: &Config, store: Option<&ProjectStore>, tr: &Translator) -> Vec<LaneInfo> {
187 let say = |id: &str, pairs: &[(&str, String)]| {
188 let mut args = i18n::args();
189 for (name, value) in pairs {
190 args.set(name.to_string(), value.clone());
191 }
192 tr.text(id, Some(&args))
193 };
194 let lane = |key: &str, title: String, lane: Lane, folded: bool, about: String, rules: Vec<String>| LaneInfo { key: key.to_string(), title, counted: lane != Lane::Review, lane, folded, about, rules };
195 let address_of = |id: &str| config.account(id).and_then(|a| a.address.clone()).unwrap_or_else(|| id.to_string());
196 let mut lanes: Vec<LaneInfo> = store
197 .map_or(&[][..], |s| &s.projects[..])
198 .iter()
199 .map(|c| {
200 let mut rules = project_rules(c, tr);
201 rules.push(tr.text("rule-where-routes", None));
202 lane(&format!("project:{}", c.id), c.title.clone(), Lane::Project(c.id.clone()), false, tr.text("lane-about-project", None), rules)
203 })
204 .collect();
205 for account in config.accounts.iter().filter(|a| a.shield) {
206 let address = address_of(&account.id);
207 let minutes = account.fetch_minutes.or(config.fetch_minutes).unwrap_or(crate::config::DEFAULT_FETCH_MINUTES);
208 let mut rules = vec![say("rule-public", &[("minutes", minutes.to_string())])];
209 if account.ai_reads() {
210 rules.push(tr.text("rule-public-ai", None));
211 }
212 rules.push(tr.text("rule-where-shield", None));
213 lanes.push(lane(&format!("public:{}", account.id), say("lane-public", &[("address", address.clone())]), Lane::Public(account.id.clone()), false, say("lane-about-public", &[("address", address)]), rules));
214 }
215 let known = crate::porch::SenderList::load(&config.known_senders_path()).entries().len();
216 lanes.push(lane("people", tr.text("lane-people", None), Lane::People, false, tr.text("lane-about-people", None), vec![say("rule-people", &[("n", known.to_string())])]));
217 lanes.push(lane("screener", tr.text("lane-screener", None), Lane::Screener, false, tr.text("lane-about-screener", None), vec![tr.text("rule-screener", None)]));
218 let words = crate::words::Words::of(config).senders.automatic.clone();
219 lanes.push(lane("filed", tr.text("lane-filed", None), Lane::Filed, true, tr.text("lane-about-filed", None), vec![say("rule-filed", &[("words", words.join(", "))])]));
220 let below: Vec<String> = config.accounts.iter().filter(|a| a.priority == Priority::Below).map(|a| address_of(&a.id)).collect();
221 lanes.push(lane("low", tr.text("lane-low", None), Lane::Low, true, tr.text("lane-about-low", None), if below.is_empty() { vec![tr.text("rule-low-none", None)] } else { vec![say("rule-low", &[("addresses", below.join(", "))]), tr.text("rule-where-rank", None)] }));
222 lanes.push(lane("review", tr.text("lane-review", None), Lane::Review, true, tr.text("lane-about-review", None), {
225 let (spam, unsure) = config.spam.thresholds();
226 let actions = config.spam.actions();
227 let mut rules: Vec<String> = crate::spam::Class::ALL
228 .iter()
229 .map(|class| {
230 let p = match class {
231 crate::spam::Class::Spam => spam,
232 crate::spam::Class::Unsure | crate::spam::Class::Ham => unsure,
233 };
234 let named = say(&format!("rule-review-class-{}", class.as_str()), &[("p", i18n::percent(p))]);
235 say(&format!("rule-review-{}", actions.of(*class).as_str()), &[("class", named)])
236 })
237 .collect();
238 rules.extend(["rule-review-protected", "rule-review-buttons", "rule-review-where"].iter().map(|id| tr.text(id, None)));
239 rules
240 }));
241 lanes.push(lane(
242 "set-aside",
243 tr.text("lane-set-aside", None),
244 Lane::SetAside,
245 true,
246 tr.text("lane-about-set-aside", None),
247 ["rule-forged", "rule-borrowed", "rule-spam", "rule-blocked", "rule-where-blocked"].iter().map(|id| tr.text(id, None)).collect(),
248 ));
249 if config.accounts.iter().any(|a| a.shield) {
250 lanes.push(lane("hostile", tr.text("lane-hostile", None), Lane::Hostile, true, tr.text("lane-about-hostile", None), vec![tr.text("rule-hostile", None), tr.text("rule-where-shield", None)]));
251 }
252 lanes
253}
254
255fn project_rules(project: &crate::projects::Project, tr: &Translator) -> Vec<String> {
257 if project.routes.is_empty() {
258 return vec![tr.text("rule-project-none", None)];
259 }
260 let part = |id: &str, list: &[String]| -> Option<String> {
261 (!list.is_empty()).then(|| {
262 let mut args = i18n::args();
263 args.set("list", list.join(", "));
264 tr.text(id, Some(&args))
265 })
266 };
267 project.routes
268 .iter()
269 .map(|route| {
270 let parts: Vec<String> = [
271 part("rule-part-domains", &route.from_domains),
272 part("rule-part-addresses", &route.from_addresses),
273 part("rule-part-subject", &route.subject_contains),
274 part("rule-part-text", &route.text_contains),
275 ]
276 .into_iter()
277 .flatten()
278 .collect();
279 let mut args = i18n::args();
280 args.set("parts", parts.join(&tr.text("rule-and", None)));
281 tr.text("rule-route", Some(&args))
282 })
283 .collect()
284}
285
286pub fn codes(items: &[Triaged], tr: &Translator) -> Vec<CodeView> {
288 items.iter().filter(|t| t.lane == Lane::RightNow).map(|t| code_view(t, tr)).collect()
289}
290
291impl CodeView {
292 pub fn body(&self) -> String {
295 let line = [self.code.as_deref(), self.validity.as_deref()].into_iter().flatten().collect::<Vec<_>>().join(", ");
296 match self.warning.as_deref() {
297 Some(warning) if line.is_empty() => warning.to_string(),
298 Some(warning) => format!("{line}\n{warning}"),
299 None => line,
300 }
301 }
302}
303
304fn code_view(t: &Triaged, tr: &Translator) -> CodeView {
305 let code = t.code.as_ref();
306 let kind = code.map_or(crate::codes::CodeKind::Code, |c| c.kind);
307 let validity = code.and_then(|c| c.expires_minutes).map(|minutes| tr.validity(minutes));
308 CodeView {
309 key: key(&t.card),
310 title: tr.right_now(kind, t.card.sender(), t.trust, None, None),
311 code: code.and_then(|c| c.code.clone()),
312 validity,
313 warning: (t.trust != Trust::Verified).then(|| tr.text("right-now-unverified", None)),
314 }
315}
316
317fn item_view(t: &Triaged, store: Option<&ProjectStore>, tr: &Translator) -> ItemView {
318 let learned = t.reasons.iter().find_map(Reason::learned).filter(|_| t.lane == Lane::Review);
320 let preview: String = t.card.excerpt.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(PREVIEW).collect();
321 let mut view = ItemView {
322 key: key(&t.card),
323 account: t.card.account.clone(),
324 sender: t.card.sender().to_string(),
325 address: t.card.from_address.clone().unwrap_or_default(),
326 trust: tr.trust(t.trust),
327 trust_level: match t.trust {
328 Trust::Verified => "verified",
329 Trust::Unverified => "unverified",
330 Trust::Forged => "forged",
331 },
332 subject: t.card.subject.clone(),
333 date: date(tr, t.card.date),
334 preview,
335 reasons: t.reasons.iter().map(|r| tr.reason(r, store)).collect(),
336 attachments: t.card.attachments.clone(),
337 screener: t.reasons.contains(&Reason::FirstMessage),
338 spam: (t.lane == Lane::SetAside && t.reasons.iter().any(Reason::is_spam)) || t.lane == Lane::Review,
339 review: t.lane == Lane::Review,
340 moved: t.reasons.contains(&Reason::MovedToJunk),
341 spam_word: learned.map_or(String::new(), |(class, _)| tr.text(&format!("spam-chip-{}", class.as_str()), None)),
342 important: t.priority == Priority::Above,
343 checks: checks(t.checks.as_ref(), tr),
344 topic: t.assessment.as_ref().map(|a| tr.topic(a.topic)).unwrap_or_default(),
345 tone: t.assessment.as_ref().map_or(String::new(), |a| format!("{:?}", a.tone).to_lowercase()),
346 hidden: false,
347 summary: t.assessment.as_ref().map(|a| a.summary.clone()).unwrap_or_default(),
348 };
349 if t.lane == Lane::Hostile {
351 view.hidden = true;
352 view.sender = t.card.sender_domain().map_or_else(|| tr.text("hostile-someone", None), |d| {
353 let mut args = i18n::args();
354 args.set("domain", d.to_string());
355 tr.text("hostile-someone-at", Some(&args))
356 });
357 view.subject = tr.text("hostile-subject", None);
358 view.preview = String::new();
359 view.summary = String::new();
360 } else if t.assessment.as_ref().is_some_and(|a| a.tone == crate::shield::Tone::Rude) {
361 view.preview = String::new();
362 }
363 view
364}
365
366pub fn checks(results: Option<&AuthResults>, tr: &Translator) -> String {
369 let Some(r) = results else { return tr.text("checks-none", None) };
370 let by = if r.authserv_id.starts_with("sioul-") {
371 tr.text("checks-by-sioul", None)
372 } else {
373 let mut args = i18n::args();
374 args.set("id", r.authserv_id.clone());
375 tr.text("checks-by-provider", Some(&args))
376 };
377 let dmarc_detail = match (&r.dmarc_domain, &r.dmarc_policy) {
378 (Some(domain), Some(policy)) => {
379 let mut args = i18n::args();
380 args.set("policy", policy.clone());
381 Some(format!("{domain}, {}", tr.text("check-policy", Some(&args))))
382 }
383 (domain, _) => domain.clone(),
384 };
385 let rows: [(String, Option<Outcome>, Option<String>); 5] = [
386 ("DKIM".into(), r.dkim, r.dkim_domain.clone()),
387 ("SPF".into(), r.spf, r.spf_domain.clone()),
388 ("DMARC".into(), r.dmarc, dmarc_detail),
389 ("ARC".into(), r.arc, None),
390 (tr.text("protocol-iprev", None), r.iprev, None),
391 ];
392 let lines: Vec<String> = rows
393 .into_iter()
394 .filter_map(|(protocol, outcome, domain)| {
395 let outcome = outcome?;
396 let mut args = i18n::args();
397 args.set("protocol", protocol);
398 args.set("outcome", tr.text(outcome_id(outcome), None));
399 let id = match domain {
400 Some(d) => {
401 args.set("domain", d);
402 "check-line-domain"
403 }
404 None => "check-line",
405 };
406 Some(tr.text(id, Some(&args)))
407 })
408 .collect();
409 if lines.is_empty() {
410 return tr.text("checks-none", None);
411 }
412 std::iter::once(by).chain(lines).collect::<Vec<_>>().join("\n")
413}
414
415fn outcome_id(outcome: Outcome) -> &'static str {
416 match outcome {
417 Outcome::Pass => "check-pass",
418 Outcome::Fail => "check-fail",
419 Outcome::SoftFail => "check-softfail",
420 Outcome::Neutral => "check-neutral",
421 Outcome::None => "check-none",
422 Outcome::TempError => "check-temperror",
423 Outcome::PermError => "check-permerror",
424 Outcome::Other => "check-other",
425 }
426}
427
428fn key(card: &Card) -> String {
429 card.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()
430}
431
432pub fn date(tr: &Translator, seconds: Option<i64>) -> String {
434 seconds
435 .and_then(|s| Timestamp::from_second(s).ok())
436 .map_or_else(|| tr.text("no-date", None), |t| tr.date(&t.to_zoned(TimeZone::system()), true))
437}
438
439#[derive(Debug, Serialize)]
440pub struct BudgetsView {
441 pub title: String,
442 pub budgets: Vec<BudgetCard>,
443 pub reserves_title: String,
444 pub reserves: Vec<ReserveCard>,
445 pub mail_title: String,
446 pub mail: Vec<MailRow>,
448 pub choices: Vec<Choice>,
450}
451
452#[derive(Debug, Serialize)]
454pub struct Figure {
455 pub label: String,
456 pub value: String,
457}
458
459#[derive(Debug, Serialize)]
460pub struct BudgetCard {
461 pub id: String,
462 pub title: String,
463 pub period: String,
465 pub mood: &'static str,
467 pub verdict: String,
469 pub pace: String,
471 pub figures: Vec<Figure>,
472 pub notes: Vec<String>,
474}
475
476#[derive(Debug, Serialize)]
477pub struct ReserveCard {
478 pub id: String,
479 pub title: String,
480 pub figures: Vec<Figure>,
481}
482
483#[derive(Debug, Serialize)]
484pub struct MailRow {
485 pub key: String,
486 pub date: String,
487 pub kind: PaymentKind,
489 pub kind_label: String,
490 pub party: String,
492 pub subject: String,
493 pub sender: String,
494 pub amount: String,
496 pub credit: bool,
497 pub budget: Option<String>,
499 pub state: &'static str,
501 pub note: String,
503 pub doubtful: bool,
505 pub can_add: bool,
507}
508
509#[derive(Debug, Serialize)]
510pub struct Choice {
511 pub id: String,
512 pub title: String,
513}
514
515pub fn budgets(ledger: &Ledger, mail: &[MailLine], tr: &Translator, today: jiff::civil::Date) -> BudgetsView {
518 let mut args = i18n::args();
519 args.set("month", tr.month_year(today));
520 BudgetsView {
521 title: tr.text("budgets-title", Some(&args)),
522 budgets: ledger.budgets.iter().map(|b| budget_card(ledger, b, tr, today)).collect(),
523 reserves_title: tr.text("reserves-title", None),
524 reserves: ledger.reserves.iter().map(|r| reserve_card(ledger, r, tr, today)).collect(),
525 mail_title: tr.text("mail-section", None),
526 mail: mail.iter().filter(|l| l.state != MailState::Ignored).map(|l| mail_row(ledger, mail, l, tr)).collect(),
527 choices: ledger.budgets.iter().map(|b| Choice { id: b.id.clone(), title: b.title.clone() }).collect(),
528 }
529}
530
531fn pace_sentence(pace: &crate::budget::Pace, tr: &Translator, monthly: bool) -> String {
533 let percent = |x: f64| ((x * 100.0).round().clamp(0.0, 999.0) as i64).to_string();
534 let mut args = i18n::args();
535 args.set("gone", percent(pace.gone));
536 let period = if monthly { "month" } else { "year" };
537 let id = if pace.needed.cents() > 0 {
538 args.set("part", percent(pace.unscheduled.cents() as f64 / pace.needed.cents() as f64));
539 "needs"
540 } else if pace.needed.cents() < 0 {
541 args.set("part", percent((-pace.unscheduled.cents()).max(0) as f64 / (-pace.needed.cents()) as f64));
542 "may-spend"
543 } else {
544 "nothing-needed"
545 };
546 tr.text(&format!("pace-{id}-{period}"), Some(&args))
547}
548
549fn budget_card(ledger: &Ledger, budget: &Budget, tr: &Translator, today: jiff::civil::Date) -> BudgetCard {
550 let status = ledger.status(budget, today);
551 let pace = ledger.pace(budget, today);
552 let figure = |id: &str, pairs: &[(&str, String)], value: Money| {
553 let mut args = i18n::args();
554 for (key, v) in pairs {
555 args.set(key.to_string(), v.clone());
556 }
557 Figure { label: tr.text(id, Some(&args)), value: tr.money(value) }
558 };
559 let mut figures = vec![
560 figure("fig-so-far", &[], status.so_far),
561 figure("fig-expected", &[("end", tr.day_month(status.end))], pace.realistic),
562 figure("fig-target", &[], status.target),
563 ];
564 if let Some((low, high)) = pace.range {
565 let mut args = i18n::args();
566 args.set("low", tr.money(low));
567 args.set("high", tr.money(high));
568 figures.insert(2, Figure { label: tr.text("fig-range", None), value: tr.text("fig-range-value", Some(&args)) });
569 }
570 if status.opening != Money::ZERO {
571 figures.insert(0, figure("fig-opening", &[], status.opening));
572 }
573 if status.earmarked != Money::ZERO {
574 figures.push(figure("fig-earmarked", &[], status.earmarked));
575 }
576 let (mood, verdict) = match pace.verdict {
578 Verdict::Better => ("better", "badge-pace-better"),
579 Verdict::AsPlanned => ("as-planned", "badge-pace-on-track"),
580 Verdict::Short => ("short", "badge-pace-short"),
581 };
582 let mut args = i18n::args();
583 args.set("gap", tr.money(pace.ahead.abs()));
584 let mut notes = Vec::new();
585 if let Some((reserve, amount)) = &status.reserve_transfer {
586 let mut args = i18n::args();
587 args.set("reserve", ledger.reserve_title(reserve).to_string());
588 args.set("amount", tr.money(amount.abs()));
589 args.set("end", tr.day_month(status.end));
590 notes.push(tr.text(if amount.is_negative() { "budget-swept" } else { "budget-drawn" }, Some(&args)));
591 }
592 BudgetCard {
593 id: budget.id.clone(),
594 title: budget.title.clone(),
595 period: tr.text(if budget.period == Period::Month { "period-month" } else { "period-year" }, None),
596 mood,
597 verdict: tr.text(verdict, Some(&args)),
598 pace: pace_sentence(&pace, tr, budget.period == Period::Month),
599 figures,
600 notes,
601 }
602}
603
604#[derive(Debug, Serialize)]
606pub struct MovementRow {
607 pub uri: String,
609 pub date: String,
610 pub day: String,
612 pub value: f64,
613 pub label: String,
614 pub amount: String,
615 pub incoming: bool,
616 pub planned: bool,
617 pub recurring: bool,
618}
619
620#[derive(Debug, Serialize)]
622pub struct BalancePoint {
623 pub label: String,
624 pub date: String,
625 pub cents: i64,
626 pub future: bool,
627}
628
629#[derive(Debug, Serialize)]
631pub struct BudgetDetail {
632 pub card: BudgetCard,
633 pub period_title: String,
635 pub from: String,
637 pub movements: Vec<MovementRow>,
638 pub money_in: String,
639 pub money_out: String,
640 pub step: String,
642 pub points: Vec<BalancePoint>,
643 pub low_cents: i64,
644 pub high_cents: i64,
645 pub target_cents: i64,
646 pub personal: bool,
648 pub area: String,
650 pub period_kind: String,
652}
653
654pub fn budget_detail(ledger: &Ledger, budget: &Budget, anchor: jiff::civil::Date, step: &str, tr: &Translator, today: jiff::civil::Date) -> BudgetDetail {
656 let (start, end) = budget.period.bounds(anchor);
657 let moves = ledger.movements(budget, start, end, today);
658 let money_in: Money = moves.iter().filter(|m| !m.amount.is_negative()).map(|m| m.amount).sum();
659 let money_out: Money = moves.iter().filter(|m| m.amount.is_negative()).map(|m| m.amount).sum();
660 let span = |years: i64, months: i64, weeks: i64, days: i64| jiff::Span::new().years(years).months(months).weeks(weeks).days(days);
661 let (from, to) = match step {
662 "week" => (anchor.checked_sub(span(0, 0, 8, 0)).unwrap_or(anchor), anchor.checked_add(span(0, 0, 4, 0)).unwrap_or(anchor)),
663 "month" => (anchor.first_of_year(), anchor.last_of_year()),
664 "year" => (budget.since.unwrap_or(anchor.checked_sub(span(4, 0, 0, 0)).unwrap_or(anchor)).first_of_year(), anchor.checked_add(span(1, 0, 0, 0)).unwrap_or(anchor).last_of_year()),
665 _ => (anchor.first_of_month(), anchor.last_of_month()),
666 };
667 let points: Vec<BalancePoint> = ledger
668 .balance_series(budget, from, to, step, today)
669 .into_iter()
670 .map(|p| BalancePoint {
671 label: match step {
672 "year" => p.date.year().to_string(),
673 "month" => tr.month_short(p.date),
675 "week" => tr.day_month(p.date),
676 _ => p.date.day().to_string(),
677 },
678 date: p.date.to_string(),
679 cents: p.balance.cents(),
680 future: p.future,
681 })
682 .collect();
683 let mut card = budget_card(ledger, budget, tr, today);
684 if !(start <= today && today <= end) {
685 card.verdict = String::new();
687 card.pace = String::new();
688 }
689 BudgetDetail {
690 card,
691 period_title: if budget.period == Period::Month { tr.month_year(start) } else { start.year().to_string() },
692 from: start.to_string(),
693 movements: moves
694 .iter()
695 .rev()
696 .map(|m| MovementRow {
697 uri: m.line.map(|i| crate::links::budget_uri(&budget.id, i)).unwrap_or_default(),
698 date: tr.day(m.date),
699 day: m.date.to_string(),
700 value: m.amount.cents() as f64 / 100.0,
701 label: m.label.clone(),
702 amount: tr.money(m.amount),
703 incoming: !m.amount.is_negative(),
704 planned: m.planned,
705 recurring: m.recurring,
706 })
707 .collect(),
708 money_in: tr.money(money_in),
709 money_out: tr.money(money_out.abs()),
710 step: step.to_string(),
711 low_cents: points.iter().map(|p| p.cents).min().unwrap_or(0).min(budget.target.cents()),
712 high_cents: points.iter().map(|p| p.cents).max().unwrap_or(0).max(budget.target.cents()),
713 target_cents: budget.target.cents(),
714 personal: budget.area.as_deref() == Some("personal"),
715 area: match budget.area.as_deref() {
717 Some("personal") => "leisure".into(),
718 Some(other) => crate::areas::Area::parse(other).map(|a| a.id()).unwrap_or_default(),
719 None => String::new(),
720 },
721 period_kind: if budget.period == Period::Year { "year".into() } else { "month".into() },
722 points,
723 }
724}
725
726fn reserve_card(ledger: &Ledger, reserve: &Reserve, tr: &Translator, today: jiff::civil::Date) -> ReserveCard {
727 let status = ledger.reserve_status(reserve, today);
728 let mut month = i18n::args();
729 month.set("done", tr.money(status.month_done));
730 month.set("planned", tr.money(status.month_planned));
731 let mut year_end = i18n::args();
732 year_end.set("date", tr.day_month(today.last_of_year()));
733 let pace = match status.months_left {
734 Some(months) => {
735 let mut args = i18n::args();
737 args.set("months", months);
738 tr.text("fig-pace-months", Some(&args))
739 }
740 None => tr.text("fig-pace-quiet", None),
741 };
742 let mut figures = vec![Figure { label: tr.text("fig-today", None), value: tr.money(status.balance_now) }];
743 if reserve.delay_days > 0 {
745 let mut args = i18n::args();
746 args.set("days", i64::from(reserve.delay_days));
747 figures.push(Figure { label: tr.text("fig-delay", None), value: tr.text("reserve-delay-short", Some(&args)) });
748 }
749 figures.extend([
750 Figure { label: tr.text("fig-this-month", None), value: tr.text("fig-month-flows", Some(&month)) },
751 Figure { label: tr.text("fig-by-date", Some(&year_end)), value: tr.money(status.year_end) },
752 Figure { label: tr.text("fig-pace", None), value: pace },
753 ]);
754 ReserveCard { id: reserve.id.clone(), title: reserve.title.clone(), figures }
755}
756
757fn mail_row(ledger: &Ledger, all: &[MailLine], line: &MailLine, tr: &Translator) -> MailRow {
758 let signed = line.signed();
759 let amount = signed.map_or_else(String::new, |m| if m.is_negative() { tr.money(m) } else { format!("+{}", tr.money(m)) });
760 let say = |id: &str, key: &str, value: String| {
761 let mut args = i18n::args();
762 args.set(key.to_string(), value);
763 tr.text(id, Some(&args))
764 };
765 let preset_label = |id: &str| ledger.presets.iter().find(|p| p.id.as_deref() == Some(id)).map_or(id.to_string(), |p| p.label.clone());
766 let (state, note) = match &line.state {
767 MailState::Recorded => {
768 let budget = ledger.lines.iter().find(|l| l.links.contains(&line.key)).map_or("", |l| ledger.budget_title(&l.budget));
769 ("recorded", say("note-recorded", "budget", budget.to_string()))
770 }
771 MailState::Duplicate(other) => {
772 let other = all.iter().find(|l| &l.key == other).map_or_else(String::new, |l| format!("{} ({})", l.payment.party, date_of(tr, l.date)));
773 ("duplicate", say("note-duplicate", "other", other))
774 }
775 _ => match (&line.preset, signed) {
776 (Some(preset), None) => ("proposed", say("note-preset", "preset", preset_label(preset))),
777 (Some(preset), Some(_)) => ("proposed", say("note-preset-amount", "preset", preset_label(preset))),
778 (None, None) => ("proposed", tr.text("note-no-amount", None)),
779 (None, Some(_)) => ("proposed", String::new()),
780 },
781 };
782 MailRow {
783 key: line.key.clone(),
784 date: date_of(tr, line.date),
785 kind: line.payment.kind,
786 kind_label: tr.text(line.payment.kind.message_id(), None),
787 party: line.payment.party.clone(),
788 subject: line.subject.clone(),
789 sender: line.sender.clone(),
790 amount,
791 credit: line.payment.kind.is_credit(),
792 budget: line.budget.clone(),
793 state,
794 note,
795 doubtful: line.doubtful,
796 can_add: line.state == MailState::Proposed && signed.is_some(),
797 }
798}
799
800fn date_of(tr: &Translator, date: jiff::civil::Date) -> String {
801 tr.day_month(date)
802}
803
804#[derive(Debug, Serialize)]
805pub struct AccountView {
806 pub id: String,
807 pub kind: &'static str,
809 pub address: Option<String>,
810 pub url: Option<String>,
811 pub rows: Vec<Figure>,
813 pub status: String,
815 pub status_error: bool,
817 pub priority: &'static str,
819 pub google: bool,
821 pub microsoft: bool,
824 pub microsoft_personal: bool,
826 pub at_microsoft: bool,
829 pub host: Option<String>,
831 pub login: Option<String>,
834 pub password_wanted: bool,
837 pub name: String,
839 pub signature: String,
840 pub enabled: bool,
842 pub identity: String,
845 pub service: &'static str,
847}
848
849pub fn accounts(config: &Config, tr: &Translator, status: &BTreeMap<String, (String, bool)>, wanted: &BTreeSet<String>) -> Vec<AccountView> {
854 config
855 .every_account()
856 .filter(|a| a.kind != AccountKind::Portal)
857 .map(|a| {
858 let mut rows = Vec::new();
859 if a.auth.as_deref() == Some("google") && a.kind == AccountKind::Dav {
862 rows.push(Figure { label: tr.text("account-row-server", None), value: "Google".into() });
863 } else if let (Some(host), true) = (&a.host, a.kind == AccountKind::Dav) {
864 rows.push(Figure { label: tr.text("account-row-server", None), value: host.clone() });
865 } else if let Some(host) = &a.host {
866 let mut args = i18n::args();
867 args.set("host", host.clone());
868 args.set("port", a.port_or_default().to_string());
869 args.set("security", tr.text(&format!("security-{}", a.security.as_str()), None));
870 rows.push(Figure { label: tr.text("account-row-server", None), value: tr.text("account-server", Some(&args)) });
871 }
872 if a.is_microsoft() {
874 let how = if a.is_microsoft_work() { "account-signed-in-microsoft-work" } else { "account-signed-in-microsoft-personal" };
875 rows.push(Figure { label: tr.text("account-row-sign-in", None), value: tr.text(how, None) });
876 }
877 if a.kind == AccountKind::Imap {
878 let checks = if a.trusted_authserv_ids.is_empty() {
879 tr.text("account-checks-learning", None)
880 } else {
881 a.trusted_authserv_ids.join(", ")
882 };
883 rows.push(Figure { label: tr.text("account-row-checks", None), value: checks });
884 rows.push(Figure { label: tr.text("account-row-mail", None), value: a.maildir_path().display().to_string() });
885 }
886 if let (Some(url), true) = (&a.url, a.kind != AccountKind::Dav) {
887 rows.push(Figure { label: tr.text("account-row-web", None), value: url.clone() });
888 }
889 if a.kind == AccountKind::Dav {
890 rows.push(Figure { label: tr.text("account-row-dav", None), value: crate::vdir::Kind::Contacts.root().join(&a.id).display().to_string() });
891 rows.push(Figure { label: String::new(), value: crate::vdir::Kind::Calendars.root().join(&a.id).display().to_string() });
892 }
893 let (status, status_error) = status.get(&a.id).cloned().unwrap_or_default();
894 AccountView {
895 id: a.id.clone(),
896 kind: match a.kind {
897 AccountKind::Imap => "imap",
898 AccountKind::Jmap => "jmap",
899 AccountKind::Portal => "portal",
900 AccountKind::Dav => "dav",
901 },
902 address: a.address.clone(),
903 url: a.url.clone(),
904 rows,
905 status,
906 status_error,
907 priority: a.priority.as_str(),
908 google: a.auth.as_deref() == Some("google"),
909 microsoft: a.is_microsoft(),
910 microsoft_personal: a.is_microsoft() && !a.is_microsoft_work(),
911 at_microsoft: a.kind == AccountKind::Imap && a.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(crate::config::MICROSOFT_IMAP)),
912 host: a.host.clone(),
913 login: a.login().map(str::to_string),
914 password_wanted: wanted.contains(&a.id) && a.auth.is_none() && !a.host.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(crate::config::MICROSOFT_IMAP)),
916 name: a.name.clone().unwrap_or_default(),
917 signature: a.signature.clone().unwrap_or_default(),
918 enabled: a.enabled,
919 identity: a.address.as_deref().map(str::to_lowercase).unwrap_or_else(|| a.id.clone()),
920 service: if a.auth.as_deref() == Some("google") && a.kind == AccountKind::Dav {
921 "google"
922 } else if a.kind == AccountKind::Dav {
923 "dav"
924 } else {
925 "mail"
926 },
927 }
928 })
929 .collect()
930}
931
932#[derive(Debug, Serialize)]
933pub struct MessageView {
934 pub uri: String,
936 pub from_name: String,
937 pub from_address: String,
938 pub to: Vec<String>,
939 pub cc: Vec<String>,
940 pub date: String,
942 pub subject: String,
943 pub attachments: Vec<AttachmentView>,
944 pub can_open: bool,
946 pub parts: Vec<PartView>,
948 pub html: Option<Html>,
950 pub checks: String,
952 pub block_address: Option<String>,
954 pub block_domain: Option<String>,
955 pub sender_judgeable: bool,
958 pub others: bool,
960 pub unread: bool,
961 pub flagged: bool,
962 pub role: Option<Role>,
964 pub protection: Option<ProtectionView>,
966 pub unsubscribe: Option<crate::unsubscribe::UnsubscribeView>,
968}
969
970#[derive(Debug, Serialize)]
972pub struct PartView {
973 pub kind: &'static str,
975 pub depth: u8,
976 pub text: String,
977 pub rich: String,
979 pub fields: Vec<String>,
981}
982
983impl From<Part> for PartView {
984 fn from(part: Part) -> PartView {
985 let (kind, depth, text, fields) = match part {
986 Part::Text { text } => ("text", 0, text, Vec::new()),
987 Part::Attribution { text } => ("attribution", 0, text, Vec::new()),
988 Part::Quote { depth, text } => ("quote", depth, text, Vec::new()),
989 Part::Signature { text } => ("signature", 0, text, Vec::new()),
990 Part::Headers { fields } => ("headers", 0, String::new(), fields.into_iter().flat_map(|(n, v)| [n, v]).collect()),
991 };
992 PartView { kind, depth, rich: reading::linkify(&text), text, fields }
993 }
994}
995
996#[derive(Debug, Serialize)]
997pub struct AttachmentView {
998 pub index: u32,
999 pub name: String,
1000 pub size: String,
1002 pub kind: &'static str,
1004}
1005
1006pub fn message(path: &std::path::Path, triaged: &Triaged, own: &[String], tr: &Translator) -> Option<MessageView> {
1011 message_from(&std::fs::read(path).ok()?, path, triaged, own, tr, None)
1012}
1013
1014pub fn message_from(raw: &[u8], path: &std::path::Path, triaged: &Triaged, own: &[String], tr: &Translator, pgp: Option<crate::pgp::PgpView>) -> Option<MessageView> {
1017 let mut reading = reading::read_bytes(raw)?;
1018 reading.attachments.retain(|a| !matches!(a.mime.to_ascii_lowercase().as_str(), "application/pgp-signature" | "application/pgp-encrypted" | "application/pgp-keys"));
1020 let mut people: Vec<String> = reading
1021 .to
1022 .iter()
1023 .chain(&reading.cc)
1024 .filter_map(|p| p.address.as_ref().map(|a| a.to_ascii_lowercase()))
1025 .chain(triaged.card.from_address.clone())
1026 .filter(|a| !own.iter().any(|o| o.eq_ignore_ascii_case(a)))
1027 .collect();
1028 people.sort();
1029 people.dedup();
1030 let flags = maildir::flags_of(path);
1031 let unread = !flags.contains('S');
1032 let flagged = flags.contains('F');
1033 let person = |p: &Person| match (&p.name, &p.address) {
1034 (Some(name), Some(address)) => format!("{name} <{address}>"),
1035 (Some(name), None) => name.clone(),
1036 (None, Some(address)) => address.clone(),
1037 (None, None) => String::new(),
1038 };
1039 let address = triaged.card.from_address.clone();
1040 Some(MessageView {
1041 uri: triaged.card.message_id.as_deref().map(crate::links::mail_uri).unwrap_or_default(),
1042 from_name: triaged.card.sender().to_string(),
1043 from_address: address.clone().unwrap_or_default(),
1044 to: reading.to.iter().map(person).collect(),
1045 cc: reading.cc.iter().map(person).collect(),
1046 date: triaged
1047 .card
1048 .date
1049 .and_then(|s| Timestamp::from_second(s).ok())
1050 .map_or_else(|| tr.text("no-date", None), |t| tr.date(&t.to_zoned(TimeZone::system()), false)),
1051 subject: reading.subject.clone(),
1052 attachments: reading
1053 .attachments
1054 .iter()
1055 .map(|a| AttachmentView { index: a.index, name: a.name.clone(), size: size(tr, a.size), kind: attachment_kind(&a.mime) })
1056 .collect(),
1057 can_open: triaged.lane != Lane::SetAside,
1058 parts: reading.parts.into_iter().map(PartView::from).collect(),
1059 html: reading.html,
1060 checks: checks(triaged.checks.as_ref(), tr),
1061 block_domain: triaged.card.sender_domain().map(|d| format!("@{}", d.to_ascii_lowercase())),
1062 block_address: address,
1063 sender_judgeable: triaged.trust != Trust::Forged && !triaged.reasons.iter().any(|r| matches!(r, Reason::Impersonation { .. } | Reason::NotAuthenticated)),
1065 others: people.len() > 1,
1066 unread,
1067 flagged,
1068 role: None,
1069 protection: pgp.map(|p| protection_line(&p, tr)),
1070 unsubscribe: None,
1071 })
1072}
1073
1074#[derive(Debug, Serialize)]
1076pub struct ProtectionView {
1077 pub encrypted: bool,
1078 pub line: String,
1080 pub fine: bool,
1082 pub security_key: bool,
1084}
1085
1086fn protection_line(p: &crate::pgp::PgpView, tr: &Translator) -> ProtectionView {
1087 let mut parts = Vec::new();
1088 let waits = p.encrypted && !p.opened && p.security_key.is_some();
1089 if p.encrypted {
1090 parts.push(tr.text(
1091 if p.opened {
1092 "pgp-encrypted"
1093 } else if waits && p.key_elsewhere {
1094 "pgp-for-security-key-elsewhere"
1096 } else if waits {
1097 "pgp-for-security-key"
1098 } else {
1099 "pgp-not-opened"
1100 },
1101 None,
1102 ));
1103 }
1104 let mut fine = !p.encrypted || p.opened || waits;
1106 for signature in &p.signatures {
1107 let mut args = i18n::args();
1108 if signature.good {
1109 args.set("signer", signature.signer.clone());
1110 parts.push(tr.text("pgp-signed-by", Some(&args)));
1111 } else if signature.problem == "unknown key" {
1112 parts.push(tr.text("pgp-signed-unknown", None));
1113 } else if signature.problem == "revoked key" {
1114 fine = false;
1115 parts.push(tr.text("pgp-signed-revoked", None));
1116 } else {
1117 fine = false;
1118 parts.push(tr.text("pgp-signature-bad", None));
1119 }
1120 }
1121 ProtectionView { encrypted: p.encrypted, line: parts.join(" · "), fine, security_key: waits && !p.key_elsewhere }
1122}
1123
1124#[derive(Debug, Serialize)]
1126pub struct MailAccountView {
1127 pub id: String,
1128 pub title: String,
1129 pub unread: bool,
1131 pub resting: bool,
1133 pub folders: Vec<FolderEntry>,
1135 pub more: Vec<FolderEntry>,
1137}
1138
1139#[derive(Debug, Serialize)]
1140pub struct FolderEntry {
1141 pub name: String,
1143 pub title: String,
1144 pub role: Role,
1145 pub icon: &'static str,
1146 pub unread: bool,
1147 pub kept: bool,
1149}
1150
1151pub fn mail_accounts(accounts: &[(Account, Vec<Folder>)], tr: &Translator, hours: Option<(crate::areas::Time, crate::areas::Week)>) -> Vec<MailAccountView> {
1157 let mut ranked: Vec<&(Account, Vec<Folder>)> = accounts.iter().collect();
1158 ranked.sort_by_key(|(account, _)| account.priority);
1159 ranked
1160 .into_iter()
1161 .map(|(account, folders)| {
1162 let root = account.maildir_path();
1163 let area = account.area.as_deref().and_then(crate::areas::Area::parse).unwrap_or(crate::areas::Area::WORK);
1165 let resting = hours.is_some_and(|(time, week)| time == crate::areas::Time::Sleep || !crate::areas::in_view(area, time, week));
1166 let has_archive = folders.iter().any(|f| f.role == Role::Archive);
1168 let named = |f: &Folder| f.role != Role::Other && folders.iter().filter(|o| o.role == f.role).count() == 1;
1170 let kept = |f: &Folder| f.role == Role::Inbox || !account.skip_folders.contains(&f.name);
1171 let entry = |f: &Folder| FolderEntry {
1172 name: f.name.clone(),
1173 title: if f.role == Role::All && !has_archive {
1174 tr.text(Role::Archive.message_id(), None)
1175 } else if named(f) {
1176 tr.text(f.role.message_id(), None)
1177 } else {
1178 folder_title(&f.display)
1179 },
1180 role: f.role,
1181 icon: f.role.icon(),
1182 unread: !resting && kept(f) && matches!(f.role, Role::Inbox | Role::Other) && maildir::has_unread(&root.join(&f.local)),
1184 kept: kept(f),
1185 };
1186 let listed: Vec<&Folder> = folders.iter().filter(|f| f.role != Role::All || !has_archive).collect();
1187 let folders: Vec<FolderEntry> = listed.iter().filter(|f| f.role != Role::Other).map(|f| entry(f)).collect();
1188 let more: Vec<FolderEntry> = listed.iter().filter(|f| f.role == Role::Other).map(|f| entry(f)).collect();
1189 MailAccountView {
1190 id: account.id.clone(),
1191 title: account.address.clone().unwrap_or_else(|| account.id.clone()),
1192 unread: folders.iter().any(|f| f.role == Role::Inbox && f.unread),
1193 resting,
1194 folders,
1195 more,
1196 }
1197 })
1198 .collect()
1199}
1200
1201fn folder_title(display: &str) -> String {
1203 ["[Gmail]/", "[Google Mail]/"].iter().fold(display.to_string(), |name, prefix| name.strip_prefix(prefix).map_or(name.clone(), str::to_string))
1204}
1205
1206pub const RECENT_DAYS: i64 = 14;
1208
1209#[derive(Debug, Serialize)]
1211pub struct FolderView {
1212 pub account: String,
1213 pub folder: String,
1214 pub title: String,
1215 pub role: Role,
1216 pub sentence: String,
1218 pub items: Vec<MailItem>,
1219 pub earlier: bool,
1221}
1222
1223#[derive(Debug, Clone, Serialize)]
1224pub struct MailItem {
1225 pub key: String,
1227 pub who: String,
1229 pub address: String,
1230 pub subject: String,
1231 pub date: String,
1232 pub unread: bool,
1233 pub flagged: bool,
1234 pub answered: bool,
1235 pub attachments: bool,
1236 pub trust_level: &'static str,
1238 pub trust: String,
1240 pub checks: String,
1241 pub thread: String,
1243 pub size: usize,
1245 pub member: bool,
1247}
1248
1249fn conversations(cards: &[Card], sent: &[Card], shown: &dyn Fn(&Card) -> bool, item: &dyn Fn(&Card, bool) -> MailItem) -> Vec<MailItem> {
1254 let all: Vec<&Card> = cards.iter().chain(sent.iter()).collect();
1255 let groups = crate::threads::group(&all);
1256 let own = |i: usize| i >= cards.len();
1257 let mut by_group: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1258 for (i, g) in groups.iter().enumerate() {
1259 by_group.entry(*g).or_default().push(i);
1260 }
1261 let mut ordered: Vec<(i64, usize, Vec<usize>)> = by_group
1263 .into_iter()
1264 .filter_map(|(_, members)| {
1265 let head = *members.iter().find(|&&i| !own(i))?;
1267 shown(all[head]).then(|| (members.iter().map(|&i| all[i].date.unwrap_or(0)).max().unwrap_or(0), head, members))
1268 })
1269 .collect();
1270 ordered.sort_by_key(|(last, head, _)| (std::cmp::Reverse(*last), *head));
1271 let mut out = Vec::new();
1272 for (_, head, mut members) in ordered {
1273 let thread = all[head].path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
1274 let mut first = item(all[head], false);
1275 first.thread = thread.clone();
1276 first.size = members.len();
1277 first.unread = members.iter().filter(|&&i| !own(i)).any(|&i| item(all[i], false).unread);
1278 out.push(first);
1279 members.retain(|&i| i != head);
1280 members.sort_by_key(|&i| all[i].date.unwrap_or(0));
1281 for i in members {
1282 let mut row = item(all[i], own(i));
1283 row.thread = thread.clone();
1284 row.member = true;
1285 out.push(row);
1286 }
1287 }
1288 out
1289}
1290
1291#[allow(clippy::too_many_arguments)]
1298pub fn mail_item(card: &Card, role: Role, own: bool, flags: &str, trusted_ids: &[String], words: &crate::words::Words, tr: &Translator) -> MailItem {
1299 let outgoing = matches!(role, Role::Sent | Role::Drafts);
1300 let (trust_level, trust, checks) = if outgoing || own {
1301 ("own", String::new(), String::new())
1302 } else {
1303 let results = trust::read_auth_results(&card.headers, trusted_ids);
1304 let failed = results.as_ref().is_some_and(|r| r.dmarc == Some(trust::Outcome::Fail));
1307 let code = card.is_list && failed && crate::codes::detect_message(&words.codes, card).is_some();
1308 let judged = trust::judge_sender(results.as_ref(), card.is_list && !code, card.sender_domain()).0;
1309 let level = match judged {
1310 Trust::Verified => "verified",
1311 Trust::Unverified => "unverified",
1312 Trust::Forged => "forged",
1313 };
1314 (level, tr.trust(judged), self::checks(results.as_ref(), tr))
1315 };
1316 let who = if outgoing || own {
1317 let mut args = i18n::args();
1318 args.set("names", if card.to.is_empty() { tr.text("mail-nobody", None) } else { card.to.join(", ") });
1319 tr.text(if own { "mail-you-to" } else { "mail-to-whom" }, Some(&args))
1320 } else {
1321 card.sender().to_string()
1322 };
1323 MailItem {
1324 key: card.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(),
1325 who,
1326 address: card.from_address.clone().unwrap_or_default(),
1327 subject: if card.subject.trim().is_empty() { tr.text("mail-no-subject", None) } else { card.subject.clone() },
1328 date: date(tr, card.date),
1329 unread: !own && matches!(role, Role::Inbox | Role::Other | Role::Archive) && !flags.contains('S'),
1331 flagged: flags.contains('F'),
1332 answered: flags.contains('R'),
1333 attachments: !card.attachments.is_empty(),
1334 trust_level,
1335 trust,
1336 checks,
1337 thread: String::new(),
1338 size: 1,
1339 member: false,
1340 }
1341}
1342
1343#[allow(clippy::too_many_arguments)]
1347pub fn folder(account: &Account, folder: &Folder, cards: Vec<Card>, trusted_ids: &[String], words: &crate::words::Words, all: bool, query: &str, tr: &Translator, now: i64, sent: Option<Vec<Card>>) -> FolderView {
1348 let all = all || account.history_days().is_none();
1350 let since = now - account.history_days().unwrap_or(RECENT_DAYS) * 86_400;
1351 let query = query.trim().to_lowercase();
1352 let found = |c: &Card| {
1353 query.is_empty()
1354 || [c.sender(), c.subject.as_str(), c.from_address.as_deref().unwrap_or("")]
1355 .into_iter()
1356 .chain(c.to.iter().map(String::as_str))
1357 .any(|t| t.to_lowercase().contains(&query))
1358 };
1359 let mut cards: Vec<Card> = cards.into_iter().filter(found).collect();
1360 cards.sort_by_key(|c| std::cmp::Reverse(c.date.unwrap_or(0)));
1361 let recent = |c: &Card| c.date.unwrap_or(0) >= since;
1362 let older = cards.iter().filter(|c| !recent(c)).count();
1363 let shown = |c: &Card| all || !query.is_empty() || recent(c);
1364 let item = |card: &Card, own: bool| mail_item(card, folder.role, own, &maildir::flags_of(card.path.as_deref().unwrap_or(std::path::Path::new(""))), trusted_ids, words, tr);
1365 let items: Vec<MailItem> = match sent {
1366 None => cards.iter().filter(|c| shown(c)).map(|c| item(c, false)).collect(),
1367 Some(sent) => conversations(&cards, &sent, &shown, &item),
1368 };
1369 let unread = items.iter().filter(|i| i.unread).count();
1370 let sentence = if items.is_empty() && !query.is_empty() {
1371 let mut args = i18n::args();
1372 args.set("query", query.clone());
1373 tr.text("mail-not-found", Some(&args))
1374 } else if items.is_empty() && older > 0 {
1375 tr.text("mail-nothing-recent", None)
1376 } else if items.is_empty() {
1377 tr.text("mail-empty", None)
1378 } else if unread > 0 {
1379 tr.text("mail-unread", Some(&tr.counted(unread)))
1380 } else {
1381 String::new()
1382 };
1383 FolderView {
1384 account: account.id.clone(),
1385 folder: folder.name.clone(),
1386 title: match folder.role {
1387 Role::Other => folder_title(&folder.display),
1388 Role::All => tr.text(Role::Archive.message_id(), None),
1389 role => tr.text(role.message_id(), None),
1390 },
1391 role: folder.role,
1392 sentence,
1393 items,
1394 earlier: !all && query.is_empty() && older > 0,
1395 }
1396}
1397
1398fn attachment_kind(mime: &str) -> &'static str {
1399 let mime = mime.to_ascii_lowercase();
1400 match mime.as_str() {
1401 "application/pdf" => "pdf",
1402 "text/calendar" | "application/ics" => "calendar",
1403 m if m.starts_with("image/") => "image",
1404 m if m.starts_with("text/") => "text",
1405 m if m.contains("zip") || m.contains("compressed") || m.contains("x-tar") || m.contains("x-7z") => "archive",
1406 m if m.contains("word") || m.contains("officedocument") || m.contains("opendocument") || m.contains("excel") || m.contains("powerpoint") || m.contains("rtf") => "document",
1407 _ => "other",
1408 }
1409}
1410
1411pub fn size(tr: &Translator, bytes: usize) -> String {
1413 let mut args = i18n::args();
1414 let (id, value) = match bytes {
1415 b if b < 1024 => ("size-b", b.to_string()),
1416 b if b < 1024 * 1024 => ("size-kb", (b / 1024).to_string()),
1417 b => ("size-mb", tr.decimal(b as f32 / (1024.0 * 1024.0))),
1418 };
1419 args.set("n", value);
1420 tr.text(id, Some(&args))
1421}
1422
1423pub const AGENDA_DAYS: i64 = 15;
1425
1426#[derive(Debug, Serialize)]
1428pub struct AgendaView {
1429 pub days: Vec<AgendaDay>,
1430 pub sentence: String,
1432 pub from: String,
1434 pub can_add: bool,
1436}
1437
1438#[derive(Debug, Serialize)]
1439pub struct AgendaDay {
1440 pub title: String,
1442 pub date: String,
1444 pub today: bool,
1445 pub start: i64,
1447 pub events: Vec<AgendaEvent>,
1448 pub hours_from: i32,
1450 pub hours_to: i32,
1451}
1452
1453#[derive(Debug, Serialize)]
1454pub struct AgendaEvent {
1455 pub key: String,
1456 pub uid: String,
1457 pub start: i64,
1459 pub end: i64,
1461 pub when: String,
1463 pub summary: String,
1464 pub location: String,
1465 pub notes: String,
1466 pub calendar: String,
1467 pub color: Option<String>,
1468 pub recurring: bool,
1469 pub cancelled: bool,
1470 pub tentative: bool,
1471 pub read_only: bool,
1472 pub organizer: String,
1473 pub attendees: Vec<crate::agenda::Attendee>,
1474 pub all_day: bool,
1475 pub from_minute: i32,
1477 pub to_minute: i32,
1478 pub column: u32,
1480 pub columns: u32,
1481 pub task: String,
1484 pub list: String,
1486 pub before: u32,
1490 pub after: u32,
1491}
1492
1493fn lay_out(events: &mut [AgendaEvent]) {
1497 let timed: Vec<usize> = (0..events.len()).filter(|&i| !events[i].all_day).collect();
1498 let spans: Vec<(i64, i64)> = timed.iter().map(|&i| (i64::from(events[i].from_minute), i64::from(events[i].to_minute.max(events[i].from_minute + 15)))).collect();
1499 for (&i, (column, columns)) in timed.iter().zip(crate::dayview::side_by_side(&spans)) {
1500 events[i].column = column;
1501 events[i].columns = columns;
1502 }
1503}
1504
1505pub fn set_hours(view: &mut AgendaView, windows: &[crate::window::AdminWindow], zone: &TimeZone) {
1509 for day in &mut view.days {
1510 let Ok(date) = day.date.parse::<jiff::civil::Date>() else { continue };
1511 if let Some((opening, closing)) = crate::window::hours_on(windows, date, zone) {
1512 let minute = |z: &jiff::Zoned| i32::try_from((z.timestamp().as_second() - day.start) / 60).unwrap_or(0).clamp(0, 1440);
1513 day.hours_from = minute(&opening);
1514 day.hours_to = minute(&closing);
1515 }
1516 }
1517}
1518
1519pub fn agenda(occurrences: &[crate::agenda::Occurrence], from: jiff::civil::Date, days: i64, tr: &Translator, zone: &TimeZone) -> AgendaView {
1522 let today = Timestamp::now().to_zoned(zone.clone()).date();
1523 let mut out = Vec::new();
1524 for offset in 0..days {
1525 let Ok(date) = from.checked_add(jiff::Span::new().days(offset)) else { break };
1526 let (Ok(start), Ok(end)) = (date.to_zoned(zone.clone()), date.tomorrow().and_then(|d| d.to_zoned(zone.clone()))) else { continue };
1527 let (start, end) = (start.timestamp().as_second(), end.timestamp().as_second());
1528 let mut events: Vec<AgendaEvent> = occurrences
1529 .iter()
1530 .filter(|o| o.start < end && o.end > start && !(o.end == start && o.start < start))
1531 .map(|o| AgendaEvent {
1532 key: o.key.clone(),
1533 uid: o.uid.clone(),
1534 start: o.start,
1535 end: o.end,
1536 when: when(o, start, end, tr, zone),
1537 summary: if o.summary.is_empty() { tr.text("agenda-untitled", None) } else { o.summary.clone() },
1538 location: o.location.clone(),
1539 notes: o.notes.clone(),
1540 calendar: o.calendar.clone(),
1541 color: o.color.clone(),
1542 recurring: o.recurring,
1543 cancelled: o.cancelled,
1544 tentative: o.tentative,
1545 read_only: o.read_only,
1546 organizer: o.organizer.clone(),
1547 attendees: o.attendees.clone(),
1548 all_day: o.all_day || (o.start <= start && o.end >= end),
1549 from_minute: i32::try_from((o.start.max(start) - start) / 60).unwrap_or(0),
1550 to_minute: i32::try_from((o.end.min(end) - start) / 60).unwrap_or(1440),
1551 column: 0,
1552 columns: 1,
1553 task: o.task.clone(),
1554 list: o.list.clone(),
1555 before: o.margins.before,
1556 after: o.margins.after,
1557 })
1558 .collect();
1559 lay_out(&mut events);
1560 let title = if date == today {
1561 tr.text("agenda-today", None)
1562 } else if Some(date) == today.tomorrow().ok() {
1563 tr.text("agenda-tomorrow", None)
1564 } else {
1565 tr.day(date)
1566 };
1567 out.push(AgendaDay { title, date: date.to_string(), today: date == today, start, events, hours_from: -1, hours_to: -1 });
1568 }
1569 let empty = out.iter().all(|d| d.events.is_empty());
1570 let can_add = crate::agenda::default_calendar().is_some();
1571 let sentence = match (empty, can_add || !crate::vdir::collections(crate::vdir::Kind::Calendars).is_empty()) {
1572 (true, false) => tr.text("agenda-no-calendar", None),
1573 (true, true) => tr.text("agenda-nothing", None),
1574 _ => String::new(),
1575 };
1576 AgendaView { sentence, days: out, from: from.to_string(), can_add }
1577}
1578
1579fn when(o: &crate::agenda::Occurrence, day_start: i64, day_end: i64, tr: &Translator, zone: &TimeZone) -> String {
1581 let hour = |t: i64| Timestamp::from_second(t).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default();
1582 let starts_today = o.start >= day_start;
1583 let ends_today = o.end <= day_end;
1584 if o.all_day || (!starts_today && !ends_today) {
1585 return tr.text("agenda-all-day", None);
1586 }
1587 let mut args = i18n::args();
1588 match (starts_today, ends_today) {
1589 (true, true) if o.end == o.start => hour(o.start),
1590 (true, true) => format!("{} – {}", hour(o.start), hour(o.end)),
1591 (true, false) => {
1592 args.set("time", hour(o.start));
1593 tr.text("agenda-from", Some(&args))
1594 }
1595 (false, _) => {
1596 args.set("time", hour(o.end));
1597 tr.text("agenda-until", Some(&args))
1598 }
1599 }
1600}
1601
1602#[derive(Debug, Serialize)]
1604pub struct ContactsView {
1605 pub contacts: Vec<ContactRow>,
1606 pub sentence: String,
1608 pub can_add: bool,
1610 pub categories: Vec<String>,
1613 pub category: String,
1615}
1616
1617#[derive(Debug, Serialize)]
1618pub struct ContactRow {
1619 pub key: String,
1620 pub uri: String,
1622 pub name: String,
1623 pub email: String,
1625 pub detail: String,
1627 pub photo: String,
1629 pub categories: Vec<String>,
1631}
1632
1633pub fn contacts(all: &[crate::contacts::Contact], query: &str, category: &str, tr: &Translator) -> ContactsView {
1636 let categories = crate::contacts::categories_in_use(all);
1637 let wanted = crate::contacts::category_key(category);
1638 let category = categories.iter().find(|c| !wanted.is_empty() && crate::contacts::category_key(c) == wanted).cloned().unwrap_or_default();
1639 let found: Vec<&crate::contacts::Contact> = crate::contacts::search(all, query).into_iter().filter(|c| category.is_empty() || crate::contacts::in_category(c, &category)).collect();
1640 let sentence = if all.is_empty() && crate::vdir::collections(crate::vdir::Kind::Contacts).is_empty() {
1641 tr.text("contacts-no-book", None)
1642 } else if all.is_empty() {
1643 tr.text("contacts-none", None)
1644 } else if found.is_empty() && query.trim().is_empty() {
1645 let mut args = i18n::args();
1646 args.set("category", category.clone());
1647 tr.text("contacts-none-in", Some(&args))
1648 } else if found.is_empty() {
1649 let mut args = i18n::args();
1650 args.set("query", query.trim().to_string());
1651 tr.text("mail-not-found", Some(&args))
1652 } else {
1653 String::new()
1654 };
1655 ContactsView {
1656 contacts: found
1657 .into_iter()
1658 .map(|c| ContactRow {
1659 key: c.key.clone(),
1660 uri: crate::links::contact_uri(&c.uid),
1661 name: c.name.clone(),
1662 email: c.emails.first().map(|e| e.value.clone()).unwrap_or_default(),
1663 detail: c.emails.first().map(|e| e.value.clone()).or_else(|| c.phones.first().map(|p| p.value.clone())).unwrap_or_else(|| c.org.clone()),
1664 photo: c.photo.clone(),
1665 categories: c.categories.clone(),
1666 })
1667 .collect(),
1668 sentence,
1669 can_add: crate::contacts::default_book().is_some(),
1670 categories,
1671 category,
1672 }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 use super::*;
1678
1679 #[test]
1682 fn mail_signed_in_with_google_is_mail() {
1683 let config: Config = toml::from_str(
1684 r#"
1685[[account]]
1686id = "gmail"
1687kind = "imap"
1688address = "you@gmail.com"
1689host = "imap.gmail.com"
1690auth = "google"
1691
1692[[account]]
1693id = "google"
1694kind = "dav"
1695address = "you@gmail.com"
1696host = "apidata.googleusercontent.com"
1697auth = "google"
1698"#,
1699 )
1700 .unwrap();
1701 let wanted: BTreeSet<String> = ["gmail".to_string()].into();
1702 let views = accounts(&config, &Translator::new("en"), &BTreeMap::new(), &wanted);
1703 let mail = views.iter().find(|v| v.id == "gmail").unwrap();
1704 assert_eq!((mail.service, mail.google, mail.password_wanted), ("mail", true, false));
1705 assert!(mail.rows[0].value.starts_with("imap.gmail.com"), "{}", mail.rows[0].value);
1706 let calendars = views.iter().find(|v| v.id == "google").unwrap();
1707 assert_eq!((calendars.service, calendars.rows[0].value.as_str()), ("google", "Google"));
1708 }
1709
1710 #[test]
1714 fn mail_signed_in_with_microsoft_asks_no_password() {
1715 let config: Config = toml::from_str(
1716 r#"
1717[[account]]
1718id = "home"
1719kind = "imap"
1720address = "you@outlook.com"
1721host = "outlook.office365.com"
1722auth = "microsoft"
1723microsoft_kind = "personal"
1724smtp_host = "smtp-mail.outlook.com"
1725
1726[[account]]
1727id = "work"
1728kind = "imap"
1729address = "you@contoso.example"
1730host = "outlook.office365.com"
1731auth = "microsoft"
1732microsoft_kind = "work"
1733smtp_host = "smtp.office365.com"
1734
1735[[account]]
1736id = "old"
1737kind = "imap"
1738address = "old@hotmail.com"
1739host = "outlook.office365.com"
1740"#,
1741 )
1742 .unwrap();
1743 let tr = Translator::new("en");
1744 let wanted: BTreeSet<String> = ["home", "work", "old"].iter().map(|s| s.to_string()).collect();
1745 let views = accounts(&config, &tr, &BTreeMap::new(), &wanted);
1746 let view = |id: &str| views.iter().find(|v| v.id == id).unwrap();
1747 assert_eq!((view("home").microsoft, view("home").microsoft_personal, view("home").password_wanted), (true, true, false));
1748 assert_eq!((view("work").microsoft, view("work").microsoft_personal, view("work").password_wanted), (true, false, false));
1749 assert_eq!((view("old").microsoft, view("old").at_microsoft, view("old").password_wanted), (false, true, false), "Microsoft's sign-in, not a password");
1750 let signs_in = |id: &str| view(id).rows.iter().find(|r| r.label == tr.text("account-row-sign-in", None)).map(|r| r.value.clone());
1751 assert_eq!(signs_in("home"), Some(tr.text("account-signed-in-microsoft-personal", None)));
1752 assert_eq!(signs_in("work"), Some(tr.text("account-signed-in-microsoft-work", None)));
1753 assert_eq!(signs_in("old"), None);
1754 }
1755
1756 #[test]
1760 fn an_account_says_the_name_it_logs_in_with() {
1761 let config: Config = toml::from_str(
1762 r#"
1763[[account]]
1764id = "work"
1765kind = "imap"
1766address = "noa@example.org"
1767username = "nferrand"
1768host = "imap.example.org"
1769
1770[[account]]
1771id = "home"
1772kind = "imap"
1773address = "noa@example.com"
1774host = "imap.example.com"
1775"#,
1776 )
1777 .unwrap();
1778 let views = accounts(&config, &Translator::new("en"), &BTreeMap::new(), &BTreeSet::new());
1779 let login = |id: &str| views.iter().find(|v| v.id == id).and_then(|v| v.login.clone());
1780 assert_eq!(login("work").as_deref(), Some("nferrand"));
1781 assert_eq!(login("home").as_deref(), Some("noa@example.com"));
1782 }
1783
1784 #[test]
1788 fn a_task_in_the_agenda_shows_as_a_task() {
1789 let zone = TimeZone::get("Europe/Paris").unwrap();
1790 let day: jiff::civil::Date = "2026-10-23".parse().unwrap();
1791 let start = day.at(14, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
1792 let occurrence = |key: &str, task: &str| crate::agenda::Occurrence { key: key.into(), summary: key.into(), start, end: start + 3600, calendar: "Planned tasks".into(), task: task.into(), ..Default::default() };
1793 let mut occurrences = [occurrence("block", "t1"), occurrence("orphan", "gone"), occurrence("event", "")];
1794 let tasks = [crate::tasks::Task { uid: "t1".into(), list: "Plan".into(), ..Default::default() }];
1795 crate::agenda::mark_tasks(&mut occurrences, &tasks);
1796 let shown = agenda(&occurrences, day, 1, &Translator::new("en"), &zone);
1797 let rows: Vec<(&str, &str, &str)> = shown.days[0].events.iter().map(|e| (e.key.as_str(), e.task.as_str(), e.list.as_str())).collect();
1798 assert_eq!(rows, [("block", "t1", "Plan"), ("orphan", "", ""), ("event", "", "")]);
1799 }
1800
1801 #[test]
1802 fn overlapping_events_sit_side_by_side() {
1803 let event = |from: i32, to: i32| AgendaEvent {
1804 key: String::new(),
1805 uid: String::new(),
1806 start: 0,
1807 end: 0,
1808 when: String::new(),
1809 summary: String::new(),
1810 location: String::new(),
1811 notes: String::new(),
1812 calendar: String::new(),
1813 color: None,
1814 recurring: false,
1815 cancelled: false,
1816 tentative: false,
1817 read_only: false,
1818 organizer: String::new(),
1819 attendees: Vec::new(),
1820 all_day: false,
1821 from_minute: from,
1822 to_minute: to,
1823 column: 9,
1824 columns: 9,
1825 task: String::new(),
1826 list: String::new(),
1827 before: 0,
1828 after: 0,
1829 };
1830 let mut day = vec![event(540, 600), event(570, 660), event(600, 630), event(840, 900)];
1832 lay_out(&mut day);
1833 let placed: Vec<(u32, u32)> = day.iter().map(|e| (e.column, e.columns)).collect();
1834 assert_eq!(placed, [(0, 2), (1, 2), (0, 2), (0, 1)]);
1835 }
1836
1837 #[test]
1838 fn a_folder_shows_two_weeks_and_says_what_is_unread() {
1839 let now = 1_790_000_000;
1840 let card = |subject: &str, days_ago: i64, file: &str| {
1841 let raw = format!("From: Jane <jane@example.org>\r\nTo: Me <me@example.net>\r\nSubject: {subject}\r\n\r\nHi.\r\n");
1842 let mut card = Card::from_bytes(raw.as_bytes()).unwrap();
1843 card.date = Some(now - days_ago * 86_400);
1844 card.path = Some(std::path::PathBuf::from(file));
1845 card
1846 };
1847 let cards = vec![card("Lease", 1, "new/1.U1-1.sioul"), card("Old", 40, "cur/2.U1-2.sioul:2,S"), card("Read", 2, "cur/3.U1-3.sioul:2,FS")];
1848 let account = Account::imap("me", "me@example.net", "imap.example.net", 993, crate::config::Security::Tls, None);
1849 let inbox = crate::folders::folder("INBOX", Some("."), None);
1850 let tr = Translator::new("en");
1851 let view = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), false, "", &tr, now, None);
1852 assert_eq!(view.items.iter().map(|i| i.subject.as_str()).collect::<Vec<_>>(), ["Lease", "Read"]);
1853 assert!(view.earlier && view.items[0].unread && view.items[1].flagged && !view.items[1].unread);
1854 assert_eq!(view.sentence, "One message you have not read.");
1855 let everything = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), true, "", &tr, now, None);
1856 assert_eq!(everything.items.len(), 3);
1857 assert!(!everything.earlier);
1858 let searched = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), false, "old", &tr, now, None);
1859 assert_eq!(searched.items.len(), 1);
1860 let sent = crate::folders::folder("Sent", Some("."), Some(Role::Sent));
1861 let view = folder(&account, &sent, cards, &[], crate::words::Words::builtin_ref(), false, "", &tr, now, None);
1862 assert_eq!(view.items[0].who, "To Me");
1863 assert!(view.items.iter().all(|i| !i.unread && i.trust_level == "own"));
1864 }
1865
1866 #[test]
1867 fn conversations_bring_your_answers() {
1868 let now = 1_790_000_000;
1869 let card = |raw: &str, hours_ago: i64, file: &str| {
1870 let mut card = Card::from_bytes(raw.replace('\n', "\r\n").as_bytes()).unwrap();
1871 card.date = Some(now - hours_ago * 3600);
1872 card.path = Some(std::path::PathBuf::from(file));
1873 card
1874 };
1875 let question = card("From: Jane <jane@example.org>\nTo: me@example.net\nSubject: Lease\nMessage-ID: <q@example.org>\n\nWhen?\n", 30, "new/1.U1-1.sioul");
1876 let thanks = card("From: Jane <jane@example.org>\nTo: me@example.net\nSubject: Re: Lease\nMessage-ID: <t@example.org>\nReferences: <q@example.org> <a@example.net>\n\nThanks.\n", 2, "new/2.U1-2.sioul");
1877 let other = card("From: Paul <paul@example.com>\nTo: me@example.net\nSubject: Lunch\nMessage-ID: <l@example.com>\n\nNoon?\n", 5, "cur/3.U1-3.sioul:2,S");
1878 let answer = card("From: Me <me@example.net>\nTo: Jane <jane@example.org>\nSubject: Re: Lease\nMessage-ID: <a@example.net>\nIn-Reply-To: <q@example.org>\n\nMonday.\n", 20, "Sent/cur/4.U1-4.sioul:2,S");
1879 let unrelated = card("From: Me <me@example.net>\nTo: Bob <bob@example.com>\nSubject: Hi\nMessage-ID: <h@example.net>\n\nHi.\n", 1, "Sent/cur/5.U1-5.sioul:2,S");
1880 let account = Account::imap("me", "me@example.net", "imap.example.net", 993, crate::config::Security::Tls, None);
1881 let inbox = crate::folders::folder("INBOX", Some("."), None);
1882 let tr = Translator::new("en");
1883 let view = folder(&account, &inbox, vec![question, thanks, other], &[], crate::words::Words::builtin_ref(), false, "", &tr, now, Some(vec![answer, unrelated]));
1884 let rows: Vec<(&str, usize, bool)> = view.items.iter().map(|i| (i.subject.as_str(), i.size, i.member)).collect();
1885 assert_eq!(rows, [("Re: Lease", 3, false), ("Lease", 1, true), ("Re: Lease", 1, true), ("Lunch", 1, false)]);
1887 assert_eq!(view.items[2].who, "You, to Jane");
1888 assert_eq!(view.items[2].trust_level, "own");
1889 assert!(view.items[0].unread, "a conversation is unread while one of its messages is");
1890 assert!(view.items.iter().all(|i| !i.subject.starts_with("Hi")), "your other mail stays in Sent");
1891 }
1892
1893 #[test]
1894 fn the_shield_says_each_check() {
1895 let results = AuthResults {
1896 authserv_id: "sioul-0123.invalid".into(),
1897 dkim: Some(Outcome::Pass),
1898 dkim_domain: Some("shop.example".into()),
1899 spf: Some(Outcome::SoftFail),
1900 spf_domain: Some("mail.shop.example".into()),
1901 dmarc: Some(Outcome::Pass),
1902 dmarc_domain: Some("shop.example".into()),
1903 dmarc_policy: Some("reject".into()),
1904 iprev: Some(Outcome::Pass),
1905 ..AuthResults::default()
1906 };
1907 assert_eq!(
1908 checks(Some(&results), &Translator::new("en")),
1909 "Checked by Sioul when the message arrived:\nDKIM: valid (shop.example)\nSPF: doubtful (mail.shop.example)\n\
1910 DMARC: valid (shop.example, policy reject)\nReverse DNS: valid"
1911 );
1912 let provider = AuthResults { authserv_id: "mx.example.net".into(), dkim: Some(Outcome::Fail), ..AuthResults::default() };
1913 assert_eq!(checks(Some(&provider), &Translator::new("fr")), "Vérifié par votre fournisseur (mx.example.net)\u{202f}:\nDKIM\u{202f}: échoue");
1914 assert_eq!(checks(None, &Translator::new("en")), "No check recorded: nothing proves who sent it.");
1915 }
1916
1917 #[test]
1921 fn the_filters_word_and_not_spam() {
1922 let tr = Translator::new("en");
1923 let card = Card::from_bytes(b"From: Desk <desk@quick-parcels.test>\r\nSubject: Your parcel\r\n\r\nPay 1.99 EUR.\r\n").unwrap();
1924 let triaged = |lane: Lane, reasons: Vec<Reason>| Triaged { card: card.clone(), lane, trust: Trust::Unverified, code: None, reasons, priority: Priority::Average, checks: None, assessment: None };
1925 let maybe = item_view(&triaged(Lane::Review, vec![Reason::Trust(trust::Proof::NoResults), Reason::Unsure { p: 0.62 }]), None, &tr);
1927 assert!(maybe.spam && maybe.review && !maybe.moved);
1928 assert_eq!(maybe.spam_word, "maybe spam");
1929 assert_eq!(maybe.reasons.last().map(String::as_str), Some("your own filter: maybe spam"));
1930 let moved = item_view(&triaged(Lane::Review, vec![Reason::LearnedSpam { p: 0.97 }, Reason::MovedToJunk]), None, &tr);
1931 assert_eq!((moved.spam, moved.moved, moved.spam_word.as_str()), (true, true, "probably spam"));
1932 assert!(moved.reasons.iter().all(|r| !r.contains('%')), "{:?}", moved.reasons);
1933 let wanted = item_view(&triaged(Lane::Review, vec![Reason::LearnedHam { p: 0.1 }]), None, &tr);
1934 assert_eq!(wanted.spam_word, "probably not spam");
1935 let aside = item_view(&triaged(Lane::SetAside, vec![Reason::Spam { source: "rspamd", score: None }]), None, &tr);
1937 assert_eq!((aside.spam, aside.review, aside.spam_word.as_str()), (true, false, ""));
1938 let plain = item_view(&triaged(Lane::Screener, vec![Reason::FirstMessage]), None, &tr);
1940 assert_eq!((plain.spam, plain.review, plain.spam_word.as_str()), (false, false, ""));
1941 }
1942}