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    /// "Show all mail now" pressed on this device, until "Done for now": set by the window.
50    pub all: bool,
51}
52
53#[derive(Debug, Serialize)]
54pub struct CodeView {
55    pub key: String,
56    /// "Code from La Banque Exemple (verified)".
57    pub title: String,
58    /// The code itself, when the message prints one.
59    pub code: Option<String>,
60    /// "valid about 10 minutes".
61    pub validity: Option<String>,
62    /// For a sender that is not verified: use it only if you just asked for it.
63    pub warning: Option<String>,
64}
65
66#[derive(Debug, Serialize)]
67pub struct LaneView {
68    pub key: String,
69    pub title: String,
70    /// Filed and set-aside mail start folded (docs/porch.md).
71    pub folded: bool,
72    /// Its count said beside its title; never for the review queue.
73    pub counted: bool,
74    /// What the lane holds, in one short line, shown under its title.
75    pub about: String,
76    /// How mail lands here, sentence by sentence, behind the lane's "?".
77    pub rules: Vec<String>,
78    /// The settings that change it, by page name (`settings::for_view`).
79    pub settings: String,
80    pub items: Vec<ItemView>,
81}
82
83#[derive(Debug, Serialize)]
84pub struct ItemView {
85    /// The file, to open the message.
86    pub key: String,
87    pub account: Option<String>,
88    pub sender: String,
89    pub address: String,
90    /// "verified", "not verified", "forged", in your language.
91    pub trust: String,
92    /// The same, as a fixed word for the interface's colours.
93    pub trust_level: &'static str,
94    pub subject: String,
95    pub date: String,
96    pub preview: String,
97    /// Every reason, in order; the last one chose the lane.
98    pub reasons: Vec<String>,
99    pub attachments: Vec<String>,
100    /// From someone new: the sender can be let in.
101    pub screener: bool,
102    /// Called spam: set aside as spam, or in the review queue. "Not spam"
103    /// puts it back in its lane (from a Junk folder, into the inbox), for good.
104    pub spam: bool,
105    /// In the review queue: "Spam" and "Not spam" beside it.
106    pub review: bool,
107    /// Your own filter moved it into a Junk folder: "Not spam" brings it back to the inbox.
108    pub moved: bool,
109    /// Your own filter's quiet word beside it in the review queue: "probably
110    /// spam", "maybe spam", "probably not spam"; "" otherwise. Never why.
111    pub spam_word: String,
112    /// From an account ranked above the others.
113    pub important: bool,
114    /// What each check said, one per line: the shield's tooltip.
115    pub checks: String,
116    /// For a shielded address: what it is about ("work"), its tone ("calm", "rude", "hostile").
117    pub topic: String,
118    pub tone: String,
119    /// Its words are not shown (hostile): no sender's name, subject or text until you ask.
120    pub hidden: bool,
121    /// What it asks, in one neutral line, when an AI read it.
122    pub summary: String,
123}
124
125/// The Porch as the window shows it. `opened_anyway` is your "open it anyway"
126/// outside a window; codes show either way.
127pub 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            // Mail of the accounts ranked above first, then work for a public address, then the newest.
153            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            // A folded lane opens by itself when it holds mail of an account ranked above; hostile mail never does.
156            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
166/// A lane of the Porch, empty or not: what it holds and how mail lands there.
167pub struct LaneInfo {
168    /// `people`, `project:<id>`, `public:<account>`.
169    pub key: String,
170    pub title: String,
171    pub lane: Lane,
172    /// Filed, less important and set-aside mail start folded (docs/porch.md).
173    pub folded: bool,
174    /// What it holds, in one short line.
175    pub about: String,
176    /// How mail lands there, sentence by sentence.
177    pub rules: Vec<String>,
178    /// Its count said beside its title: every lane's but the review queue's.
179    pub counted: bool,
180}
181
182/// Every lane mail can land in, in the order the Porch shows them: projects,
183/// public addresses, people, the screener, filed and less important mail,
184/// what waits for your review, what is set aside, hostile mail when an
185/// address is shielded.
186pub 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    // What your own spam filter flagged or moved, as you chose for each of
223    // its verdicts: folded, never counted, never notified.
224    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
255/// A project's routes, each in a sentence.
256fn 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
286/// The verified codes among `items`: what a notification says.
287pub 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    /// The code and how long it lasts, on one line: "482913, valid about 10
293    /// minutes"; under it, the warning when its sender is not verified.
294    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    // Sioul's own filter's word on it, in the review queue.
319    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    // Hostile mail keeps its words to itself: a sender's name can insult too.
350    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
366/// What each check said, one per line: who checked, then DKIM, SPF, DMARC with
367/// the domain's policy, ARC and the reverse DNS, each with the domain it was for.
368pub 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
432/// A message's date, short, in your language and time zone.
433pub 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    /// Messages about money, newest first; those you said are not payments are left out.
447    pub mail: Vec<MailRow>,
448    /// The budgets a line can go to.
449    pub choices: Vec<Choice>,
450}
451
452/// One figure of a card: a label and a value, on their own line.
453#[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    /// "monthly", "mensuel".
464    pub period: String,
465    /// "better", "as-planned", "short", for the interface's colours and icons.
466    pub mood: &'static str,
467    /// The verdict, at the period's pace: "On track", "Ahead of its pace by €40".
468    pub verdict: String,
469    /// "10 % of October gone; 12 % of what it still needs has come in."
470    pub pace: String,
471    pub figures: Vec<Figure>,
472    /// What is kept for later, and what a reserve does at the end of the period.
473    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    /// "received", "paid", "order", "bill", "refund".
488    pub kind: PaymentKind,
489    pub kind_label: String,
490    /// Who paid or was paid.
491    pub party: String,
492    pub subject: String,
493    pub sender: String,
494    /// "+€12.00", "−€387.00"; empty when the message gives no amount.
495    pub amount: String,
496    pub credit: bool,
497    /// The budget its rule gives it, else none: you choose.
498    pub budget: Option<String>,
499    /// "recorded", "duplicate", "proposed".
500    pub state: &'static str,
501    /// What becomes of it, in a few words.
502    pub note: String,
503    /// From someone new and not verified: to check before counting.
504    pub doubtful: bool,
505    /// It can be added: proposed, with an amount.
506    pub can_add: bool,
507}
508
509#[derive(Debug, Serialize)]
510pub struct Choice {
511    pub id: String,
512    pub title: String,
513}
514
515/// Budgets, reserves and the mail about money, itemized: each figure on its own
516/// line, the verdict apart.
517pub 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
531/// "10 % of the month gone; 12 % of what it still needs has come in.", or what it may spend.
532fn 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    // Judged at its pace: what simply happens is weighed against the part of the period gone.
577    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/// One movement of a budget, as its ledger lists it.
605#[derive(Debug, Serialize)]
606pub struct MovementRow {
607    /// Its address for links, when it is a line of the file.
608    pub uri: String,
609    pub date: String,
610    /// The same, for its form: "2026-10-03", and the amount in units.
611    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/// The balance at one point, for the graph.
621#[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/// A budget opened: its ledger for a period, and its balance as a graph.
630#[derive(Debug, Serialize)]
631pub struct BudgetDetail {
632    pub card: BudgetCard,
633    /// "October 2026", "2026".
634    pub period_title: String,
635    /// The first day of the period shown, to move from.
636    pub from: String,
637    pub movements: Vec<MovementRow>,
638    pub money_in: String,
639    pub money_out: String,
640    /// "day", "week", "month", "year".
641    pub step: String,
642    pub points: Vec<BalancePoint>,
643    pub low_cents: i64,
644    pub high_cents: i64,
645    pub target_cents: i64,
646    /// Leisure money: it stays in view in quiet time.
647    pub personal: bool,
648    /// What it is for (docs/areas.md): "work", "admin", "leisure", joined by "+"; "" for your admin, unsaid.
649    pub area: String,
650    /// "month" or "year", for its form.
651    pub period_kind: String,
652}
653
654/// A budget's ledger for the period around `anchor`, and its balance by `step`.
655pub 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                // Its short name: three letters would make "jui" of both juin and juillet.
674                "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        // Another period: its movements only, no pace.
686        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        // "personal" was the word for leisure money before areas had three.
716        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            // A number, for its plural: "about a month", "about 16 months".
736            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    // How long money asked from it takes to arrive, when it does not come at once.
744    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    /// "imap", "portal".
808    pub kind: &'static str,
809    pub address: Option<String>,
810    pub url: Option<String>,
811    /// Server, sender checks, where the mail is kept: one per line.
812    pub rows: Vec<Figure>,
813    /// The last thing sync said, given by the caller.
814    pub status: String,
815    /// That last thing was a problem.
816    pub status_error: bool,
817    /// "above", "average", "below".
818    pub priority: &'static str,
819    /// Signed in with Google (its tokens in the keyring): it can be signed in again.
820    pub google: bool,
821    /// Signed in with Microsoft (its tokens in the keyring): it can be signed
822    /// in again, and asks no password (docs/microsoft.md).
823    pub microsoft: bool,
824    /// A personal Microsoft account (Outlook.com…), rather than a work or school one.
825    pub microsoft_personal: bool,
826    /// Its server is Microsoft's: signed in with Microsoft, or added with a
827    /// password Microsoft no longer takes, which "Sign in with Microsoft" replaces.
828    pub at_microsoft: bool,
829    /// Its server, for its password's login in a vault (Bitwarden).
830    pub host: Option<String>,
831    /// The name it logs in with (`Account::login`: its user name, else its
832    /// address): the user name its login in a vault is found by (Bitwarden).
833    pub login: Option<String>,
834    /// No password kept on this device, or its server refused it: one can be
835    /// given (an account come from another device arrives without one).
836    pub password_wanted: bool,
837    /// Your name as recipients see it, and the signature, in Markdown.
838    pub name: String,
839    pub signature: String,
840    /// Switched on: synced and shown.
841    pub enabled: bool,
842    /// Who it is: its address (lowercase), else its id. One card per identity,
843    /// each of its services (mail, calendars and contacts, Google) on it.
844    pub identity: String,
845    /// "mail", "dav", "google".
846    pub service: &'static str,
847}
848
849/// The accounts of the configuration, those switched off too (sites are not
850/// accounts: docs/sites.md), with what sync last said about each (`status`:
851/// the sentence, and whether it is a problem) and those whose password is
852/// wanted (`wanted`, by id).
853pub 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            // Google's calendars: no server of theirs to name. Its mail, signed in with
860            // Google too, has its IMAP server like any other.
861            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            // Signed in with Microsoft: how, and as which kind of account.
873            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                // Signed in with Google or Microsoft: no password; at Microsoft, none would work.
915                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    /// `mid:<Message-ID>`, to follow and make links (`links`); empty without one.
935    pub uri: String,
936    pub from_name: String,
937    pub from_address: String,
938    pub to: Vec<String>,
939    pub cc: Vec<String>,
940    /// The long date: "Thursday 1 October at 12:03".
941    pub date: String,
942    pub subject: String,
943    pub attachments: Vec<AttachmentView>,
944    /// Mail set aside shows its attachments' names, and does not open them.
945    pub can_open: bool,
946    /// The text in parts, each also as rich text with clickable links.
947    pub parts: Vec<PartView>,
948    /// The HTML version made safe, shown instead of the parts when there is one.
949    pub html: Option<Html>,
950    /// What each check said, one per line: the shield's tooltip.
951    pub checks: String,
952    /// What "block" offers: the address, and `@domain`.
953    pub block_address: Option<String>,
954    pub block_domain: Option<String>,
955    /// The sender may be judged from this message: it is not forged and borrows
956    /// no name. A forged message never changes who is safe or blocked.
957    pub sender_judgeable: bool,
958    /// Someone besides the sender got it: "Reply to all" is offered.
959    pub others: bool,
960    pub unread: bool,
961    pub flagged: bool,
962    /// The folder it is in, for the actions that fit there ("Not junk" in the junk).
963    pub role: Option<Role>,
964    /// Encrypted or signed with OpenPGP, and what came of it.
965    pub protection: Option<ProtectionView>,
966    /// Leaving its list in one click, when it names a way out (`unsubscribe::view`); the window fills it.
967    pub unsubscribe: Option<crate::unsubscribe::UnsubscribeView>,
968}
969
970/// A part of a message's text (see `reading::Part`), with its rich text.
971#[derive(Debug, Serialize)]
972pub struct PartView {
973    /// "text", "attribution", "quote", "headers", "signature".
974    pub kind: &'static str,
975    pub depth: u8,
976    pub text: String,
977    /// The text as HTML, its links clickable.
978    pub rich: String,
979    /// For "headers": name, value, name, value…
980    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    /// "1.2 MB", "202 Ko".
1001    pub size: String,
1002    /// "pdf", "image", "text", "document", "archive", "calendar", "other", for the icon.
1003    pub kind: &'static str,
1004}
1005
1006/// A message for the reading pane: who wrote to whom, the attachments apart, the
1007/// text in parts (see `reading`).
1008/// A message laid out for reading. `own` holds your addresses, to tell whether
1009/// anyone else would get a reply to all.
1010pub 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
1014/// The same from the message's bytes (decrypted, they live only in memory),
1015/// with what OpenPGP found when it was protected.
1016pub 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    // A signature, or the encrypted payload, is not something to open.
1019    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        // A message nothing authenticates says nothing of the address it shows: no list changes from it.
1064        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/// What OpenPGP found, said in a line next to the sender.
1075#[derive(Debug, Serialize)]
1076pub struct ProtectionView {
1077    pub encrypted: bool,
1078    /// "Encrypted · Signed by Jane <jane@example.org>"; "No key of yours opens it."
1079    pub line: String,
1080    /// Everything checked out; else the line is said in warm colours, never red.
1081    pub fine: bool,
1082    /// Encrypted to your security key and not opened yet: "Open with your security key" is offered.
1083    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                // A phone, until it can use the key: said plainly, no button.
1095                "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    // Waiting for your security key is nothing wrong.
1105    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/// An account in the mail page's left column.
1125#[derive(Debug, Serialize)]
1126pub struct MailAccountView {
1127    pub id: String,
1128    pub title: String,
1129    /// Something unread in its inbox: a small dot, never a number (docs/client.md, rule 1).
1130    pub unread: bool,
1131    /// A work address in quiet time: folded, without dots, there if you look for it.
1132    pub resting: bool,
1133    /// Inbox, drafts, sent, archive, junk, trash: the ones the server has.
1134    pub folders: Vec<FolderEntry>,
1135    /// The other folders, folded under "More folders".
1136    pub more: Vec<FolderEntry>,
1137}
1138
1139#[derive(Debug, Serialize)]
1140pub struct FolderEntry {
1141    /// The server's name, to open it.
1142    pub name: String,
1143    pub title: String,
1144    pub role: Role,
1145    pub icon: &'static str,
1146    pub unread: bool,
1147    /// A copy is kept here; else it stays on the server only.
1148    pub kept: bool,
1149}
1150
1151/// The accounts and their folders, ranked as their mail is: above, average, below.
1152/// Views of other folders are not listed, but Gmail's "All Mail", its archive.
1153/// The mail page's accounts; `hours` what now is for (None: no hours set): an
1154/// address whose area does not fit it rests, and every address while you
1155/// sleep (docs/areas.md).
1156pub 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            // An address whose area is unsaid is work's.
1164            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            // "All Mail" stands for the archive where there is none (Gmail).
1167            let has_archive = folders.iter().any(|f| f.role == Role::Archive);
1168            // Two folders for one purpose ("Archive" and "Archives") each keep their own name.
1169            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                // The junk and the trash never call for attention; work mail neither, in quiet time.
1183                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
1201/// `[Gmail]/Important` → `Important`: the provider's prefix says nothing.
1202fn 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
1206/// Days a folder shows before "Earlier" when the configuration says nothing (docs/client.md, rule 7).
1207pub const RECENT_DAYS: i64 = 14;
1208
1209/// A folder's messages, newest first: the last two weeks, or everything kept on request.
1210#[derive(Debug, Serialize)]
1211pub struct FolderView {
1212    pub account: String,
1213    pub folder: String,
1214    pub title: String,
1215    pub role: Role,
1216    /// What is there, in words, said when you open it.
1217    pub sentence: String,
1218    pub items: Vec<MailItem>,
1219    /// Older messages are kept: "Earlier" shows them.
1220    pub earlier: bool,
1221}
1222
1223#[derive(Debug, Clone, Serialize)]
1224pub struct MailItem {
1225    /// The file, to open the message.
1226    pub key: String,
1227    /// Who wrote; in Sent and Drafts, to whom.
1228    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    /// "verified", "unverified", "forged"; "own" for what you sent.
1237    pub trust_level: &'static str,
1238    /// The same in your language; empty for what you sent.
1239    pub trust: String,
1240    pub checks: String,
1241    /// Shown by conversation: the conversation, the same for all its rows; empty otherwise.
1242    pub thread: String,
1243    /// On a conversation's first row: how many messages it holds.
1244    pub size: usize,
1245    /// A row inside a conversation, under its first; folded until it is opened.
1246    pub member: bool,
1247}
1248
1249/// Messages by conversation: each one's newest message of the folder first, the
1250/// conversations by their last message (yours included), then the rest of each,
1251/// oldest first, your answers from Sent among them. A conversation shows when
1252/// its first row would.
1253fn 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    // Conversations with a message of this folder, the newest activity first.
1262    let mut ordered: Vec<(i64, usize, Vec<usize>)> = by_group
1263        .into_iter()
1264        .filter_map(|(_, members)| {
1265            // Cards are newest first: the folder's first one is the head.
1266            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/// One message as a list shows it, in a folder for `role`: who wrote (to whom,
1292/// in Sent and Drafts), what, when, its marks (`flags`, Maildir's letters),
1293/// and how far its sender is verified, as the Porch judges it. `own`: one of
1294/// your answers from Sent, shown in a conversation. Its key is its file, when
1295/// it has one (a search gives a message seen on its server only its own).
1296/// `words`: what the codes detector looks for, as the Porch reads it.
1297#[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        // As the Porch judges it: a signature counts for the sender's own domain only,
1305        // and a list excuses a DMARC failure, never for a code (`porch::triage`).
1306        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        // In Sent, Drafts, Junk and Trash, nothing waits to be read.
1330        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/// What a folder shows: `all` past the last two weeks; `query` searches every
1344/// message kept, by sender, recipient and subject. With `sent` (the account's
1345/// Sent folder), messages are shown by conversation, your answers among them.
1346#[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    // The account's history, two weeks unless set; everything kept when it says so.
1349    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
1411/// "812 B", "202 KB", "1.2 MB", in your language's units and decimals.
1412pub 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
1423/// Days the agenda shows by default: today, then the next two weeks (docs/client.md, rule 9).
1424pub const AGENDA_DAYS: i64 = 15;
1425
1426/// What comes, day by day.
1427#[derive(Debug, Serialize)]
1428pub struct AgendaView {
1429    pub days: Vec<AgendaDay>,
1430    /// "Nothing planned in the next two weeks.", when so.
1431    pub sentence: String,
1432    /// The first day shown, "2026-10-03", to move week by week or month by month.
1433    pub from: String,
1434    /// Whether an event can be added: a calendar that takes it exists.
1435    pub can_add: bool,
1436}
1437
1438#[derive(Debug, Serialize)]
1439pub struct AgendaDay {
1440    /// "Today", "Tomorrow", "Monday 5 October".
1441    pub title: String,
1442    /// "2026-10-05".
1443    pub date: String,
1444    pub today: bool,
1445    /// The day's midnight, Unix seconds, to place what happens in it.
1446    pub start: i64,
1447    pub events: Vec<AgendaEvent>,
1448    /// Its hours given to work or admin, in minutes from midnight; -1 without (`set_hours`).
1449    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    /// When this occurrence starts, Unix seconds: to leave it out of a repeating event.
1458    pub start: i64,
1459    /// When it ends, Unix seconds: a drag in the planning keeps its length.
1460    pub end: i64,
1461    /// "09:00 – 10:30", "All day", "Until 12:00".
1462    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    /// Where it sits in this day's hours, in minutes from midnight: for the week's planning.
1476    pub from_minute: i32,
1477    pub to_minute: i32,
1478    /// Side by side with what overlaps it: its column among `columns`.
1479    pub column: u32,
1480    pub columns: u32,
1481    /// The task it is the time block of, by UID (`blocks`), known here; ""
1482    /// for an event of its own. Shown as a task, never as an event (`agenda::mark_tasks`).
1483    pub task: String,
1484    /// That task's list, by name: what the agenda says instead of a calendar.
1485    pub list: String,
1486    /// Minutes it keeps before and after it, its own (`X-SIOUL-BEFORE`,
1487    /// `X-SIOUL-AFTER`): said in its details, so that no time between events
1488    /// is ever hidden (22 October 2026).
1489    pub before: u32,
1490    pub after: u32,
1491}
1492
1493/// Places a day's timed events side by side where they overlap
1494/// (`dayview::side_by_side`); one shorter than a quarter of an hour takes a
1495/// quarter of an hour, as it is drawn.
1496fn 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
1505/// Each day's hours given to something, work or your admin, in
1506/// minutes from its midnight (docs/areas.md): the planning shows those, and
1507/// the events outside them, rather than the night.
1508pub 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
1519/// The agenda from `from` for `days` days: each day with what it holds; an
1520/// event over several days shows on each of them.
1521pub 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
1579/// An occurrence's time on one day: its hours, "All day", or how it continues.
1580fn 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/// The address book: names first, details when one is opened (docs/client.md, rule 10).
1603#[derive(Debug, Serialize)]
1604pub struct ContactsView {
1605    pub contacts: Vec<ContactRow>,
1606    /// "No contact yet.", "Nothing matches “x”.", when so.
1607    pub sentence: String,
1608    /// Whether a contact can be added: an address book that takes it exists.
1609    pub can_add: bool,
1610    /// The categories the cards have, to filter by: the most used first, each
1611    /// as first written, one name once (`contacts::category_key`).
1612    pub categories: Vec<String>,
1613    /// The category shown; empty: every contact (also when the one asked is gone).
1614    pub category: String,
1615}
1616
1617#[derive(Debug, Serialize)]
1618pub struct ContactRow {
1619    pub key: String,
1620    /// Its address for links: `sioul:contact/<UID>`.
1621    pub uri: String,
1622    pub name: String,
1623    /// The first e-mail address, to write to them.
1624    pub email: String,
1625    /// The first address, else number, else organisation: one line, never more.
1626    pub detail: String,
1627    /// Its picture, as the card gives it (`Contact::photo`).
1628    pub photo: String,
1629    /// Its categories, as the card writes them.
1630    pub categories: Vec<String>,
1631}
1632
1633/// The contacts matching `query`, by name; of one category when `category`
1634/// names one (case and accents aside, as the sender lists compare them).
1635pub 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    /// Mail signed in with Google is mail (its settings, its server), signed
1680    /// in again rather than given a password; Google's calendars stay Google's.
1681    #[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    /// Mail signed in with Microsoft asks no password and says how it signs
1711    /// in; one added with a password at Microsoft's server is offered
1712    /// Microsoft's sign-in instead of a password, none of which would work.
1713    #[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    /// The password dialog looks in a vault for the name an account logs in
1757    /// with: its own user name when it has one, else its address (the review
1758    /// of 5 October 2026 found it prefilled with the address all the same).
1759    #[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    /// The owner's appointment of 21 October 2026 was a task whose time
1785    /// block looked like an event: a block of a task known here says its task
1786    /// and its list; one whose task is gone is an event of its own.
1787    #[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        // 9:00–10:00 and 9:30–11:00 overlap; 10:00–10:30 takes the first column again; 14:00 is alone.
1831        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        // The lease first (its last message is the newest), with its question and your answer under it, oldest first.
1886        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    /// Sioul's own spam filter's word, beside a message in the review queue,
1918    /// never why; "Spam" and "Not spam" there; "Not spam" for mail set aside
1919    /// as spam; nothing for the rest.
1920    #[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        // In the review queue: its word, Spam and Not spam; never why, nor how sure.
1926        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        // Set aside by your provider: "Not spam", no word of your filter's.
1936        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        // Nothing said of it: nothing offered.
1939        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}