Track open rate for new post notifications (user clicked the button in the link or not). No data about the user is collected during the process, it only uses an ID inserted by the issue delivery worker.
146 lines
4.1 KiB
Rust
146 lines
4.1 KiB
Rust
use std::fmt::Display;
|
|
|
|
use crate::{
|
|
authentication::AuthenticatedUser,
|
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
|
routes::{AdminError, AppError},
|
|
startup::AppState,
|
|
templates::{EmailTemplate, MessageTemplate, StandaloneEmailTemplate},
|
|
};
|
|
use anyhow::Context;
|
|
use askama::Template;
|
|
use axum::{
|
|
Extension, Form,
|
|
extract::State,
|
|
response::{Html, IntoResponse, Response},
|
|
};
|
|
use sqlx::{Executor, Postgres, Transaction};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(serde::Deserialize)]
|
|
pub struct BodyData {
|
|
title: String,
|
|
html: String,
|
|
text: String,
|
|
idempotency_key: String,
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn insert_newsletter_issue(
|
|
transaction: &mut Transaction<'static, Postgres>,
|
|
title: &str,
|
|
email_template: &dyn EmailTemplate,
|
|
) -> Result<Uuid, sqlx::Error> {
|
|
let newsletter_issue_id = Uuid::new_v4();
|
|
let query = sqlx::query!(
|
|
r#"
|
|
INSERT INTO newsletter_issues (
|
|
newsletter_issue_id, title, text_content, html_content, published_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
"#,
|
|
newsletter_issue_id,
|
|
title,
|
|
email_template.text(),
|
|
email_template.html(),
|
|
);
|
|
transaction.execute(query).await?;
|
|
Ok(newsletter_issue_id)
|
|
}
|
|
|
|
pub enum EmailType {
|
|
NewPost,
|
|
Newsletter,
|
|
}
|
|
|
|
impl Display for EmailType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
EmailType::NewPost => write!(f, "new_post"),
|
|
EmailType::Newsletter => write!(f, "newsletter"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn enqueue_delivery_tasks(
|
|
transaction: &mut Transaction<'static, Postgres>,
|
|
newsletter_issue_id: Uuid,
|
|
kind: EmailType,
|
|
) -> Result<(), sqlx::Error> {
|
|
let query = sqlx::query!(
|
|
r#"
|
|
INSERT INTO issue_delivery_queue (
|
|
newsletter_issue_id,
|
|
subscriber_email,
|
|
unsubscribe_token,
|
|
kind
|
|
)
|
|
SELECT $1, email, unsubscribe_token, $2
|
|
FROM subscriptions
|
|
WHERE status = 'confirmed'
|
|
"#,
|
|
newsletter_issue_id,
|
|
kind.to_string()
|
|
);
|
|
transaction.execute(query).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
|
|
pub async fn publish_newsletter(
|
|
State(AppState {
|
|
connection_pool,
|
|
base_url,
|
|
..
|
|
}): State<AppState>,
|
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
|
Form(form): Form<BodyData>,
|
|
) -> Result<Response, AppError> {
|
|
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
|
|
|
let idempotency_key: IdempotencyKey = form
|
|
.idempotency_key
|
|
.try_into()
|
|
.map_err(AdminError::Idempotency)?;
|
|
|
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
|
return Ok(response);
|
|
}
|
|
};
|
|
|
|
let email_template = StandaloneEmailTemplate {
|
|
base_url: &base_url,
|
|
text_content: &form.text,
|
|
html_content: &form.html,
|
|
};
|
|
|
|
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &email_template)
|
|
.await
|
|
.context("Failed to store newsletter issue details.")?;
|
|
|
|
enqueue_delivery_tasks(&mut transaction, issue_id, EmailType::Newsletter)
|
|
.await
|
|
.context("Failed to enqueue delivery tasks.")?;
|
|
|
|
let message = String::from("Your email has been queued for delivery.");
|
|
let template = MessageTemplate::Success { message };
|
|
let response = Html(template.render().unwrap()).into_response();
|
|
let response = save_response(transaction, &idempotency_key, user_id, response)
|
|
.await
|
|
.map_err(AdminError::UnexpectedError)?;
|
|
Ok(response)
|
|
}
|
|
|
|
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
|
if form.title.is_empty() {
|
|
return Err("The title was empty.");
|
|
}
|
|
if form.html.is_empty() || form.text.is_empty() {
|
|
return Err("The content was empty.");
|
|
}
|
|
Ok(())
|
|
}
|