@anthusai/papyrus 5.33.1 → 5.34.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.
Files changed (40) hide show
  1. package/amplify/site-backend-config.ts +1 -1
  2. package/amplify/site-backend-names.js +4 -2
  3. package/amplify/site-backend-names.ts +4 -2
  4. package/amplify/site-backend.js +1 -1
  5. package/amplify/site-backend.ts +1 -1
  6. package/components/news-desk-client-provider.tsx +18 -18
  7. package/components/news-desk-taxonomy-client.ts +28 -8
  8. package/components/newsroom-access-gate.tsx +15 -1
  9. package/components/newsroom-article-editor.tsx +4 -2
  10. package/components/newsroom-articles-view.tsx +6 -6
  11. package/components/newsroom-board-view.tsx +8 -3
  12. package/components/newsroom-ops-overview.tsx +12 -100
  13. package/components/newsroom-ops-shell.tsx +2 -2
  14. package/components/newsroom-organizer.css +2 -0
  15. package/components/newsroom-overview-skeleton.tsx +25 -0
  16. package/components/newsroom-references-view.tsx +179 -36
  17. package/components/notification-center.tsx +13 -2
  18. package/components/reader-auth-state.ts +1 -1
  19. package/components/reference-cyclotron-suggestion.tsx +9 -5
  20. package/components/reference-relevance-review.tsx +3 -3
  21. package/components/topic-steering-workspace.tsx +144 -123
  22. package/infra/amplify-app-shell.js +4 -0
  23. package/infra/amplify-app-shell.ts +4 -0
  24. package/infra/cloud-email-config.js +32 -0
  25. package/infra/cloud-email-config.ts +39 -0
  26. package/infra/cloud-email.js +57 -0
  27. package/infra/cloud-email.test.js +69 -0
  28. package/infra/cloud-email.test.ts +66 -0
  29. package/infra/cloud-email.ts +60 -0
  30. package/infra/site-config.js +4 -2
  31. package/infra/site-config.ts +7 -2
  32. package/lib/cyclotron-suggestions.ts +4 -4
  33. package/lib/news-desk-session.ts +12 -21
  34. package/lib/newsroom-organizer-nav.ts +2 -1
  35. package/lib/reference-review-details.ts +71 -0
  36. package/lib/reference-review-request.ts +14 -0
  37. package/lib/relevance-decisions.ts +42 -10
  38. package/lib/use-reference-review-data.ts +44 -0
  39. package/package.json +5 -1
  40. package/routes.manifest.json +1 -1
@@ -40,6 +40,7 @@ import {
40
40
  loadEditorReferencesData,
41
41
  loadEditorProcedureData,
42
42
  loadEditorSemanticRelationsData,
43
+ loadEditorReferenceReviewDetails,
43
44
  loadEditionForumThreads,
44
45
  loadNewsroomAssignmentPage,
45
46
  loadNewsroomMessagePage,
@@ -167,6 +168,9 @@ import {
167
168
  } from "../lib/reference-policy";
168
169
  import { isLocalPreviewEnabled } from "../lib/local-preview";
169
170
  import { currentRelevanceDecisions } from "../lib/relevance-decisions";
171
+ import { runReferenceReviewRequest } from "../lib/reference-review-request";
172
+ import { useReferenceReviewData } from "../lib/use-reference-review-data";
173
+ import { latestReferenceReviewReasons, latestReferenceReviewSections } from "../lib/reference-review-details";
170
174
  import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
171
175
  import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
172
176
  import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
@@ -670,7 +674,7 @@ export function NewsDeskWorkspace({
670
674
  );
671
675
  }
672
676
 
673
- if (session && sessionDashboard) {
677
+ if (session && sessionDashboard && session.shell.phase !== "loadingDesk") {
674
678
  return (
675
679
  <NewsDeskDashboard
676
680
  analysisProfiles={analysisProfiles}
@@ -902,6 +906,11 @@ function NewsDeskDashboard({
902
906
  const [referencesRealtimeStatus, setReferencesRealtimeStatus] = useState<RealtimeSubscriptionStatus>("idle");
903
907
  const [referencesRealtimeError, setReferencesRealtimeError] = useState<string | null>(null);
904
908
  const referencesRef = useRef(references);
909
+ const referenceReviewsPending = useRef(new Map<string, Promise<void>>());
910
+ const [selectedReviewRead, setSelectedReviewRead] = useState<{ lineageId: string; state: "loading" | "ready" | "error"; error: string | null }>({ lineageId: "", state: "ready", error: null });
911
+ const [selectedReviewAttempt, setSelectedReviewAttempt] = useState(0);
912
+ const reviewReasons = useMemo(() => latestReferenceReviewReasons(messages), [messages]);
913
+ const reviewSectionIds = useMemo(() => latestReferenceReviewSections(messages), [messages]);
905
914
 
906
915
  useEffect(() => {
907
916
  referencesRef.current = references;
@@ -1231,17 +1240,6 @@ function NewsDeskDashboard({
1231
1240
  if (dashboard.isDemo) return;
1232
1241
  let active = true;
1233
1242
 
1234
- if (activeTab === "messages" && !loadedSections.messages) {
1235
- setLoadedSections((current) => ({ ...current, messages: true }));
1236
- void loadEditorMessagesData()
1237
- .then((nextMessages) => {
1238
- if (active) setMessages(nextMessages);
1239
- })
1240
- .catch((error) => {
1241
- if (active) setActionState({ id: "messages-load", message: error instanceof Error ? error.message : "messages load failed", tone: "error" });
1242
- });
1243
- }
1244
-
1245
1243
  if (activeTab === "assignments" && !loadedSections.assignments) {
1246
1244
  setLoadedSections((current) => ({ ...current, assignments: true }));
1247
1245
  void loadEditorAssignmentsData()
@@ -1269,23 +1267,6 @@ function NewsDeskDashboard({
1269
1267
  });
1270
1268
  }
1271
1269
 
1272
- if ((activeTab === "assignments" || activeTab === "references" || activeTab === "messages") && !loadedSections.semanticRelations) {
1273
- setLoadedSections((current) => ({ ...current, semanticRelations: true }));
1274
- void loadEditorSemanticRelationsData()
1275
- .then((relations) => {
1276
- if (active) setSemanticRelations(relations);
1277
- })
1278
- .catch(() => {
1279
- if (active) setSemanticRelations([]);
1280
- });
1281
- if (!loadedSections.messages) {
1282
- setLoadedSections((current) => ({ ...current, messages: true }));
1283
- void loadEditorMessagesData().then((nextMessages) => {
1284
- if (active) setMessages(nextMessages);
1285
- }).catch(() => undefined);
1286
- }
1287
- }
1288
-
1289
1270
  if ((activeTab === "topics" || activeTab === "desks" || activeTab === "concepts" || activeTab === "administration") && !loadedSections.fullDashboard) {
1290
1271
  setLoadedSections((current) => ({ ...current, fullDashboard: true }));
1291
1272
  void loadEditorFullNewsDeskDashboard({ isAdmin: Boolean(dashboard.canManageUsers) })
@@ -1340,6 +1321,37 @@ function NewsDeskDashboard({
1340
1321
  refreshNewsroomSections,
1341
1322
  ]);
1342
1323
 
1324
+ const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1325
+ const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1326
+ const referenceReviewData = useReferenceReviewData({
1327
+ enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1328
+ loadRelations: loadEditorSemanticRelationsData,
1329
+ loadMessages: loadEditorMessagesData,
1330
+ onRelations: setSemanticRelations,
1331
+ onMessages: setMessages,
1332
+ });
1333
+
1334
+ useEffect(() => {
1335
+ if (!canLoadPrivateCorpus || activeTab !== "references" || !selectedReviewLineageId) return;
1336
+ let active = true;
1337
+ setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "loading", error: null });
1338
+ void loadEditorReferenceReviewDetails(selectedReviewLineageId).then(({ relations, messages: history }) => {
1339
+ if (!active) return;
1340
+ setSemanticRelations((current) => [...current.filter((relation) => relation.subjectLineageId !== selectedReviewLineageId || relation.subjectKind !== "reference"), ...relations]);
1341
+ setMessages((current) => [...current.filter((message) => !history.some((entry) => entry.id === message.id)), ...history]);
1342
+ setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "ready", error: null });
1343
+ }).catch((error) => {
1344
+ if (active) setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "error", error: error instanceof Error ? error.message : "Reference review details could not be loaded." });
1345
+ });
1346
+ return () => { active = false; };
1347
+ }, [activeTab, canLoadPrivateCorpus, selectedReviewLineageId, selectedReviewAttempt]);
1348
+ const selectedReviewIsCurrent = selectedReviewLineageId && !dashboard.isDemo;
1349
+ const predictionState = selectedReviewIsCurrent
1350
+ ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1351
+ : referenceReviewData.state;
1352
+ const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1353
+ const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1354
+
1343
1355
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
1344
1356
  const { nextRecords, nextRecord, previousRecord } = upsertReferenceRecords(referencesRef.current, nextReference);
1345
1357
  referencesRef.current = nextRecords;
@@ -1594,96 +1606,98 @@ function NewsDeskDashboard({
1594
1606
  reasonCode?: ReferenceRejectionReasonCode | null,
1595
1607
  relevance?: { decisionRelationId: string; shareable: boolean },
1596
1608
  sectionId?: string | null,
1597
- ) {
1598
- const nextStatus = referenceCurationStatusForAction(action);
1599
- setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1600
- if (dashboard.isDemo) {
1601
- const now = new Date().toISOString();
1602
- const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
1603
- const relationId = `semantic-relation-demo-${messageId}`;
1604
- applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1605
- actorLabel: authState.label,
1606
- nextStatus,
1607
- note: note ?? null,
1608
- now,
1609
- }));
1610
- setMessages((current) => [{
1611
- id: messageId,
1612
- messageKind: "reference_curation",
1613
- messageDomain: "commentary",
1614
- status: "active",
1615
- body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
1616
- summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
1617
- source: "newsroom",
1618
- authorLabel: authState.label,
1619
- createdAt: now,
1620
- updatedAt: now,
1621
- metadata: {
1622
- action,
1623
- curationStatus: nextStatus,
1624
- reasonCode: action === "reject" ? reasonCode ?? null : null,
1625
- ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1626
- ...(action === "accept" && sectionId ? { sectionId } : {}),
1627
- },
1628
- }, ...current]);
1629
- setSemanticRelations((current) => [{
1630
- id: relationId,
1631
- relationState: "current",
1632
- predicate: "comment",
1633
- relationTypeId: "semantic-relation-type-comment",
1634
- relationTypeKey: "comment",
1635
- relationDomain: "commentary",
1636
- subjectKind: "message",
1637
- subjectId: messageId,
1638
- subjectLineageId: messageId,
1639
- subjectVersionNumber: 1,
1640
- objectKind: "reference",
1641
- objectId: reference.id,
1642
- objectLineageId: reference.lineageId ?? reference.id,
1643
- objectVersionNumber: reference.versionNumber ?? null,
1644
- subjectStateKey: `message#${messageId}#current`,
1645
- objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
1646
- objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
1647
- predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
1648
- subjectVersionKey: `message#${messageId}`,
1649
- objectVersionKey: `reference#${reference.id}`,
1650
- rank: 1,
1651
- reviewRecommended: false,
1652
- importedAt: now,
1653
- }, ...current]);
1654
- setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1655
- return;
1656
- }
1657
- startTransition(() => {
1658
- void (async () => {
1659
- try {
1660
- const response = await dataClient.mutations.reviewReferenceCuration(
1661
- {
1662
- referenceId: reference.id,
1663
- action,
1664
- actorLabel: authState.label,
1665
- note: note?.trim() || undefined,
1666
- reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1667
- decisionRelationId: relevance?.decisionRelationId,
1668
- shareable: relevance ? relevance.shareable : undefined,
1669
- sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1670
- },
1671
- { authMode: USER_POOL_AUTH_MODE },
1672
- );
1673
- const review = assertReferenceReviewMutationSucceeded(response, reference.id);
1674
- const status = review.status ?? nextStatus;
1675
- const now = new Date().toISOString();
1676
- applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1609
+ ): Promise<void> {
1610
+ return runReferenceReviewRequest(referenceReviewsPending.current, reference.lineageId ?? reference.id, async () => {
1611
+ if (!canEdit) throw new Error("Your session does not have permission to review references.");
1612
+ const nextStatus = referenceCurationStatusForAction(action);
1613
+ setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1614
+ if (dashboard.isDemo) {
1615
+ const now = new Date().toISOString();
1616
+ const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
1617
+ const relationId = `semantic-relation-demo-${messageId}`;
1618
+ applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1619
+ actorLabel: authState.label,
1620
+ nextStatus,
1621
+ note: note ?? null,
1622
+ now,
1623
+ }));
1624
+ setMessages((current) => [{
1625
+ id: messageId,
1626
+ messageKind: "reference_curation",
1627
+ messageDomain: "commentary",
1628
+ status: "active",
1629
+ body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
1630
+ summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
1631
+ source: "newsroom",
1632
+ authorLabel: authState.label,
1633
+ createdAt: now,
1634
+ updatedAt: now,
1635
+ metadata: {
1636
+ referenceLineageId: reference.lineageId ?? reference.id,
1637
+ action,
1638
+ curationStatus: nextStatus,
1639
+ reasonCode: action === "reject" ? reasonCode ?? null : null,
1640
+ ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1641
+ ...(action === "accept" && sectionId ? { sectionId } : {}),
1642
+ },
1643
+ }, ...current]);
1644
+ setSemanticRelations((current) => [{
1645
+ id: relationId,
1646
+ relationState: "current",
1647
+ predicate: "comment",
1648
+ relationTypeId: "semantic-relation-type-comment",
1649
+ relationTypeKey: "comment",
1650
+ relationDomain: "commentary",
1651
+ subjectKind: "message",
1652
+ subjectId: messageId,
1653
+ subjectLineageId: messageId,
1654
+ subjectVersionNumber: 1,
1655
+ objectKind: "reference",
1656
+ objectId: reference.id,
1657
+ objectLineageId: reference.lineageId ?? reference.id,
1658
+ objectVersionNumber: reference.versionNumber ?? null,
1659
+ subjectStateKey: `message#${messageId}#current`,
1660
+ objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
1661
+ objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
1662
+ predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
1663
+ subjectVersionKey: `message#${messageId}`,
1664
+ objectVersionKey: `reference#${reference.id}`,
1665
+ rank: 1,
1666
+ reviewRecommended: false,
1667
+ importedAt: now,
1668
+ }, ...current]);
1669
+ setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1670
+ return;
1671
+ }
1672
+ try {
1673
+ const response = await dataClient.mutations.reviewReferenceCuration(
1674
+ {
1675
+ referenceId: reference.id,
1676
+ action,
1677
1677
  actorLabel: authState.label,
1678
- nextStatus: status,
1679
- note: note?.trim() || null,
1680
- now,
1681
- }));
1682
- setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1683
- } catch (error) {
1684
- setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
1685
- }
1686
- })();
1678
+ note: note?.trim() || undefined,
1679
+ reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1680
+ decisionRelationId: relevance?.decisionRelationId,
1681
+ shareable: relevance ? relevance.shareable : undefined,
1682
+ sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1683
+ },
1684
+ { authMode: USER_POOL_AUTH_MODE },
1685
+ );
1686
+ const review = assertReferenceReviewMutationSucceeded(response, reference.id);
1687
+ const status = review.status ?? nextStatus;
1688
+ const now = new Date().toISOString();
1689
+ applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1690
+ actorLabel: authState.label,
1691
+ nextStatus: status,
1692
+ note: note?.trim() || null,
1693
+ now,
1694
+ }));
1695
+ setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1696
+ retryReferenceReviewData();
1697
+ } catch (error) {
1698
+ setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
1699
+ throw error;
1700
+ }
1687
1701
  });
1688
1702
  }
1689
1703
 
@@ -3067,7 +3081,6 @@ function NewsDeskDashboard({
3067
3081
  decisions={relevanceDecisions}
3068
3082
  demo={Boolean(dashboard.isDemo)}
3069
3083
  humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3070
- messages={messages}
3071
3084
  newsroomSections={newsroomSections}
3072
3085
  references={references}
3073
3086
  />
@@ -3127,7 +3140,7 @@ function NewsDeskDashboard({
3127
3140
  <NewsroomReferencesView
3128
3141
  demo={Boolean(dashboard.isDemo)}
3129
3142
  disabled={controlsDisabled}
3130
- initialReferenceLineageId={initialSelection.reference ?? pathnameReferenceLineageId}
3143
+ initialReferenceLineageId={selectedReviewLineageId}
3131
3144
  cyclotronStatus={cyclotronStatus}
3132
3145
  cyclotronPriming={{
3133
3146
  labels: primingLabels,
@@ -3139,8 +3152,8 @@ function NewsDeskDashboard({
3139
3152
  },
3140
3153
  }}
3141
3154
  decisions={relevanceDecisions}
3142
- humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3143
- delegatedCurations={delegatedCurations}
3155
+ humanCuratedReferenceLineageIds={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : humanCuratedReferenceLineageIds}
3156
+ delegatedCurations={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : delegatedCurations}
3144
3157
  shadowAgreement={shadowAgreement}
3145
3158
  onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
3146
3159
  const request = buildReviewRateRequest(choice, authState.label);
@@ -3153,11 +3166,19 @@ function NewsDeskDashboard({
3153
3166
  curation.note ?? undefined,
3154
3167
  normalizeReferenceRejectionReasonCode(curation.reasonCode),
3155
3168
  { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3169
+ curation.sectionId,
3156
3170
  )}
3157
3171
  sections={sectionChoices}
3158
3172
  sectionSuggestions={sectionSuggestions}
3159
3173
  sectionAgreement={siteSectionAgreement}
3160
- onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, undefined, undefined, options?.sectionId)}
3174
+ predictionState={predictionState}
3175
+ predictionError={predictionError}
3176
+ onRetryPredictions={retryReferenceReviewData}
3177
+ reviewReasons={reviewReasons}
3178
+ reviewSectionIds={reviewSectionIds}
3179
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
3180
+ options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined,
3181
+ options?.sectionId)}
3161
3182
  referenceAttachments={referenceAttachments}
3162
3183
  references={references}
3163
3184
  />
@@ -2,6 +2,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
4
  import { SecureStringParameter } from "./secure-string-parameter.js";
5
+ import { addCloudEmail } from "./cloud-email.js";
6
+ import { parseCloudEmail } from "./cloud-email-config.js";
5
7
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
8
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
7
9
  import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
@@ -74,6 +76,7 @@ export class AmplifyAppShellStack extends Stack {
74
76
  return;
75
77
  }
76
78
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
79
+ const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
77
80
  const stagingEnabled = isStagingEnabled(config);
78
81
  const stagingDomainName = resolveStagingDomainName(config);
79
82
  const cmsHostName = resolveCmsHostName(config);
@@ -195,6 +198,7 @@ export class AmplifyAppShellStack extends Stack {
195
198
  enablePerformanceMode: false,
196
199
  environmentVariables: environmentVariables({
197
200
  ...config.cms.environment,
201
+ ...emailEnvironment,
198
202
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
199
203
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
200
204
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -3,6 +3,8 @@ import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-l
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { SecureStringParameter } from "./secure-string-parameter";
6
+ import { addCloudEmail } from "./cloud-email";
7
+ import { parseCloudEmail } from "./cloud-email-config";
6
8
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
9
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
8
10
  import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
@@ -79,6 +81,7 @@ export class AmplifyAppShellStack extends Stack {
79
81
  }
80
82
 
81
83
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
84
+ const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
82
85
  const stagingEnabled = isStagingEnabled(config);
83
86
  const stagingDomainName = resolveStagingDomainName(config);
84
87
  const cmsHostName = resolveCmsHostName(config);
@@ -217,6 +220,7 @@ export class AmplifyAppShellStack extends Stack {
217
220
  enablePerformanceMode: false,
218
221
  environmentVariables: environmentVariables({
219
222
  ...config.cms.environment,
223
+ ...emailEnvironment,
220
224
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
221
225
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
222
226
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
@@ -0,0 +1,32 @@
1
+ export function parseCloudEmail(raw, cloud, siteId) {
2
+ if (!cloud) {
3
+ if (raw !== undefined)
4
+ throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
5
+ return undefined;
6
+ }
7
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
8
+ throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
9
+ }
10
+ const value = raw;
11
+ for (const key of Object.keys(value)) {
12
+ if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
13
+ throw new Error(`site.json: email.${key} is not a known field`);
14
+ }
15
+ }
16
+ if (value.mode !== "managed" && value.mode !== "existing")
17
+ throw new Error("site.json: email.mode must be managed or existing");
18
+ const string = (key, pattern, fallback) => {
19
+ const result = value[key] ?? fallback;
20
+ if (typeof result !== "string" || !pattern.test(result))
21
+ throw new Error(`site.json: email.${key} is required and must be valid`);
22
+ return result;
23
+ };
24
+ return {
25
+ mode: value.mode,
26
+ sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
27
+ senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
28
+ configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
29
+ // Preserve the newsletter consumer's historical default; the topic now also serves notifications.
30
+ eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
31
+ };
32
+ }
@@ -0,0 +1,39 @@
1
+ /** Email belongs to a cloud site, independently of any newsletter plugin. */
2
+ export type CloudEmailConfig = {
3
+ mode: "managed" | "existing";
4
+ sendingDomain: string;
5
+ senderLocalPart: string;
6
+ configurationSet: string;
7
+ eventTopicName: string;
8
+ };
9
+
10
+ export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
11
+ if (!cloud) {
12
+ if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
13
+ return undefined;
14
+ }
15
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
16
+ throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
17
+ }
18
+ const value = raw as Record<string, unknown>;
19
+ for (const key of Object.keys(value)) {
20
+ if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
21
+ throw new Error(`site.json: email.${key} is not a known field`);
22
+ }
23
+ }
24
+ if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
25
+ const string = (key: string, pattern: RegExp, fallback?: string) => {
26
+ const result = value[key] ?? fallback;
27
+ if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
28
+ return result;
29
+ };
30
+ return {
31
+ mode: value.mode,
32
+ sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
33
+ senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
34
+ configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
35
+ // Preserve the newsletter consumer's historical default; the topic now also serves notifications.
36
+ eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
37
+ };
38
+ }
39
+
@@ -0,0 +1,57 @@
1
+ import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sns from "aws-cdk-lib/aws-sns";
5
+ /** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
6
+ export function addCloudEmail(stack, config) {
7
+ if (config.mode === "managed") {
8
+ const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
9
+ name: config.configurationSet,
10
+ reputationOptions: { reputationMetricsEnabled: true },
11
+ suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
12
+ sendingOptions: { sendingEnabled: true },
13
+ });
14
+ configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
15
+ const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
16
+ emailIdentity: config.sendingDomain,
17
+ dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
18
+ configurationSetAttributes: { configurationSetName: configurationSet.ref },
19
+ });
20
+ identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
21
+ const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
22
+ topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
23
+ topic.addToResourcePolicy(new iam.PolicyStatement({
24
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
25
+ resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
26
+ }));
27
+ const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
28
+ configurationSetName: configurationSet.ref,
29
+ eventDestination: { name: "delivery-events", enabled: true,
30
+ matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
31
+ snsDestination: { topicArn: topic.topicArn } },
32
+ });
33
+ events.node.addDependency(topic);
34
+ // These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
35
+ new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
36
+ configurationSetName: configurationSet.ref,
37
+ eventDestination: { name: "cloudwatch-metrics", enabled: true,
38
+ matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
39
+ cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
40
+ dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
41
+ });
42
+ const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
43
+ [identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
44
+ [identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
45
+ records.forEach(([name, value], index) => {
46
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
47
+ new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
48
+ });
49
+ }
50
+ const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
51
+ new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
52
+ new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
53
+ new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
54
+ return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
55
+ PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
56
+ PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
57
+ }
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { App } from "aws-cdk-lib";
3
+ import { Template } from "aws-cdk-lib/assertions";
4
+ import { AmplifyAppShellStack } from "./amplify-app-shell.js";
5
+ import { parseSiteConfig } from "./site-config.js";
6
+ const base = () => ({
7
+ siteId: "example", repository: "https://github.com/example/publication", brand: "example",
8
+ frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
9
+ cms: { cognitoDomainPrefix: "example-cms", staging: false,
10
+ environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
11
+ });
12
+ function synth(raw) {
13
+ const app = new App();
14
+ return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
15
+ env: { account: "123456789012", region: "us-east-1" },
16
+ }));
17
+ }
18
+ const checks = {
19
+ managed() {
20
+ const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
21
+ template.resourceCountIs("AWS::SES::EmailIdentity", 1);
22
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
23
+ template.resourceCountIs("AWS::Route53::RecordSet", 0);
24
+ template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
25
+ template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
26
+ template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
27
+ template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
28
+ EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
29
+ CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
30
+ DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
31
+ });
32
+ const text = JSON.stringify(template.toJSON());
33
+ assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
34
+ assert.ok(text.includes("notifications@news.example.test"));
35
+ assert.ok(text.includes("EmailDkimRecord1Name"));
36
+ assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
37
+ },
38
+ existing() {
39
+ const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
40
+ configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
41
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
42
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
43
+ template.resourceCountIs("AWS::SNS::Topic", 0);
44
+ assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
45
+ },
46
+ static() {
47
+ const raw = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
48
+ delete raw.cms;
49
+ const template = synth(raw);
50
+ template.resourceCountIs("AWS::SES::EmailIdentity", 0);
51
+ template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
52
+ template.resourceCountIs("AWS::SES::ReceiptRule", 0);
53
+ },
54
+ missing() {
55
+ assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
56
+ assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
57
+ },
58
+ };
59
+ export function runCloudEmailTests(selected) {
60
+ if (selected) {
61
+ assert.ok(checks[selected], selected);
62
+ checks[selected]();
63
+ }
64
+ else
65
+ for (const check of Object.values(checks))
66
+ check();
67
+ console.log("cloud email infrastructure: ok");
68
+ }
69
+ runCloudEmailTests(process.argv[2]);