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 { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasAlchemyTags,
14} from "../../Tags.ts";
15import { AWSEnvironment } from "../Environment.ts";
16import type { Providers } from "../Providers.ts";
17
18export interface CustomVerificationEmailTemplateProps {
19 /**
20 * Name of the template. May contain letters, numbers, dashes and
21 * underscores, up to 64 characters. If omitted, a deterministic physical
22 * name is generated from the app, stage, and logical ID. Changing the name
23 * replaces the template.
24 */
25 templateName?: string;
26 /**
27 * The verified email address the verification email is sent from. The
28 * identity must already be verified for sending.
29 */
30 fromEmailAddress: string;
31 /**
32 * The subject line of the verification email.
33 */
34 templateSubject: string;
35 /**
36 * The HTML body of the verification email. Must contain a link to the
37 * verification URL SES generates.
38 */
39 templateContent: string;
40 /**
41 * The URL the recipient is redirected to after successfully verifying.
42 */
43 successRedirectionURL: string;
44 /**
45 * The URL the recipient is redirected to if verification fails.
46 */
47 failureRedirectionURL: string;
48 /**
49 * Tags to apply to the template. Merged with internal Alchemy tags.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface CustomVerificationEmailTemplate extends Resource<
55 "AWS.SES.CustomVerificationEmailTemplate",
56 CustomVerificationEmailTemplateProps,
57 {
58 /** Name of the template. */
59 templateName: string;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * An Amazon SES v2 custom verification email template — the branded email SES
67 * sends when you verify a new email-address identity via
68 * `SendCustomVerificationEmail`.
69 *
70 * Creating, reading, updating, and deleting the template works on any account.
71 * Actually *sending* a custom verification email requires the account to be
72 * out of the SES sandbox (production access).
73 * ### Creating Templates
74 * **Example:** Branded Verification Email
75 * ```typescript
76 * import * as SES from "alchemy/AWS/SES";
77 *
78 * const template = yield* SES.CustomVerificationEmailTemplate("Verify", {
79 * fromEmailAddress: "verify@example.com",
80 * templateSubject: "Please confirm your email",
81 * templateContent:
82 * "<html><body>Click the link to verify your address.</body></html>",
83 * successRedirectionURL: "https://example.com/verified",
84 * failureRedirectionURL: "https://example.com/verify-failed",
85 * });
86 * ```
87 *
88 * **Example:** Explicit Template Name
89 * ```typescript
90 * // Without templateName a deterministic name is derived from app/stage/id.
91 * const template = yield* SES.CustomVerificationEmailTemplate("Verify", {
92 * templateName: "onboarding-verification",
93 * fromEmailAddress: "verify@example.com",
94 * templateSubject: "Please confirm your email",
95 * templateContent:
96 * "<html><body>Click the link to verify your address.</body></html>",
97 * successRedirectionURL: "https://example.com/verified",
98 * failureRedirectionURL: "https://example.com/verify-failed",
99 * });
100 * ```
101 *
102 * ### Sending the Verification Email
103 * **Example:** Verify a New Address from a Lambda Function
104 * ```typescript
105 * // init — account-level binding, no resource argument
106 * const sendVerification = yield* SES.SendCustomVerificationEmail();
107 *
108 * // runtime — SES emails the branded template to the address, and the
109 * // address becomes a verified identity once the recipient clicks through.
110 * const { MessageId } = yield* sendVerification({
111 * EmailAddress: "new-user@example.com",
112 * TemplateName: yield* template.templateName,
113 * });
114 * ```
115 *
116 * @resource
117 */
118export const CustomVerificationEmailTemplate =
119 Resource<CustomVerificationEmailTemplate>(
120 "AWS.SES.CustomVerificationEmailTemplate",
121 );
122
123const toTagRecord = (
124 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
125): Record<string, string> =>
126 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
127
128// getCustomVerificationEmailTemplate returns no ARN, so the ARN
129// listTagsForResource needs is derived — verified live against SES.
130const templateArnOf = (region: string, accountId: string, name: string) =>
131 `arn:aws:ses:${region}:${accountId}:custom-verification-email-template/${name}`;
132
133export const CustomVerificationEmailTemplateProvider = () =>
134 Provider.effect(
135 CustomVerificationEmailTemplate,
136 Effect.gen(function* () {
137 const createName = Effect.fn(function* (
138 id: string,
139 props: Pick<CustomVerificationEmailTemplateProps, "templateName">,
140 ) {
141 return (
142 props.templateName ??
143 (yield* createPhysicalName({ id, maxLength: 64 }))
144 );
145 });
146
147 const getTemplate = Effect.fn(function* (name: string) {
148 return yield* sesv2
149 .getCustomVerificationEmailTemplate({ TemplateName: name })
150 .pipe(
151 Effect.catchTag("NotFoundException", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155 });
156
157 // The get returns no tags, so ownership costs a second API call. Only
158 // `read` pays it — `list` deletes by name.
159 const getTemplateTags = Effect.fn(function* (name: string) {
160 const { accountId, region } = yield* AWSEnvironment.current;
161 return yield* sesv2
162 .listTagsForResource({
163 ResourceArn: templateArnOf(region, accountId, name),
164 })
165 .pipe(
166 Effect.map((response) => toTagRecord(response.Tags)),
167 Effect.catchTag("NotFoundException", () =>
168 Effect.succeed({} as Record<string, string>),
169 ),
170 );
171 });
172
173 return CustomVerificationEmailTemplate.Provider.of({
174 stables: ["templateName"],
175
176 // Account/region-scoped: enumerate every template so leaked test
177 // resources are cleaned by nuke. Custom verification email templates
178 // carry no ownership signal, so existence is treated as ownership.
179 list: Effect.fn(function* () {
180 const pages = yield* sesv2.listCustomVerificationEmailTemplates
181 .pages({})
182 .pipe(Stream.runCollect);
183 return Array.from(pages)
184 .flatMap((page) => page.CustomVerificationEmailTemplates ?? [])
185 .flatMap((meta) =>
186 meta.TemplateName ? [{ templateName: meta.TemplateName }] : [],
187 );
188 }),
189
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const name =
192 output?.templateName ?? (yield* createName(id, olds ?? {}));
193 const found = yield* getTemplate(name);
194 if (!found) return undefined;
195 // Templates are taggable and reconcile brands the ones it creates,
196 // so existence at our deterministic name is not proof of ownership.
197 const tags = yield* getTemplateTags(name);
198 return (yield* hasAlchemyTags(id, tags))
199 ? { templateName: name }
200 : Unowned({ templateName: name });
201 }),
202
203 diff: Effect.fn(function* ({ id, news, olds }) {
204 if (!isResolved(news)) return undefined;
205 const oldName = yield* createName(id, olds ?? {});
206 const newName = yield* createName(id, news ?? {});
207 if (oldName !== newName) {
208 return { action: "replace" } as const;
209 }
210 }),
211
212 reconcile: Effect.fn(function* ({ id, news, output }) {
213 const name = output?.templateName ?? (yield* createName(id, news));
214 const internalTags = yield* createInternalTags(id);
215 const desiredTags = { ...news.tags, ...internalTags };
216
217 // 1. OBSERVE — cloud state is authoritative.
218 const observed = yield* getTemplate(name);
219
220 if (observed === undefined) {
221 // 2. ENSURE — create; AlreadyExists is a race → converge via update.
222 yield* sesv2
223 .createCustomVerificationEmailTemplate({
224 TemplateName: name,
225 FromEmailAddress: news.fromEmailAddress,
226 TemplateSubject: news.templateSubject,
227 TemplateContent: news.templateContent,
228 SuccessRedirectionURL: news.successRedirectionURL,
229 FailureRedirectionURL: news.failureRedirectionURL,
230 Tags: createTagsList(desiredTags),
231 })
232 .pipe(
233 Effect.catchTag("AlreadyExistsException", () =>
234 sesv2.updateCustomVerificationEmailTemplate({
235 TemplateName: name,
236 FromEmailAddress: news.fromEmailAddress,
237 TemplateSubject: news.templateSubject,
238 TemplateContent: news.templateContent,
239 SuccessRedirectionURL: news.successRedirectionURL,
240 FailureRedirectionURL: news.failureRedirectionURL,
241 }),
242 ),
243 );
244 } else if (
245 observed.FromEmailAddress !== news.fromEmailAddress ||
246 observed.TemplateSubject !== news.templateSubject ||
247 observed.TemplateContent !== news.templateContent ||
248 observed.SuccessRedirectionURL !== news.successRedirectionURL ||
249 observed.FailureRedirectionURL !== news.failureRedirectionURL
250 ) {
251 // 3. SYNC — a single full-replace update converges the template.
252 yield* sesv2.updateCustomVerificationEmailTemplate({
253 TemplateName: name,
254 FromEmailAddress: news.fromEmailAddress,
255 TemplateSubject: news.templateSubject,
256 TemplateContent: news.templateContent,
257 SuccessRedirectionURL: news.successRedirectionURL,
258 FailureRedirectionURL: news.failureRedirectionURL,
259 });
260 }
261
262 // 4. SYNC TAGS — diff against OBSERVED cloud tags so an adopted
263 // template gets branded and stops reading as Unowned.
264 const observedTags = yield* getTemplateTags(name);
265 const { upsert, removed } = diffTags(observedTags, desiredTags);
266 if (upsert.length > 0 || removed.length > 0) {
267 const { accountId, region } = yield* AWSEnvironment.current;
268 const arn = templateArnOf(region, accountId, name);
269 if (upsert.length > 0) {
270 yield* sesv2.tagResource({ ResourceArn: arn, Tags: upsert });
271 }
272 if (removed.length > 0) {
273 yield* sesv2.untagResource({
274 ResourceArn: arn,
275 TagKeys: removed,
276 });
277 }
278 }
279
280 return { templateName: name };
281 }),
282
283 delete: Effect.fn(function* ({ output }) {
284 yield* sesv2
285 .deleteCustomVerificationEmailTemplate({
286 TemplateName: output.templateName,
287 })
288 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
289 }),
290 });
291 }),
292 );