iOS is the one client none of the mail autoconfiguration reaches: Apple Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so adding an account means the device probing for ports - and a probe that meets a filtered port waits out a TCP timeout and reports the account as unverifiable, which the person reads as a wrong password. A profile states the hosts and the ports instead, and carries mail, calendars and contacts in one install. It invents none of them: site.yaml names a PACC document - the standard defined to be fetched from the mail domain itself - and every host, port and collection path comes from there, so the server is described in one place and the profile follows it. The document holds no address, username or password. iOS asks for those at install, which means one profile serves everyone with an account and it can be fetched by anyone: it says no more than PACC already says in public. Identifiers are derived from the site and the payload rather than drawn at random, because iOS replaces a profile whose ids it knows and stacks a duplicate when they differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
129 lines
4.4 KiB
Rust
129 lines
4.4 KiB
Rust
//! An alternative as the visitor meets it, in text.
|
|
//!
|
|
//! Portal renders a card: the heading, the description, a button, one
|
|
//! or two encouragements, and for each section an icon, a title, a
|
|
//! line of guidance and the fields with their labels and placeholders.
|
|
//! A visitor is oriented by all of it, not the heading alone. The
|
|
//! answering visitor and `rate` both read this rendering, so the
|
|
//! scored page is the served page; the gutted twin keeps the card's
|
|
//! shape and none of its words.
|
|
|
|
use serde_json::Value;
|
|
|
|
pub fn s(v: &Value) -> &str {
|
|
v.as_str().unwrap_or_default()
|
|
}
|
|
|
|
/// One option on a page. `full` is the card as rendered; otherwise the
|
|
/// heading alone, which is what a visitor who reads nothing else sees.
|
|
pub fn alternative(alt: &Value, full: bool) -> String {
|
|
let mut out = format!("- {}", s(&alt["name"]));
|
|
if !full {
|
|
out.push('\n');
|
|
return out;
|
|
}
|
|
let d = s(&alt["description"]).trim();
|
|
if !d.is_empty() {
|
|
out.push_str(&format!(": {d}"));
|
|
}
|
|
out.push('\n');
|
|
if let Some(button) = alt["consequence"]
|
|
.as_array()
|
|
.and_then(|c| c.first())
|
|
.and_then(Value::as_str)
|
|
{
|
|
out.push_str(&format!(" Button: {button}\n"));
|
|
}
|
|
for e in alt["encouragements"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(Value::as_str)
|
|
{
|
|
out.push_str(&format!(" {e}\n"));
|
|
}
|
|
for f in alt["features"].as_array().into_iter().flatten() {
|
|
let (name, desc, icon) = (
|
|
s(&f["name"]).trim(),
|
|
s(&f["description"]).trim(),
|
|
s(&f["icon"]).trim(),
|
|
);
|
|
let mut line = String::from(" ");
|
|
if !icon.is_empty() {
|
|
line.push_str(&format!(
|
|
"[icon {}] ",
|
|
icon.rsplit(':').next().unwrap_or(icon)
|
|
));
|
|
}
|
|
if !name.is_empty() {
|
|
line.push_str(name);
|
|
}
|
|
if !desc.is_empty() {
|
|
line.push_str(&format!(
|
|
"{}{desc}",
|
|
if name.is_empty() { "" } else { ": " }
|
|
));
|
|
}
|
|
if !line.trim().is_empty() {
|
|
out.push_str(line.trim_end());
|
|
out.push('\n');
|
|
}
|
|
let fields: Vec<String> = f["requirements"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|r| {
|
|
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
|
let hint = s(&r["placeholder"]).trim();
|
|
let opt = if r["optional"] == true {
|
|
" (optional)"
|
|
} else {
|
|
""
|
|
};
|
|
if hint.is_empty() {
|
|
format!("{label}{opt}")
|
|
} else {
|
|
format!("{label}{opt} e.g. {hint}")
|
|
}
|
|
})
|
|
.collect();
|
|
if !fields.is_empty() {
|
|
out.push_str(&format!(" Asks for: {}\n", fields.join("; ")));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The page around its options: what it asks and says of itself.
|
|
pub fn page_head(page: &Value) -> String {
|
|
let mut out = format!("{}\n", s(&page["name"]));
|
|
let d = s(&page["description"]).trim();
|
|
if !d.is_empty() {
|
|
out.push_str(&format!("{d}\n"));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() {
|
|
let alt = json!({
|
|
"name": "Get a price", "description": "For owners.",
|
|
"consequence": ["Send it"], "encouragements": ["We answer within two days."],
|
|
"features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.",
|
|
"requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"},
|
|
{"name": "address", "label": "Address", "optional": true}]}]
|
|
});
|
|
let full = alternative(&alt, true);
|
|
assert!(full.starts_with("- Get a price: For owners.\n"));
|
|
assert!(full.contains("Button: Send it") && full.contains("We answer within two days."));
|
|
assert!(full.contains("[icon file-text] Your brief: A few lines are enough."));
|
|
assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)"));
|
|
assert_eq!(alternative(&alt, false), "- Get a price\n");
|
|
}
|
|
}
|