feat: rich bulk mail

This commit is contained in:
Ben Allfree
2024-01-07 12:22:12 +00:00
parent eaa4ad70b1
commit 6c83d8a6c8
11 changed files with 893 additions and 98 deletions

View File

@@ -3,108 +3,129 @@ import {
DefaultSettingsService,
MOTHERSHIP_ADMIN_PASSWORD,
MOTHERSHIP_ADMIN_USERNAME,
MOTHERSHIP_DATA_DB,
MOTHERSHIP_INTERNAL_URL,
MOTHERSHIP_URL,
SETTINGS,
TEST_EMAIL,
} from '$constants'
import { LogLevelName, LoggerService } from '$src/shared'
import { SqliteService } from '$services'
import { LogLevelName, LoggerService, UserFields } from '$src/shared'
import { map } from '@s-libs/micro-dash'
import Bottleneck from 'bottleneck'
import PocketBase, { RecordModel } from 'pocketbase'
;(async () => {
DefaultSettingsService(SETTINGS)
LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
import { InvalidArgumentError, program } from 'commander'
import PocketBase from 'pocketbase'
const TBL_SENT_MESSAGES = `sent_messages`
DefaultSettingsService(SETTINGS)
const { dbg, info } = LoggerService({
level: DEBUG() ? LogLevelName.Debug : LogLevelName.Info,
}).create(`mail.ts`)
function myParseInt(value: string) {
// parseInt takes a string and a radix
const parsedValue = parseInt(value, 10)
if (isNaN(parsedValue)) {
throw new InvalidArgumentError('Not a number.')
}
return parsedValue
}
function interpolateString(
template: string,
dict: { [key: string]: string },
): string {
return template.replace(/\{\$(\w+)\}/g, (match, key) => {
dbg({ match, key })
const lowerKey = key.toLowerCase()
return dict.hasOwnProperty(lowerKey) ? dict[lowerKey]! : match
})
}
program
.argument(`<messageId>`, `ID of the message to send`)
.option('--limit <number>', `Max messages to send`, myParseInt, 1)
.option('--confirm', `Really send messages`, false)
.action(async (messageId, { limit, confirm }) => {
dbg({ messageId, confirm, limit })
const { getDatabase } = SqliteService({})
const db = await getDatabase(MOTHERSHIP_DATA_DB())
info(MOTHERSHIP_URL())
const client = new PocketBase(MOTHERSHIP_INTERNAL_URL())
await client.admins.authWithPassword(
MOTHERSHIP_ADMIN_USERNAME(),
MOTHERSHIP_ADMIN_PASSWORD(),
)
const message = await client
.collection(`campaign_messages`)
.getOne(messageId, { expand: 'campaign' })
const { campaign } = message.expand || {}
dbg({ messageId, limit, message, campaign })
const vars: { [_: string]: string } = {
messageId,
}
await Promise.all(
map(campaign.vars, async (sql, k) => {
const res = await db.raw(sql)
const [{ value }] = res
vars[k.toLocaleLowerCase()] = value
}),
)
dbg({ vars })
const subject = interpolateString(message.subject, vars)
const body = interpolateString(message.body, vars)
const sql = `SELECT u.*
FROM (${campaign.usersQuery}) u
LEFT JOIN sent_messages sm ON u.id = sm.user AND sm.campaign_message = '${messageId}'
WHERE sm.id IS NULL;
`
dbg(sql)
const users = (await db.raw<UserFields[]>(sql)).slice(0, limit)
// dbg({ users })
const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 100 })
await Promise.all(
users.map((user) => {
return limiter.schedule(async () => {
if (!confirm) {
const old = user.email
user.email = TEST_EMAIL()
info(`Sending to ${user.email} masking ${old}`)
} else {
info(`Sending to ${user.email}`)
}
await client.send(`/api/mail`, {
method: 'POST',
body: {
to: user.email,
subject,
body: `${body}<hr/>I only send PocketHost annoucements. But I get it. <a href="https://pockethost-central.pockethost.io/api/unsubscribe?e=${user.id}">[[unsub]]</a>`,
},
})
info(`Sent`)
if (confirm) {
await client.collection(TBL_SENT_MESSAGES).create({
user: user.id,
message: messageId,
campaign_message: messageId,
})
}
})
}),
)
SqliteService().shutdown()
})
const logger = LoggerService().create(`mail.ts`)
const { dbg, error, info, warn } = logger
info(MOTHERSHIP_URL())
const client = new PocketBase(MOTHERSHIP_INTERNAL_URL())
await client.admins.authWithPassword(
MOTHERSHIP_ADMIN_USERNAME(),
MOTHERSHIP_ADMIN_PASSWORD(),
)
const membershipCount = await client
.collection('settings')
.getFirstListItem(`name = 'founders-edition-count'`)
const MESSAGES = {
'founder-announce-1': {
subject: `Grab your Founder's Edition membership before they're gone (${membershipCount.value} remaining)`,
body: [
`Hello, this message is going out to anyone who is already a PocketHost user…`,
`<p>In case you missed <a href="https://discord.com/channels/1128192380500193370/1179854182363173005/1184769442618552340">the Discord announcement</a>, PocketHost is rolling out a paid tier of unlimited (fair use) bandwidth, storage, and projects.`,
`<p>New free users are limited to one project. To keep things fair, I made you a Legacy user which means all your existing projects still work but you cant create new ones.`,
`<p>As a way of saying thank you to all of you who supported PocketHost in the early days, I made a Founders Edition membership where you can pay once ($299) for lifetime access, or pay yearly ($99/yr) which is a 50% discount.`,
`<p>There are only <span style="text-decoration: line-through;">100</span> ${membershipCount.value} Founders Edition memberships. If you want one, please grab one from the PocketHost dashboard (https://app.pockethost.io/dashboard) before I announce it publicly.`,
`<p>Happy coding! `,
`<p>~Ben`,
],
},
'founder-announce-2': {
subject: `${membershipCount.value} Founder's Memberships left`,
body: [
`<p>Just a reminder to anyone who is still thinking about a Founder's membership - they are my way of saying thank you to all of you who supported PocketHost in the early days. They haven't been announced publicly yet, they are just for you guys right now.`,
`<p>You can grab one for $299 lifetime access, or $99/yr which is a 55% discount.`,
`<p>There are <span style="text-decoration: line-through;">100</span> ${membershipCount.value} Founders Edition memberships available. If you want one, please grab one from your PocketHost account page (https://app.pockethost.io/account).`,
`<p>Happy coding! `,
`<p>~Ben`,
],
},
}
const TEST = !!process.env.TEST
const TRAUNCH_SIZE = 1000
const MESSAGE_ID = `founder-announce-2`
const TBL_SENT_MESSAGES = `sent_messages`
const TBL_USERS = 'users'
const { subject, body } = MESSAGES[MESSAGE_ID]
const users = (
await client.collection(TBL_USERS).getFullList(undefined, {
filter: `verified=1 && unsubscribe=0 && subscription='legacy'`,
expand: `sent_messages(user)`,
})
)
.filter((user) => {
const sent = user.expand?.[`sent_messages(user)`] as
| RecordModel
| undefined
if (sent?.find((rec: RecordModel) => rec.message === MESSAGE_ID)) {
dbg(`Skipping ${user.email}`)
return false
}
return true
})
.slice(0, TRAUNCH_SIZE)
info(`user count ${users.length}`)
const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 100 })
await Promise.all(
users.map((user) => {
if (TEST) {
user.email = 'ben@benallfree.com'
}
return limiter.schedule(async () => {
info(`Sending to ${user.email}`)
await client.send(`/api/mail`, {
method: 'POST',
body: {
to: user.email,
subject,
body: body.join(`<p>`),
},
})
if (!TEST) {
await client
.collection(TBL_SENT_MESSAGES)
.create({ user: user.id, message: MESSAGE_ID })
}
})
}),
)
})()
program.parseAsync()

View File

@@ -94,6 +94,8 @@ export const SETTINGS = {
),
DISCORD_POCKETSTREAM_URL: mkString(''),
TEST_EMAIL: mkString(),
}
;(() => {
let passed = true
@@ -215,10 +217,15 @@ export const INSTANCE_APP_MIGRATIONS_DIR = () =>
export const DISCORD_POCKETSTREAM_URL = () =>
settings().DISCORD_POCKETSTREAM_URL
export const TEST_EMAIL = () => settings().TEST_EMAIL
/**
* Helpers
*/
export const MOTHERSHIP_DATA_ROOT = () => INSTANCE_DATA_ROOT(MOTHERSHIP_NAME())
export const MOTHERSHIP_DATA_DB = () =>
join(MOTHERSHIP_DATA_ROOT(), `pb_data`, `data.db`)
export const MOTHERSHIP_INTERNAL_URL = (path = '') =>
`http://${MOTHERSHIP_INTERNAL_HOST()}:${MOTHERSHIP_PORT()}${path}`
export const INSTANCE_DATA_ROOT = (id: InstanceId) => join(DATA_ROOT(), id)

View File

@@ -0,0 +1,276 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const snapshot = [
{
"id": "18rfmj8aklx6bjq",
"created": "2023-12-22 17:15:47.557Z",
"updated": "2023-12-29 15:07:15.882Z",
"name": "sent_messages",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "wuitrzp6",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "systemprofiles0",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "yzvlcy7m",
"name": "message",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "kigwtdjb",
"name": "campaign",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "w1vjr1edr5tsam3",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "w1vjr1edr5tsam3",
"created": "2023-12-29 14:50:43.496Z",
"updated": "2024-01-05 09:13:27.720Z",
"name": "campaign_messages",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "4h2cqwha",
"name": "body",
"type": "editor",
"required": true,
"presentable": false,
"unique": false,
"options": {
"convertUrls": false
}
},
{
"system": false,
"id": "lxy9jyyk",
"name": "subject",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "wsfnencj",
"name": "campaign",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "yfhnigik0uvyt4m",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "yfhnigik0uvyt4m",
"created": "2024-01-05 08:50:50.016Z",
"updated": "2024-01-05 09:08:00.316Z",
"name": "campaigns",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "1laswhyx",
"name": "name",
"type": "text",
"required": true,
"presentable": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "4fxgwtui",
"name": "vars",
"type": "json",
"required": true,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "tni65iyd",
"name": "usersQuery",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "s00x84jumfjcuvc",
"created": "2024-01-05 08:59:19.802Z",
"updated": "2024-01-05 08:59:19.802Z",
"name": "subscribed_users",
"type": "view",
"system": false,
"schema": [
{
"system": false,
"id": "v2xqodbb",
"name": "username",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "r2ir7x1v",
"name": "email",
"type": "email",
"required": false,
"presentable": false,
"unique": false,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
},
{
"system": false,
"id": "ngvopwyl",
"name": "subscription",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"free",
"premium",
"lifetime",
"legacy"
]
}
},
{
"system": false,
"id": "a5yursiu",
"name": "isLegacy",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "wly2vqr5",
"name": "isFounder",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"query": "select id, username, email, subscription, isLegacy, isFounder,created,updated from users where verified=1 and unsubscribe=0"
}
}
];
const collections = snapshot.map((item) => new Collection(item));
return Dao(db).importCollections(collections, false, null);
}, (db) => {
return null;
})

View File

@@ -0,0 +1,67 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "yfhnigik0uvyt4m",
"created": "2024-01-05 08:50:50.016Z",
"updated": "2024-01-05 17:03:19.348Z",
"name": "campaigns",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "1laswhyx",
"name": "name",
"type": "text",
"required": true,
"presentable": true,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "4fxgwtui",
"name": "vars",
"type": "json",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSize": 20000
}
},
{
"system": false,
"id": "tni65iyd",
"name": "usersQuery",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("yfhnigik0uvyt4m");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,69 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "w1vjr1edr5tsam3",
"created": "2023-12-29 14:50:43.496Z",
"updated": "2024-01-05 17:05:50.218Z",
"name": "campaign_messages",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "wsfnencj",
"name": "campaign",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "yfhnigik0uvyt4m",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "lxy9jyyk",
"name": "subject",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "4h2cqwha",
"name": "body",
"type": "editor",
"required": true,
"presentable": false,
"unique": false,
"options": {
"convertUrls": false
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("w1vjr1edr5tsam3");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("18rfmj8aklx6bjq")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "kigwtdjb",
"name": "campaign_message",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "w1vjr1edr5tsam3",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("18rfmj8aklx6bjq")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "kigwtdjb",
"name": "campaign",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "w1vjr1edr5tsam3",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,57 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "hsuwe2h3csch1yr",
"created": "2024-01-07 07:16:49.459Z",
"updated": "2024-01-07 07:16:49.459Z",
"name": "audit",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "3frdgrxe",
"name": "user",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "systemprofiles0",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "chqbmew7",
"name": "note",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("hsuwe2h3csch1yr");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,31 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("hsuwe2h3csch1yr")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "orqvyzje",
"name": "email",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("hsuwe2h3csch1yr")
// remove
collection.schema.removeField("orqvyzje")
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,31 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("hsuwe2h3csch1yr")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "cgkurilx",
"name": "event",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("hsuwe2h3csch1yr")
// remove
collection.schema.removeField("cgkurilx")
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,136 @@
/// <reference path="../types/types.d.ts" />
routerAdd('POST', '/api/sns', (c) => {
const log = (...s) =>
console.log(
`*** [sns]`,
...s.map((p) => {
if (typeof p === 'object') return JSON.stringify(p, null, 2)
return p
}),
)
const error = (...s) => console.error(`***`, ...s)
const audit = (key, note) => {
log(note)
const collection = $app.dao().findCollectionByNameOrId('audit')
const record = new Record(collection, {
...key,
note,
})
$app.dao().saveRecord(record)
}
const processBounce = (emailAddress) => {
log(`Processing ${emailAddress}`)
try {
const user = $app
.dao()
.findFirstRecordByData('users', 'email', emailAddress)
log(`user is`, user)
user.setVerified(false)
try {
$app.dao().saveRecord(user)
} catch (e) {
audit(
{ user: user.getId(), event: `PBOUNCE_ERR` },
`User ${emailAddress} could not be disabled `,
)
}
audit(
{ user: user.getId(), event: `PBOUNCE` },
`User ${emailAddress} has been disabled`,
)
} catch (e) {
audit(
{ email: emailAddress, event: `PBOUNCE_ERR` },
`${emailAddress} is not in the system.`,
)
log(`After audit`)
}
}
;[].forEach(processBounce)
const raw = readerToString(c.request().body)
const data = JSON.parse(raw)
log(JSON.stringify(data, null, 2))
const { Type } = data
switch (Type) {
case `SubscriptionConfirmation`:
const url = data.SubscribeURL
log(url)
$http.send({ url })
return c.json(200, { status: 'ok' })
case `Notification`:
const msg = JSON.parse(data.Message)
log(msg)
const { notificationType } = msg
switch (notificationType) {
case `Bounce`: {
log(`Message is a bounce`)
const { bounce } = msg
const { bounceType } = bounce
switch (bounceType) {
case `Permanent`:
log(`Message is a permanent bounce`)
const { bouncedRecipients } = bounce
bouncedRecipients.forEach((recipient) => {
const { emailAddress } = recipient
processBounce(emailAddress)
})
break
default:
audit(
{ event: `SNS` },
`Unrecognized bounce type ${bounceType}: ${data}`,
)
}
break
}
case `Complaint`:
{
log(`Message is a Complaint`, msg)
const { complaint } = msg
const { complainedRecipients } = complaint
complainedRecipients.forEach((recipient) => {
const { emailAddress } = recipient
log(`Processing ${emailAddress}`)
try {
const user = $app
.dao()
.findFirstRecordByData('users', 'email', emailAddress)
log(`user is`, user)
user.set(`unsubscribe`, true)
$app.dao().saveRecord(user)
audit(
{ user: user.getId(), event: `COMPLAINT` },
`User ${emailAddress} has been unsubscribed`,
)
} catch (e) {
audit(
{ email: emailAddress, event: `COMPLAINT` },
`${emailAddress} is not in the system.`,
)
}
})
}
break
default:
audit(
{ event: `SNS` },
`Unrecognized notification type ${notificationType}: ${data}`,
)
}
break
default:
audit({ event: `SNS` }, `Message ${Type} not handled: ${data}`)
}
return c.json(200, { status: 'ok' })
})

View File

@@ -0,0 +1,52 @@
/// <reference path="../types/types.d.ts" />
routerAdd('GET', '/api/unsubscribe', (c) => {
const log = (...s) =>
console.log(
`*** [unsubscribe]`,
...s.map((p) => {
if (typeof p === 'object') return JSON.stringify(p, null, 2)
return p
}),
)
const error = (...s) => console.error(`***`, ...s)
const audit = (key, note) => {
log(note)
const email = new MailerMessage({
from: {
address: $app.settings().meta.senderAddress,
name: $app.settings().meta.senderName,
},
to: [{ address: `ben@benallfree.com` }],
subject: JSON.stringify(key),
html: JSON.stringify(key),
})
$app.newMailClient().send(email)
const collection = $app.dao().findCollectionByNameOrId('audit')
const record = new Record(collection, {
...key,
note,
})
$app.dao().saveRecord(record)
}
const id = c.queryParam('e')
try {
const record = $app.dao().findRecordById('users', id)
record.set(`unsubscribe`, true)
$app.dao().saveRecord(record)
const email = record.getString('email')
audit({ email, user: id, event: `UNSUBSCRIBE` }, ``)
return c.html(200, `<p>${email} has been unsubscribed.`)
} catch (e) {
audit({ email, event: `UNSUBSCRIBE_ERR` }, ``)
return c.html(200, `<p>Looks like you're already unsubscribed.`)
}
})