Skip to main content

sioul_core/
view.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What the window shows, as plain data: every sentence already translated,
5//! nothing left to decide.
6//!
7//! The Qt interface reads these as JSON and only lays them out; an agent or a
8//! script can read the very same (docs/architecture.md: the interface holds no
9//! logic).
10
11use 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
31/// Characters of the text shown under a subject.
32const PREVIEW: usize = 160;
33
34#[derive(Debug, Serialize)]
35pub struct PorchView {
36    /// Inside an admin window, without windows, or opened anyway.
37    pub open: bool,
38    /// Outside a window: when it opens, and nothing else.
39    pub closed: Option<String>,
40    /// The codes, passwords and links you asked sites for, shown whatever the
41    /// time; with a warning when their sender is not verified.
42    pub right_now: Vec<CodeView>,
43    /// A few sentences about what came: at the top of the Porch, and from `sioul porch`.
44    pub summary: String,
45    /// One short line: until when the window is open, or that it was opened anyway.
46    pub status: String,
47    /// The lanes, in the order of the Porch; empty when closed.
48    pub lanes: Vec<LaneView>,
49}
50
51#[derive(Debug, Serialize)]
52pub struct CodeView {
53    pub key: String,
54    /// "Code from La Banque Exemple (verified)".
55    pub title: String,
56    /// The code itself, when the message prints one.
57    pub code: Option<String>,
58    /// "valid about 10 minutes".
59    pub validity: Option<String>,
60    /// For a sender that is not verified: use it only if you just asked for it.
61    pub warning: Option<String>,
62}
63
64#[derive(Debug, Serialize)]
65pub struct LaneView {
66    pub key: String,
67    pub title: String,
68    /// Filed and set-aside mail start folded (docs/porch.md).
69    pub folded: bool,
70    /// Its count said beside its title; never for the review queue.
71    pub counted: bool,
72    /// What the lane holds, in one short line, shown under its title.
73    pub about: String,
74    /// How mail lands here, sentence by sentence, behind the lane's "?".
75    pub rules: Vec<String>,
76    /// The settings that change it, by page name (`settings::for_view`).
77    pub settings: String,
78    pub items: Vec<ItemView>,
79}
80
81#[derive(Debug, Serialize)]
82pub struct ItemView {
83    /// The file, to open the message.
84    pub key: String,
85    pub account: Option<String>,
86    pub sender: String,
87    pub address: String,
88    /// "verified", "not verified", "forged", in your language.
89    pub trust: String,
90    /// The same, as a fixed word for the interface's colours.
91    pub trust_level: &'static str,
92    pub subject: String,
93    pub date: String,
94    pub preview: String,
95    /// Every reason, in order; the last one chose the lane.
96    pub reasons: Vec<String>,
97    pub attachments: Vec<String>,
98    /// From someone new: the sender can be let in.
99    pub screener: bool,
100    /// Called spam: set aside as spam, or in the review queue. "Not spam"
101    /// puts it back in its lane (from a Junk folder, into the inbox), for good.
102    pub spam: bool,
103    /// In the review queue: "Spam" and "Not spam" beside it.
104    pub review: bool,
105    /// Your own filter moved it into a Junk folder: "Not spam" brings it back to the inbox.
106    pub moved: bool,
107    /// Your own filter's quiet word beside it in the review queue: "probably
108    /// spam", "maybe spam", "probably not spam"; "" otherwise. Never why.
109    pub spam_word: String,
110    /// From an account ranked above the others.
111    pub important: bool,
112    /// What each check said, one per line: the shield's tooltip.
113    pub checks: String,
114    /// For a shielded address: what it is about ("work"), its tone ("calm", "rude", "hostile").
115    pub topic: String,
116    pub tone: String,
117    /// Its words are not shown (hostile): no sender's name, subject or text until you ask.
118    pub hidden: bool,
119    /// What it asks, in one neutral line, when an AI read it.
120    pub summary: String,
121}
122
123/// The Porch as the window shows it. `opened_anyway` is your "open it anyway"
124/// outside a window; codes show either way.
125pub fn porch(items: &[Triaged], config: &Config, store: Option<&ProjectStore>, tr: &Translator, now: &Zoned, opened_anyway: bool) -> PorchView {
126    let right_now = codes(items, tr);
127    let open = opened_anyway || config.windows.is_empty() || window::current(&config.windows, now).is_some();
128    if !open {
129        let mut args = i18n::args();
130        let next = window::next_opening(&config.windows, now);
131        args.set("when", next.map_or_else(String::new, |z| tr.when(&z)));
132        let closed = Some(tr.text("porch-closed", Some(&args)));
133        return PorchView { open, closed, right_now, summary: String::new(), status: String::new(), lanes: Vec::new() };
134    }
135    let status = match window::current(&config.windows, now) {
136        Some((_, closing)) => {
137            let mut args = i18n::args();
138            args.set("time", closing.strftime("%H:%M").to_string());
139            tr.text("porch-open-until", Some(&args))
140        }
141        None if !config.windows.is_empty() => tr.text("porch-opened-anyway", None),
142        None => String::new(),
143    };
144    let order = tr.text("rule-order", None);
145    let hidden = |key: &str| key.strip_prefix("project:").is_some_and(|id| config.porch.hidden_projects.iter().any(|h| h == id));
146    let lanes = lanes(config, store, tr)
147        .into_iter()
148        .filter(|l| !hidden(&l.key))
149        .map(|LaneInfo { key, title, lane, folded, about, mut rules, counted }| {
150            // Mail of the accounts ranked above first, then work for a public address, then the newest.
151            let mut ranked: Vec<&Triaged> = items.iter().filter(|t| t.lane == lane).collect();
152            ranked.sort_by_key(|t| (t.priority, t.assessment.as_ref().map(|a| a.topic), std::cmp::Reverse(t.card.date.unwrap_or(0))));
153            // A folded lane opens by itself when it holds mail of an account ranked above; hostile mail never does.
154            let folded = folded && (lane == Lane::Hostile || !ranked.iter().any(|t| t.priority == Priority::Above));
155            rules.push(order.clone());
156            let settings = format!("lane:{key}");
157            LaneView { key, title, folded, counted, about, rules, settings, items: ranked.into_iter().map(|t| item_view(t, store, tr)).collect() }
158        })
159        .filter(|l| !l.items.is_empty())
160        .collect();
161    PorchView { open, closed: None, right_now, summary: tr.summary(&porch::summarise(items), store), status, lanes }
162}
163
164/// A lane of the Porch, empty or not: what it holds and how mail lands there.
165pub struct LaneInfo {
166    /// `people`, `project:<id>`, `public:<account>`.
167    pub key: String,
168    pub title: String,
169    pub lane: Lane,
170    /// Filed, less important and set-aside mail start folded (docs/porch.md).
171    pub folded: bool,
172    /// What it holds, in one short line.
173    pub about: String,
174    /// How mail lands there, sentence by sentence.
175    pub rules: Vec<String>,
176    /// Its count said beside its title: every lane's but the review queue's.
177    pub counted: bool,
178}
179
180/// Every lane mail can land in, in the order the Porch shows them: projects,
181/// public addresses, people, the screener, filed and less important mail,
182/// what waits for your review, what is set aside, hostile mail when an
183/// address is shielded.
184pub fn lanes(config: &Config, store: Option<&ProjectStore>, tr: &Translator) -> Vec<LaneInfo> {
185    let say = |id: &str, pairs: &[(&str, String)]| {
186        let mut args = i18n::args();
187        for (name, value) in pairs {
188            args.set(name.to_string(), value.clone());
189        }
190        tr.text(id, Some(&args))
191    };
192    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 };
193    let address_of = |id: &str| config.account(id).and_then(|a| a.address.clone()).unwrap_or_else(|| id.to_string());
194    let mut lanes: Vec<LaneInfo> = store
195        .map_or(&[][..], |s| &s.projects[..])
196        .iter()
197        .map(|c| {
198            let mut rules = project_rules(c, tr);
199            rules.push(tr.text("rule-where-routes", None));
200            lane(&format!("project:{}", c.id), c.title.clone(), Lane::Project(c.id.clone()), false, tr.text("lane-about-project", None), rules)
201        })
202        .collect();
203    for account in config.accounts.iter().filter(|a| a.shield) {
204        let address = address_of(&account.id);
205        let minutes = account.fetch_minutes.or(config.fetch_minutes).unwrap_or(crate::config::DEFAULT_FETCH_MINUTES);
206        let mut rules = vec![say("rule-public", &[("minutes", minutes.to_string())])];
207        if account.shield_ai {
208            rules.push(tr.text("rule-public-ai", None));
209        }
210        rules.push(tr.text("rule-where-shield", None));
211        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));
212    }
213    let known = crate::porch::SenderList::load(&config.known_senders_path()).entries().len();
214    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())])]));
215    lanes.push(lane("screener", tr.text("lane-screener", None), Lane::Screener, false, tr.text("lane-about-screener", None), vec![tr.text("rule-screener", None)]));
216    let words = crate::words::Words::of(config).senders.automatic.clone();
217    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(", "))])]));
218    let below: Vec<String> = config.accounts.iter().filter(|a| a.priority == Priority::Below).map(|a| address_of(&a.id)).collect();
219    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)] }));
220    // What your own spam filter flagged or moved, as you chose for each of
221    // its verdicts: folded, never counted, never notified.
222    lanes.push(lane("review", tr.text("lane-review", None), Lane::Review, true, tr.text("lane-about-review", None), {
223        let (spam, unsure) = config.spam.thresholds();
224        let actions = config.spam.actions();
225        let mut rules: Vec<String> = crate::spam::Class::ALL
226            .iter()
227            .map(|class| {
228                let p = match class {
229                    crate::spam::Class::Spam => spam,
230                    crate::spam::Class::Unsure | crate::spam::Class::Ham => unsure,
231                };
232                let named = say(&format!("rule-review-class-{}", class.as_str()), &[("p", i18n::percent(p))]);
233                say(&format!("rule-review-{}", actions.of(*class).as_str()), &[("class", named)])
234            })
235            .collect();
236        rules.extend(["rule-review-protected", "rule-review-buttons", "rule-review-where"].iter().map(|id| tr.text(id, None)));
237        rules
238    }));
239    lanes.push(lane(
240        "set-aside",
241        tr.text("lane-set-aside", None),
242        Lane::SetAside,
243        true,
244        tr.text("lane-about-set-aside", None),
245        ["rule-forged", "rule-borrowed", "rule-spam", "rule-blocked", "rule-where-blocked"].iter().map(|id| tr.text(id, None)).collect(),
246    ));
247    if config.accounts.iter().any(|a| a.shield) {
248        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)]));
249    }
250    lanes
251}
252
253/// A project's routes, each in a sentence.
254fn project_rules(project: &crate::projects::Project, tr: &Translator) -> Vec<String> {
255    if project.routes.is_empty() {
256        return vec![tr.text("rule-project-none", None)];
257    }
258    let part = |id: &str, list: &[String]| -> Option<String> {
259        (!list.is_empty()).then(|| {
260            let mut args = i18n::args();
261            args.set("list", list.join(", "));
262            tr.text(id, Some(&args))
263        })
264    };
265    project.routes
266        .iter()
267        .map(|route| {
268            let parts: Vec<String> = [
269                part("rule-part-domains", &route.from_domains),
270                part("rule-part-addresses", &route.from_addresses),
271                part("rule-part-subject", &route.subject_contains),
272                part("rule-part-text", &route.text_contains),
273            ]
274            .into_iter()
275            .flatten()
276            .collect();
277            let mut args = i18n::args();
278            args.set("parts", parts.join(&tr.text("rule-and", None)));
279            tr.text("rule-route", Some(&args))
280        })
281        .collect()
282}
283
284/// The verified codes among `items`: what a notification says.
285pub fn codes(items: &[Triaged], tr: &Translator) -> Vec<CodeView> {
286    items.iter().filter(|t| t.lane == Lane::RightNow).map(|t| code_view(t, tr)).collect()
287}
288
289impl CodeView {
290    /// The code and how long it lasts, on one line: "482913, valid about 10
291    /// minutes"; under it, the warning when its sender is not verified.
292    pub fn body(&self) -> String {
293        let line = [self.code.as_deref(), self.validity.as_deref()].into_iter().flatten().collect::<Vec<_>>().join(", ");
294        match self.warning.as_deref() {
295            Some(warning) if line.is_empty() => warning.to_string(),
296            Some(warning) => format!("{line}\n{warning}"),
297            None => line,
298        }
299    }
300}
301
302fn code_view(t: &Triaged, tr: &Translator) -> CodeView {
303    let code = t.code.as_ref();
304    let kind = code.map_or(crate::codes::CodeKind::Code, |c| c.kind);
305    let validity = code.and_then(|c| c.expires_minutes).map(|minutes| tr.validity(minutes));
306    CodeView {
307        key: key(&t.card),
308        title: tr.right_now(kind, t.card.sender(), t.trust, None, None),
309        code: code.and_then(|c| c.code.clone()),
310        validity,
311        warning: (t.trust != Trust::Verified).then(|| tr.text("right-now-unverified", None)),
312    }
313}
314
315fn item_view(t: &Triaged, store: Option<&ProjectStore>, tr: &Translator) -> ItemView {
316    // Sioul's own filter's word on it, in the review queue.
317    let learned = t.reasons.iter().find_map(Reason::learned).filter(|_| t.lane == Lane::Review);
318    let preview: String = t.card.excerpt.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(PREVIEW).collect();
319    let mut view = ItemView {
320        key: key(&t.card),
321        account: t.card.account.clone(),
322        sender: t.card.sender().to_string(),
323        address: t.card.from_address.clone().unwrap_or_default(),
324        trust: tr.trust(t.trust),
325        trust_level: match t.trust {
326            Trust::Verified => "verified",
327            Trust::Unverified => "unverified",
328            Trust::Forged => "forged",
329        },
330        subject: t.card.subject.clone(),
331        date: date(tr, t.card.date),
332        preview,
333        reasons: t.reasons.iter().map(|r| tr.reason(r, store)).collect(),
334        attachments: t.card.attachments.clone(),
335        screener: t.reasons.contains(&Reason::FirstMessage),
336        spam: (t.lane == Lane::SetAside && t.reasons.iter().any(Reason::is_spam)) || t.lane == Lane::Review,
337        review: t.lane == Lane::Review,
338        moved: t.reasons.contains(&Reason::MovedToJunk),
339        spam_word: learned.map_or(String::new(), |(class, _)| tr.text(&format!("spam-chip-{}", class.as_str()), None)),
340        important: t.priority == Priority::Above,
341        checks: checks(t.checks.as_ref(), tr),
342        topic: t.assessment.as_ref().map(|a| tr.topic(a.topic)).unwrap_or_default(),
343        tone: t.assessment.as_ref().map_or(String::new(), |a| format!("{:?}", a.tone).to_lowercase()),
344        hidden: false,
345        summary: t.assessment.as_ref().map(|a| a.summary.clone()).unwrap_or_default(),
346    };
347    // Hostile mail keeps its words to itself: a sender's name can insult too.
348    if t.lane == Lane::Hostile {
349        view.hidden = true;
350        view.sender = t.card.sender_domain().map_or_else(|| tr.text("hostile-someone", None), |d| {
351            let mut args = i18n::args();
352            args.set("domain", d.to_string());
353            tr.text("hostile-someone-at", Some(&args))
354        });
355        view.subject = tr.text("hostile-subject", None);
356        view.preview = String::new();
357        view.summary = String::new();
358    } else if t.assessment.as_ref().is_some_and(|a| a.tone == crate::shield::Tone::Rude) {
359        view.preview = String::new();
360    }
361    view
362}
363
364/// What each check said, one per line: who checked, then DKIM, SPF, DMARC with
365/// the domain's policy, ARC and the reverse DNS, each with the domain it was for.
366pub fn checks(results: Option<&AuthResults>, tr: &Translator) -> String {
367    let Some(r) = results else { return tr.text("checks-none", None) };
368    let by = if r.authserv_id.starts_with("sioul-") {
369        tr.text("checks-by-sioul", None)
370    } else {
371        let mut args = i18n::args();
372        args.set("id", r.authserv_id.clone());
373        tr.text("checks-by-provider", Some(&args))
374    };
375    let dmarc_detail = match (&r.dmarc_domain, &r.dmarc_policy) {
376        (Some(domain), Some(policy)) => {
377            let mut args = i18n::args();
378            args.set("policy", policy.clone());
379            Some(format!("{domain}, {}", tr.text("check-policy", Some(&args))))
380        }
381        (domain, _) => domain.clone(),
382    };
383    let rows: [(String, Option<Outcome>, Option<String>); 5] = [
384        ("DKIM".into(), r.dkim, r.dkim_domain.clone()),
385        ("SPF".into(), r.spf, r.spf_domain.clone()),
386        ("DMARC".into(), r.dmarc, dmarc_detail),
387        ("ARC".into(), r.arc, None),
388        (tr.text("protocol-iprev", None), r.iprev, None),
389    ];
390    let lines: Vec<String> = rows
391        .into_iter()
392        .filter_map(|(protocol, outcome, domain)| {
393            let outcome = outcome?;
394            let mut args = i18n::args();
395            args.set("protocol", protocol);
396            args.set("outcome", tr.text(outcome_id(outcome), None));
397            let id = match domain {
398                Some(d) => {
399                    args.set("domain", d);
400                    "check-line-domain"
401                }
402                None => "check-line",
403            };
404            Some(tr.text(id, Some(&args)))
405        })
406        .collect();
407    if lines.is_empty() {
408        return tr.text("checks-none", None);
409    }
410    std::iter::once(by).chain(lines).collect::<Vec<_>>().join("\n")
411}
412
413fn outcome_id(outcome: Outcome) -> &'static str {
414    match outcome {
415        Outcome::Pass => "check-pass",
416        Outcome::Fail => "check-fail",
417        Outcome::SoftFail => "check-softfail",
418        Outcome::Neutral => "check-neutral",
419        Outcome::None => "check-none",
420        Outcome::TempError => "check-temperror",
421        Outcome::PermError => "check-permerror",
422        Outcome::Other => "check-other",
423    }
424}
425
426fn key(card: &Card) -> String {
427    card.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()
428}
429
430/// A message's date, short, in your language and time zone.
431pub fn date(tr: &Translator, seconds: Option<i64>) -> String {
432    seconds
433        .and_then(|s| Timestamp::from_second(s).ok())
434        .map_or_else(|| tr.text("no-date", None), |t| tr.date(&t.to_zoned(TimeZone::system()), true))
435}
436
437#[derive(Debug, Serialize)]
438pub struct BudgetsView {
439    pub title: String,
440    pub budgets: Vec<BudgetCard>,
441    pub reserves_title: String,
442    pub reserves: Vec<ReserveCard>,
443    pub mail_title: String,
444    /// Messages about money, newest first; those you said are not payments are left out.
445    pub mail: Vec<MailRow>,
446    /// The budgets a line can go to.
447    pub choices: Vec<Choice>,
448}
449
450/// One figure of a card: a label and a value, on their own line.
451#[derive(Debug, Serialize)]
452pub struct Figure {
453    pub label: String,
454    pub value: String,
455}
456
457#[derive(Debug, Serialize)]
458pub struct BudgetCard {
459    pub id: String,
460    pub title: String,
461    /// "monthly", "mensuel".
462    pub period: String,
463    /// "better", "as-planned", "short", for the interface's colours and icons.
464    pub mood: &'static str,
465    /// The verdict, at the period's pace: "On track", "Ahead of its pace by €40".
466    pub verdict: String,
467    /// "10 % of October gone; 12 % of what it still needs has come in."
468    pub pace: String,
469    pub figures: Vec<Figure>,
470    /// What is kept for later, and what a reserve does at the end of the period.
471    pub notes: Vec<String>,
472}
473
474#[derive(Debug, Serialize)]
475pub struct ReserveCard {
476    pub id: String,
477    pub title: String,
478    pub figures: Vec<Figure>,
479}
480
481#[derive(Debug, Serialize)]
482pub struct MailRow {
483    pub key: String,
484    pub date: String,
485    /// "received", "paid", "order", "bill", "refund".
486    pub kind: PaymentKind,
487    pub kind_label: String,
488    /// Who paid or was paid.
489    pub party: String,
490    pub subject: String,
491    pub sender: String,
492    /// "+€12.00", "−€387.00"; empty when the message gives no amount.
493    pub amount: String,
494    pub credit: bool,
495    /// The budget its rule gives it, else none: you choose.
496    pub budget: Option<String>,
497    /// "recorded", "duplicate", "proposed".
498    pub state: &'static str,
499    /// What becomes of it, in a few words.
500    pub note: String,
501    /// From someone new and not verified: to check before counting.
502    pub doubtful: bool,
503    /// It can be added: proposed, with an amount.
504    pub can_add: bool,
505}
506
507#[derive(Debug, Serialize)]
508pub struct Choice {
509    pub id: String,
510    pub title: String,
511}
512
513/// Budgets, reserves and the mail about money, itemized: each figure on its own
514/// line, the verdict apart.
515pub fn budgets(ledger: &Ledger, mail: &[MailLine], tr: &Translator, today: jiff::civil::Date) -> BudgetsView {
516    let mut args = i18n::args();
517    args.set("month", tr.month_year(today));
518    BudgetsView {
519        title: tr.text("budgets-title", Some(&args)),
520        budgets: ledger.budgets.iter().map(|b| budget_card(ledger, b, tr, today)).collect(),
521        reserves_title: tr.text("reserves-title", None),
522        reserves: ledger.reserves.iter().map(|r| reserve_card(ledger, r, tr, today)).collect(),
523        mail_title: tr.text("mail-section", None),
524        mail: mail.iter().filter(|l| l.state != MailState::Ignored).map(|l| mail_row(ledger, mail, l, tr)).collect(),
525        choices: ledger.budgets.iter().map(|b| Choice { id: b.id.clone(), title: b.title.clone() }).collect(),
526    }
527}
528
529/// "10 % of the month gone; 12 % of what it still needs has come in.", or what it may spend.
530fn pace_sentence(pace: &crate::budget::Pace, tr: &Translator, monthly: bool) -> String {
531    let percent = |x: f64| ((x * 100.0).round().clamp(0.0, 999.0) as i64).to_string();
532    let mut args = i18n::args();
533    args.set("gone", percent(pace.gone));
534    let period = if monthly { "month" } else { "year" };
535    let id = if pace.needed.cents() > 0 {
536        args.set("part", percent(pace.unscheduled.cents() as f64 / pace.needed.cents() as f64));
537        "needs"
538    } else if pace.needed.cents() < 0 {
539        args.set("part", percent((-pace.unscheduled.cents()).max(0) as f64 / (-pace.needed.cents()) as f64));
540        "may-spend"
541    } else {
542        "nothing-needed"
543    };
544    tr.text(&format!("pace-{id}-{period}"), Some(&args))
545}
546
547fn budget_card(ledger: &Ledger, budget: &Budget, tr: &Translator, today: jiff::civil::Date) -> BudgetCard {
548    let status = ledger.status(budget, today);
549    let pace = ledger.pace(budget, today);
550    let figure = |id: &str, pairs: &[(&str, String)], value: Money| {
551        let mut args = i18n::args();
552        for (key, v) in pairs {
553            args.set(key.to_string(), v.clone());
554        }
555        Figure { label: tr.text(id, Some(&args)), value: tr.money(value) }
556    };
557    let mut figures = vec![
558        figure("fig-so-far", &[], status.so_far),
559        figure("fig-expected", &[("end", tr.day_month(status.end))], pace.realistic),
560        figure("fig-target", &[], status.target),
561    ];
562    if let Some((low, high)) = pace.range {
563        let mut args = i18n::args();
564        args.set("low", tr.money(low));
565        args.set("high", tr.money(high));
566        figures.insert(2, Figure { label: tr.text("fig-range", None), value: tr.text("fig-range-value", Some(&args)) });
567    }
568    if status.opening != Money::ZERO {
569        figures.insert(0, figure("fig-opening", &[], status.opening));
570    }
571    if status.earmarked != Money::ZERO {
572        figures.push(figure("fig-earmarked", &[], status.earmarked));
573    }
574    // Judged at its pace: what simply happens is weighed against the part of the period gone.
575    let (mood, verdict) = match pace.verdict {
576        Verdict::Better => ("better", "badge-pace-better"),
577        Verdict::AsPlanned => ("as-planned", "badge-pace-on-track"),
578        Verdict::Short => ("short", "badge-pace-short"),
579    };
580    let mut args = i18n::args();
581    args.set("gap", tr.money(pace.ahead.abs()));
582    let mut notes = Vec::new();
583    if let Some((reserve, amount)) = &status.reserve_transfer {
584        let mut args = i18n::args();
585        args.set("reserve", ledger.reserve_title(reserve).to_string());
586        args.set("amount", tr.money(amount.abs()));
587        args.set("end", tr.day_month(status.end));
588        notes.push(tr.text(if amount.is_negative() { "budget-swept" } else { "budget-drawn" }, Some(&args)));
589    }
590    BudgetCard {
591        id: budget.id.clone(),
592        title: budget.title.clone(),
593        period: tr.text(if budget.period == Period::Month { "period-month" } else { "period-year" }, None),
594        mood,
595        verdict: tr.text(verdict, Some(&args)),
596        pace: pace_sentence(&pace, tr, budget.period == Period::Month),
597        figures,
598        notes,
599    }
600}
601
602/// One movement of a budget, as its ledger lists it.
603#[derive(Debug, Serialize)]
604pub struct MovementRow {
605    /// Its address for links, when it is a line of the file.
606    pub uri: String,
607    pub date: String,
608    /// The same, for its form: "2026-10-03", and the amount in units.
609    pub day: String,
610    pub value: f64,
611    pub label: String,
612    pub amount: String,
613    pub incoming: bool,
614    pub planned: bool,
615    pub recurring: bool,
616}
617
618/// The balance at one point, for the graph.
619#[derive(Debug, Serialize)]
620pub struct BalancePoint {
621    pub label: String,
622    pub date: String,
623    pub cents: i64,
624    pub future: bool,
625}
626
627/// A budget opened: its ledger for a period, and its balance as a graph.
628#[derive(Debug, Serialize)]
629pub struct BudgetDetail {
630    pub card: BudgetCard,
631    /// "October 2026", "2026".
632    pub period_title: String,
633    /// The first day of the period shown, to move from.
634    pub from: String,
635    pub movements: Vec<MovementRow>,
636    pub money_in: String,
637    pub money_out: String,
638    /// "day", "week", "month", "year".
639    pub step: String,
640    pub points: Vec<BalancePoint>,
641    pub low_cents: i64,
642    pub high_cents: i64,
643    pub target_cents: i64,
644    /// Leisure money: it stays in view in quiet time.
645    pub personal: bool,
646    /// What it is for (docs/areas.md): "work", "admin", "leisure", joined by "+"; "" for your admin, unsaid.
647    pub area: String,
648    /// "month" or "year", for its form.
649    pub period_kind: String,
650}
651
652/// A budget's ledger for the period around `anchor`, and its balance by `step`.
653pub fn budget_detail(ledger: &Ledger, budget: &Budget, anchor: jiff::civil::Date, step: &str, tr: &Translator, today: jiff::civil::Date) -> BudgetDetail {
654    let (start, end) = budget.period.bounds(anchor);
655    let moves = ledger.movements(budget, start, end, today);
656    let money_in: Money = moves.iter().filter(|m| !m.amount.is_negative()).map(|m| m.amount).sum();
657    let money_out: Money = moves.iter().filter(|m| m.amount.is_negative()).map(|m| m.amount).sum();
658    let span = |years: i64, months: i64, weeks: i64, days: i64| jiff::Span::new().years(years).months(months).weeks(weeks).days(days);
659    let (from, to) = match step {
660        "week" => (anchor.checked_sub(span(0, 0, 8, 0)).unwrap_or(anchor), anchor.checked_add(span(0, 0, 4, 0)).unwrap_or(anchor)),
661        "month" => (anchor.first_of_year(), anchor.last_of_year()),
662        "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()),
663        _ => (anchor.first_of_month(), anchor.last_of_month()),
664    };
665    let points: Vec<BalancePoint> = ledger
666        .balance_series(budget, from, to, step, today)
667        .into_iter()
668        .map(|p| BalancePoint {
669            label: match step {
670                "year" => p.date.year().to_string(),
671                // Its short name: three letters would make "jui" of both juin and juillet.
672                "month" => tr.month_short(p.date),
673                "week" => tr.day_month(p.date),
674                _ => p.date.day().to_string(),
675            },
676            date: p.date.to_string(),
677            cents: p.balance.cents(),
678            future: p.future,
679        })
680        .collect();
681    let mut card = budget_card(ledger, budget, tr, today);
682    if !(start <= today && today <= end) {
683        // Another period: its movements only, no pace.
684        card.verdict = String::new();
685        card.pace = String::new();
686    }
687    BudgetDetail {
688        card,
689        period_title: if budget.period == Period::Month { tr.month_year(start) } else { start.year().to_string() },
690        from: start.to_string(),
691        movements: moves
692            .iter()
693            .rev()
694            .map(|m| MovementRow {
695                uri: m.line.map(|i| crate::links::budget_uri(&budget.id, i)).unwrap_or_default(),
696                date: tr.day(m.date),
697                day: m.date.to_string(),
698                value: m.amount.cents() as f64 / 100.0,
699                label: m.label.clone(),
700                amount: tr.money(m.amount),
701                incoming: !m.amount.is_negative(),
702                planned: m.planned,
703                recurring: m.recurring,
704            })
705            .collect(),
706        money_in: tr.money(money_in),
707        money_out: tr.money(money_out.abs()),
708        step: step.to_string(),
709        low_cents: points.iter().map(|p| p.cents).min().unwrap_or(0).min(budget.target.cents()),
710        high_cents: points.iter().map(|p| p.cents).max().unwrap_or(0).max(budget.target.cents()),
711        target_cents: budget.target.cents(),
712        personal: budget.area.as_deref() == Some("personal"),
713        // "personal" was the word for leisure money before areas had three.
714        area: match budget.area.as_deref() {
715            Some("personal") => "leisure".into(),
716            Some(other) => crate::areas::Area::parse(other).map(|a| a.id()).unwrap_or_default(),
717            None => String::new(),
718        },
719        period_kind: if budget.period == Period::Year { "year".into() } else { "month".into() },
720        points,
721    }
722}
723
724fn reserve_card(ledger: &Ledger, reserve: &Reserve, tr: &Translator, today: jiff::civil::Date) -> ReserveCard {
725    let status = ledger.reserve_status(reserve, today);
726    let mut month = i18n::args();
727    month.set("done", tr.money(status.month_done));
728    month.set("planned", tr.money(status.month_planned));
729    let mut year_end = i18n::args();
730    year_end.set("date", tr.day_month(today.last_of_year()));
731    let pace = match status.months_left {
732        Some(months) => {
733            // A number, for its plural: "about a month", "about 16 months".
734            let mut args = i18n::args();
735            args.set("months", months);
736            tr.text("fig-pace-months", Some(&args))
737        }
738        None => tr.text("fig-pace-quiet", None),
739    };
740    let mut figures = vec![Figure { label: tr.text("fig-today", None), value: tr.money(status.balance_now) }];
741    // How long money asked from it takes to arrive, when it does not come at once.
742    if reserve.delay_days > 0 {
743        let mut args = i18n::args();
744        args.set("days", i64::from(reserve.delay_days));
745        figures.push(Figure { label: tr.text("fig-delay", None), value: tr.text("reserve-delay-short", Some(&args)) });
746    }
747    figures.extend([
748        Figure { label: tr.text("fig-this-month", None), value: tr.text("fig-month-flows", Some(&month)) },
749        Figure { label: tr.text("fig-by-date", Some(&year_end)), value: tr.money(status.year_end) },
750        Figure { label: tr.text("fig-pace", None), value: pace },
751    ]);
752    ReserveCard { id: reserve.id.clone(), title: reserve.title.clone(), figures }
753}
754
755fn mail_row(ledger: &Ledger, all: &[MailLine], line: &MailLine, tr: &Translator) -> MailRow {
756    let signed = line.signed();
757    let amount = signed.map_or_else(String::new, |m| if m.is_negative() { tr.money(m) } else { format!("+{}", tr.money(m)) });
758    let say = |id: &str, key: &str, value: String| {
759        let mut args = i18n::args();
760        args.set(key.to_string(), value);
761        tr.text(id, Some(&args))
762    };
763    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());
764    let (state, note) = match &line.state {
765        MailState::Recorded => {
766            let budget = ledger.lines.iter().find(|l| l.links.contains(&line.key)).map_or("", |l| ledger.budget_title(&l.budget));
767            ("recorded", say("note-recorded", "budget", budget.to_string()))
768        }
769        MailState::Duplicate(other) => {
770            let other = all.iter().find(|l| &l.key == other).map_or_else(String::new, |l| format!("{} ({})", l.payment.party, date_of(tr, l.date)));
771            ("duplicate", say("note-duplicate", "other", other))
772        }
773        _ => match (&line.preset, signed) {
774            (Some(preset), None) => ("proposed", say("note-preset", "preset", preset_label(preset))),
775            (Some(preset), Some(_)) => ("proposed", say("note-preset-amount", "preset", preset_label(preset))),
776            (None, None) => ("proposed", tr.text("note-no-amount", None)),
777            (None, Some(_)) => ("proposed", String::new()),
778        },
779    };
780    MailRow {
781        key: line.key.clone(),
782        date: date_of(tr, line.date),
783        kind: line.payment.kind,
784        kind_label: tr.text(line.payment.kind.message_id(), None),
785        party: line.payment.party.clone(),
786        subject: line.subject.clone(),
787        sender: line.sender.clone(),
788        amount,
789        credit: line.payment.kind.is_credit(),
790        budget: line.budget.clone(),
791        state,
792        note,
793        doubtful: line.doubtful,
794        can_add: line.state == MailState::Proposed && signed.is_some(),
795    }
796}
797
798fn date_of(tr: &Translator, date: jiff::civil::Date) -> String {
799    tr.day_month(date)
800}
801
802#[derive(Debug, Serialize)]
803pub struct AccountView {
804    pub id: String,
805    /// "imap", "portal".
806    pub kind: &'static str,
807    pub address: Option<String>,
808    pub url: Option<String>,
809    /// Server, sender checks, where the mail is kept: one per line.
810    pub rows: Vec<Figure>,
811    /// The last thing sync said, given by the caller.
812    pub status: String,
813    /// That last thing was a problem.
814    pub status_error: bool,
815    /// "above", "average", "below".
816    pub priority: &'static str,
817    /// Signed in with Google (its tokens in the keyring): it can be signed in again.
818    pub google: bool,
819    /// Its server, for its password's login in a vault (Bitwarden).
820    pub host: Option<String>,
821    /// The name it logs in with (`Account::login`: its user name, else its
822    /// address): the user name its login in a vault is found by (Bitwarden).
823    pub login: Option<String>,
824    /// No password kept on this device, or its server refused it: one can be
825    /// given (an account come from another device arrives without one).
826    pub password_wanted: bool,
827    /// Your name as recipients see it, and the signature, in Markdown.
828    pub name: String,
829    pub signature: String,
830    /// Switched on: synced and shown.
831    pub enabled: bool,
832    /// Who it is: its address (lowercase), else its id. One card per identity,
833    /// each of its services (mail, calendars and contacts, Google) on it.
834    pub identity: String,
835    /// "mail", "dav", "google".
836    pub service: &'static str,
837}
838
839/// The accounts of the configuration, those switched off too (sites are not
840/// accounts: docs/sites.md), with what sync last said about each (`status`:
841/// the sentence, and whether it is a problem) and those whose password is
842/// wanted (`wanted`, by id).
843pub fn accounts(config: &Config, tr: &Translator, status: &BTreeMap<String, (String, bool)>, wanted: &BTreeSet<String>) -> Vec<AccountView> {
844    config
845        .every_account()
846        .filter(|a| a.kind != AccountKind::Portal)
847        .map(|a| {
848            let mut rows = Vec::new();
849            // Google's calendars: no server of theirs to name. Its mail, signed in with
850            // Google too, has its IMAP server like any other.
851            if a.auth.as_deref() == Some("google") && a.kind == AccountKind::Dav {
852                rows.push(Figure { label: tr.text("account-row-server", None), value: "Google".into() });
853            } else if let (Some(host), true) = (&a.host, a.kind == AccountKind::Dav) {
854                rows.push(Figure { label: tr.text("account-row-server", None), value: host.clone() });
855            } else if let Some(host) = &a.host {
856                let mut args = i18n::args();
857                args.set("host", host.clone());
858                args.set("port", a.port_or_default().to_string());
859                args.set("security", tr.text(&format!("security-{}", a.security.as_str()), None));
860                rows.push(Figure { label: tr.text("account-row-server", None), value: tr.text("account-server", Some(&args)) });
861            }
862            if a.kind == AccountKind::Imap {
863                let checks = if a.trusted_authserv_ids.is_empty() {
864                    tr.text("account-checks-learning", None)
865                } else {
866                    a.trusted_authserv_ids.join(", ")
867                };
868                rows.push(Figure { label: tr.text("account-row-checks", None), value: checks });
869                rows.push(Figure { label: tr.text("account-row-mail", None), value: a.maildir_path().display().to_string() });
870            }
871            if let (Some(url), true) = (&a.url, a.kind != AccountKind::Dav) {
872                rows.push(Figure { label: tr.text("account-row-web", None), value: url.clone() });
873            }
874            if a.kind == AccountKind::Dav {
875                rows.push(Figure { label: tr.text("account-row-dav", None), value: crate::vdir::Kind::Contacts.root().join(&a.id).display().to_string() });
876                rows.push(Figure { label: String::new(), value: crate::vdir::Kind::Calendars.root().join(&a.id).display().to_string() });
877            }
878            let (status, status_error) = status.get(&a.id).cloned().unwrap_or_default();
879            AccountView {
880                id: a.id.clone(),
881                kind: match a.kind {
882                    AccountKind::Imap => "imap",
883                    AccountKind::Jmap => "jmap",
884                    AccountKind::Portal => "portal",
885                    AccountKind::Dav => "dav",
886                },
887                address: a.address.clone(),
888                url: a.url.clone(),
889                rows,
890                status,
891                status_error,
892                priority: a.priority.as_str(),
893                google: a.auth.as_deref() == Some("google"),
894                host: a.host.clone(),
895                login: a.login().map(str::to_string),
896                password_wanted: wanted.contains(&a.id) && a.auth.as_deref() != Some("google"),
897                name: a.name.clone().unwrap_or_default(),
898                signature: a.signature.clone().unwrap_or_default(),
899                enabled: a.enabled,
900                identity: a.address.as_deref().map(str::to_lowercase).unwrap_or_else(|| a.id.clone()),
901                service: if a.auth.as_deref() == Some("google") && a.kind == AccountKind::Dav {
902                    "google"
903                } else if a.kind == AccountKind::Dav {
904                    "dav"
905                } else {
906                    "mail"
907                },
908            }
909        })
910        .collect()
911}
912
913#[derive(Debug, Serialize)]
914pub struct MessageView {
915    /// `mid:<Message-ID>`, to follow and make links (`links`); empty without one.
916    pub uri: String,
917    pub from_name: String,
918    pub from_address: String,
919    pub to: Vec<String>,
920    pub cc: Vec<String>,
921    /// The long date: "Thursday 1 October at 12:03".
922    pub date: String,
923    pub subject: String,
924    pub attachments: Vec<AttachmentView>,
925    /// Mail set aside shows its attachments' names, and does not open them.
926    pub can_open: bool,
927    /// The text in parts, each also as rich text with clickable links.
928    pub parts: Vec<PartView>,
929    /// The HTML version made safe, shown instead of the parts when there is one.
930    pub html: Option<Html>,
931    /// What each check said, one per line: the shield's tooltip.
932    pub checks: String,
933    /// What "block" offers: the address, and `@domain`.
934    pub block_address: Option<String>,
935    pub block_domain: Option<String>,
936    /// The sender may be judged from this message: it is not forged and borrows
937    /// no name. A forged message never changes who is safe or blocked.
938    pub sender_judgeable: bool,
939    /// Someone besides the sender got it: "Reply to all" is offered.
940    pub others: bool,
941    pub unread: bool,
942    pub flagged: bool,
943    /// The folder it is in, for the actions that fit there ("Not junk" in the junk).
944    pub role: Option<Role>,
945    /// Encrypted or signed with OpenPGP, and what came of it.
946    pub protection: Option<ProtectionView>,
947    /// Leaving its list in one click, when it names a way out (`unsubscribe::view`); the window fills it.
948    pub unsubscribe: Option<crate::unsubscribe::UnsubscribeView>,
949}
950
951/// A part of a message's text (see `reading::Part`), with its rich text.
952#[derive(Debug, Serialize)]
953pub struct PartView {
954    /// "text", "attribution", "quote", "headers", "signature".
955    pub kind: &'static str,
956    pub depth: u8,
957    pub text: String,
958    /// The text as HTML, its links clickable.
959    pub rich: String,
960    /// For "headers": name, value, name, value…
961    pub fields: Vec<String>,
962}
963
964impl From<Part> for PartView {
965    fn from(part: Part) -> PartView {
966        let (kind, depth, text, fields) = match part {
967            Part::Text { text } => ("text", 0, text, Vec::new()),
968            Part::Attribution { text } => ("attribution", 0, text, Vec::new()),
969            Part::Quote { depth, text } => ("quote", depth, text, Vec::new()),
970            Part::Signature { text } => ("signature", 0, text, Vec::new()),
971            Part::Headers { fields } => ("headers", 0, String::new(), fields.into_iter().flat_map(|(n, v)| [n, v]).collect()),
972        };
973        PartView { kind, depth, rich: reading::linkify(&text), text, fields }
974    }
975}
976
977#[derive(Debug, Serialize)]
978pub struct AttachmentView {
979    pub index: u32,
980    pub name: String,
981    /// "1.2 MB", "202 Ko".
982    pub size: String,
983    /// "pdf", "image", "text", "document", "archive", "calendar", "other", for the icon.
984    pub kind: &'static str,
985}
986
987/// A message for the reading pane: who wrote to whom, the attachments apart, the
988/// text in parts (see `reading`).
989/// A message laid out for reading. `own` holds your addresses, to tell whether
990/// anyone else would get a reply to all.
991pub fn message(path: &std::path::Path, triaged: &Triaged, own: &[String], tr: &Translator) -> Option<MessageView> {
992    message_from(&std::fs::read(path).ok()?, path, triaged, own, tr, None)
993}
994
995/// The same from the message's bytes (decrypted, they live only in memory),
996/// with what OpenPGP found when it was protected.
997pub fn message_from(raw: &[u8], path: &std::path::Path, triaged: &Triaged, own: &[String], tr: &Translator, pgp: Option<crate::pgp::PgpView>) -> Option<MessageView> {
998    let mut reading = reading::read_bytes(raw)?;
999    // A signature, or the encrypted payload, is not something to open.
1000    reading.attachments.retain(|a| !matches!(a.mime.to_ascii_lowercase().as_str(), "application/pgp-signature" | "application/pgp-encrypted" | "application/pgp-keys"));
1001    let mut people: Vec<String> = reading
1002        .to
1003        .iter()
1004        .chain(&reading.cc)
1005        .filter_map(|p| p.address.as_ref().map(|a| a.to_ascii_lowercase()))
1006        .chain(triaged.card.from_address.clone())
1007        .filter(|a| !own.iter().any(|o| o.eq_ignore_ascii_case(a)))
1008        .collect();
1009    people.sort();
1010    people.dedup();
1011    let flags = maildir::flags_of(path);
1012    let unread = !flags.contains('S');
1013    let flagged = flags.contains('F');
1014    let person = |p: &Person| match (&p.name, &p.address) {
1015        (Some(name), Some(address)) => format!("{name} <{address}>"),
1016        (Some(name), None) => name.clone(),
1017        (None, Some(address)) => address.clone(),
1018        (None, None) => String::new(),
1019    };
1020    let address = triaged.card.from_address.clone();
1021    Some(MessageView {
1022        uri: triaged.card.message_id.as_deref().map(crate::links::mail_uri).unwrap_or_default(),
1023        from_name: triaged.card.sender().to_string(),
1024        from_address: address.clone().unwrap_or_default(),
1025        to: reading.to.iter().map(person).collect(),
1026        cc: reading.cc.iter().map(person).collect(),
1027        date: triaged
1028            .card
1029            .date
1030            .and_then(|s| Timestamp::from_second(s).ok())
1031            .map_or_else(|| tr.text("no-date", None), |t| tr.date(&t.to_zoned(TimeZone::system()), false)),
1032        subject: reading.subject.clone(),
1033        attachments: reading
1034            .attachments
1035            .iter()
1036            .map(|a| AttachmentView { index: a.index, name: a.name.clone(), size: size(tr, a.size), kind: attachment_kind(&a.mime) })
1037            .collect(),
1038        can_open: triaged.lane != Lane::SetAside,
1039        parts: reading.parts.into_iter().map(PartView::from).collect(),
1040        html: reading.html,
1041        checks: checks(triaged.checks.as_ref(), tr),
1042        block_domain: triaged.card.sender_domain().map(|d| format!("@{}", d.to_ascii_lowercase())),
1043        block_address: address,
1044        // A message nothing authenticates says nothing of the address it shows: no list changes from it.
1045        sender_judgeable: triaged.trust != Trust::Forged && !triaged.reasons.iter().any(|r| matches!(r, Reason::Impersonation { .. } | Reason::NotAuthenticated)),
1046        others: people.len() > 1,
1047        unread,
1048        flagged,
1049        role: None,
1050        protection: pgp.map(|p| protection_line(&p, tr)),
1051        unsubscribe: None,
1052    })
1053}
1054
1055/// What OpenPGP found, said in a line next to the sender.
1056#[derive(Debug, Serialize)]
1057pub struct ProtectionView {
1058    pub encrypted: bool,
1059    /// "Encrypted · Signed by Jane <jane@example.org>"; "No key of yours opens it."
1060    pub line: String,
1061    /// Everything checked out; else the line is said in warm colours, never red.
1062    pub fine: bool,
1063    /// Encrypted to your security key and not opened yet: "Open with your security key" is offered.
1064    pub security_key: bool,
1065}
1066
1067fn protection_line(p: &crate::pgp::PgpView, tr: &Translator) -> ProtectionView {
1068    let mut parts = Vec::new();
1069    let waits = p.encrypted && !p.opened && p.security_key.is_some();
1070    if p.encrypted {
1071        parts.push(tr.text(
1072            if p.opened {
1073                "pgp-encrypted"
1074            } else if waits {
1075                "pgp-for-security-key"
1076            } else {
1077                "pgp-not-opened"
1078            },
1079            None,
1080        ));
1081    }
1082    // Waiting for your security key is nothing wrong.
1083    let mut fine = !p.encrypted || p.opened || waits;
1084    for signature in &p.signatures {
1085        let mut args = i18n::args();
1086        if signature.good {
1087            args.set("signer", signature.signer.clone());
1088            parts.push(tr.text("pgp-signed-by", Some(&args)));
1089        } else if signature.problem == "unknown key" {
1090            parts.push(tr.text("pgp-signed-unknown", None));
1091        } else {
1092            fine = false;
1093            parts.push(tr.text("pgp-signature-bad", None));
1094        }
1095    }
1096    ProtectionView { encrypted: p.encrypted, line: parts.join(" · "), fine, security_key: waits }
1097}
1098
1099/// An account in the mail page's left column.
1100#[derive(Debug, Serialize)]
1101pub struct MailAccountView {
1102    pub id: String,
1103    pub title: String,
1104    /// Something unread in its inbox: a small dot, never a number (docs/client.md, rule 1).
1105    pub unread: bool,
1106    /// A work address in quiet time: folded, without dots, there if you look for it.
1107    pub resting: bool,
1108    /// Inbox, drafts, sent, archive, junk, trash: the ones the server has.
1109    pub folders: Vec<FolderEntry>,
1110    /// The other folders, folded under "More folders".
1111    pub more: Vec<FolderEntry>,
1112}
1113
1114#[derive(Debug, Serialize)]
1115pub struct FolderEntry {
1116    /// The server's name, to open it.
1117    pub name: String,
1118    pub title: String,
1119    pub role: Role,
1120    pub icon: &'static str,
1121    pub unread: bool,
1122    /// A copy is kept here; else it stays on the server only.
1123    pub kept: bool,
1124}
1125
1126/// The accounts and their folders, ranked as their mail is: above, average, below.
1127/// Views of other folders are not listed, but Gmail's "All Mail", its archive.
1128/// The mail page's accounts; `hours` what now is for (None: no hours set): an
1129/// address whose area does not fit it rests, and every address while you
1130/// sleep (docs/areas.md).
1131pub fn mail_accounts(accounts: &[(Account, Vec<Folder>)], tr: &Translator, hours: Option<(crate::areas::Time, crate::areas::Week)>) -> Vec<MailAccountView> {
1132    let mut ranked: Vec<&(Account, Vec<Folder>)> = accounts.iter().collect();
1133    ranked.sort_by_key(|(account, _)| account.priority);
1134    ranked
1135        .into_iter()
1136        .map(|(account, folders)| {
1137            let root = account.maildir_path();
1138            // An address whose area is unsaid is work's.
1139            let area = account.area.as_deref().and_then(crate::areas::Area::parse).unwrap_or(crate::areas::Area::WORK);
1140            let resting = hours.is_some_and(|(time, week)| time == crate::areas::Time::Sleep || !crate::areas::in_view(area, time, week));
1141            // "All Mail" stands for the archive where there is none (Gmail).
1142            let has_archive = folders.iter().any(|f| f.role == Role::Archive);
1143            // Two folders for one purpose ("Archive" and "Archives") each keep their own name.
1144            let named = |f: &Folder| f.role != Role::Other && folders.iter().filter(|o| o.role == f.role).count() == 1;
1145            let kept = |f: &Folder| f.role == Role::Inbox || !account.skip_folders.contains(&f.name);
1146            let entry = |f: &Folder| FolderEntry {
1147                name: f.name.clone(),
1148                title: if f.role == Role::All && !has_archive {
1149                    tr.text(Role::Archive.message_id(), None)
1150                } else if named(f) {
1151                    tr.text(f.role.message_id(), None)
1152                } else {
1153                    folder_title(&f.display)
1154                },
1155                role: f.role,
1156                icon: f.role.icon(),
1157                // The junk and the trash never call for attention; work mail neither, in quiet time.
1158                unread: !resting && kept(f) && matches!(f.role, Role::Inbox | Role::Other) && maildir::has_unread(&root.join(&f.local)),
1159                kept: kept(f),
1160            };
1161            let listed: Vec<&Folder> = folders.iter().filter(|f| f.role != Role::All || !has_archive).collect();
1162            let folders: Vec<FolderEntry> = listed.iter().filter(|f| f.role != Role::Other).map(|f| entry(f)).collect();
1163            let more: Vec<FolderEntry> = listed.iter().filter(|f| f.role == Role::Other).map(|f| entry(f)).collect();
1164            MailAccountView {
1165                id: account.id.clone(),
1166                title: account.address.clone().unwrap_or_else(|| account.id.clone()),
1167                unread: folders.iter().any(|f| f.role == Role::Inbox && f.unread),
1168                resting,
1169                folders,
1170                more,
1171            }
1172        })
1173        .collect()
1174}
1175
1176/// `[Gmail]/Important` → `Important`: the provider's prefix says nothing.
1177fn folder_title(display: &str) -> String {
1178    ["[Gmail]/", "[Google Mail]/"].iter().fold(display.to_string(), |name, prefix| name.strip_prefix(prefix).map_or(name.clone(), str::to_string))
1179}
1180
1181/// Days a folder shows before "Earlier" when the configuration says nothing (docs/client.md, rule 7).
1182pub const RECENT_DAYS: i64 = 14;
1183
1184/// A folder's messages, newest first: the last two weeks, or everything kept on request.
1185#[derive(Debug, Serialize)]
1186pub struct FolderView {
1187    pub account: String,
1188    pub folder: String,
1189    pub title: String,
1190    pub role: Role,
1191    /// What is there, in words, said when you open it.
1192    pub sentence: String,
1193    pub items: Vec<MailItem>,
1194    /// Older messages are kept: "Earlier" shows them.
1195    pub earlier: bool,
1196}
1197
1198#[derive(Debug, Clone, Serialize)]
1199pub struct MailItem {
1200    /// The file, to open the message.
1201    pub key: String,
1202    /// Who wrote; in Sent and Drafts, to whom.
1203    pub who: String,
1204    pub address: String,
1205    pub subject: String,
1206    pub date: String,
1207    pub unread: bool,
1208    pub flagged: bool,
1209    pub answered: bool,
1210    pub attachments: bool,
1211    /// "verified", "unverified", "forged"; "own" for what you sent.
1212    pub trust_level: &'static str,
1213    /// The same in your language; empty for what you sent.
1214    pub trust: String,
1215    pub checks: String,
1216    /// Shown by conversation: the conversation, the same for all its rows; empty otherwise.
1217    pub thread: String,
1218    /// On a conversation's first row: how many messages it holds.
1219    pub size: usize,
1220    /// A row inside a conversation, under its first; folded until it is opened.
1221    pub member: bool,
1222}
1223
1224/// Messages by conversation: each one's newest message of the folder first, the
1225/// conversations by their last message (yours included), then the rest of each,
1226/// oldest first, your answers from Sent among them. A conversation shows when
1227/// its first row would.
1228fn conversations(cards: &[Card], sent: &[Card], shown: &dyn Fn(&Card) -> bool, item: &dyn Fn(&Card, bool) -> MailItem) -> Vec<MailItem> {
1229    let all: Vec<&Card> = cards.iter().chain(sent.iter()).collect();
1230    let groups = crate::threads::group(&all);
1231    let own = |i: usize| i >= cards.len();
1232    let mut by_group: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1233    for (i, g) in groups.iter().enumerate() {
1234        by_group.entry(*g).or_default().push(i);
1235    }
1236    // Conversations with a message of this folder, the newest activity first.
1237    let mut ordered: Vec<(i64, usize, Vec<usize>)> = by_group
1238        .into_iter()
1239        .filter_map(|(_, members)| {
1240            // Cards are newest first: the folder's first one is the head.
1241            let head = *members.iter().find(|&&i| !own(i))?;
1242            shown(all[head]).then(|| (members.iter().map(|&i| all[i].date.unwrap_or(0)).max().unwrap_or(0), head, members))
1243        })
1244        .collect();
1245    ordered.sort_by_key(|(last, head, _)| (std::cmp::Reverse(*last), *head));
1246    let mut out = Vec::new();
1247    for (_, head, mut members) in ordered {
1248        let thread = all[head].path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
1249        let mut first = item(all[head], false);
1250        first.thread = thread.clone();
1251        first.size = members.len();
1252        first.unread = members.iter().filter(|&&i| !own(i)).any(|&i| item(all[i], false).unread);
1253        out.push(first);
1254        members.retain(|&i| i != head);
1255        members.sort_by_key(|&i| all[i].date.unwrap_or(0));
1256        for i in members {
1257            let mut row = item(all[i], own(i));
1258            row.thread = thread.clone();
1259            row.member = true;
1260            out.push(row);
1261        }
1262    }
1263    out
1264}
1265
1266/// One message as a list shows it, in a folder for `role`: who wrote (to whom,
1267/// in Sent and Drafts), what, when, its marks (`flags`, Maildir's letters),
1268/// and how far its sender is verified, as the Porch judges it. `own`: one of
1269/// your answers from Sent, shown in a conversation. Its key is its file, when
1270/// it has one (a search gives a message seen on its server only its own).
1271/// `words`: what the codes detector looks for, as the Porch reads it.
1272#[allow(clippy::too_many_arguments)]
1273pub fn mail_item(card: &Card, role: Role, own: bool, flags: &str, trusted_ids: &[String], words: &crate::words::Words, tr: &Translator) -> MailItem {
1274    let outgoing = matches!(role, Role::Sent | Role::Drafts);
1275    let (trust_level, trust, checks) = if outgoing || own {
1276        ("own", String::new(), String::new())
1277    } else {
1278        let results = trust::read_auth_results(&card.headers, trusted_ids);
1279        // As the Porch judges it: a signature counts for the sender's own domain only,
1280        // and a list excuses a DMARC failure, never for a code (`porch::triage`).
1281        let failed = results.as_ref().is_some_and(|r| r.dmarc == Some(trust::Outcome::Fail));
1282        let code = card.is_list && failed && crate::codes::detect_message(&words.codes, card).is_some();
1283        let judged = trust::judge_sender(results.as_ref(), card.is_list && !code, card.sender_domain()).0;
1284        let level = match judged {
1285            Trust::Verified => "verified",
1286            Trust::Unverified => "unverified",
1287            Trust::Forged => "forged",
1288        };
1289        (level, tr.trust(judged), self::checks(results.as_ref(), tr))
1290    };
1291    let who = if outgoing || own {
1292        let mut args = i18n::args();
1293        args.set("names", if card.to.is_empty() { tr.text("mail-nobody", None) } else { card.to.join(", ") });
1294        tr.text(if own { "mail-you-to" } else { "mail-to-whom" }, Some(&args))
1295    } else {
1296        card.sender().to_string()
1297    };
1298    MailItem {
1299        key: card.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(),
1300        who,
1301        address: card.from_address.clone().unwrap_or_default(),
1302        subject: if card.subject.trim().is_empty() { tr.text("mail-no-subject", None) } else { card.subject.clone() },
1303        date: date(tr, card.date),
1304        // In Sent, Drafts, Junk and Trash, nothing waits to be read.
1305        unread: !own && matches!(role, Role::Inbox | Role::Other | Role::Archive) && !flags.contains('S'),
1306        flagged: flags.contains('F'),
1307        answered: flags.contains('R'),
1308        attachments: !card.attachments.is_empty(),
1309        trust_level,
1310        trust,
1311        checks,
1312        thread: String::new(),
1313        size: 1,
1314        member: false,
1315    }
1316}
1317
1318/// What a folder shows: `all` past the last two weeks; `query` searches every
1319/// message kept, by sender, recipient and subject. With `sent` (the account's
1320/// Sent folder), messages are shown by conversation, your answers among them.
1321#[allow(clippy::too_many_arguments)]
1322pub 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 {
1323    // The account's history, two weeks unless set; everything kept when it says so.
1324    let all = all || account.history_days().is_none();
1325    let since = now - account.history_days().unwrap_or(RECENT_DAYS) * 86_400;
1326    let query = query.trim().to_lowercase();
1327    let found = |c: &Card| {
1328        query.is_empty()
1329            || [c.sender(), c.subject.as_str(), c.from_address.as_deref().unwrap_or("")]
1330                .into_iter()
1331                .chain(c.to.iter().map(String::as_str))
1332                .any(|t| t.to_lowercase().contains(&query))
1333    };
1334    let mut cards: Vec<Card> = cards.into_iter().filter(found).collect();
1335    cards.sort_by_key(|c| std::cmp::Reverse(c.date.unwrap_or(0)));
1336    let recent = |c: &Card| c.date.unwrap_or(0) >= since;
1337    let older = cards.iter().filter(|c| !recent(c)).count();
1338    let shown = |c: &Card| all || !query.is_empty() || recent(c);
1339    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);
1340    let items: Vec<MailItem> = match sent {
1341        None => cards.iter().filter(|c| shown(c)).map(|c| item(c, false)).collect(),
1342        Some(sent) => conversations(&cards, &sent, &shown, &item),
1343    };
1344    let unread = items.iter().filter(|i| i.unread).count();
1345    let sentence = if items.is_empty() && !query.is_empty() {
1346        let mut args = i18n::args();
1347        args.set("query", query.clone());
1348        tr.text("mail-not-found", Some(&args))
1349    } else if items.is_empty() && older > 0 {
1350        tr.text("mail-nothing-recent", None)
1351    } else if items.is_empty() {
1352        tr.text("mail-empty", None)
1353    } else if unread > 0 {
1354        tr.text("mail-unread", Some(&tr.counted(unread)))
1355    } else {
1356        String::new()
1357    };
1358    FolderView {
1359        account: account.id.clone(),
1360        folder: folder.name.clone(),
1361        title: match folder.role {
1362            Role::Other => folder_title(&folder.display),
1363            Role::All => tr.text(Role::Archive.message_id(), None),
1364            role => tr.text(role.message_id(), None),
1365        },
1366        role: folder.role,
1367        sentence,
1368        items,
1369        earlier: !all && query.is_empty() && older > 0,
1370    }
1371}
1372
1373fn attachment_kind(mime: &str) -> &'static str {
1374    let mime = mime.to_ascii_lowercase();
1375    match mime.as_str() {
1376        "application/pdf" => "pdf",
1377        "text/calendar" | "application/ics" => "calendar",
1378        m if m.starts_with("image/") => "image",
1379        m if m.starts_with("text/") => "text",
1380        m if m.contains("zip") || m.contains("compressed") || m.contains("x-tar") || m.contains("x-7z") => "archive",
1381        m if m.contains("word") || m.contains("officedocument") || m.contains("opendocument") || m.contains("excel") || m.contains("powerpoint") || m.contains("rtf") => "document",
1382        _ => "other",
1383    }
1384}
1385
1386/// "812 B", "202 KB", "1.2 MB", in your language's units and decimals.
1387pub fn size(tr: &Translator, bytes: usize) -> String {
1388    let mut args = i18n::args();
1389    let (id, value) = match bytes {
1390        b if b < 1024 => ("size-b", b.to_string()),
1391        b if b < 1024 * 1024 => ("size-kb", (b / 1024).to_string()),
1392        b => ("size-mb", tr.decimal(b as f32 / (1024.0 * 1024.0))),
1393    };
1394    args.set("n", value);
1395    tr.text(id, Some(&args))
1396}
1397
1398/// Days the agenda shows by default: today, then the next two weeks (docs/client.md, rule 9).
1399pub const AGENDA_DAYS: i64 = 15;
1400
1401/// What comes, day by day.
1402#[derive(Debug, Serialize)]
1403pub struct AgendaView {
1404    pub days: Vec<AgendaDay>,
1405    /// "Nothing planned in the next two weeks.", when so.
1406    pub sentence: String,
1407    /// The first day shown, "2026-10-03", to move week by week or month by month.
1408    pub from: String,
1409    /// Whether an event can be added: a calendar that takes it exists.
1410    pub can_add: bool,
1411}
1412
1413#[derive(Debug, Serialize)]
1414pub struct AgendaDay {
1415    /// "Today", "Tomorrow", "Monday 5 October".
1416    pub title: String,
1417    /// "2026-10-05".
1418    pub date: String,
1419    pub today: bool,
1420    /// The day's midnight, Unix seconds, to place what happens in it.
1421    pub start: i64,
1422    pub events: Vec<AgendaEvent>,
1423    /// Its hours given to work or admin, in minutes from midnight; -1 without (`set_hours`).
1424    pub hours_from: i32,
1425    pub hours_to: i32,
1426}
1427
1428#[derive(Debug, Serialize)]
1429pub struct AgendaEvent {
1430    pub key: String,
1431    pub uid: String,
1432    /// When this occurrence starts, Unix seconds: to leave it out of a repeating event.
1433    pub start: i64,
1434    /// When it ends, Unix seconds: a drag in the planning keeps its length.
1435    pub end: i64,
1436    /// "09:00 – 10:30", "All day", "Until 12:00".
1437    pub when: String,
1438    pub summary: String,
1439    pub location: String,
1440    pub notes: String,
1441    pub calendar: String,
1442    pub color: Option<String>,
1443    pub recurring: bool,
1444    pub cancelled: bool,
1445    pub tentative: bool,
1446    pub read_only: bool,
1447    pub organizer: String,
1448    pub attendees: Vec<crate::agenda::Attendee>,
1449    pub all_day: bool,
1450    /// Where it sits in this day's hours, in minutes from midnight: for the week's planning.
1451    pub from_minute: i32,
1452    pub to_minute: i32,
1453    /// Side by side with what overlaps it: its column among `columns`.
1454    pub column: u32,
1455    pub columns: u32,
1456}
1457
1458/// Places a day's timed events side by side where they overlap
1459/// (`dayview::side_by_side`); one shorter than a quarter of an hour takes a
1460/// quarter of an hour, as it is drawn.
1461fn lay_out(events: &mut [AgendaEvent]) {
1462    let timed: Vec<usize> = (0..events.len()).filter(|&i| !events[i].all_day).collect();
1463    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();
1464    for (&i, (column, columns)) in timed.iter().zip(crate::dayview::side_by_side(&spans)) {
1465        events[i].column = column;
1466        events[i].columns = columns;
1467    }
1468}
1469
1470/// Each day's hours given to something, work or your admin, in
1471/// minutes from its midnight (docs/areas.md): the planning shows those, and
1472/// the events outside them, rather than the night.
1473pub fn set_hours(view: &mut AgendaView, windows: &[crate::window::AdminWindow], zone: &TimeZone) {
1474    for day in &mut view.days {
1475        let Ok(date) = day.date.parse::<jiff::civil::Date>() else { continue };
1476        if let Some((opening, closing)) = crate::window::hours_on(windows, date, zone) {
1477            let minute = |z: &jiff::Zoned| i32::try_from((z.timestamp().as_second() - day.start) / 60).unwrap_or(0).clamp(0, 1440);
1478            day.hours_from = minute(&opening);
1479            day.hours_to = minute(&closing);
1480        }
1481    }
1482}
1483
1484/// The agenda from `from` for `days` days: each day with what it holds; an
1485/// event over several days shows on each of them.
1486pub fn agenda(occurrences: &[crate::agenda::Occurrence], from: jiff::civil::Date, days: i64, tr: &Translator, zone: &TimeZone) -> AgendaView {
1487    let today = Timestamp::now().to_zoned(zone.clone()).date();
1488    let mut out = Vec::new();
1489    for offset in 0..days {
1490        let Ok(date) = from.checked_add(jiff::Span::new().days(offset)) else { break };
1491        let (Ok(start), Ok(end)) = (date.to_zoned(zone.clone()), date.tomorrow().and_then(|d| d.to_zoned(zone.clone()))) else { continue };
1492        let (start, end) = (start.timestamp().as_second(), end.timestamp().as_second());
1493        let mut events: Vec<AgendaEvent> = occurrences
1494            .iter()
1495            .filter(|o| o.start < end && o.end > start && !(o.end == start && o.start < start))
1496            .map(|o| AgendaEvent {
1497                key: o.key.clone(),
1498                uid: o.uid.clone(),
1499                start: o.start,
1500                end: o.end,
1501                when: when(o, start, end, tr, zone),
1502                summary: if o.summary.is_empty() { tr.text("agenda-untitled", None) } else { o.summary.clone() },
1503                location: o.location.clone(),
1504                notes: o.notes.clone(),
1505                calendar: o.calendar.clone(),
1506                color: o.color.clone(),
1507                recurring: o.recurring,
1508                cancelled: o.cancelled,
1509                tentative: o.tentative,
1510                read_only: o.read_only,
1511                organizer: o.organizer.clone(),
1512                attendees: o.attendees.clone(),
1513                all_day: o.all_day || (o.start <= start && o.end >= end),
1514                from_minute: i32::try_from((o.start.max(start) - start) / 60).unwrap_or(0),
1515                to_minute: i32::try_from((o.end.min(end) - start) / 60).unwrap_or(1440),
1516                column: 0,
1517                columns: 1,
1518            })
1519            .collect();
1520        lay_out(&mut events);
1521        let title = if date == today {
1522            tr.text("agenda-today", None)
1523        } else if Some(date) == today.tomorrow().ok() {
1524            tr.text("agenda-tomorrow", None)
1525        } else {
1526            tr.day(date)
1527        };
1528        out.push(AgendaDay { title, date: date.to_string(), today: date == today, start, events, hours_from: -1, hours_to: -1 });
1529    }
1530    let empty = out.iter().all(|d| d.events.is_empty());
1531    let can_add = crate::agenda::default_calendar().is_some();
1532    let sentence = match (empty, can_add || !crate::vdir::collections(crate::vdir::Kind::Calendars).is_empty()) {
1533        (true, false) => tr.text("agenda-no-calendar", None),
1534        (true, true) => tr.text("agenda-nothing", None),
1535        _ => String::new(),
1536    };
1537    AgendaView { sentence, days: out, from: from.to_string(), can_add }
1538}
1539
1540/// An occurrence's time on one day: its hours, "All day", or how it continues.
1541fn when(o: &crate::agenda::Occurrence, day_start: i64, day_end: i64, tr: &Translator, zone: &TimeZone) -> String {
1542    let hour = |t: i64| Timestamp::from_second(t).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default();
1543    let starts_today = o.start >= day_start;
1544    let ends_today = o.end <= day_end;
1545    if o.all_day || (!starts_today && !ends_today) {
1546        return tr.text("agenda-all-day", None);
1547    }
1548    let mut args = i18n::args();
1549    match (starts_today, ends_today) {
1550        (true, true) if o.end == o.start => hour(o.start),
1551        (true, true) => format!("{} – {}", hour(o.start), hour(o.end)),
1552        (true, false) => {
1553            args.set("time", hour(o.start));
1554            tr.text("agenda-from", Some(&args))
1555        }
1556        (false, _) => {
1557            args.set("time", hour(o.end));
1558            tr.text("agenda-until", Some(&args))
1559        }
1560    }
1561}
1562
1563/// The address book: names first, details when one is opened (docs/client.md, rule 10).
1564#[derive(Debug, Serialize)]
1565pub struct ContactsView {
1566    pub contacts: Vec<ContactRow>,
1567    /// "No contact yet.", "Nothing matches “x”.", when so.
1568    pub sentence: String,
1569    /// Whether a contact can be added: an address book that takes it exists.
1570    pub can_add: bool,
1571    /// The categories the cards have, to filter by: the most used first, each
1572    /// as first written, one name once (`contacts::category_key`).
1573    pub categories: Vec<String>,
1574    /// The category shown; empty: every contact (also when the one asked is gone).
1575    pub category: String,
1576}
1577
1578#[derive(Debug, Serialize)]
1579pub struct ContactRow {
1580    pub key: String,
1581    /// Its address for links: `sioul:contact/<UID>`.
1582    pub uri: String,
1583    pub name: String,
1584    /// The first e-mail address, to write to them.
1585    pub email: String,
1586    /// The first address, else number, else organisation: one line, never more.
1587    pub detail: String,
1588    /// Its picture, as the card gives it (`Contact::photo`).
1589    pub photo: String,
1590    /// Its categories, as the card writes them.
1591    pub categories: Vec<String>,
1592}
1593
1594/// The contacts matching `query`, by name; of one category when `category`
1595/// names one (case and accents aside, as the sender lists compare them).
1596pub fn contacts(all: &[crate::contacts::Contact], query: &str, category: &str, tr: &Translator) -> ContactsView {
1597    let categories = crate::contacts::categories_in_use(all);
1598    let wanted = crate::contacts::category_key(category);
1599    let category = categories.iter().find(|c| !wanted.is_empty() && crate::contacts::category_key(c) == wanted).cloned().unwrap_or_default();
1600    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();
1601    let sentence = if all.is_empty() && crate::vdir::collections(crate::vdir::Kind::Contacts).is_empty() {
1602        tr.text("contacts-no-book", None)
1603    } else if all.is_empty() {
1604        tr.text("contacts-none", None)
1605    } else if found.is_empty() && query.trim().is_empty() {
1606        let mut args = i18n::args();
1607        args.set("category", category.clone());
1608        tr.text("contacts-none-in", Some(&args))
1609    } else if found.is_empty() {
1610        let mut args = i18n::args();
1611        args.set("query", query.trim().to_string());
1612        tr.text("mail-not-found", Some(&args))
1613    } else {
1614        String::new()
1615    };
1616    ContactsView {
1617        contacts: found
1618            .into_iter()
1619            .map(|c| ContactRow {
1620                key: c.key.clone(),
1621                uri: crate::links::contact_uri(&c.uid),
1622                name: c.name.clone(),
1623                email: c.emails.first().map(|e| e.value.clone()).unwrap_or_default(),
1624                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()),
1625                photo: c.photo.clone(),
1626                categories: c.categories.clone(),
1627            })
1628            .collect(),
1629        sentence,
1630        can_add: crate::contacts::default_book().is_some(),
1631        categories,
1632        category,
1633    }
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638    use super::*;
1639
1640    /// Mail signed in with Google is mail (its settings, its server), signed
1641    /// in again rather than given a password; Google's calendars stay Google's.
1642    #[test]
1643    fn mail_signed_in_with_google_is_mail() {
1644        let config: Config = toml::from_str(
1645            r#"
1646[[account]]
1647id = "gmail"
1648kind = "imap"
1649address = "you@gmail.com"
1650host = "imap.gmail.com"
1651auth = "google"
1652
1653[[account]]
1654id = "google"
1655kind = "dav"
1656address = "you@gmail.com"
1657host = "apidata.googleusercontent.com"
1658auth = "google"
1659"#,
1660        )
1661        .unwrap();
1662        let wanted: BTreeSet<String> = ["gmail".to_string()].into();
1663        let views = accounts(&config, &Translator::new("en"), &BTreeMap::new(), &wanted);
1664        let mail = views.iter().find(|v| v.id == "gmail").unwrap();
1665        assert_eq!((mail.service, mail.google, mail.password_wanted), ("mail", true, false));
1666        assert!(mail.rows[0].value.starts_with("imap.gmail.com"), "{}", mail.rows[0].value);
1667        let calendars = views.iter().find(|v| v.id == "google").unwrap();
1668        assert_eq!((calendars.service, calendars.rows[0].value.as_str()), ("google", "Google"));
1669    }
1670
1671    /// The password dialog looks in a vault for the name an account logs in
1672    /// with: its own user name when it has one, else its address (the review
1673    /// of 5 October 2026 found it prefilled with the address all the same).
1674    #[test]
1675    fn an_account_says_the_name_it_logs_in_with() {
1676        let config: Config = toml::from_str(
1677            r#"
1678[[account]]
1679id = "work"
1680kind = "imap"
1681address = "noa@example.org"
1682username = "nferrand"
1683host = "imap.example.org"
1684
1685[[account]]
1686id = "home"
1687kind = "imap"
1688address = "noa@example.com"
1689host = "imap.example.com"
1690"#,
1691        )
1692        .unwrap();
1693        let views = accounts(&config, &Translator::new("en"), &BTreeMap::new(), &BTreeSet::new());
1694        let login = |id: &str| views.iter().find(|v| v.id == id).and_then(|v| v.login.clone());
1695        assert_eq!(login("work").as_deref(), Some("nferrand"));
1696        assert_eq!(login("home").as_deref(), Some("noa@example.com"));
1697    }
1698
1699    #[test]
1700    fn overlapping_events_sit_side_by_side() {
1701        let event = |from: i32, to: i32| AgendaEvent {
1702            key: String::new(),
1703            uid: String::new(),
1704            start: 0,
1705            end: 0,
1706            when: String::new(),
1707            summary: String::new(),
1708            location: String::new(),
1709            notes: String::new(),
1710            calendar: String::new(),
1711            color: None,
1712            recurring: false,
1713            cancelled: false,
1714            tentative: false,
1715            read_only: false,
1716            organizer: String::new(),
1717            attendees: Vec::new(),
1718            all_day: false,
1719            from_minute: from,
1720            to_minute: to,
1721            column: 9,
1722            columns: 9,
1723        };
1724        // 9:00–10:00 and 9:30–11:00 overlap; 10:00–10:30 takes the first column again; 14:00 is alone.
1725        let mut day = vec![event(540, 600), event(570, 660), event(600, 630), event(840, 900)];
1726        lay_out(&mut day);
1727        let placed: Vec<(u32, u32)> = day.iter().map(|e| (e.column, e.columns)).collect();
1728        assert_eq!(placed, [(0, 2), (1, 2), (0, 2), (0, 1)]);
1729    }
1730
1731    #[test]
1732    fn a_folder_shows_two_weeks_and_says_what_is_unread() {
1733        let now = 1_790_000_000;
1734        let card = |subject: &str, days_ago: i64, file: &str| {
1735            let raw = format!("From: Jane <jane@example.org>\r\nTo: Me <me@example.net>\r\nSubject: {subject}\r\n\r\nHi.\r\n");
1736            let mut card = Card::from_bytes(raw.as_bytes()).unwrap();
1737            card.date = Some(now - days_ago * 86_400);
1738            card.path = Some(std::path::PathBuf::from(file));
1739            card
1740        };
1741        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")];
1742        let account = Account::imap("me", "me@example.net", "imap.example.net", 993, crate::config::Security::Tls, None);
1743        let inbox = crate::folders::folder("INBOX", Some("."), None);
1744        let tr = Translator::new("en");
1745        let view = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), false, "", &tr, now, None);
1746        assert_eq!(view.items.iter().map(|i| i.subject.as_str()).collect::<Vec<_>>(), ["Lease", "Read"]);
1747        assert!(view.earlier && view.items[0].unread && view.items[1].flagged && !view.items[1].unread);
1748        assert_eq!(view.sentence, "One message you have not read.");
1749        let everything = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), true, "", &tr, now, None);
1750        assert_eq!(everything.items.len(), 3);
1751        assert!(!everything.earlier);
1752        let searched = folder(&account, &inbox, cards.clone(), &[], crate::words::Words::builtin_ref(), false, "old", &tr, now, None);
1753        assert_eq!(searched.items.len(), 1);
1754        let sent = crate::folders::folder("Sent", Some("."), Some(Role::Sent));
1755        let view = folder(&account, &sent, cards, &[], crate::words::Words::builtin_ref(), false, "", &tr, now, None);
1756        assert_eq!(view.items[0].who, "To Me");
1757        assert!(view.items.iter().all(|i| !i.unread && i.trust_level == "own"));
1758    }
1759
1760    #[test]
1761    fn conversations_bring_your_answers() {
1762        let now = 1_790_000_000;
1763        let card = |raw: &str, hours_ago: i64, file: &str| {
1764            let mut card = Card::from_bytes(raw.replace('\n', "\r\n").as_bytes()).unwrap();
1765            card.date = Some(now - hours_ago * 3600);
1766            card.path = Some(std::path::PathBuf::from(file));
1767            card
1768        };
1769        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");
1770        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");
1771        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");
1772        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");
1773        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");
1774        let account = Account::imap("me", "me@example.net", "imap.example.net", 993, crate::config::Security::Tls, None);
1775        let inbox = crate::folders::folder("INBOX", Some("."), None);
1776        let tr = Translator::new("en");
1777        let view = folder(&account, &inbox, vec![question, thanks, other], &[], crate::words::Words::builtin_ref(), false, "", &tr, now, Some(vec![answer, unrelated]));
1778        let rows: Vec<(&str, usize, bool)> = view.items.iter().map(|i| (i.subject.as_str(), i.size, i.member)).collect();
1779        // The lease first (its last message is the newest), with its question and your answer under it, oldest first.
1780        assert_eq!(rows, [("Re: Lease", 3, false), ("Lease", 1, true), ("Re: Lease", 1, true), ("Lunch", 1, false)]);
1781        assert_eq!(view.items[2].who, "You, to Jane");
1782        assert_eq!(view.items[2].trust_level, "own");
1783        assert!(view.items[0].unread, "a conversation is unread while one of its messages is");
1784        assert!(view.items.iter().all(|i| !i.subject.starts_with("Hi")), "your other mail stays in Sent");
1785    }
1786
1787    #[test]
1788    fn the_shield_says_each_check() {
1789        let results = AuthResults {
1790            authserv_id: "sioul-0123.invalid".into(),
1791            dkim: Some(Outcome::Pass),
1792            dkim_domain: Some("shop.example".into()),
1793            spf: Some(Outcome::SoftFail),
1794            spf_domain: Some("mail.shop.example".into()),
1795            dmarc: Some(Outcome::Pass),
1796            dmarc_domain: Some("shop.example".into()),
1797            dmarc_policy: Some("reject".into()),
1798            iprev: Some(Outcome::Pass),
1799            ..AuthResults::default()
1800        };
1801        assert_eq!(
1802            checks(Some(&results), &Translator::new("en")),
1803            "Checked by Sioul when the message arrived:\nDKIM: valid (shop.example)\nSPF: doubtful (mail.shop.example)\n\
1804             DMARC: valid (shop.example, policy reject)\nReverse DNS: valid"
1805        );
1806        let provider = AuthResults { authserv_id: "mx.example.net".into(), dkim: Some(Outcome::Fail), ..AuthResults::default() };
1807        assert_eq!(checks(Some(&provider), &Translator::new("fr")), "Vérifié par votre fournisseur (mx.example.net)\u{202f}:\nDKIM\u{202f}: échoue");
1808        assert_eq!(checks(None, &Translator::new("en")), "No check recorded: nothing proves who sent it.");
1809    }
1810
1811    /// Sioul's own spam filter's word, beside a message in the review queue,
1812    /// never why; "Spam" and "Not spam" there; "Not spam" for mail set aside
1813    /// as spam; nothing for the rest.
1814    #[test]
1815    fn the_filters_word_and_not_spam() {
1816        let tr = Translator::new("en");
1817        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();
1818        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 };
1819        // In the review queue: its word, Spam and Not spam; never why, nor how sure.
1820        let maybe = item_view(&triaged(Lane::Review, vec![Reason::Trust(trust::Proof::NoResults), Reason::Unsure { p: 0.62 }]), None, &tr);
1821        assert!(maybe.spam && maybe.review && !maybe.moved);
1822        assert_eq!(maybe.spam_word, "maybe spam");
1823        assert_eq!(maybe.reasons.last().map(String::as_str), Some("your own filter: maybe spam"));
1824        let moved = item_view(&triaged(Lane::Review, vec![Reason::LearnedSpam { p: 0.97 }, Reason::MovedToJunk]), None, &tr);
1825        assert_eq!((moved.spam, moved.moved, moved.spam_word.as_str()), (true, true, "probably spam"));
1826        assert!(moved.reasons.iter().all(|r| !r.contains('%')), "{:?}", moved.reasons);
1827        let wanted = item_view(&triaged(Lane::Review, vec![Reason::LearnedHam { p: 0.1 }]), None, &tr);
1828        assert_eq!(wanted.spam_word, "probably not spam");
1829        // Set aside by your provider: "Not spam", no word of your filter's.
1830        let aside = item_view(&triaged(Lane::SetAside, vec![Reason::Spam { source: "rspamd", score: None }]), None, &tr);
1831        assert_eq!((aside.spam, aside.review, aside.spam_word.as_str()), (true, false, ""));
1832        // Nothing said of it: nothing offered.
1833        let plain = item_view(&triaged(Lane::Screener, vec![Reason::FirstMessage]), None, &tr);
1834        assert_eq!((plain.spam, plain.review, plain.spam_word.as_str()), (false, false, ""));
1835    }
1836}