From 93fed93d41006c8b594c66335847764f897de89e Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Thu, 20 Nov 2025 23:29:44 +0100
Subject: [PATCH] Add unsubscribe endpoint and email footers
---
app/pages/unsubscribe.vue | 102 ++++++++++++++++++
public/docs/feedback-request.html | 6 +-
public/docs/invites.html | 6 +-
server/api/admin/waitlist/[id]/invite.post.ts | 4 +-
server/api/service/cron/waitlist-drip.get.ts | 8 +-
server/api/service/updates.delete.ts | 33 ++++++
server/utils/invitations.ts | 34 ++++--
7 files changed, 177 insertions(+), 16 deletions(-)
create mode 100644 app/pages/unsubscribe.vue
create mode 100644 server/api/service/updates.delete.ts
diff --git a/app/pages/unsubscribe.vue b/app/pages/unsubscribe.vue
new file mode 100644
index 0000000..4bcdf6e
--- /dev/null
+++ b/app/pages/unsubscribe.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+ Updates
+
+ Bleibe oder melde dich ab
+
+ Wir sind auf ehrliches Feedback angewiesen, um OpenSquawk besser zu machen. Es wäre schade, dich zu verlieren –
+ hinter info@opensquawk.de
+ kannst du uns jederzeit schreiben, was du dir wünschst. Deine Nachrichten landen direkt bei den Menschen, die OpenSquawk
+ entwickeln, damit wir gemeinsam eine richtig starke Community aufbauen können.
+
+
+
+
+
+
+
E-Mail abmelden
+
Wir entfernen deine Adresse aus der Warteliste und den Produktupdates.
+
+
+ Erfolgreich abgemeldet
+
+
+ {{ errorMessage || 'Abmeldung fehlgeschlagen' }}
+
+
+
+
+
+
+ Wir schicken keine weiteren Mails an diese Adresse, sobald du dich abgemeldet hast.
+
+
+
+
+
+
+
diff --git a/public/docs/feedback-request.html b/public/docs/feedback-request.html
index 521332c..5aec3fb 100644
--- a/public/docs/feedback-request.html
+++ b/public/docs/feedback-request.html
@@ -112,7 +112,11 @@
© OpenSquawk · Not for real-world aviation.
- Feedback · info@opensquawk.de
+
+ Feedback ·
+ info@opensquawk.de
+
+ If you prefer no more emails: Unsubscribe
|
Built with an open stack
diff --git a/server/api/admin/waitlist/[id]/invite.post.ts b/server/api/admin/waitlist/[id]/invite.post.ts
index 2be44f9..3c6d371 100644
--- a/server/api/admin/waitlist/[id]/invite.post.ts
+++ b/server/api/admin/waitlist/[id]/invite.post.ts
@@ -54,8 +54,8 @@ export default defineEventHandler(async (event) => {
entry.invitationSentAt = now
await entry.save()
- const html = await renderInvitationEmail(invitation.code)
- const text = renderInvitationText(invitation.code)
+ const html = await renderInvitationEmail(invitation.code, entry.email)
+ const text = renderInvitationText(invitation.code, entry.email)
await sendMail({
to: email,
diff --git a/server/api/service/cron/waitlist-drip.get.ts b/server/api/service/cron/waitlist-drip.get.ts
index 0601d39..c959f4d 100644
--- a/server/api/service/cron/waitlist-drip.get.ts
+++ b/server/api/service/cron/waitlist-drip.get.ts
@@ -57,8 +57,8 @@ export default defineEventHandler(async () => {
entry.invitationSentAt = sentAt
await entry.save()
- const html = await renderInvitationEmail(invitation.code)
- const text = renderInvitationText(invitation.code)
+ const html = await renderInvitationEmail(invitation.code, email)
+ const text = renderInvitationText(invitation.code, email)
await sendMail({
to: email,
@@ -88,8 +88,8 @@ export default defineEventHandler(async () => {
const requestedAt = new Date()
- const text = renderFeedbackText()
- const html = await renderFeedbackEmail()
+ const text = renderFeedbackText(email)
+ const html = await renderFeedbackEmail(email)
await sendMail({
to: email,
diff --git a/server/api/service/updates.delete.ts b/server/api/service/updates.delete.ts
new file mode 100644
index 0000000..7649111
--- /dev/null
+++ b/server/api/service/updates.delete.ts
@@ -0,0 +1,33 @@
+import { createError, readBody } from 'h3'
+import { UpdateSubscriber } from '../../models/UpdateSubscriber'
+import { WaitlistEntry } from '../../models/WaitlistEntry'
+
+interface UnsubscribeRequestBody {
+ email?: string
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const email = body.email?.trim().toLowerCase()
+
+ if (!email) {
+ throw createError({ statusCode: 400, statusMessage: 'Email is required' })
+ }
+
+ const [updateResult, waitlistResult] = await Promise.all([
+ UpdateSubscriber.deleteOne({ email }),
+ WaitlistEntry.deleteOne({ email }),
+ ])
+
+ const removed = Boolean(updateResult.deletedCount || waitlistResult.deletedCount)
+
+ if (!removed) {
+ throw createError({ statusCode: 404, statusMessage: 'No matching email found' })
+ }
+
+ return {
+ success: true,
+ removedFromUpdates: updateResult.deletedCount > 0,
+ removedFromWaitlist: waitlistResult.deletedCount > 0,
+ }
+})
diff --git a/server/utils/invitations.ts b/server/utils/invitations.ts
index fc323a7..0f09db3 100644
--- a/server/utils/invitations.ts
+++ b/server/utils/invitations.ts
@@ -16,6 +16,16 @@ export function generateInvitationCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
+function buildUnsubscribeUrl(email?: string) {
+ const baseUrl = 'https://opensquawk.de/unsubscribe'
+ if (!email) {
+ return baseUrl
+ }
+
+ const params = new URLSearchParams({ email })
+ return `${baseUrl}?${params.toString()}`
+}
+
async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallback: string) {
if (cachedTemplates[kind]) {
return cachedTemplates[kind] as string
@@ -34,17 +44,19 @@ async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallb
}
}
-export async function renderInvitationEmail(code: string) {
+export async function renderInvitationEmail(code: string, email?: string) {
const template = await loadTemplate(
'invitations',
'invites.html',
- ` Your OpenSquawk invite code: {{INVITE_CODE}} `
+ `Your OpenSquawk invite code: {{INVITE_CODE}} Unsubscribe from updates `
)
- return template.replace(/{{INVITE_CODE}}/g, code)
+ return template
+ .replace(/{{INVITE_CODE}}/g, code)
+ .replace(/{{UNSUBSCRIBE_URL}}/g, buildUnsubscribeUrl(email))
}
-export function renderInvitationText(code: string) {
+export function renderInvitationText(code: string, email?: string) {
const lines = [
'Welcome to OpenSquawk!',
'',
@@ -56,22 +68,25 @@ export function renderInvitationText(code: string) {
'',
'Blue skies,',
'Your OpenSquawk crew',
+ '',
+ 'If you would rather not receive emails from us, you can opt out here:',
+ buildUnsubscribeUrl(email),
]
return lines.join('\n')
}
-export async function renderFeedbackEmail() {
+export async function renderFeedbackEmail(email?: string) {
const template = await loadTemplate(
'feedback',
'feedback-request.html',
- `We'd love your feedback: opensquawk.de/feedback `
+ `We'd love your feedback: opensquawk.de/feedback Unsubscribe from future emails `
)
- return template
+ return template.replace(/{{UNSUBSCRIBE_URL}}/g, buildUnsubscribeUrl(email))
}
-export function renderFeedbackText() {
+export function renderFeedbackText(email?: string) {
const lines = [
'Hi there,',
'',
@@ -81,6 +96,9 @@ export function renderFeedbackText() {
'https://opensquawk.de/feedback',
'',
'Thank you for helping us improve OpenSquawk!',
+ '',
+ 'If you do not want any more emails, you can unsubscribe here:',
+ buildUnsubscribeUrl(email),
]
return lines.join('\n')
|