Skip to main content

hypermail/
config.rs

1use crate::error::{HypermailError, Result};
2use std::path::{Path, PathBuf};
3
4pub const ANTISPAM_AT: &str = "@";
5pub const LANGUAGE: &str = "en";
6pub const HTMLSUFFIX: &str = "html";
7pub const DEFAULTINDEX: &str = "date";
8pub const INLINE_TYPES: &str = "image/gif image/jpeg image/png";
9pub const PROGRESS: i32 = 0;
10pub const MAILCOMMAND: &str = "mailto:$TO?subject=$SUBJECT&in-reply-to=$ID";
11pub const DOMAINADDR: &str = "";
12
13pub const DELETE_REMOVES_FILES: i32 = 0;
14pub const DELETE_LEAVES_STUBS: i32 = 1;
15pub const DELETE_LEAVES_EXPIRED_TEXT: i32 = 2;
16pub const DELETE_LEAVES_TEXT: i32 = 3;
17
18/// Supported configuration value types for the hypermail config parser.
19#[derive(Debug, Clone)]
20pub enum ConfigType {
21    String,
22    Switch,
23    Integer,
24    List,
25    StringList,
26    Octal,
27}
28
29/// A single configuration entry definition with metadata for parsing.
30#[derive(Debug, Clone)]
31pub struct ConfigEntry {
32    pub label: &'static str,
33    pub flags: ConfigType,
34    pub default_str: Option<&'static str>,
35    pub default_int: i64,
36    pub verbose: &'static str,
37}
38
39/// A whitespace-separated list of values used for multi-value config options.
40#[derive(Debug, Clone)]
41pub struct HmList {
42    pub values: Vec<String>,
43}
44
45impl Default for HmList {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl HmList {
52    /// Creates an empty list.
53    pub fn new() -> Self {
54        HmList { values: Vec::new() }
55    }
56
57    /// Creates a list by splitting a whitespace-delimited string.
58    pub fn from_whitespace_str(s: &str) -> Self {
59        let values: Vec<String> = s.split_whitespace().map(|s| s.to_string()).collect();
60        HmList { values }
61    }
62
63    /// Returns true if the list contains the given value.
64    pub fn contains(&self, val: &str) -> bool {
65        self.values.iter().any(|v| v == val)
66    }
67
68    /// Adds a value to the list if not already present.
69    pub fn add(&mut self, val: &str) {
70        if !self.contains(val) {
71            self.values.push(val.to_string());
72        }
73    }
74
75    /// Splits a whitespace-delimited string and adds each token to the list.
76    pub fn add_list(&mut self, val: &str) {
77        for v in val.split_whitespace() {
78            self.add(v);
79        }
80    }
81}
82
83/// Complete runtime configuration for a hypermail archive run.
84///
85/// Controls input/output paths, HTML generation options, index types,
86/// spam protection, i18n, MIME handling, and template customization.
87#[derive(Debug, Clone)]
88pub struct Config {
89    // --- String configs ---
90    pub fragment_prefix: String,
91    pub htmlmessage_deleted: Option<String>,
92    pub antispam_at: String,
93    pub antispamdomain: Option<String>,
94    pub language: String,
95    pub htmlsuffix: String,
96    pub mbox: Option<String>,
97    pub archives: Option<String>,
98    pub custom_archives: Option<String>,
99    pub about: Option<String>,
100    pub label: Option<String>,
101    pub dir: Option<String>,
102    pub defaultindex: String,
103    pub default_top_index: String,
104    pub mailcommand: String,
105    pub newmsg_command: String,
106    pub replymsg_command: String,
107    pub inreplyto_command: Option<String>,
108    pub mailto: Option<String>,
109    pub hmail: Option<String>,
110    pub domainaddr: Option<String>,
111    pub css: Option<String>,
112    pub icss_url: Option<String>,
113    pub mcss_url: Option<String>,
114    pub dateformat: Option<String>,
115    pub indexdateformat: Option<String>,
116    pub stripsubject: Option<String>,
117    pub link_to_replies: Option<String>,
118    pub quote_link_string: Option<String>,
119    pub ihtmlheader: Option<String>,
120    pub ihtmlfooter: Option<String>,
121    pub ihtmlhead: Option<String>,
122    pub ihtmlhelpup: Option<String>,
123    pub ihtmlhelplow: Option<String>,
124    pub ihtmlnavbar2up: Option<String>,
125    pub mhtmlheader: Option<String>,
126    pub mhtmlfooter: Option<String>,
127    pub attachmentlink: Option<String>,
128    pub bodyheader: Option<String>,
129    pub bodyheaderend: Option<String>,
130    pub bodyfooter: Option<String>,
131    pub unsafe_chars: Option<String>,
132    pub filename_base: Option<String>,
133    pub folder_by_date: Option<String>,
134    pub latest_folder: Option<String>,
135    pub base_url: Option<String>,
136    pub describe_folder: Option<String>,
137    pub delete_older: Option<String>,
138    pub delete_newer: Option<String>,
139    pub alts_text: Option<String>,
140    pub description: Option<String>,
141    pub theme: Option<String>,
142    pub append_filename: Option<String>,
143    pub txtsuffix: Option<String>,
144
145    // --- Switch (bool) configs ---
146    pub email_address_obfuscation: bool,
147    pub i18n: bool,
148    pub i18n_body: bool,
149    pub overwrite: bool,
150    pub inlinehtml: bool,
151    pub readone: bool,
152    pub reverse: bool,
153    pub reverse_folders: bool,
154    pub showheaders: bool,
155    pub showbr: bool,
156    pub showreplies: bool,
157    pub indextable: bool,
158    pub iquotes: bool,
159    pub eurodate: bool,
160    pub gmtime: bool,
161    pub isodate: bool,
162    pub require_msgids: bool,
163    pub discard_dup_msgids: bool,
164    pub usemeta: bool,
165    pub uselock: bool,
166    pub ietf_mbox: bool,
167    pub linkquotes: bool,
168    pub monthly_index: bool,
169    pub yearly_index: bool,
170    pub spamprotect: bool,
171    pub spamprotect_id: bool,
172    pub attachmentsindex: bool,
173    pub usegdbm: bool,
174    pub writehaof: bool,
175    pub append: bool,
176    pub nonsequential: bool,
177    pub warn_surpressions: bool,
178    pub files_by_thread: bool,
179    pub href_detection: bool,
180    pub mbox_shortened: bool,
181    pub report_new_file: bool,
182    pub report_new_folder: bool,
183    pub use_sender_date: bool,
184    pub inline_addlink: bool,
185    pub iso2022jp: bool,
186    pub delete_incremental: bool,
187    pub showgenerator: bool,
188    pub show_warnings: bool,
189    /// When true (default), link Google Fonts CDN. Set false for offline/privacy.
190    pub external_fonts: bool,
191
192    // --- Integer configs ---
193    pub increment: i32,
194    pub showhtml: i32,
195    pub show_msg_links: i32,
196    pub show_index_links: i32,
197    pub thrdlevels: i32,
198    pub dirmode: i32,
199    pub filemode: i32,
200    pub locktime: i32,
201    pub searchbackmsgnum: i32,
202    pub quote_hide_threshold: i32,
203    pub thread_file_depth: i32,
204    pub startmsgnum: i32,
205    pub msgsperfolder: i32,
206    pub save_alts: i32,
207    pub delete_level: i32,
208    pub progress: i32,
209    pub max_message_size: usize,
210    /// Hard cap on messages kept in one run (DoS guard). 0 = unlimited.
211    pub max_messages: usize,
212
213    // --- List configs ---
214    pub show_headers: HmList,
215    pub avoid_indices: HmList,
216    pub avoid_top_indices: HmList,
217    pub skip_headers: HmList,
218    pub text_types: HmList,
219    pub inline_types: HmList,
220    pub prefered_types: HmList,
221    pub ignore_types: HmList,
222    pub filter_out: HmList,
223    pub filter_require: HmList,
224    pub filter_out_full_body: HmList,
225    pub filter_require_full_body: HmList,
226    pub deleted: HmList,
227    pub expires: HmList,
228    pub delete_msgnum: HmList,
229}
230
231impl Default for Config {
232    fn default() -> Self {
233        Config {
234            fragment_prefix: "msg".to_string(),
235            htmlmessage_deleted: None,
236            antispam_at: ANTISPAM_AT.to_string(),
237            antispamdomain: None,
238            language: LANGUAGE.to_string(),
239            htmlsuffix: HTMLSUFFIX.to_string(),
240            mbox: None,
241            archives: None,
242            custom_archives: None,
243            about: None,
244            label: None,
245            dir: None,
246            defaultindex: DEFAULTINDEX.to_string(),
247            default_top_index: "folders".to_string(),
248            mailcommand: MAILCOMMAND.to_string(),
249            newmsg_command: "mailto:$TO".to_string(),
250            replymsg_command: MAILCOMMAND.to_string(),
251            inreplyto_command: None,
252            mailto: None,
253            hmail: None,
254            domainaddr: None,
255            css: None,
256            icss_url: None,
257            mcss_url: None,
258            dateformat: None,
259            indexdateformat: None,
260            stripsubject: None,
261            link_to_replies: None,
262            quote_link_string: None,
263            ihtmlheader: None,
264            ihtmlfooter: None,
265            ihtmlhead: None,
266            ihtmlhelpup: None,
267            ihtmlhelplow: None,
268            ihtmlnavbar2up: None,
269            mhtmlheader: None,
270            mhtmlfooter: None,
271            attachmentlink: None,
272            unsafe_chars: None,
273            filename_base: None,
274            folder_by_date: None,
275            latest_folder: None,
276            base_url: None,
277            describe_folder: None,
278            delete_older: None,
279            delete_newer: None,
280            alts_text: None,
281            append_filename: None,
282            txtsuffix: None,
283            description: None,
284            theme: None,
285            bodyheader: None,
286            bodyheaderend: None,
287            bodyfooter: None,
288            email_address_obfuscation: false,
289            i18n: false,
290            i18n_body: false,
291            overwrite: false,
292            inlinehtml: true,
293            readone: false,
294            reverse: false,
295            reverse_folders: false,
296            showheaders: true,
297            showbr: true,
298            showreplies: true,
299            indextable: false,
300            iquotes: true,
301            eurodate: true,
302            gmtime: false,
303            isodate: false,
304            require_msgids: true,
305            discard_dup_msgids: true,
306            usemeta: false,
307            uselock: true,
308            ietf_mbox: false,
309            linkquotes: false,
310            monthly_index: false,
311            yearly_index: false,
312            spamprotect: true,
313            spamprotect_id: true,
314            attachmentsindex: true,
315            usegdbm: false,
316            writehaof: false,
317            append: false,
318            nonsequential: false,
319            warn_surpressions: true,
320            files_by_thread: false,
321            href_detection: true,
322            mbox_shortened: false,
323            report_new_file: false,
324            report_new_folder: false,
325            use_sender_date: false,
326            inline_addlink: true,
327            iso2022jp: false,
328            delete_incremental: true,
329            showgenerator: true,
330            show_warnings: false,
331            external_fonts: true,
332            increment: 0,
333            showhtml: 1,
334            show_msg_links: 1,
335            show_index_links: 1,
336            thrdlevels: 50, // High default to show full tree structure
337            dirmode: 0o755,
338            filemode: 0o644,
339            locktime: 3600,
340            searchbackmsgnum: 500,
341            quote_hide_threshold: 100,
342            thread_file_depth: 0,
343            startmsgnum: 0,
344            msgsperfolder: 0,
345            save_alts: 0,
346            delete_level: DELETE_LEAVES_TEXT,
347            progress: PROGRESS,
348            max_message_size: 100 * 1024 * 1024,
349            max_messages: 1_000_000,
350            show_headers: HmList::new(),
351            avoid_indices: HmList::new(),
352            avoid_top_indices: HmList::new(),
353            skip_headers: HmList::new(),
354            text_types: HmList::new(),
355            inline_types: HmList::from_whitespace_str(INLINE_TYPES),
356            deleted: HmList::from_whitespace_str("X-Hypermail-Deleted X-No-Archive"),
357            expires: HmList::from_whitespace_str("Expires"),
358            delete_msgnum: HmList::new(),
359            filter_out: HmList::new(),
360            filter_require: HmList::new(),
361            filter_out_full_body: HmList::new(),
362            filter_require_full_body: HmList::new(),
363            prefered_types: HmList::new(),
364            ignore_types: HmList::new(),
365        }
366    }
367}
368
369impl Config {
370    /// Sets a string configuration value by key name.
371    pub fn set_string(&mut self, key: &str, val: &str) -> Result<()> {
372        match key {
373            "fragment_prefix" => self.fragment_prefix = val.to_string(),
374            "htmlmessage_deleted" => self.htmlmessage_deleted = Some(val.to_string()),
375            "antispam_at" => self.antispam_at = val.to_string(),
376            "antispamdomain" => {
377                if val == "NONE" || val.is_empty() {
378                    self.antispamdomain = None;
379                } else {
380                    self.antispamdomain = Some(val.to_string());
381                }
382            },
383            "language" => self.language = val.to_string(),
384            "htmlsuffix" => self.htmlsuffix = val.to_string(),
385            "mbox" => {
386                if val == "NONE" {
387                    self.mbox = None;
388                } else {
389                    self.mbox = Some(val.to_string());
390                }
391            },
392            "archives" => {
393                if val == "NONE" {
394                    self.archives = None;
395                } else {
396                    self.archives = Some(val.to_string());
397                }
398            },
399            "custom_archives" => {
400                if val == "NONE" {
401                    self.custom_archives = None;
402                } else {
403                    self.custom_archives = Some(val.to_string());
404                }
405            },
406            "about" => {
407                if val == "NONE" {
408                    self.about = None;
409                } else {
410                    self.about = Some(val.to_string());
411                }
412            },
413            "label" => {
414                if val == "NONE" {
415                    self.label = None;
416                } else {
417                    self.label = Some(val.to_string());
418                }
419            },
420            "dir" => {
421                if val == "NONE" {
422                    self.dir = None;
423                } else {
424                    self.dir = Some(val.to_string());
425                }
426            },
427            "defaultindex" => self.defaultindex = val.to_string(),
428            "default_top_index" => self.default_top_index = val.to_string(),
429            "mailcommand" => self.mailcommand = val.to_string(),
430            "newmsg_command" => self.newmsg_command = val.to_string(),
431            "replymsg_command" => self.replymsg_command = val.to_string(),
432            "inreplyto_command" => self.inreplyto_command = Some(val.to_string()),
433            "mailto" => {
434                if val == "NONE" {
435                    self.mailto = None;
436                } else {
437                    self.mailto = Some(val.to_string());
438                }
439            },
440            "hmail" => {
441                if val == "NONE" {
442                    self.hmail = None;
443                } else {
444                    self.hmail = Some(val.to_string());
445                }
446            },
447            "domainaddr" => {
448                if val == "NONE" {
449                    self.domainaddr = None;
450                } else {
451                    self.domainaddr = Some(val.to_string());
452                }
453            },
454            "css" => self.css = Some(val.to_string()),
455            "icss_url" => self.icss_url = Some(val.to_string()),
456            "mcss_url" => self.mcss_url = Some(val.to_string()),
457            "dateformat" => self.dateformat = Some(val.to_string()),
458            "indexdateformat" => self.indexdateformat = Some(val.to_string()),
459            "stripsubject" => self.stripsubject = Some(val.to_string()),
460            "link_to_replies" => self.link_to_replies = Some(val.to_string()),
461            "quote_link_string" => self.quote_link_string = Some(val.to_string()),
462            "ihtmlheaderfile" => self.ihtmlheader = Some(val.to_string()),
463            "ihtmlfooterfile" => self.ihtmlfooter = Some(val.to_string()),
464            "ihtmlheadfile" => self.ihtmlhead = Some(val.to_string()),
465            "ihtmlhelpupfile" => self.ihtmlhelpup = Some(val.to_string()),
466            "ihtmlhelplowfile" => self.ihtmlhelplow = Some(val.to_string()),
467            "ihtmlnavbar2upfile" => self.ihtmlnavbar2up = Some(val.to_string()),
468            "mhtmlheaderfile" => self.mhtmlheader = Some(val.to_string()),
469            "mhtmlfooterfile" => self.mhtmlfooter = Some(val.to_string()),
470            // Aliases that apply to both index and message pages simultaneously.
471            "htmlheaderfile" => {
472                self.ihtmlheader = Some(val.to_string());
473                self.mhtmlheader = Some(val.to_string());
474            },
475            "htmlfooterfile" => {
476                self.ihtmlfooter = Some(val.to_string());
477                self.mhtmlfooter = Some(val.to_string());
478            },
479            "attachmentlink" => self.attachmentlink = Some(val.to_string()),
480            "unsafe_chars" => self.unsafe_chars = Some(val.to_string()),
481            "description" => self.description = Some(val.to_string()),
482            "theme" => self.theme = Some(val.to_string()),
483            "bodyheader" => self.bodyheader = Some(val.to_string()),
484            "bodyheaderend" => self.bodyheaderend = Some(val.to_string()),
485            "bodyfooter" => self.bodyfooter = Some(val.to_string()),
486            "filename_base" => self.filename_base = Some(val.to_string()),
487            "folder_by_date" => {
488                if val.is_empty() || val == "NONE" {
489                    self.folder_by_date = None;
490                } else {
491                    self.folder_by_date = Some(val.to_string());
492                }
493            },
494            "latest_folder" => self.latest_folder = Some(val.to_string()),
495            "base_url" => self.base_url = Some(val.to_string()),
496            "describe_folder" => self.describe_folder = Some(val.to_string()),
497            "delete_older" => {
498                self.delete_older = if val.is_empty() {
499                    None
500                } else {
501                    Some(val.to_string())
502                }
503            },
504            "delete_newer" => {
505                self.delete_newer = if val.is_empty() {
506                    None
507                } else {
508                    Some(val.to_string())
509                }
510            },
511            "alts_text" => self.alts_text = Some(val.to_string()),
512            "append_filename" => self.append_filename = Some(val.to_string()),
513            "txtsuffix" => self.txtsuffix = Some(val.to_string()),
514            _ => {
515                return Err(HypermailError::InvalidConfigValue {
516                    key: key.to_string(),
517                    message: format!("unknown string config key: {}", key),
518                })
519            },
520        }
521        Ok(())
522    }
523
524    /// Sets a boolean switch configuration value by key name.
525    pub fn set_switch(&mut self, key: &str, val: bool) -> Result<()> {
526        match key {
527            "email_address_obfuscation" => self.email_address_obfuscation = val,
528            "i18n" => self.i18n = val,
529            "i18n_body" => self.i18n_body = val,
530            "overwrite" => self.overwrite = val,
531            "inlinehtml" => self.inlinehtml = val,
532            "readone" => self.readone = val,
533            "reverse" => self.reverse = val,
534            "reverse_folders" => self.reverse_folders = val,
535            "showheaders" => self.showheaders = val,
536            "showbr" => self.showbr = val,
537            "showreplies" => self.showreplies = val,
538            "indextable" => self.indextable = val,
539            "iquotes" => self.iquotes = val,
540            "eurodate" => self.eurodate = val,
541            "gmtime" => self.gmtime = val,
542            "isodate" => self.isodate = val,
543            "require_msgids" => self.require_msgids = val,
544            "discard_dup_msgids" => self.discard_dup_msgids = val,
545            "usemeta" => self.usemeta = val,
546            "uselock" => self.uselock = val,
547            "ietf_mbox" => self.ietf_mbox = val,
548            "linkquotes" => self.linkquotes = val,
549            "monthly_index" => self.monthly_index = val,
550            "yearly_index" => self.yearly_index = val,
551            "spamprotect" => self.spamprotect = val,
552            "spamprotect_id" => self.spamprotect_id = val,
553            "attachmentsindex" => self.attachmentsindex = val,
554            "usegdbm" => self.usegdbm = val,
555            "writehaof" => self.writehaof = val,
556            "append" => self.append = val,
557            "nonsequential" => self.nonsequential = val,
558            "warn_surpressions" => self.warn_surpressions = val,
559            "files_by_thread" => self.files_by_thread = val,
560            "href_detection" => self.href_detection = val,
561            "mbox_shortened" => self.mbox_shortened = val,
562            "report_new_file" => self.report_new_file = val,
563            "report_new_folder" => self.report_new_folder = val,
564            "use_sender_date" => self.use_sender_date = val,
565            "inline_addlink" => self.inline_addlink = val,
566            "iso2022jp" => self.iso2022jp = val,
567            "delete_incremental" => self.delete_incremental = val,
568            "showgenerator" => self.showgenerator = val,
569            "show_warnings" => self.show_warnings = val,
570            "external_fonts" => self.external_fonts = val,
571            _ => {
572                return Err(HypermailError::InvalidConfigValue {
573                    key: key.to_string(),
574                    message: format!("unknown switch config key: {}", key),
575                })
576            },
577        }
578        Ok(())
579    }
580
581    /// Sets an integer configuration value by key name.
582    pub fn set_integer(&mut self, key: &str, val: i64) -> Result<()> {
583        match key {
584            "increment" => self.increment = val as i32,
585            "showhtml" => self.showhtml = val as i32,
586            "show_msg_links" => self.show_msg_links = val as i32,
587            "show_index_links" => self.show_index_links = val as i32,
588            "thrdlevels" => self.thrdlevels = val as i32,
589            "dirmode" => self.dirmode = val as i32,
590            "filemode" => self.filemode = val as i32,
591            "locktime" => self.locktime = val as i32,
592            "searchbackmsgnum" => self.searchbackmsgnum = val as i32,
593            "quote_hide_threshold" => self.quote_hide_threshold = val as i32,
594            "thread_file_depth" => self.thread_file_depth = val as i32,
595            "startmsgnum" => self.startmsgnum = val as i32,
596            "msgsperfolder" => self.msgsperfolder = val as i32,
597            "save_alts" => self.save_alts = val as i32,
598            "delete_level" => self.delete_level = val as i32,
599            "progress" => self.progress = val as i32,
600            "max_message_size" => self.max_message_size = val as usize,
601            "max_messages" => self.max_messages = val as usize,
602            _ => {
603                return Err(HypermailError::InvalidConfigValue {
604                    key: key.to_string(),
605                    message: format!("unknown integer config key: {}", key),
606                })
607            },
608        }
609        Ok(())
610    }
611
612    /// Appends whitespace-separated values to a list configuration by key name.
613    pub fn set_list(&mut self, key: &str, val: &str) -> Result<()> {
614        let list = match key {
615            "show_headers" => &mut self.show_headers,
616            "avoid_indices" => &mut self.avoid_indices,
617            "avoid_top_indices" => &mut self.avoid_top_indices,
618            "text_types" => &mut self.text_types,
619            "inline_types" => &mut self.inline_types,
620            "prefered_types" => &mut self.prefered_types,
621            "ignore_types" => &mut self.ignore_types,
622            "filter_out" => &mut self.filter_out,
623            "filter_require" => &mut self.filter_require,
624            "filter_out_full_body" => &mut self.filter_out_full_body,
625            "filter_require_full_body" => &mut self.filter_require_full_body,
626            "deleted" => &mut self.deleted,
627            "expires" => &mut self.expires,
628            "delete_msgnum" => &mut self.delete_msgnum,
629            _ => {
630                return Err(HypermailError::InvalidConfigValue {
631                    key: key.to_string(),
632                    message: format!("unknown list config key: {}", key),
633                })
634            },
635        };
636        list.add_list(val);
637        Ok(())
638    }
639
640    /// Applies a CLI argument, auto-detecting the value type (bool/int/string/list).
641    ///
642    /// Supports `key=value` syntax, `hm_` and `set_` prefixes, and ON/OFF/YES/NO values.
643    pub fn apply_cli_arg(&mut self, key: &str, val: &str) -> Result<()> {
644        let (actual_key, actual_val) = if let Some(eq_pos) = key.find('=') {
645            let k = &key[..eq_pos];
646            let v = &key[eq_pos + 1..];
647            (k, v)
648        } else {
649            (key, val)
650        };
651
652        let actual_key = actual_key.strip_prefix("hm_").unwrap_or(actual_key);
653        let actual_key = actual_key.strip_prefix("set_").unwrap_or(actual_key);
654
655        // Deprecated keys from C hypermail that are no longer functional.
656        // Return a recognizable error so callers can emit a deprecation warning.
657        if matches!(actual_key, "showhr" | "usetable" | "body") {
658            return Err(HypermailError::InvalidConfigValue {
659                key: actual_key.to_string(),
660                message: "deprecated config key — has no effect; remove from your config file"
661                    .to_string(),
662            });
663        }
664        let actual_val = actual_val.trim();
665
666        if actual_val == "ON"
667            || actual_val == "YES"
668            || actual_val == "On"
669            || actual_val == "Yes"
670            || actual_val == "on"
671            || actual_val == "yes"
672        {
673            if let Ok(()) = self.set_switch(actual_key, true) {
674                return Ok(());
675            }
676            return self
677                .set_integer(actual_key, 1)
678                .or_else(|_| self.set_string(actual_key, actual_val));
679        }
680        if (actual_val == "OFF"
681            || actual_val == "NO"
682            || actual_val == "Off"
683            || actual_val == "No"
684            || actual_val == "off"
685            || actual_val == "no")
686            && self.set_switch(actual_key, false).is_ok()
687        {
688            return Ok(());
689        }
690
691        // Octal parsing for permission modes (dirmode, filemode)
692        // Always parse as octal — "755" means 0o755, with or without leading zero
693        if actual_key == "dirmode" || actual_key == "filemode" {
694            let octal_str = actual_val.strip_prefix('0').unwrap_or(actual_val);
695            if let Ok(i) = i64::from_str_radix(octal_str, 8) {
696                if self.set_integer(actual_key, i).is_ok() {
697                    return Ok(());
698                }
699            }
700        }
701
702        let int_val = actual_val.parse::<i64>();
703        if let Ok(i) = int_val {
704            if self.set_integer(actual_key, i).is_ok() {
705                return Ok(());
706            }
707        }
708        if self.set_string(actual_key, actual_val).is_ok() {
709            return Ok(());
710        }
711        if self.set_list(actual_key, actual_val).is_ok() {
712            return Ok(());
713        }
714
715        Err(HypermailError::InvalidConfigValue {
716            key: actual_key.to_string(),
717            message: format!("unrecognized config key or invalid value: {}", actual_val),
718        })
719    }
720
721    /// Loads configuration from environment variables prefixed with `HM_`.
722    pub fn load_env(&mut self) {
723        self.load_env_from(std::env::vars());
724    }
725
726    /// Loads configuration from an iterator of `(key, value)` pairs prefixed with `HM_`.
727    /// Used directly in tests to avoid `set_var`/`remove_var` races.
728    fn load_env_from(&mut self, vars: impl Iterator<Item = (String, String)>) {
729        for (key, val) in vars {
730            if let Some(stripped) = key.strip_prefix("HM_") {
731                let config_key = stripped.to_lowercase();
732                let _ = self.apply_cli_arg(&config_key, &val);
733            }
734        }
735    }
736
737    /// Applies post-processing defaults (e.g., always-skipped headers).
738    pub fn post_process(&mut self) {
739        self.skip_headers.add("from");
740        self.skip_headers.add("date");
741        self.skip_headers.add("subject");
742    }
743
744    /// Returns the resolved CSS path, joining with `dir` if relative.
745    pub fn css_path(&self) -> String {
746        if let Some(ref css) = self.css {
747            if css.starts_with("http") || Path::new(css).is_absolute() {
748                css.clone()
749            } else if let Some(ref dir) = self.dir {
750                PathBuf::from(dir).join(css).to_string_lossy().into_owned()
751            } else {
752                css.clone()
753            }
754        } else {
755            String::new()
756        }
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    #[test]
765    fn test_default_config() {
766        let cfg = Config::default();
767        assert_eq!(cfg.language, "en");
768        assert_eq!(cfg.htmlsuffix, "html");
769        assert_eq!(cfg.defaultindex, "date");
770        assert!(cfg.inlinehtml);
771        assert!(!cfg.overwrite);
772        assert_eq!(cfg.showhtml, 1);
773        assert_eq!(cfg.thrdlevels, 50); // Changed from 4 to 50 for deeper thread display
774        assert_eq!(cfg.dirmode, 0o755);
775        assert_eq!(cfg.filemode, 0o644);
776        assert_eq!(cfg.locktime, 3600);
777        assert_eq!(cfg.searchbackmsgnum, 500);
778        assert_eq!(cfg.quote_hide_threshold, 100);
779        assert_eq!(cfg.delete_level, DELETE_LEAVES_TEXT);
780        assert!(cfg.discard_dup_msgids);
781        assert!(cfg.require_msgids);
782        assert!(cfg.uselock);
783        assert!(cfg.href_detection);
784        assert!(cfg.warn_surpressions);
785        assert!(cfg.attachmentsindex);
786        assert!(cfg.spamprotect);
787        assert!(cfg.spamprotect_id);
788        assert!(cfg.showbr);
789        assert!(cfg.showreplies);
790        assert!(cfg.inline_addlink);
791        assert!(cfg.delete_incremental);
792        assert_eq!(cfg.fragment_prefix, "msg");
793        assert_eq!(cfg.antispam_at, "@");
794        assert_eq!(cfg.progress, 0);
795        assert!(cfg.inline_types.contains("image/gif"));
796        assert!(cfg.deleted.contains("X-Hypermail-Deleted"));
797        assert!(cfg.expires.contains("Expires"));
798    }
799
800    #[test]
801    fn test_set_string() {
802        let mut cfg = Config::default();
803        cfg.set_string("language", "de").unwrap();
804        assert_eq!(cfg.language, "de");
805        cfg.set_string("mbox", "NONE").unwrap();
806        assert!(cfg.mbox.is_none());
807        cfg.set_string("label", "test list").unwrap();
808        assert_eq!(cfg.label.as_deref(), Some("test list"));
809    }
810
811    #[test]
812    fn test_set_switch() {
813        let mut cfg = Config::default();
814        assert!(!cfg.overwrite);
815        cfg.set_switch("overwrite", true).unwrap();
816        assert!(cfg.overwrite);
817    }
818
819    #[test]
820    fn test_set_integer() {
821        let mut cfg = Config::default();
822        cfg.set_integer("showhtml", 2).unwrap();
823        assert_eq!(cfg.showhtml, 2);
824        cfg.set_integer("thrdlevels", 8).unwrap();
825        assert_eq!(cfg.thrdlevels, 8);
826    }
827
828    #[test]
829    fn test_set_list() {
830        let mut cfg = Config::default();
831        cfg.set_list("text_types", "text/html text/plain").unwrap();
832        assert!(cfg.text_types.contains("text/html"));
833        assert!(cfg.text_types.contains("text/plain"));
834    }
835
836    #[test]
837    fn test_apply_cli_arg() {
838        let mut cfg = Config::default();
839        cfg.apply_cli_arg("overwrite", "On").unwrap();
840        assert!(cfg.overwrite);
841        cfg.apply_cli_arg("showhtml", "2").unwrap();
842        assert_eq!(cfg.showhtml, 2);
843        cfg.apply_cli_arg("language=de", "").unwrap();
844        assert_eq!(cfg.language, "de");
845    }
846
847    #[test]
848    fn test_post_process() {
849        let mut cfg = Config::default();
850        cfg.post_process();
851        assert!(cfg.skip_headers.contains("from"));
852        assert!(cfg.skip_headers.contains("date"));
853        assert!(cfg.skip_headers.contains("subject"));
854    }
855
856    #[test]
857    fn test_unknown_key() {
858        let mut cfg = Config::default();
859        assert!(cfg.set_string("nonexistent", "value").is_err());
860        assert!(cfg.set_switch("nonexistent", true).is_err());
861        assert!(cfg.set_integer("nonexistent", 42).is_err());
862    }
863
864    #[test]
865    fn test_none_strings() {
866        let mut cfg = Config::default();
867        cfg.set_string("archives", "NONE").unwrap();
868        assert!(cfg.archives.is_none());
869        cfg.set_string("about", "NONE").unwrap();
870        assert!(cfg.about.is_none());
871        cfg.set_string("custom_archives", "NONE").unwrap();
872        assert!(cfg.custom_archives.is_none());
873    }
874
875    #[test]
876    fn test_apply_cli_hm_prefix() {
877        let mut cfg = Config::default();
878        cfg.apply_cli_arg("hm_overwrite", "On").unwrap();
879        assert!(cfg.overwrite);
880    }
881
882    #[test]
883    fn test_apply_cli_set_prefix() {
884        let mut cfg = Config::default();
885        cfg.apply_cli_arg("set_overwrite", "On").unwrap();
886        assert!(cfg.overwrite);
887    }
888
889    #[test]
890    fn test_apply_cli_bool_yes() {
891        let mut cfg = Config::default();
892        cfg.apply_cli_arg("overwrite", "YES").unwrap();
893        assert!(cfg.overwrite);
894    }
895
896    #[test]
897    fn test_apply_cli_bool_no() {
898        let mut cfg = Config::default();
899        assert!(cfg.inlinehtml);
900        cfg.apply_cli_arg("inlinehtml", "OFF").unwrap();
901        assert!(!cfg.inlinehtml);
902    }
903
904    #[test]
905    fn test_apply_cli_octal_dirmode() {
906        let mut cfg = Config::default();
907        cfg.apply_cli_arg("dirmode", "0755").unwrap();
908        assert_eq!(cfg.dirmode, 0o755);
909    }
910
911    #[test]
912    fn test_apply_cli_octal_filemode() {
913        let mut cfg = Config::default();
914        cfg.apply_cli_arg("filemode", "0644").unwrap();
915        assert_eq!(cfg.filemode, 0o644);
916    }
917
918    #[test]
919    fn test_apply_cli_decimal_dirmode() {
920        let mut cfg = Config::default();
921        // "755" without leading zero is still parsed as octal for dirmode/filemode
922        cfg.apply_cli_arg("dirmode", "755").unwrap();
923        assert_eq!(cfg.dirmode, 0o755);
924    }
925
926    #[test]
927    fn test_apply_cli_inline_eq() {
928        let mut cfg = Config::default();
929        cfg.apply_cli_arg("language=fr", "").unwrap();
930        assert_eq!(cfg.language, "fr");
931    }
932
933    #[test]
934    fn test_apply_cli_list() {
935        let mut cfg = Config::default();
936        cfg.apply_cli_arg("filter_out", "spam").unwrap();
937        assert!(cfg.filter_out.contains("spam"));
938    }
939
940    #[test]
941    fn test_apply_cli_with_quoted_value() {
942        let mut cfg = Config::default();
943        cfg.apply_cli_arg("label", "\"My Archive\"").unwrap();
944        assert_eq!(cfg.label.as_deref(), Some("\"My Archive\""));
945    }
946
947    #[test]
948    fn test_apply_cli_unknown_key() {
949        let mut cfg = Config::default();
950        assert!(cfg.apply_cli_arg("nonexistent", "value").is_err());
951    }
952
953    #[test]
954    fn test_htmlheaderfile_sets_both_i_and_m() {
955        let mut cfg = Config::default();
956        cfg.apply_cli_arg("htmlheaderfile", "/path/to/header.html").unwrap();
957        assert_eq!(cfg.ihtmlheader.as_deref(), Some("/path/to/header.html"));
958        assert_eq!(cfg.mhtmlheader.as_deref(), Some("/path/to/header.html"));
959    }
960
961    #[test]
962    fn test_htmlfooterfile_sets_both_i_and_m() {
963        let mut cfg = Config::default();
964        cfg.apply_cli_arg("htmlfooterfile", "/path/to/footer.html").unwrap();
965        assert_eq!(cfg.ihtmlfooter.as_deref(), Some("/path/to/footer.html"));
966        assert_eq!(cfg.mhtmlfooter.as_deref(), Some("/path/to/footer.html"));
967    }
968
969    #[test]
970    fn test_htmlheaderfile_does_not_override_specific_variants() {
971        let mut cfg = Config::default();
972        cfg.apply_cli_arg("ihtmlheaderfile", "/index/header.html").unwrap();
973        cfg.apply_cli_arg("htmlheaderfile", "/shared/header.html").unwrap();
974        // htmlheaderfile overwrites both — if you want per-page control, use the specific keys
975        assert_eq!(cfg.ihtmlheader.as_deref(), Some("/shared/header.html"));
976        assert_eq!(cfg.mhtmlheader.as_deref(), Some("/shared/header.html"));
977    }
978
979    #[test]
980    fn test_deprecated_showhr_returns_deprecation_error() {
981        let mut cfg = Config::default();
982        let err = cfg.apply_cli_arg("showhr", "1").unwrap_err();
983        assert!(
984            err.to_string().contains("deprecated config key"),
985            "expected deprecation message, got: {err}"
986        );
987    }
988
989    #[test]
990    fn test_deprecated_usetable_returns_deprecation_error() {
991        let mut cfg = Config::default();
992        let err = cfg.apply_cli_arg("usetable", "1").unwrap_err();
993        assert!(
994            err.to_string().contains("deprecated config key"),
995            "expected deprecation message, got: {err}"
996        );
997    }
998
999    #[test]
1000    fn test_deprecated_body_returns_deprecation_error() {
1001        let mut cfg = Config::default();
1002        let err = cfg.apply_cli_arg("body", "1").unwrap_err();
1003        assert!(
1004            err.to_string().contains("deprecated config key"),
1005            "expected deprecation message, got: {err}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_env_loading() {
1011        let mut cfg = Config::default();
1012        let vars = vec![("HM_LANGUAGE".to_string(), "de".to_string())];
1013        cfg.load_env_from(vars.into_iter());
1014        assert_eq!(cfg.language, "de");
1015    }
1016
1017    #[test]
1018    fn test_set_all_switches() {
1019        let mut cfg = Config::default();
1020        for (key, initial) in &[
1021            ("email_address_obfuscation", false),
1022            ("i18n", true),
1023            ("i18n_body", false),
1024            ("overwrite", false),
1025            ("inlinehtml", true),
1026            ("readone", false),
1027            ("reverse", false),
1028            ("reverse_folders", false),
1029            ("showheaders", true),
1030            ("showbr", true),
1031            ("showreplies", true),
1032            ("indextable", false),
1033            ("iquotes", true),
1034            ("eurodate", true),
1035            ("gmtime", false),
1036            ("isodate", false),
1037            ("require_msgids", true),
1038            ("discard_dup_msgids", true),
1039            ("usemeta", false),
1040            ("uselock", true),
1041            ("ietf_mbox", false),
1042            ("linkquotes", false),
1043            ("monthly_index", false),
1044            ("yearly_index", false),
1045            ("spamprotect", true),
1046            ("spamprotect_id", true),
1047            ("attachmentsindex", true),
1048            ("usegdbm", false),
1049            ("writehaof", false),
1050            ("append", false),
1051            ("nonsequential", false),
1052            ("warn_surpressions", true),
1053            ("files_by_thread", false),
1054            ("href_detection", true),
1055            ("mbox_shortened", false),
1056            ("report_new_file", false),
1057            ("report_new_folder", false),
1058            ("use_sender_date", false),
1059            ("inline_addlink", true),
1060            ("iso2022jp", false),
1061            ("delete_incremental", true),
1062            ("showgenerator", true),
1063            ("show_warnings", false),
1064            ("external_fonts", true),
1065        ] {
1066            assert!(cfg.set_switch(key, *initial).is_ok(), "switch {} should exist", key);
1067        }
1068    }
1069
1070    #[test]
1071    fn test_set_all_integers() {
1072        let mut cfg = Config::default();
1073        for key in &[
1074            "increment",
1075            "showhtml",
1076            "show_msg_links",
1077            "show_index_links",
1078            "thrdlevels",
1079            "dirmode",
1080            "filemode",
1081            "locktime",
1082            "searchbackmsgnum",
1083            "quote_hide_threshold",
1084            "thread_file_depth",
1085            "startmsgnum",
1086            "msgsperfolder",
1087            "save_alts",
1088            "delete_level",
1089            "progress",
1090            "max_message_size",
1091            "max_messages",
1092        ] {
1093            assert!(cfg.set_integer(key, 1).is_ok(), "integer {} should exist", key);
1094        }
1095    }
1096
1097    #[test]
1098    fn test_set_all_strings() {
1099        let mut cfg = Config::default();
1100        for key in &[
1101            "fragment_prefix",
1102            "htmlmessage_deleted",
1103            "antispam_at",
1104            "antispamdomain",
1105            "language",
1106            "htmlsuffix",
1107            "mbox",
1108            "archives",
1109            "custom_archives",
1110            "about",
1111            "label",
1112            "dir",
1113            "defaultindex",
1114            "default_top_index",
1115            "mailcommand",
1116            "newmsg_command",
1117            "replymsg_command",
1118            "inreplyto_command",
1119            "mailto",
1120            "hmail",
1121            "domainaddr",
1122            "css",
1123            "icss_url",
1124            "mcss_url",
1125            "dateformat",
1126            "indexdateformat",
1127            "stripsubject",
1128            "link_to_replies",
1129            "quote_link_string",
1130            "ihtmlheaderfile",
1131            "ihtmlfooterfile",
1132            "ihtmlheadfile",
1133            "ihtmlhelpupfile",
1134            "ihtmlhelplowfile",
1135            "ihtmlnavbar2upfile",
1136            "mhtmlheaderfile",
1137            "mhtmlfooterfile",
1138            "htmlheaderfile",
1139            "htmlfooterfile",
1140            "attachmentlink",
1141            "bodyheader",
1142            "bodyheaderend",
1143            "bodyfooter",
1144            "unsafe_chars",
1145            "filename_base",
1146            "folder_by_date",
1147            "latest_folder",
1148            "base_url",
1149            "describe_folder",
1150            "delete_older",
1151            "delete_newer",
1152            "alts_text",
1153            "description",
1154            "theme",
1155            "append_filename",
1156            "txtsuffix",
1157        ] {
1158            assert!(cfg.set_string(key, "test").is_ok(), "string {} should exist", key);
1159        }
1160    }
1161
1162    #[test]
1163    fn test_set_all_lists() {
1164        let mut cfg = Config::default();
1165        for key in &[
1166            "show_headers",
1167            "avoid_indices",
1168            "avoid_top_indices",
1169            "text_types",
1170            "inline_types",
1171            "prefered_types",
1172            "ignore_types",
1173            "filter_out",
1174            "filter_require",
1175            "filter_out_full_body",
1176            "filter_require_full_body",
1177            "deleted",
1178            "expires",
1179            "delete_msgnum",
1180        ] {
1181            assert!(cfg.set_list(key, "test").is_ok(), "list {} should exist", key);
1182        }
1183    }
1184
1185    #[test]
1186    fn test_config_file_content_can_parse() {
1187        let config_content = "\
1188# comment
1189set language=de
1190hm_overwrite=On
1191nonsequential On
1192dirmode 0755
1193mbox mailbox/test
1194label \"My Archive\"
1195";
1196        // Verify each line can be parsed via apply_cli_arg
1197        let mut cfg = Config::default();
1198        for line in config_content.lines() {
1199            let line = line.trim();
1200            if line.is_empty() || line.starts_with('#') {
1201                continue;
1202            }
1203            let line = line.strip_prefix("set ").unwrap_or(line);
1204            let eq_pos = line.find('=').or_else(|| line.find(':'));
1205            if let Some(eq_pos) = eq_pos {
1206                let key = line[..eq_pos].trim();
1207                let val = line[eq_pos + 1..].trim();
1208                let val = val.trim_matches('"');
1209                cfg.apply_cli_arg(key, val).unwrap_or_else(|e| {
1210                    panic!("Failed to parse line '{}': {e}", line);
1211                });
1212            }
1213        }
1214        assert_eq!(cfg.language, "de");
1215        assert!(cfg.overwrite);
1216    }
1217
1218    #[test]
1219    fn test_antispamdomain_roundtrip() {
1220        let mut cfg = Config::default();
1221        cfg.set_string("antispamdomain", "nospam.invalid").unwrap();
1222        assert_eq!(cfg.antispamdomain.as_deref(), Some("nospam.invalid"));
1223    }
1224
1225    #[test]
1226    fn test_antispamdomain_none_on_empty() {
1227        let mut cfg = Config::default();
1228        cfg.set_string("antispamdomain", "").unwrap();
1229        assert!(cfg.antispamdomain.is_none());
1230    }
1231
1232    #[test]
1233    fn test_antispamdomain_none_on_keyword() {
1234        let mut cfg = Config::default();
1235        cfg.set_string("antispamdomain", "NONE").unwrap();
1236        assert!(cfg.antispamdomain.is_none());
1237    }
1238}