@anthusai/papyrus 5.20.0 → 5.22.0
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/amplify/data/schema.js +11 -25
- package/amplify/data/schema.ts +13 -26
- package/amplify/functions/content-actions/handler.py +17 -2
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +52 -1
- package/amplify/plugins/reader-steering/config.ts +91 -1
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +42 -5
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +19 -1
- package/routes.manifest.json +103 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pretend Newsletter service for `?demo=1` and the tests: the same operations and answers as the functions, held in memory.
|
|
3
|
+
* Placeholders only (reader addresses are @example.com); nothing here is a real subscriber, issue or postal address.
|
|
4
|
+
*/
|
|
5
|
+
import type { NewsletterApi, NewsletterOperation } from "./newsletter-client";
|
|
6
|
+
import type {
|
|
7
|
+
NewsletterDelivery,
|
|
8
|
+
NewsletterEvent,
|
|
9
|
+
NewsletterExportRow,
|
|
10
|
+
NewsletterIssue,
|
|
11
|
+
NewsletterList,
|
|
12
|
+
NewsletterSubscriber,
|
|
13
|
+
} from "./newsletter-model";
|
|
14
|
+
|
|
15
|
+
export const FIXTURE_TIME_ZONE = "America/New_York";
|
|
16
|
+
|
|
17
|
+
const LISTS: NewsletterList[] = [
|
|
18
|
+
{ id: "weekly", name: "Weekly roundup", kind: "roundup", confirmed: 412, pending: 17, unsubscribed: 38, timezone: FIXTURE_TIME_ZONE },
|
|
19
|
+
{ id: "alerts-security", name: "Security alerts", kind: "alert", confirmed: 96, pending: 4, unsubscribed: 9 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function fixtureIssues(): NewsletterIssue[] {
|
|
23
|
+
const counts = { queued: 0, sent: 0, failed: 0, suppressed: 0, skipped: 0, delivered: 0, bounced: 0, complained: 0, softBounced: 0 };
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
issueId: "a1", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0122", status: "draft",
|
|
27
|
+
subject: "Weekly roundup: Jan 15 to Jan 22, 2027", counts,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
issueId: "a2", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-1", status: "awaiting_approval",
|
|
31
|
+
subject: "A critical patch is out for the build tool", counts,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
issueId: "a3", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0115", status: "scheduled",
|
|
35
|
+
subject: "Weekly roundup: Jan 8 to Jan 15, 2027", sendAt: "2027-01-15T14:00:00Z", approvedBy: "admin-user", approvedAt: "2027-01-14T18:30:00Z",
|
|
36
|
+
counts, editedSinceApproval: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
issueId: "a4", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0108", status: "blocked",
|
|
40
|
+
subject: "Weekly roundup: Jan 1 to Jan 8, 2027", sendAt: "2027-01-08T14:00:00Z", note: "postal address missing", counts,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
issueId: "a5", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0101", status: "sending",
|
|
44
|
+
subject: "Weekly roundup: Dec 25 to Jan 1, 2027", sendAt: "2027-01-01T14:00:00Z", approvedBy: "admin-user",
|
|
45
|
+
counts: { ...counts, queued: 429, sent: 240, delivered: 188 },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
issueId: "a6", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-1225", status: "sent",
|
|
49
|
+
subject: "Weekly roundup: Dec 18 to Dec 25, 2026", sendAt: "2026-12-25T14:00:00Z", sentAt: "2026-12-25T14:09:40Z", approvedBy: "admin-user",
|
|
50
|
+
archiveUrl: "https://news.example.com/roundup/2026-12-25.html",
|
|
51
|
+
counts: { ...counts, queued: 421, sent: 418, delivered: 409, bounced: 3, complained: 1, failed: 1, suppressed: 2, softBounced: 2 },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
issueId: "a7", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-0", status: "quarantined",
|
|
55
|
+
subject: "An alert with a status this panel does not know", counts,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deliveriesFor(issueId: string, full: boolean): NewsletterDelivery[] {
|
|
61
|
+
const address = (index: number) => (full ? `reader${index}@example.com` : `r***@example.com`);
|
|
62
|
+
const rows: NewsletterDelivery[] = [
|
|
63
|
+
{ address: address(1), status: "delivered", at: "2026-12-25T14:00:04Z", eventAt: "2026-12-25T14:00:09Z" },
|
|
64
|
+
{ address: address(2), status: "delivered", at: "2026-12-25T14:00:05Z", eventAt: "2026-12-25T14:00:12Z" },
|
|
65
|
+
{ address: address(3), status: "bounced", at: "2026-12-25T14:00:06Z", eventAt: "2026-12-25T14:00:20Z" },
|
|
66
|
+
{ address: address(4), status: "complained", at: "2026-12-25T14:00:07Z", eventAt: "2026-12-25T14:02:31Z" },
|
|
67
|
+
{ address: address(5), status: "failed", at: "2026-12-25T14:00:08Z", error: "MessageRejected" },
|
|
68
|
+
{ address: "(erased)", status: "sent", at: "2026-12-25T14:00:09Z" },
|
|
69
|
+
];
|
|
70
|
+
return issueId === "a6" || issueId === "a5" ? rows : [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SUBSCRIBERS: NewsletterSubscriber[] = [
|
|
74
|
+
{ email: "reader1@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }, { id: "alerts-security", status: "subscribed" }], createdAt: "2026-11-02T10:11:00Z", confirmedAt: "2026-11-02T10:13:00Z" },
|
|
75
|
+
{ email: "reader2@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }], createdAt: "2026-11-09T08:00:00Z", confirmedAt: "2026-11-09T08:02:00Z" },
|
|
76
|
+
{ email: "reader3@example.com", status: "pending", lists: [{ id: "alerts-security", status: "pending" }], createdAt: "2026-12-30T21:45:00Z" },
|
|
77
|
+
{ email: "=reader4@example.com", status: "unsubscribed", lists: [{ id: "weekly", status: "unsubscribed" }], createdAt: "2026-10-01T09:00:00Z", confirmedAt: "2026-10-01T09:05:00Z" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const EVENTS: NewsletterEvent[] = [
|
|
81
|
+
{ type: "Complaint", at: "2026-12-25T14:02:31Z", issue: "a6", list: "weekly" },
|
|
82
|
+
{ type: "Bounce", at: "2026-12-25T14:00:20Z", issue: "a6", list: "weekly", detail: "Permanent" },
|
|
83
|
+
{ type: "Delivery", at: "2026-12-25T14:00:12Z", issue: "a6", list: "weekly" },
|
|
84
|
+
{ type: "Delivery", at: "2026-12-25T14:00:09Z", issue: "a6", list: "weekly" },
|
|
85
|
+
{ type: "Bounce", at: "2026-12-25T13:59:50Z", issue: "a5", list: "weekly", detail: "Transient" },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const SAMPLE_HTML = `<!doctype html><html><body style="margin:0;font-family:Georgia,serif;color:#111"><div style="max-width:560px;margin:0 auto;padding:24px"><h1 style="font-size:24px;margin:0 0 12px">Weekly roundup</h1><p>A short introduction goes here.</p><h2 style="font-size:18px">Security</h2><ul><li><a href="#preview">A critical patch is out</a>: update today.</li></ul><hr><p style="font-size:12px;color:#555">You get this because you subscribed. <a href="#preview">Manage</a> · <a href="#preview">Unsubscribe</a><br>1 Example Street, Example City</p></div></body></html>`;
|
|
89
|
+
|
|
90
|
+
export type FixtureOptions = { postalAddress?: boolean; admin?: boolean };
|
|
91
|
+
|
|
92
|
+
/** One fixture service per mounted panel, so a demo session keeps its changes until reload. */
|
|
93
|
+
export function createFixtureApi(options: FixtureOptions = {}): NewsletterApi {
|
|
94
|
+
const full = options.admin !== false;
|
|
95
|
+
const issues = fixtureIssues();
|
|
96
|
+
const subscribers = [...SUBSCRIBERS];
|
|
97
|
+
const paused = { paused: true, reason: "papyrus-newsletter-example-bounce-rate: bounce rate above 4%", pausedAt: "2026-12-26T09:12:00Z" } as { paused: boolean; reason?: string; pausedAt?: string };
|
|
98
|
+
const findIssue = (input: Record<string, unknown>) => issues.find((issue) => issue.issueId === input.issueId);
|
|
99
|
+
const failure = (error: string, message = "") => ({ ok: false as const, error, message });
|
|
100
|
+
|
|
101
|
+
const handlers: Record<NewsletterOperation, (input: Record<string, unknown>) => object> = {
|
|
102
|
+
newsletterOverview: () => ({
|
|
103
|
+
ok: true,
|
|
104
|
+
lists: LISTS,
|
|
105
|
+
totals: { confirmed: 488, pending: 20, unsubscribed: 45 },
|
|
106
|
+
suppressed: 12,
|
|
107
|
+
paused: { ...paused },
|
|
108
|
+
events: EVENTS,
|
|
109
|
+
}),
|
|
110
|
+
newsletterIssues: (input) => ({
|
|
111
|
+
ok: true,
|
|
112
|
+
issues: issues.filter((issue) => (!input.status || issue.status === input.status) && (!input.listId || issue.listId === input.listId)).map(({ editedSinceApproval: _edited, ...rest }) => rest),
|
|
113
|
+
}),
|
|
114
|
+
newsletterIssue: (input) => {
|
|
115
|
+
const issue = findIssue(input);
|
|
116
|
+
return issue ? { ok: true, issue } : failure("not_found");
|
|
117
|
+
},
|
|
118
|
+
newsletterPreview: (input) => {
|
|
119
|
+
const issue = findIssue(input);
|
|
120
|
+
if (!issue) return failure("not_found");
|
|
121
|
+
return { ok: true, subject: issue.subject, html: SAMPLE_HTML, text: `${issue.subject}\n\nA short introduction goes here.\n\nSecurity\n- A critical patch is out: update today.\n\nYou get this because you subscribed.`, bytes: SAMPLE_HTML.length, postalAddress: options.postalAddress !== false };
|
|
122
|
+
},
|
|
123
|
+
newsletterIssueDeliveries: (input) => (findIssue(input) ? { ok: true, deliveries: deliveriesFor(String(input.issueId), full) } : failure("not_found")),
|
|
124
|
+
newsletterSubscribers: (input) => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
subscribers: subscribers.filter((row) => (!input.status || row.status === input.status) && (!input.listId || row.lists.some((entry) => entry.id === input.listId))),
|
|
127
|
+
}),
|
|
128
|
+
newsletterExport: () => ({
|
|
129
|
+
ok: true,
|
|
130
|
+
rows: subscribers.filter((row) => row.status !== "unsubscribed").map((row): NewsletterExportRow => ({
|
|
131
|
+
email: row.email, status: row.status, lists: row.lists.map((entry) => `${entry.id}:${entry.status}`).join(";"), createdAt: row.createdAt,
|
|
132
|
+
consentRequestedAt: row.createdAt, consentConfirmedAt: row.confirmedAt ?? "", consentFormId: "newsletter",
|
|
133
|
+
consentPageUrl: "https://news.example.com/roundup/", consentWording: "Send me the weekly email.",
|
|
134
|
+
})),
|
|
135
|
+
}),
|
|
136
|
+
newsletterApproveIssue: (input) => {
|
|
137
|
+
const issue = findIssue(input);
|
|
138
|
+
if (!issue) return failure("not_found");
|
|
139
|
+
if (!["draft", "awaiting_approval", "scheduled", "blocked"].includes(issue.status)) return failure("invalid_state");
|
|
140
|
+
issue.status = "scheduled";
|
|
141
|
+
issue.sendAt = String(input.sendAt);
|
|
142
|
+
issue.approvedBy = "admin-user";
|
|
143
|
+
delete issue.note;
|
|
144
|
+
delete issue.editedSinceApproval;
|
|
145
|
+
return { ok: true, issue, adjusted: false, warnings: options.postalAddress === false ? ["postal address missing: the send will be blocked until an operator sets it"] : [] };
|
|
146
|
+
},
|
|
147
|
+
newsletterCancelIssue: (input) => {
|
|
148
|
+
const issue = findIssue(input);
|
|
149
|
+
if (!issue) return failure("not_found");
|
|
150
|
+
if (issue.status !== "scheduled") return failure("invalid_state");
|
|
151
|
+
issue.status = "draft";
|
|
152
|
+
issue.note = "cancelled by admin-user";
|
|
153
|
+
delete issue.sendAt;
|
|
154
|
+
delete issue.editedSinceApproval;
|
|
155
|
+
return { ok: true, issue };
|
|
156
|
+
},
|
|
157
|
+
newsletterStopIssue: (input) => {
|
|
158
|
+
const issue = findIssue(input);
|
|
159
|
+
if (!issue) return failure("not_found");
|
|
160
|
+
if (issue.status !== "sending") return failure("invalid_state");
|
|
161
|
+
issue.status = "stopped";
|
|
162
|
+
issue.note = "stopped by admin-user";
|
|
163
|
+
return { ok: true, issue };
|
|
164
|
+
},
|
|
165
|
+
newsletterTestSend: (input) => (findIssue(input) ? { ok: true } : failure("not_found")),
|
|
166
|
+
newsletterCreateDraft: (input) => (input.listId === "weekly" ? { ok: true, status: "exists", issueId: "a1", itemId: "item-weekly-0122" } : failure("bad_request")),
|
|
167
|
+
newsletterErase: (input) => {
|
|
168
|
+
const at = subscribers.findIndex((row) => row.email === input.email);
|
|
169
|
+
if (at >= 0) subscribers.splice(at, 1);
|
|
170
|
+
return { ok: true, removed: at >= 0 ? 3 : 2 };
|
|
171
|
+
},
|
|
172
|
+
newsletterResume: () => {
|
|
173
|
+
paused.paused = false;
|
|
174
|
+
paused.reason = undefined;
|
|
175
|
+
return { ok: true, paused: false };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (async (operation: NewsletterOperation, input: Record<string, unknown> = {}) => {
|
|
180
|
+
const admin = ["newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterSubscribers", "newsletterExport", "newsletterErase", "newsletterResume"];
|
|
181
|
+
if (options.admin === false && admin.includes(operation)) return { ok: false, error: "forbidden" };
|
|
182
|
+
return handlers[operation](input);
|
|
183
|
+
}) as NewsletterApi;
|
|
184
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Newsletter panel. `src/papyrus_content/newsletter/` (panel.py, issues.py) produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
import { csvCell } from "./signup-csv";
|
|
4
|
+
|
|
5
|
+
export type NewsletterCounts = Partial<Record<"queued" | "sent" | "failed" | "suppressed" | "skipped" | "delivered" | "bounced" | "complained" | "softBounced", number>>;
|
|
6
|
+
|
|
7
|
+
export type NewsletterList = { id: string; name: string; kind: string; confirmed: number; pending: number; unsubscribed: number; timezone?: string };
|
|
8
|
+
|
|
9
|
+
export type NewsletterEvent = { type: string; at: string; issue?: string; list?: string; detail?: string };
|
|
10
|
+
|
|
11
|
+
export type NewsletterOverview = {
|
|
12
|
+
lists: NewsletterList[];
|
|
13
|
+
totals: { confirmed: number; pending: number; unsubscribed: number };
|
|
14
|
+
suppressed: number;
|
|
15
|
+
paused: { paused: boolean; reason?: string; pausedAt?: string; resumedAt?: string };
|
|
16
|
+
events: NewsletterEvent[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type NewsletterIssue = {
|
|
20
|
+
issueId: string;
|
|
21
|
+
listId: string;
|
|
22
|
+
listName?: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
itemId?: string;
|
|
25
|
+
subject: string;
|
|
26
|
+
status: string;
|
|
27
|
+
sendAt?: string;
|
|
28
|
+
approvedBy?: string;
|
|
29
|
+
approvedAt?: string;
|
|
30
|
+
sentAt?: string;
|
|
31
|
+
counts: NewsletterCounts;
|
|
32
|
+
note?: string;
|
|
33
|
+
archiveUrl?: string;
|
|
34
|
+
editedSinceApproval?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type NewsletterPreview = { subject: string; html: string; text: string; bytes: number; postalAddress: boolean };
|
|
38
|
+
|
|
39
|
+
export type NewsletterDelivery = { address: string; status: string; at: string; eventAt?: string; error?: string };
|
|
40
|
+
|
|
41
|
+
export type NewsletterSubscriber = { email: string; status: string; lists: { id: string; status: string }[]; createdAt: string; confirmedAt?: string };
|
|
42
|
+
|
|
43
|
+
export type NewsletterExportRow = {
|
|
44
|
+
email: string;
|
|
45
|
+
status: string;
|
|
46
|
+
lists: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
consentRequestedAt: string;
|
|
49
|
+
consentConfirmedAt: string;
|
|
50
|
+
consentFormId: string;
|
|
51
|
+
consentPageUrl: string;
|
|
52
|
+
consentWording: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type NewsletterFailure = { ok: false; error: string; message?: string };
|
|
56
|
+
|
|
57
|
+
/** The newsroom words for the statuses the functions write. A status this panel does not know is shown as written, never hidden. */
|
|
58
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
59
|
+
draft: "Draft",
|
|
60
|
+
awaiting_approval: "Awaiting approval",
|
|
61
|
+
scheduled: "Scheduled",
|
|
62
|
+
sending: "Sending",
|
|
63
|
+
sent: "Sent",
|
|
64
|
+
stopped: "Stopped",
|
|
65
|
+
cancelled: "Cancelled",
|
|
66
|
+
blocked: "Blocked",
|
|
67
|
+
};
|
|
68
|
+
/** A mark beside the word, so no status relies on colour alone. */
|
|
69
|
+
const STATUS_MARKS: Record<string, string> = { draft: "✎", awaiting_approval: "?", scheduled: "◷", sending: "▶", sent: "✓", stopped: "■", cancelled: "–", blocked: "!" };
|
|
70
|
+
|
|
71
|
+
export function statusLabel(status: string): string {
|
|
72
|
+
if (STATUS_LABELS[status]) return STATUS_LABELS[status];
|
|
73
|
+
const words = status.replace(/_/g, " ").trim();
|
|
74
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function statusMark(status: string): string {
|
|
78
|
+
return STATUS_MARKS[status] ?? "·";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const ISSUE_STATUS_FILTERS = ["draft", "awaiting_approval", "scheduled", "sending", "sent", "blocked", "stopped", "cancelled"] as const;
|
|
82
|
+
|
|
83
|
+
/** Approval accepts a draft, an alert awaiting approval, a scheduled issue (a new time) and a blocked one (cause fixed). */
|
|
84
|
+
export function canApprove(status: string): boolean {
|
|
85
|
+
return status === "draft" || status === "awaiting_approval" || status === "scheduled" || status === "blocked";
|
|
86
|
+
}
|
|
87
|
+
export const canCancel = (status: string): boolean => status === "scheduled";
|
|
88
|
+
export const canStop = (status: string): boolean => status === "sending";
|
|
89
|
+
|
|
90
|
+
export function issueEditorHref(itemId: string, demo?: boolean): string {
|
|
91
|
+
return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(itemId)}`, demo);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ERROR_WORDS: Record<string, string> = {
|
|
95
|
+
forbidden: "Your role cannot do this.",
|
|
96
|
+
not_found: "That issue no longer exists.",
|
|
97
|
+
invalid_state: "The issue changed state. Refresh and try again.",
|
|
98
|
+
item_missing: "The issue's item no longer exists.",
|
|
99
|
+
render_failed: "The issue cannot be rendered. Open it in the editor and fix the body.",
|
|
100
|
+
bad_request: "The request was not understood.",
|
|
101
|
+
no_address: "Your account has no email address to send a test to.",
|
|
102
|
+
unavailable: "The newsletter service could not be reached. Try again in a minute.",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** One sentence for a failed operation. A missing postal address is an operator task, so the panel says where it is set. */
|
|
106
|
+
export function failureText(failure: NewsletterFailure): string {
|
|
107
|
+
if (failure.error === "postal_address_missing") {
|
|
108
|
+
return "This site has no postal address for the email footer, and mail law requires one. An operator sets it in the site's AWS configuration (an SSM parameter); it cannot be set from the newsroom.";
|
|
109
|
+
}
|
|
110
|
+
return ERROR_WORDS[failure.error] ?? failure.message ?? "The request failed.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- times ---------------------------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
116
|
+
if (!iso) return "never";
|
|
117
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
118
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function validTimeZone(zone: string | null | undefined): string | null {
|
|
122
|
+
if (!zone) return null;
|
|
123
|
+
try {
|
|
124
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
125
|
+
return zone;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wallParts(instant: Date, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
|
|
132
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
133
|
+
timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
134
|
+
}).formatToParts(instant);
|
|
135
|
+
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
136
|
+
return { year: pick("year"), month: pick("month"), day: pick("day"), hour: pick("hour") % 24, minute: pick("minute") };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Minutes the zone is ahead of UTC at that instant. */
|
|
140
|
+
function offsetMinutes(instant: Date, zone: string): number {
|
|
141
|
+
const wall = wallParts(instant, zone);
|
|
142
|
+
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
|
|
143
|
+
return Math.round((asUtc - Math.floor(instant.getTime() / 60000) * 60000) / 60000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
147
|
+
|
|
148
|
+
/** Local wall time `YYYY-MM-DDTHH:mm` (a datetime-local value) as an ISO instant, for the given zone. */
|
|
149
|
+
export function zonedInputToIso(local: string, zone: string): string | null {
|
|
150
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(local);
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
const [, year, month, day, hour, minute] = match.map(Number) as unknown as number[];
|
|
153
|
+
const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
154
|
+
let guess = wallAsUtc - offsetMinutes(new Date(wallAsUtc), zone) * 60000;
|
|
155
|
+
guess = wallAsUtc - offsetMinutes(new Date(guess), zone) * 60000; // once more: the offset can differ across a clock change
|
|
156
|
+
const instant = new Date(guess);
|
|
157
|
+
return Number.isNaN(instant.getTime()) ? null : instant.toISOString().replace(".000Z", "Z");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The next whole hour on the zone's clock, as a datetime-local value. */
|
|
161
|
+
export function nextWholeHourInput(now: Date, zone: string): string {
|
|
162
|
+
const wall = wallParts(now, zone);
|
|
163
|
+
const next = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour + 1, 0));
|
|
164
|
+
return `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}T${pad(next.getUTCHours())}:00`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An ISO instant as a datetime-local value in the zone. */
|
|
168
|
+
export function isoToZonedInput(iso: string, zone: string): string {
|
|
169
|
+
const wall = wallParts(new Date(iso), zone);
|
|
170
|
+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `2027-01-15 09:00 EST` in the zone. */
|
|
174
|
+
export function formatInZone(iso: string | null | undefined, zone: string): string {
|
|
175
|
+
if (!iso) return "not set";
|
|
176
|
+
const instant = new Date(iso);
|
|
177
|
+
if (Number.isNaN(instant.getTime())) return iso;
|
|
178
|
+
const label = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find((part) => part.type === "timeZoneName")?.value ?? zone;
|
|
179
|
+
return `${isoToZonedInput(iso, zone).replace("T", " ")} ${label}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -- counts and CSV ------------------------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** "12 sent, 2 delivered, 1 bounced"; only counters above zero, in reading order. */
|
|
185
|
+
export function countsText(counts: NewsletterCounts): string {
|
|
186
|
+
const order: [keyof NewsletterCounts, string][] = [
|
|
187
|
+
["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["bounced", "bounced"], ["complained", "complained"],
|
|
188
|
+
["failed", "failed"], ["suppressed", "suppressed"], ["skipped", "skipped"], ["softBounced", "soft bounced"],
|
|
189
|
+
];
|
|
190
|
+
const parts = order.filter(([name]) => (counts[name] ?? 0) > 0).map(([name, label]) => `${counts[name]} ${label}`);
|
|
191
|
+
return parts.length ? parts.join(", ") : "nothing sent yet";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const NEWSLETTER_CSV_HEADER = [
|
|
195
|
+
"email", "status", "lists", "createdAt", "consentRequestedAt", "consentConfirmedAt", "consentFormId", "consentPageUrl", "consentWording",
|
|
196
|
+
] as const;
|
|
197
|
+
|
|
198
|
+
/** Every cell goes through the formula neutraliser: addresses and consent wording are text people typed. */
|
|
199
|
+
export function subscribersToCsv(rows: NewsletterExportRow[]): string {
|
|
200
|
+
const lines = rows.map((row) => NEWSLETTER_CSV_HEADER.map((column) => csvCell(String(row[column] ?? ""))).join(","));
|
|
201
|
+
return `${[NEWSLETTER_CSV_HEADER.join(","), ...lines].join("\r\n")}\r\n`;
|
|
202
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The small server-rendered pages of the newsletter plugin (confirm prompt, confirmed, expired).
|
|
5
|
+
* Plain HTML, no scripts, themed from the plugin's public settings. Every dynamic value is escaped, the token never
|
|
6
|
+
* reaches a link, and the pages are never cached or indexed.
|
|
7
|
+
*/
|
|
8
|
+
export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
|
|
11
|
+
accent: "#1a4d8f",
|
|
12
|
+
text: "#1b1b1b",
|
|
13
|
+
background: "#ffffff",
|
|
14
|
+
surface: "#f4f4f4",
|
|
15
|
+
muted: "#555555",
|
|
16
|
+
fontFamily: "Georgia, 'Times New Roman', serif",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
|
|
20
|
+
const MAXIMUM_LISTS_SHOWN = 20;
|
|
21
|
+
|
|
22
|
+
export function escapeHtml(value: string): string {
|
|
23
|
+
return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
|
|
27
|
+
export function pageTheme(raw: unknown): NewsletterPageTheme {
|
|
28
|
+
const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
|
|
29
|
+
if (typeof raw !== "object" || raw === null) return theme;
|
|
30
|
+
for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
|
|
31
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
32
|
+
if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return theme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FormSpec = {
|
|
38
|
+
action: string;
|
|
39
|
+
/** Hidden fields, such as the token. */
|
|
40
|
+
hidden: Record<string, string>;
|
|
41
|
+
checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
|
|
42
|
+
/** Every button says what it does in words. `secondary` only changes how it looks. */
|
|
43
|
+
buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type PageContent = {
|
|
47
|
+
siteName: string;
|
|
48
|
+
theme: NewsletterPageTheme;
|
|
49
|
+
heading: string;
|
|
50
|
+
/** A short status line shown above the text (for example "Saved"), as words. */
|
|
51
|
+
notice?: string;
|
|
52
|
+
paragraphs: string[];
|
|
53
|
+
lists?: string[];
|
|
54
|
+
forms?: FormSpec[];
|
|
55
|
+
links?: Array<{ href: string; label: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function renderForm(form: FormSpec): string {
|
|
59
|
+
const hidden = Object.entries(form.hidden)
|
|
60
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
61
|
+
.join("");
|
|
62
|
+
const boxes = (form.checkboxes ?? [])
|
|
63
|
+
.map(
|
|
64
|
+
(box, index) =>
|
|
65
|
+
`<p class="box"><input type="checkbox" id="c${index}" name="${escapeHtml(box.name)}" value="${escapeHtml(box.value)}"${box.checked ? " checked" : ""}> <label for="c${index}">${escapeHtml(box.label)}</label></p>`,
|
|
66
|
+
)
|
|
67
|
+
.join("");
|
|
68
|
+
const buttons = form.buttons
|
|
69
|
+
.map(
|
|
70
|
+
(button) =>
|
|
71
|
+
`<button type="submit"${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}${button.secondary ? ' class="secondary"' : ""}>${escapeHtml(button.label)}</button>`,
|
|
72
|
+
)
|
|
73
|
+
.join(" ");
|
|
74
|
+
return `<form method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="actions">${buttons}</p></form>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderPage(page: PageContent): string {
|
|
78
|
+
const { theme } = page;
|
|
79
|
+
const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
|
|
80
|
+
const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
|
|
81
|
+
return `<!doctype html>
|
|
82
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
83
|
+
<meta name="robots" content="noindex"><meta name="referrer" content="no-referrer">
|
|
84
|
+
<title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
|
|
85
|
+
<style>
|
|
86
|
+
body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
|
|
87
|
+
main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
|
|
88
|
+
.site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
|
|
89
|
+
h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
|
|
90
|
+
.notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
|
|
91
|
+
ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
|
|
92
|
+
.box{margin:.6rem 0}
|
|
93
|
+
input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
|
|
94
|
+
.actions{margin:1.25rem 0}
|
|
95
|
+
button{background:${theme.accent};color:#fff;border:2px solid ${theme.accent};font:inherit;font-weight:bold;padding:.7rem 1.4rem;cursor:pointer;border-radius:4px;margin:0 .5rem .5rem 0}
|
|
96
|
+
button.secondary{background:${theme.background};color:${theme.accent}}
|
|
97
|
+
button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
|
|
98
|
+
a{color:${theme.accent}}
|
|
99
|
+
</style></head><body><main>
|
|
100
|
+
<p class="site">${escapeHtml(page.siteName)}</p>
|
|
101
|
+
<h1>${escapeHtml(page.heading)}</h1>
|
|
102
|
+
${page.notice ? `<p class="notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
|
|
103
|
+
${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
|
|
104
|
+
${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
|
|
105
|
+
${(page.forms ?? []).map(renderForm).join("\n")}
|
|
106
|
+
${links}
|
|
107
|
+
</main></body></html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const PAGE_HEADERS = {
|
|
111
|
+
"content-type": "text/html; charset=utf-8",
|
|
112
|
+
"cache-control": "no-store",
|
|
113
|
+
"referrer-policy": "no-referrer",
|
|
114
|
+
"x-content-type-options": "nosniff",
|
|
115
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
export function pageResponse(page: PageContent, status = 200): NextResponse {
|
|
119
|
+
return new NextResponse(renderPage(page), { status, headers: PAGE_HEADERS });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const CONFIRM_PATH = "/api/newsletter/confirm";
|
|
123
|
+
|
|
124
|
+
type Base = Pick<PageContent, "siteName" | "theme">;
|
|
125
|
+
const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
|
|
126
|
+
|
|
127
|
+
export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
heading: "Confirm your subscription",
|
|
131
|
+
paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
|
|
132
|
+
lists,
|
|
133
|
+
forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
heading: "You are subscribed",
|
|
141
|
+
paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
|
|
142
|
+
lists,
|
|
143
|
+
links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function expiredPage(base: Base): PageContent {
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
heading: "This link has expired",
|
|
151
|
+
paragraphs: [
|
|
152
|
+
"This confirmation link has expired or was already used. If you already confirmed, you are all set.",
|
|
153
|
+
"If you did not finish signing up, go back to the site and sign up again to get a new link.",
|
|
154
|
+
],
|
|
155
|
+
links: [home(base)],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function unavailablePage(base: Base): PageContent {
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
heading: "Something went wrong",
|
|
163
|
+
paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
|
|
164
|
+
links: [home(base)],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
|
|
169
|
+
export const PREFERENCES_PATH = "/api/newsletter/preferences";
|
|
170
|
+
|
|
171
|
+
export function preferencesHref(token: string): string {
|
|
172
|
+
return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function invalidManagePage(base: Base): PageContent {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
heading: "This link is not valid",
|
|
179
|
+
paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
|
|
180
|
+
links: [home(base)],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
heading: "Unsubscribe",
|
|
188
|
+
paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
|
|
189
|
+
forms: [
|
|
190
|
+
{
|
|
191
|
+
action: UNSUBSCRIBE_PATH,
|
|
192
|
+
hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
|
|
193
|
+
buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
heading: "You are unsubscribed",
|
|
204
|
+
paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
|
|
205
|
+
lists: names,
|
|
206
|
+
links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
|
|
211
|
+
|
|
212
|
+
export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
heading: "Your subscriptions",
|
|
216
|
+
notice: input.notice,
|
|
217
|
+
paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
|
|
218
|
+
forms: [
|
|
219
|
+
{
|
|
220
|
+
action: PREFERENCES_PATH,
|
|
221
|
+
hidden: { t: input.token },
|
|
222
|
+
checkboxes: input.lists.map((list) => ({
|
|
223
|
+
name: "l",
|
|
224
|
+
value: list.id,
|
|
225
|
+
label: list.description ? `${list.name}: ${list.description}` : list.name,
|
|
226
|
+
checked: list.subscribed,
|
|
227
|
+
})),
|
|
228
|
+
buttons: [
|
|
229
|
+
{ label: "Save preferences", name: "action", value: "save" },
|
|
230
|
+
{ label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
|
|
231
|
+
{ label: "Delete my data", name: "action", value: "delete", secondary: true },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
links: [home(base)],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function deleteConfirmPage(base: Base, token: string): PageContent {
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
heading: "Delete your data?",
|
|
243
|
+
paragraphs: [
|
|
244
|
+
`This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
|
|
245
|
+
],
|
|
246
|
+
forms: [
|
|
247
|
+
{
|
|
248
|
+
action: PREFERENCES_PATH,
|
|
249
|
+
hidden: { t: token, action: "delete", confirm: "1" },
|
|
250
|
+
buttons: [{ label: "Yes, delete my data" }],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
links: [{ href: preferencesHref(token), label: "No, go back" }],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function deletedPage(base: Base): PageContent {
|
|
258
|
+
return {
|
|
259
|
+
...base,
|
|
260
|
+
heading: "Your data is deleted",
|
|
261
|
+
paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
|
|
262
|
+
links: [home(base)],
|
|
263
|
+
};
|
|
264
|
+
}
|