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 @@ + + + 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/public/docs/invites.html b/public/docs/invites.html index 6125902..94252fd 100644 --- a/public/docs/invites.html +++ b/public/docs/invites.html @@ -147,7 +147,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')