@anthusai/papyrus 2.0.0-next.43 → 2.0.0-next.45

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.
@@ -16,6 +16,7 @@ import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
17
  import { MonitoringPanel } from "./monitoring-panel";
18
18
  import { ModerationPanel } from "./moderation-panel";
19
+ import { NewsletterPanel } from "./newsletter-panel";
19
20
  import { SignupEntriesPanel } from "./signup-entries-panel";
20
21
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
21
22
  import { generateClient } from "aws-amplify/data";
@@ -250,7 +251,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
250
251
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
251
252
  type AssignmentDeskViewMode = "queue" | "budget";
252
253
  type UserRoleAction = "grant" | "revoke";
253
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "moderation" | "editorial-checks" | "monitoring";
254
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
254
255
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
255
256
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
256
257
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -882,7 +883,7 @@ function NewsDeskDashboard({
882
883
  const [mergeSelection, setMergeSelection] = useState<MergeSelection | null>(null);
883
884
  const [isPending, startTransition] = useTransition();
884
885
  const controlsDisabled = isPending || !canEdit;
885
- const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel);
886
+ const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo));
886
887
  const [administrationPanel, setAdministrationPanel] = useState<AdministrationPanel>(initialAdministrationPanel);
887
888
  const [doctrineDrafts, setDoctrineDrafts] = useState<DoctrineEditorState>(() => buildDoctrineEditorState(dashboard.doctrineRecords));
888
889
  const [loadedSections, setLoadedSections] = useState<Record<string, boolean>>({
@@ -1123,8 +1124,8 @@ function NewsDeskDashboard({
1123
1124
  }, [dashboard.procedureDefinitions, dashboard.procedureRuns, dashboard.procedureVersions]);
1124
1125
 
1125
1126
  useEffect(() => {
1126
- setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel));
1127
- }, [initialSelection.panel]);
1127
+ setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo)));
1128
+ }, [initialSelection.panel, dashboard.isDemo]);
1128
1129
 
1129
1130
  useEffect(() => {
1130
1131
  if (activeTab !== "administration") return;
@@ -4352,6 +4353,17 @@ function AdministrationDeskView({
4352
4353
  <span>Addresses from the site&apos;s signup forms</span>
4353
4354
  </Link>
4354
4355
  ) : null}
4356
+ {newsletterPluginEnabled(isDemo) ? (
4357
+ <Link
4358
+ href={administrationPanelHref("newsletter", isDemo)}
4359
+ data-news-desk-admin-nav="newsletter"
4360
+ data-active={administrationPanel === "newsletter" || undefined}
4361
+ onClick={() => onPanelChange("newsletter")}
4362
+ >
4363
+ <strong>Newsletter</strong>
4364
+ <span>Subscribers, issues and delivery for the site&apos;s email</span>
4365
+ </Link>
4366
+ ) : null}
4355
4367
  {readerPluginEnabled() ? (
4356
4368
  <Link
4357
4369
  href={administrationPanelHref("moderation", isDemo)}
@@ -4366,6 +4378,7 @@ function AdministrationDeskView({
4366
4378
  </nav>}
4367
4379
  <div className="news-desk-settings-panel">
4368
4380
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4381
+ {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4369
4382
  {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4370
4383
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4371
4384
  {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
@@ -12832,13 +12845,19 @@ function signupPluginEnabled(): boolean {
12832
12845
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12833
12846
  }
12834
12847
 
12848
+ /** The Newsletter panel exists when the site enabled the plugin; `?demo=1` shows it with fixture data so it can be seen and tested. */
12849
+ function newsletterPluginEnabled(demo = false): boolean {
12850
+ return demo || pluginSettings(amplifyOutputs as PluginOutputs, "newsletter") !== null;
12851
+ }
12852
+
12835
12853
  function readerPluginEnabled(): boolean {
12836
12854
  return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
12837
12855
  }
12838
12856
 
12839
- function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
12857
+ function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12840
12858
  if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12841
12859
  if (value === "signups" && signupPluginEnabled()) return value;
12860
+ if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12842
12861
  if (value === "moderation" && readerPluginEnabled()) return value;
12843
12862
  return "users";
12844
12863
  }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { NewsletterFailure } from "./newsletter-model";
5
+
6
+ /** The operations the newsroom Newsletter panel calls. All take and return AWSJSON; the function behind them checks the role again. */
7
+ export const NEWSLETTER_QUERY_OPERATIONS = [
8
+ "newsletterOverview", "newsletterIssues", "newsletterIssue", "newsletterPreview", "newsletterIssueDeliveries", "newsletterSubscribers", "newsletterExport",
9
+ ] as const;
10
+ export const NEWSLETTER_MUTATION_OPERATIONS = [
11
+ "newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterTestSend", "newsletterCreateDraft", "newsletterErase", "newsletterResume",
12
+ ] as const;
13
+ export type NewsletterOperation = (typeof NEWSLETTER_QUERY_OPERATIONS)[number] | (typeof NEWSLETTER_MUTATION_OPERATIONS)[number];
14
+
15
+ type GraphQLResult = { data?: Record<string, unknown>; errors?: { message?: string; errorType?: string }[] };
16
+
17
+ export type NewsletterApi = <T extends object = Record<string, unknown>>(operation: NewsletterOperation, input?: Record<string, unknown>) => Promise<({ ok: true } & T) | NewsletterFailure>;
18
+
19
+ export function newsletterOperationDocument(operation: NewsletterOperation): string {
20
+ const kind = (NEWSLETTER_MUTATION_OPERATIONS as readonly string[]).includes(operation) ? "mutation" : "query";
21
+ const title = operation.charAt(0).toUpperCase() + operation.slice(1);
22
+ return `${kind} ${title}($input: AWSJSON) { ${operation}(input: $input) }`;
23
+ }
24
+
25
+ /** Calls one operation through the site's authenticated API (user pool). The browser holds no table name, key or address list. */
26
+ export const callNewsletter: NewsletterApi = async (operation, input = {}) => {
27
+ const client = generateClient({ authMode: "userPool" });
28
+ let result: GraphQLResult;
29
+ try {
30
+ result = (await client.graphql({ query: newsletterOperationDocument(operation), variables: { input: JSON.stringify(input) } })) as GraphQLResult;
31
+ } catch (error) {
32
+ // Amplify throws the response when AppSync returns errors.
33
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
34
+ }
35
+ if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
36
+ return { ok: false, error: "forbidden" };
37
+ }
38
+ const raw = result.data?.[operation];
39
+ if (result.errors?.length || raw === undefined || raw === null) return { ok: false, error: "unavailable" };
40
+ try {
41
+ const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw) as { ok?: boolean };
42
+ return parsed && typeof parsed === "object" && "ok" in parsed ? (parsed as never) : { ok: false, error: "unavailable" };
43
+ } catch {
44
+ return { ok: false, error: "unavailable" };
45
+ }
46
+ };
@@ -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> &middot; <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
+ }
package/package.json CHANGED
@@ -91,6 +91,8 @@
91
91
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
92
92
  "./components/news-desk-page": "./components/news-desk-page.tsx",
93
93
  "./components/news-desk-taxonomy-client": "./components/news-desk-taxonomy-client.ts",
94
+ "./components/newsletter-panel": "./components/newsletter-panel.tsx",
95
+ "./components/newsletter-sections": "./components/newsletter-sections.tsx",
94
96
  "./components/newspaper": "./components/newspaper.tsx",
95
97
  "./components/newsroom-access-gate": "./components/newsroom-access-gate.tsx",
96
98
  "./components/newsroom-article-editor": "./components/newsroom-article-editor.tsx",
@@ -235,6 +237,9 @@
235
237
  "./lib/monitoring-overview": "./lib/monitoring-overview.ts",
236
238
  "./lib/news-desk-session": "./lib/news-desk-session.ts",
237
239
  "./lib/newsletter-api": "./lib/newsletter-api.ts",
240
+ "./lib/newsletter-client": "./lib/newsletter-client.ts",
241
+ "./lib/newsletter-fixture": "./lib/newsletter-fixture.ts",
242
+ "./lib/newsletter-model": "./lib/newsletter-model.ts",
238
243
  "./lib/newsletter-pages": "./lib/newsletter-pages.ts",
239
244
  "./lib/newspaper-layout": "./lib/newspaper-layout.ts",
240
245
  "./lib/newsroom-articles": "./lib/newsroom-articles.ts",
@@ -428,7 +433,7 @@
428
433
  "optional": true
429
434
  }
430
435
  },
431
- "version": "2.0.0-next.43",
436
+ "version": "2.0.0-next.45",
432
437
  "dependencies": {
433
438
  "@aws-amplify/adapter-nextjs": "^1.7.3",
434
439
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.43",
2
+ "version": "2.0.0-next.45",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",