From 6c83d8a6c886cee1e8effc9847a1b0abb016b2c2 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 7 Jan 2024 12:22:12 +0000 Subject: [PATCH] feat: rich bulk mail --- src/cli/sendmail.ts | 217 +++++++------- src/constants.ts | 7 + .../1704449269_collections_snapshot.js | 276 ++++++++++++++++++ .../1704474199_created_campaigns.js | 67 +++++ .../1704474350_created_campaign_messages.js | 69 +++++ .../1704538216_updated_sent_messages.js | 48 +++ .../migrations/1704611809_created_audit.js | 57 ++++ .../migrations/1704611928_updated_audit.js | 31 ++ .../migrations/1704612886_updated_audit.js | 31 ++ src/mothership-app/pb_hooks/src/sns.pb.js | 136 +++++++++ .../pb_hooks/src/unsubscribe.pb.js | 52 ++++ 11 files changed, 893 insertions(+), 98 deletions(-) create mode 100644 src/mothership-app/migrations/1704449269_collections_snapshot.js create mode 100644 src/mothership-app/migrations/1704474199_created_campaigns.js create mode 100644 src/mothership-app/migrations/1704474350_created_campaign_messages.js create mode 100644 src/mothership-app/migrations/1704538216_updated_sent_messages.js create mode 100644 src/mothership-app/migrations/1704611809_created_audit.js create mode 100644 src/mothership-app/migrations/1704611928_updated_audit.js create mode 100644 src/mothership-app/migrations/1704612886_updated_audit.js create mode 100644 src/mothership-app/pb_hooks/src/sns.pb.js create mode 100644 src/mothership-app/pb_hooks/src/unsubscribe.pb.js diff --git a/src/cli/sendmail.ts b/src/cli/sendmail.ts index 0f126664..fb2717d4 100644 --- a/src/cli/sendmail.ts +++ b/src/cli/sendmail.ts @@ -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(``, `ID of the message to send`) + .option('--limit ', `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(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}
I only send PocketHost annoucements. But I get it. [[unsub]]`, + }, + }) + 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…`, - `

In case you missed the Discord announcement, PocketHost is rolling out a paid tier of unlimited (fair use) bandwidth, storage, and projects.`, - `

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 can’t create new ones.`, - `

As a way of saying thank you to all of you who supported PocketHost in the early days, I made a Founder’s Edition membership where you can pay once ($299) for lifetime access, or pay yearly ($99/yr) which is a 50% discount.`, - `

There are only 100 ${membershipCount.value} Founder’s Edition memberships. If you want one, please grab one from the PocketHost dashboard (https://app.pockethost.io/dashboard) before I announce it publicly.`, - `

Happy coding! `, - `

~Ben`, - ], - }, - 'founder-announce-2': { - subject: `${membershipCount.value} Founder's Memberships left`, - body: [ - `

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.`, - `

You can grab one for $299 lifetime access, or $99/yr which is a 55% discount.`, - `

There are 100 ${membershipCount.value} Founder’s Edition memberships available. If you want one, please grab one from your PocketHost account page (https://app.pockethost.io/account).`, - `

Happy coding! `, - `

~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(`

`), - }, - }) - if (!TEST) { - await client - .collection(TBL_SENT_MESSAGES) - .create({ user: user.id, message: MESSAGE_ID }) - } - }) - }), - ) -})() +program.parseAsync() diff --git a/src/constants.ts b/src/constants.ts index 886be045..928a16e7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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) diff --git a/src/mothership-app/migrations/1704449269_collections_snapshot.js b/src/mothership-app/migrations/1704449269_collections_snapshot.js new file mode 100644 index 00000000..29fdcb18 --- /dev/null +++ b/src/mothership-app/migrations/1704449269_collections_snapshot.js @@ -0,0 +1,276 @@ +/// +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; +}) diff --git a/src/mothership-app/migrations/1704474199_created_campaigns.js b/src/mothership-app/migrations/1704474199_created_campaigns.js new file mode 100644 index 00000000..7601ce60 --- /dev/null +++ b/src/mothership-app/migrations/1704474199_created_campaigns.js @@ -0,0 +1,67 @@ +/// +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); +}) diff --git a/src/mothership-app/migrations/1704474350_created_campaign_messages.js b/src/mothership-app/migrations/1704474350_created_campaign_messages.js new file mode 100644 index 00000000..90a48399 --- /dev/null +++ b/src/mothership-app/migrations/1704474350_created_campaign_messages.js @@ -0,0 +1,69 @@ +/// +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); +}) diff --git a/src/mothership-app/migrations/1704538216_updated_sent_messages.js b/src/mothership-app/migrations/1704538216_updated_sent_messages.js new file mode 100644 index 00000000..de61c988 --- /dev/null +++ b/src/mothership-app/migrations/1704538216_updated_sent_messages.js @@ -0,0 +1,48 @@ +/// +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) +}) diff --git a/src/mothership-app/migrations/1704611809_created_audit.js b/src/mothership-app/migrations/1704611809_created_audit.js new file mode 100644 index 00000000..86ae2f67 --- /dev/null +++ b/src/mothership-app/migrations/1704611809_created_audit.js @@ -0,0 +1,57 @@ +/// +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); +}) diff --git a/src/mothership-app/migrations/1704611928_updated_audit.js b/src/mothership-app/migrations/1704611928_updated_audit.js new file mode 100644 index 00000000..c23ed65b --- /dev/null +++ b/src/mothership-app/migrations/1704611928_updated_audit.js @@ -0,0 +1,31 @@ +/// +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) +}) diff --git a/src/mothership-app/migrations/1704612886_updated_audit.js b/src/mothership-app/migrations/1704612886_updated_audit.js new file mode 100644 index 00000000..269be81e --- /dev/null +++ b/src/mothership-app/migrations/1704612886_updated_audit.js @@ -0,0 +1,31 @@ +/// +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) +}) diff --git a/src/mothership-app/pb_hooks/src/sns.pb.js b/src/mothership-app/pb_hooks/src/sns.pb.js new file mode 100644 index 00000000..f75c4130 --- /dev/null +++ b/src/mothership-app/pb_hooks/src/sns.pb.js @@ -0,0 +1,136 @@ +/// + +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' }) +}) diff --git a/src/mothership-app/pb_hooks/src/unsubscribe.pb.js b/src/mothership-app/pb_hooks/src/unsubscribe.pb.js new file mode 100644 index 00000000..fb03fad5 --- /dev/null +++ b/src/mothership-app/pb_hooks/src/unsubscribe.pb.js @@ -0,0 +1,52 @@ +/// + +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, `

${email} has been unsubscribed.`) + } catch (e) { + audit({ email, event: `UNSUBSCRIBE_ERR` }, ``) + return c.html(200, `

Looks like you're already unsubscribed.`) + } +})