Compare commits
2 Commits
6a963a8c0d
...
72fa283a6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fa283a6d | ||
|
|
7af07ea0dd |
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e"
|
||||||
|
}
|
||||||
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal file
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123"
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email\n )\n SELECT $1, email\n FROM subscriptions\n WHERE status = 'confirmed'\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "9bfa261067713ca31b191c9f9bcf19ae0dd2d12a570ce06e8e2abd72c5d7b42d"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b"
|
|
||||||
}
|
|
||||||
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
|
||||||
|
}
|
||||||
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal file
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f"
|
||||||
|
}
|
||||||
14
.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json
generated
Normal file
14
.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token\n )\n SELECT $1, email, unsubscribe_token\n FROM subscriptions\n WHERE status = 'confirmed'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE subscriptions ADD COLUMN unsubscribe_token TEXT UNIQUE;
|
||||||
|
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET unsubscribe_token = left(md5(random()::text), 25)
|
||||||
|
WHERE status = 'confirmed' AND unsubscribe_token IS NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
DROP CONSTRAINT subscription_tokens_subscriber_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
ADD CONSTRAINT subscription_tokens_subscriber_id_fkey
|
||||||
|
FOREIGN KEY (subscriber_id)
|
||||||
|
REFERENCES subscriptions (id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE issue_delivery_queue ADD COLUMN unsubscribe_token TEXT NOT NULL;
|
||||||
@@ -44,13 +44,14 @@ pub async fn try_execute_task(
|
|||||||
if task.is_none() {
|
if task.is_none() {
|
||||||
return Ok(ExecutionOutcome::EmptyQueue);
|
return Ok(ExecutionOutcome::EmptyQueue);
|
||||||
}
|
}
|
||||||
let (transaction, issue_id, email) = task.unwrap();
|
let (transaction, task) = task.unwrap();
|
||||||
Span::current()
|
Span::current()
|
||||||
.record("newsletter_issue_id", display(issue_id))
|
.record("newsletter_issue_id", display(task.newsletter_issue_id))
|
||||||
.record("subscriber_email", display(&email));
|
.record("subscriber_email", display(&task.subscriber_email));
|
||||||
match SubscriberEmail::parse(email.clone()) {
|
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
||||||
Ok(email) => {
|
Ok(email) => {
|
||||||
let issue = get_issue(connection_pool, issue_id).await?;
|
let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?;
|
||||||
|
issue.inject_unsubscribe_token(&task.unsubscribe_token);
|
||||||
if let Err(e) = email_client
|
if let Err(e) = email_client
|
||||||
.send_email(
|
.send_email(
|
||||||
&email,
|
&email,
|
||||||
@@ -73,7 +74,12 @@ pub async fn try_execute_task(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete_task(transaction, issue_id, &email).await?;
|
delete_task(
|
||||||
|
transaction,
|
||||||
|
task.newsletter_issue_id,
|
||||||
|
&task.subscriber_email,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(ExecutionOutcome::TaskCompleted)
|
Ok(ExecutionOutcome::TaskCompleted)
|
||||||
}
|
}
|
||||||
@@ -84,6 +90,13 @@ struct NewsletterIssue {
|
|||||||
html_content: String,
|
html_content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl NewsletterIssue {
|
||||||
|
fn inject_unsubscribe_token(&mut self, token: &str) {
|
||||||
|
self.text_content = self.text_content.replace("UNSUBSCRIBE_TOKEN", token);
|
||||||
|
self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
async fn get_issue(
|
async fn get_issue(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
@@ -103,14 +116,20 @@ async fn get_issue(
|
|||||||
Ok(issue)
|
Ok(issue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct Task {
|
||||||
|
pub newsletter_issue_id: Uuid,
|
||||||
|
pub subscriber_email: String,
|
||||||
|
pub unsubscribe_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
async fn dequeue_task(
|
async fn dequeue_task(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
) -> Result<Option<(Transaction<'static, Postgres>, Uuid, String)>, anyhow::Error> {
|
) -> Result<Option<(Transaction<'static, Postgres>, Task)>, anyhow::Error> {
|
||||||
let mut transaction = connection_pool.begin().await?;
|
let mut transaction = connection_pool.begin().await?;
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
SELECT newsletter_issue_id, subscriber_email
|
SELECT newsletter_issue_id, subscriber_email, unsubscribe_token
|
||||||
FROM issue_delivery_queue
|
FROM issue_delivery_queue
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
SKIP LOCKED
|
SKIP LOCKED
|
||||||
@@ -119,11 +138,12 @@ async fn dequeue_task(
|
|||||||
);
|
);
|
||||||
let r = transaction.fetch_optional(query).await?;
|
let r = transaction.fetch_optional(query).await?;
|
||||||
if let Some(row) = r {
|
if let Some(row) = r {
|
||||||
Ok(Some((
|
let task = Task {
|
||||||
transaction,
|
newsletter_issue_id: row.get("newsletter_issue_id"),
|
||||||
row.get("newsletter_issue_id"),
|
subscriber_email: row.get("subscriber_email"),
|
||||||
row.get("subscriber_email"),
|
unsubscribe_token: row.get("unsubscribe_token"),
|
||||||
)))
|
};
|
||||||
|
Ok(Some((transaction, task)))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod login;
|
|||||||
mod posts;
|
mod posts;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
|
mod unsubscribe;
|
||||||
|
|
||||||
pub use admin::*;
|
pub use admin::*;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -16,15 +17,38 @@ pub use health_check::*;
|
|||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
pub use posts::*;
|
pub use posts::*;
|
||||||
|
use rand::{Rng, distr::Alphanumeric};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
pub use subscriptions::*;
|
pub use subscriptions::*;
|
||||||
pub use subscriptions_confirm::*;
|
pub use subscriptions_confirm::*;
|
||||||
|
pub use unsubscribe::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthError,
|
authentication::AuthError,
|
||||||
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
|
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||||
|
.map(char::from)
|
||||||
|
.take(25)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
writeln!(f, "{}", e)?;
|
||||||
|
let mut current = e.source();
|
||||||
|
while let Some(cause) = current {
|
||||||
|
write!(f, "Caused by:\n\t{}", cause)?;
|
||||||
|
current = cause.source();
|
||||||
|
if current.is_some() {
|
||||||
|
writeln!(f)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error)]
|
#[derive(thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("An unexpected error was encountered.")]
|
#[error("An unexpected error was encountered.")]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::{
|
|||||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
routes::{AdminError, AppError},
|
routes::{AdminError, AppError},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::MessageTemplate,
|
templates::{EmailTemplate, MessageTemplate, StandaloneEmailTemplate},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -27,8 +27,7 @@ pub struct BodyData {
|
|||||||
pub async fn insert_newsletter_issue(
|
pub async fn insert_newsletter_issue(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
title: &str,
|
title: &str,
|
||||||
text_content: &str,
|
email_template: &dyn EmailTemplate,
|
||||||
html_content: &str,
|
|
||||||
) -> Result<Uuid, sqlx::Error> {
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
let newsletter_issue_id = Uuid::new_v4();
|
let newsletter_issue_id = Uuid::new_v4();
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
@@ -40,8 +39,8 @@ pub async fn insert_newsletter_issue(
|
|||||||
"#,
|
"#,
|
||||||
newsletter_issue_id,
|
newsletter_issue_id,
|
||||||
title,
|
title,
|
||||||
text_content,
|
email_template.text(),
|
||||||
html_content
|
email_template.html(),
|
||||||
);
|
);
|
||||||
transaction.execute(query).await?;
|
transaction.execute(query).await?;
|
||||||
Ok(newsletter_issue_id)
|
Ok(newsletter_issue_id)
|
||||||
@@ -56,9 +55,10 @@ pub async fn enqueue_delivery_tasks(
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO issue_delivery_queue (
|
INSERT INTO issue_delivery_queue (
|
||||||
newsletter_issue_id,
|
newsletter_issue_id,
|
||||||
subscriber_email
|
subscriber_email,
|
||||||
|
unsubscribe_token
|
||||||
)
|
)
|
||||||
SELECT $1, email
|
SELECT $1, email, unsubscribe_token
|
||||||
FROM subscriptions
|
FROM subscriptions
|
||||||
WHERE status = 'confirmed'
|
WHERE status = 'confirmed'
|
||||||
"#,
|
"#,
|
||||||
@@ -71,7 +71,9 @@ pub async fn enqueue_delivery_tasks(
|
|||||||
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
|
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
|
||||||
pub async fn publish_newsletter(
|
pub async fn publish_newsletter(
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool,
|
||||||
|
base_url,
|
||||||
|
..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
Form(form): Form<BodyData>,
|
Form(form): Form<BodyData>,
|
||||||
@@ -90,7 +92,13 @@ pub async fn publish_newsletter(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
|
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
|
.await
|
||||||
.context("Failed to store newsletter issue details.")?;
|
.context("Failed to store newsletter issue details.")?;
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,5 @@ pub async fn create_newsletter(
|
|||||||
post_id,
|
post_id,
|
||||||
post_excerpt: "",
|
post_excerpt: "",
|
||||||
};
|
};
|
||||||
let html_content = template.render().unwrap();
|
insert_newsletter_issue(transaction, post_title, &template).await
|
||||||
let text_content = template.text_version();
|
|
||||||
insert_newsletter_issue(transaction, post_title, &text_content, &html_content).await
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
domain::{NewSubscriber, SubscriberEmail},
|
domain::{NewSubscriber, SubscriberEmail},
|
||||||
email_client::EmailClient,
|
email_client::EmailClient,
|
||||||
routes::AppError,
|
routes::{AppError, generate_token},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::MessageTemplate,
|
templates::MessageTemplate,
|
||||||
};
|
};
|
||||||
@@ -13,35 +13,10 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rand::{Rng, distr::Alphanumeric};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{Executor, Postgres, Transaction};
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn generate_subscription_token() -> String {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
|
||||||
.map(char::from)
|
|
||||||
.take(25)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_chain_fmt(
|
|
||||||
e: &impl std::error::Error,
|
|
||||||
f: &mut std::fmt::Formatter,
|
|
||||||
) -> std::fmt::Result {
|
|
||||||
writeln!(f, "{}", e)?;
|
|
||||||
let mut current = e.source();
|
|
||||||
while let Some(cause) = current {
|
|
||||||
write!(f, "Caused by:\n\t{}", cause)?;
|
|
||||||
current = cause.source();
|
|
||||||
if current.is_some() {
|
|
||||||
writeln!(f)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "Adding a new subscriber",
|
name = "Adding a new subscriber",
|
||||||
skip(connection_pool, email_client, base_url, form),
|
skip(connection_pool, email_client, base_url, form),
|
||||||
@@ -69,7 +44,7 @@ pub async fn subscribe(
|
|||||||
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
|
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
|
||||||
.await
|
.await
|
||||||
.context("Failed to insert new subscriber in the database.")?;
|
.context("Failed to insert new subscriber in the database.")?;
|
||||||
let subscription_token = generate_subscription_token();
|
let subscription_token = generate_token();
|
||||||
store_token(&mut transaction, &subscription_token, &subscriber_id)
|
store_token(&mut transaction, &subscription_token, &subscriber_id)
|
||||||
.await
|
.await
|
||||||
.context("Failed to store the confirmation token for a new subscriber.")?;
|
.context("Failed to store the confirmation token for a new subscriber.")?;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{startup::AppState, templates::ConfirmTemplate};
|
use crate::{routes::generate_token, startup::AppState, templates::ConfirmTemplate};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -44,7 +44,8 @@ async fn confirm_subscriber(
|
|||||||
subscriber_id: &Uuid,
|
subscriber_id: &Uuid,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
|
"UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
|
||||||
|
generate_token(),
|
||||||
subscriber_id
|
subscriber_id
|
||||||
)
|
)
|
||||||
.execute(connection_pool)
|
.execute(connection_pool)
|
||||||
|
|||||||
44
src/routes/unsubscribe.rs
Normal file
44
src/routes/unsubscribe.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::{
|
||||||
|
routes::AppError,
|
||||||
|
startup::AppState,
|
||||||
|
templates::{NotFoundTemplate, UnsubscribeTemplate},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use sqlx::Executor;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UnsubQueryParams {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unsubscribe(
|
||||||
|
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let query = sqlx::query!(
|
||||||
|
"DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
token
|
||||||
|
);
|
||||||
|
let result = connection_pool
|
||||||
|
.execute(query)
|
||||||
|
.await
|
||||||
|
.context("Could not update subscriptions table.")?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Html(NotFoundTemplate.render().unwrap()),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ pub fn app(
|
|||||||
.route("/health_check", get(health_check))
|
.route("/health_check", get(health_check))
|
||||||
.route("/subscriptions", post(subscribe))
|
.route("/subscriptions", post(subscribe))
|
||||||
.route("/subscriptions/confirm", get(confirm))
|
.route("/subscriptions/confirm", get(confirm))
|
||||||
|
.route("/unsubscribe", get(unsubscribe))
|
||||||
.route("/posts", get(list_posts))
|
.route("/posts", get(list_posts))
|
||||||
.route("/posts/{post_id}", get(see_post))
|
.route("/posts/{post_id}", get(see_post))
|
||||||
.nest("/admin", admin_routes)
|
.nest("/admin", admin_routes)
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
|
|||||||
#[template(path = "../templates/404.html")]
|
#[template(path = "../templates/404.html")]
|
||||||
pub struct NotFoundTemplate;
|
pub struct NotFoundTemplate;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "../templates/unsubscribe.html")]
|
||||||
|
pub struct UnsubscribeTemplate;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "../templates/email/new_post.html")]
|
#[template(path = "../templates/email/new_post.html")]
|
||||||
pub struct NewPostEmailTemplate<'a> {
|
pub struct NewPostEmailTemplate<'a> {
|
||||||
@@ -59,8 +63,25 @@ pub struct NewPostEmailTemplate<'a> {
|
|||||||
pub post_excerpt: &'a str,
|
pub post_excerpt: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> NewPostEmailTemplate<'a> {
|
#[derive(Template)]
|
||||||
pub fn text_version(&self) -> String {
|
#[template(path = "../templates/email/standalone.html")]
|
||||||
|
pub struct StandaloneEmailTemplate<'a> {
|
||||||
|
pub base_url: &'a str,
|
||||||
|
pub html_content: &'a str,
|
||||||
|
pub text_content: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EmailTemplate: Sync {
|
||||||
|
fn html(&self) -> String;
|
||||||
|
fn text(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EmailTemplate for NewPostEmailTemplate<'a> {
|
||||||
|
fn html(&self) -> String {
|
||||||
|
self.render().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"New post available!
|
r#"New post available!
|
||||||
|
|
||||||
@@ -82,10 +103,31 @@ Alphonse
|
|||||||
|
|
||||||
zero2prod - Building better backends with Rust
|
zero2prod - Building better backends with Rust
|
||||||
Visit the blog: {}
|
Visit the blog: {}
|
||||||
Unsubscribe: {}/unsubscribe
|
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
|
||||||
|
|
||||||
You're receiving this because you subscribed to the zero2prod newsletter."#,
|
You're receiving this because you subscribed to the zero2prod newsletter."#,
|
||||||
self.post_title, self.base_url, self.post_id, self.base_url, self.base_url,
|
self.post_title, self.base_url, self.post_id, self.base_url, self.base_url,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> {
|
||||||
|
fn html(&self) -> String {
|
||||||
|
self.render().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&self) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
zero2prod - Building better backends with Rust
|
||||||
|
Visit the blog: {}
|
||||||
|
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
|
||||||
|
|
||||||
|
You're receiving this because you subscribed to the zero2prod newsletter."#,
|
||||||
|
self.text_content, self.base_url, self.base_url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
145
templates/email/base.html
Normal file
145
templates/email/base.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="zero2prod">
|
||||||
|
<meta name="keywords" content="rust backend programming">
|
||||||
|
<title>
|
||||||
|
{% block title %}Updates{% endblock %}
|
||||||
|
- zero2prod</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||||
|
color: white;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.header p {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
.post-preview {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.post-excerpt {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||||
|
color: white;
|
||||||
|
padding: 14px 28px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 24px 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.cta-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
padding: 24px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.unsubscribe {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.header,
|
||||||
|
.content,
|
||||||
|
.footer {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.post-preview {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.post-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Updates from zero2prod</h1>
|
||||||
|
<p>Fresh insights on Rust backend development</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>
|
||||||
|
<strong>zero2prod</strong>
|
||||||
|
<br>
|
||||||
|
Building better backends with Rust
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ base_url }}">Visit the blog</a> •
|
||||||
|
<a href="{{ base_url }}/unsubscribe?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
|
||||||
|
</p>
|
||||||
|
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,128 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}New post available: {{ post_title }}{% endblock %}
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="description" content="zero2prod newsletter" />
|
|
||||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
|
||||||
<title>{{ post_title }}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #374151;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
|
||||||
color: white;
|
|
||||||
padding: 32px 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.header p {
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 32px 24px;
|
|
||||||
}
|
|
||||||
.post-preview {
|
|
||||||
background-color: #f8fafc;
|
|
||||||
border-left: 4px solid #3b82f6;
|
|
||||||
padding: 24px;
|
|
||||||
margin: 24px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
.post-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.post-excerpt {
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
.cta-button {
|
|
||||||
display: inline-block;
|
|
||||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
|
||||||
color: white;
|
|
||||||
padding: 14px 28px;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 24px 0;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.cta-button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
background-color: #f8fafc;
|
|
||||||
padding: 24px;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.footer p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
.footer a {
|
|
||||||
color: #3b82f6;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.unsubscribe {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.header, .content, .footer {
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
.post-preview {
|
|
||||||
padding: 16px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
.post-title {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>A new post is available!</h1>
|
|
||||||
<p>Fresh insights on Rust backend development</p>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Hello there!</p>
|
<p>Hello there!</p>
|
||||||
<p>I just published a new post that I think you'll find interesting:</p>
|
<p>I just published a new post that I think you'll find interesting:</p>
|
||||||
<div class="post-preview">
|
<div class="post-preview">
|
||||||
@@ -139,19 +17,4 @@
|
|||||||
<br>
|
<br>
|
||||||
<strong>Alphonse</strong>
|
<strong>Alphonse</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{% endblock %}
|
||||||
<div class="footer">
|
|
||||||
<p>
|
|
||||||
<strong>zero2prod</strong>
|
|
||||||
<br>
|
|
||||||
Building better backends with Rust
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a href="{{ base_url }}">Visit the blog</a> •
|
|
||||||
<a href="{{ base_url }}/unsubscribe">Unsubscribe</a>
|
|
||||||
</p>
|
|
||||||
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
2
templates/email/standalone.html
Normal file
2
templates/email/standalone.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}{{ html_content }}{% endblock %}
|
||||||
45
templates/unsubscribe.html
Normal file
45
templates/unsubscribe.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Unsubscribed{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-12 h-12 text-amber-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-bold text-gray-800 mb-4">Good bye, old friend!</h1>
|
||||||
|
<h2 class="text-xl font-medium text-gray-600 mb-6">You've successfully unsubscribed</h2>
|
||||||
|
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||||
|
Your email has been removed from the database. You won't receive emails anymore. If you change your mind, you are welcome back any time!
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<a href="/"
|
||||||
|
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/posts"
|
||||||
|
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||||
|
</svg>
|
||||||
|
Posts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@ use argon2::{
|
|||||||
password_hash::{SaltString, rand_core::OsRng},
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
use fake::{Fake, faker::internet::en::SafeEmail};
|
use fake::{Fake, faker::internet::en::SafeEmail};
|
||||||
use linkify::LinkFinder;
|
use linkify::{Link, LinkFinder};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -169,16 +169,13 @@ impl TestApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
|
pub fn get_unsubscribe_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
|
||||||
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
||||||
let get_link = |s: &str| {
|
let get_link = |s: &str| {
|
||||||
let links: Vec<_> = LinkFinder::new()
|
let links = get_links(s);
|
||||||
.links(s)
|
assert!(!links.is_empty());
|
||||||
.filter(|l| *l.kind() == linkify::LinkKind::Url)
|
let mut confirmation_link =
|
||||||
.collect();
|
reqwest::Url::parse(links.last().unwrap().as_str()).unwrap();
|
||||||
assert_eq!(links.len(), 1);
|
|
||||||
let raw_link = links[0].as_str();
|
|
||||||
let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap();
|
|
||||||
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
|
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
|
||||||
confirmation_link.set_port(Some(self.port)).unwrap();
|
confirmation_link.set_port(Some(self.port)).unwrap();
|
||||||
confirmation_link
|
confirmation_link
|
||||||
@@ -189,6 +186,33 @@ impl TestApp {
|
|||||||
ConfirmationLinks { html, text }
|
ConfirmationLinks { html, text }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
||||||
|
let get_link = |s: &str| {
|
||||||
|
let links = get_links(s);
|
||||||
|
assert_eq!(links.len(), 1);
|
||||||
|
let mut confirmation_link = reqwest::Url::parse(links[0].as_str()).unwrap();
|
||||||
|
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
|
||||||
|
confirmation_link.set_port(Some(self.port)).unwrap();
|
||||||
|
confirmation_link
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = get_link(body["html"].as_str().unwrap());
|
||||||
|
let text = get_link(body["text"].as_str().unwrap());
|
||||||
|
ConfirmationLinks { html, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_unsubscribe(&self, unsubscribe_token: String) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.get(format!(
|
||||||
|
"{}/unsubscribe?token={}",
|
||||||
|
&self.address, unsubscribe_token
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/dashboard", &self.address))
|
.get(format!("{}/admin/dashboard", &self.address))
|
||||||
@@ -307,3 +331,29 @@ pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
|||||||
pub fn when_sending_an_email() -> MockBuilder {
|
pub fn when_sending_an_email() -> MockBuilder {
|
||||||
Mock::given(path("/email")).and(method("POST"))
|
Mock::given(path("/email")).and(method("POST"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fake_newsletter_body() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"title": "Newsletter title",
|
||||||
|
"text": "Newsletter body as plain text",
|
||||||
|
"html": "<p>Newsletter body as HTML</p>",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fake_post_body() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"title": "Post title",
|
||||||
|
"content": "Post content",
|
||||||
|
"idempotency_key": Uuid::new_v4().to_string(),
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_links(s: &'_ str) -> Vec<Link<'_>> {
|
||||||
|
LinkFinder::new()
|
||||||
|
.links(s)
|
||||||
|
.filter(|l| *l.kind() == linkify::LinkKind::Url)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ mod newsletters;
|
|||||||
mod posts;
|
mod posts;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
|
mod unsubscribe;
|
||||||
|
|||||||
129
tests/api/unsubscribe.rs
Normal file
129
tests/api/unsubscribe.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
|
||||||
|
use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscriber_can_unsubscribe() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT unsubscribe_token FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch saved token");
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.get_unsubscribe(
|
||||||
|
record
|
||||||
|
.unsubscribe_token
|
||||||
|
.expect("Confirmed subscriber should have a valid unsubscribe token"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 200);
|
||||||
|
let html_fragment = response.text().await.unwrap();
|
||||||
|
assert!(html_fragment.contains("Good bye, old friend"));
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT email FROM subscriptions")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch subscription table");
|
||||||
|
|
||||||
|
assert!(record.is_none());
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(0)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_valid_unsubscribe_link_is_present_in_new_post_email_notifications() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
let email_request = app
|
||||||
|
.email_server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
|
let unsubscribe_links = app.get_unsubscribe_links(&email_request);
|
||||||
|
reqwest::get(unsubscribe_links.html)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT email FROM subscriptions")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch subscription table");
|
||||||
|
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_valid_unsubscribe_link_is_present_in_emails_manually_sent() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
let email_request = app
|
||||||
|
.email_server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
|
let unsubscribe_links = app.get_unsubscribe_links(&email_request);
|
||||||
|
reqwest::get(unsubscribe_links.html)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_invalid_unsubscribe_token_is_rejected() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
|
||||||
|
let response = reqwest::get(format!("{}/unsubscribe?token=invalid", app.address))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status().as_u16(), 404);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user