mirror of
https://github.com/pockethost/pockethost.git
synced 2026-03-18 06:08:52 +00:00
feat: rich bulk mail
This commit is contained in:
@@ -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 can’t 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 Founder’s 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} 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.`,
|
||||
`<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} Founder’s 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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
276
src/mothership-app/migrations/1704449269_collections_snapshot.js
Normal file
276
src/mothership-app/migrations/1704449269_collections_snapshot.js
Normal 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;
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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);
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
57
src/mothership-app/migrations/1704611809_created_audit.js
Normal file
57
src/mothership-app/migrations/1704611809_created_audit.js
Normal 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);
|
||||
})
|
||||
31
src/mothership-app/migrations/1704611928_updated_audit.js
Normal file
31
src/mothership-app/migrations/1704611928_updated_audit.js
Normal 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)
|
||||
})
|
||||
31
src/mothership-app/migrations/1704612886_updated_audit.js
Normal file
31
src/mothership-app/migrations/1704612886_updated_audit.js
Normal 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)
|
||||
})
|
||||
136
src/mothership-app/pb_hooks/src/sns.pb.js
Normal file
136
src/mothership-app/pb_hooks/src/sns.pb.js
Normal 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' })
|
||||
})
|
||||
52
src/mothership-app/pb_hooks/src/unsubscribe.pb.js
Normal file
52
src/mothership-app/pb_hooks/src/unsubscribe.pb.js
Normal 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.`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user