Unsubscribe link in emails sent
This commit is contained in:
@@ -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"
|
||||
}
|
||||
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 @@
|
||||
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() {
|
||||
return Ok(ExecutionOutcome::EmptyQueue);
|
||||
}
|
||||
let (transaction, issue_id, email) = task.unwrap();
|
||||
let (transaction, task) = task.unwrap();
|
||||
Span::current()
|
||||
.record("newsletter_issue_id", display(issue_id))
|
||||
.record("subscriber_email", display(&email));
|
||||
match SubscriberEmail::parse(email.clone()) {
|
||||
.record("newsletter_issue_id", display(task.newsletter_issue_id))
|
||||
.record("subscriber_email", display(&task.subscriber_email));
|
||||
match SubscriberEmail::parse(task.subscriber_email.clone()) {
|
||||
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
|
||||
.send_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)
|
||||
}
|
||||
@@ -84,6 +90,13 @@ struct NewsletterIssue {
|
||||
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)]
|
||||
async fn get_issue(
|
||||
connection_pool: &PgPool,
|
||||
@@ -103,14 +116,20 @@ async fn get_issue(
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
pub struct Task {
|
||||
pub newsletter_issue_id: Uuid,
|
||||
pub subscriber_email: String,
|
||||
pub unsubscribe_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn dequeue_task(
|
||||
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 query = sqlx::query!(
|
||||
r#"
|
||||
SELECT newsletter_issue_id, subscriber_email
|
||||
SELECT newsletter_issue_id, subscriber_email, unsubscribe_token
|
||||
FROM issue_delivery_queue
|
||||
FOR UPDATE
|
||||
SKIP LOCKED
|
||||
@@ -119,11 +138,12 @@ async fn dequeue_task(
|
||||
);
|
||||
let r = transaction.fetch_optional(query).await?;
|
||||
if let Some(row) = r {
|
||||
Ok(Some((
|
||||
transaction,
|
||||
row.get("newsletter_issue_id"),
|
||||
row.get("subscriber_email"),
|
||||
)))
|
||||
let task = Task {
|
||||
newsletter_issue_id: row.get("newsletter_issue_id"),
|
||||
subscriber_email: row.get("subscriber_email"),
|
||||
unsubscribe_token: row.get("unsubscribe_token"),
|
||||
};
|
||||
Ok(Some((transaction, task)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||
routes::{AdminError, AppError},
|
||||
startup::AppState,
|
||||
templates::MessageTemplate,
|
||||
templates::{EmailTemplate, MessageTemplate, StandaloneEmailTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
@@ -27,8 +27,7 @@ pub struct BodyData {
|
||||
pub async fn insert_newsletter_issue(
|
||||
transaction: &mut Transaction<'static, Postgres>,
|
||||
title: &str,
|
||||
text_content: &str,
|
||||
html_content: &str,
|
||||
email_template: &dyn EmailTemplate,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let newsletter_issue_id = Uuid::new_v4();
|
||||
let query = sqlx::query!(
|
||||
@@ -40,8 +39,8 @@ pub async fn insert_newsletter_issue(
|
||||
"#,
|
||||
newsletter_issue_id,
|
||||
title,
|
||||
text_content,
|
||||
html_content
|
||||
email_template.text(),
|
||||
email_template.html(),
|
||||
);
|
||||
transaction.execute(query).await?;
|
||||
Ok(newsletter_issue_id)
|
||||
@@ -56,9 +55,10 @@ pub async fn enqueue_delivery_tasks(
|
||||
r#"
|
||||
INSERT INTO issue_delivery_queue (
|
||||
newsletter_issue_id,
|
||||
subscriber_email
|
||||
subscriber_email,
|
||||
unsubscribe_token
|
||||
)
|
||||
SELECT $1, email
|
||||
SELECT $1, email, unsubscribe_token
|
||||
FROM subscriptions
|
||||
WHERE status = 'confirmed'
|
||||
"#,
|
||||
@@ -71,7 +71,9 @@ pub async fn enqueue_delivery_tasks(
|
||||
#[tracing::instrument(name = "Publishing a newsletter", skip(connection_pool, form))]
|
||||
pub async fn publish_newsletter(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
connection_pool,
|
||||
base_url,
|
||||
..
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
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
|
||||
.context("Failed to store newsletter issue details.")?;
|
||||
|
||||
|
||||
@@ -119,7 +119,5 @@ pub async fn create_newsletter(
|
||||
post_id,
|
||||
post_excerpt: "",
|
||||
};
|
||||
let html_content = template.render().unwrap();
|
||||
let text_content = template.text_version();
|
||||
insert_newsletter_issue(transaction, post_title, &text_content, &html_content).await
|
||||
insert_newsletter_issue(transaction, post_title, &template).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::{routes::AppError, startup::AppState, templates::UnsubscribeTemplate};
|
||||
use crate::{
|
||||
routes::AppError,
|
||||
startup::AppState,
|
||||
templates::{NotFoundTemplate, UnsubscribeTemplate},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
@@ -29,7 +33,11 @@ pub async fn unsubscribe(
|
||||
.context("Could not update subscriptions table.")?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(NotFoundTemplate.render().unwrap()),
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
@@ -63,8 +63,25 @@ pub struct NewPostEmailTemplate<'a> {
|
||||
pub post_excerpt: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> NewPostEmailTemplate<'a> {
|
||||
pub fn text_version(&self) -> String {
|
||||
#[derive(Template)]
|
||||
#[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!(
|
||||
r#"New post available!
|
||||
|
||||
@@ -86,10 +103,31 @@ Alphonse
|
||||
|
||||
zero2prod - Building better backends with Rust
|
||||
Visit the blog: {}
|
||||
Unsubscribe: {}/unsubscribe
|
||||
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,157 +1,20 @@
|
||||
<!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 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>I just published a new post that I think you'll find interesting:</p>
|
||||
<div class="post-preview">
|
||||
<h2 class="post-title">{{ post_title }}</h2>
|
||||
{% if !post_excerpt.is_empty() %}<p class="post-excerpt">{{ post_excerpt }}</p>{% endif %}
|
||||
</div>
|
||||
<a href="{{ base_url }}/posts/{{ post_id }}" class="cta-button">Read the full post →</a>
|
||||
<p>
|
||||
This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.
|
||||
</p>
|
||||
<p>Thanks for being a subscriber!</p>
|
||||
<p>
|
||||
Best regards,
|
||||
<br>
|
||||
<strong>Alphonse</strong>
|
||||
</p>
|
||||
</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">Unsubscribe</a>
|
||||
</p>
|
||||
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New post available: {{ post_title }}{% endblock %}
|
||||
{% block content %}
|
||||
<p>Hello there!</p>
|
||||
<p>I just published a new post that I think you'll find interesting:</p>
|
||||
<div class="post-preview">
|
||||
<h2 class="post-title">{{ post_title }}</h2>
|
||||
{% if !post_excerpt.is_empty() %}<p class="post-excerpt">{{ post_excerpt }}</p>{% endif %}
|
||||
</div>
|
||||
<a href="{{ base_url }}/posts/{{ post_id }}" class="cta-button">Read the full post →</a>
|
||||
<p>
|
||||
This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.
|
||||
</p>
|
||||
<p>Thanks for being a subscriber!</p>
|
||||
<p>
|
||||
Best regards,
|
||||
<br>
|
||||
<strong>Alphonse</strong>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
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 %}
|
||||
@@ -3,7 +3,7 @@ use argon2::{
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use fake::{Fake, faker::internet::en::SafeEmail};
|
||||
use linkify::LinkFinder;
|
||||
use linkify::{Link, LinkFinder};
|
||||
use once_cell::sync::Lazy;
|
||||
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
||||
use uuid::Uuid;
|
||||
@@ -169,16 +169,29 @@ impl TestApp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_unsubscribe_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!(!links.is_empty());
|
||||
let mut confirmation_link =
|
||||
reqwest::Url::parse(links.last().unwrap().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 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: Vec<_> = LinkFinder::new()
|
||||
.links(s)
|
||||
.filter(|l| *l.kind() == linkify::LinkKind::Url)
|
||||
.collect();
|
||||
let links = get_links(s);
|
||||
assert_eq!(links.len(), 1);
|
||||
let raw_link = links[0].as_str();
|
||||
let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap();
|
||||
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
|
||||
@@ -318,3 +331,29 @@ pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
||||
pub fn when_sending_an_email() -> MockBuilder {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
use crate::helpers::TestApp;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email};
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsubscribe_works_with_a_valid_token() {
|
||||
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)
|
||||
@@ -18,7 +30,7 @@ async fn unsubscribe_works_with_a_valid_token() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("Good bye, old friend"));
|
||||
|
||||
@@ -28,4 +40,90 @@ async fn unsubscribe_works_with_a_valid_token() {
|
||||
.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