feat: send email notifications (sendmail/smtp)

Closes kemitix/git-next#114
This commit is contained in:
Paul Campbell 2024-08-01 19:48:59 +01:00 committed by Paul Campbell
parent 538728c491
commit 12a2981ab5
9 changed files with 297 additions and 36 deletions

View file

@ -21,9 +21,6 @@ nursery = { level = "warn", priority = -1 }
unwrap_used = "warn" unwrap_used = "warn"
expect_used = "warn" expect_used = "warn"
[workspace.lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
[workspace.dependencies] [workspace.dependencies]
git-next-core = { path = "crates/core", version = "0.12" } git-next-core = { path = "crates/core", version = "0.12" }
git-next-forge-forgejo = { path = "crates/forge-forgejo", 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" actix-rt = "2.9"
tokio = { version = "1.37", features = ["rt", "macros"] } tokio = { version = "1.37", features = ["rt", "macros"] }
# email
lettre = "0.11"
sendmail = "2.0"
# Testing # Testing
assert2 = "0.3" assert2 = "0.3"
pretty_assertions = "1.4" pretty_assertions = "1.4"

View file

@ -66,6 +66,10 @@ warp = { workspace = true }
# file watcher (linux) # file watcher (linux)
notify = { workspace = true } notify = { workspace = true }
# email
lettre = { workspace = true }
sendmail = { workspace = true }
[dev-dependencies] [dev-dependencies]
# Testing # Testing
assert2 = { workspace = true } assert2 = { workspace = true }

View file

@ -58,6 +58,8 @@ impl NotifyUser {
repo_alias, repo_alias,
dev_branch, dev_branch,
main_branch, main_branch,
dev_commit,
main_commit,
} => json!({ } => json!({
"type": "branch.dev.not-on-main", "type": "branch.dev.not-on-main",
"timestamp": timestamp, "timestamp": timestamp,
@ -67,6 +69,16 @@ impl NotifyUser {
"branches": { "branches": {
"dev": dev_branch, "dev": dev_branch,
"main": main_branch "main": main_branch
},
"commits": {
"dev": {
"sha": dev_commit.sha(),
"message": dev_commit.message()
},
"main": {
"sha": main_commit.sha(),
"message": main_commit.message()
}
} }
} }
}), }),

View file

@ -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::<Vec<_>>()
})
.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")
}
}
}

View file

@ -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<NotifyUser> 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);
}
}

View file

@ -1,38 +1,14 @@
// //
use actix::prelude::*; use git_next_core::server::OutboundWebhook;
use secrecy::ExposeSecret as _;
use secrecy::ExposeSecret;
use standardwebhooks::Webhook; 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}; pub(super) async fn send_webhook(
msg: &NotifyUser,
impl Handler<NotifyUser> 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,
webhook_config: &OutboundWebhook, webhook_config: &OutboundWebhook,
net: kxio::network::Network, net: &kxio::network::Network,
) { ) {
let Ok(webhook) = let Ok(webhook) =
Webhook::from_bytes(webhook_config.secret().expose_secret().as_bytes().into()) Webhook::from_bytes(webhook_config.secret().expose_secret().as_bytes().into())
@ -44,10 +20,10 @@ async fn send_webhook(
} }
async fn do_send_webhook( async fn do_send_webhook(
msg: NotifyUser, msg: &NotifyUser,
webhook: Webhook, webhook: Webhook,
webhook_config: &server::OutboundWebhook, webhook_config: &OutboundWebhook,
net: kxio::network::Network, net: &kxio::network::Network,
) { ) {
let message_id = format!("msg_{}", ulid::Ulid::new()); let message_id = format!("msg_{}", ulid::Ulid::new());
let timestamp = time::OffsetDateTime::now_utc(); let timestamp = time::OffsetDateTime::now_utc();

View file

@ -207,11 +207,13 @@ impl ServerStorage {
)] )]
pub struct Shout { pub struct Shout {
webhook: Option<OutboundWebhook>, webhook: Option<OutboundWebhook>,
email: Option<EmailConfig>,
} }
impl Shout { impl Shout {
pub const fn new_webhook(webhook: OutboundWebhook) -> Self { pub const fn new_webhook(webhook: OutboundWebhook) -> Self {
Self { Self {
webhook: Some(webhook), webhook: Some(webhook),
email: None,
} }
} }
@ -226,6 +228,10 @@ impl Shout {
pub fn webhook_secret(&self) -> Option<Secret<String>> { pub fn webhook_secret(&self) -> Option<Secret<String>> {
self.webhook.clone().map(|x| x.secret).map(Secret::new) self.webhook.clone().map(|x| x.secret).map(Secret::new)
} }
pub const fn email(&self) -> Option<&EmailConfig> {
self.email.as_ref()
}
} }
#[derive( #[derive(
@ -251,3 +257,64 @@ impl OutboundWebhook {
Secret::new(self.secret.clone()) 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<SmtpConfig>,
}
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
}
}

View file

@ -23,5 +23,7 @@ pub enum UserNotification {
repo_alias: RepoAlias, repo_alias: RepoAlias,
dev_branch: BranchName, dev_branch: BranchName,
main_branch: BranchName, main_branch: BranchName,
dev_commit: Commit,
main_commit: Commit,
}, },
} }

View file

@ -15,6 +15,7 @@ pub struct Positions {
pub next_is_valid: bool, pub next_is_valid: bool,
} }
#[allow(clippy::result_large_err)]
pub fn validate_positions( pub fn validate_positions(
open_repository: &dyn OpenRepositoryLike, open_repository: &dyn OpenRepositoryLike,
repo_details: &git::RepoDetails, repo_details: &git::RepoDetails,
@ -49,6 +50,8 @@ pub fn validate_positions(
repo_alias: repo_details.repo_alias.clone(), repo_alias: repo_details.repo_alias.clone(),
dev_branch, dev_branch,
main_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( fn reset_next_to_main(
open_repository: &dyn OpenRepositoryLike, open_repository: &dyn OpenRepositoryLike,
repo_details: &RepoDetails, repo_details: &RepoDetails,