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 EmailTemplateProps {
19 /**
20 * The name of the template. If omitted, a deterministic physical name is
21 * generated from the app, stage, and logical ID. Changing the name
22 * replaces the template.
23 */
24 templateName?: string;
25 /**
26 * The subject line, with optional `{{variable}}` personalization tags.
27 */
28 subject?: string;
29 /**
30 * The plain-text body, with optional `{{variable}}` personalization tags.
31 */
32 text?: string;
33 /**
34 * The HTML body, with optional `{{variable}}` personalization tags.
35 */
36 html?: string;
37 /**
38 * Tags to apply to the template. Merged with internal Alchemy tags.
39 */
40 tags?: Record<string, string>;
41}
42
43export interface EmailTemplate extends Resource<
44 "AWS.SES.EmailTemplate",
45 EmailTemplateProps,
46 {
47 templateName: string;
48 templateArn: string;
49 },
50 never,
51 Providers
52> {}
53
54/**
55 * An Amazon SES v2 email template — reusable subject/text/HTML content with
56 * `{{variable}}` personalization tags, rendered server-side when you send
57 * templated email.
58 * ### Creating Templates
59 * **Example:** Welcome Email Template
60 * ```typescript
61 * import * as SES from "alchemy/AWS/SES";
62 *
63 * const template = yield* SES.EmailTemplate("Welcome", {
64 * subject: "Welcome, {{name}}!",
65 * text: "Hi {{name}}, thanks for signing up.",
66 * html: "<h1>Hi {{name}}</h1><p>Thanks for signing up.</p>",
67 * });
68 * ```
69 *
70 * ### Sending Templated Email
71 * **Example:** Send with Template Data
72 * ```typescript
73 * const sendEmail = yield* SES.SendEmail(identity);
74 *
75 * const result = yield* sendEmail({
76 * Destination: { ToAddresses: ["customer@example.com"] },
77 * Content: {
78 * Template: {
79 * TemplateName: "my-welcome-template",
80 * TemplateData: JSON.stringify({ name: "Ada" }),
81 * },
82 * },
83 * });
84 * ```
85 *
86 * @resource
87 */
88export const EmailTemplate = Resource<EmailTemplate>("AWS.SES.EmailTemplate");
89
90const toTagRecord = (
91 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
92): Record<string, string> =>
93 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
94
95const templateArnOf = (region: string, accountId: string, name: string) =>
96 `arn:aws:ses:${region}:${accountId}:template/${name}`;
97
98const toContent = (props: EmailTemplateProps): sesv2.EmailTemplateContent => ({
99 Subject: props.subject,
100 Text: props.text,
101 Html: props.html,
102});
103
104export const EmailTemplateProvider = () =>
105 Provider.effect(
106 EmailTemplate,
107 Effect.gen(function* () {
108 const createName = Effect.fn(function* (
109 id: string,
110 props: Pick<EmailTemplateProps, "templateName">,
111 ) {
112 return (
113 props.templateName ??
114 (yield* createPhysicalName({ id, maxLength: 64 }))
115 );
116 });
117
118 const getTemplate = Effect.fn(function* (name: string) {
119 return yield* sesv2
120 .getEmailTemplate({ TemplateName: name })
121 .pipe(
122 Effect.catchTag("NotFoundException", () =>
123 Effect.succeed(undefined),
124 ),
125 );
126 });
127
128 return EmailTemplate.Provider.of({
129 stables: ["templateName", "templateArn"],
130
131 list: () =>
132 Effect.gen(function* () {
133 const { accountId, region } = yield* AWSEnvironment.current;
134 const pages = yield* sesv2.listEmailTemplates
135 .pages({})
136 .pipe(Stream.runCollect);
137 return Array.from(pages)
138 .flatMap((page) => page.TemplatesMetadata ?? [])
139 .filter(
140 (meta): meta is typeof meta & { TemplateName: string } =>
141 meta.TemplateName != null,
142 )
143 .map((meta) => ({
144 templateName: meta.TemplateName,
145 templateArn: templateArnOf(
146 region,
147 accountId,
148 meta.TemplateName,
149 ),
150 }));
151 }),
152
153 read: Effect.fn(function* ({ id, olds, output }) {
154 const { accountId, region } = yield* AWSEnvironment.current;
155 const name =
156 output?.templateName ?? (yield* createName(id, olds ?? {}));
157 const found = yield* getTemplate(name);
158 if (!found) return undefined;
159 const attrs = {
160 templateName: name,
161 templateArn: templateArnOf(region, accountId, name),
162 };
163 const tags = toTagRecord(found.Tags);
164 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
165 }),
166
167 diff: Effect.fn(function* ({ id, news, olds }) {
168 if (!isResolved(news)) return undefined;
169 const oldName = yield* createName(id, olds ?? {});
170 const newName = yield* createName(id, news ?? {});
171 if (oldName !== newName) {
172 return { action: "replace" } as const;
173 }
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const { accountId, region } = yield* AWSEnvironment.current;
178 const name = output?.templateName ?? (yield* createName(id, news));
179 const templateArn = templateArnOf(region, accountId, name);
180 const internalTags = yield* createInternalTags(id);
181 const desiredTags = { ...news.tags, ...internalTags };
182 const desiredContent = toContent(news);
183
184 // 1. OBSERVE
185 let observed = yield* getTemplate(name);
186
187 if (observed === undefined) {
188 // 2. ENSURE — AlreadyExists is a race → converge via update.
189 yield* sesv2
190 .createEmailTemplate({
191 TemplateName: name,
192 TemplateContent: desiredContent,
193 Tags: createTagsList(desiredTags),
194 })
195 .pipe(
196 Effect.catchTag("AlreadyExistsException", () =>
197 sesv2.updateEmailTemplate({
198 TemplateName: name,
199 TemplateContent: desiredContent,
200 }),
201 ),
202 );
203 observed = yield* sesv2.getEmailTemplate({ TemplateName: name });
204 } else if (
205 observed.TemplateContent.Subject !== desiredContent.Subject ||
206 observed.TemplateContent.Text !== desiredContent.Text ||
207 observed.TemplateContent.Html !== desiredContent.Html
208 ) {
209 // 3. SYNC content — only when the observed content drifted.
210 yield* sesv2.updateEmailTemplate({
211 TemplateName: name,
212 TemplateContent: desiredContent,
213 });
214 }
215
216 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
217 const observedTags = toTagRecord(observed.Tags);
218 const { upsert, removed } = diffTags(observedTags, desiredTags);
219 if (upsert.length > 0) {
220 yield* sesv2.tagResource({
221 ResourceArn: templateArn,
222 Tags: upsert,
223 });
224 }
225 if (removed.length > 0) {
226 yield* sesv2.untagResource({
227 ResourceArn: templateArn,
228 TagKeys: removed,
229 });
230 }
231
232 yield* session.note(templateArn);
233 return { templateName: name, templateArn };
234 }),
235
236 delete: Effect.fn(function* ({ output }) {
237 yield* sesv2
238 .deleteEmailTemplate({ TemplateName: output.templateName })
239 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
240 }),
241 });
242 }),
243 );