1import * as sesv2 from "@distilled.cloud/aws/sesv2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 normalizePolicyDocument,
10 stringifyPolicyDocument,
11 type PolicyDocument,
12} from "../IAM/Policy.ts";
13import type { Providers } from "../Providers.ts";
14
15export interface EmailIdentityPolicyProps {
16 /**
17 * The email address or domain identity the sending-authorization policy is
18 * attached to. Typically the `emailIdentity` output of a
19 * `SES.EmailIdentity`. Changing it replaces the policy.
20 */
21 emailIdentity: string;
22 /**
23 * Name of the policy. May contain letters, numbers, dashes and underscores,
24 * up to 64 characters. If omitted, a deterministic physical name is
25 * generated from the app, stage, and logical ID. Changing it replaces the
26 * policy.
27 */
28 policyName?: string;
29 /**
30 * The IAM policy document that grants sending authorization. Equivalent
31 * document representations are ignored when detecting drift.
32 */
33 policy: PolicyDocument;
34}
35
36export interface EmailIdentityPolicy extends Resource<
37 "AWS.SES.EmailIdentityPolicy",
38 EmailIdentityPolicyProps,
39 {
40 /** The identity the policy is attached to. */
41 emailIdentity: string;
42 /** Name of the policy. */
43 policyName: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * An Amazon SES v2 sending-authorization policy attached to an email identity —
51 * lets the identity owner authorize other AWS accounts or IAM principals to
52 * send email using the identity.
53 *
54 * SES stores the policy document as JSON; Alchemy serializes the typed IAM
55 * policy at the API boundary and compares its normalized content for drift.
56 * ### Attaching a Policy
57 * **Example:** Authorize Another Account to Send
58 * ```typescript
59 * import * as SES from "alchemy/AWS/SES";
60 *
61 * const identity = yield* SES.EmailIdentity("Sender", {
62 * emailIdentity: "mail.example.com",
63 * });
64 *
65 * const policy = yield* SES.EmailIdentityPolicy("AllowPartner", {
66 * emailIdentity: identity.emailIdentity,
67 * policy: {
68 * Version: "2012-10-17",
69 * Statement: [
70 * {
71 * Effect: "Allow",
72 * Principal: { AWS: "arn:aws:iam::111122223333:root" },
73 * Action: ["ses:SendEmail"],
74 * Resource: identity.identityArn,
75 * },
76 * ],
77 * },
78 * });
79 * ```
80 *
81 * **Example:** Explicit Policy Name
82 * ```typescript
83 * // Without policyName a deterministic name is derived from app/stage/id.
84 * const policy = yield* SES.EmailIdentityPolicy("AllowPartner", {
85 * emailIdentity: identity.emailIdentity,
86 * policyName: "partner-send",
87 * policy: {
88 * Version: "2012-10-17",
89 * Statement: [
90 * {
91 * Effect: "Allow",
92 * Principal: { AWS: "arn:aws:iam::111122223333:root" },
93 * Action: ["ses:SendEmail"],
94 * Resource: identity.identityArn,
95 * },
96 * ],
97 * },
98 * });
99 * ```
100 *
101 * **Example:** Restrict the Grant with Conditions
102 * ```typescript
103 * const policy = yield* SES.EmailIdentityPolicy("AllowPartnerScoped", {
104 * emailIdentity: identity.emailIdentity,
105 * policy: {
106 * Version: "2012-10-17",
107 * Statement: [
108 * {
109 * Effect: "Allow",
110 * Principal: { AWS: "arn:aws:iam::111122223333:root" },
111 * Action: ["ses:SendEmail", "ses:SendRawEmail"],
112 * Resource: identity.identityArn,
113 * Condition: {
114 * StringEquals: { "ses:FromAddress": "noreply@mail.example.com" },
115 * },
116 * },
117 * ],
118 * },
119 * });
120 * ```
121 *
122 * **Example:** Several Policies on One Identity
123 * ```typescript
124 * // Each policy is a separate resource keyed by its own name.
125 * for (const partner of ["111122223333", "444455556666"]) {
126 * yield* SES.EmailIdentityPolicy(`Allow${partner}`, {
127 * emailIdentity: identity.emailIdentity,
128 * policyName: `partner-${partner}`,
129 * policy: {
130 * Version: "2012-10-17",
131 * Statement: [
132 * {
133 * Effect: "Allow",
134 * Principal: { AWS: `arn:aws:iam::${partner}:root` },
135 * Action: ["ses:SendEmail"],
136 * Resource: identity.identityArn,
137 * },
138 * ],
139 * },
140 * });
141 * }
142 * ```
143 *
144 * @resource
145 */
146export const EmailIdentityPolicy = Resource<EmailIdentityPolicy>(
147 "AWS.SES.EmailIdentityPolicy",
148);
149
150export const EmailIdentityPolicyProvider = () =>
151 Provider.effect(
152 EmailIdentityPolicy,
153 Effect.gen(function* () {
154 const createName = Effect.fn(function* (
155 id: string,
156 props: Pick<EmailIdentityPolicyProps, "policyName">,
157 ) {
158 return (
159 props.policyName ?? (yield* createPhysicalName({ id, maxLength: 64 }))
160 );
161 });
162
163 // Returns the policy-name -> document map for the identity, or undefined
164 // when the identity itself no longer exists.
165 const getPolicies = Effect.fn(function* (emailIdentity: string) {
166 return yield* sesv2
167 .getEmailIdentityPolicies({ EmailIdentity: emailIdentity })
168 .pipe(
169 Effect.map((response) => response.Policies ?? {}),
170 Effect.catchTag("NotFoundException", () =>
171 Effect.succeed(undefined),
172 ),
173 );
174 });
175
176 return EmailIdentityPolicy.Provider.of({
177 // Deleting a policy requires its parent identity to still exist, so
178 // the identity must outlive every policy nuke tears down.
179 nuke: { dependsOn: ["AWS.SES.EmailIdentity"] },
180 stables: ["emailIdentity", "policyName"],
181
182 // Policies are keyed by their parent identity, so enumeration walks
183 // every identity and reads its policy map. getPolicies already treats
184 // a vanished identity as "no policies".
185 list: Effect.fn(function* () {
186 const pages = yield* sesv2.listEmailIdentities
187 .pages({})
188 .pipe(Stream.runCollect);
189 const identities = Array.from(pages)
190 .flatMap((page) => page.EmailIdentities ?? [])
191 .flatMap((info) => (info.IdentityName ? [info.IdentityName] : []));
192 const nested = yield* Effect.forEach(
193 identities,
194 (emailIdentity) =>
195 getPolicies(emailIdentity).pipe(
196 Effect.map((policies) =>
197 Object.keys(policies ?? {}).map((policyName) => ({
198 emailIdentity,
199 policyName,
200 })),
201 ),
202 ),
203 { concurrency: 2 },
204 );
205 return nested.flat();
206 }),
207
208 read: Effect.fn(function* ({ id, olds, output }) {
209 const emailIdentity = output?.emailIdentity ?? olds?.emailIdentity;
210 if (emailIdentity === undefined) return undefined;
211 const policyName =
212 output?.policyName ?? (yield* createName(id, olds ?? {}));
213 const policies = yield* getPolicies(emailIdentity);
214 if (policies === undefined || policies[policyName] === undefined) {
215 return undefined;
216 }
217 // Policies carry no tags, so existence at our deterministic name is
218 // treated as ownership.
219 return { emailIdentity, policyName };
220 }),
221
222 diff: Effect.fn(function* ({ id, news, olds }) {
223 if (!isResolved(news)) return undefined;
224 const oldName = yield* createName(id, olds);
225 const newName = yield* createName(id, news);
226 if (
227 oldName !== newName ||
228 olds.emailIdentity !== news.emailIdentity
229 ) {
230 return { action: "replace" } as const;
231 }
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const emailIdentity = output?.emailIdentity ?? news.emailIdentity;
236 const policyName =
237 output?.policyName ?? (yield* createName(id, news));
238
239 // 1. OBSERVE — cloud state is authoritative.
240 const policies = yield* getPolicies(emailIdentity);
241 const existing = policies?.[policyName];
242 const desiredPolicy = stringifyPolicyDocument(news.policy);
243
244 if (existing === undefined) {
245 // 2. ENSURE — create; AlreadyExists is a race → converge via update.
246 yield* sesv2
247 .createEmailIdentityPolicy({
248 EmailIdentity: emailIdentity,
249 PolicyName: policyName,
250 Policy: desiredPolicy,
251 })
252 .pipe(
253 Effect.catchTag("AlreadyExistsException", () =>
254 sesv2.updateEmailIdentityPolicy({
255 EmailIdentity: emailIdentity,
256 PolicyName: policyName,
257 Policy: desiredPolicy,
258 }),
259 ),
260 );
261 } else {
262 // 3. SYNC — update only when the stored document differs.
263 const changed = yield* Effect.sync(
264 () =>
265 normalizePolicyDocument(existing) !==
266 normalizePolicyDocument(news.policy),
267 );
268 if (changed) {
269 yield* sesv2.updateEmailIdentityPolicy({
270 EmailIdentity: emailIdentity,
271 PolicyName: policyName,
272 Policy: desiredPolicy,
273 });
274 }
275 }
276
277 return { emailIdentity, policyName };
278 }),
279
280 delete: Effect.fn(function* ({ output }) {
281 // deleteEmailIdentityPolicy succeeds even if the policy is absent; a
282 // missing identity means the policy is already gone.
283 yield* sesv2
284 .deleteEmailIdentityPolicy({
285 EmailIdentity: output.emailIdentity,
286 PolicyName: output.policyName,
287 })
288 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
289 }),
290 });
291 }),
292 );