1use std::collections::HashMap;
2
3use crate::i18n::I18n;
4
5pub type CookieMap = HashMap<String, String>;
6
7pub struct PrintfileData<'a> {
10 pub label: &'a str,
12 pub subject: &'a str,
14 pub dir: &'a str,
16 pub name: Option<&'a str>,
18 pub email: Option<&'a str>,
20 pub msgid: Option<&'a str>,
22 pub charset: Option<&'a str>,
24 pub date: Option<&'a str>,
26 pub display_date: Option<&'a str>,
28 pub filename: Option<&'a str>,
30 pub archives: Option<&'a str>,
32 pub about: Option<&'a str>,
34 pub mailto: Option<&'a str>,
36 pub language: &'a str,
38 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
46pub 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 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 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 result.push_str(&crate::txt2html::escape_html(email));
150 }
151 i += 2;
152 },
153 'f' => {
154 if let Some(fname) = data.filename {
155 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 result.push_str(&crate::txt2html::escape_html(msgid));
176 }
177 i += 2;
178 },
179 'l' => {
180 result.push_str(&crate::txt2html::escape_html(data.label));
182 i += 2;
183 },
184 'm' => {
185 if let Some(mailto) = data.mailto {
186 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' => {
218 if let Some(name) = data.name {
219 result.push_str(&crate::txt2html::escape_html(name));
220 }
221 i += 2;
222 },
223 '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' => {
232 let i18n = I18n::new(data.language);
233 result.push_str(i18n.get("Author"));
234 i += 2;
235 },
236 'j' => {
238 let i18n = I18n::new(data.language);
239 result.push_str(i18n.get("Subject"));
240 i += 2;
241 },
242 'k' => {
244 let i18n = I18n::new(data.language);
245 result.push_str(i18n.get("Date"));
246 i += 2;
247 },
248 'w' => {
250 let i18n = I18n::new(data.language);
251 result.push_str(i18n.get("Generated by"));
252 i += 2;
253 },
254 '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 "{} <<a href=\"mailto:{}\">{}</a>>",
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 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
311fn 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("&"),
317 '"' => result.push_str("""),
318 '<' => result.push_str("<"),
319 '>' => result.push_str(">"),
320 '\'' => result.push_str("'"),
321 c => result.push(c),
322 }
323 }
324 result
325}
326
327pub fn substitute_cookies(template: &str, cookies: &CookieMap) -> String {
333 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 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
362pub fn set_cookie(cookies: &mut CookieMap, key: &str, value: &str) {
364 cookies.insert(key.to_string(), value.to_string());
365}
366
367pub fn unset_cookie(cookies: &mut CookieMap, key: &str) {
369 cookies.remove(key);
370}
371
372pub 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\">◑</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↓</button><button data-a11y=\"size-up\" aria-label=\"Increase font size\" title=\"Larger\">A↑</button><button data-a11y=\"line-height\" aria-label=\"Toggle line spacing\" aria-pressed=\"false\" title=\"Line spacing\">☰</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\">↺</button></div>
479%BODYHEADER%
480%BODYHEADEREND%
481%NAVIGATION%
482<main id=\"content\">
483<h1 class=\"hm-sr-only\">%TITLE%</h1>
484"
485}
486
487pub 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
500pub fn default_article_template() -> &'static str {
502 "%ARTICLE%
503"
504}
505
506pub 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 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 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 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 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 #[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("</title>"));
674 assert!(title.contains("<script>"));
675 let html = substitute_cookies("<title>%TITLE%</title>", &cookies);
676 assert!(!html.contains("<script>alert"));
677 assert!(html.contains("<script>"));
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("<script>"));
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 & 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 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}