From 12a2981ab567f006b935f4b34090a4ac9ee4d501 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 1 Aug 2024 19:48:59 +0100 Subject: [PATCH] feat: send email notifications (sendmail/smtp) Closes kemitix/git-next#114 --- Cargo.toml | 7 +- crates/cli/Cargo.toml | 4 + crates/cli/src/repo/notifications.rs | 12 ++ .../actor/handlers/notify_user/email.rs | 161 ++++++++++++++++++ .../server/actor/handlers/notify_user/mod.rs | 34 ++++ .../webhook.rs} | 42 +---- crates/core/src/config/server.rs | 67 ++++++++ crates/core/src/git/user_notification.rs | 2 + crates/core/src/git/validation/positions.rs | 4 + 9 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 crates/cli/src/server/actor/handlers/notify_user/email.rs create mode 100644 crates/cli/src/server/actor/handlers/notify_user/mod.rs rename crates/cli/src/server/actor/handlers/{notify_user.rs => notify_user/webhook.rs} (61%) diff --git a/Cargo.toml b/Cargo.toml index 2a77ae97..485f3b7e 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 e1a65e8f..a8b49345 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/repo/notifications.rs b/crates/cli/src/repo/notifications.rs index 1c5ff3e2..1ec3bc2b 100644 --- a/crates/cli/src/repo/notifications.rs +++ b/crates/cli/src/repo/notifications.rs @@ -58,6 +58,8 @@ impl NotifyUser { repo_alias, dev_branch, main_branch, + dev_commit, + main_commit, } => json!({ "type": "branch.dev.not-on-main", "timestamp": timestamp, @@ -67,6 +69,16 @@ impl NotifyUser { "branches": { "dev": dev_branch, "main": main_branch + }, + "commits": { + "dev": { + "sha": dev_commit.sha(), + "message": dev_commit.message() + }, + "main": { + "sha": main_commit.sha(), + "message": main_commit.message() + } } } }), 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 00000000..d011eed7 --- /dev/null +++ b/crates/cli/src/server/actor/handlers/notify_user/email.rs @@ -0,0 +1,161 @@ +use std::ops::Deref as _; + +use git_next_core::{ + git::UserNotification, + server::{EmailConfig, SmtpConfig}, +}; + +use crate::repo::messages::NotifyUser; + +#[derive(Debug)] +struct EmailMessage { + from: String, + to: String, + subject: String, + body: String, +} + +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), + } +} + +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), + } +} + +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"); + } +} + +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| { + response + .message() + .map(|s| s.to_string()) + .collect::>() + }) + .map(|response| { + tracing::info!(?response, "email sent via smtp"); + })?) +} + +fn email_subject(msg: &NotifyUser) -> String { + let tail = 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: _, + dev_commit: _, + main_commit: _, + } => format!("Dev not based on Main: {forge_alias}/{repo_alias}"), + }; + format!("[git-next] {tail}") +} + +fn email_body(msg: &NotifyUser) -> String { + match msg.deref() { + UserNotification::CICheckFailed { + forge_alias, + repo_alias, + commit, + } => { + let sha = commit.sha(); + let message = commit.message(); + [ + "CI Checks had Failed".to_string(), + format!("Forge: {forge_alias}\nRepo : {repo_alias}"), + format!("Commit:\n - {sha}\n - {message}"), + ] + .join("\n\n") + } + UserNotification::RepoConfigLoadFailure { + forge_alias, + repo_alias, + reason, + } => [ + "Failed to read or parse the .git-next.toml file from repo".to_string(), + format!(" - {reason}"), + format!("Forge: {forge_alias}\nRepo : {repo_alias}"), + ] + .join("\n\n"), + UserNotification::WebhookRegistration { + forge_alias, + repo_alias, + reason, + } => [ + "Failed to register webhook with the forge".to_string(), + format!(" - {reason}"), + format!("Forge: {forge_alias}\nRepo : {repo_alias}"), + ] + .join("\n\n"), + UserNotification::DevNotBasedOnMain { + forge_alias, + repo_alias, + dev_branch, + main_branch, + dev_commit, + main_commit, + } => { + let dev_sha = dev_commit.sha(); + let dev_message = dev_commit.message(); + let main_sha = main_commit.sha(); + let main_message = main_commit.message(); + [ + format!("The branch '{dev_branch}' is not based on the branch '{main_branch}'."), + format!("TODO: Rebase '{dev_branch}' onto '{main_branch}'."), + format!("Forge: {forge_alias}\nRepo : {repo_alias}"), + format!("{dev_branch}:\n - {dev_sha}\n - {dev_message}"), + format!("{main_branch}:\n - {main_sha}\n - {main_message}"), + ] + .join("\n\n") + } + } +} 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 00000000..c7333a6d --- /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 76f4aa34..4c72c5dd 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 4486612d..1133862b 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 + } +} diff --git a/crates/core/src/git/user_notification.rs b/crates/core/src/git/user_notification.rs index a224705a..e077338f 100644 --- a/crates/core/src/git/user_notification.rs +++ b/crates/core/src/git/user_notification.rs @@ -23,5 +23,7 @@ pub enum UserNotification { repo_alias: RepoAlias, dev_branch: BranchName, main_branch: BranchName, + dev_commit: Commit, + main_commit: Commit, }, } diff --git a/crates/core/src/git/validation/positions.rs b/crates/core/src/git/validation/positions.rs index 390d5dbc..56866ac9 100644 --- a/crates/core/src/git/validation/positions.rs +++ b/crates/core/src/git/validation/positions.rs @@ -15,6 +15,7 @@ pub struct Positions { pub next_is_valid: bool, } +#[allow(clippy::result_large_err)] pub fn validate_positions( open_repository: &dyn OpenRepositoryLike, repo_details: &git::RepoDetails, @@ -49,6 +50,8 @@ pub fn validate_positions( repo_alias: repo_details.repo_alias.clone(), dev_branch, main_branch, + dev_commit: dev, + main_commit: main, }, )); } @@ -83,6 +86,7 @@ pub fn validate_positions( }) } +#[allow(clippy::result_large_err)] fn reset_next_to_main( open_repository: &dyn OpenRepositoryLike, repo_details: &RepoDetails,