Compare commits
2 Commits
a37123a32d
...
998c156d3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998c156d3c | ||
|
|
f1ce77a762 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT newsletter_issue_id, subscriber_email\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
|
||||
"query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,15 +12,21 @@
|
||||
"ordinal": 1,
|
||||
"name": "subscriber_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "unsubscribe_token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a"
|
||||
"hash": "6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048"
|
||||
}
|
||||
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
22
.sqlx/query-8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "unsubscribe_token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561"
|
||||
}
|
||||
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
22
.sqlx/query-aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM subscriptions WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ database:
|
||||
username: "postgres"
|
||||
password: "Jq09NF6Y8ZXJS4jd9c8U"
|
||||
require_ssl: false
|
||||
timeout_millis: 1000
|
||||
email_client:
|
||||
authorization_token: "secret-token"
|
||||
redis_uri: "redis://127.0.0.1:6379"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
application:
|
||||
host: "0.0.0.0"
|
||||
database:
|
||||
timeout_millis: 500
|
||||
|
||||
@@ -110,6 +110,7 @@ pub struct DatabaseSettings {
|
||||
pub host: String,
|
||||
pub database_name: String,
|
||||
pub require_ssl: bool,
|
||||
pub timeout_millis: u64,
|
||||
}
|
||||
|
||||
impl DatabaseSettings {
|
||||
|
||||
@@ -111,7 +111,7 @@ impl IntoResponse for AppError {
|
||||
};
|
||||
Html(template.render().unwrap())
|
||||
};
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
|
||||
html.into_response()
|
||||
}
|
||||
AppError::FormError(error) => {
|
||||
let template = MessageTemplate::Error {
|
||||
|
||||
@@ -41,13 +41,16 @@ pub async fn subscribe(
|
||||
.begin()
|
||||
.await
|
||||
.context("Failed to acquire a Postgres connection from the pool.")?;
|
||||
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
|
||||
if let Some(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber)
|
||||
.await
|
||||
.context("Failed to insert new subscriber in the database.")?;
|
||||
.context("Failed to insert new subscriber in the database.")
|
||||
.map_err(AppError::unexpected_message)?
|
||||
{
|
||||
let subscription_token = generate_token();
|
||||
store_token(&mut transaction, &subscription_token, &subscriber_id)
|
||||
.await
|
||||
.context("Failed to store the confirmation token for a new subscriber.")?;
|
||||
.context("Failed to store the confirmation token for a new subscriber.")
|
||||
.map_err(AppError::unexpected_message)?;
|
||||
send_confirmation_email(
|
||||
&email_client,
|
||||
&new_subscriber,
|
||||
@@ -61,8 +64,10 @@ pub async fn subscribe(
|
||||
.commit()
|
||||
.await
|
||||
.context("Failed to commit the database transaction to store a new subscriber.")?;
|
||||
}
|
||||
|
||||
let template = MessageTemplate::Success {
|
||||
message: "A confirmation email has been sent.".to_string(),
|
||||
message: "You'll receive a confirmation email shortly.".to_string(),
|
||||
};
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
@@ -74,7 +79,15 @@ pub async fn subscribe(
|
||||
pub async fn insert_subscriber(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
new_subscriber: &NewSubscriber,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
) -> Result<Option<Uuid>, sqlx::Error> {
|
||||
let query = sqlx::query!(
|
||||
"SELECT id FROM subscriptions WHERE email = $1",
|
||||
new_subscriber.email.as_ref()
|
||||
);
|
||||
let existing = transaction.fetch_optional(query).await?;
|
||||
if existing.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let subscriber_id = Uuid::new_v4();
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
@@ -86,7 +99,7 @@ pub async fn insert_subscriber(
|
||||
Utc::now()
|
||||
);
|
||||
transaction.execute(query).await?;
|
||||
Ok(subscriber_id)
|
||||
Ok(Some(subscriber_id))
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
|
||||
@@ -1,23 +1,109 @@
|
||||
use crate::{
|
||||
domain::SubscriberEmail,
|
||||
email_client::EmailClient,
|
||||
routes::AppError,
|
||||
startup::AppState,
|
||||
templates::{NotFoundTemplate, UnsubscribeTemplate},
|
||||
templates::{
|
||||
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
|
||||
},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Query, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use sqlx::Executor;
|
||||
use sqlx::{Executor, PgPool};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UnsubQueryParams {
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub async fn unsubscribe(
|
||||
pub async fn get_unsubscribe() -> Response {
|
||||
Html(UnsubscribeTemplate.render().unwrap()).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UnsubFormData {
|
||||
email: String,
|
||||
}
|
||||
|
||||
pub async fn post_unsubscribe(
|
||||
State(AppState {
|
||||
connection_pool,
|
||||
email_client,
|
||||
base_url,
|
||||
}): State<AppState>,
|
||||
Form(UnsubFormData { email }): Form<UnsubFormData>,
|
||||
) -> Result<Response, AppError> {
|
||||
let subscriber_email = SubscriberEmail::parse(email)?;
|
||||
if let Some(token) = fetch_unsubscribe_token(&connection_pool, &subscriber_email)
|
||||
.await
|
||||
.context("Could not fetch unsubscribe token.")?
|
||||
{
|
||||
send_unsubscribe_email(&email_client, &subscriber_email, &base_url, &token)
|
||||
.await
|
||||
.context("Failed to send a confirmation email.")?;
|
||||
}
|
||||
let template = MessageTemplate::Success {
|
||||
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
|
||||
};
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Fetching unsubscribe token from the database", skip_all)]
|
||||
async fn fetch_unsubscribe_token(
|
||||
connection_pool: &PgPool,
|
||||
subscriber_email: &SubscriberEmail,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
let r = sqlx::query!(
|
||||
"SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
|
||||
subscriber_email.as_ref()
|
||||
)
|
||||
.fetch_optional(connection_pool)
|
||||
.await?;
|
||||
|
||||
Ok(r.and_then(|r| r.unsubscribe_token))
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Send an unsubscribe confirmation email", skip_all)]
|
||||
pub async fn send_unsubscribe_email(
|
||||
email_client: &EmailClient,
|
||||
subscriber_email: &SubscriberEmail,
|
||||
base_url: &str,
|
||||
unsubscribe_token: &str,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
let confirmation_link = format!(
|
||||
"{}/unsubscribe/confirm?token={}",
|
||||
base_url, unsubscribe_token
|
||||
);
|
||||
let html_content = format!(
|
||||
"You've requested to unsubscribe from my emails. To confirm, please click the link below:<br />\
|
||||
<a href=\"{}\">Confirm unsubscribe</a><br />\
|
||||
If you did not request this, you can safely ignore this email.",
|
||||
confirmation_link
|
||||
);
|
||||
let text_content = format!(
|
||||
"You've requested to unsubscribe from my emails. To confirm, please follow the link below:\
|
||||
{}\
|
||||
If you did not request this, you can safely ignore this email.",
|
||||
confirmation_link
|
||||
);
|
||||
email_client
|
||||
.send_email(
|
||||
subscriber_email,
|
||||
"I will miss you",
|
||||
&html_content,
|
||||
&text_content,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Removing user from database if he exists", skip_all)]
|
||||
pub async fn unsubscribe_confirm(
|
||||
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
@@ -33,12 +119,14 @@ pub async fn unsubscribe(
|
||||
.context("Could not update subscriptions table.")?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::info!("Unsubscribe token is not tied to any confirmed user");
|
||||
Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(NotFoundTemplate.render().unwrap()),
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
|
||||
tracing::info!("User successfully removed");
|
||||
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use secrecy::ExposeSecret;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use std::{net::TcpListener, sync::Arc};
|
||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use tower_sessions_redis_store::{
|
||||
@@ -37,8 +37,9 @@ impl Application {
|
||||
"{}:{}",
|
||||
configuration.application.host, configuration.application.port
|
||||
);
|
||||
let connection_pool =
|
||||
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
|
||||
let connection_pool = PgPoolOptions::new()
|
||||
.acquire_timeout(Duration::from_millis(configuration.database.timeout_millis))
|
||||
.connect_lazy_with(configuration.database.with_db());
|
||||
let email_client = EmailClient::build(configuration.email_client).unwrap();
|
||||
let pool = Pool::new(
|
||||
Config::from_url(configuration.redis_uri.expose_secret())
|
||||
@@ -81,7 +82,7 @@ impl Application {
|
||||
}
|
||||
|
||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||
tracing::debug!("listening on {}", self.local_addr());
|
||||
tracing::debug!("Listening on {}", self.local_addr());
|
||||
if let Some(tls_config) = self.tls_config {
|
||||
axum_server::from_tcp_rustls(self.listener, tls_config)
|
||||
.serve(self.router.into_make_service())
|
||||
@@ -127,7 +128,8 @@ pub fn app(
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/subscriptions", post(subscribe))
|
||||
.route("/subscriptions/confirm", get(confirm))
|
||||
.route("/unsubscribe", get(unsubscribe))
|
||||
.route("/unsubscribe", get(get_unsubscribe).post(post_unsubscribe))
|
||||
.route("/unsubscribe/confirm", get(unsubscribe_confirm))
|
||||
.route("/posts", get(list_posts))
|
||||
.route("/posts/{post_id}", get(see_post))
|
||||
.nest("/admin", admin_routes)
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
|
||||
#[template(path = "../templates/404.html")]
|
||||
pub struct NotFoundTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/unsubscribe_confirm.html")]
|
||||
pub struct UnsubscribeConfirmTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/unsubscribe.html")]
|
||||
pub struct UnsubscribeTemplate;
|
||||
@@ -103,7 +107,7 @@ Alphonse
|
||||
|
||||
zero2prod - Building better backends with Rust
|
||||
Visit the blog: {}
|
||||
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
|
||||
Unsubscribe: {}/unsubscribe/confirm?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,
|
||||
@@ -124,7 +128,7 @@ impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> {
|
||||
|
||||
zero2prod - Building better backends with Rust
|
||||
Visit the blog: {}
|
||||
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
|
||||
Unsubscribe: {}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN
|
||||
|
||||
You're receiving this because you subscribed to the zero2prod newsletter."#,
|
||||
self.text_content, self.base_url, self.base_url
|
||||
|
||||
@@ -62,16 +62,16 @@
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a href="/"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
home
|
||||
Home
|
||||
</a>
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
posts
|
||||
Posts
|
||||
</a>
|
||||
<a href="/admin/dashboard"
|
||||
hx-boost="true"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
dashboard
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -98,6 +98,9 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<a href="/unsubscribe"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0">
|
||||
|
||||
@@ -136,7 +136,7 @@ body {
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ base_url }}">Visit the blog</a> •
|
||||
<a href="{{ base_url }}/unsubscribe?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
|
||||
<a href="{{ base_url }}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
|
||||
</p>
|
||||
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Unsubscribed{% endblock %}
|
||||
{% block title %}Unsubscribe{% 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"
|
||||
<div class="py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-orange-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" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</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!
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Unsubscribe</h1>
|
||||
<p class="text-gray-600 text-sm">I'm sorry to see you go. Enter your email address below to unsubscribe.</p>
|
||||
</div>
|
||||
<form hx-post="/unsubscribe"
|
||||
hx-target="#unsubscribe-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-6">
|
||||
<div>
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-amber-800">
|
||||
<p>
|
||||
You will receive an email with an unsubscribe link. Alternatively, you can click the 'Unsubscribe' link in any of our newsletter emails.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-orange-600 text-white hover:bg-orange-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Unsubscribe
|
||||
</button>
|
||||
<div id="unsubscribe-messages" class="mt-4"></div>
|
||||
</form>
|
||||
<div class="mt-8 pt-6 border-t border-gray-200 text-center">
|
||||
<p class="text-sm text-gray-500 mb-4">Changed your mind?</p>
|
||||
<div class="flex flex-col sm:flex-row gap-2 justify-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>
|
||||
class="text-blue-600 hover:text-blue-700 text-sm font-medium">Back to homepage</a>
|
||||
<span class="hidden sm:inline text-gray-300">•</span>
|
||||
<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>
|
||||
class="text-blue-600 hover:text-blue-700 text-sm font-medium">See latest posts</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
45
templates/unsubscribe_confirm.html
Normal file
45
templates/unsubscribe_confirm.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, 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 %}
|
||||
@@ -202,10 +202,10 @@ impl TestApp {
|
||||
ConfirmationLinks { html, text }
|
||||
}
|
||||
|
||||
pub async fn get_unsubscribe(&self, unsubscribe_token: String) -> reqwest::Response {
|
||||
pub async fn get_unsubscribe_confirm(&self, unsubscribe_token: &str) -> reqwest::Response {
|
||||
self.api_client
|
||||
.get(format!(
|
||||
"{}/unsubscribe?token={}",
|
||||
"{}/unsubscribe/confirm?token={}",
|
||||
&self.address, unsubscribe_token
|
||||
))
|
||||
.send()
|
||||
@@ -298,6 +298,18 @@ impl TestApp {
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
{
|
||||
self.api_client
|
||||
.post(format!("{}/unsubscribe", self.address))
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure_database(config: &DatabaseSettings) -> PgPool {
|
||||
|
||||
@@ -8,3 +8,4 @@ mod posts;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
mod unsubscribe;
|
||||
mod unsubscribe_confirm;
|
||||
|
||||
@@ -16,7 +16,7 @@ async fn subscribe_displays_a_confirmation_message_for_valid_form_data() {
|
||||
|
||||
assert!(response.status().is_success());
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("A confirmation email has been sent"));
|
||||
assert!(html_fragment.contains("You'll receive a confirmation email shortly"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -33,8 +33,8 @@ async fn subscribe_persists_the_new_subscriber() {
|
||||
let response = app.post_subscriptions(body).await;
|
||||
|
||||
assert!(response.status().is_success());
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("A confirmation email has been sent"));
|
||||
let html_fragment = dbg!(response.text().await.unwrap());
|
||||
assert!(html_fragment.contains("You'll receive a confirmation email shortly"));
|
||||
|
||||
let saved = sqlx::query!("SELECT email, status FROM subscriptions")
|
||||
.fetch_one(&app.connection_pool)
|
||||
|
||||
@@ -22,16 +22,12 @@ async fn subscriber_can_unsubscribe() {
|
||||
.expect("Failed to fetch saved token");
|
||||
|
||||
let response = app
|
||||
.get_unsubscribe(
|
||||
record
|
||||
.unsubscribe_token
|
||||
.expect("Confirmed subscriber should have a valid unsubscribe token"),
|
||||
)
|
||||
.get_unsubscribe_confirm(&record.unsubscribe_token.unwrap())
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("Good bye, old friend"));
|
||||
assert!(html_fragment.contains("Good bye, friend"));
|
||||
|
||||
let record = sqlx::query!("SELECT email FROM subscriptions")
|
||||
.fetch_optional(&app.connection_pool)
|
||||
@@ -120,9 +116,11 @@ 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();
|
||||
let response = app.get_unsubscribe_confirm("invalid-token").await;
|
||||
|
||||
// let response = reqwest::get(format!("{}/unsubscribe?token=invalid", app.address))
|
||||
// .await
|
||||
// .unwrap();
|
||||
|
||||
assert_eq!(response.status().as_u16(), 404);
|
||||
}
|
||||
@@ -139,16 +137,12 @@ async fn subscription_works_after_unsubscribe() {
|
||||
let email = record.email;
|
||||
|
||||
let response = app
|
||||
.get_unsubscribe(
|
||||
record
|
||||
.unsubscribe_token
|
||||
.expect("Confirmed subscriber should have a valid unsubscribe token"),
|
||||
)
|
||||
.get_unsubscribe_confirm(&record.unsubscribe_token.unwrap())
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("Good bye, old friend"));
|
||||
assert!(html_fragment.contains("Good bye, friend"));
|
||||
|
||||
let record = sqlx::query!("SELECT email, unsubscribe_token FROM subscriptions")
|
||||
.fetch_optional(&app.connection_pool)
|
||||
|
||||
67
tests/api/unsubscribe_confirm.rs
Normal file
67
tests/api/unsubscribe_confirm.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use crate::helpers::{TestApp, when_sending_an_email};
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsubscribe_form_sends_a_valid_link_if_email_is_in_database() {
|
||||
let app = TestApp::spawn().await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
|
||||
when_sending_an_email()
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
let record = sqlx::query!("SELECT email, unsubscribe_token FROM subscriptions")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to fetch saved email and token");
|
||||
|
||||
let body = serde_json::json!({
|
||||
"email": record.email
|
||||
});
|
||||
app.post_unsubscribe(&body).await;
|
||||
|
||||
let email_request = app
|
||||
.email_server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
|
||||
let unsubscribe_links = app.get_unsubscribe_links(&email_request);
|
||||
|
||||
assert!(
|
||||
unsubscribe_links
|
||||
.html
|
||||
.as_str()
|
||||
.contains(&record.unsubscribe_token.unwrap())
|
||||
);
|
||||
|
||||
let response = reqwest::get(unsubscribe_links.html)
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("Good bye, friend"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_invalid_email_is_ignored() {
|
||||
let app = TestApp::spawn().await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
|
||||
when_sending_an_email()
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"email": "invalid.email@example.com"
|
||||
});
|
||||
|
||||
app.post_unsubscribe(&body).await;
|
||||
}
|
||||
Reference in New Issue
Block a user