1import * as ses from "@distilled.cloud/aws/ses";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9/**
10 * Whether SES requires (`Require`) or merely prefers (`Optional`) a TLS
11 * connection from the sending mail server before accepting a message that
12 * matches this rule.
13 */
14export type ReceiptRuleTlsPolicy = ses.TlsPolicy;
15
16/**
17 * A single receipt-rule action. Pass the distilled action shape directly — one
18 * of `S3Action`, `SNSAction`, `LambdaAction`, `BounceAction`, `AddHeaderAction`,
19 * `StopAction`, `WorkmailAction`, or `ConnectAction`. Actions run in array
20 * order; resource ARNs (bucket, topic, function) are supplied by the caller.
21 */
22export type ReceiptRuleAction = ses.ReceiptAction;
23
24export interface ReceiptRuleProps {
25 /**
26 * Name of the receipt rule set this rule belongs to. Typically the
27 * `ruleSetName` output of a `SES.ReceiptRuleSet`. Changing it replaces the
28 * rule.
29 */
30 ruleSetName: string;
31 /**
32 * Name of the receipt rule. May contain letters, numbers, dashes,
33 * underscores and periods, up to 64 characters. If omitted, a deterministic
34 * physical name is generated from the app, stage, and logical ID. Changing
35 * the name replaces the rule.
36 */
37 ruleName?: string;
38 /**
39 * Whether the rule is enabled. Disabled rules are skipped during receipt
40 * processing.
41 * @default true
42 */
43 enabled?: boolean;
44 /**
45 * Whether SES scans incoming messages that match this rule for spam and
46 * viruses.
47 * @default true
48 */
49 scanEnabled?: boolean;
50 /**
51 * Whether SES accepts email over a clear text connection (`Optional`) or
52 * requires TLS (`Require`) before applying this rule.
53 * @default "Optional"
54 */
55 tlsPolicy?: ReceiptRuleTlsPolicy;
56 /**
57 * Recipient email addresses or domains this rule applies to. An empty or
58 * omitted list matches all recipients.
59 */
60 recipients?: string[];
61 /**
62 * Name of an existing rule in the same rule set after which this rule should
63 * be placed. Omit to place the rule at the top of the rule set. Rules are
64 * evaluated in order.
65 */
66 after?: string;
67 /**
68 * Ordered list of actions SES performs when a message matches this rule
69 * (deliver to S3, publish to SNS, invoke Lambda, bounce, add a header, stop
70 * processing, etc.).
71 */
72 actions?: ReceiptRuleAction[];
73}
74
75export interface ReceiptRule extends Resource<
76 "AWS.SES.ReceiptRule",
77 ReceiptRuleProps,
78 {
79 /** Name of the rule set this rule belongs to. */
80 ruleSetName: string;
81 /** Name of the receipt rule. */
82 ruleName: string;
83 },
84 never,
85 Providers
86> {}
87
88/**
89 * An Amazon SES receipt rule — a matcher plus an ordered list of actions that
90 * SES applies to inbound email received through the parent
91 * `SES.ReceiptRuleSet`.
92 *
93 * Actions are passed as the raw distilled action shapes (no marshalling): the
94 * caller supplies bucket names, topic ARNs, and function ARNs directly.
95 * ### Creating Rules
96 * **Example:** Deliver Matching Mail to S3
97 * ```typescript
98 * import * as SES from "alchemy/AWS/SES";
99 *
100 * const ruleSet = yield* SES.ReceiptRuleSet("Inbound", {});
101 * const rule = yield* SES.ReceiptRule("ToBucket", {
102 * ruleSetName: ruleSet.ruleSetName,
103 * recipients: ["support@example.com"],
104 * actions: [
105 * { S3Action: { BucketName: "my-inbound-mail" } },
106 * ],
107 * });
108 * ```
109 *
110 * **Example:** Invoke a Lambda and Add a Header
111 * ```typescript
112 * const rule = yield* SES.ReceiptRule("Process", {
113 * ruleSetName: ruleSet.ruleSetName,
114 * tlsPolicy: "Require",
115 * scanEnabled: true,
116 * actions: [
117 * { AddHeaderAction: { HeaderName: "X-Inbound", HeaderValue: "ses" } },
118 * { LambdaAction: { FunctionArn: fn.functionArn, InvocationType: "Event" } },
119 * ],
120 * });
121 * ```
122 *
123 * ### Ordering Rules
124 * **Example:** Place a Rule After Another
125 * ```typescript
126 * // A BounceAction's Sender must be a verified SES identity — SES rejects the
127 * // rule with IdentityNotVerified at create/update time otherwise.
128 * const first = yield* SES.ReceiptRule("First", {
129 * ruleSetName: ruleSet.ruleSetName,
130 * actions: [{ StopAction: { Scope: "RuleSet" } }],
131 * });
132 * const second = yield* SES.ReceiptRule("Second", {
133 * ruleSetName: ruleSet.ruleSetName,
134 * after: first.ruleName,
135 * actions: [{ BounceAction: {
136 * SmtpReplyCode: "550",
137 * Message: "Mailbox does not exist",
138 * Sender: "mailer-daemon@example.com",
139 * } }],
140 * });
141 * ```
142 *
143 * @resource
144 */
145export const ReceiptRule = Resource<ReceiptRule>("AWS.SES.ReceiptRule");
146
147/**
148 * The name of the rule immediately preceding `ruleName` in the rule set's
149 * ordered rule list, or `undefined` when the rule is first (or absent). This is
150 * the observed value of the `after` position.
151 */
152const observedPredecessor = (
153 rules: ReadonlyArray<ses.ReceiptRule>,
154 ruleName: string,
155): string | undefined => {
156 const index = rules.findIndex((rule) => rule.Name === ruleName);
157 if (index <= 0) return undefined;
158 return rules[index - 1]?.Name;
159};
160
161export const ReceiptRuleProvider = () =>
162 Provider.effect(
163 ReceiptRule,
164 Effect.gen(function* () {
165 const createName = Effect.fn(function* (
166 id: string,
167 props: Pick<ReceiptRuleProps, "ruleName">,
168 ) {
169 return (
170 props.ruleName ?? (yield* createPhysicalName({ id, maxLength: 64 }))
171 );
172 });
173
174 const describeRule = Effect.fn(function* (
175 ruleSetName: string,
176 ruleName: string,
177 ) {
178 return yield* ses
179 .describeReceiptRule({ RuleSetName: ruleSetName, RuleName: ruleName })
180 .pipe(
181 Effect.catchTags({
182 RuleDoesNotExistException: () => Effect.succeed(undefined),
183 RuleSetDoesNotExistException: () => Effect.succeed(undefined),
184 }),
185 );
186 });
187
188 const buildRule = (
189 ruleName: string,
190 props: ReceiptRuleProps,
191 ): ses.ReceiptRule => ({
192 Name: ruleName,
193 // The classic API defaults an omitted Enabled/ScanEnabled to FALSE —
194 // apply the documented defaults explicitly so an undeclared rule is
195 // enabled (a silently disabled rule bounces all inbound mail).
196 Enabled: props.enabled ?? true,
197 TlsPolicy: props.tlsPolicy,
198 Recipients: props.recipients,
199 Actions: props.actions,
200 ScanEnabled: props.scanEnabled ?? true,
201 });
202
203 return ReceiptRule.Provider.of({
204 stables: ["ruleSetName", "ruleName"],
205
206 // Rules are sub-resources keyed entirely by their parent rule set;
207 // there is no flat account-level enumeration, so nuke handles them via
208 // the parent rule set's deletion.
209 list: () => Effect.succeed([]),
210
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const ruleSetName = output?.ruleSetName ?? olds.ruleSetName;
213 if (!ruleSetName) return undefined;
214 const ruleName = output?.ruleName ?? (yield* createName(id, olds));
215 const found = yield* describeRule(ruleSetName, ruleName);
216 return found?.Rule ? { ruleSetName, ruleName } : undefined;
217 }),
218
219 diff: Effect.fn(function* ({ id, news, olds }) {
220 if (!isResolved(news)) return undefined;
221 if (news.ruleSetName !== olds.ruleSetName) {
222 return { action: "replace" } as const;
223 }
224 const oldName = yield* createName(id, olds);
225 const newName = yield* createName(id, news);
226 if (oldName !== newName) {
227 return { action: "replace" } as const;
228 }
229 }),
230
231 reconcile: Effect.fn(function* ({ id, news, output }) {
232 const ruleSetName = news.ruleSetName;
233 const ruleName = output?.ruleName ?? (yield* createName(id, news));
234 const rule = buildRule(ruleName, news);
235
236 // OBSERVE — cloud state is authoritative.
237 const observed = yield* describeRule(ruleSetName, ruleName);
238
239 // ENSURE / SYNC — a single full-replace call converges the rule's
240 // matcher and actions whether it is missing or already present.
241 if (observed?.Rule === undefined) {
242 yield* ses
243 .createReceiptRule({
244 RuleSetName: ruleSetName,
245 After: news.after,
246 Rule: rule,
247 })
248 .pipe(
249 Effect.catchTag("AlreadyExistsException", () =>
250 ses.updateReceiptRule({
251 RuleSetName: ruleSetName,
252 Rule: rule,
253 }),
254 ),
255 );
256 } else {
257 yield* ses.updateReceiptRule({
258 RuleSetName: ruleSetName,
259 Rule: rule,
260 });
261 }
262
263 // SYNC POSITION — updateReceiptRule never moves the rule, so diff the
264 // observed predecessor against the desired `after` and reposition
265 // only on a mismatch.
266 const ruleSet = yield* ses.describeReceiptRuleSet({
267 RuleSetName: ruleSetName,
268 });
269 const predecessor = observedPredecessor(
270 ruleSet.Rules ?? [],
271 ruleName,
272 );
273 if (predecessor !== news.after) {
274 yield* ses.setReceiptRulePosition({
275 RuleSetName: ruleSetName,
276 RuleName: ruleName,
277 After: news.after,
278 });
279 }
280
281 return { ruleSetName, ruleName };
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 // deleteReceiptRule is idempotent for a missing rule; a missing rule
286 // set means the rule is already gone.
287 yield* ses
288 .deleteReceiptRule({
289 RuleSetName: output.ruleSetName,
290 RuleName: output.ruleName,
291 })
292 .pipe(
293 Effect.catchTag(
294 "RuleSetDoesNotExistException",
295 () => Effect.void,
296 ),
297 );
298 }),
299 });
300 }),
301 );