1import * as Effect from "effect/Effect";
2import * as Binding from "../../Binding.ts";
3import type { Input } from "../../Input.ts";
4import * as Output from "../../Output.ts";
5import { AWSEnvironment } from "../Environment.ts";
6import { isBindingHost } from "../Lambda/Function.ts";
7import type { ConfigurationSet } from "./ConfigurationSet.ts";
8import type { EmailIdentity } from "./EmailIdentity.ts";
9import type { EmailTemplate } from "./EmailTemplate.ts";
10
11/**
12 * Shared scaffolding for Amazon SES v2 HTTP bindings.
13 *
14 * NOT exported from `index.ts` — every thin `{Op}Http.ts` in this service is
15 * a `Layer.effect(Cap, make…HttpBinding({ … }))` over one of the builders
16 * below. Everything except the operation, the IAM action list, and the
17 * injected identifier is boilerplate.
18 */
19
20/**
21 * Build the impl Effect for an account-level SES operation (account status,
22 * account suppression list). These IAM actions do not support resource-level
23 * permissions, so the deploy-time half grants `actions` on `*` and the
24 * runtime callable passes the caller's request through as-is.
25 */
26export const makeSESHttpBinding = <I, A, E, R>(options: {
27 /** Fully-qualified binding tag, e.g. `AWS.SES.GetAccount`. */
28 tag: string;
29 /** The distilled operation, invoked with the caller's request as-is. */
30 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
31 /** IAM actions granted on `*`. */
32 actions: readonly string[];
33}) =>
34 Effect.gen(function* () {
35 const op = yield* options.operation;
36
37 return Effect.fn(function* () {
38 if (!globalThis.__ALCHEMY_RUNTIME__) {
39 const host = yield* Binding.Host;
40 if (isBindingHost(host)) {
41 yield* host.bind`Allow(${host}, ${options.tag}())`({
42 policyStatements: [
43 {
44 Effect: "Allow",
45 Action: [...options.actions],
46 Resource: ["*"],
47 },
48 ],
49 });
50 }
51 }
52 return Effect.fn(options.tag)(function* (request?: I) {
53 return yield* op((request ?? {}) as I);
54 });
55 });
56 });
57
58/**
59 * Build the impl Effect for a template-scoped SES operation. The runtime
60 * callable injects the bound {@link EmailTemplate}'s name as the request's
61 * `TemplateName`; the deploy-time half grants `actions` on the template ARN.
62 */
63export const makeTemplateScopedHttpBinding = <
64 I extends { TemplateName: string },
65 A,
66 E,
67 R,
68>(options: {
69 /** Fully-qualified binding tag, e.g. `AWS.SES.RenderEmailTemplate`. */
70 tag: string;
71 /** The distilled operation; `TemplateName` is injected from the resource. */
72 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
73 /** IAM actions granted on the template ARN. */
74 actions: readonly string[];
75}) =>
76 Effect.gen(function* () {
77 const op = yield* options.operation;
78
79 return Effect.fn(function* (template: EmailTemplate) {
80 const TemplateName = yield* template.templateName;
81 if (!globalThis.__ALCHEMY_RUNTIME__) {
82 const host = yield* Binding.Host;
83 if (isBindingHost(host)) {
84 yield* host.bind`Allow(${host}, ${options.tag}(${template}))`({
85 policyStatements: [
86 {
87 Effect: "Allow",
88 Action: [...options.actions],
89 Resource: [template.templateArn],
90 },
91 ],
92 });
93 }
94 }
95 return Effect.fn(`${options.tag}(${template.LogicalId})`)(function* (
96 request: Omit<I, "TemplateName">,
97 ) {
98 return yield* op({
99 ...request,
100 TemplateName: yield* TemplateName,
101 } as I);
102 });
103 });
104 });
105
106/**
107 * An SES identity referenced by address or domain, used ONLY to scope an IAM
108 * grant.
109 *
110 * Unlike passing an {@link EmailIdentity} resource this creates no resource
111 * edge and no ownership: nothing is created, adopted, or destroyed. That
112 * matters when the identity is managed outside the stack — binding to a
113 * resource would mean owning it, and owning it would mean `destroy` deleting
114 * it.
115 */
116export interface EmailIdentityRef {
117 /** The verified email address or domain, e.g. `"sender@example.com"`. */
118 emailIdentity: string;
119}
120
121// Discriminate on the REF side: a resource's `emailIdentity` is an Output, not
122// a string. Testing for a resource key with `in` does not work — the resource
123// is an Output proxy, so every property "exists".
124const isEmailIdentityRef = (
125 identity: EmailIdentity | EmailIdentityRef,
126): identity is EmailIdentityRef =>
127 typeof (identity as EmailIdentityRef).emailIdentity === "string";
128
129/**
130 * Build the impl Effect for `SendCustomVerificationEmail`, scoped to the
131 * identity being VERIFIED.
132 *
133 * Unlike the send bindings, SES authorizes this action against the identity
134 * of the address in the request — the recipient the verification email starts
135 * verification for — not against the template's FROM identity. Confirmed
136 * live: a policy granting only the sender identity is refused with
137 * "not authorized to perform: ses:SendCustomVerificationEmail on resource:
138 * arn:aws:ses:<region>:<account>:identity/<RECIPIENT>".
139 *
140 * So the bound identity names what the function is allowed to verify: a
141 * single address, or a domain, in which case addresses at that domain are
142 * covered. That is the constraint worth enforcing — it stops a leaked binding
143 * being used to send verification mail to arbitrary addresses.
144 *
145 * Accepts a managed {@link EmailIdentity} or an {@link EmailIdentityRef}; the
146 * reference form derives the ARNs from the ambient account and region without
147 * taking ownership. An optional {@link ConfigurationSet} is injected into the
148 * request and added to the grant.
149 */
150export const makeVerificationScopedHttpBinding = <
151 I extends { ConfigurationSetName?: string },
152 A,
153 E,
154 R,
155>(options: {
156 /** Fully-qualified binding tag, e.g. `AWS.SES.SendCustomVerificationEmail`. */
157 tag: string;
158 /** The distilled operation. */
159 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
160 /** IAM actions granted on the identity/address/template ARNs. */
161 actions: readonly string[];
162}) =>
163 Effect.gen(function* () {
164 const op = yield* options.operation;
165
166 return Effect.fn(function* (
167 identity: EmailIdentity | EmailIdentityRef,
168 configurationSet?: ConfigurationSet,
169 ) {
170 const ConfigurationSetName = configurationSet
171 ? yield* configurationSet.configurationSetName
172 : undefined;
173 const label = isEmailIdentityRef(identity)
174 ? identity.emailIdentity
175 : identity.LogicalId;
176 if (!globalThis.__ALCHEMY_RUNTIME__) {
177 const host = yield* Binding.Host;
178 if (isBindingHost(host)) {
179 // Authorize the identity being verified: the address itself, or
180 // every address at the domain when a domain is bound. The account's
181 // custom verification email templates are granted alongside, since
182 // the request also names one.
183 const resources: Input<string>[] = [];
184 if (isEmailIdentityRef(identity)) {
185 const { accountId, region } =
186 yield* AWSEnvironment.current as unknown as Effect.Effect<{
187 accountId: string;
188 region: string;
189 }>;
190 const base = `arn:aws:ses:${region}:${accountId}`;
191 const name = identity.emailIdentity;
192 resources.push(
193 `${base}:identity/${name}`,
194 // A domain reference also authorizes addresses at that domain.
195 name.includes("@")
196 ? `${base}:identity/${name}`
197 : `${base}:identity/*@${name}`,
198 `${base}:custom-verification-email-template/*`,
199 );
200 } else {
201 resources.push(
202 identity.identityArn,
203 Output.all(identity.identityArn).pipe(
204 Output.map(([identityArn]) =>
205 identityArn.includes("@")
206 ? identityArn
207 : identityArn.replace(/:identity\//, ":identity/*@"),
208 ),
209 ),
210 Output.all(identity.identityArn).pipe(
211 Output.map(([identityArn]) =>
212 identityArn.replace(
213 /:identity\/.*$/,
214 ":custom-verification-email-template/*",
215 ),
216 ),
217 ),
218 );
219 }
220 if (configurationSet) {
221 resources.push(configurationSet.configurationSetArn);
222 }
223 yield* host.bind`Allow(${host}, ${options.tag}(${label}, ${configurationSet ?? "none"}))`(
224 {
225 policyStatements: [
226 {
227 Effect: "Allow",
228 Action: [...options.actions],
229 Resource: resources,
230 },
231 ],
232 },
233 );
234 }
235 }
236 return Effect.fn(`${options.tag}(${label})`)(function* (
237 request: Omit<I, "ConfigurationSetName">,
238 ) {
239 const configurationSetName = ConfigurationSetName
240 ? yield* ConfigurationSetName
241 : undefined;
242 return yield* op({
243 ...request,
244 ConfigurationSetName: configurationSetName,
245 } as I);
246 });
247 });
248 });
249
250/**
251 * Build the impl Effect for an identity-scoped send operation (`SendEmail`,
252 * `SendBulkEmail`). The binding resolves the bound {@link EmailIdentity}
253 * (and optional {@link ConfigurationSet}) and:
254 *
255 * - grants `actions` on the identity ARN, on addresses at the identity's
256 * domain (SES authorizes a send against the identity of the FROM address,
257 * not the domain identity ARN), on the account's templates (templated
258 * sends are authorized against the template resource), and on the
259 * configuration set ARN when one is bound;
260 * - at runtime defaults `FromEmailAddress` to the identity and injects the
261 * bound configuration set's name.
262 */
263export const makeSendScopedHttpBinding = <
264 I extends { FromEmailAddress?: string; ConfigurationSetName?: string },
265 A,
266 E,
267 R,
268>(options: {
269 /** Fully-qualified binding tag, e.g. `AWS.SES.SendEmail`. */
270 tag: string;
271 /** The distilled send operation. */
272 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
273 /** IAM actions granted on the identity/address/template/config-set ARNs. */
274 actions: readonly string[];
275}) =>
276 Effect.gen(function* () {
277 const op = yield* options.operation;
278
279 return Effect.fn(function* <Identity extends EmailIdentity>(
280 identity: Identity,
281 configurationSet?: ConfigurationSet,
282 ) {
283 const FromIdentity = yield* identity.emailIdentity;
284 const ConfigurationSetName = configurationSet
285 ? yield* configurationSet.configurationSetName
286 : undefined;
287 if (!globalThis.__ALCHEMY_RUNTIME__) {
288 const host = yield* Binding.Host;
289 if (isBindingHost(host)) {
290 // Templated sends are authorized against the template resource, so
291 // grant the account's templates alongside the bound identity.
292 const templateArns = Output.all(identity.identityArn).pipe(
293 Output.map(([identityArn]) =>
294 identityArn.replace(/:identity\/.*$/, ":template/*"),
295 ),
296 );
297 // For a domain identity, SES authorizes the send against the
298 // identity ARN of the FROM address (identity/user@domain), not the
299 // domain identity ARN — grant addresses at the domain too.
300 const addressArns = Output.all(identity.identityArn).pipe(
301 Output.map(([identityArn]) =>
302 identityArn.includes("@")
303 ? identityArn
304 : identityArn.replace(/:identity\//, ":identity/*@"),
305 ),
306 );
307 yield* host.bind`Allow(${host}, ${options.tag}(${identity}, ${configurationSet ?? "none"}))`(
308 {
309 policyStatements: [
310 {
311 Effect: "Allow",
312 Action: [...options.actions],
313 Resource: [
314 identity.identityArn,
315 addressArns,
316 templateArns,
317 ...(configurationSet
318 ? [configurationSet.configurationSetArn]
319 : []),
320 ],
321 },
322 ],
323 },
324 );
325 }
326 }
327 return Effect.fn(`${options.tag}(${identity.LogicalId})`)(function* (
328 request: Omit<I, "ConfigurationSetName">,
329 ) {
330 const fromIdentity = yield* FromIdentity;
331 const configurationSetName = ConfigurationSetName
332 ? yield* ConfigurationSetName
333 : undefined;
334 return yield* op({
335 ...request,
336 FromEmailAddress: (request as I).FromEmailAddress ?? fromIdentity,
337 ConfigurationSetName: configurationSetName,
338 } as I);
339 });
340 });
341 });