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