domma-cms 1.6.34 → 1.6.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/js/api.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/call/ice.js +5 -2
- package/plugins/_lib/call/rooms.js +9 -3
- package/plugins/_lib/call/routes.js +7 -4
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/readRoutes.js +20 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/_lib/public/call/call.js +3 -1
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/docs/guide.md +1 -1
- package/plugins/blog/lib/page.js +32 -0
- package/plugins/blog/plugin.js +58 -16
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +5 -4
- package/plugins/blog/tests/public.test.js +30 -1
- package/plugins/booking/CLAUDE.md +4 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +16 -21
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +26 -1
- package/plugins/feedback/CLAUDE.md +7 -2
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/receiver.js +1 -1
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/free-tier.lock.json +63 -51
- package/plugins/mail-reader/CLAUDE.md +6 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +2 -2
- package/plugins/shopping-cart/CLAUDE.md +62 -1
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +1 -0
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +26 -1
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +185 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/payments.js +7 -4
- package/plugins/shopping-cart/lib/render.js +13 -1
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +21 -3
- package/plugins/shopping-cart/plugin.js +180 -35
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +136 -0
- package/plugins/shopping-cart/tests/api.test.js +34 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/plugins/support/CLAUDE.md +6 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/docs/guide.md +90 -0
- package/plugins/support/lib/receiver.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +10 -1
- package/server/services/authChecks.js +8 -0
- package/server/services/collectionAccess.js +191 -0
- package/server/services/collections.js +8 -2
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/request-log.js +70 -0
- package/server/templates/page.html +3 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared expiry state: a thing that closes by a DATE or by a USAGE TALLY,
|
|
3
|
+
* whichever is reached first. The vocabulary is deliberately the same as
|
|
4
|
+
* Cart Pro's discount codes (shopping-cart-pro/lib/codes.js):
|
|
5
|
+
*
|
|
6
|
+
* {active, startsAt, endsAt, limit, uses}
|
|
7
|
+
*
|
|
8
|
+
* Dates are 'YYYY-MM-DD', inclusive, '' for open; `limit` is null for
|
|
9
|
+
* unlimited; `uses` is the server's counter. The two modules must never
|
|
10
|
+
* disagree about what "expired" means - change one, change both.
|
|
11
|
+
*
|
|
12
|
+
* One deliberate difference: an entry that never carried `active` (a
|
|
13
|
+
* campaign offer, say) is treated as active; Cart Pro's cleanCode always
|
|
14
|
+
* writes the field, so its rows never hit that branch.
|
|
15
|
+
*
|
|
16
|
+
* @module _lib/outreach/expiry
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 'YYYY-MM-DD' for a time, in the server's zone. */
|
|
20
|
+
export const dayOf = (t) => {
|
|
21
|
+
const d = new Date(t);
|
|
22
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Live, scheduled, ended, used up or paused - for state chips and refusals.
|
|
27
|
+
*
|
|
28
|
+
* @param {{active?: boolean, startsAt?: string, endsAt?: string, limit?: number|null, uses?: number}} c
|
|
29
|
+
* @param {number} [now]
|
|
30
|
+
* @returns {'paused'|'scheduled'|'ended'|'used'|'live'}
|
|
31
|
+
*/
|
|
32
|
+
export function codeState(c, now = Date.now()) {
|
|
33
|
+
const today = dayOf(now);
|
|
34
|
+
const active = c.active === undefined ? true : Boolean(c.active);
|
|
35
|
+
if (!active) return 'paused';
|
|
36
|
+
if (c.startsAt && today < c.startsAt) return 'scheduled';
|
|
37
|
+
if (c.endsAt && today > c.endsAt) return 'ended';
|
|
38
|
+
if (c.limit !== null && c.limit !== undefined && (Number(c.uses) || 0) >= c.limit) return 'used';
|
|
39
|
+
return 'live';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Why a thing is not live now, or null when it is. */
|
|
43
|
+
export function closedReason(c, now = Date.now()) {
|
|
44
|
+
const state = codeState(c, now);
|
|
45
|
+
return state === 'live' ? null : state;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outreach library: the audience, token, ledger, sending, suppression
|
|
3
|
+
* and expiry plumbing shared by every Tool that emails people and follows
|
|
4
|
+
* up (Surveys, Campaigns, Onboard). See each module's header; collection
|
|
5
|
+
* schemas the consuming plugins copy are in ./collections.
|
|
6
|
+
*
|
|
7
|
+
* @module _lib/outreach
|
|
8
|
+
*/
|
|
9
|
+
export {generateToken, hashToken, formatCode, normaliseCode} from './tokens.js';
|
|
10
|
+
export {createAudience} from './audience.js';
|
|
11
|
+
export {createLedger} from './ledger.js';
|
|
12
|
+
export {createSuppression, normaliseEmail} from './suppression.js';
|
|
13
|
+
export {escapeHtml, escapeAttr, outreachBaseUrl, renderInvite, sendBatch} from './sending.js';
|
|
14
|
+
export {dayOf, codeState, closedReason} from './expiry.js';
|
|
15
|
+
export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outreach send ledger: one row per (thing, contact) with a hashed
|
|
3
|
+
* per-recipient token and a status - the record that makes sends resumable
|
|
4
|
+
* (the row is written before the email goes) and reminders targetable.
|
|
5
|
+
*
|
|
6
|
+
* Generic over what is being sent: `refField` names the column tying a row
|
|
7
|
+
* to its campaign/survey/journey. Surveys passes
|
|
8
|
+
* `{slug: 'surveys-invites', refField: 'surveyId', source: 'surveys'}` so its stored data is
|
|
9
|
+
* unchanged. Lifted from Surveys 1.4 (lib/ledger.js).
|
|
10
|
+
*
|
|
11
|
+
* Statuses: pending -> sent -> completed, or bounced (any send failure,
|
|
12
|
+
* including a suppressed address - deliberately excluded from reminders).
|
|
13
|
+
*
|
|
14
|
+
* @module _lib/outreach/ledger
|
|
15
|
+
*/
|
|
16
|
+
import {generateToken, hashToken, normaliseCode} from './tokens.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
|
|
20
|
+
* @param {{slug?: string, refField?: string}} [opts]
|
|
21
|
+
*/
|
|
22
|
+
export function createLedger(col, {slug = 'outreach-invites', refField = 'ref', source = 'outreach'} = {}) {
|
|
23
|
+
async function allInvites() {
|
|
24
|
+
const {entries} = await col.listEntries(slug, {limit: 100000});
|
|
25
|
+
return entries;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function invitesFor(refId) {
|
|
29
|
+
return (await allInvites()).filter(e => e.data[refField] === refId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
|
|
33
|
+
async function issueInvites(refId, contactIds) {
|
|
34
|
+
const existing = await invitesFor(refId);
|
|
35
|
+
const seen = new Set(existing.map(e => e.data.contactId));
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const contactId of contactIds) {
|
|
38
|
+
if (seen.has(contactId)) continue;
|
|
39
|
+
const token = generateToken();
|
|
40
|
+
const entry = await col.createEntry(slug, {
|
|
41
|
+
[refField]: refId, contactId,
|
|
42
|
+
tokenHash: hashToken(normaliseCode(token)),
|
|
43
|
+
status: 'pending', sentAt: null, completedAt: null, responseId: null
|
|
44
|
+
}, {source});
|
|
45
|
+
out.push({id: entry.id, contactId, token});
|
|
46
|
+
seen.add(contactId);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure every listed contact has a FRESH token to email. New contacts get
|
|
53
|
+
* a created invite; existing non-completed invites are re-issued with a
|
|
54
|
+
* new token (so re-sends and reminders carry a working personalised link);
|
|
55
|
+
* contacts who already completed are skipped. The previous token for a
|
|
56
|
+
* re-issued invite stops working - latest link wins.
|
|
57
|
+
*
|
|
58
|
+
* Returns [{id, contactId, token}] for the invites to email.
|
|
59
|
+
*/
|
|
60
|
+
async function issueOrReissue(refId, contactIds) {
|
|
61
|
+
const existing = await invitesFor(refId);
|
|
62
|
+
const byContact = new Map(existing.map(e => [e.data.contactId, e]));
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const contactId of contactIds) {
|
|
65
|
+
const ex = byContact.get(contactId);
|
|
66
|
+
if (ex && ex.data.status === 'completed') continue;
|
|
67
|
+
const token = generateToken();
|
|
68
|
+
const tokenHash = hashToken(normaliseCode(token));
|
|
69
|
+
if (ex) {
|
|
70
|
+
await col.updateEntry(slug, ex.id, {...ex.data, tokenHash});
|
|
71
|
+
out.push({id: ex.id, contactId, token});
|
|
72
|
+
} else {
|
|
73
|
+
const entry = await col.createEntry(slug, {
|
|
74
|
+
[refField]: refId, contactId, tokenHash,
|
|
75
|
+
status: 'pending', sentAt: null, completedAt: null, responseId: null
|
|
76
|
+
}, {source});
|
|
77
|
+
out.push({id: entry.id, contactId, token});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Find an invite by submitted code (any format). Null if unknown. */
|
|
84
|
+
async function findByCode(refId, code) {
|
|
85
|
+
const hash = hashToken(normaliseCode(code));
|
|
86
|
+
const invites = await invitesFor(refId);
|
|
87
|
+
return invites.find(e => e.data.tokenHash === hash) ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Mark sent / bounced. */
|
|
91
|
+
async function markSent(inviteId, ok, nowIso) {
|
|
92
|
+
const entry = await col.getEntry(slug, inviteId);
|
|
93
|
+
if (!entry) return null;
|
|
94
|
+
return col.updateEntry(slug, inviteId, {
|
|
95
|
+
...entry.data,
|
|
96
|
+
status: ok ? 'sent' : 'bounced',
|
|
97
|
+
sentAt: ok ? nowIso : entry.data.sentAt
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mark completed with the stored response id. */
|
|
102
|
+
async function markCompleted(inviteId, responseId, nowIso) {
|
|
103
|
+
const entry = await col.getEntry(slug, inviteId);
|
|
104
|
+
if (!entry) return null;
|
|
105
|
+
return col.updateEntry(slug, inviteId, {
|
|
106
|
+
...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Invites that are neither completed nor bounced. */
|
|
111
|
+
async function outstanding(refId) {
|
|
112
|
+
const invites = await invitesFor(refId);
|
|
113
|
+
return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Sent invites, not yet completed, whose sentAt is older than afterDays before now. */
|
|
117
|
+
async function dueForReminder(refId, afterDays, now) {
|
|
118
|
+
const cutoff = now.getTime() - Number(afterDays) * 86400000;
|
|
119
|
+
const invites = await invitesFor(refId);
|
|
120
|
+
return invites.filter(e =>
|
|
121
|
+
e.data.status === 'sent' &&
|
|
122
|
+
e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {allInvites, invitesFor, issueInvites, issueOrReissue, findByCode, markSent, markCompleted, outstanding, dueForReminder, slug, refField};
|
|
127
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outreach email rendering and batch sending. Lifted from Surveys 1.4
|
|
3
|
+
* (lib/sending.js), generalised: the per-recipient link and the button label
|
|
4
|
+
* are the caller's, and a suppression check can sit in front of every send.
|
|
5
|
+
*
|
|
6
|
+
* @module _lib/outreach/sending
|
|
7
|
+
*/
|
|
8
|
+
import {normaliseEmail} from './suppression.js';
|
|
9
|
+
|
|
10
|
+
export function escapeHtml(s) {
|
|
11
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Escape a value for use inside a double-quoted HTML attribute (e.g. href). */
|
|
15
|
+
export function escapeAttr(s) {
|
|
16
|
+
return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 'https://example.com' from a configured address, or '' when it is not one. */
|
|
20
|
+
function origin(value) {
|
|
21
|
+
const v = String(value ?? '').trim().replace(/\/+$/, '');
|
|
22
|
+
return /^https?:\/\/[^\s/?#]+$/i.test(v) ? v : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the links in outreach emails point: the Site URL setting
|
|
27
|
+
* (site.baseUrl, the address core builds password-reset, canonical and
|
|
28
|
+
* sitemap links on), else the old `site.url`, else the address this request
|
|
29
|
+
* came in on.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} site - getConfig('site')
|
|
32
|
+
* @param {{protocol?: string, host?: string, headers?: object}} [req]
|
|
33
|
+
* @returns {string} origin, no trailing slash
|
|
34
|
+
*/
|
|
35
|
+
export function outreachBaseUrl(site, req) {
|
|
36
|
+
const configured = origin(site?.baseUrl) || origin(site?.url);
|
|
37
|
+
if (configured) return configured;
|
|
38
|
+
const host = req?.host || req?.headers?.host || '';
|
|
39
|
+
return host ? `${req?.protocol || 'http'}://${host}` : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render one message's subject + plain-text + HTML from a template and a link.
|
|
44
|
+
*
|
|
45
|
+
* The HTML version turns each {{link}} into a clickable button; all non-link
|
|
46
|
+
* copy is escaped so user-authored bodies can't inject markup. The plain-text
|
|
47
|
+
* version keeps the raw URL. An optional `footerHtml` (already-safe markup -
|
|
48
|
+
* an unsubscribe line) is appended inside the wrapper.
|
|
49
|
+
*
|
|
50
|
+
* @param {{subject?: string, body?: string}} template
|
|
51
|
+
* @param {{name?: string}} contact
|
|
52
|
+
* @param {string} link
|
|
53
|
+
* @param {{buttonLabel?: string, defaultSubject?: string, defaultBody?: string, footerHtml?: string, footerText?: string}} [opts]
|
|
54
|
+
*/
|
|
55
|
+
export function renderInvite(template, contact, link, opts = {}) {
|
|
56
|
+
const {
|
|
57
|
+
buttonLabel = 'Open the link',
|
|
58
|
+
defaultSubject = 'A message for you',
|
|
59
|
+
defaultBody = "Hi {{name}},\n\n{{link}}",
|
|
60
|
+
footerHtml = '',
|
|
61
|
+
footerText = ''
|
|
62
|
+
} = opts;
|
|
63
|
+
const name = contact.name || 'there';
|
|
64
|
+
const subjectTpl = (template.subject && template.subject.trim()) || defaultSubject;
|
|
65
|
+
const bodyTpl = (template.body && template.body.trim()) || defaultBody;
|
|
66
|
+
|
|
67
|
+
const subject = subjectTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
|
|
68
|
+
const text = bodyTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link)
|
|
69
|
+
+ (footerText ? `\n\n${footerText}` : '');
|
|
70
|
+
|
|
71
|
+
const button = `<p style="margin:24px 0;"><a href="${escapeAttr(link)}" `
|
|
72
|
+
+ `style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;`
|
|
73
|
+
+ `padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;">${escapeHtml(buttonLabel)}</a></p>`;
|
|
74
|
+
|
|
75
|
+
const bodyHtml = bodyTpl
|
|
76
|
+
.split('{{link}}')
|
|
77
|
+
.map((seg) => escapeHtml(seg.replaceAll('{{name}}', name)).replace(/\n/g, '<br>'))
|
|
78
|
+
.join(button);
|
|
79
|
+
|
|
80
|
+
const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;`
|
|
81
|
+
+ `max-width:560px;margin:0 auto;padding:24px;color:#1f2937;line-height:1.6;font-size:15px;">`
|
|
82
|
+
+ bodyHtml
|
|
83
|
+
+ `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
|
|
84
|
+
+ `<p style="font-size:12px;color:#6b7280;">If the button doesn't work, paste this link into your browser:<br>`
|
|
85
|
+
+ `<a href="${escapeAttr(link)}" style="color:#2563eb;">${escapeHtml(link)}</a></p>`
|
|
86
|
+
+ (footerHtml || '')
|
|
87
|
+
+ `</div>`;
|
|
88
|
+
|
|
89
|
+
return {subject, text, html};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send to each recipient; never fails wholesale. A recipient on the
|
|
94
|
+
* suppression set is not sent to at all and comes back
|
|
95
|
+
* `{inviteId, ok: false, suppressed: true}` - the caller's ledger treats
|
|
96
|
+
* that like a bounce, which keeps them out of reminders.
|
|
97
|
+
*
|
|
98
|
+
* @param {Array<{invite: {id: string}, contact: {name?: string, email: string}, token: string}>} recipients
|
|
99
|
+
* @param {{subject?: string, body?: string}} template
|
|
100
|
+
* @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite}
|
|
101
|
+
* @returns {Promise<Array<{inviteId: string, ok: boolean, suppressed?: true}>>}
|
|
102
|
+
*/
|
|
103
|
+
export async function sendBatch(recipients, template, deps) {
|
|
104
|
+
const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}} = deps;
|
|
105
|
+
const results = [];
|
|
106
|
+
for (const r of recipients) {
|
|
107
|
+
if (suppressed && suppressed.has(normaliseEmail(r.contact.email))) {
|
|
108
|
+
results.push({inviteId: r.invite.id, ok: false, suppressed: true});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const link = linkFor(r);
|
|
112
|
+
const msg = renderInvite(template, r.contact, link, render);
|
|
113
|
+
try {
|
|
114
|
+
await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
|
|
115
|
+
results.push({inviteId: r.invite.id, ok: true});
|
|
116
|
+
} catch {
|
|
117
|
+
results.push({inviteId: r.invite.id, ok: false});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared suppression list: addresses that must not be emailed by any
|
|
3
|
+
* outreach Tool. One opt-out silences them all - marketing email without a
|
|
4
|
+
* working unsubscribe is not shippable (PECR), and a surveys opt-out should
|
|
5
|
+
* silence campaigns too. Rows live in the shared `outreach-unsubscribes`
|
|
6
|
+
* collection ({email, at, source}; schema in ./collections).
|
|
7
|
+
*
|
|
8
|
+
* Addresses are compared lower-cased and trimmed. Suppressed recipients are
|
|
9
|
+
* not sent to and are marked `bounced` in the ledger, which keeps them out
|
|
10
|
+
* of reminders for good.
|
|
11
|
+
*
|
|
12
|
+
* @module _lib/outreach/suppression
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Canonical form for comparison and storage. */
|
|
16
|
+
export const normaliseEmail = (email) => String(email ?? '').trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{createEntry,listEntries,deleteEntry?}} col collections service
|
|
20
|
+
* @param {{slug?: string}} [opts]
|
|
21
|
+
*/
|
|
22
|
+
export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
|
|
23
|
+
async function rows() {
|
|
24
|
+
const {entries} = await col.listEntries(slug, {limit: 100000}).catch(() => ({entries: []}));
|
|
25
|
+
return entries;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every suppressed address as a Set - take one per send batch. */
|
|
29
|
+
async function suppressedSet() {
|
|
30
|
+
return new Set((await rows()).map(e => normaliseEmail(e.data.email)).filter(Boolean));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function isSuppressed(email) {
|
|
34
|
+
const want = normaliseEmail(email);
|
|
35
|
+
if (!want) return false;
|
|
36
|
+
return (await suppressedSet()).has(want);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Add an address (idempotent). `source` says which Tool took the opt-out. */
|
|
40
|
+
async function add(email, source = 'outreach') {
|
|
41
|
+
const want = normaliseEmail(email);
|
|
42
|
+
if (!want) return null;
|
|
43
|
+
const existing = (await rows()).find(e => normaliseEmail(e.data.email) === want);
|
|
44
|
+
if (existing) return existing;
|
|
45
|
+
return col.createEntry(slug, {email: want, at: new Date().toISOString(), source}, {source});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Take an address off the list (they asked to hear from us again). */
|
|
49
|
+
async function remove(email) {
|
|
50
|
+
if (typeof col.deleteEntry !== 'function') return false;
|
|
51
|
+
const want = normaliseEmail(email);
|
|
52
|
+
const hits = (await rows()).filter(e => normaliseEmail(e.data.email) === want);
|
|
53
|
+
for (const e of hits) await col.deleteEntry(slug, e.id).catch(() => {});
|
|
54
|
+
return hits.length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function list() {
|
|
58
|
+
return (await rows()).map(e => ({id: e.id, email: e.data.email, at: e.data.at ?? null, source: e.data.source ?? ''}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {isSuppressed, suppressedSet, add, remove, list, slug};
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outreach tokens: short unambiguous codes for per-recipient links. Only the
|
|
3
|
+
* SHA-256 hash is ever stored - the plaintext travels in the emailed URL and
|
|
4
|
+
* nowhere else. Lifted from Surveys 1.4 (lib/tokens.js) unchanged.
|
|
5
|
+
*
|
|
6
|
+
* @module _lib/outreach/tokens
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
|
|
11
|
+
const LENGTH = 8;
|
|
12
|
+
|
|
13
|
+
/** Generate an 8-char unambiguous uppercase token. */
|
|
14
|
+
export function generateToken() {
|
|
15
|
+
const bytes = crypto.randomBytes(LENGTH);
|
|
16
|
+
let out = '';
|
|
17
|
+
for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** SHA-256 hex of a normalised code. The plaintext is never stored. */
|
|
22
|
+
export function hashToken(code) {
|
|
23
|
+
return crypto.createHash('sha256').update(String(code)).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Display form: ABCD-EFGH. */
|
|
27
|
+
export function formatCode(token) {
|
|
28
|
+
const t = String(token).toUpperCase();
|
|
29
|
+
return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
|
|
33
|
+
export function normaliseCode(input) {
|
|
34
|
+
return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unsubscribe links that keep working forever: an HMAC of the address under a
|
|
3
|
+
* per-site secret, not a ledger token (a re-send re-issues ledger tokens, and
|
|
4
|
+
* the unsubscribe link in an OLD email must still work - PECR). The secret is
|
|
5
|
+
* the engine's (server/services/outreachUnsubscribe.js) and so is the
|
|
6
|
+
* `/campaign-unsubscribe` route (server/routes/outreach-public.js), so a link
|
|
7
|
+
* keeps working after the Tool that sent it lapses or is removed.
|
|
8
|
+
*
|
|
9
|
+
* Campaigns Pro carries a byte-identical copy for engines older than this
|
|
10
|
+
* file - change the signature here and the links already sent stop verifying.
|
|
11
|
+
*
|
|
12
|
+
* @module _lib/outreach/unsubscribe
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
import {normaliseEmail} from './suppression.js';
|
|
16
|
+
|
|
17
|
+
/** Where every unsubscribe link points; links already sent carry this path. */
|
|
18
|
+
export const UNSUBSCRIBE_PATH = '/campaign-unsubscribe';
|
|
19
|
+
|
|
20
|
+
export function mintSecret() {
|
|
21
|
+
return crypto.randomBytes(24).toString('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function signEmail(secret, email) {
|
|
25
|
+
return crypto.createHmac('sha256', String(secret)).update(normaliseEmail(email)).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The address an emailed unsubscribe link points at. */
|
|
29
|
+
export function unsubscribeUrl(baseUrl, secret, email) {
|
|
30
|
+
const e = Buffer.from(normaliseEmail(email), 'utf8').toString('base64url');
|
|
31
|
+
return `${baseUrl}${UNSUBSCRIBE_PATH}?e=${e}&k=${signEmail(secret, email)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The verified address from ?e=&k=, or null. Constant-time on the signature. */
|
|
35
|
+
export function verifyUnsubscribe(secret, e, k) {
|
|
36
|
+
let email = '';
|
|
37
|
+
try { email = Buffer.from(String(e ?? ''), 'base64url').toString('utf8'); } catch { return null; }
|
|
38
|
+
email = normaliseEmail(email);
|
|
39
|
+
if (!email || !k) return null;
|
|
40
|
+
const want = signEmail(secret, email);
|
|
41
|
+
const got = String(k);
|
|
42
|
+
if (got.length !== want.length) return null;
|
|
43
|
+
try {
|
|
44
|
+
return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex')) ? email : null;
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
@@ -569,6 +569,8 @@ function mount(el) {
|
|
|
569
569
|
switch (e.type) {
|
|
570
570
|
case 'knocking': stage.value = 'waiting'; break;
|
|
571
571
|
case 'admitted':
|
|
572
|
+
// The ICE servers arrive with admission (not the join reply) - before any peer is connected.
|
|
573
|
+
if (Array.isArray(e.iceServers)) iceServers = e.iceServers;
|
|
572
574
|
stage.value = 'in';
|
|
573
575
|
recording.value = e.recording === true;
|
|
574
576
|
(e.chat || []).forEach(item => addItem(item, {live: false}));
|
|
@@ -868,7 +870,7 @@ function mount(el) {
|
|
|
868
870
|
if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
|
|
869
871
|
if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
|
|
870
872
|
me = {id: body.id, secret: body.secret, who: body.who || body.id};
|
|
871
|
-
iceServers = body.iceServers || [];
|
|
873
|
+
iceServers = body.iceServers || []; // an older engine's join reply; the admitted event replaces it
|
|
872
874
|
host.value = body.host === true;
|
|
873
875
|
if (body.name) name.value = body.name;
|
|
874
876
|
max.value = body.max || 6;
|
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -20,7 +20,7 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
|
|
|
20
20
|
- `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
|
|
21
21
|
- `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
|
|
22
22
|
- `public/samples/*.svg` - sample covers
|
|
23
|
-
- `tests/
|
|
23
|
+
- `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
|
|
24
24
|
- `admin/views/blog.js` - post list (KPIs + status/category/search filters)
|
|
25
25
|
- `admin/views/post-editor.js` - create / edit a post
|
|
26
26
|
- `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
|
|
@@ -319,3 +319,13 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
|
|
|
319
319
|
- `usePermissionLookup()` points the checks at a lookup: the plugin passes core's `getPermissionsFor` (or
|
|
320
320
|
`options.getPermissionsFor` in tests).
|
|
321
321
|
|
|
322
|
+
## 1.9.6 (2026-10-13 Tool sweep)
|
|
323
|
+
|
|
324
|
+
- **One slug, one post.** `POST /posts` and `PUT /posts/:id` answer 409 when another post (draft or not; a Pro-era post
|
|
325
|
+
in another blog does not count) holds the slug - `slugHolder()` in lib/page.js. The public post lookup is
|
|
326
|
+
`publicPost()`: the LIVE post with that slug, not whichever is newest. Before, an author's draft saved with a live
|
|
327
|
+
post's slug sat first in the newest-first list and the live address answered 404.
|
|
328
|
+
- **POST /comments** has its own `config.rateLimit` (5/min/IP), caps every field (`commentFields()`, exported and
|
|
329
|
+
tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
|
|
330
|
+
used to raise a staff notification.
|
|
331
|
+
- `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
|
|
@@ -39,7 +39,7 @@ Writers without the Publish permission see only their own posts.
|
|
|
39
39
|
| Field | What it does |
|
|
40
40
|
|---|---|
|
|
41
41
|
| Title | Required. |
|
|
42
|
-
| Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. |
|
|
42
|
+
| Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. Two posts cannot share a slug: saving one with a slug another post already has (a draft included) is refused with a message. |
|
|
43
43
|
| Excerpt | A sentence or two shown in listings. Also used as the search-engine description when **SEO Description** is empty. |
|
|
44
44
|
| Content | Markdown. Every shortcode the site supports works here too. |
|
|
45
45
|
| SEO Title / SEO Description | What search engines and link previews show. Leave empty to use the title and excerpt. |
|
package/plugins/blog/lib/page.js
CHANGED
|
@@ -26,6 +26,38 @@ export function isVisible(post) {
|
|
|
26
26
|
(post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Whether a post is this blog's (a Pro-era post in another blog is not), whatever its status. */
|
|
30
|
+
function inThisBlog(post) {
|
|
31
|
+
return !post.blogId || post.blogId === 'main';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The post readers get for an address: a LIVE one. The list is newest first,
|
|
36
|
+
* so `find(slug)` alone picked an author's fresh draft over the published
|
|
37
|
+
* post with the same slug and answered 404 for the live one.
|
|
38
|
+
*
|
|
39
|
+
* @param {object[]} posts
|
|
40
|
+
* @param {string} slug
|
|
41
|
+
* @returns {object|null}
|
|
42
|
+
*/
|
|
43
|
+
export function publicPost(posts, slug) {
|
|
44
|
+
return posts.find((p) => p.slug === slug && isVisible(p)) ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The post, other than `exceptId`, that already holds a slug - a save that
|
|
49
|
+
* would make two posts answer one address. Drafts count: the one that is live
|
|
50
|
+
* today is not always the one that is live tomorrow.
|
|
51
|
+
*
|
|
52
|
+
* @param {object[]} posts
|
|
53
|
+
* @param {string} slug
|
|
54
|
+
* @param {string|null} [exceptId] - the post being saved
|
|
55
|
+
* @returns {object|null}
|
|
56
|
+
*/
|
|
57
|
+
export function slugHolder(posts, slug, exceptId = null) {
|
|
58
|
+
return posts.find((p) => p.slug === slug && p.id !== exceptId && inThisBlog(p)) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
29
61
|
/** Settings as they are NOW - display choices apply without a restart. */
|
|
30
62
|
export function liveSettings(registered) {
|
|
31
63
|
return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};
|