diff --git a/services/reports/.gitignore b/services/reports/.gitignore --- a/services/reports/.gitignore +++ b/services/reports/.gitignore @@ -1 +1,2 @@ target +email-config.json diff --git a/services/reports/src/config.rs b/services/reports/src/config.rs --- a/services/reports/src/config.rs +++ b/services/reports/src/config.rs @@ -1,10 +1,12 @@ use anyhow::Result; use clap::Parser; use comm_services_lib::blob::client::Url; -use once_cell::sync::Lazy; +use once_cell::sync::{Lazy, OnceCell}; use tracing::info; -// environment variabl names +use crate::email::config::{EmailArgs, EmailConfig}; + +// environment variable names const ENV_LOCALSTACK_ENDPOINT: &str = "LOCALSTACK_ENDPOINT"; const ENV_BLOB_SERVICE_URL: &str = "BLOB_SERVICE_URL"; @@ -21,12 +23,28 @@ #[arg(env = ENV_LOCALSTACK_ENDPOINT)] #[arg(long)] localstack_endpoint: Option, + + /// This config shouldn't be used directly. + /// Use [`AppConfig::email_config()`] instead. + #[clap(skip)] + email_config: OnceCell>, + /// used for parsing purposes only + #[command(flatten)] + email_args: EmailArgs, } impl AppConfig { pub fn is_dev(&self) -> bool { self.localstack_endpoint.is_some() } + + pub fn emails_enabled(&self) -> bool { + self.email_config().is_some() + } + + pub fn email_config(&self) -> Option<&EmailConfig> { + self.email_config.get().and_then(Option::as_ref) + } } /// Stores configuration parsed from command-line arguments @@ -37,7 +55,14 @@ /// Should be called at the beginning of the `main()` function. pub(super) fn parse_cmdline_args() -> Result<&'static AppConfig> { // force evaluation of the lazy initialized config - Ok(Lazy::force(&CONFIG)) + let cfg = Lazy::force(&CONFIG); + + // initialize e-mail config + cfg + .email_config + .get_or_try_init(|| cfg.email_args.parse())?; + + Ok(cfg) } /// Provides region/credentials configuration for AWS SDKs diff --git a/services/reports/src/email/config.rs b/services/reports/src/email/config.rs --- a/services/reports/src/email/config.rs +++ b/services/reports/src/email/config.rs @@ -1,8 +1,11 @@ +use anyhow::{Context, Result}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; use tracing::warn; use crate::report_types::ReportType; const ENV_POSTMARK_TOKEN: &str = "POSTMARK_TOKEN"; +const DEFAULT_CONFIG_PATH: &str = "./email-config.json"; #[derive(Clone, Debug, Hash, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -60,6 +63,56 @@ } } +/// Email-related CLI arguments for clap +#[derive(clap::Args)] +#[group(multiple = false)] +pub struct EmailArgs { + // these args are mutually exclusive + #[arg(long = "email-config")] + config_content: Option, + + #[arg( + long = "email-config-file", + default_value = DEFAULT_CONFIG_PATH, + value_hint = clap::ValueHint::FilePath + )] + config_file: Option, +} + +impl EmailArgs { + pub fn parse(&self) -> Result> { + if self + .config_content + .as_ref() + .is_some_and(|it| it.postmark_token.is_some()) + { + return Ok(self.config_content.clone()); + } + + let Some(path) = self.config_file.as_ref() else { + return Ok(None); + }; + + let file_contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(_) if path.to_str() == Some(DEFAULT_CONFIG_PATH) => { + // Failed to read but it's default path so we can skip + return Ok(None); + } + err => err.with_context(|| format!("Failed to read path: {path:?}"))?, + }; + + let cfg = EmailConfig::from_str(&file_contents) + .context("Failed to parse email config file")?; + + // Don't provide emails config if token not present + if cfg.postmark_token.is_none() { + return Ok(None); + } + Ok(Some(cfg)) + } +} + fn postmark_token_from_env() -> Option { std::env::var(ENV_POSTMARK_TOKEN) .ok() @@ -68,6 +121,8 @@ #[cfg(test)] mod tests { + use once_cell::sync::Lazy; + use super::*; const RAW_CFG: &str = r#"{ @@ -78,6 +133,18 @@ const RAW_CFG_NO_TOKEN: &str = r#"{"senderEmail": "a@b.c", "mailingGroups": {} }"#; + static EXAMPLE_CFG: Lazy = Lazy::new(|| EmailConfig { + postmark_token: Some("hello".to_string()), + sender_email: "foo@bar.com".to_string(), + mailing_groups: HashMap::new(), + }); + + static EXAMPLE_CFG_NO_TOKEN: Lazy = Lazy::new(|| EmailConfig { + postmark_token: None, + sender_email: "foo@bar.com".to_string(), + mailing_groups: HashMap::new(), + }); + #[test] fn test_postmark_token() { // make sure existing env var doesn't interrupt the test @@ -103,4 +170,50 @@ .as_ref() .is_some_and(|token| token == "world")); } + + #[test] + fn parse_args_no_token() { + // should return none when config exists but has no token + let mut args = EmailArgs { + config_content: Some(EXAMPLE_CFG_NO_TOKEN.clone()), + config_file: None, + }; + let cfg = args.parse().expect("failed to parse"); + assert!(cfg.is_none()); + + args.config_content = Some(EXAMPLE_CFG.clone()); + let cfg = args.parse().expect("failed to parse"); + assert!(cfg.is_some()); + } + + #[test] + fn parse_args_priority() { + // direct content should have higher priority than file + let args = EmailArgs { + config_content: Some(EXAMPLE_CFG.clone()), + config_file: Some("/hello.json".into()), + }; + let cfg = args.parse().expect("failed to parse"); + assert!(cfg.is_some()); + assert_eq!(cfg.unwrap().sender_email, EXAMPLE_CFG.sender_email); + } + + #[test] + fn parse_skips_default_path() { + let args = EmailArgs { + config_content: None, + config_file: Some("not/exists.json".into()), + }; + args.parse().expect_err("parse should fail"); + + let args = EmailArgs { + config_content: None, + config_file: Some(DEFAULT_CONFIG_PATH.into()), + }; + // if this fails, check if your email-config.json is correct + let _ = args.parse().expect("failed to parse"); + + // we cannot assert if parsed config is none because the actual file + // can exist on developer's machine + } }