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 retryWhileMailManagerConflict,
18 syncMailManagerTags,
19} from "./internal.ts";
20
21export interface ArchiveProps {
22 /**
23 * Name of the archive. If omitted, a deterministic physical name is
24 * generated from the app, stage, and logical ID. Renames apply in place.
25 */
26 archiveName?: string;
27 /**
28 * How long archived emails are retained before automatic deletion, as a
29 * Mail Manager retention enum (`THREE_MONTHS` ... `TEN_YEARS`,
30 * `PERMANENT`). The wire values are calendar periods, not arbitrary
31 * durations. Updates apply in place.
32 * @default PERMANENT
33 */
34 retentionPeriod?: mm.RetentionPeriod;
35 /**
36 * ARN of the KMS key used to encrypt the archived emails. Immutable —
37 * changing it replaces the archive.
38 * @default an AWS-owned key
39 */
40 kmsKeyArn?: string;
41 /**
42 * Tags applied to the archive. Alchemy ownership tags are merged in
43 * automatically.
44 */
45 tags?: Record<string, string>;
46}
47
48export interface Archive extends Resource<
49 "AWS.MailManager.Archive",
50 ArchiveProps,
51 {
52 /** Server-assigned ID of the archive. */
53 archiveId: string;
54 /** ARN of the archive. */
55 archiveArn: string;
56 /** Name of the archive. */
57 archiveName: string;
58 /** Current state (ACTIVE or PENDING_DELETION). */
59 archiveState: string | undefined;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * An SES Mail Manager email archive — durable storage for emails captured
67 * by an `Archive` rule action, searchable and exportable for compliance.
68 *
69 * Deleting an archive puts it into `PENDING_DELETION` for 30 days before
70 * its contents are permanently removed; the archive cannot be revived, so
71 * the provider treats a pending-deletion archive as gone.
72 * ### Creating Archives
73 * **Example:** Compliance Archive
74 * ```typescript
75 * import * as MailManager from "alchemy/AWS/MailManager";
76 *
77 * const archive = yield* MailManager.Archive("Compliance", {
78 * retentionPeriod: "ONE_YEAR",
79 * });
80 *
81 * const ruleSet = yield* MailManager.RuleSet("Inbound", {
82 * rules: [
83 * {
84 * Name: "ArchiveAll",
85 * Actions: [{ Archive: { TargetArchive: archive.archiveId } }],
86 * },
87 * ],
88 * });
89 * ```
90 *
91 * ### Searching the Archive at Runtime
92 * **Example:** Search Archived Mail from a Lambda
93 * ```typescript
94 * // init — bind the search capabilities to the archive
95 * const startSearch = yield* MailManager.StartArchiveSearch(archive);
96 * const getSearchResults = yield* MailManager.GetArchiveSearchResults(archive);
97 *
98 * // runtime
99 * const { SearchId } = yield* startSearch({
100 * FromTimestamp: new Date(Date.now() - 86_400_000),
101 * ToTimestamp: new Date(),
102 * MaxResults: 100,
103 * });
104 * ```
105 *
106 * @resource
107 */
108export const Archive = Resource<Archive>("AWS.MailManager.Archive");
109
110export const ArchiveProvider = () =>
111 Provider.effect(
112 Archive,
113 Effect.gen(function* () {
114 const createName = Effect.fn(function* (
115 id: string,
116 props: { archiveName?: string },
117 ) {
118 return (
119 props.archiveName ??
120 (yield* createPhysicalName({ id, maxLength: 64 }))
121 );
122 });
123
124 // A PENDING_DELETION archive is irrevocably deleted (contents already
125 // inaccessible) — treat it as gone everywhere.
126 const getById = (archiveId: string) =>
127 mm.getArchive({ ArchiveId: archiveId }).pipe(
128 Effect.map((a) =>
129 a.ArchiveState === "PENDING_DELETION" ? undefined : a,
130 ),
131 Effect.catchTag("ResourceNotFoundException", () =>
132 Effect.succeed(undefined),
133 ),
134 );
135
136 // Archives have no name-keyed Get — enumerate and match, skipping
137 // tombstones in PENDING_DELETION.
138 const findByName = (name: string) =>
139 mm.listArchives.pages({}).pipe(
140 Stream.runCollect,
141 Effect.map((chunk) =>
142 Array.from(chunk)
143 .flatMap((page) => page.Archives ?? [])
144 .find(
145 (a) =>
146 a.ArchiveName === name &&
147 a.ArchiveState !== "PENDING_DELETION",
148 ),
149 ),
150 );
151
152 const observe = Effect.fn(function* (
153 output: Archive["Attributes"] | undefined,
154 name: string,
155 ) {
156 if (output?.archiveId !== undefined) {
157 const found = yield* getById(output.archiveId);
158 if (found !== undefined) return found;
159 }
160 const summary = yield* findByName(name);
161 if (summary?.ArchiveId === undefined) return undefined;
162 return yield* getById(summary.ArchiveId);
163 });
164
165 const toAttrs = (archive: mm.GetArchiveResponse) => ({
166 archiveId: archive.ArchiveId,
167 archiveArn: archive.ArchiveArn,
168 archiveName: archive.ArchiveName,
169 archiveState: archive.ArchiveState,
170 });
171
172 return Archive.Provider.of({
173 stables: ["archiveId", "archiveArn"],
174
175 list: () =>
176 mm.listArchives.pages({}).pipe(
177 Stream.runCollect,
178 Effect.flatMap((chunk) =>
179 Effect.forEach(
180 Array.from(chunk)
181 .flatMap((page) => page.Archives ?? [])
182 .filter((a) => a.ArchiveState !== "PENDING_DELETION")
183 .map((a) => a.ArchiveId),
184 (archiveId) => getById(archiveId),
185 ),
186 ),
187 Effect.map((results) =>
188 results.flatMap((a) => (a === undefined ? [] : [toAttrs(a)])),
189 ),
190 ),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const name =
194 output?.archiveName ?? (yield* createName(id, olds ?? {}));
195 const archive = yield* observe(output, name);
196 if (archive === undefined) return undefined;
197 const attrs = toAttrs(archive);
198 const tags = yield* readMailManagerTags(attrs.archiveArn);
199 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
200 }),
201
202 // The KMS key is create-only — changing it replaces the archive.
203 // Name and retention update in place via UpdateArchive.
204 diff: Effect.fn(function* ({ news, olds }) {
205 if (!isResolved(news)) return undefined;
206 if (olds === undefined) return undefined;
207 if (olds.kmsKeyArn !== news.kmsKeyArn) {
208 return { action: "replace" } as const;
209 }
210 }),
211
212 reconcile: Effect.fn(function* ({ id, news, output, session }) {
213 const name = yield* createName(id, news);
214 const internalTags = yield* createInternalTags(id);
215 const desiredTags = { ...news.tags, ...internalTags };
216 const desiredRetention: mm.RetentionPeriod =
217 news.retentionPeriod ?? "PERMANENT";
218
219 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
220 let archive = yield* observe(output, name);
221
222 // 2. ENSURE — create if missing; a Conflict race re-observes by
223 // name instead of failing.
224 if (archive === undefined) {
225 yield* session.note(`creating archive ${name}`);
226 const created = yield* mm
227 .createArchive({
228 ArchiveName: name,
229 Retention: { RetentionPeriod: desiredRetention },
230 KmsKeyArn: news.kmsKeyArn,
231 Tags: createTagsList(desiredTags),
232 })
233 .pipe(
234 Effect.catchTag("ConflictException", () =>
235 Effect.succeed(undefined),
236 ),
237 );
238 archive =
239 created !== undefined
240 ? yield* getById(created.ArchiveId)
241 : yield* observe(undefined, name);
242 }
243 if (archive === undefined) {
244 return yield* Effect.fail(
245 new Error(
246 `Mail Manager archive '${name}' not found after create`,
247 ),
248 );
249 }
250
251 // 3. SYNC — diff OBSERVED name/retention against desired; apply
252 // only the delta.
253 if (
254 archive.ArchiveName !== name ||
255 archive.Retention?.RetentionPeriod !== desiredRetention
256 ) {
257 yield* mm.updateArchive({
258 ArchiveId: archive.ArchiveId,
259 ArchiveName: name,
260 Retention: { RetentionPeriod: desiredRetention },
261 });
262 archive = (yield* getById(archive.ArchiveId)) ?? archive;
263 }
264
265 // 3b. SYNC TAGS — against observed cloud tags.
266 yield* syncMailManagerTags(archive.ArchiveArn, desiredTags);
267
268 yield* session.note(archive.ArchiveId);
269 return { ...toAttrs(archive), archiveName: name };
270 }),
271
272 delete: Effect.fn(function* ({ output }) {
273 // DeleteArchive's error union carries no not-found tag — deletion
274 // is natively idempotent (the archive moves to PENDING_DELETION).
275 // An in-flight search or export reports Conflict — retry through.
276 yield* retryWhileMailManagerConflict(
277 mm.deleteArchive({ ArchiveId: output.archiveId }),
278 ).pipe(Effect.asVoid);
279 }),
280 });
281 }),
282 );