Skip to main content

sioul_app/
senders.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Who someone is to you (docs/porch.md, "Who may reach you, and when"): a
5//! message's sender ("Their mail"), and a contact's card ("Their list"),
6//! where they stand and why, and their own choice set or taken back. The
7//! lists and their order are `sioul_core::porch`'s, the states
8//! `sioul_core::reach`'s.
9
10use crate::backend::{load_config, say, tr};
11use sioul_core::porch::{self, By, Judged, Standing};
12use sioul_core::reach::Who;
13
14/// A JSON array of addresses, or one address.
15fn addresses(text: &str) -> Vec<String> {
16    let text = text.trim();
17    let list: Vec<String> = if text.starts_with('[') { serde_json::from_str(text).unwrap_or_default() } else { vec![text.to_string()] };
18    list.into_iter().map(|a| a.trim().to_ascii_lowercase()).filter(|a| !a.is_empty()).collect()
19}
20
21/// The sentence saying who decided: "Safe, as the category Friends says.",
22/// "Neutral: in your address book, on no list.", "A stranger: in none of
23/// your address books, on no list."
24fn said(judged: &Judged) -> String {
25    let one = tr().text(&format!("sender-one-{}", judged.who.id()), None);
26    match judged.by {
27        By::Address | By::Number => say("sender-from-address", &[("list", one)]),
28        By::Card => say("sender-from-card", &[("list", one), ("name", judged.name.clone())]),
29        By::Category => say("sender-from-category", &[("list", one), ("name", judged.name.clone())]),
30        By::Domain | By::Prefix => say("sender-from-domain", &[("list", one), ("name", judged.name.trim_start_matches("*@").to_string())]),
31        By::LetIn => say("sender-from-let-in", &[("list", one)]),
32        By::Book => say("sender-from-book", &[("list", one)]),
33        By::Default => say("sender-from-default", &[("list", one)]),
34    }
35}
36
37/// What decided, as the window reads it: "address", "number", "card", "category", "domain", "prefix", "let-in", "book", "default".
38fn by_id(by: By) -> String {
39    serde_json::to_value(by).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
40}
41
42/// Where a sender stands, and why, for "Their mail" (`addresses`: theirs, a
43/// JSON array, or one; or a caller's number, `tel:…`, judged as calls judge
44/// it): {standing (one of the five states), from (what decided), name (the
45/// card, category or pattern that decided), own (their first address or
46/// number has its own entry), choice (that entry's list, "" when none), said
47/// (a sentence), choices [{value, label}]: "As their categories say" and the
48/// four lists with their mail's times}.
49pub(crate) fn standing_json(text: &str) -> String {
50    let config = load_config();
51    let senders = porch::Senders::load(&config);
52    let attention = sioul_core::attention::Attention::of(&config);
53    let first = addresses(text).into_iter().next().unwrap_or_default();
54    let (judged, own) = match first.strip_prefix(porch::TEL) {
55        Some(number) => {
56            let judged = senders.judge_number(number);
57            let own = judged.by == By::Number;
58            (judged, own)
59        }
60        None => {
61            let judged = senders.judge(&first);
62            let own = judged.by == By::Address;
63            (judged, own)
64        }
65    };
66    let choices: Vec<serde_json::Value> = std::iter::once(serde_json::json!({ "value": "", "label": tr().text("sender-categories", None) }))
67        .chain(Standing::ALL.iter().map(|s| serde_json::json!({ "value": s.as_str(), "label": sioul_core::attention::list_choice(tr(), Who::of(*s), &attention, true) })))
68        .collect();
69    serde_json::json!({
70        "standing": judged.who.id(),
71        "from": by_id(judged.by),
72        "name": judged.name,
73        "own": own,
74        "choice": if own { judged.who.id() } else { "" },
75        "said": said(&judged),
76        "choices": choices,
77    })
78    .to_string()
79}
80
81/// "Their mail" for every address given: on one list ("safe", "neutral",
82/// "restricted", "blocked") and out of the others, or ("") out of every list,
83/// their card, its categories, else their domain, deciding. Returns the status line.
84pub(crate) fn set_standing_of(text: &str, standing: &str) -> String {
85    let config = load_config();
86    let list = addresses(text);
87    let Some(first) = list.first().cloned() else { return String::new() };
88    let wanted = Standing::read(standing);
89    if wanted.is_none() && !standing.is_empty() {
90        return String::new();
91    }
92    for address in &list {
93        let done = match wanted {
94            Some(standing) => porch::set_standing(&config, address, standing),
95            None => porch::clear_standing(&config, address),
96        };
97        if let Err(e) = done {
98            return e;
99        }
100    }
101    let entry = porch::normalize(&first).unwrap_or(first).trim_start_matches("*@").to_string();
102    match wanted {
103        Some(standing) => say(&format!("sender-now-{}", standing.as_str()), &[("entry", entry)]),
104        None => say("sender-now-categories", &[("entry", entry)]),
105    }
106}
107
108/// The card with this file or UID, from every address book.
109fn card(key: &str) -> Option<sioul_core::contacts::Contact> {
110    let key = key.trim();
111    (!key.is_empty()).then(|| sioul_core::contacts::all().into_iter().find(|c| c.key == key || c.uid.trim() == key)).flatten()
112}
113
114/// A contact's list, for its card ("Their list"; `key`: its file or UID):
115/// {who (one of the five states), from (what decided), choice (the card's own
116/// list, "" when none), said (a sentence), own [lines: an address or a
117/// number of theirs with a list of its own, which comes before the card's],
118/// choices [{value, label}]: "As their categories say" and the four lists}.
119/// "null" for a card no address book holds.
120pub(crate) fn person_json(key: &str) -> String {
121    let config = load_config();
122    let senders = porch::Senders::load(&config);
123    let Some(card) = card(key) else { return String::from("null") };
124    let judged = senders.judge_card(&card.key).unwrap_or_else(Judged::stranger);
125    // Their own choice: the card's line, else the one list all their addresses are on ("Their mail" before).
126    let choice = senders.person_standing(&card.key).map_or("", |s| s.as_str());
127    // Their addresses and numbers with an entry of their own that says otherwise: it comes first.
128    let own: Vec<String> = card
129        .emails
130        .iter()
131        .map(|e| senders.judge(e.value.trim().trim_start_matches("mailto:")))
132        .chain(card.phones.iter().map(|p| senders.judge_number(&p.value)))
133        .filter(|j| matches!(j.by, By::Address | By::Number) && j.who != judged.who)
134        .map(|j| say("sender-own-entry", &[("entry", j.name.clone()), ("list", tr().text(&format!("sender-one-{}", j.who.id()), None))]))
135        .collect();
136    // On their own card, their own choice is theirs, not "as you chose for" them.
137    let sentence = if judged.by == By::Card { say("sender-from-address", &[("list", tr().text(&format!("sender-one-{}", judged.who.id()), None))]) } else { said(&judged) };
138    let choices: Vec<serde_json::Value> = std::iter::once(serde_json::json!({ "value": "", "label": tr().text("sender-categories", None) }))
139        .chain(Standing::ALL.iter().map(|s| serde_json::json!({ "value": s.as_str(), "label": tr().text(&format!("sender-one-{}", s.as_str()), None) })))
140        .collect();
141    serde_json::json!({
142        "who": judged.who.id(),
143        "from": by_id(judged.by),
144        "choice": choice,
145        "said": sentence,
146        "own": own,
147        "choices": choices,
148    })
149    .to_string()
150}
151
152/// A contact's list, for all their addresses and numbers (`key`: the card's
153/// file or UID): their card on one list ("safe", "neutral", "restricted",
154/// "blocked"), or ("") on none, their categories deciding; the own entries
155/// of their addresses and numbers taken out. Returns the status line.
156pub(crate) fn set_person(key: &str, standing: &str) -> String {
157    let config = load_config();
158    let Some(card) = card(key) else { return String::new() };
159    let wanted = Standing::read(standing);
160    if wanted.is_none() && !standing.is_empty() {
161        return String::new();
162    }
163    let addresses: Vec<String> = card.emails.iter().map(|e| e.value.clone()).collect();
164    let numbers: Vec<String> = card.phones.iter().map(|p| p.value.clone()).collect();
165    if let Err(e) = porch::set_person(&config, &card.uid, &addresses, &numbers, wanted) {
166        return e;
167    }
168    let name = if card.name.trim().is_empty() { addresses.first().cloned().unwrap_or_default() } else { card.name.trim().to_string() };
169    match wanted {
170        Some(standing) => say(&format!("person-now-{}", standing.as_str()), &[("name", name)]),
171        None => say("person-now-categories", &[("name", name)]),
172    }
173}
174
175/// An address, a number or a pattern unblocked: out of the blocked list,
176/// written neutral when a card, a category or a domain would still block it.
177/// Returns the status line.
178pub(crate) fn unblock(entry: &str) -> String {
179    let config = load_config();
180    let entry = entry.trim();
181    match porch::unblock(&config, entry) {
182        Ok(()) => say("ui-unblocked", &[("entry", porch::Entry::read(entry, sioul_core::reach::region(&config)).map_or_else(|| entry.to_string(), |e| e.shown()))]),
183        Err(e) => e,
184    }
185}