diff --git a/services/reports/.gitignore b/services/reports/.gitignore index eb5a316cb..054d3ea81 100644 --- a/services/reports/.gitignore +++ b/services/reports/.gitignore @@ -1 +1,2 @@ target +email-config.json diff --git a/services/reports/src/email/config.rs b/services/reports/src/email/config.rs new file mode 100644 index 000000000..b92cd025d --- /dev/null +++ b/services/reports/src/email/config.rs @@ -0,0 +1,144 @@ +use anyhow::{Context, Result}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; + +use crate::report_types::ReportType; + +const ENV_EMAIL_CONFIG: &str = "EMAIL_CONFIG"; +const DEFAULT_CONFIG_PATH: &str = "./email-config.json"; + +#[derive(Clone, Debug, Hash, Eq, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MailingGroup { + ErrorsReports, + InconsistencyReports, + MediaReports, +} + +impl From<&ReportType> for MailingGroup { + fn from(value: &ReportType) -> Self { + use ReportType::*; + match value { + ErrorReport => Self::ErrorsReports, + MediaMission => Self::MediaReports, + ThreadInconsistency | EntryInconsistency | UserInconsistency => { + Self::InconsistencyReports + } + } + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmailConfig { + /// Ability to disable e-mails while keeping the rest of the config + #[serde(default)] + disabled: bool, + /// API key for Postmark + pub postmark_token: String, + /// E-mail that is used as a sender + pub sender_email: String, + /// Receiver e-mails for report types + pub mailing_groups: HashMap, +} + +impl FromStr for EmailConfig { + type Err = serde_json::Error; + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +/// Email-related CLI arguments for clap +#[derive(clap::Args)] +#[group(multiple = false)] +pub struct EmailArgs { + // these args are mutually exclusive + #[arg(env = ENV_EMAIL_CONFIG)] + #[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> { + let config_content = self.config_content.as_ref(); + if config_content.is_some() { + // we deliberately check 'disabled' here so if somebody disables, + // it doesn't fall back to file + let config = config_content.filter(|cfg| !cfg.disabled).cloned(); + return Ok(config); + } + + 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")?; + + if cfg.disabled { + return Ok(None); + } + Ok(Some(cfg)) + } +} + +#[cfg(test)] +mod tests { + use once_cell::sync::Lazy; + + use super::*; + + static EXAMPLE_CFG: Lazy = Lazy::new(|| EmailConfig { + disabled: false, + postmark_token: "supersecret".to_string(), + sender_email: "foo@bar.com".to_string(), + mailing_groups: HashMap::new(), + }); + + #[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 + } +} diff --git a/services/reports/src/email/mod.rs b/services/reports/src/email/mod.rs new file mode 100644 index 000000000..ef68c3694 --- /dev/null +++ b/services/reports/src/email/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/services/reports/src/main.rs b/services/reports/src/main.rs index 93cf02405..fefba5ca6 100644 --- a/services/reports/src/main.rs +++ b/services/reports/src/main.rs @@ -1,39 +1,40 @@ pub mod config; pub mod constants; pub mod database; +pub mod email; pub mod http; pub mod report_types; pub mod service; use anyhow::Result; use comm_services_lib::blob::client::BlobServiceClient; use service::ReportsService; use tracing_subscriber::filter::{EnvFilter, LevelFilter}; fn configure_logging() -> Result<()> { let filter = EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .with_env_var(EnvFilter::DEFAULT_ENV) .from_env_lossy(); // init HTTP logger - it relies on 'log' instead of 'tracing' // so we have to initialize a polyfill tracing_log::LogTracer::init()?; let subscriber = tracing_subscriber::fmt().with_env_filter(filter).finish(); tracing::subscriber::set_global_default(subscriber)?; Ok(()) } #[tokio::main] async fn main() -> Result<()> { configure_logging()?; let cfg = config::parse_cmdline_args()?; let aws_config = config::load_aws_config().await; let db = database::client::DatabaseClient::new(&aws_config); let blob_client = BlobServiceClient::new(cfg.blob_service_url.clone()); let service = ReportsService::new(db, blob_client); crate::http::run_http_server(service).await }