feat: send email notifications (sendmail/smtp)
Closes kemitix/git-next#114
This commit is contained in:
parent
538728c491
commit
12a2981ab5
9 changed files with 297 additions and 36 deletions
|
@ -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"
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
161
crates/cli/src/server/actor/handlers/notify_user/email.rs
Normal file
161
crates/cli/src/server/actor/handlers/notify_user/email.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
crates/cli/src/server/actor/handlers/notify_user/mod.rs
Normal file
34
crates/cli/src/server/actor/handlers/notify_user/mod.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue