@anthusai/papyrus 1.0.0-next.31 → 1.0.0-next.33

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.
@@ -287,6 +287,8 @@ const schema = a.schema({
287
287
  actorLabel: a.string(),
288
288
  note: a.string(),
289
289
  reasonCode: a.string(),
290
+ decisionRelationId: a.id(),
291
+ shareable: a.boolean(),
290
292
  })
291
293
  .returns(a.ref("ReferenceCurationActionResult"))
292
294
  .authorization((allow) => [
@@ -312,6 +312,8 @@ const schema = a.schema({
312
312
  actorLabel: a.string(),
313
313
  note: a.string(),
314
314
  reasonCode: a.string(),
315
+ decisionRelationId: a.id(),
316
+ shareable: a.boolean(),
315
317
  })
316
318
  .returns(a.ref("ReferenceCurationActionResult"))
317
319
  .authorization((allow) => [
@@ -245,6 +245,8 @@ export const schema = a.schema({
245
245
  actorLabel: a.string(),
246
246
  note: a.string(),
247
247
  reasonCode: a.string(),
248
+ decisionRelationId: a.id(),
249
+ shareable: a.boolean(),
248
250
  })
249
251
  .returns(a.ref("ReferenceCurationActionResult"))
250
252
  .authorization((allow) => [
@@ -268,6 +268,8 @@ export const schema = a.schema({
268
268
  actorLabel: a.string(),
269
269
  note: a.string(),
270
270
  reasonCode: a.string(),
271
+ decisionRelationId: a.id(),
272
+ shareable: a.boolean(),
271
273
  })
272
274
  .returns(a.ref("ReferenceCurationActionResult"))
273
275
  .authorization((allow) => [
@@ -4,6 +4,7 @@ import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
6
  import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
+ import { referenceCurationMetadata } from "../shared/reference-curation-metadata";
7
8
 
8
9
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
9
10
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -225,14 +226,15 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
225
226
  { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
226
227
  "metadata",
227
228
  "metadata",
228
- {
229
+ referenceCurationMetadata({
229
230
  action,
230
231
  curationStatus: nextStatus,
231
232
  reasonCode,
232
- curationReasonCode: reasonCode,
233
233
  referenceId,
234
234
  referenceLineageId,
235
- },
235
+ decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
236
+ shareable: event.arguments.shareable ?? null,
237
+ }),
236
238
  { filename: "metadata.json", now },
237
239
  );
238
240
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Metadata stored with an editor's reference curation Message. When the
3
+ * relevance cyclotron decided the reference, the review records which
4
+ * decision it answered and whether its explanation may be shared (for the
5
+ * Cyclotron marketing recording). Both are optional.
6
+ */
7
+ export type ReferenceCurationMetadataInput = {
8
+ action: string
9
+ curationStatus: string
10
+ reasonCode: string | null
11
+ referenceId: string
12
+ referenceLineageId: string
13
+ decisionRelationId?: string | null
14
+ shareable?: boolean | null
15
+ }
16
+
17
+ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
18
+ const decisionRelationId = input.decisionRelationId?.trim() || null
19
+ return {
20
+ action: input.action,
21
+ curationStatus: input.curationStatus,
22
+ reasonCode: input.reasonCode,
23
+ curationReasonCode: input.reasonCode,
24
+ referenceId: input.referenceId,
25
+ referenceLineageId: input.referenceLineageId,
26
+ ...(decisionRelationId ? {decisionRelationId} : {}),
27
+ ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
28
+ }
29
+ }
@@ -0,0 +1,86 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
6
+ /**
7
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
8
+ * the logical ids of the live p.apyr.us stack stay unchanged.
9
+ */
10
+ export function addInboundEmailSesIntake(scope, props) {
11
+ if (props.domainIdentity) {
12
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
13
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
14
+ hostedZoneId,
15
+ zoneName: hostedZoneName,
16
+ });
17
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
18
+ onCreate: {
19
+ service: "SES",
20
+ action: "verifyDomainIdentity",
21
+ parameters: { Domain: domain },
22
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
23
+ },
24
+ onUpdate: {
25
+ service: "SES",
26
+ action: "verifyDomainIdentity",
27
+ parameters: { Domain: domain },
28
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
29
+ },
30
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
31
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
32
+ }),
33
+ timeout: Duration.minutes(2),
34
+ });
35
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
36
+ zone,
37
+ recordName: `_amazonses.${recordName}`,
38
+ ttl: Duration.minutes(5),
39
+ values: [verifyDomain.getResponseField("VerificationToken")],
40
+ });
41
+ }
42
+ const ruleSet = props.existingRuleSetName
43
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
44
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
45
+ receiptRuleSetName: props.ruleSetName,
46
+ });
47
+ ruleSet.addRule("PapyrusInboundSubmissions", {
48
+ receiptRuleName: props.ruleName,
49
+ recipients: props.recipients,
50
+ enabled: true,
51
+ scanEnabled: true,
52
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
53
+ actions: [
54
+ new sesActions.S3({
55
+ bucket: props.storageBucket,
56
+ objectKeyPrefix: "inbound-email/",
57
+ }),
58
+ ],
59
+ });
60
+ if (props.activateRuleSet && !props.existingRuleSetName) {
61
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
62
+ onCreate: {
63
+ service: "SES",
64
+ action: "setActiveReceiptRuleSet",
65
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
66
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
67
+ },
68
+ onUpdate: {
69
+ service: "SES",
70
+ action: "setActiveReceiptRuleSet",
71
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
72
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
73
+ },
74
+ onDelete: {
75
+ service: "SES",
76
+ action: "setActiveReceiptRuleSet",
77
+ parameters: { RuleSetName: null },
78
+ },
79
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
80
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
81
+ }),
82
+ timeout: Duration.minutes(2),
83
+ });
84
+ }
85
+ return { ruleSet };
86
+ }
@@ -0,0 +1,110 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import type * as s3 from "aws-cdk-lib/aws-s3";
6
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
7
+ import type { Construct } from "constructs";
8
+
9
+ export type InboundEmailSesIntakeProps = {
10
+ storageBucket: s3.IBucket;
11
+ recipients: string[];
12
+ ruleSetName: string;
13
+ /** Adds the rule to a rule set another stack owns instead of creating one. */
14
+ existingRuleSetName?: string;
15
+ /** `undefined` lets CloudFormation generate the name (the live p.apyr.us rule). */
16
+ ruleName?: string;
17
+ /** Only the legacy p.apyr.us pipeline sets this; a new site never activates a rule set. */
18
+ activateRuleSet: boolean;
19
+ domainIdentity?: {
20
+ domain: string;
21
+ hostedZoneId: string;
22
+ hostedZoneName: string;
23
+ recordName: string;
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
29
+ * the logical ids of the live p.apyr.us stack stay unchanged.
30
+ */
31
+ export function addInboundEmailSesIntake(scope: Construct, props: InboundEmailSesIntakeProps) {
32
+ if (props.domainIdentity) {
33
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
34
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
35
+ hostedZoneId,
36
+ zoneName: hostedZoneName,
37
+ });
38
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
39
+ onCreate: {
40
+ service: "SES",
41
+ action: "verifyDomainIdentity",
42
+ parameters: { Domain: domain },
43
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
44
+ },
45
+ onUpdate: {
46
+ service: "SES",
47
+ action: "verifyDomainIdentity",
48
+ parameters: { Domain: domain },
49
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
50
+ },
51
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
52
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
53
+ }),
54
+ timeout: Duration.minutes(2),
55
+ });
56
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
57
+ zone,
58
+ recordName: `_amazonses.${recordName}`,
59
+ ttl: Duration.minutes(5),
60
+ values: [verifyDomain.getResponseField("VerificationToken")],
61
+ });
62
+ }
63
+
64
+ const ruleSet = props.existingRuleSetName
65
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
66
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
67
+ receiptRuleSetName: props.ruleSetName,
68
+ });
69
+ ruleSet.addRule("PapyrusInboundSubmissions", {
70
+ receiptRuleName: props.ruleName,
71
+ recipients: props.recipients,
72
+ enabled: true,
73
+ scanEnabled: true,
74
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
75
+ actions: [
76
+ new sesActions.S3({
77
+ bucket: props.storageBucket,
78
+ objectKeyPrefix: "inbound-email/",
79
+ }),
80
+ ],
81
+ });
82
+
83
+ if (props.activateRuleSet && !props.existingRuleSetName) {
84
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
85
+ onCreate: {
86
+ service: "SES",
87
+ action: "setActiveReceiptRuleSet",
88
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
89
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
90
+ },
91
+ onUpdate: {
92
+ service: "SES",
93
+ action: "setActiveReceiptRuleSet",
94
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
95
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
96
+ },
97
+ onDelete: {
98
+ service: "SES",
99
+ action: "setActiveReceiptRuleSet",
100
+ parameters: { RuleSetName: null },
101
+ },
102
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
103
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
104
+ }),
105
+ timeout: Duration.minutes(2),
106
+ });
107
+ }
108
+
109
+ return { ruleSet };
110
+ }
@@ -32,6 +32,17 @@ export type PapyrusSiteBackendConfig = {
32
32
  stagingBuild?: { enabled: boolean };
33
33
  /** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
34
34
  revalidateBaseUrl?: string;
35
+ /**
36
+ * SES receipt options for a site that is not the legacy p.apyr.us backend.
37
+ * By default the site gets its own brand-named rule set that is never
38
+ * activated by CloudFormation (SES allows one active rule set per region).
39
+ */
40
+ inboundEmailSes?: {
41
+ /** Add this site's rule to a rule set another stack owns instead of creating one. */
42
+ existingReceiptRuleSetName?: string;
43
+ /** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
44
+ manageDomainIdentity?: boolean;
45
+ };
35
46
  features?: {
36
47
  consoleResponder?: boolean;
37
48
  inboundEmail?: boolean;
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sanitizeAwsName(value, maxLength = 50) {
3
+ return value
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]/g, "-")
6
+ .replace(/-+/g, "-")
7
+ .replace(/^-|-$/g, "")
8
+ .slice(0, maxLength);
9
+ }
10
+ export function readBooleanEnvFlag(environment, name, defaultValue) {
11
+ const raw = (environment[name] ?? "").trim().toLowerCase();
12
+ if (["1", "true", "yes", "on"].includes(raw))
13
+ return true;
14
+ if (["0", "false", "no", "off"].includes(raw))
15
+ return false;
16
+ return defaultValue;
17
+ }
18
+ /**
19
+ * True only for the Amplify app that owns the original p.apyr.us backend. That
20
+ * backend keeps its historical account-global names so its live resources are
21
+ * never replaced. Every other site gets brand-scoped names.
22
+ */
23
+ export function isLegacyProductionApp(identity) {
24
+ return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
25
+ }
26
+ export function isLegacyProductionPipeline(identity) {
27
+ return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
28
+ }
29
+ export function resolveSiteBackendFeatureFlags(features, environment, identity) {
30
+ const legacyPipeline = isLegacyProductionPipeline(identity);
31
+ return {
32
+ consoleResponder: features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
33
+ inboundEmail: features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
34
+ slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
35
+ storageBackups: features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
36
+ };
37
+ }
38
+ export function deriveKnowledgeVectorIndexName(identity) {
39
+ return isLegacyProductionApp(identity)
40
+ ? "papyrus-knowledge"
41
+ : `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
42
+ }
43
+ export function deriveStorageBackupVaultName(identity, backupsStackName, explicitName) {
44
+ const explicit = (explicitName ?? "").trim();
45
+ if (explicit !== "")
46
+ return sanitizeAwsName(explicit);
47
+ if (isLegacyProductionPipeline(identity)) {
48
+ return sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`);
49
+ }
50
+ const stackHash = createHash("sha256").update(backupsStackName).digest("hex").slice(0, 8);
51
+ return `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${stackHash}-media-vault`;
52
+ }
53
+ /**
54
+ * The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
55
+ * colliding with the legacy `papyrus-inbound-p-apyr-us` set.
56
+ * SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
57
+ * its own brand-named rule set and never activates it from CloudFormation.
58
+ */
59
+ export function deriveReceiptRuleSetName(identity, inboundEmailDomain) {
60
+ if (isLegacyProductionApp(identity)) {
61
+ return `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`;
62
+ }
63
+ return `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
64
+ }
65
+ /** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
66
+ export function deriveReceiptRuleName(identity) {
67
+ if (isLegacyProductionApp(identity))
68
+ return undefined;
69
+ return `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`;
70
+ }
71
+ export function planInboundEmailSes(identity, inboundEmailEnabled, options) {
72
+ const legacyPipeline = isLegacyProductionPipeline(identity);
73
+ const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
74
+ const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
75
+ return {
76
+ createReceiptRules,
77
+ activateReceiptRuleSet: createReceiptRules && legacyPipeline,
78
+ manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
79
+ };
80
+ }
81
+ export function deriveSiteGlobalResourceNames(identity, inboundEmailDomain, backupsStackName) {
82
+ return {
83
+ knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
84
+ storageBackupVaultName: deriveStorageBackupVaultName(identity, backupsStackName),
85
+ receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
86
+ receiptRuleName: deriveReceiptRuleName(identity),
87
+ };
88
+ }
@@ -0,0 +1,147 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type SiteBackendFeatureFlags = {
4
+ consoleResponder: boolean;
5
+ inboundEmail: boolean;
6
+ slack: boolean;
7
+ storageBackups: boolean;
8
+ };
9
+
10
+ export type SiteBackendFeatureInput = Partial<SiteBackendFeatureFlags> | undefined;
11
+
12
+ export function sanitizeAwsName(value: string, maxLength = 50): string {
13
+ return value
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "")
18
+ .slice(0, maxLength);
19
+ }
20
+
21
+ export function readBooleanEnvFlag(
22
+ environment: Record<string, string | undefined>,
23
+ name: string,
24
+ defaultValue: boolean,
25
+ ): boolean {
26
+ const raw = (environment[name] ?? "").trim().toLowerCase();
27
+ if (["1", "true", "yes", "on"].includes(raw)) return true;
28
+ if (["0", "false", "no", "off"].includes(raw)) return false;
29
+ return defaultValue;
30
+ }
31
+
32
+ export type SiteBackendIdentity = {
33
+ brandId: string;
34
+ amplifyAppId: string;
35
+ amplifyBranch: string;
36
+ productionAppId: string;
37
+ };
38
+
39
+ /**
40
+ * True only for the Amplify app that owns the original p.apyr.us backend. That
41
+ * backend keeps its historical account-global names so its live resources are
42
+ * never replaced. Every other site gets brand-scoped names.
43
+ */
44
+ export function isLegacyProductionApp(identity: SiteBackendIdentity): boolean {
45
+ return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
46
+ }
47
+
48
+ export function isLegacyProductionPipeline(identity: SiteBackendIdentity): boolean {
49
+ return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
50
+ }
51
+
52
+ export function resolveSiteBackendFeatureFlags(
53
+ features: SiteBackendFeatureInput,
54
+ environment: Record<string, string | undefined>,
55
+ identity: SiteBackendIdentity,
56
+ ): SiteBackendFeatureFlags {
57
+ const legacyPipeline = isLegacyProductionPipeline(identity);
58
+ return {
59
+ consoleResponder:
60
+ features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
61
+ inboundEmail:
62
+ features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
63
+ slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
64
+ storageBackups:
65
+ features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
66
+ };
67
+ }
68
+
69
+ export function deriveKnowledgeVectorIndexName(identity: SiteBackendIdentity): string {
70
+ return isLegacyProductionApp(identity)
71
+ ? "papyrus-knowledge"
72
+ : `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
73
+ }
74
+
75
+ export function deriveStorageBackupVaultName(
76
+ identity: SiteBackendIdentity,
77
+ backupsStackName: string,
78
+ explicitName?: string,
79
+ ): string {
80
+ const explicit = (explicitName ?? "").trim();
81
+ if (explicit !== "") return sanitizeAwsName(explicit);
82
+ if (isLegacyProductionPipeline(identity)) {
83
+ return sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`);
84
+ }
85
+ const stackHash = createHash("sha256").update(backupsStackName).digest("hex").slice(0, 8);
86
+ return `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${stackHash}-media-vault`;
87
+ }
88
+
89
+ /**
90
+ * The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
91
+ * colliding with the legacy `papyrus-inbound-p-apyr-us` set.
92
+ * SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
93
+ * its own brand-named rule set and never activates it from CloudFormation.
94
+ */
95
+ export function deriveReceiptRuleSetName(identity: SiteBackendIdentity, inboundEmailDomain: string): string {
96
+ if (isLegacyProductionApp(identity)) {
97
+ return `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`;
98
+ }
99
+ return `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
100
+ }
101
+
102
+ /** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
103
+ export function deriveReceiptRuleName(identity: SiteBackendIdentity): string | undefined {
104
+ if (isLegacyProductionApp(identity)) return undefined;
105
+ return `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`;
106
+ }
107
+
108
+ export type InboundEmailSesPlan = {
109
+ createReceiptRules: boolean;
110
+ activateReceiptRuleSet: boolean;
111
+ manageDomainIdentity: boolean;
112
+ };
113
+
114
+ export function planInboundEmailSes(
115
+ identity: SiteBackendIdentity,
116
+ inboundEmailEnabled: boolean,
117
+ options: { manageDomainIdentity?: boolean } | undefined,
118
+ ): InboundEmailSesPlan {
119
+ const legacyPipeline = isLegacyProductionPipeline(identity);
120
+ const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
121
+ const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
122
+ return {
123
+ createReceiptRules,
124
+ activateReceiptRuleSet: createReceiptRules && legacyPipeline,
125
+ manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
126
+ };
127
+ }
128
+
129
+ export type SiteGlobalResourceNames = {
130
+ knowledgeVectorIndexName: string;
131
+ storageBackupVaultName: string;
132
+ receiptRuleSetName: string;
133
+ receiptRuleName: string | undefined;
134
+ };
135
+
136
+ export function deriveSiteGlobalResourceNames(
137
+ identity: SiteBackendIdentity,
138
+ inboundEmailDomain: string,
139
+ backupsStackName: string,
140
+ ): SiteGlobalResourceNames {
141
+ return {
142
+ knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
143
+ storageBackupVaultName: deriveStorageBackupVaultName(identity, backupsStackName),
144
+ receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
145
+ receiptRuleName: deriveReceiptRuleName(identity),
146
+ };
147
+ }