Skip to main content

hypermail/
templates.rs

1use std::collections::HashMap;
2
3use crate::i18n::I18n;
4
5pub type CookieMap = HashMap<String, String>;
6
7/// Data for original hypermail-style `%x` single-character template substitution,
8/// matching the `printfile()` function in the original hypermail `printfile.c`.
9pub struct PrintfileData<'a> {
10    /// Archive label (%l)
11    pub label: &'a str,
12    /// Message subject text — HTML-escaped where needed (%s, %S)
13    pub subject: &'a str,
14    /// Output directory (%~)
15    pub dir: &'a str,
16    /// Author display name (%A combined with email)
17    pub name: Option<&'a str>,
18    /// Author email address (%e, %A)
19    pub email: Option<&'a str>,
20    /// Message-ID (%i)
21    pub msgid: Option<&'a str>,
22    /// Charset (%c)
23    pub charset: Option<&'a str>,
24    /// ISO date string for meta tag (%D)
25    pub date: Option<&'a str>,
26    /// Human-readable date string for display (%d)
27    pub display_date: Option<&'a str>,
28    /// HTML filename of the current page (%f)
29    pub filename: Option<&'a str>,
30    /// Other-archives URL (%a)
31    pub archives: Option<&'a str>,
32    /// About-archive URL (%b)
33    pub about: Option<&'a str>,
34    /// Mailto address (%m)
35    pub mailto: Option<&'a str>,
36    /// Language code (%G)
37    pub language: &'a str,
38    /// Relative path from current page back to the top-level index (%t)
39    pub rel_path_to_top: &'a str,
40}
41
42const HMURL: &str = "https://www.hypermail-project.org/";
43const PROGNAME: &str = "hypermail-rs";
44const VERSION: &str = env!("CARGO_PKG_VERSION");
45
46/// Perform original hypermail `printfile()`-style `%x` substitution on a template string.
47///
48/// Supported escapes:
49/// - `%%` → `%`
50/// - `\n` → newline, `\t` → tab
51/// - `%~` → output directory
52/// - `%a` → other-archives URL
53/// - `%b` → about-archive URL
54/// - `%c` → charset `<meta>` tag
55/// - `%d` → human-readable date string for display (plain text, no markup)
56/// - `%D` → date `<meta name="Date">` tag (message pages only)
57/// - `%e` → author email address
58/// - `%f` → filename
59/// - `%g` → current date/time string
60/// - `%G` → two-letter language code
61/// - `%h` → hypermail homepage URL
62/// - `%i` → Message-ID
63/// - `%j` → localized "Subject:" label (e.g., "Betreff:" in German, "Θέμα:" in Greek)
64/// - `%k` → localized "Date:" label (e.g., "Datum:" in German, "Ημερομηνία:" in Greek)
65/// - `%l` → archive label
66/// - `%m` → mailto address
67/// - `%n` → author display name (plain text, no markup)
68/// - `%N` → author name + email as linked HTML
69/// - `%p` → program name
70/// - `%s` → subject (HTML-escaped, with stripsubject applied)
71/// - `%S` → subject `<meta name="Subject">` tag (message pages only)
72/// - `%A` → author `<meta name="Author">` tag (message pages only)
73/// - `%t` → relative path to top-level index
74/// - `%u` → expanded version link `<a href=...>progname version</a>`
75/// - `%v` → version string
76/// - `%w` → localized "Generated by" text
77/// - `%y` → localized "Author:" label
78pub fn substitute_printfile(template: &str, data: &PrintfileData<'_>) -> String {
79    let mut result = String::with_capacity(template.len() + 256);
80    let bytes = template.as_bytes();
81    let mut i = 0;
82
83    while i < bytes.len() {
84        let b = bytes[i];
85        if b == b'\\' && i + 1 < bytes.len() {
86            match bytes[i + 1] {
87                b'n' => {
88                    result.push('\n');
89                    i += 2;
90                },
91                b't' => {
92                    result.push('\t');
93                    i += 2;
94                },
95                _ => {
96                    result.push(b as char);
97                    i += 1;
98                },
99            }
100        } else if b == b'%' && i + 1 < bytes.len() {
101            let next = bytes[i + 1] as char;
102            match next {
103                '%' => {
104                    result.push('%');
105                    i += 2;
106                },
107                '~' => {
108                    result.push_str(data.dir);
109                    i += 2;
110                },
111                'a' => {
112                    if let Some(a) = data.archives {
113                        result.push_str(a);
114                    }
115                    i += 2;
116                },
117                'b' => {
118                    if let Some(b_url) = data.about {
119                        result.push_str(b_url);
120                    }
121                    i += 2;
122                },
123                'c' => {
124                    if let Some(cs) = data.charset {
125                        if !cs.is_empty() {
126                            // Untrusted: charset comes from the message's Content-Type header.
127                            result.push_str(&format!(
128                                "<meta http-equiv=\"Content-Type\" content=\"text/html; charset={}\" />\n",
129                                crate::txt2html::escape_html(cs)
130                            ));
131                        }
132                    }
133                    i += 2;
134                },
135                'D' => {
136                    if let Some(date) = data.date {
137                        // Date string is generated from a parsed timestamp, but escape defensively
138                        // in case future callers pass through an unparsed header value.
139                        result.push_str(&format!(
140                            "<meta name=\"Date\" content=\"{}\" />",
141                            crate::txt2html::escape_html(date)
142                        ));
143                    }
144                    i += 2;
145                },
146                'e' => {
147                    if let Some(email) = data.email {
148                        // Untrusted: email address comes from the From: header.
149                        result.push_str(&crate::txt2html::escape_html(email));
150                    }
151                    i += 2;
152                },
153                'f' => {
154                    if let Some(fname) = data.filename {
155                        // Filename is generated from a controlled message_url_str(); escape defensively.
156                        result.push_str(&crate::txt2html::escape_html(fname));
157                    }
158                    i += 2;
159                },
160                'g' => {
161                    result.push_str(&get_local_time_str());
162                    i += 2;
163                },
164                'G' => {
165                    result.push_str(data.language);
166                    i += 2;
167                },
168                'h' => {
169                    result.push_str(HMURL);
170                    i += 2;
171                },
172                'i' => {
173                    if let Some(msgid) = data.msgid {
174                        // Untrusted: Message-ID comes from message headers.
175                        result.push_str(&crate::txt2html::escape_html(msgid));
176                    }
177                    i += 2;
178                },
179                'l' => {
180                    // Label is config-controlled but may be set from a CLI arg; escape defensively.
181                    result.push_str(&crate::txt2html::escape_html(data.label));
182                    i += 2;
183                },
184                'm' => {
185                    if let Some(mailto) = data.mailto {
186                        // Mailto is config-controlled but escape defensively.
187                        result.push_str(&crate::txt2html::escape_html(mailto));
188                    }
189                    i += 2;
190                },
191                'p' => {
192                    result.push_str(PROGNAME);
193                    i += 2;
194                },
195                's' => {
196                    result.push_str(&crate::txt2html::escape_html(data.subject));
197                    i += 2;
198                },
199                'S' => {
200                    result.push_str(&format!(
201                        "<meta name=\"Subject\" content=\"{}\" />",
202                        crate::txt2html::escape_html(data.subject)
203                    ));
204                    i += 2;
205                },
206                'A' => {
207                    if let (Some(name), Some(email)) = (data.name, data.email) {
208                        result.push_str(&format!(
209                            "<meta name=\"Author\" content=\"{} ({})\" />",
210                            crate::txt2html::escape_html(name),
211                            crate::txt2html::escape_html(email)
212                        ));
213                    }
214                    i += 2;
215                },
216                // %n → plain-text author name (no markup)
217                'n' => {
218                    if let Some(name) = data.name {
219                        result.push_str(&crate::txt2html::escape_html(name));
220                    }
221                    i += 2;
222                },
223                // %d → plain-text date string for display (human-readable, no markup)
224                'd' => {
225                    if let Some(date) = data.display_date {
226                        result.push_str(&crate::txt2html::escape_html(date));
227                    }
228                    i += 2;
229                },
230                // %y → i18n-translated "Author:" label (language-aware)
231                'y' => {
232                    let i18n = I18n::new(data.language);
233                    result.push_str(i18n.get("Author"));
234                    i += 2;
235                },
236                // %j → i18n-translated "Subject:" label (language-aware)
237                'j' => {
238                    let i18n = I18n::new(data.language);
239                    result.push_str(i18n.get("Subject"));
240                    i += 2;
241                },
242                // %k → i18n-translated "Date:" label (language-aware)
243                'k' => {
244                    let i18n = I18n::new(data.language);
245                    result.push_str(i18n.get("Date"));
246                    i += 2;
247                },
248                // %w → i18n-translated "Generated by" text (language-aware)
249                'w' => {
250                    let i18n = I18n::new(data.language);
251                    result.push_str(i18n.get("Generated by"));
252                    i += 2;
253                },
254                // %N → author name + email as linked HTML: Name &lt;<a href="mailto:addr">addr</a>&gt;
255                'N' => {
256                    match (data.name, data.email) {
257                        (Some(name), Some(addr)) if !addr.is_empty() => {
258                            let esc_name = crate::txt2html::escape_html(name);
259                            let esc_addr = crate::txt2html::escape_html(addr);
260                            result.push_str(&format!(
261                                "{} &lt;<a href=\"mailto:{}\">{}</a>&gt;",
262                                esc_name, esc_addr, esc_addr
263                            ));
264                        },
265                        (Some(name), _) => {
266                            result.push_str(&crate::txt2html::escape_html(name));
267                        },
268                        _ => {},
269                    }
270                    i += 2;
271                },
272                't' => {
273                    result.push_str(data.rel_path_to_top);
274                    i += 2;
275                },
276                'v' => {
277                    result.push_str(VERSION);
278                    i += 2;
279                },
280                'u' => {
281                    result.push_str(&format!("<a href=\"{}\">{} {}</a>", HMURL, PROGNAME, VERSION));
282                    i += 2;
283                },
284                _ => {
285                    result.push('%');
286                    result.push(next);
287                    i += 2;
288                },
289            }
290        } else {
291            // Correctly advance through multi-byte UTF-8 sequences.
292            // `b as char` would silently produce Latin-1 mojibake for non-ASCII bytes.
293            let ch = template[i..].chars().next().unwrap_or('\u{FFFD}');
294            result.push(ch);
295            i += ch.len_utf8();
296        }
297    }
298
299    result
300}
301
302fn get_local_time_str() -> String {
303    use std::time::{SystemTime, UNIX_EPOCH};
304    let secs = SystemTime::now()
305        .duration_since(UNIX_EPOCH)
306        .map(|d| d.as_secs() as i64)
307        .unwrap_or(0);
308    crate::date::get_date_str(secs, None, false, false, false, "en")
309}
310
311/// Escape a string for safe use inside an HTML attribute value (double-quote delimited).
312fn escape_attr(s: &str) -> String {
313    let mut result = String::with_capacity(s.len());
314    for c in s.chars() {
315        match c {
316            '&' => result.push_str("&amp;"),
317            '"' => result.push_str("&quot;"),
318            '<' => result.push_str("&lt;"),
319            '>' => result.push_str("&gt;"),
320            '\'' => result.push_str("&#39;"),
321            c => result.push(c),
322        }
323    }
324    result
325}
326
327/// Replaces `%KEY%` placeholders in a template with values from the cookie map.
328///
329/// # Security
330///
331/// Uses single-pass scanning to prevent cascade expansion (second-order injection).
332pub fn substitute_cookies(template: &str, cookies: &CookieMap) -> String {
333    // SEC-4: Single-pass scan of the ORIGINAL template to prevent cascade expansion.
334    // A cookie value containing %OTHER_KEY% is written verbatim into the result
335    // without being re-scanned, so no second-order template injection is possible.
336    let mut result = String::with_capacity(template.len() * 2);
337    let chars: Vec<char> = template.chars().collect();
338    let mut i = 0;
339    while i < chars.len() {
340        if chars[i] == '%' {
341            // look for closing %
342            let start = i + 1;
343            let mut j = start;
344            while j < chars.len() && chars[j] != '%' && chars[j] != '\n' {
345                j += 1;
346            }
347            if j < chars.len() && chars[j] == '%' && j > start {
348                let key: String = chars[start..j].iter().collect();
349                if let Some(val) = cookies.get(key.as_str()) {
350                    result.push_str(val);
351                    i = j + 1;
352                    continue;
353                }
354            }
355        }
356        result.push(chars[i]);
357        i += 1;
358    }
359    result
360}
361
362/// Sets a key-value pair in the cookie map.
363pub fn set_cookie(cookies: &mut CookieMap, key: &str, value: &str) {
364    cookies.insert(key.to_string(), value.to_string());
365}
366
367/// Removes a key from the cookie map.
368pub fn unset_cookie(cookies: &mut CookieMap, key: &str) {
369    cookies.remove(key);
370}
371
372/// Note: CSP includes 'unsafe-inline' in style-src because embedded images in txt2html.rs
373/// use inline style attributes (style="max-width:100%;height:auto") for responsive sizing.
374/// script-src uses a sha256 hash of the fixed inline theme/a11y script instead of
375/// 'unsafe-inline', so injected `<script>` content anywhere else in the page (e.g. an
376/// unescaped subject) still gets blocked by the browser.
377pub fn default_header_template() -> &'static str {
378    "<!DOCTYPE html>
379<html lang=\"%LANG%\" %HTMLATTRS%>
380<head>
381<meta charset=\"utf-8\">
382<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
383<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self' 'sha256-JbUne2+8oxOCK8KTcztLDaHNd2v0sTpOx3FcKB/5By4='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; frame-src 'none'; object-src 'none'; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com\">
384<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
385<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
386<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300..900;1,300..900&display=swap\">
387<title>%TITLE%</title>
388<meta name=\"generator\" content=\"hypermail-rs\">
389%STYLESHEET%
390<style>
391:root{--font-body:\"Noto Sans\",\"Inter\",system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Helvetica Neue\",Roboto,\"Liberation Sans\",Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Noto Color Emoji\";--font-mono:ui-monospace,\"Cascadia Code\",\"Source Code Pro\",Menlo,Consolas,\"DejaVu Sans Mono\",\"Liberation Mono\",\"Courier New\",monospace;--font-size-sm:.85rem;--font-size-base:clamp(.95rem,1rem + .25vw,1.15rem);--font-size-lg:1rem;--line-height:1.5;--max-width:68rem;--border-radius:6px;--color-bg:#fff;--color-text:#111827;--color-link:#2563eb;--color-link-visited:#7c3aed;--color-link-hover:#1d4ed8;--color-muted:#475569;--color-border:#e2e8f0;--color-bg-subtle:#f8fafc;--color-bg-code:#f1f5f9;--color-deleted:#dc2626;--color-quote-border-1:#cbd5e1;--color-quote-border-2:#94a3b8;--color-quote-border-3:#64748b;--color-skip-bg:var(--color-link);--color-skip-text:#fff}
392@media(prefers-color-scheme:dark){:root:not([data-theme]){--color-bg:#000;--color-text:#f1f5f9;--color-link:#60a5fa;--color-link-visited:#a78bfa;--color-link-hover:#93c5fd;--color-muted:#94a3b8;--color-border:#334155;--color-bg-subtle:#111827;--color-bg-code:#1e293b;--color-deleted:#f87171;--color-quote-border-1:#475569;--color-quote-border-2:#475569;--color-quote-border-3:#334155;--color-skip-bg:var(--color-link);--color-skip-text:#000}}
393[data-theme=\"light\"]{--color-bg:#fff;--color-text:#111827;--color-link:#2563eb;--color-link-visited:#7c3aed;--color-link-hover:#1d4ed8;--color-muted:#475569;--color-border:#e2e8f0;--color-bg-subtle:#f8fafc;--color-bg-code:#f1f5f9;--color-deleted:#dc2626;--color-quote-border-1:#cbd5e1;--color-quote-border-2:#94a3b8;--color-quote-border-3:#64748b;--color-skip-bg:var(--color-link);--color-skip-text:#fff}
394[data-theme=\"dark\"]{--color-bg:#000;--color-text:#f1f5f9;--color-link:#60a5fa;--color-link-visited:#a78bfa;--color-link-hover:#93c5fd;--color-muted:#94a3b8;--color-border:#334155;--color-bg-subtle:#111827;--color-bg-code:#1e293b;--color-deleted:#f87171;--color-quote-border-1:#475569;--color-quote-border-2:#475569;--color-quote-border-3:#334155;--color-skip-bg:var(--color-link);--color-skip-text:#000}
395@media(prefers-contrast:more){:root{--color-muted:var(--color-text);--color-border:var(--color-text)}[data-theme=\"light\"],:root:not([data-theme]){--color-link:inherit;--color-link-visited:inherit}}
396*,*::before,*::after{box-sizing:border-box}
397@media(prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}
398body{font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height);color:var(--color-text);background:var(--color-bg);margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}
399main#content{max-width:var(--max-width);margin:0 auto;padding:0 1.25rem}
400a{color:var(--color-link);transition:color .15s ease}
401a:focus-visible{outline:2px solid var(--color-link);outline-offset:2px;border-radius:2px}
402a:hover{text-decoration:underline;color:var(--color-link-hover)}
403a:visited{color:var(--color-link-visited)}
404img{max-width:100%;height:auto;display:block}
405hr{border:none;border-top:1px solid var(--color-border);margin:1.25rem 0}
406pre,code{font-family:var(--font-mono);font-size:.875em;background:var(--color-bg-code);padding:.2em .4em;border-radius:var(--border-radius)}
407pre{padding:1em;overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--color-border);background:var(--color-bg-code);line-height:1.5}
408pre code{background:none;padding:0;border:none;font-size:1em}
409kbd{padding:.15em .4em;border:1px solid var(--color-border);border-radius:var(--border-radius);font-size:.875em;font-family:var(--font-mono)}
410samp,tt{font-family:var(--font-mono);font-size:.875em}
411blockquote{margin:.5rem .75rem;padding:.25rem .75rem;border-left:3px solid var(--color-quote-border-2);background:var(--color-bg-subtle);border-radius:0 var(--border-radius) var(--border-radius) 0}
412blockquote blockquote{margin-left:0}
413.hm-skip{position:absolute;top:-100px;left:0;background:var(--color-skip-bg);color:var(--color-skip-text);padding:.5rem 1rem;z-index:100;text-decoration:none;border-radius:0 0 var(--border-radius) 0}
414.hm-skip:focus{top:0;color:var(--color-skip-text)}.hm-nav{background:var(--color-bg-subtle);border-bottom:1px solid var(--color-border);padding:.75rem 1rem}
415.hm-nav a{margin-right:1rem;font-size:var(--font-size-sm);display:inline-block;padding:.2rem 0}
416.hm-nav a:not(:last-child)::after{content:\"\";display:none}
417.hm-msg-header{width:100%;border-collapse:collapse;margin:1.25rem 0}
418.hm-msg-header th,.hm-msg-header td{padding:.45rem .75rem;text-align:left;vertical-align:top;border-bottom:1px solid var(--color-border)}
419.hm-msg-header th{white-space:nowrap;color:var(--color-muted);font-size:var(--font-size-sm);width:8rem;font-weight:600;text-transform:uppercase;letter-spacing:.025em}
420.hm-msg-header td{font-size:var(--font-size-lg);word-break:break-word}
421.hm-msg-sep{border:none;border-top:2px solid var(--color-border);margin:1.5rem 0}
422.hm-pg{padding:0;font-family:var(--font-body);font-size:1em;line-height:var(--line-height);white-space:pre-wrap;word-wrap:break-word}
423.hm-blank{display:none}
424.hm-quote-1,.hm-quote-2,.hm-quote-3,.hm-quote-4,.hm-quote-5,.hm-quote-6,.hm-quote-7,.hm-quote-8,.hm-quote-9{padding:.2rem .75rem;font-family:var(--font-body);font-size:.95em;line-height:var(--line-height);white-space:pre-wrap;word-wrap:break-word;font-style:italic}
425.hm-sig-text{padding:.1rem .75rem;font-family:var(--font-mono);font-size:.85em;line-height:1.4;white-space:pre-wrap;word-wrap:break-word;color:var(--color-muted)}
426.hm-quote-1{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-1)}
427.hm-quote-2{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-2)}
428.hm-quote-3{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-3)}
429.hm-quote-4,.hm-quote-5,.hm-quote-6,.hm-quote-7,.hm-quote-8,.hm-quote-9{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-1)}
430.hm-sig{border:none;border-top:1px solid var(--color-border);margin:1rem .75rem;width:4rem}
431.hm-deleted{color:var(--color-deleted);font-style:italic;padding:.5rem .75rem}
432.hm-attachment{margin:.5rem .75rem;padding:.5rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius)}
433.hm-attachment summary{font-weight:600;cursor:pointer;color:var(--color-muted);padding:.25rem}
434.hm-attachment summary:hover{color:var(--color-text)}
435.hm-index{padding:0 .75rem}
436.hm-index li{margin:0;line-height:1.4}
437.hm-index a,.hm-thread-list a{display:inline-block;padding:0}
438.hm-reply-list{list-style:none;padding:0 .75rem;margin:.25rem 0}
439.hm-reply-list li{margin:0;font-size:var(--font-size-sm);line-height:1.4}
440.hm-reply-list li::before{content:\"\\21AA\";margin-right:.35rem;color:var(--color-muted)}
441.hm-thread-children{list-style:disc;padding-left:1.5rem;margin:.15rem 0;border-left:2px solid var(--color-border)}
442.hm-thread-children li{margin:0;line-height:1.4}
443.hm-hdrlabel{font-weight:600}
444.hm-breadcrumb{font-size:var(--font-size-sm);color:var(--color-muted);padding:.5rem 0}
445.hm-breadcrumb a{color:var(--color-link)}
446.hm-generator{text-align:center;font-size:.7rem;color:var(--color-muted);margin:1rem 0}
447.hm-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
448@media(max-width:640px){main#content{padding:0 .5rem}.hm-msg-header,.hm-msg-header tbody,.hm-msg-header tr,.hm-msg-header th,.hm-msg-header td{display:block;width:auto}.hm-msg-header th{width:auto;background:var(--color-bg-subtle)}.hm-msg-header td{padding-left:.75rem}.hm-nav{padding:.5rem}.hm-nav a{display:inline-block;margin:.25rem .5rem}}
449@media print{body{font-size:10pt;color:#000;background:#fff;--color-link:#000;--color-link-visited:#000;--color-muted:#000;--color-border:#ccc}a{color:#000;text-decoration:underline}.hm-nav,.hm-skip,.hm-reply-list,.hm-theme-toggle,.hm-a11y-trigger,.hm-a11y-bar{display:none}.hm-msg-header th{color:#000}pre,code{background:#f5f5f5;border:1px solid #ccc}blockquote{border-left-color:#ccc}}
450.hm-theme-toggle{position:fixed;bottom:1rem;right:1rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.4rem .6rem;cursor:pointer;font-size:1.1rem;color:var(--color-text);z-index:50;line-height:1}
451.hm-theme-toggle:hover{background:var(--color-border)}
452.hm-theme-toggle:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
453.hm-a11y-bar{position:fixed;bottom:1rem;right:3.5rem;display:flex;gap:.25rem;align-items:center;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.3rem .5rem;z-index:50;opacity:0;pointer-events:none;transition:opacity .2s}
454.hm-a11y-bar.open{opacity:1;pointer-events:auto}
455.hm-a11y-bar button{background:none;border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.25rem .45rem;cursor:pointer;font-size:.85rem;color:var(--color-text);line-height:1}
456.hm-a11y-bar button:hover{background:var(--color-border)}
457.hm-a11y-bar button:focus-visible{outline:2px solid var(--color-link);outline-offset:1px}
458.hm-a11y-bar button[aria-pressed=\"true\"]{background:var(--color-link);color:var(--color-bg);border-color:var(--color-link)}
459.hm-a11y-trigger{position:fixed;bottom:1rem;right:3.5rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius);padding:.4rem .6rem;cursor:pointer;font-size:.9rem;color:var(--color-text);z-index:50;line-height:1}
460.hm-a11y-trigger:hover{background:var(--color-border)}
461.hm-a11y-trigger:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
462[data-font-size=\"small\"]{--font-size-base:.9rem;--font-size-sm:.8rem;--font-size-lg:.9rem}
463[data-font-size=\"large\"]{--font-size-base:1.2rem;--font-size-sm:1rem;--font-size-lg:1.15rem}
464[data-font-size=\"xlarge\"]{--font-size-base:1.4rem;--font-size-sm:1.15rem;--font-size-lg:1.3rem}
465[data-line-height=\"compact\"]{--line-height:1.3}
466[data-line-height=\"relaxed\"]{--line-height:1.9}
467[data-dyslexic] body{font-family:\"OpenDyslexic\",\"Comic Sans MS\",var(--font-body);letter-spacing:.05em;word-spacing:.15em}
468</style>
469<script>
470(function(){var d=document.documentElement,k='hm-theme',b;function t(){var c=d.getAttribute('data-theme');var n=c==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem(k,n)}catch(e){}if(b)b.setAttribute('aria-label','Switch to '+(n==='dark'?'light':'dark')+' mode')}try{var s=localStorage.getItem(k);if(s)d.setAttribute('data-theme',s)}catch(e){}try{var fs=localStorage.getItem('hm-font-size');if(fs)d.setAttribute('data-font-size',fs);var lh=localStorage.getItem('hm-line-height');if(lh)d.setAttribute('data-line-height',lh);if(localStorage.getItem('hm-dyslexic')==='1')d.setAttribute('data-dyslexic','')}catch(e){}document.addEventListener('DOMContentLoaded',function(){b=document.querySelector('.hm-theme-toggle');if(b)b.addEventListener('click',t);var trig=document.querySelector('.hm-a11y-trigger'),bar=document.querySelector('.hm-a11y-bar');if(trig&&bar){trig.addEventListener('click',function(){bar.classList.toggle('open');trig.setAttribute('aria-expanded',bar.classList.contains('open'))})}var sizes=['small','','large','xlarge'],si=sizes.indexOf(d.getAttribute('data-font-size')||'');if(si<0)si=1;document.querySelector('[data-a11y=\"size-up\"]')&&document.querySelector('[data-a11y=\"size-up\"]').addEventListener('click',function(){si=Math.min(si+1,3);var v=sizes[si];if(v){d.setAttribute('data-font-size',v);try{localStorage.setItem('hm-font-size',v)}catch(e){}}else{d.removeAttribute('data-font-size');try{localStorage.removeItem('hm-font-size')}catch(e){}}});document.querySelector('[data-a11y=\"size-down\"]')&&document.querySelector('[data-a11y=\"size-down\"]').addEventListener('click',function(){si=Math.max(si-1,0);var v=sizes[si];if(v){d.setAttribute('data-font-size',v);try{localStorage.setItem('hm-font-size',v)}catch(e){}}else{d.removeAttribute('data-font-size');try{localStorage.removeItem('hm-font-size')}catch(e){}}});var lhStates=['compact','','relaxed'],li=lhStates.indexOf(d.getAttribute('data-line-height')||'');if(li<0)li=1;var lhBtn=document.querySelector('[data-a11y=\"line-height\"]');function updLh(){if(lhBtn)lhBtn.setAttribute('aria-pressed',li!==1?'true':'false')}updLh();lhBtn&&lhBtn.addEventListener('click',function(){li=(li+1)%3;var v=lhStates[li];if(v){d.setAttribute('data-line-height',v);try{localStorage.setItem('hm-line-height',v)}catch(e){}}else{d.removeAttribute('data-line-height');try{localStorage.removeItem('hm-line-height')}catch(e){}}updLh()});var dyBtn=document.querySelector('[data-a11y=\"dyslexic\"]');function updDy(){if(dyBtn)dyBtn.setAttribute('aria-pressed',d.hasAttribute('data-dyslexic')?'true':'false')}updDy();dyBtn&&dyBtn.addEventListener('click',function(){if(d.hasAttribute('data-dyslexic')){d.removeAttribute('data-dyslexic');try{localStorage.removeItem('hm-dyslexic')}catch(e){}}else{d.setAttribute('data-dyslexic','');try{localStorage.setItem('hm-dyslexic','1')}catch(e){}}updDy()});var resetBtn=document.querySelector('[data-a11y=\"reset\"]');resetBtn&&resetBtn.addEventListener('click',function(){si=1;li=1;d.removeAttribute('data-font-size');d.removeAttribute('data-line-height');d.removeAttribute('data-dyslexic');try{localStorage.removeItem('hm-font-size');localStorage.removeItem('hm-line-height');localStorage.removeItem('hm-dyslexic')}catch(e){}updLh();updDy()})})})();
471</script>
472%METADATA%
473</head>
474<body>
475<a class=\"hm-skip\" href=\"#content\">Skip to content</a>
476<button class=\"hm-theme-toggle\" aria-label=\"Switch to dark mode\" title=\"Toggle dark/light mode\">&#9681;</button>
477<button class=\"hm-a11y-trigger\" aria-label=\"Accessibility options\" aria-expanded=\"false\" title=\"Accessibility\">Aa</button>
478<div class=\"hm-a11y-bar\" role=\"toolbar\" aria-label=\"Accessibility controls\"><button data-a11y=\"size-down\" aria-label=\"Decrease font size\" title=\"Smaller\">A&#8595;</button><button data-a11y=\"size-up\" aria-label=\"Increase font size\" title=\"Larger\">A&#8593;</button><button data-a11y=\"line-height\" aria-label=\"Toggle line spacing\" aria-pressed=\"false\" title=\"Line spacing\">&#9776;</button><button data-a11y=\"dyslexic\" aria-label=\"Toggle dyslexia-friendly font\" aria-pressed=\"false\" title=\"Dyslexia font\">Dy</button><button data-a11y=\"reset\" aria-label=\"Reset accessibility settings\" title=\"Reset\">&#8634;</button></div>
479%BODYHEADER%
480%BODYHEADEREND%
481%NAVIGATION%
482<main id=\"content\">
483<h1 class=\"hm-sr-only\">%TITLE%</h1>
484"
485}
486
487/// Returns the default footer HTML template.
488pub fn default_footer_template() -> &'static str {
489    "
490</main>
491%NAVIGATION%
492<footer role=\"contentinfo\">
493%BODYFOOTER%
494%GENERATOR%
495</footer>
496</body>
497</html>"
498}
499
500/// Returns the default article body template (`%ARTICLE%`).
501pub fn default_article_template() -> &'static str {
502    "%ARTICLE%
503"
504}
505
506/// Builds the initial cookie map with standard header values (title, stylesheet, metadata, etc.).
507///
508/// # Security
509///
510/// `title` is treated as untrusted (e.g. message subject) and is HTML-escaped before
511/// insertion into `%TITLE%` (used in `<title>` and headings).
512pub fn get_header_cookies(config: &crate::config::Config, title: &str) -> CookieMap {
513    let mut cookies = CookieMap::new();
514    set_cookie(&mut cookies, "TITLE", &crate::txt2html::escape_html(title));
515
516    let stylesheet = if let Some(ref css) = config.css {
517        let css_path = if !css.starts_with("http") && !css.starts_with('/') {
518            config.css_path()
519        } else {
520            css.clone()
521        };
522        let mut tag = String::from("<link rel=\"stylesheet\" type=\"text/css\" href=\"");
523        tag.push_str(&escape_attr(&css_path));
524        tag.push_str("\">\n");
525        tag
526    } else {
527        String::new()
528    };
529    set_cookie(&mut cookies, "STYLESHEET", &stylesheet);
530
531    let metadata = if let Some(ref desc) = config.description {
532        let mut tag = String::from("<meta name=\"description\" content=\"");
533        tag.push_str(&crate::txt2html::escape_html(desc));
534        tag.push_str("\">\n");
535        tag
536    } else {
537        String::new()
538    };
539    set_cookie(&mut cookies, "METADATA", &metadata);
540
541    // SECURITY: These values are inserted as raw HTML. Only use from trusted config files.
542    let bodyheader = config.bodyheader.as_deref().unwrap_or("");
543    if bodyheader.to_ascii_lowercase().contains("<script") {
544        log::warn!("bodyheader contains <script> — ensure this is intentional");
545    }
546    set_cookie(&mut cookies, "BODYHEADER", bodyheader);
547
548    let bodyheaderend = config.bodyheaderend.as_deref().unwrap_or("");
549    if bodyheaderend.to_ascii_lowercase().contains("<script") {
550        log::warn!("bodyheaderend contains <script> — ensure this is intentional");
551    }
552    set_cookie(&mut cookies, "BODYHEADEREND", bodyheaderend);
553
554    set_cookie(&mut cookies, "NAVIGATION", "");
555
556    // SECURITY: These values are inserted as raw HTML. Only use from trusted config files.
557    let bodyfooter = config.bodyfooter.as_deref().unwrap_or("");
558    if bodyfooter.to_ascii_lowercase().contains("<script") {
559        log::warn!("bodyfooter contains <script> — ensure this is intentional");
560    }
561    set_cookie(&mut cookies, "BODYFOOTER", bodyfooter);
562
563    // Generator credit (suppressible via showgenerator = Off or --no-generator)
564    let generator = if config.showgenerator {
565        let i18n = crate::i18n::I18n::new(&config.language);
566        let gen_text = i18n.get("Generated by");
567        format!(
568            "<p class=\"hm-generator\">{} <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>",
569            crate::txt2html::escape_html(gen_text)
570        )
571    } else {
572        String::new()
573    };
574    set_cookie(&mut cookies, "GENERATOR", &generator);
575
576    // Set HTML lang attribute from configured language (defaults to "en")
577    let lang = if config.language.is_empty() {
578        "en"
579    } else {
580        &config.language
581    };
582    set_cookie(&mut cookies, "LANG", lang);
583
584    let htmlattrs = match config.theme.as_deref() {
585        Some("light") => "data-theme=\"light\"",
586        Some("dark") => "data-theme=\"dark\"",
587        _ => "",
588    };
589    set_cookie(&mut cookies, "HTMLATTRS", htmlattrs);
590
591    cookies
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_basic_substitution() {
600        let mut cookies = CookieMap::new();
601        set_cookie(&mut cookies, "TITLE", "Test Title");
602        let result = substitute_cookies("<h1>%TITLE%</h1>", &cookies);
603        assert_eq!(result, "<h1>Test Title</h1>");
604    }
605
606    #[test]
607    fn test_multiple_cookies() {
608        let mut cookies = CookieMap::new();
609        set_cookie(&mut cookies, "TITLE", "My List");
610        set_cookie(&mut cookies, "NAV", "Home");
611        let result = substitute_cookies("%NAV% - %TITLE%", &cookies);
612        assert_eq!(result, "Home - My List");
613    }
614
615    #[test]
616    fn test_unknown_cookie() {
617        let cookies = CookieMap::new();
618        let result = substitute_cookies("%UNKNOWN%", &cookies);
619        assert_eq!(result, "%UNKNOWN%");
620    }
621
622    #[test]
623    fn test_unset_cookie() {
624        let mut cookies = CookieMap::new();
625        set_cookie(&mut cookies, "TITLE", "test");
626        unset_cookie(&mut cookies, "TITLE");
627        assert!(!cookies.contains_key("TITLE"));
628    }
629
630    #[test]
631    fn test_csp_script_src_has_no_unsafe_inline() {
632        let header = default_header_template();
633        let csp_start = header.find("script-src ").unwrap();
634        let csp_segment = &header[csp_start..csp_start + 80];
635        assert!(
636            !csp_segment.contains("'unsafe-inline'"),
637            "script-src must use a hash, not 'unsafe-inline': {}",
638            csp_segment
639        );
640        assert!(csp_segment.contains("'sha256-"), "script-src must pin the inline script hash");
641    }
642
643    /// Canary: if the inline theme/a11y script in default_header_template() changes,
644    /// this length check will fail as a reminder to regenerate the CSP sha256 hash
645    /// (`shasum -a 256` over the exact bytes between `<script>\n` and `\n</script>`)
646    /// and update the `'sha256-...'` value above.
647    #[test]
648    fn test_inline_script_unchanged_canary() {
649        let header = default_header_template();
650        let start = header.find("<script>\n").unwrap() + "<script>\n".len();
651        let end = header.find("\n</script>").unwrap();
652        let script = &header[start..end];
653        assert_eq!(
654            script.len(),
655            3262,
656            "inline script content changed; regenerate the CSP sha256 hash in default_header_template()"
657        );
658    }
659
660    #[test]
661    fn test_header_cookies() {
662        let config = crate::config::Config::default();
663        let cookies = get_header_cookies(&config, "Test Archive");
664        assert_eq!(cookies.get("TITLE").unwrap(), "Test Archive");
665    }
666
667    #[test]
668    fn test_title_cookie_escapes_xss() {
669        let config = crate::config::Config::default();
670        let cookies = get_header_cookies(&config, "</title><script>alert(1)</script>");
671        let title = cookies.get("TITLE").unwrap();
672        assert!(!title.contains("<script>"), "raw script must not appear in TITLE");
673        assert!(title.contains("&lt;/title&gt;"));
674        assert!(title.contains("&lt;script&gt;"));
675        let html = substitute_cookies("<title>%TITLE%</title>", &cookies);
676        assert!(!html.contains("<script>alert"));
677        assert!(html.contains("&lt;script&gt;"));
678    }
679
680    #[test]
681    fn test_description_meta_escaped() {
682        let mut config = crate::config::Config::default();
683        config.description = Some("<script>alert('xss')</script>".to_string());
684        let cookies = get_header_cookies(&config, "Test");
685        let meta = cookies.get("METADATA").unwrap();
686        assert!(meta.contains("&lt;script&gt;"));
687        assert!(!meta.contains("<script>"));
688    }
689
690    #[test]
691    fn test_description_meta_safe() {
692        let mut config = crate::config::Config::default();
693        config.description = Some("Hello & welcome".to_string());
694        let cookies = get_header_cookies(&config, "Test");
695        let meta = cookies.get("METADATA").unwrap();
696        assert!(meta.contains("Hello &amp; welcome"));
697    }
698
699    #[test]
700    fn test_no_description_no_meta() {
701        let config = crate::config::Config::default();
702        let cookies = get_header_cookies(&config, "Test");
703        let meta = cookies.get("METADATA").unwrap();
704        assert!(meta.is_empty());
705    }
706
707    #[test]
708    fn test_printfile_localized_labels_include_colon() {
709        let data = PrintfileData {
710            label: "Test",
711            subject: "Hello",
712            dir: ".",
713            name: None,
714            email: None,
715            msgid: None,
716            charset: None,
717            date: None,
718            display_date: None,
719            filename: None,
720            archives: None,
721            about: None,
722            mailto: None,
723            language: "en",
724            rel_path_to_top: "",
725        };
726        // %j = localized Subject label, %k = localized Date label, %y = localized Author label
727        let result = substitute_printfile("%j | %k | %y", &data);
728        assert_eq!(result, "Subject: | Date: | Author:");
729    }
730
731    #[test]
732    fn test_printfile_localized_labels_german() {
733        let data = PrintfileData {
734            label: "Test",
735            subject: "Hallo",
736            dir: ".",
737            name: None,
738            email: None,
739            msgid: None,
740            charset: None,
741            date: None,
742            display_date: None,
743            filename: None,
744            archives: None,
745            about: None,
746            mailto: None,
747            language: "de",
748            rel_path_to_top: "",
749        };
750        let result = substitute_printfile("%j | %k | %y", &data);
751        assert_eq!(result, "Betreff: | Datum: | Autor:");
752    }
753
754    #[test]
755    fn test_printfile_localized_labels_greek() {
756        let data = PrintfileData {
757            label: "Test",
758            subject: "Test",
759            dir: ".",
760            name: None,
761            email: None,
762            msgid: None,
763            charset: None,
764            date: None,
765            display_date: None,
766            filename: None,
767            archives: None,
768            about: None,
769            mailto: None,
770            language: "el",
771            rel_path_to_top: "",
772        };
773        let result = substitute_printfile("%j | %k | %y", &data);
774        assert!(result.contains("Θέμα:"), "Greek Subject label should include colon");
775        assert!(result.contains("Ημερομηνία:"), "Greek Date label should include colon");
776        assert!(result.contains("Συγγραφέας:"), "Greek Author label should include colon");
777    }
778
779    #[test]
780    fn test_printfile_generated_by_localized() {
781        let data = PrintfileData {
782            label: "Test",
783            subject: "Test",
784            dir: ".",
785            name: None,
786            email: None,
787            msgid: None,
788            charset: None,
789            date: None,
790            display_date: None,
791            filename: None,
792            archives: None,
793            about: None,
794            mailto: None,
795            language: "de",
796            rel_path_to_top: "",
797        };
798        let result = substitute_printfile("%w", &data);
799        assert_eq!(result, "Erstellt mit", "German 'Generated by' should be localized");
800    }
801}