diff --git a/Cargo.toml b/Cargo.toml index 2a77ae9..485f3b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,6 @@ nursery = { level = "warn", priority = -1 } unwrap_used = "warn" expect_used = "warn" -[workspace.lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } - [workspace.dependencies] git-next-core = { path = "crates/core", version = "0.12" } git-next-forge-forgejo = { path = "crates/forge-forgejo", version = "0.12" } @@ -95,6 +92,10 @@ actix = "0.13" actix-rt = "2.9" tokio = { version = "1.37", features = ["rt", "macros"] } +# email +lettre = "0.11" +sendmail = "2.0" + # Testing assert2 = "0.3" pretty_assertions = "1.4" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e1a65e8..a8b4934 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -66,6 +66,10 @@ warp = { workspace = true } # file watcher (linux) notify = { workspace = true } +# email +lettre = { workspace = true } +sendmail = { workspace = true } + [dev-dependencies] # Testing assert2 = { workspace = true } diff --git a/crates/cli/src/server/actor/handlers/notify_user/email.rs b/crates/cli/src/server/actor/handlers/notify_user/email.rs new file mode 100644 index 0000000..b9b40eb --- /dev/null +++ b/crates/cli/src/server/actor/handlers/notify_user/email.rs @@ -0,0 +1,123 @@ +use std::ops::Deref as _; + +use git_next_core::{ + git::UserNotification, + server::{EmailConfig, SmtpConfig}, +}; +use tracing::instrument; + +use crate::repo::messages::NotifyUser; + +#[derive(Debug)] +struct EmailMessage { + from: String, + to: String, + subject: String, + body: String, +} + +#[instrument] +pub(super) fn send_email(msg: &NotifyUser, email_config: &EmailConfig) { + let email_message = EmailMessage { + from: email_config.from().to_string(), + to: email_config.to().to_string(), + subject: email_subject(msg), + body: email_body(msg), + }; + match email_config.smtp() { + Some(smtp) => send_email_smtp(email_message, smtp), + None => send_email_sendmail(email_message), + } +} + +#[instrument] +fn send_email_sendmail(email_message: EmailMessage) { + use sendmail::email; + match email::send( + &email_message.from, + [email_message.to.as_ref()], + &email_message.subject, + &email_message.body, + ) { + Ok(_) => tracing::info!("Email sent successfully!"), + Err(e) => tracing::warn!("Could not send email: {:?}", e), + } +} + +#[instrument] +fn send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) { + if let Err(err) = do_send_email_smtp(email_message, smtp) { + tracing::warn!(?err, "sending email"); + } +} + +#[instrument] +fn do_send_email_smtp(email_message: EmailMessage, smtp: &SmtpConfig) -> Result<(), anyhow::Error> { + use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport}; + let email = Message::builder() + .from(email_message.from.parse()?) + .to(email_message.to.parse()?) + .subject(email_message.subject) + .body(email_message.body)?; + let creds = Credentials::new(smtp.username().to_string(), smtp.password().to_string()); + let mailer = SmtpTransport::relay(smtp.hostname())? + .credentials(creds) + .build(); + Ok(mailer.send(&email).map(|response| { + tracing::info!(?response, "email sent via smtp"); + })?) +} + +fn email_body(msg: &NotifyUser) -> String { + match msg.deref() { + UserNotification::CICheckFailed { + forge_alias, + repo_alias, + commit, + } => format!("CI Checks had Failed in {forge_alias}/{repo_alias} for the {commit} commit."), + UserNotification::RepoConfigLoadFailure { + forge_alias, + repo_alias, + reason, + } => format!( + "Failed to read/parse the .git-next.toml file for {forge_alias}/{repo_alias}: {reason}" + ), + UserNotification::WebhookRegistration { + forge_alias, + repo_alias, + reason, + } => format!("Failed to register the webhook for {forge_alias}/{repo_alias} with the forge: {reason}"), + UserNotification::DevNotBasedOnMain { + forge_alias, + repo_alias, + dev_branch, + main_branch, + } => format!("In {forge_alias}/{repo_alias}, the dev branch '{dev_branch}' is not based on the main branch '{main_branch}, it needs to be rebased."), + } +} + +fn email_subject(msg: &NotifyUser) -> String { + match msg.deref() { + UserNotification::CICheckFailed { + forge_alias, + repo_alias, + commit, + } => format!("CI Check Failed: {forge_alias}/{repo_alias}: {commit}"), + UserNotification::RepoConfigLoadFailure { + forge_alias, + repo_alias, + reason: _, + } => format!("Invalid Repo Config: {forge_alias}/{repo_alias}"), + UserNotification::WebhookRegistration { + forge_alias, + repo_alias, + reason: _, + } => format!("Failed Webhook Registration: {forge_alias}/{repo_alias}"), + UserNotification::DevNotBasedOnMain { + forge_alias, + repo_alias, + dev_branch: _, + main_branch: _, + } => format!("Dev Not Based on Main: {forge_alias}/{repo_alias}"), + } +} diff --git a/crates/cli/src/server/actor/handlers/notify_user/mod.rs b/crates/cli/src/server/actor/handlers/notify_user/mod.rs new file mode 100644 index 0000000..c7333a6 --- /dev/null +++ b/crates/cli/src/server/actor/handlers/notify_user/mod.rs @@ -0,0 +1,34 @@ +// +mod email; +mod webhook; + +use actix::prelude::*; + +use email::send_email; +use tracing::Instrument; +use webhook::send_webhook; + +use crate::{repo::messages::NotifyUser, server::actor::ServerActor}; + +impl Handler for ServerActor { + type Result = (); + + fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result { + let Some(server_config) = &self.server_config else { + return; + }; + let shout_config = server_config.shout().clone(); + let net = self.net.clone(); + async move { + if let Some(webhook_config) = shout_config.webhook() { + send_webhook(&msg, webhook_config, &net).await; + } + if let Some(email_config) = shout_config.email() { + send_email(&msg, email_config); + } + } + .in_current_span() + .into_actor(self) + .wait(ctx); + } +} diff --git a/crates/cli/src/server/actor/handlers/notify_user.rs b/crates/cli/src/server/actor/handlers/notify_user/webhook.rs similarity index 61% rename from crates/cli/src/server/actor/handlers/notify_user.rs rename to crates/cli/src/server/actor/handlers/notify_user/webhook.rs index 76f4aa3..4c72c5d 100644 --- a/crates/cli/src/server/actor/handlers/notify_user.rs +++ b/crates/cli/src/server/actor/handlers/notify_user/webhook.rs @@ -1,38 +1,14 @@ // -use actix::prelude::*; - -use secrecy::ExposeSecret; +use git_next_core::server::OutboundWebhook; +use secrecy::ExposeSecret as _; use standardwebhooks::Webhook; -use tracing::Instrument; -use crate::{repo::messages::NotifyUser, server::actor::ServerActor}; +use crate::repo::messages::NotifyUser; -use git_next_core::server::{self, OutboundWebhook}; - -impl Handler for ServerActor { - type Result = (); - - fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result { - let Some(server_config) = &self.server_config else { - return; - }; - let shout_config = server_config.shout().clone(); - let net = self.net.clone(); - async move { - if let Some(webhook_config) = shout_config.webhook() { - send_webhook(msg, webhook_config, net).await; - } - } - .in_current_span() - .into_actor(self) - .wait(ctx); - } -} - -async fn send_webhook( - msg: NotifyUser, +pub(super) async fn send_webhook( + msg: &NotifyUser, webhook_config: &OutboundWebhook, - net: kxio::network::Network, + net: &kxio::network::Network, ) { let Ok(webhook) = Webhook::from_bytes(webhook_config.secret().expose_secret().as_bytes().into()) @@ -44,10 +20,10 @@ async fn send_webhook( } async fn do_send_webhook( - msg: NotifyUser, + msg: &NotifyUser, webhook: Webhook, - webhook_config: &server::OutboundWebhook, - net: kxio::network::Network, + webhook_config: &OutboundWebhook, + net: &kxio::network::Network, ) { let message_id = format!("msg_{}", ulid::Ulid::new()); let timestamp = time::OffsetDateTime::now_utc(); diff --git a/crates/core/src/config/server.rs b/crates/core/src/config/server.rs index 4486612..1133862 100644 --- a/crates/core/src/config/server.rs +++ b/crates/core/src/config/server.rs @@ -207,11 +207,13 @@ impl ServerStorage { )] pub struct Shout { webhook: Option, + email: Option, } impl Shout { pub const fn new_webhook(webhook: OutboundWebhook) -> Self { Self { webhook: Some(webhook), + email: None, } } @@ -226,6 +228,10 @@ impl Shout { pub fn webhook_secret(&self) -> Option> { self.webhook.clone().map(|x| x.secret).map(Secret::new) } + + pub const fn email(&self) -> Option<&EmailConfig> { + self.email.as_ref() + } } #[derive( @@ -251,3 +257,64 @@ impl OutboundWebhook { Secret::new(self.secret.clone()) } } + +#[derive( + Clone, + Debug, + derive_more::From, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Deserialize, + derive_more::Constructor, +)] +pub struct EmailConfig { + from: String, + to: String, + // email will be sent via sendmail, unless smtp is specified + smtp: Option, +} +impl EmailConfig { + pub fn from(&self) -> &str { + &self.from + } + + pub fn to(&self) -> &str { + &self.to + } + + pub const fn smtp(&self) -> Option<&SmtpConfig> { + self.smtp.as_ref() + } +} + +#[derive( + Clone, + Debug, + derive_more::From, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Deserialize, + derive_more::Constructor, +)] +pub struct SmtpConfig { + hostname: String, + username: String, + password: String, +} +impl SmtpConfig { + pub fn username(&self) -> &str { + &self.username + } + + pub fn password(&self) -> &str { + &self.password + } + + pub fn hostname(&self) -> &str { + &self.hostname + } +}