Skip to main content

hypermail/
templates.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use crate::i18n::I18n;
6
7pub type CookieMap = HashMap<String, String>;
8
9/// Data for original hypermail-style `%x` single-character template substitution,
10/// matching the `printfile()` function in the original hypermail `printfile.c`.
11pub struct PrintfileData<'a> {
12    /// Archive label (%l)
13    pub label: &'a str,
14    /// Message subject text — HTML-escaped where needed (%s, %S)
15    pub subject: &'a str,
16    /// Output directory (%~)
17    pub dir: &'a str,
18    /// Author display name (%A combined with email)
19    pub name: Option<&'a str>,
20    /// Author email address (%e, %A)
21    pub email: Option<&'a str>,
22    /// Message-ID (%i)
23    pub msgid: Option<&'a str>,
24    /// Charset (%c)
25    pub charset: Option<&'a str>,
26    /// ISO date string for meta tag (%D)
27    pub date: Option<&'a str>,
28    /// Human-readable date string for display (%d)
29    pub display_date: Option<&'a str>,
30    /// HTML filename of the current page (%f)
31    pub filename: Option<&'a str>,
32    /// Other-archives URL (%a)
33    pub archives: Option<&'a str>,
34    /// About-archive URL (%b)
35    pub about: Option<&'a str>,
36    /// Mailto address (%m)
37    pub mailto: Option<&'a str>,
38    /// Language code (%G)
39    pub language: &'a str,
40    /// Relative path from current page back to the top-level index (%t)
41    pub rel_path_to_top: &'a str,
42}
43
44const HMURL: &str = "https://www.hypermail-project.org/";
45const PROGNAME: &str = "hypermail-rs";
46const VERSION: &str = env!("CARGO_PKG_VERSION");
47
48/// Perform original hypermail `printfile()`-style `%x` substitution on a template string.
49///
50/// Supported escapes:
51/// - `%%` → `%`
52/// - `\n` → newline, `\t` → tab
53/// - `%~` → output directory
54/// - `%a` → other-archives URL
55/// - `%b` → about-archive URL
56/// - `%c` → charset `<meta>` tag
57/// - `%D` → date `<meta name="Date">` tag (message pages only)
58/// - `%e` → author email address
59/// - `%f` → filename
60/// - `%g` → current date/time string
61/// - `%G` → two-letter language code
62/// - `%h` → hypermail homepage URL
63/// - `%i` → Message-ID
64/// - `%l` → archive label
65/// - `%m` → mailto address
66/// - `%p` → program name
67/// - `%s` → subject (HTML-escaped)
68/// - `%S` → subject `<meta name="Subject">` tag (message pages only)
69/// - `%A` → author `<meta name="Author">` tag (message pages only)
70/// - `%t` → relative path to top-level index
71/// - `%v` → version string
72/// - `%u` → expanded version link `<a href=...>progname version</a>`
73pub fn substitute_printfile(template: &str, data: &PrintfileData<'_>) -> String {
74    let mut result = String::with_capacity(template.len() + 256);
75    let bytes = template.as_bytes();
76    let mut i = 0;
77
78    while i < bytes.len() {
79        let b = bytes[i];
80        if b == b'\\' && i + 1 < bytes.len() {
81            match bytes[i + 1] {
82                b'n' => {
83                    result.push('\n');
84                    i += 2;
85                },
86                b't' => {
87                    result.push('\t');
88                    i += 2;
89                },
90                _ => {
91                    result.push(b as char);
92                    i += 1;
93                },
94            }
95        } else if b == b'%' && i + 1 < bytes.len() {
96            let next = bytes[i + 1] as char;
97            match next {
98                '%' => {
99                    result.push('%');
100                    i += 2;
101                },
102                '~' => {
103                    result.push_str(data.dir);
104                    i += 2;
105                },
106                'a' => {
107                    if let Some(a) = data.archives {
108                        result.push_str(a);
109                    }
110                    i += 2;
111                },
112                'b' => {
113                    if let Some(b_url) = data.about {
114                        result.push_str(b_url);
115                    }
116                    i += 2;
117                },
118                'c' => {
119                    if let Some(cs) = data.charset {
120                        if !cs.is_empty() {
121                            result.push_str(&format!(
122                                "<meta http-equiv=\"Content-Type\" content=\"text/html; charset={}\" />\n",
123                                cs
124                            ));
125                        }
126                    }
127                    i += 2;
128                },
129                'D' => {
130                    if let Some(date) = data.date {
131                        result.push_str(&format!("<meta name=\"Date\" content=\"{}\" />", date));
132                    }
133                    i += 2;
134                },
135                'e' => {
136                    if let Some(email) = data.email {
137                        result.push_str(email);
138                    }
139                    i += 2;
140                },
141                'f' => {
142                    if let Some(fname) = data.filename {
143                        result.push_str(fname);
144                    }
145                    i += 2;
146                },
147                'g' => {
148                    result.push_str(&get_local_time_str());
149                    i += 2;
150                },
151                'G' => {
152                    result.push_str(data.language);
153                    i += 2;
154                },
155                'h' => {
156                    result.push_str(HMURL);
157                    i += 2;
158                },
159                'i' => {
160                    if let Some(msgid) = data.msgid {
161                        result.push_str(msgid);
162                    }
163                    i += 2;
164                },
165                'l' => {
166                    result.push_str(data.label);
167                    i += 2;
168                },
169                'm' => {
170                    if let Some(mailto) = data.mailto {
171                        result.push_str(mailto);
172                    }
173                    i += 2;
174                },
175                'p' => {
176                    result.push_str(PROGNAME);
177                    i += 2;
178                },
179                's' => {
180                    result.push_str(&crate::txt2html::escape_html(data.subject));
181                    i += 2;
182                },
183                'S' => {
184                    result.push_str(&format!(
185                        "<meta name=\"Subject\" content=\"{}\" />",
186                        crate::txt2html::escape_html(data.subject)
187                    ));
188                    i += 2;
189                },
190                'A' => {
191                    if let (Some(name), Some(email)) = (data.name, data.email) {
192                        result.push_str(&format!(
193                            "<meta name=\"Author\" content=\"{} ({})\" />",
194                            crate::txt2html::escape_html(name),
195                            email
196                        ));
197                    }
198                    i += 2;
199                },
200                // %n → plain-text author name (no markup)
201                'n' => {
202                    if let Some(name) = data.name {
203                        result.push_str(&crate::txt2html::escape_html(name));
204                    }
205                    i += 2;
206                },
207                // %d → plain-text date string for display (human-readable, no markup)
208                'd' => {
209                    if let Some(date) = data.display_date {
210                        result.push_str(&crate::txt2html::escape_html(date));
211                    }
212                    i += 2;
213                },
214                // %y → i18n-translated "Author:" label (language-aware)
215                'y' => {
216                    let i18n = I18n::new(data.language);
217                    result.push_str(i18n.get("Author:"));
218                    i += 2;
219                },
220                // %N → author name + email as linked HTML: Name &lt;<a href="mailto:addr">addr</a>&gt;
221                'N' => {
222                    match (data.name, data.email) {
223                        (Some(name), Some(addr)) if !addr.is_empty() => {
224                            let esc_name = crate::txt2html::escape_html(name);
225                            let esc_addr = crate::txt2html::escape_html(addr);
226                            result.push_str(&format!(
227                                "{} &lt;<a href=\"mailto:{}\">{}</a>&gt;",
228                                esc_name, esc_addr, esc_addr
229                            ));
230                        },
231                        (Some(name), _) => {
232                            result.push_str(&crate::txt2html::escape_html(name));
233                        },
234                        _ => {},
235                    }
236                    i += 2;
237                },
238                't' => {
239                    result.push_str(data.rel_path_to_top);
240                    i += 2;
241                },
242                'v' => {
243                    result.push_str(VERSION);
244                    i += 2;
245                },
246                'u' => {
247                    result.push_str(&format!("<a href=\"{}\">{} {}</a>", HMURL, PROGNAME, VERSION));
248                    i += 2;
249                },
250                _ => {
251                    result.push('%');
252                    result.push(next);
253                    i += 2;
254                },
255            }
256        } else {
257            // Correctly advance through multi-byte UTF-8 sequences.
258            // `b as char` would silently produce Latin-1 mojibake for non-ASCII bytes.
259            let ch = template[i..].chars().next().unwrap_or('\u{FFFD}');
260            result.push(ch);
261            i += ch.len_utf8();
262        }
263    }
264
265    result
266}
267
268fn get_local_time_str() -> String {
269    use std::time::{SystemTime, UNIX_EPOCH};
270    let secs = SystemTime::now()
271        .duration_since(UNIX_EPOCH)
272        .map(|d| d.as_secs() as i64)
273        .unwrap_or(0);
274    crate::date::get_date_str(secs, None, false, false, false, "en")
275}
276
277/// Escape a string for safe use inside an HTML attribute value (double-quote delimited).
278fn escape_attr(s: &str) -> String {
279    let mut result = String::with_capacity(s.len());
280    for c in s.chars() {
281        match c {
282            '&' => result.push_str("&amp;"),
283            '"' => result.push_str("&quot;"),
284            '<' => result.push_str("&lt;"),
285            '>' => result.push_str("&gt;"),
286            '\'' => result.push_str("&#39;"),
287            c => result.push(c),
288        }
289    }
290    result
291}
292
293/// Replaces `%KEY%` placeholders in a template with values from the cookie map.
294///
295/// # Security
296///
297/// Uses single-pass scanning to prevent cascade expansion (second-order injection).
298pub fn substitute_cookies(template: &str, cookies: &CookieMap) -> String {
299    // SEC-4: Single-pass scan of the ORIGINAL template to prevent cascade expansion.
300    // A cookie value containing %OTHER_KEY% is written verbatim into the result
301    // without being re-scanned, so no second-order template injection is possible.
302    let mut result = String::with_capacity(template.len() * 2);
303    let chars: Vec<char> = template.chars().collect();
304    let mut i = 0;
305    while i < chars.len() {
306        if chars[i] == '%' {
307            // look for closing %
308            let start = i + 1;
309            let mut j = start;
310            while j < chars.len() && chars[j] != '%' && chars[j] != '\n' {
311                j += 1;
312            }
313            if j < chars.len() && chars[j] == '%' && j > start {
314                let key: String = chars[start..j].iter().collect();
315                if let Some(val) = cookies.get(key.as_str()) {
316                    result.push_str(val);
317                    i = j + 1;
318                    continue;
319                }
320            }
321        }
322        result.push(chars[i]);
323        i += 1;
324    }
325    result
326}
327
328/// Sets a key-value pair in the cookie map.
329pub fn set_cookie(cookies: &mut CookieMap, key: &str, value: &str) {
330    cookies.insert(key.to_string(), value.to_string());
331}
332
333/// Removes a key from the cookie map.
334pub fn unset_cookie(cookies: &mut CookieMap, key: &str) {
335    cookies.remove(key);
336}
337
338/// Note: CSP includes 'unsafe-inline' in style-src because embedded images in txt2html.rs
339/// use inline style attributes (style="max-width:100%;height:auto") for responsive sizing.
340pub fn default_header_template() -> &'static str {
341    "<!DOCTYPE html>
342<html lang=\"%LANG%\" %HTMLATTRS%>
343<head>
344<meta charset=\"utf-8\">
345<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
346<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self' 'unsafe-inline'; 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\">
347<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
348<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
349<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300..900;1,300..900&display=swap\">
350<title>%TITLE%</title>
351<meta name=\"generator\" content=\"hypermail-rs\">
352%STYLESHEET%
353<style>
354: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:#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}
355@media(prefers-color-scheme:light){:root:not([data-theme]){--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}}
356[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}
357[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}
358@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}}
359*,*::before,*::after{box-sizing:border-box}
360@media(prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}
361body{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}
362main#content{max-width:var(--max-width);margin:0 auto;padding:0 1.25rem}
363a{color:var(--color-link);transition:color .15s ease}
364a:focus-visible{outline:2px solid var(--color-link);outline-offset:2px;border-radius:2px}
365a:hover{text-decoration:underline;color:var(--color-link-hover)}
366a:visited{color:var(--color-link-visited)}
367img{max-width:100%;height:auto;display:block}
368hr{border:none;border-top:1px solid var(--color-border);margin:1.25rem 0}
369pre,code{font-family:var(--font-mono);font-size:.875em;background:var(--color-bg-code);padding:.2em .4em;border-radius:var(--border-radius)}
370pre{padding:1em;overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--color-border);background:var(--color-bg-code);line-height:1.5}
371pre code{background:none;padding:0;border:none;font-size:1em}
372kbd{padding:.15em .4em;border:1px solid var(--color-border);border-radius:var(--border-radius);font-size:.875em;font-family:var(--font-mono)}
373samp,tt{font-family:var(--font-mono);font-size:.875em}
374blockquote{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}
375blockquote blockquote{margin-left:0}
376.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}
377.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}
378.hm-nav a{margin-right:1rem;font-size:var(--font-size-sm);display:inline-block;padding:.2rem 0}
379.hm-nav a:not(:last-child)::after{content:\"\";display:none}
380.hm-msg-header{width:100%;border-collapse:collapse;margin:1.25rem 0}
381.hm-msg-header th,.hm-msg-header td{padding:.45rem .75rem;text-align:left;vertical-align:top;border-bottom:1px solid var(--color-border)}
382.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}
383.hm-msg-header td{font-size:var(--font-size-lg);word-break:break-word}
384.hm-msg-sep{border:none;border-top:2px solid var(--color-border);margin:1.5rem 0}
385.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}
386.hm-blank{display:none}
387.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}
388.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)}
389.hm-quote-1{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-1)}
390.hm-quote-2{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-2)}
391.hm-quote-3{background:var(--color-bg-subtle);border-left:3px solid var(--color-quote-border-3)}
392.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)}
393.hm-sig{border:none;border-top:1px solid var(--color-border);margin:1rem .75rem;width:4rem}
394.hm-deleted{color:var(--color-deleted);font-style:italic;padding:.5rem .75rem}
395.hm-attachment{margin:.5rem .75rem;padding:.5rem;background:var(--color-bg-subtle);border:1px solid var(--color-border);border-radius:var(--border-radius)}
396.hm-attachment summary{font-weight:600;cursor:pointer;color:var(--color-muted);padding:.25rem}
397.hm-attachment summary:hover{color:var(--color-text)}
398.hm-index{padding:0 .75rem}
399.hm-index li{margin:0;line-height:1.4}
400.hm-index a,.hm-thread-list a{display:inline-block;padding:0}
401.hm-reply-list{list-style:none;padding:0 .75rem;margin:.25rem 0}
402.hm-reply-list li{margin:0;font-size:var(--font-size-sm);line-height:1.4}
403.hm-reply-list li::before{content:\"\\21AA\";margin-right:.35rem;color:var(--color-muted)}
404.hm-thread-children{list-style:disc;padding-left:1.5rem;margin:.15rem 0;border-left:2px solid var(--color-border)}
405.hm-thread-children li{margin:0;line-height:1.4}
406.hm-hdrlabel{font-weight:600}
407.hm-breadcrumb{font-size:var(--font-size-sm);color:var(--color-muted);padding:.5rem 0}
408.hm-breadcrumb a{color:var(--color-link)}
409.hm-generator{text-align:center;font-size:.7rem;color:var(--color-muted);margin:1rem 0}
410.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}
411@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}}
412@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}}
413.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}
414.hm-theme-toggle:hover{background:var(--color-border)}
415.hm-theme-toggle:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
416.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}
417.hm-a11y-bar.open{opacity:1;pointer-events:auto}
418.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}
419.hm-a11y-bar button:hover{background:var(--color-border)}
420.hm-a11y-bar button:focus-visible{outline:2px solid var(--color-link);outline-offset:1px}
421.hm-a11y-bar button[aria-pressed=\"true\"]{background:var(--color-link);color:var(--color-bg);border-color:var(--color-link)}
422.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}
423.hm-a11y-trigger:hover{background:var(--color-border)}
424.hm-a11y-trigger:focus-visible{outline:2px solid var(--color-link);outline-offset:2px}
425[data-font-size=\"small\"]{--font-size-base:.9rem;--font-size-sm:.8rem;--font-size-lg:.9rem}
426[data-font-size=\"large\"]{--font-size-base:1.2rem;--font-size-sm:1rem;--font-size-lg:1.15rem}
427[data-font-size=\"xlarge\"]{--font-size-base:1.4rem;--font-size-sm:1.15rem;--font-size-lg:1.3rem}
428[data-line-height=\"compact\"]{--line-height:1.3}
429[data-line-height=\"relaxed\"]{--line-height:1.9}
430[data-dyslexic] body{font-family:\"OpenDyslexic\",\"Comic Sans MS\",var(--font-body);letter-spacing:.05em;word-spacing:.15em}
431</style>
432<script>
433(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()})})})();
434</script>
435%METADATA%
436</head>
437<body>
438<a class=\"hm-skip\" href=\"#content\">Skip to content</a>
439<button class=\"hm-theme-toggle\" aria-label=\"Switch to light mode\" title=\"Toggle dark/light mode\">&#9681;</button>
440<button class=\"hm-a11y-trigger\" aria-label=\"Accessibility options\" aria-expanded=\"false\" title=\"Accessibility\">Aa</button>
441<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>
442%BODYHEADER%
443%BODYHEADEREND%
444%NAVIGATION%
445<main id=\"content\">
446"
447}
448
449/// Returns the default footer HTML template.
450pub fn default_footer_template() -> &'static str {
451    "
452</main>
453%NAVIGATION%
454<footer role=\"contentinfo\">
455%BODYFOOTER%
456%GENERATOR%
457</footer>
458</body>
459</html>"
460}
461
462/// Returns the default article body template (`%ARTICLE%`).
463pub fn default_article_template() -> &'static str {
464    "%ARTICLE%
465"
466}
467
468/// Loads the article template from `.hm_article` in `dir`, or returns the default.
469pub fn load_article_template(dir: &str) -> String {
470    let path = Path::new(dir).join(".hm_article");
471    fs::read_to_string(&path).unwrap_or_else(|_| default_article_template().to_string())
472}
473
474/// Loads the header template from `.hm_header` in `dir`, or returns the default.
475pub fn load_header_template(dir: &str) -> String {
476    let path = Path::new(dir).join(".hm_header");
477    fs::read_to_string(&path).unwrap_or_else(|_| default_header_template().to_string())
478}
479
480/// Loads the footer template from `.hm_footer` in `dir`, or returns the default.
481pub fn load_footer_template(dir: &str) -> String {
482    let path = Path::new(dir).join(".hm_footer");
483    fs::read_to_string(&path).unwrap_or_else(|_| default_footer_template().to_string())
484}
485
486/// Loads a template file by path, returning `None` if it cannot be read.
487pub fn load_template(path: &str) -> Option<String> {
488    std::fs::read_to_string(path).ok()
489}
490
491/// Builds the initial cookie map with standard header values (title, stylesheet, metadata, etc.).
492pub fn get_header_cookies(config: &crate::config::Config, title: &str) -> CookieMap {
493    let mut cookies = CookieMap::new();
494    set_cookie(&mut cookies, "TITLE", title);
495
496    let stylesheet = if let Some(ref css) = config.css {
497        let css_path = if !css.starts_with("http") && !css.starts_with('/') {
498            config.css_path()
499        } else {
500            css.clone()
501        };
502        let mut tag = String::from("<link rel=\"stylesheet\" type=\"text/css\" href=\"");
503        tag.push_str(&escape_attr(&css_path));
504        tag.push_str("\">\n");
505        tag
506    } else {
507        String::new()
508    };
509    set_cookie(&mut cookies, "STYLESHEET", &stylesheet);
510
511    let metadata = if let Some(ref desc) = config.description {
512        let mut tag = String::from("<meta name=\"description\" content=\"");
513        tag.push_str(&crate::txt2html::escape_html(desc));
514        tag.push_str("\">\n");
515        tag
516    } else {
517        String::new()
518    };
519    set_cookie(&mut cookies, "METADATA", &metadata);
520
521    // SECURITY: These values are inserted as raw HTML. Only use from trusted config files.
522    let bodyheader = config.bodyheader.as_deref().unwrap_or("");
523    if bodyheader.to_ascii_lowercase().contains("<script") {
524        log::warn!("bodyheader contains <script> — ensure this is intentional");
525    }
526    set_cookie(&mut cookies, "BODYHEADER", bodyheader);
527
528    let bodyheaderend = config.bodyheaderend.as_deref().unwrap_or("");
529    if bodyheaderend.to_ascii_lowercase().contains("<script") {
530        log::warn!("bodyheaderend contains <script> — ensure this is intentional");
531    }
532    set_cookie(&mut cookies, "BODYHEADEREND", bodyheaderend);
533
534    set_cookie(&mut cookies, "NAVIGATION", "");
535
536    // SECURITY: These values are inserted as raw HTML. Only use from trusted config files.
537    let bodyfooter = config.bodyfooter.as_deref().unwrap_or("");
538    if bodyfooter.to_ascii_lowercase().contains("<script") {
539        log::warn!("bodyfooter contains <script> — ensure this is intentional");
540    }
541    set_cookie(&mut cookies, "BODYFOOTER", bodyfooter);
542
543    // Generator credit (suppressible via showgenerator = Off or --no-generator)
544    let generator = if config.showgenerator {
545        "<p class=\"hm-generator\">Generated by <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>"
546    } else {
547        ""
548    };
549    set_cookie(&mut cookies, "GENERATOR", generator);
550
551    // Set HTML lang attribute from configured language (defaults to "en")
552    let lang = if config.language.is_empty() {
553        "en"
554    } else {
555        &config.language
556    };
557    set_cookie(&mut cookies, "LANG", lang);
558
559    let htmlattrs = match config.theme.as_deref() {
560        Some("light") => "data-theme=\"light\"",
561        Some("dark") => "data-theme=\"dark\"",
562        _ => "",
563    };
564    set_cookie(&mut cookies, "HTMLATTRS", htmlattrs);
565
566    cookies
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_basic_substitution() {
575        let mut cookies = CookieMap::new();
576        set_cookie(&mut cookies, "TITLE", "Test Title");
577        let result = substitute_cookies("<h1>%TITLE%</h1>", &cookies);
578        assert_eq!(result, "<h1>Test Title</h1>");
579    }
580
581    #[test]
582    fn test_multiple_cookies() {
583        let mut cookies = CookieMap::new();
584        set_cookie(&mut cookies, "TITLE", "My List");
585        set_cookie(&mut cookies, "NAV", "Home");
586        let result = substitute_cookies("%NAV% - %TITLE%", &cookies);
587        assert_eq!(result, "Home - My List");
588    }
589
590    #[test]
591    fn test_unknown_cookie() {
592        let cookies = CookieMap::new();
593        let result = substitute_cookies("%UNKNOWN%", &cookies);
594        assert_eq!(result, "%UNKNOWN%");
595    }
596
597    #[test]
598    fn test_unset_cookie() {
599        let mut cookies = CookieMap::new();
600        set_cookie(&mut cookies, "TITLE", "test");
601        unset_cookie(&mut cookies, "TITLE");
602        assert!(!cookies.contains_key("TITLE"));
603    }
604
605    #[test]
606    fn test_header_cookies() {
607        let config = crate::config::Config::default();
608        let cookies = get_header_cookies(&config, "Test Archive");
609        assert_eq!(cookies.get("TITLE").unwrap(), "Test Archive");
610    }
611
612    #[test]
613    fn test_description_meta_escaped() {
614        let mut config = crate::config::Config::default();
615        config.description = Some("<script>alert('xss')</script>".to_string());
616        let cookies = get_header_cookies(&config, "Test");
617        let meta = cookies.get("METADATA").unwrap();
618        assert!(meta.contains("&lt;script&gt;"));
619        assert!(!meta.contains("<script>"));
620    }
621
622    #[test]
623    fn test_description_meta_safe() {
624        let mut config = crate::config::Config::default();
625        config.description = Some("Hello & welcome".to_string());
626        let cookies = get_header_cookies(&config, "Test");
627        let meta = cookies.get("METADATA").unwrap();
628        assert!(meta.contains("Hello &amp; welcome"));
629    }
630
631    #[test]
632    fn test_no_description_no_meta() {
633        let config = crate::config::Config::default();
634        let cookies = get_header_cookies(&config, "Test");
635        let meta = cookies.get("METADATA").unwrap();
636        assert!(meta.is_empty());
637    }
638}