1import * as contacts from "@distilled.cloud/aws/notificationscontacts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface EmailContactProps {
15 /**
16 * Display name of the contact (1–64 characters). If omitted, a unique
17 * name is generated from the app, stage and logical ID. Contacts have no
18 * update API, so changing the name replaces the contact.
19 */
20 name?: string;
21
22 /**
23 * The email address that receives notifications. Unique per account —
24 * AWS rejects a second contact with the same address. Changing the
25 * address replaces the contact.
26 *
27 * A new contact is created in the unverified `inactive` state; AWS sends
28 * notifications to it only after the address owner confirms the
29 * activation email (`SendActivationCode` / the console).
30 */
31 emailAddress: string;
32
33 /**
34 * User tags to attach to the contact. Merged with internal Alchemy tags.
35 */
36 tags?: Record<string, string>;
37}
38
39export interface EmailContact extends Resource<
40 "AWS.NotificationsContacts.EmailContact",
41 EmailContactProps,
42 {
43 /** The ARN of the email contact (regionless). */
44 emailContactArn: string;
45 /** The display name of the contact. */
46 name: string;
47 /** The email address of the contact. */
48 emailAddress: string;
49 /**
50 * Verification status: `inactive` until the address owner confirms the
51 * activation email, then `active`.
52 */
53 status: string;
54 },
55 never,
56 Providers
57> {}
58
59/**
60 * An AWS User Notifications Contacts **email contact** — an email address
61 * that can be attached to a notification configuration as a delivery
62 * channel.
63 *
64 * Contacts are created in the unverified `inactive` state; activation is a
65 * human email-confirmation loop (AWS emails the address a confirmation
66 * link), so Alchemy provisions the contact and leaves activation to the
67 * address owner. Contacts are immutable (no update API) — changing the
68 * name or address replaces the contact; tags update in place.
69 *
70 * ### Creating an Email Contact
71 * **Example:** Basic email contact
72 * ```typescript
73 * import * as NotificationsContacts from "alchemy/AWS/NotificationsContacts";
74 *
75 * const contact = yield* NotificationsContacts.EmailContact("OnCall", {
76 * emailAddress: "oncall@example.com",
77 * });
78 * // contact.status === "inactive" until the address owner confirms
79 * ```
80 *
81 * **Example:** Named contact with tags
82 * ```typescript
83 * const contact = yield* NotificationsContacts.EmailContact("OnCall", {
84 * name: "platform-oncall",
85 * emailAddress: "oncall@example.com",
86 * tags: { team: "platform" },
87 * });
88 * ```
89 *
90 * @resource
91 */
92export const EmailContact = Resource<EmailContact>(
93 "AWS.NotificationsContacts.EmailContact",
94);
95
96/** Unwrap distilled `SensitiveString` values into plain strings. */
97const unwrapSensitive = (value: string | Redacted.Redacted<string>): string =>
98 typeof value === "string" ? value : Redacted.value(value);
99
100const readContactTags = Effect.fn(function* (arn: string) {
101 return yield* contacts.listTagsForResource({ arn }).pipe(
102 Effect.map((r) => (r.tags ?? {}) as Record<string, string>),
103 Effect.catchTag(["ResourceNotFoundException", "ValidationException"], () =>
104 Effect.succeed({} as Record<string, string>),
105 ),
106 );
107});
108
109const syncContactTags = Effect.fn(function* (
110 arn: string,
111 id: string,
112 userTags: Record<string, string> | undefined,
113) {
114 const internalTags = yield* createInternalTags(id);
115 const desired = { ...userTags, ...internalTags };
116 const observed = yield* readContactTags(arn);
117 const { upsert, removed } = diffTags(observed, desired);
118 if (upsert.length > 0) {
119 yield* contacts.tagResource({
120 arn,
121 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
122 });
123 }
124 if (removed.length > 0) {
125 yield* contacts.untagResource({ arn, tagKeys: removed });
126 }
127});
128
129export const EmailContactProvider = () =>
130 Provider.effect(
131 EmailContact,
132 Effect.gen(function* () {
133 const createName = Effect.fn(function* (
134 id: string,
135 props: EmailContactProps,
136 ) {
137 return props.name ?? (yield* createPhysicalName({ id }));
138 });
139
140 // Email addresses are unique account-wide (CreateEmailContact rejects
141 // duplicates with ConflictException), so an address scan is a
142 // reliable identity fallback when the ARN cache is missing.
143 const findByAddress = Effect.fn(function* (emailAddress: string) {
144 return yield* contacts.listEmailContacts.items({}).pipe(
145 Stream.filter(
146 (contact) => unwrapSensitive(contact.address) === emailAddress,
147 ),
148 Stream.runHead,
149 Effect.map(Option.getOrUndefined),
150 );
151 });
152
153 const getByArn = Effect.fn(function* (arn: string) {
154 return yield* contacts.getEmailContact({ arn }).pipe(
155 Effect.map((r) => r.emailContact),
156 Effect.catchTag("ResourceNotFoundException", () =>
157 Effect.succeed(undefined),
158 ),
159 );
160 });
161
162 const toAttrs = (live: contacts.EmailContact) => ({
163 emailContactArn: live.arn,
164 name: unwrapSensitive(live.name),
165 emailAddress: unwrapSensitive(live.address),
166 status: live.status,
167 });
168
169 return EmailContact.Provider.of({
170 stables: ["emailContactArn", "name", "emailAddress"],
171
172 list: () =>
173 contacts.listEmailContacts.items({}).pipe(
174 Stream.runCollect,
175 Effect.map((chunk) => Array.from(chunk).map(toAttrs)),
176 ),
177
178 read: Effect.fn(function* ({ id, olds, output }) {
179 const found = output?.emailContactArn
180 ? yield* getByArn(output.emailContactArn)
181 : olds?.emailAddress
182 ? yield* findByAddress(olds.emailAddress)
183 : undefined;
184 if (!found) return undefined;
185 const attrs = toAttrs(found);
186 const tags = yield* readContactTags(found.arn);
187 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
188 }),
189
190 diff: Effect.fn(function* ({ id, news, olds }) {
191 if (!isResolved(news)) return undefined;
192 const oldName = yield* createName(id, olds);
193 const newName = yield* createName(id, news);
194 // No update API — any change to the contact itself replaces it.
195 if (oldName !== newName || news.emailAddress !== olds.emailAddress) {
196 return { action: "replace" } as const;
197 }
198 return undefined;
199 }),
200
201 reconcile: Effect.fn(function* ({ id, news, output, session }) {
202 const name = yield* createName(id, news);
203
204 // OBSERVE — cloud state is authoritative; the cached ARN falls
205 // through to an address scan when it no longer resolves.
206 let live = output?.emailContactArn
207 ? yield* getByArn(output.emailContactArn)
208 : yield* findByAddress(news.emailAddress);
209
210 // ENSURE — create when missing; ConflictException means a
211 // same-address contact already exists (race) → re-observe.
212 if (live === undefined) {
213 const created = yield* contacts
214 .createEmailContact({
215 name,
216 emailAddress: news.emailAddress,
217 tags: news.tags,
218 })
219 .pipe(
220 Effect.catchTag("ConflictException", () =>
221 Effect.succeed(undefined),
222 ),
223 );
224 live = created
225 ? yield* getByArn(created.arn)
226 : yield* findByAddress(news.emailAddress);
227 }
228 const arn = live!.arn;
229
230 // SYNC tags — the contact itself is immutable; tags are the only
231 // mutable aspect (diffed against observed cloud tags).
232 yield* syncContactTags(arn, id, news.tags);
233
234 yield* session.note(arn);
235 return toAttrs(live!);
236 }),
237
238 delete: Effect.fn(function* ({ output }) {
239 yield* contacts
240 .deleteEmailContact({ arn: output.emailContactArn })
241 .pipe(
242 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
243 );
244 }),
245 });
246 }),
247 );