@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.70
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/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -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 +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -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 +180 -0
- package/amplify/plugins/reader-steering/config.ts +280 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- 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/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
- package/amplify/plugins/reader-steering/schema.js +204 -0
- package/amplify/plugins/reader-steering/schema.ts +206 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +38 -1
- package/amplify/site-backend.ts +46 -1
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -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/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { PluginRequest } from "../types";
|
|
2
|
+
|
|
3
|
+
export const NEWSLETTER_SITE_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
4
|
+
export const NEWSLETTER_LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
5
|
+
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
6
|
+
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
7
|
+
const LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
|
|
8
|
+
const SES_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
9
|
+
const ISSUE_TYPE = /^[a-z][a-z0-9_]{0,59}$/;
|
|
10
|
+
/** One path segment: it is the Item section of the archive and the `urls.sections` key of the site's projection.json. */
|
|
11
|
+
const ARCHIVE_PATH = /^\/[a-z0-9][a-z0-9_-]*\/$/;
|
|
12
|
+
const SCHEDULER_CRON = /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
|
|
13
|
+
const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
|
|
14
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
15
|
+
|
|
16
|
+
export const ITEM_URL_STYLES = ["html", "directory", "clean"] as const;
|
|
17
|
+
export type ItemUrlStyle = (typeof ITEM_URL_STYLES)[number];
|
|
18
|
+
|
|
19
|
+
export type NewsletterTheme = {
|
|
20
|
+
accent: string;
|
|
21
|
+
text: string;
|
|
22
|
+
background: string;
|
|
23
|
+
surface?: string;
|
|
24
|
+
muted?: string;
|
|
25
|
+
fontFamily?: string;
|
|
26
|
+
logoUrl?: string;
|
|
27
|
+
/** Branding, all optional: a plain theme keeps the plain layout. Colours are CSS values; the border is 1.5px when set. */
|
|
28
|
+
headerBand?: string;
|
|
29
|
+
headerText?: string;
|
|
30
|
+
buttonFill?: string;
|
|
31
|
+
buttonText?: string;
|
|
32
|
+
buttonBorder?: string;
|
|
33
|
+
footerBand?: string;
|
|
34
|
+
footerText?: string;
|
|
35
|
+
/** An absolute https image, drawn with alt text and explicit pixel size (web fonts do not load in email). */
|
|
36
|
+
wordmarkImageUrl?: string;
|
|
37
|
+
wordmarkAlt?: string;
|
|
38
|
+
wordmarkWidth?: number;
|
|
39
|
+
wordmarkHeight?: number;
|
|
40
|
+
/** Draws the footer's wordmark at this width in pixels (1 to 400), smaller than the header's; the height keeps the aspect ratio. */
|
|
41
|
+
footerWordmarkWidth?: number;
|
|
42
|
+
labelFont?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const THEME_STYLE_KEYS = [
|
|
46
|
+
"surface", "muted", "fontFamily", "headerBand", "headerText", "buttonFill", "buttonText", "buttonBorder", "footerBand",
|
|
47
|
+
"footerText", "labelFont",
|
|
48
|
+
] as const;
|
|
49
|
+
const THEME_KEYS = [
|
|
50
|
+
"accent", "text", "background", ...THEME_STYLE_KEYS, "logoUrl", "wordmarkImageUrl", "wordmarkAlt", "wordmarkWidth", "wordmarkHeight",
|
|
51
|
+
"footerWordmarkWidth",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export type NewsletterRule = { sections?: string[]; tags?: string[]; types?: string[] };
|
|
55
|
+
|
|
56
|
+
type ListBase = { id: string; name: string; description: string };
|
|
57
|
+
export type RoundupList = ListBase & {
|
|
58
|
+
kind: "roundup";
|
|
59
|
+
/** EventBridge Scheduler cron for assembling the draft, and the IANA time zone it runs in. */
|
|
60
|
+
schedule: { draft: string; timezone: string };
|
|
61
|
+
/** The CMS item type of an issue. */
|
|
62
|
+
issueType: string;
|
|
63
|
+
/** Where the sent issue is archived, such as `/roundup/`: one path segment, also the section the issue Items are filed under. */
|
|
64
|
+
archivePath: string;
|
|
65
|
+
};
|
|
66
|
+
export type AlertList = ListBase & {
|
|
67
|
+
kind: "alert";
|
|
68
|
+
/** Which published items raise an alert. Absent or empty: every published item (an admin still approves it). */
|
|
69
|
+
rule: NewsletterRule;
|
|
70
|
+
approval: "admin" | "none";
|
|
71
|
+
};
|
|
72
|
+
export type NewsletterList = RoundupList | AlertList;
|
|
73
|
+
|
|
74
|
+
export type NewsletterConfig = {
|
|
75
|
+
/** The site id: names the key parameter, the configuration set and the event topic. Must match the sending stack's SiteId. */
|
|
76
|
+
siteId: string;
|
|
77
|
+
/** The site's public https origin. The plugin's pages are at `<siteOrigin><basePath>/newsletter/...`. */
|
|
78
|
+
siteOrigin: string;
|
|
79
|
+
/** The site's `urls.basePath` from projection.json, as a path without slashes at either end ("" for a site at the root, "posts" for dataparade.io/posts). */
|
|
80
|
+
basePath: string;
|
|
81
|
+
/** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of the function. */
|
|
82
|
+
computeRoleArn: string;
|
|
83
|
+
sendingDomain: string;
|
|
84
|
+
fromAddress: string;
|
|
85
|
+
fromName: string;
|
|
86
|
+
/** A site must set this to true before any list may send without approval. */
|
|
87
|
+
instantAlerts: boolean;
|
|
88
|
+
theme: NewsletterTheme;
|
|
89
|
+
lists: NewsletterList[];
|
|
90
|
+
/** How the site addresses its item pages (`urls.style` in projection.json): roundup drafts link to published items with it. */
|
|
91
|
+
itemUrlStyle: ItemUrlStyle;
|
|
92
|
+
/** `papyrus-<siteId>` unless overridden. */
|
|
93
|
+
configurationSet: string;
|
|
94
|
+
/** `papyrus-newsletter-events-<siteId>` unless overridden. */
|
|
95
|
+
eventTopicName: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const KNOWN_KEYS = new Set([
|
|
99
|
+
"name", "siteId", "siteOrigin", "basePath", "computeRoleArn", "sendingDomain", "fromAddress", "fromName", "instantAlerts", "theme", "lists",
|
|
100
|
+
"configurationSet", "eventTopicName", "itemUrlStyle",
|
|
101
|
+
]);
|
|
102
|
+
const LIST_KEYS = {
|
|
103
|
+
roundup: ["id", "name", "description", "kind", "schedule", "issueType", "archivePath"],
|
|
104
|
+
alert: ["id", "name", "description", "kind", "rule", "approval"],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function fail(message: string): never {
|
|
108
|
+
throw new Error(`newsletter plugin ${message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function record(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
112
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail(`${label} must be an object`);
|
|
113
|
+
const unknown = Object.keys(raw).filter((key) => !known.includes(key));
|
|
114
|
+
if (unknown.length > 0) fail(`${label} has unknown settings: ${unknown.join(", ")}`);
|
|
115
|
+
return raw as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function text(raw: unknown, label: string, maximum: number, options: { optional?: boolean; fallback?: string } = {}): string {
|
|
119
|
+
const value = raw === undefined ? options.fallback : raw;
|
|
120
|
+
if (value === undefined) fail(`${label} is required`);
|
|
121
|
+
if (typeof value !== "string" || CONTROL_CHARACTERS.test(value) || value.trim() !== value) {
|
|
122
|
+
fail(`${label} must be text on one line with no leading or trailing spaces`);
|
|
123
|
+
}
|
|
124
|
+
if (value.length === 0 && !options.optional) fail(`${label} is required`);
|
|
125
|
+
if (value.length > maximum) fail(`${label} must be at most ${maximum} characters`);
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matching(raw: unknown, label: string, pattern: RegExp, description: string, fallback?: string): string {
|
|
130
|
+
const value = raw === undefined ? fallback : raw;
|
|
131
|
+
if (typeof value !== "string" || !pattern.test(value)) fail(`${label} must be ${description}`);
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function timeZone(raw: unknown, label: string): string {
|
|
136
|
+
const value = text(raw, label, 64);
|
|
137
|
+
try {
|
|
138
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
139
|
+
} catch {
|
|
140
|
+
fail(`${label} must be an IANA time zone such as America/New_York`);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function origin(raw: unknown): string {
|
|
146
|
+
const value = text(raw, "siteOrigin", 200);
|
|
147
|
+
let parsed: URL | undefined;
|
|
148
|
+
try {
|
|
149
|
+
parsed = new URL(value);
|
|
150
|
+
} catch {
|
|
151
|
+
parsed = undefined;
|
|
152
|
+
}
|
|
153
|
+
if (!parsed || parsed.protocol !== "https:" || parsed.origin !== value) fail("siteOrigin must be an https origin such as https://example.com with no path");
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `urls.basePath` as projection.json writes it ("/posts" or "posts"): lowercase path segments, no dots, at most 3 deep. */
|
|
158
|
+
const BASE_PATH = /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/;
|
|
159
|
+
|
|
160
|
+
function basePath(raw: unknown): string {
|
|
161
|
+
if (raw === undefined) return "";
|
|
162
|
+
const value = typeof raw === "string" ? raw.replace(/^\/+|\/+$/g, "") : null;
|
|
163
|
+
if (value === null || (value !== "" && !BASE_PATH.test(value))) fail('basePath must be the site\'s urls.basePath, a lowercase path such as "/posts"');
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stringList(raw: unknown, label: string): string[] | undefined {
|
|
168
|
+
if (raw === undefined) return undefined;
|
|
169
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 50) fail(`${label} must be a list of 1 to 50 names`);
|
|
170
|
+
return [...new Set(raw.map((entry, index) => text(entry, `${label}[${index}]`, 80)))];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function itemUrlStyle(raw: unknown): ItemUrlStyle {
|
|
174
|
+
if (raw === undefined) return "html";
|
|
175
|
+
if (typeof raw !== "string" || !(ITEM_URL_STYLES as readonly string[]).includes(raw)) fail(`itemUrlStyle must be one of ${ITEM_URL_STYLES.join(", ")}`);
|
|
176
|
+
return raw as ItemUrlStyle;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveTheme(raw: unknown): NewsletterTheme {
|
|
180
|
+
const theme = record(raw, "theme", THEME_KEYS);
|
|
181
|
+
const resolved: NewsletterTheme = {
|
|
182
|
+
accent: text(theme.accent, "theme.accent", 80),
|
|
183
|
+
text: text(theme.text, "theme.text", 80),
|
|
184
|
+
background: text(theme.background, "theme.background", 80),
|
|
185
|
+
};
|
|
186
|
+
for (const key of THEME_STYLE_KEYS) {
|
|
187
|
+
if (theme[key] !== undefined) resolved[key] = text(theme[key], `theme.${key}`, 200);
|
|
188
|
+
}
|
|
189
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
190
|
+
if (typeof value === "string" && UNSAFE_CSS.test(value)) fail(`theme.${key} contains characters that are not allowed in a style value`);
|
|
191
|
+
}
|
|
192
|
+
if (theme.logoUrl !== undefined) {
|
|
193
|
+
const logoUrl = text(theme.logoUrl, "theme.logoUrl", 500);
|
|
194
|
+
if (!/^https:\/\//.test(logoUrl)) fail("theme.logoUrl must be an https URL");
|
|
195
|
+
resolved.logoUrl = logoUrl;
|
|
196
|
+
}
|
|
197
|
+
if (theme.wordmarkImageUrl !== undefined) {
|
|
198
|
+
const url = text(theme.wordmarkImageUrl, "theme.wordmarkImageUrl", 500);
|
|
199
|
+
if (!/^https:\/\/[^\s"'<>\\`]+$/.test(url)) fail("theme.wordmarkImageUrl must be an absolute https URL");
|
|
200
|
+
resolved.wordmarkImageUrl = url;
|
|
201
|
+
for (const key of ["wordmarkWidth", "wordmarkHeight"] as const) {
|
|
202
|
+
const value = theme[key];
|
|
203
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 2000) {
|
|
204
|
+
fail(`theme.${key} is required with theme.wordmarkImageUrl and must be a whole number of pixels from 1 to 2000`);
|
|
205
|
+
}
|
|
206
|
+
resolved[key] = value;
|
|
207
|
+
}
|
|
208
|
+
} else if (theme.wordmarkWidth !== undefined || theme.wordmarkHeight !== undefined) {
|
|
209
|
+
fail("theme.wordmarkWidth and theme.wordmarkHeight need theme.wordmarkImageUrl");
|
|
210
|
+
}
|
|
211
|
+
if (theme.footerWordmarkWidth !== undefined) {
|
|
212
|
+
if (theme.wordmarkImageUrl === undefined) fail("theme.footerWordmarkWidth needs theme.wordmarkImageUrl");
|
|
213
|
+
const width = theme.footerWordmarkWidth;
|
|
214
|
+
if (typeof width !== "number" || !Number.isInteger(width) || width < 1 || width > 400) {
|
|
215
|
+
fail("theme.footerWordmarkWidth must be a whole number of pixels from 1 to 400");
|
|
216
|
+
}
|
|
217
|
+
resolved.footerWordmarkWidth = width;
|
|
218
|
+
}
|
|
219
|
+
if (theme.wordmarkAlt !== undefined) {
|
|
220
|
+
const alt = text(theme.wordmarkAlt, "theme.wordmarkAlt", 200);
|
|
221
|
+
if (/[\u0000-\u001f\u007f]/.test(alt)) fail("theme.wordmarkAlt must not contain control characters");
|
|
222
|
+
resolved.wordmarkAlt = alt;
|
|
223
|
+
}
|
|
224
|
+
return resolved;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveRule(raw: unknown, label: string): NewsletterRule {
|
|
228
|
+
if (raw === undefined) return {};
|
|
229
|
+
const rule = record(raw, label, ["sections", "tags", "types"]);
|
|
230
|
+
return {
|
|
231
|
+
...(rule.sections === undefined ? {} : { sections: stringList(rule.sections, `${label}.sections`) }),
|
|
232
|
+
...(rule.tags === undefined ? {} : { tags: stringList(rule.tags, `${label}.tags`) }),
|
|
233
|
+
...(rule.types === undefined ? {} : { types: stringList(rule.types, `${label}.types`) }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveList(raw: unknown, index: number, instantAlerts: boolean): NewsletterList {
|
|
238
|
+
const label = `lists[${index}]`;
|
|
239
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail(`${label} must be an object`);
|
|
240
|
+
const kind = (raw as Record<string, unknown>).kind;
|
|
241
|
+
if (kind !== "roundup" && kind !== "alert") fail(`${label}.kind must be "roundup" or "alert"`);
|
|
242
|
+
const list = record(raw, label, LIST_KEYS[kind]);
|
|
243
|
+
const base = {
|
|
244
|
+
id: matching(list.id, `${label}.id`, NEWSLETTER_LIST_ID, "lowercase letters, digits and hyphens, starting with a letter (at most 40 characters)"),
|
|
245
|
+
name: text(list.name, `${label}.name`, 80),
|
|
246
|
+
description: text(list.description, `${label}.description`, 300, { optional: true, fallback: "" }),
|
|
247
|
+
};
|
|
248
|
+
if (kind === "roundup") {
|
|
249
|
+
const schedule = record(list.schedule, `${label}.schedule`, ["draft", "timezone"]);
|
|
250
|
+
return {
|
|
251
|
+
...base,
|
|
252
|
+
kind,
|
|
253
|
+
schedule: {
|
|
254
|
+
draft: matching(schedule.draft, `${label}.schedule.draft`, SCHEDULER_CRON, 'an EventBridge Scheduler cron such as "cron(0 13 ? * FRI *)"'),
|
|
255
|
+
timezone: timeZone(schedule.timezone, `${label}.schedule.timezone`),
|
|
256
|
+
},
|
|
257
|
+
issueType: matching(list.issueType, `${label}.issueType`, ISSUE_TYPE, "a lowercase item type name", "newsletter_issue"),
|
|
258
|
+
archivePath: matching(list.archivePath, `${label}.archivePath`, ARCHIVE_PATH, 'a path that starts and ends with "/", such as /roundup/'),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const approval = list.approval === undefined ? "admin" : list.approval;
|
|
262
|
+
if (approval !== "admin" && approval !== "none") fail(`${label}.approval must be "admin" or "none"`);
|
|
263
|
+
if (approval === "none" && !instantAlerts) {
|
|
264
|
+
fail(`${label}.approval "none" sends alerts without an admin approving them: set instantAlerts: true on the plugin first`);
|
|
265
|
+
}
|
|
266
|
+
return { ...base, kind, rule: resolveRule(list.rule, `${label}.rule`), approval };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function resolveNewsletterConfig(request: PluginRequest): NewsletterConfig {
|
|
270
|
+
const raw = record(typeof request === "string" ? { name: request } : request, "settings", [...KNOWN_KEYS]);
|
|
271
|
+
const siteId = matching(raw.siteId, "siteId", NEWSLETTER_SITE_ID, "a lowercase site id (letters, digits, hyphens), the SiteId of the sending stack");
|
|
272
|
+
const computeRoleArn = raw.computeRoleArn;
|
|
273
|
+
if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
|
|
274
|
+
fail("computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
|
|
275
|
+
}
|
|
276
|
+
const sendingDomain = matching(raw.sendingDomain, "sendingDomain", HOSTNAME, "a lowercase domain name such as news.example.com");
|
|
277
|
+
const fromAddress = text(raw.fromAddress, "fromAddress", 254);
|
|
278
|
+
const [local, domain, ...extra] = fromAddress.split("@");
|
|
279
|
+
if (extra.length > 0 || !local || local.length > 64 || !LOCAL_PART.test(local) || domain !== sendingDomain) {
|
|
280
|
+
fail(`fromAddress must be an address on ${sendingDomain} (lowercase, such as newsletter@${sendingDomain})`);
|
|
281
|
+
}
|
|
282
|
+
if (raw.instantAlerts !== undefined && typeof raw.instantAlerts !== "boolean") fail("instantAlerts must be a boolean");
|
|
283
|
+
const instantAlerts = raw.instantAlerts === true;
|
|
284
|
+
if (!Array.isArray(raw.lists) || raw.lists.length === 0 || raw.lists.length > 20) fail("lists must be a list of 1 to 20 lists");
|
|
285
|
+
const lists = raw.lists.map((entry, index) => resolveList(entry, index, instantAlerts));
|
|
286
|
+
const duplicates = lists.map((list) => list.id).filter((id, index, ids) => ids.indexOf(id) !== index);
|
|
287
|
+
if (duplicates.length > 0) fail(`lists has duplicate ids: ${[...new Set(duplicates)].join(", ")}`);
|
|
288
|
+
const roundups = lists.filter((list) => list.kind === "roundup");
|
|
289
|
+
if (new Set(roundups.map((list) => (list as RoundupList).archivePath)).size !== roundups.length) fail("roundup lists need different archivePath values");
|
|
290
|
+
return {
|
|
291
|
+
siteId,
|
|
292
|
+
siteOrigin: origin(raw.siteOrigin),
|
|
293
|
+
basePath: basePath(raw.basePath),
|
|
294
|
+
computeRoleArn,
|
|
295
|
+
sendingDomain,
|
|
296
|
+
fromAddress,
|
|
297
|
+
fromName: text(raw.fromName, "fromName", 80),
|
|
298
|
+
instantAlerts,
|
|
299
|
+
theme: resolveTheme(raw.theme),
|
|
300
|
+
lists,
|
|
301
|
+
itemUrlStyle: itemUrlStyle(raw.itemUrlStyle),
|
|
302
|
+
configurationSet: matching(raw.configurationSet, "configurationSet", SES_NAME, "an SES configuration set name", `papyrus-${siteId}`),
|
|
303
|
+
eventTopicName: matching(raw.eventTopicName, "eventTopicName", SES_NAME, "an SNS topic name", `papyrus-newsletter-events-${siteId}`),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Bundle entry kept for the shared Python bundler, which copies ``handler.py`` to the asset root.
|
|
2
|
+
|
|
3
|
+
The Lambda's handler is ``papyrus_content.newsletter.handler.handler`` (src/papyrus_content/newsletter/handler.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from papyrus_content.newsletter.handler import handler # noqa: F401
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Bundle entry for the newsroom's newsletter operations (AppSync): forwards to the ``newsletter`` function.
|
|
2
|
+
|
|
3
|
+
The logic is ``papyrus_content.newsletter.ops_proxy`` (src/papyrus_content/newsletter/ops_proxy.py).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from papyrus_content.newsletter.ops_proxy import handle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handler(event: dict[str, Any], _context: Any = None) -> Any:
|
|
12
|
+
return handle(event)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration } from "aws-cdk-lib";
|
|
3
|
+
import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import { pythonLambdaCode } from "../../../functions/shared/python-bundle.js";
|
|
5
|
+
/** AppSync gives a resolver 30 seconds; this function only forwards to `newsletter`, which answers in a few. */
|
|
6
|
+
export const NEWSLETTER_OPS_TIMEOUT_SECONDS = 25;
|
|
7
|
+
/**
|
|
8
|
+
* The newsroom's newsletter operations enter here because an Amplify custom operation needs an Amplify-defined function and
|
|
9
|
+
* the `newsletter` function itself lives in the plugin stack. It holds no data access: it may invoke that one function.
|
|
10
|
+
*/
|
|
11
|
+
export const newsletterOps = defineFunction((scope) => new Function(scope, "papyrus-newsletter-ops", {
|
|
12
|
+
runtime: Runtime.PYTHON_3_12,
|
|
13
|
+
architecture: Architecture.ARM_64,
|
|
14
|
+
handler: "handler.handler",
|
|
15
|
+
timeout: Duration.seconds(NEWSLETTER_OPS_TIMEOUT_SECONDS),
|
|
16
|
+
memorySize: 256,
|
|
17
|
+
code: pythonLambdaCode({ functionDir: "../plugins/newsletter/ops", modules: ["papyrus_content"], noDeps: true }, [
|
|
18
|
+
"bash",
|
|
19
|
+
"-c",
|
|
20
|
+
[
|
|
21
|
+
"set -euo pipefail",
|
|
22
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
23
|
+
"cp amplify/plugins/newsletter/ops/handler.py /asset-output/handler.py",
|
|
24
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
25
|
+
].join(" && "),
|
|
26
|
+
]),
|
|
27
|
+
}), { resourceGroupName: "data" });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration } from "aws-cdk-lib";
|
|
3
|
+
import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import type { Construct } from "constructs";
|
|
5
|
+
import { pythonLambdaCode } from "../../../functions/shared/python-bundle";
|
|
6
|
+
|
|
7
|
+
/** AppSync gives a resolver 30 seconds; this function only forwards to `newsletter`, which answers in a few. */
|
|
8
|
+
export const NEWSLETTER_OPS_TIMEOUT_SECONDS = 25;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The newsroom's newsletter operations enter here because an Amplify custom operation needs an Amplify-defined function and
|
|
12
|
+
* the `newsletter` function itself lives in the plugin stack. It holds no data access: it may invoke that one function.
|
|
13
|
+
*/
|
|
14
|
+
export const newsletterOps = defineFunction(
|
|
15
|
+
(scope: Construct) =>
|
|
16
|
+
new Function(scope, "papyrus-newsletter-ops", {
|
|
17
|
+
runtime: Runtime.PYTHON_3_12,
|
|
18
|
+
architecture: Architecture.ARM_64,
|
|
19
|
+
handler: "handler.handler",
|
|
20
|
+
timeout: Duration.seconds(NEWSLETTER_OPS_TIMEOUT_SECONDS),
|
|
21
|
+
memorySize: 256,
|
|
22
|
+
code: pythonLambdaCode(
|
|
23
|
+
{ functionDir: "../plugins/newsletter/ops", modules: ["papyrus_content"], noDeps: true },
|
|
24
|
+
[
|
|
25
|
+
"bash",
|
|
26
|
+
"-c",
|
|
27
|
+
[
|
|
28
|
+
"set -euo pipefail",
|
|
29
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
30
|
+
"cp amplify/plugins/newsletter/ops/handler.py /asset-output/handler.py",
|
|
31
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
32
|
+
].join(" && "),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
{ resourceGroupName: "data" },
|
|
37
|
+
);
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Alarm, ComparisonOperator, MathExpression, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
|
|
3
|
+
import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
4
|
+
import { AttributeType, BillingMode, ProjectionType, Table, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
|
|
5
|
+
import { ArnPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import { Key } from "aws-cdk-lib/aws-kms";
|
|
7
|
+
import { Topic } from "aws-cdk-lib/aws-sns";
|
|
8
|
+
import { LambdaSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
|
|
9
|
+
import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
14
|
+
import { pythonLambdaCode } from "../../functions/shared/python-bundle.js";
|
|
15
|
+
import { resolveNewsletterConfig } from "./config.js";
|
|
16
|
+
import { newsletterOps } from "./ops/resource.js";
|
|
17
|
+
import { NEWSLETTER_MUTATIONS, NEWSLETTER_QUERIES, newsletterSchemaDefinitions } from "./schema.js";
|
|
18
|
+
import { functionArn, newsletterFunctionName, wireSending } from "./sending.js";
|
|
19
|
+
export { resolveNewsletterConfig };
|
|
20
|
+
export const NEWSLETTER_OUTPUT_KEY = "newsletter";
|
|
21
|
+
/** Set on the site's content-actions function: it invokes this function (`item-published`) after a real publish. Unset: no alerts. */
|
|
22
|
+
export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
|
|
23
|
+
export const NEWSLETTER_HANDLER = "papyrus_content.newsletter.handler.handler";
|
|
24
|
+
/** `boto3` is bundled because the runtime's copy may predate SESv2 `Simple.Headers` (List-Unsubscribe). */
|
|
25
|
+
export const NEWSLETTER_REQUIREMENTS = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "boto3>=1.35.8,<2"];
|
|
26
|
+
/** Relative to `amplify/functions/`, which is where the shared bundler looks for a function's `handler.py`. */
|
|
27
|
+
const FUNCTION_DIRECTORY = "../plugins/newsletter/function";
|
|
28
|
+
export const newsletterManifest = {
|
|
29
|
+
name: "newsletter",
|
|
30
|
+
contract: 1,
|
|
31
|
+
models: [],
|
|
32
|
+
queries: NEWSLETTER_QUERIES,
|
|
33
|
+
mutations: NEWSLETTER_MUTATIONS,
|
|
34
|
+
guest: { mutations: [] },
|
|
35
|
+
newsroomPanels: [],
|
|
36
|
+
staticAssets: ["_plugins/papyrus-newsletter@1.js", "_plugins/papyrus-newsletter-pages@1.js"],
|
|
37
|
+
};
|
|
38
|
+
let codeForTests;
|
|
39
|
+
/** Synthesis tests swap the bundled Python asset (a pip install) for inline code. Pass nothing to restore it. */
|
|
40
|
+
export function overrideNewsletterCodeForTests(code) {
|
|
41
|
+
codeForTests = code;
|
|
42
|
+
}
|
|
43
|
+
export function newsletterKeyParameterName(siteId) {
|
|
44
|
+
return `/papyrus/${siteId}/newsletter-key`;
|
|
45
|
+
}
|
|
46
|
+
/** The SES identity ARN and configuration set ARN the function may send through. */
|
|
47
|
+
export function newsletterSesResources(stack, config) {
|
|
48
|
+
return [
|
|
49
|
+
`arn:${stack.partition}:ses:${stack.region}:${stack.account}:identity/${config.sendingDomain}`,
|
|
50
|
+
`arn:${stack.partition}:ses:${stack.region}:${stack.account}:configuration-set/${config.configurationSet}`,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
export const BOUNCE_RATE_THRESHOLD = 0.04;
|
|
54
|
+
export const COMPLAINT_RATE_THRESHOLD = 0.0008;
|
|
55
|
+
export const ALARM_MINIMUM_SENDS = 50;
|
|
56
|
+
export function newsletterAlarmTopicName(siteId) {
|
|
57
|
+
return `papyrus-newsletter-alarms-${siteId}`;
|
|
58
|
+
}
|
|
59
|
+
/** The two alarm names; the function pauses sending only for these (python: `events.alarm_names`). */
|
|
60
|
+
export function newsletterAlarmNames(siteId) {
|
|
61
|
+
return { bounce: `papyrus-newsletter-${siteId}-bounce-rate`, complaint: `papyrus-newsletter-${siteId}-complaint-rate` };
|
|
62
|
+
}
|
|
63
|
+
/** What the function reads from its environment. Lists carry only what it needs to name them in the email. */
|
|
64
|
+
export function newsletterEnvironment(config, tableName, keyParameter) {
|
|
65
|
+
return {
|
|
66
|
+
TABLE_NAME: tableName,
|
|
67
|
+
KEY_PARAMETER: keyParameter,
|
|
68
|
+
SITE_ID: config.siteId,
|
|
69
|
+
SITE_ORIGIN: config.siteOrigin,
|
|
70
|
+
SITE_BASE_PATH: config.basePath,
|
|
71
|
+
SENDING_DOMAIN: config.sendingDomain,
|
|
72
|
+
FROM_ADDRESS: config.fromAddress,
|
|
73
|
+
FROM_NAME: config.fromName,
|
|
74
|
+
CONFIGURATION_SET: config.configurationSet,
|
|
75
|
+
ITEM_URL_STYLE: config.itemUrlStyle,
|
|
76
|
+
INSTANT_ALERTS: config.instantAlerts ? "true" : "false",
|
|
77
|
+
LISTS_JSON: JSON.stringify(config.lists.map((list) => ({
|
|
78
|
+
id: list.id,
|
|
79
|
+
name: list.name,
|
|
80
|
+
description: list.description,
|
|
81
|
+
kind: list.kind,
|
|
82
|
+
// A roundup's issue type, archive section and time zone are what drafting needs; its schedule stays in the stack.
|
|
83
|
+
...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath, timezone: list.schedule.timezone } : {}),
|
|
84
|
+
// An alert's rule and approval decide which published items raise an alert and whether an admin approves it.
|
|
85
|
+
...(list.kind === "alert" ? { rule: list.rule, approval: list.approval } : {}),
|
|
86
|
+
}))),
|
|
87
|
+
THEME_JSON: JSON.stringify(config.theme),
|
|
88
|
+
ALARM_NAMES: Object.values(newsletterAlarmNames(config.siteId)).join(","),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export const newsletterPlugin = {
|
|
92
|
+
manifest: newsletterManifest,
|
|
93
|
+
resolveConfig: resolveNewsletterConfig,
|
|
94
|
+
functions: () => ({ newsletterOps }),
|
|
95
|
+
schemaDefinitions: newsletterSchemaDefinitions,
|
|
96
|
+
publicSettings: (config) => ({
|
|
97
|
+
contract: newsletterManifest.contract,
|
|
98
|
+
fromName: config.fromName,
|
|
99
|
+
// The CMS routes redirect people to the plugin's pages under the site's base path.
|
|
100
|
+
basePath: config.basePath,
|
|
101
|
+
lists: config.lists.map((list) => ({
|
|
102
|
+
id: list.id,
|
|
103
|
+
name: list.name,
|
|
104
|
+
description: list.description,
|
|
105
|
+
kind: list.kind,
|
|
106
|
+
// The panel needs the item type to open an issue in the editor and the archive path to show where it lands.
|
|
107
|
+
...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath } : {}),
|
|
108
|
+
})),
|
|
109
|
+
theme: config.theme,
|
|
110
|
+
}),
|
|
111
|
+
wire(backend, config, context) {
|
|
112
|
+
const stack = Stack.of(context.createStack("newsletter"));
|
|
113
|
+
const key = new Key(stack, "NewsletterKey", {
|
|
114
|
+
description: `Encrypts the ${config.siteId} newsletter subscriber table`,
|
|
115
|
+
enableKeyRotation: true,
|
|
116
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
117
|
+
});
|
|
118
|
+
const table = new Table(stack, "NewsletterTable", {
|
|
119
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
120
|
+
sortKey: { name: "sk", type: AttributeType.STRING },
|
|
121
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
122
|
+
timeToLiveAttribute: "expiresAt",
|
|
123
|
+
encryption: TableEncryption.CUSTOMER_MANAGED,
|
|
124
|
+
encryptionKey: key,
|
|
125
|
+
// Point-in-time recovery stays off so that erasing a subscriber is real erasure.
|
|
126
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: false },
|
|
127
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
128
|
+
});
|
|
129
|
+
table.addGlobalSecondaryIndex({
|
|
130
|
+
indexName: "byList",
|
|
131
|
+
partitionKey: { name: "gsiList", type: AttributeType.STRING },
|
|
132
|
+
sortKey: { name: "sk", type: AttributeType.STRING },
|
|
133
|
+
projectionType: ProjectionType.KEYS_ONLY,
|
|
134
|
+
});
|
|
135
|
+
// Sparse: only send-ledger rows carry `gsiHid`, so erasing a subscriber finds their rows with one query, not a scan.
|
|
136
|
+
table.addGlobalSecondaryIndex({
|
|
137
|
+
indexName: "byHid",
|
|
138
|
+
partitionKey: { name: "gsiHid", type: AttributeType.STRING },
|
|
139
|
+
projectionType: ProjectionType.KEYS_ONLY,
|
|
140
|
+
});
|
|
141
|
+
const keyParameter = new SecureStringParameter(stack, "NewsletterKeyParameter", {
|
|
142
|
+
parameterName: newsletterKeyParameterName(config.siteId),
|
|
143
|
+
description: `Root key for ${config.siteId} newsletter subscriber hashing and link signing (HKDF-derived)`,
|
|
144
|
+
});
|
|
145
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
146
|
+
// One bundle serves the `newsletter` function and the send worker; they differ in handler, timeout and IAM.
|
|
147
|
+
const code = codeForTests ?? pythonLambdaCode({
|
|
148
|
+
functionDir: FUNCTION_DIRECTORY,
|
|
149
|
+
modules: ["papyrus_content"],
|
|
150
|
+
requirements: NEWSLETTER_REQUIREMENTS,
|
|
151
|
+
noDeps: true,
|
|
152
|
+
checkoutExtras: (outputDir) => installRequirements(path.join(functionDirectory, "function", "requirements.txt"), outputDir),
|
|
153
|
+
}, [
|
|
154
|
+
"bash",
|
|
155
|
+
"-c",
|
|
156
|
+
[
|
|
157
|
+
"set -euo pipefail",
|
|
158
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
159
|
+
"cp amplify/plugins/newsletter/function/handler.py /asset-output/handler.py",
|
|
160
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
161
|
+
"python3 -m pip install -r amplify/plugins/newsletter/function/requirements.txt -t /asset-output --no-cache-dir",
|
|
162
|
+
].join(" && "),
|
|
163
|
+
]);
|
|
164
|
+
const environment = newsletterEnvironment(config, table.tableName, keyParameter.parameterName);
|
|
165
|
+
const lambda = new LambdaFunction(stack, "NewsletterFunction", {
|
|
166
|
+
functionName: newsletterFunctionName(config.siteId),
|
|
167
|
+
runtime: Runtime.PYTHON_3_12,
|
|
168
|
+
architecture: Architecture.ARM_64,
|
|
169
|
+
handler: NEWSLETTER_HANDLER,
|
|
170
|
+
// Starting a send pages the whole list and queues it, so this is longer than a signup needs.
|
|
171
|
+
timeout: Duration.seconds(120),
|
|
172
|
+
memorySize: 512,
|
|
173
|
+
description: `Newsletter for ${config.siteId}: subscriptions, roundup drafts, approval and starting sends`,
|
|
174
|
+
environment,
|
|
175
|
+
code,
|
|
176
|
+
});
|
|
177
|
+
table.grantReadWriteData(lambda); // includes the table key's encrypt and decrypt
|
|
178
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameter.parameterArn] }));
|
|
179
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
180
|
+
effect: Effect.ALLOW,
|
|
181
|
+
actions: ["ses:SendEmail", "ses:SendRawEmail"],
|
|
182
|
+
resources: newsletterSesResources(stack, config),
|
|
183
|
+
conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
|
|
184
|
+
}));
|
|
185
|
+
wireSending({
|
|
186
|
+
stack,
|
|
187
|
+
config,
|
|
188
|
+
context,
|
|
189
|
+
backend,
|
|
190
|
+
table,
|
|
191
|
+
lambda,
|
|
192
|
+
environment,
|
|
193
|
+
keyParameterArn: keyParameter.parameterArn,
|
|
194
|
+
sesResources: newsletterSesResources(stack, config),
|
|
195
|
+
code,
|
|
196
|
+
});
|
|
197
|
+
// The SES configuration set's own sending switch, for the alarm pause. Nothing else of SES is writable.
|
|
198
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
199
|
+
effect: Effect.ALLOW,
|
|
200
|
+
actions: ["ses:PutConfigurationSetSendingOptions"],
|
|
201
|
+
resources: [newsletterSesResources(stack, config)[1]],
|
|
202
|
+
}));
|
|
203
|
+
// Lifting a suppression after a deliberate re-subscribe. IAM has no resource-level scope for the account list.
|
|
204
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ses:DeleteSuppressedDestination"], resources: ["*"] }));
|
|
205
|
+
// SES delivery events: the topic belongs to the sending stack and is named literally.
|
|
206
|
+
const eventTopic = Topic.fromTopicArn(stack, "SesEventTopic", `arn:${stack.partition}:sns:${stack.region}:${stack.account}:${config.eventTopicName}`);
|
|
207
|
+
eventTopic.addSubscription(new LambdaSubscription(lambda));
|
|
208
|
+
// Bounce and complaint rate alarms over the CloudWatch event destination, 24 h period, at least 50 sends.
|
|
209
|
+
const alarmTopic = new Topic(stack, "AlarmTopic", { topicName: newsletterAlarmTopicName(config.siteId) });
|
|
210
|
+
alarmTopic.addSubscription(new LambdaSubscription(lambda));
|
|
211
|
+
const metric = (metricName) => new Metric({
|
|
212
|
+
namespace: "AWS/SES",
|
|
213
|
+
metricName,
|
|
214
|
+
dimensionsMap: { "ses:configuration-set": config.configurationSet },
|
|
215
|
+
statistic: "Sum",
|
|
216
|
+
period: Duration.hours(24),
|
|
217
|
+
});
|
|
218
|
+
const names = newsletterAlarmNames(config.siteId);
|
|
219
|
+
for (const [id, alarmName, metricName, threshold] of [
|
|
220
|
+
["BounceRateAlarm", names.bounce, "Bounce", BOUNCE_RATE_THRESHOLD],
|
|
221
|
+
["ComplaintRateAlarm", names.complaint, "Complaint", COMPLAINT_RATE_THRESHOLD],
|
|
222
|
+
]) {
|
|
223
|
+
const rate = new MathExpression({
|
|
224
|
+
expression: `IF(sent >= ${ALARM_MINIMUM_SENDS}, FILL(events, 0) / sent, 0)`,
|
|
225
|
+
usingMetrics: { sent: metric("Send"), events: metric(metricName) },
|
|
226
|
+
period: Duration.hours(24),
|
|
227
|
+
label: `${metricName} rate`,
|
|
228
|
+
});
|
|
229
|
+
const alarm = new Alarm(stack, id, {
|
|
230
|
+
alarmName,
|
|
231
|
+
alarmDescription: `${metricName} rate above ${threshold * 100}% of sends in 24 hours (at least ${ALARM_MINIMUM_SENDS} sends): pauses ${config.siteId} newsletter sending`,
|
|
232
|
+
metric: rate,
|
|
233
|
+
threshold,
|
|
234
|
+
evaluationPeriods: 1,
|
|
235
|
+
comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
236
|
+
treatMissingData: TreatMissingData.NOT_BREACHING,
|
|
237
|
+
});
|
|
238
|
+
alarm.addAlarmAction(new SnsAction(alarmTopic));
|
|
239
|
+
}
|
|
240
|
+
// The publish path tells this function about a real publish. The ARN is spelled out (not referenced) so the content-actions
|
|
241
|
+
// stack does not depend on this one, and the grant is for this one function only.
|
|
242
|
+
const contentActions = context.contentActionsLambda;
|
|
243
|
+
contentActions.addEnvironment(NEWSLETTER_FUNCTION_ENV, newsletterFunctionName(config.siteId));
|
|
244
|
+
contentActions.addToRolePolicy(new PolicyStatement({
|
|
245
|
+
effect: Effect.ALLOW,
|
|
246
|
+
actions: ["lambda:InvokeFunction"],
|
|
247
|
+
resources: [functionArn(Stack.of(contentActions), newsletterFunctionName(config.siteId))],
|
|
248
|
+
}));
|
|
249
|
+
lambda.addPermission("InvokeFromCmsCompute", {
|
|
250
|
+
principal: new ArnPrincipal(config.computeRoleArn),
|
|
251
|
+
action: "lambda:InvokeFunction",
|
|
252
|
+
});
|
|
253
|
+
backend.addOutput({ custom: { [NEWSLETTER_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
function installRequirements(requirementsPath, outputDir) {
|
|
257
|
+
// Same wheels and platform as the other Python functions: a Lambda on arm64 with CPython 3.12.
|
|
258
|
+
execFileSync("python3", [
|
|
259
|
+
"-m", "pip", "install", "-r", requirementsPath, "-t", outputDir, "--no-cache-dir",
|
|
260
|
+
"--platform", "manylinux2014_aarch64", "--implementation", "cp", "--python-version", "3.12",
|
|
261
|
+
"--only-binary=:all:", "--ignore-requires-python",
|
|
262
|
+
], { stdio: "inherit" });
|
|
263
|
+
}
|