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"
|
||||
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"
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
|
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 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<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,
|
||||
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();
|
|
@ -207,11 +207,13 @@ impl ServerStorage {
|
|||
)]
|
||||
pub struct Shout {
|
||||
webhook: Option<OutboundWebhook>,
|
||||
email: Option<EmailConfig>,
|
||||
}
|
||||
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<Secret<String>> {
|
||||
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<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,
|
||||
dev_branch: BranchName,
|
||||
main_branch: BranchName,
|
||||
dev_commit: Commit,
|
||||
main_commit: Commit,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue