1import * as mm from "@distilled.cloud/aws/mailmanager";
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 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 readMailManagerTags,
17 repeatUntilMailManagerStable,
18 retryWhileMailManagerConflict,
19 sameShape,
20 syncMailManagerTags,
21} from "./internal.ts";
22
23export interface IngressPointProps {
24 /**
25 * Name of the ingress point. If omitted, a deterministic physical name is
26 * generated from the app, stage, and logical ID. Renames apply in place.
27 */
28 ingressPointName?: string;
29 /**
30 * Type of the ingress endpoint: `OPEN` accepts unauthenticated SMTP;
31 * `AUTH` requires SMTP AUTH credentials (see
32 * {@link IngressPointProps.ingressPointConfiguration}); `MTLS` requires
33 * client certificates. Immutable — changing the type replaces the ingress
34 * point.
35 */
36 type: mm.IngressPointType;
37 /**
38 * ID of the rule set applied to email arriving through this ingress
39 * point. Updates apply in place.
40 */
41 ruleSetId: string;
42 /**
43 * ID of the traffic policy evaluated against connections to this ingress
44 * point. Updates apply in place.
45 */
46 trafficPolicyId: string;
47 /**
48 * Authentication configuration for `AUTH`/`MTLS` ingress points: an SMTP
49 * password, a Secrets Manager secret ARN, or a TLS trust store. Updates
50 * apply in place.
51 */
52 ingressPointConfiguration?: mm.IngressPointConfiguration;
53 /**
54 * Network configuration: public (with IP type) or VPC-endpoint private.
55 * Immutable — changing it replaces the ingress point.
56 * @default public IPV4
57 */
58 networkConfiguration?: mm.NetworkConfiguration;
59 /**
60 * TLS policy for inbound connections: `REQUIRED`, `OPTIONAL`, or `FIPS`.
61 * Omitting it applies AWS's `FIPS` default. Changes between `REQUIRED` and
62 * `OPTIONAL` update in place; AWS rejects updates from or to `FIPS`
63 * (including the omitted default), so those transitions replace the
64 * ingress point.
65 */
66 tlsPolicy?: mm.TlsPolicy;
67 /**
68 * Tags applied to the ingress point. Alchemy ownership tags are merged in
69 * automatically.
70 */
71 tags?: Record<string, string>;
72}
73
74export interface IngressPoint extends Resource<
75 "AWS.MailManager.IngressPoint",
76 IngressPointProps,
77 {
78 /** Server-assigned ID of the ingress point. */
79 ingressPointId: string;
80 /** ARN of the ingress point. */
81 ingressPointArn: string;
82 /** Name of the ingress point. */
83 ingressPointName: string;
84 /** DNS A record (SMTP endpoint host) of the ingress point. */
85 aRecord: string | undefined;
86 /** Current status (PROVISIONING, ACTIVE, CLOSED, FAILED, ...). */
87 status: string | undefined;
88 },
89 never,
90 Providers
91> {}
92
93/**
94 * An SES Mail Manager ingress point — the SMTP endpoint that receives
95 * incoming email, screens it with a traffic policy, and processes it with a
96 * rule set.
97 *
98 * `type` and `networkConfiguration` are immutable (changes replace the
99 * endpoint); everything else updates in place.
100 * ### Creating Ingress Points
101 * **Example:** Open Ingress Point
102 * ```typescript
103 * import * as MailManager from "alchemy/AWS/MailManager";
104 *
105 * const ruleSet = yield* MailManager.RuleSet("Inbound", {
106 * rules: [{ Name: "DropAll", Actions: [{ Drop: {} }] }],
107 * });
108 * const trafficPolicy = yield* MailManager.TrafficPolicy("Edge", {
109 * defaultAction: "ALLOW",
110 * });
111 * const ingress = yield* MailManager.IngressPoint("Smtp", {
112 * type: "OPEN",
113 * ruleSetId: ruleSet.ruleSetId,
114 * trafficPolicyId: trafficPolicy.trafficPolicyId,
115 * });
116 * // point your domain's MX record at ingress.aRecord
117 * ```
118 *
119 * **Example:** Authenticated Ingress Point
120 * ```typescript
121 * const ingress = yield* MailManager.IngressPoint("Smtp", {
122 * type: "AUTH",
123 * ruleSetId: ruleSet.ruleSetId,
124 * trafficPolicyId: trafficPolicy.trafficPolicyId,
125 * ingressPointConfiguration: { SecretArn: secret.secretArn },
126 * tlsPolicy: "REQUIRED",
127 * });
128 * ```
129 *
130 * @resource
131 */
132export const IngressPoint = Resource<IngressPoint>(
133 "AWS.MailManager.IngressPoint",
134);
135
136export const IngressPointProvider = () =>
137 Provider.effect(
138 IngressPoint,
139 Effect.gen(function* () {
140 const createName = Effect.fn(function* (
141 id: string,
142 props: { ingressPointName?: string },
143 ) {
144 return (
145 props.ingressPointName ??
146 (yield* createPhysicalName({ id, maxLength: 63, lowercase: true }))
147 );
148 });
149
150 const getById = (ingressPointId: string) =>
151 mm
152 .getIngressPoint({ IngressPointId: ingressPointId })
153 .pipe(
154 Effect.catchTag("ResourceNotFoundException", () =>
155 Effect.succeed(undefined),
156 ),
157 );
158
159 const findByName = (name: string) =>
160 mm.listIngressPoints.pages({}).pipe(
161 Stream.runCollect,
162 Effect.map((chunk) =>
163 Array.from(chunk)
164 .flatMap((page) => page.IngressPoints ?? [])
165 .find((p) => p.IngressPointName === name),
166 ),
167 );
168
169 const observe = Effect.fn(function* (
170 output: IngressPoint["Attributes"] | undefined,
171 name: string,
172 ) {
173 if (output?.ingressPointId !== undefined) {
174 const found = yield* getById(output.ingressPointId);
175 if (found !== undefined) return found;
176 }
177 const summary = yield* findByName(name);
178 if (summary?.IngressPointId === undefined) return undefined;
179 return yield* getById(summary.IngressPointId);
180 });
181
182 // Provisioning/updating is asynchronous — wait (bounded, ~2 min) for
183 // the endpoint to leave its transitional state before mutating it.
184 const waitForStable = (ingressPointId: string) =>
185 repeatUntilMailManagerStable(
186 getById(ingressPointId),
187 (r) =>
188 r === undefined ||
189 (r.Status !== "PROVISIONING" && r.Status !== "UPDATING"),
190 );
191
192 const toAttrs = Effect.fn(function* (
193 ingress: mm.GetIngressPointResponse,
194 ) {
195 if (ingress.IngressPointArn === undefined) {
196 return yield* Effect.fail(
197 new Error(
198 `Mail Manager ingress point '${ingress.IngressPointId}' returned without an ARN`,
199 ),
200 );
201 }
202 return {
203 ingressPointId: ingress.IngressPointId,
204 ingressPointArn: ingress.IngressPointArn,
205 ingressPointName: ingress.IngressPointName,
206 aRecord: ingress.ARecord,
207 status: ingress.Status,
208 };
209 });
210
211 return IngressPoint.Provider.of({
212 stables: ["ingressPointId", "ingressPointArn"],
213
214 list: () =>
215 mm.listIngressPoints.pages({}).pipe(
216 Stream.runCollect,
217 Effect.flatMap((chunk) =>
218 Effect.forEach(
219 Array.from(chunk)
220 .flatMap((page) => page.IngressPoints ?? [])
221 .map((p) => p.IngressPointId),
222 (ingressPointId) => getById(ingressPointId),
223 ),
224 ),
225 Effect.flatMap((results) =>
226 Effect.forEach(
227 results.flatMap((p) => (p === undefined ? [] : [p])),
228 (p) => toAttrs(p),
229 ),
230 ),
231 ),
232
233 read: Effect.fn(function* ({ id, olds, output }) {
234 const name =
235 output?.ingressPointName ?? (yield* createName(id, olds ?? {}));
236 const ingress = yield* observe(output, name);
237 if (ingress === undefined) return undefined;
238 const attrs = yield* toAttrs(ingress);
239 const tags = yield* readMailManagerTags(attrs.ingressPointArn);
240 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
241 }),
242
243 // `type` and `networkConfiguration` are create-only — changing
244 // either replaces the ingress point. TlsPolicy transitions from or
245 // to FIPS (including the omitted-default, which AWS creates as
246 // FIPS) are rejected by UpdateIngressPoint, so they replace too.
247 diff: Effect.fn(function* ({ news, olds }) {
248 if (!isResolved(news)) return undefined;
249 if (olds === undefined) return undefined;
250 if (olds.type !== news.type) {
251 return { action: "replace" } as const;
252 }
253 if (
254 !sameShape(olds.networkConfiguration, news.networkConfiguration)
255 ) {
256 return { action: "replace" } as const;
257 }
258 if (olds.tlsPolicy !== news.tlsPolicy) {
259 const fipsInvolved =
260 olds.tlsPolicy === undefined ||
261 news.tlsPolicy === undefined ||
262 olds.tlsPolicy === "FIPS" ||
263 news.tlsPolicy === "FIPS";
264 if (fipsInvolved) return { action: "replace" } as const;
265 }
266 }),
267
268 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
269 const name = yield* createName(id, news);
270 const internalTags = yield* createInternalTags(id);
271 const desiredTags = { ...news.tags, ...internalTags };
272
273 // 1. OBSERVE.
274 let ingress = yield* observe(output, name);
275
276 // 2. ENSURE — create if missing; a Conflict race re-observes.
277 if (ingress === undefined) {
278 yield* session.note(`creating ingress point ${name}`);
279 const created = yield* mm
280 .createIngressPoint({
281 IngressPointName: name,
282 Type: news.type,
283 RuleSetId: news.ruleSetId,
284 TrafficPolicyId: news.trafficPolicyId,
285 IngressPointConfiguration: news.ingressPointConfiguration,
286 NetworkConfiguration: news.networkConfiguration,
287 TlsPolicy: news.tlsPolicy,
288 Tags: createTagsList(desiredTags),
289 })
290 .pipe(
291 Effect.catchTag("ConflictException", () =>
292 Effect.succeed(undefined),
293 ),
294 );
295 ingress =
296 created !== undefined
297 ? yield* getById(created.IngressPointId)
298 : yield* observe(undefined, name);
299 }
300 if (ingress === undefined) {
301 return yield* Effect.fail(
302 new Error(
303 `Mail Manager ingress point '${name}' not found after create`,
304 ),
305 );
306 }
307
308 // Provisioning is asynchronous — settle before mutating.
309 const settled = yield* waitForStable(ingress.IngressPointId);
310 ingress = settled ?? ingress;
311
312 // 3. SYNC — diff OBSERVED state against desired. The auth
313 // configuration is write-only (the Get response only exposes
314 // password versions / secret ARN), so `olds` is used as a hint
315 // to push it when the prop changed.
316 const configChanged =
317 news.ingressPointConfiguration !== undefined &&
318 !sameShape(
319 olds?.ingressPointConfiguration,
320 news.ingressPointConfiguration,
321 );
322 // AWS rejects any update from or to FIPS — those transitions go
323 // through replacement (see diff), so only non-FIPS deltas are
324 // pushed here.
325 const tlsChanged =
326 news.tlsPolicy !== undefined &&
327 news.tlsPolicy !== "FIPS" &&
328 ingress.TlsPolicy !== "FIPS" &&
329 ingress.TlsPolicy !== news.tlsPolicy;
330 if (
331 ingress.IngressPointName !== name ||
332 ingress.RuleSetId !== news.ruleSetId ||
333 ingress.TrafficPolicyId !== news.trafficPolicyId ||
334 tlsChanged ||
335 configChanged
336 ) {
337 yield* mm.updateIngressPoint({
338 IngressPointId: ingress.IngressPointId,
339 IngressPointName: name,
340 RuleSetId: news.ruleSetId,
341 TrafficPolicyId: news.trafficPolicyId,
342 IngressPointConfiguration: configChanged
343 ? news.ingressPointConfiguration
344 : undefined,
345 TlsPolicy: tlsChanged ? news.tlsPolicy : undefined,
346 });
347 const updated = yield* waitForStable(ingress.IngressPointId);
348 ingress = updated ?? ingress;
349 }
350
351 // 3b. SYNC TAGS.
352 const attrs = yield* toAttrs(ingress);
353 yield* syncMailManagerTags(attrs.ingressPointArn, desiredTags);
354
355 yield* session.note(attrs.ingressPointId);
356 return { ...attrs, ingressPointName: name };
357 }),
358
359 delete: Effect.fn(function* ({ output }) {
360 // Deleting mid-provision conflicts — retry through the window.
361 yield* retryWhileMailManagerConflict(
362 mm.deleteIngressPoint({ IngressPointId: output.ingressPointId }),
363 ).pipe(
364 Effect.asVoid,
365 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
366 );
367 // Deprovisioning is asynchronous and the endpoint keeps its rule
368 // set and traffic policy "in use" until it is fully gone — wait
369 // (bounded, ~3 min) so dependent deletes don't conflict.
370 yield* repeatUntilMailManagerStable(
371 getById(output.ingressPointId),
372 (r) => r === undefined,
373 36,
374 );
375 }),
376 });
377 }),
378 );