1import * as sesv2 from "@distilled.cloud/aws/sesv2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 diffTags,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import type { Providers } from "../Providers.ts";
16
17/**
18 * The length of the RSA key pair SES generates for Easy DKIM signing.
19 */
20export type DkimSigningKeyLength = "RSA_1024_BIT" | "RSA_2048_BIT";
21
22export interface EmailIdentityProps {
23 /**
24 * The email address or domain to verify with SES, e.g. `hello@example.com`
25 * or `example.com`. Changing this replaces the identity.
26 *
27 * - **Email address** — SES sends a verification email to the address; the
28 * identity stays `PENDING` until the link is clicked.
29 * - **Domain** — SES generates Easy DKIM tokens (see the `dkimTokens`
30 * attribute) that must be published as CNAME records; the identity stays
31 * `PENDING` until the DNS records propagate.
32 */
33 emailIdentity: string;
34 /**
35 * The configuration set to apply by default to messages sent from this
36 * identity. The configuration set must exist in the same region.
37 */
38 configurationSetName?: string;
39 /**
40 * The length of the private key SES uses for Easy DKIM signing. Only
41 * applies to domain identities.
42 * @default "RSA_2048_BIT"
43 */
44 dkimSigningKeyLength?: DkimSigningKeyLength;
45 /**
46 * Whether Easy DKIM signing is enabled for the identity. Leave undefined to
47 * keep SES's current setting.
48 */
49 dkimSigningEnabled?: boolean;
50 /**
51 * Whether SES forwards bounce and complaint feedback to the identity's email
52 * address. Leave undefined to keep SES's current setting.
53 * @default true
54 */
55 feedbackForwardingEnabled?: boolean;
56 /**
57 * A custom MAIL FROM domain (a verified subdomain of the identity) SES uses
58 * in the message envelope. Publish the required MX and SPF records for the
59 * subdomain to complete setup. Leave undefined to keep SES's current
60 * setting — there is no removal path once one is configured.
61 */
62 mailFromDomain?: string;
63 /**
64 * What SES does when the custom MAIL FROM domain's MX record can't be read:
65 * fall back to the SES default (`USE_DEFAULT_VALUE`) or reject the message
66 * (`REJECT_MESSAGE`).
67 * @default "USE_DEFAULT_VALUE"
68 */
69 mailFromBehaviorOnMxFailure?: sesv2.BehaviorOnMxFailure;
70 /**
71 * Tags to apply to the identity. Merged with internal Alchemy tags.
72 */
73 tags?: Record<string, string>;
74}
75
76export interface EmailIdentity extends Resource<
77 "AWS.SES.EmailIdentity",
78 EmailIdentityProps,
79 {
80 emailIdentity: string;
81 identityArn: string;
82 identityType: sesv2.IdentityType;
83 verifiedForSendingStatus: boolean;
84 verificationStatus: sesv2.VerificationStatus | undefined;
85 /**
86 * The Easy DKIM CNAME tokens for a domain identity. Publish each token as
87 * `{token}._domainkey.{domain} CNAME {token}.dkim.amazonses.com` to
88 * complete verification. Empty for email-address identities.
89 */
90 dkimTokens: string[];
91 dkimStatus: sesv2.DkimStatus | undefined;
92 },
93 never,
94 Providers
95> {}
96
97/**
98 * An Amazon SES v2 email identity — a verified email address or domain that
99 * you send email from.
100 *
101 * Creating the identity starts verification: email-address identities receive
102 * a verification email, and domain identities get Easy DKIM tokens (exposed
103 * as the `dkimTokens` attribute) to publish as CNAME records. The identity
104 * is usable for sending once `verificationStatus` is `SUCCESS`.
105 * ### Creating Identities
106 * **Example:** Domain Identity
107 * ```typescript
108 * import * as SES from "alchemy/AWS/SES";
109 *
110 * const identity = yield* SES.EmailIdentity("Sender", {
111 * emailIdentity: "mail.example.com",
112 * });
113 * // publish identity.dkimTokens as CNAME records to verify
114 * ```
115 *
116 * **Example:** Email Address Identity
117 * ```typescript
118 * const identity = yield* SES.EmailIdentity("Sender", {
119 * emailIdentity: "hello@example.com",
120 * });
121 * // SES emails hello@example.com a verification link
122 * ```
123 *
124 * ### Configuration Set Association
125 * **Example:** Apply a Configuration Set by Default
126 * ```typescript
127 * const configSet = yield* SES.ConfigurationSet("Tracking", {});
128 * const identity = yield* SES.EmailIdentity("Sender", {
129 * emailIdentity: "mail.example.com",
130 * configurationSetName: configSet.configurationSetName,
131 * });
132 * ```
133 *
134 * ### DKIM and Feedback
135 * **Example:** Turn Easy DKIM Signing Off
136 * ```typescript
137 * // Omit the prop entirely to leave SES's current setting alone.
138 * const identity = yield* SES.EmailIdentity("Sender", {
139 * emailIdentity: "mail.example.com",
140 * dkimSigningEnabled: false,
141 * });
142 * ```
143 *
144 * **Example:** Stop Forwarding Bounces and Complaints by Email
145 * ```typescript
146 * // Turn this off once a configuration set event destination is handling
147 * // bounces and complaints, so they stop arriving as mail.
148 * const identity = yield* SES.EmailIdentity("Sender", {
149 * emailIdentity: "mail.example.com",
150 * feedbackForwardingEnabled: false,
151 * });
152 * ```
153 *
154 * ### Custom MAIL FROM Domain
155 * **Example:** Send with Your Own Envelope Domain
156 * ```typescript
157 * // mailFromDomain must be a subdomain of the identity, and needs MX and
158 * // SPF records published before SES will use it.
159 * const identity = yield* SES.EmailIdentity("Sender", {
160 * emailIdentity: "mail.example.com",
161 * mailFromDomain: "bounce.mail.example.com",
162 * // Reject rather than silently falling back to the SES default when the
163 * // MX record cannot be read.
164 * mailFromBehaviorOnMxFailure: "REJECT_MESSAGE",
165 * });
166 * ```
167 *
168 * ### Sending Email at Runtime
169 * **Example:** Send Through the Identity from a Lambda Function
170 * ```typescript
171 * // init
172 * const sendEmail = yield* SES.SendEmail(identity);
173 *
174 * // runtime
175 * const result = yield* sendEmail({
176 * FromEmailAddress: "hello@mail.example.com",
177 * Destination: { ToAddresses: ["customer@example.com"] },
178 * Content: {
179 * Simple: {
180 * Subject: { Data: "Welcome!" },
181 * Body: { Text: { Data: "Hello from SES." } },
182 * },
183 * },
184 * });
185 * ```
186 *
187 * @resource
188 */
189export const EmailIdentity = Resource<EmailIdentity>("AWS.SES.EmailIdentity");
190
191const toTagRecord = (
192 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
193): Record<string, string> =>
194 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
195
196const identityArnOf = (region: string, accountId: string, name: string) =>
197 `arn:aws:ses:${region}:${accountId}:identity/${name}`;
198
199const toAttributes = (
200 name: string,
201 identityArn: string,
202 identity: sesv2.GetEmailIdentityResponse,
203) => ({
204 emailIdentity: name,
205 identityArn,
206 identityType: identity.IdentityType ?? "DOMAIN",
207 verifiedForSendingStatus: identity.VerifiedForSendingStatus ?? false,
208 verificationStatus: identity.VerificationStatus,
209 dkimTokens: [...(identity.DkimAttributes?.Tokens ?? [])],
210 dkimStatus: identity.DkimAttributes?.Status,
211});
212
213export const EmailIdentityProvider = () =>
214 Provider.effect(
215 EmailIdentity,
216 Effect.gen(function* () {
217 const getIdentity = Effect.fn(function* (name: string) {
218 return yield* sesv2
219 .getEmailIdentity({ EmailIdentity: name })
220 .pipe(
221 Effect.catchTag("NotFoundException", () =>
222 Effect.succeed(undefined),
223 ),
224 );
225 });
226
227 return EmailIdentity.Provider.of({
228 stables: ["emailIdentity", "identityArn"],
229
230 list: () =>
231 Effect.gen(function* () {
232 const { accountId, region } = yield* AWSEnvironment.current;
233 const pages = yield* sesv2.listEmailIdentities
234 .pages({})
235 .pipe(Stream.runCollect);
236 const infos = Array.from(pages).flatMap(
237 (page) => page.EmailIdentities ?? [],
238 );
239 const attrs = yield* Effect.forEach(
240 infos.filter(
241 (info): info is typeof info & { IdentityName: string } =>
242 info.IdentityName != null,
243 ),
244 (info) =>
245 Effect.gen(function* () {
246 const identity = yield* getIdentity(info.IdentityName);
247 return identity === undefined
248 ? undefined
249 : toAttributes(
250 info.IdentityName,
251 identityArnOf(region, accountId, info.IdentityName),
252 identity,
253 );
254 }),
255 { concurrency: 2 },
256 );
257 return attrs.filter((a) => a !== undefined);
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const { accountId, region } = yield* AWSEnvironment.current;
262 const name = output?.emailIdentity ?? olds?.emailIdentity;
263 if (name === undefined) return undefined;
264 const found = yield* getIdentity(name);
265 if (!found) return undefined;
266 const attrs = toAttributes(
267 name,
268 identityArnOf(region, accountId, name),
269 found,
270 );
271 const tags = toTagRecord(found.Tags);
272 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
273 }),
274
275 diff: Effect.fn(function* ({ news, olds }) {
276 if (!isResolved(news)) return undefined;
277 if (
278 olds?.emailIdentity !== undefined &&
279 news?.emailIdentity !== undefined &&
280 olds.emailIdentity !== news.emailIdentity
281 ) {
282 return { action: "replace" } as const;
283 }
284 }),
285
286 reconcile: Effect.fn(function* ({ id, news, output, session }) {
287 const { accountId, region } = yield* AWSEnvironment.current;
288 const name = output?.emailIdentity ?? news.emailIdentity;
289 const identityArn = identityArnOf(region, accountId, name);
290 const internalTags = yield* createInternalTags(id);
291 const desiredTags = { ...news.tags, ...internalTags };
292
293 // 1. OBSERVE — cloud state is authoritative; `output` is only a
294 // cache of the identity name.
295 let observed = yield* getIdentity(name);
296
297 // 2. ENSURE — create if missing; AlreadyExists is a race, not a
298 // failure. Creation kicks off verification (email or DKIM DNS).
299 if (observed === undefined) {
300 yield* sesv2
301 .createEmailIdentity({
302 EmailIdentity: name,
303 ConfigurationSetName: news.configurationSetName,
304 DkimSigningAttributes:
305 news.dkimSigningKeyLength !== undefined
306 ? { NextSigningKeyLength: news.dkimSigningKeyLength }
307 : undefined,
308 Tags: createTagsList(desiredTags),
309 })
310 .pipe(
311 Effect.catchTag("AlreadyExistsException", () =>
312 Effect.succeed({}),
313 ),
314 );
315 observed = yield* sesv2.getEmailIdentity({ EmailIdentity: name });
316 }
317
318 // 3. SYNC configuration-set association — compare observed against
319 // desired; passing undefined removes the association.
320 if (observed.ConfigurationSetName !== news.configurationSetName) {
321 yield* sesv2.putEmailIdentityConfigurationSetAttributes({
322 EmailIdentity: name,
323 ConfigurationSetName: news.configurationSetName,
324 });
325 }
326
327 // 3b. SYNC Easy DKIM signing key length (domain identities). Only
328 // applied when explicitly requested and observably different —
329 // rotating the key regenerates the DKIM tokens.
330 if (
331 news.dkimSigningKeyLength !== undefined &&
332 observed.DkimAttributes?.NextSigningKeyLength !== undefined &&
333 observed.DkimAttributes.NextSigningKeyLength !==
334 news.dkimSigningKeyLength
335 ) {
336 yield* sesv2.putEmailIdentityDkimSigningAttributes({
337 EmailIdentity: name,
338 SigningAttributesOrigin: "AWS_SES",
339 SigningAttributes: {
340 NextSigningKeyLength: news.dkimSigningKeyLength,
341 },
342 });
343 }
344
345 // 3c. SYNC Easy DKIM signing enablement — only applied when
346 // explicitly requested and observably different.
347 if (
348 news.dkimSigningEnabled !== undefined &&
349 (observed.DkimAttributes?.SigningEnabled ?? false) !==
350 news.dkimSigningEnabled
351 ) {
352 yield* sesv2.putEmailIdentityDkimAttributes({
353 EmailIdentity: name,
354 SigningEnabled: news.dkimSigningEnabled,
355 });
356 }
357
358 // 3d. SYNC feedback forwarding — only applied when explicitly
359 // requested and observably different (SES defaults it to on).
360 if (
361 news.feedbackForwardingEnabled !== undefined &&
362 (observed.FeedbackForwardingStatus ?? true) !==
363 news.feedbackForwardingEnabled
364 ) {
365 yield* sesv2.putEmailIdentityFeedbackAttributes({
366 EmailIdentity: name,
367 EmailForwardingEnabled: news.feedbackForwardingEnabled,
368 });
369 }
370
371 // 3e. SYNC custom MAIL FROM domain — only applied when explicitly
372 // requested and observably different.
373 if (
374 news.mailFromDomain !== undefined &&
375 (observed.MailFromAttributes?.MailFromDomain !==
376 news.mailFromDomain ||
377 (news.mailFromBehaviorOnMxFailure !== undefined &&
378 observed.MailFromAttributes?.BehaviorOnMxFailure !==
379 news.mailFromBehaviorOnMxFailure))
380 ) {
381 yield* sesv2.putEmailIdentityMailFromAttributes({
382 EmailIdentity: name,
383 MailFromDomain: news.mailFromDomain,
384 BehaviorOnMxFailure: news.mailFromBehaviorOnMxFailure,
385 });
386 }
387
388 // 3f. SYNC TAGS — diff against OBSERVED cloud tags so adoption
389 // converges.
390 const observedTags = toTagRecord(observed.Tags);
391 const { upsert, removed } = diffTags(observedTags, desiredTags);
392 if (upsert.length > 0) {
393 yield* sesv2.tagResource({
394 ResourceArn: identityArn,
395 Tags: upsert,
396 });
397 }
398 if (removed.length > 0) {
399 yield* sesv2.untagResource({
400 ResourceArn: identityArn,
401 TagKeys: removed,
402 });
403 }
404
405 // 4. RETURN — re-read so DKIM tokens/status reflect any sync above.
406 const final = yield* sesv2.getEmailIdentity({ EmailIdentity: name });
407 yield* session.note(identityArn);
408 return toAttributes(name, identityArn, final);
409 }),
410
411 delete: Effect.fn(function* ({ output }) {
412 yield* sesv2
413 .deleteEmailIdentity({ EmailIdentity: output.emailIdentity })
414 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
415 }),
416 });
417 }),
418 );