1import * as mm from "@distilled.cloud/aws/mailmanager";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 hasAlchemyTags,
12} from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 readMailManagerTags,
16 retryWhileMailManagerConflict,
17 sameShape,
18 syncMailManagerTags,
19} from "./internal.ts";
20
21export interface RuleSetProps {
22 /**
23 * Name of the rule set. If omitted, a deterministic physical name is
24 * generated from the app, stage, and logical ID. Renames apply in place.
25 */
26 ruleSetName?: string;
27 /**
28 * The ordered list of rules evaluated against emails arriving through an
29 * ingress point associated with this rule set. Each rule pairs optional
30 * conditions with one or more actions (Drop, Relay, Archive, WriteToS3,
31 * Send, AddHeader, ReplaceRecipient, DeliverToMailbox, DeliverToQBusiness,
32 * PublishToSns, Bounce, InvokeLambda). Updates apply in place.
33 * @default []
34 */
35 rules?: mm.Rule[];
36 /**
37 * Tags applied to the rule set. Alchemy ownership tags are merged in
38 * automatically.
39 */
40 tags?: Record<string, string>;
41}
42
43export interface RuleSet extends Resource<
44 "AWS.MailManager.RuleSet",
45 RuleSetProps,
46 {
47 /** Server-assigned ID of the rule set. */
48 ruleSetId: string;
49 /** ARN of the rule set. */
50 ruleSetArn: string;
51 /** Name of the rule set. */
52 ruleSetName: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * An SES Mail Manager rule set — the ordered rules an ingress point applies
60 * to incoming email (drop, archive, write to S3, deliver, bounce, invoke
61 * Lambda, ...).
62 *
63 * All aspects (name, rules, tags) update in place.
64 * ### Creating Rule Sets
65 * **Example:** Drop Everything
66 * ```typescript
67 * import * as MailManager from "alchemy/AWS/MailManager";
68 *
69 * const ruleSet = yield* MailManager.RuleSet("Inbound", {
70 * rules: [{ Name: "DropAll", Actions: [{ Drop: {} }] }],
71 * });
72 * ```
73 *
74 * **Example:** Conditional Archive
75 * ```typescript
76 * const ruleSet = yield* MailManager.RuleSet("Inbound", {
77 * rules: [
78 * {
79 * Name: "ArchiveLarge",
80 * Conditions: [
81 * {
82 * NumberExpression: {
83 * Evaluate: { Attribute: "MESSAGE_SIZE" },
84 * Operator: "GREATER_THAN",
85 * Value: 1024,
86 * },
87 * },
88 * ],
89 * Actions: [{ Archive: { TargetArchive: archive.archiveId } }],
90 * },
91 * ],
92 * });
93 * ```
94 *
95 * ### Wiring to an Ingress Point
96 * **Example:** Rule Set + Traffic Policy + Ingress Point
97 * ```typescript
98 * const ingress = yield* MailManager.IngressPoint("Smtp", {
99 * type: "OPEN",
100 * ruleSetId: ruleSet.ruleSetId,
101 * trafficPolicyId: trafficPolicy.trafficPolicyId,
102 * });
103 * ```
104 *
105 * ### Delivering Email Events to Compute
106 * **Example:** Invoke a Lambda for Matching Mail
107 * ```typescript
108 * // Mail Manager has no EventBridge events or event-source mapping — email
109 * // events reach compute through rule actions: InvokeLambda (direct),
110 * // PublishToSns (SNS event source), or WriteToS3 (S3 event source). The
111 * // role must be assumable by ses.amazonaws.com with lambda:InvokeFunction.
112 * const ruleSet = yield* MailManager.RuleSet("Inbound", {
113 * rules: [
114 * {
115 * Name: "NotifyOnMail",
116 * Actions: [
117 * {
118 * InvokeLambda: {
119 * FunctionArn: fn.functionArn,
120 * InvocationType: "EVENT",
121 * RoleArn: invokeRole.roleArn,
122 * },
123 * },
124 * ],
125 * },
126 * ],
127 * });
128 * ```
129 *
130 * @resource
131 */
132export const RuleSet = Resource<RuleSet>("AWS.MailManager.RuleSet");
133
134export const RuleSetProvider = () =>
135 Provider.effect(
136 RuleSet,
137 Effect.gen(function* () {
138 const createName = Effect.fn(function* (
139 id: string,
140 props: { ruleSetName?: string },
141 ) {
142 return (
143 props.ruleSetName ??
144 (yield* createPhysicalName({ id, maxLength: 100 }))
145 );
146 });
147
148 const getById = (ruleSetId: string) =>
149 mm
150 .getRuleSet({ RuleSetId: ruleSetId })
151 .pipe(
152 Effect.catchTag("ResourceNotFoundException", () =>
153 Effect.succeed(undefined),
154 ),
155 );
156
157 // Rule sets have no name-keyed Get — enumerate and match. The physical
158 // name is deterministic, so this recovers identity after a lost state
159 // write.
160 const findByName = (name: string) =>
161 mm.listRuleSets.pages({}).pipe(
162 Stream.runCollect,
163 Effect.map((chunk) =>
164 Array.from(chunk)
165 .flatMap((page) => page.RuleSets ?? [])
166 .find((r) => r.RuleSetName === name),
167 ),
168 );
169
170 const observe = Effect.fn(function* (
171 output: RuleSet["Attributes"] | undefined,
172 name: string,
173 ) {
174 if (output?.ruleSetId !== undefined) {
175 const found = yield* getById(output.ruleSetId);
176 if (found !== undefined) return found;
177 }
178 const summary = yield* findByName(name);
179 if (summary?.RuleSetId === undefined) return undefined;
180 return yield* getById(summary.RuleSetId);
181 });
182
183 const toAttrs = (ruleSet: mm.GetRuleSetResponse) => ({
184 ruleSetId: ruleSet.RuleSetId,
185 ruleSetArn: ruleSet.RuleSetArn,
186 ruleSetName: ruleSet.RuleSetName,
187 });
188
189 return RuleSet.Provider.of({
190 stables: ["ruleSetId", "ruleSetArn"],
191
192 list: () =>
193 mm.listRuleSets.pages({}).pipe(
194 Stream.runCollect,
195 Effect.flatMap((chunk) =>
196 Effect.forEach(
197 Array.from(chunk)
198 .flatMap((page) => page.RuleSets ?? [])
199 .flatMap((r) =>
200 r.RuleSetId !== undefined ? [r.RuleSetId] : [],
201 ),
202 (ruleSetId) => getById(ruleSetId),
203 ),
204 ),
205 Effect.map((results) =>
206 results.flatMap((r) => (r === undefined ? [] : [toAttrs(r)])),
207 ),
208 ),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const name =
212 output?.ruleSetName ?? (yield* createName(id, olds ?? {}));
213 const ruleSet = yield* observe(output, name);
214 if (ruleSet === undefined) return undefined;
215 const attrs = toAttrs(ruleSet);
216 const tags = yield* readMailManagerTags(attrs.ruleSetArn);
217 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
218 }),
219
220 // Name and rules both update in place — no replacement triggers, so
221 // the engine's default update path applies (no diff needed).
222
223 reconcile: Effect.fn(function* ({ id, news, output, session }) {
224 const name = yield* createName(id, news);
225 const internalTags = yield* createInternalTags(id);
226 const desiredTags = { ...news.tags, ...internalTags };
227 const desiredRules = news.rules ?? [];
228
229 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
230 let ruleSet = yield* observe(output, name);
231
232 // 2. ENSURE — create if missing; a Conflict race re-observes by
233 // name instead of failing.
234 if (ruleSet === undefined) {
235 yield* session.note(`creating rule set ${name}`);
236 const created = yield* mm
237 .createRuleSet({
238 RuleSetName: name,
239 Rules: desiredRules,
240 Tags: createTagsList(desiredTags),
241 })
242 .pipe(
243 Effect.catchTag("ConflictException", () =>
244 Effect.succeed(undefined),
245 ),
246 );
247 ruleSet =
248 created !== undefined
249 ? yield* getById(created.RuleSetId)
250 : yield* observe(undefined, name);
251 }
252 if (ruleSet === undefined) {
253 return yield* Effect.fail(
254 new Error(
255 `Mail Manager rule set '${name}' not found after create`,
256 ),
257 );
258 }
259
260 // 3. SYNC — diff OBSERVED name/rules against desired; apply only
261 // the delta.
262 if (
263 ruleSet.RuleSetName !== name ||
264 !sameShape(ruleSet.Rules, desiredRules)
265 ) {
266 yield* mm.updateRuleSet({
267 RuleSetId: ruleSet.RuleSetId,
268 RuleSetName: name,
269 Rules: desiredRules,
270 });
271 }
272
273 // 3b. SYNC TAGS — against observed cloud tags.
274 yield* syncMailManagerTags(ruleSet.RuleSetArn, desiredTags);
275
276 yield* session.note(ruleSet.RuleSetId);
277 return {
278 ruleSetId: ruleSet.RuleSetId,
279 ruleSetArn: ruleSet.RuleSetArn,
280 ruleSetName: name,
281 };
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 // DeleteRuleSet is natively idempotent — deleting a nonexistent
286 // rule set succeeds (verified live by the delete probe test). A
287 // rule set stays "in use" while a deleted ingress point finishes
288 // deprovisioning — retry through the Conflict window.
289 yield* retryWhileMailManagerConflict(
290 mm.deleteRuleSet({ RuleSetId: output.ruleSetId }),
291 ).pipe(Effect.asVoid);
292 }),
293 });
294 }),
295 );