@alvera-ai/platform-sdk 0.17.0-next.gff303b1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,6 +61,9 @@ starts from there.
61
61
  | Audience contracts + audience activation client | build |
62
62
  | SMS tool (SNS-backed) | build |
63
63
  | Email tool (`provider: 'mock'`) | build |
64
+ | SES email tool (LocalStack) | build |
65
+ | SES event reader tool + SES delivery updater | build |
66
+ | SES Delivery Check workflow | build |
64
67
  | Campaign Booking Form connected app | build |
65
68
  | Loyalty Campaign workflow | build |
66
69
  | Reply contract + reply activation client | build |
@@ -130,7 +133,7 @@ it, so a suppressed customer is unselectable by *any* campaign, not
130
133
  merely hidden in a UI. `phone` and `email` are the two channel
131
134
  destinations, and their presence is the reachability gate. `bucket`
132
135
  is the A/B assignment, an integer 0–99 stamped at ingest (§006) and
133
- split on by the decision node (§009).
136
+ split on by the decision node (§010).
134
137
 
135
138
  The three PII columns are `tokenize`; `suppressed` and `bucket` are
136
139
  `none` — they are campaign mechanics, not personal data, and the
@@ -197,7 +200,7 @@ load-bearing.
197
200
 
198
201
  The data source's `uri` flows into every ingested row as
199
202
  `source_uri`, and the contracts render it onto the MDM identifier's
200
- `system`. The reply (§012) re-attaches by looking up
203
+ `system`. The reply (§013) re-attaches by looking up
201
204
  `(system, handle)`. If the reply arrived through a *different* data
202
205
  source, its `system` would differ, the lookup would miss, and the
203
206
  reply would attach to nobody. One capture system, one `uri`, one
@@ -238,7 +241,7 @@ The `legal_entity` contract shapes the MDM subject
238
241
  the table row — and its `mdm_input_config` is what tells the platform
239
242
  which subject this row belongs to. The platform then **stamps that
240
243
  subject's id onto the row** as `legal_entity_id`. That stamp is the
241
- whole point: §009's workflow resolves a row's MDM subject from the
244
+ whole point: §010's workflow resolves a row's MDM subject from the
242
245
  stamped FK, never from the row's own id.
243
246
 
244
247
  ```typescript
@@ -317,7 +320,7 @@ campaign.
317
320
  First, each recipient resolves to an **individual** legal entity —
318
321
  their own MDM subject, keyed on their contact handle (phone if
319
322
  present, else email). That subject is what the per-recipient short
320
- link (§011) is minted against.
323
+ link (§012) is minted against.
321
324
 
322
325
  Second, the generic-table contract assigns the **`bucket`**. It is
323
326
  derived from the last two digits of the vendor's own id
@@ -494,7 +497,7 @@ ctx.adaSubjectId = String(byId.get(`EC-${runSuffix}-10`)!.legal_entity_id)
494
497
 
495
498
  **Two sender tools, not one.** An SMS number and an email identity
496
499
  are different assets, provisioned and billed separately, so they are
497
- separate tools — and §009's actions bind each channel to its own.
500
+ separate tools — and §010's actions bind each channel to its own.
498
501
 
499
502
  The email tool uses `provider: 'mock'`, which delivers in-process to
500
503
  the dev mailbox at `/dev/mailbox` — no credentials, no provider
@@ -505,7 +508,7 @@ deployment to be in "test mode". Swap `mock` for `ses` and the same
505
508
  workflow sends real email.
506
509
 
507
510
  The connected app is the campaign's booking form. It is registered
508
- here, and §009's actions deep-link into it.
511
+ here, and §010's actions deep-link into it.
509
512
 
510
513
  ```typescript
511
514
  const { ToolIntent } = await import('@alvera-ai/platform-sdk')
@@ -553,7 +556,344 @@ connectedAppId = app.id!
553
556
  ctx.connectedAppSlug = app.slug!
554
557
  ```
555
558
 
556
- ## 009 — the campaign workflow
559
+ ## 009 — an SES sender on LocalStack, and its delivery read back from SES's own events
560
+
561
+ §008's email tool delivers in-process. A deployment sends through Amazon
562
+ SES, and this step proves the SES path end to end on a laptop, with
563
+ LocalStack standing in for AWS.
564
+
565
+ **An SES tool sends to its own `endpoint_url`.** It is the whole request
566
+ URL; left blank, the tool uses `https://email.<region>.amazonaws.com`.
567
+ Pointed at LocalStack (`http://localhost:4566`), every send goes there
568
+ instead, so the walk needs no AWS account. LocalStack keeps each message
569
+ it accepts, raw, at `http://localhost:4566/_aws/ses`.
570
+
571
+ **LocalStack refuses an unverified sender, as SES does.** Verify the
572
+ from address first. Outside this walk that is
573
+ `aws --endpoint-url http://localhost:4566 --region us-east-1 ses verify-email-identity --email-address <sender>`
574
+ with LocalStack's keys (`localkey` / `localsecret`); the step makes the
575
+ same call over HTTP. A send from the address before it is verified is
576
+ refused; after, it is accepted.
577
+
578
+ **A test call sends, but writes no message row.** `testInvocation` is
579
+ the quick check that the tool reaches its provider. Reconciling a
580
+ delivery needs a message row, and a message row comes from a workflow,
581
+ so the step also sends one email to one recipient from a workflow whose
582
+ only action uses the SES tool. That workflow is `status: 'manual'`: it
583
+ runs only when invoked. A `live` workflow is also fired by event
584
+ sampling, so a live check workflow over the audience would go on to
585
+ email everyone in it, the suppressed customer included, the next time
586
+ the audience changes.
587
+
588
+ **Name a status template by its provider's path.** Mailgun and SES both
589
+ ship an `email_message_status` under `status_poller`, so the bare name
590
+ answers 409 with both paths as candidates. The updater names
591
+ `status_poller/aws_ses/email_message_status` and
592
+ `status_poller/aws_ses/email_action_log_status`.
593
+
594
+ **Delivery comes from SES's own events.** SES has no CloudWatch Logs
595
+ destination of its own: its events travel configuration set →
596
+ EventBridge → rule → log group, each wrapped in EventBridge's envelope
597
+ with the SES event under `detail`. The SES templates find the message by
598
+ `detail.mail.messageId`, which is the row's `external_id`, and record:
599
+
600
+ | SES event | Message becomes | Also set |
601
+ |---|---|---|
602
+ | Delivery | `delivered` | `delivered_at` |
603
+ | Bounce, Reject | `failed` | `failure_reason` |
604
+ | Complaint | `customer_rejected` | |
605
+ | Open | `read` | `read_at` |
606
+ | Click | `clicked` | `clicked_at` |
607
+ | anything else (Send, DeliveryDelay, …) | unchanged | |
608
+
609
+ LocalStack publishes none of those events, so the step writes them
610
+ into a log group itself: a Send and a Delivery for the message it just
611
+ sent, and a Bounce for another message.
612
+
613
+ **`refresh` only enqueues a poll.** Read the updater again until
614
+ `last_run_at` is set. The poll reads the log group one page per job to
615
+ the end of its window, so `last_run_status` is `ok` when it read the
616
+ whole window and `error` when it stopped, never anything in between.
617
+
618
+ ```typescript
619
+ const { ToolIntent } = await import('@alvera-ai/platform-sdk')
620
+
621
+ const SES_ENDPOINT = 'http://localhost:4566'
622
+ const SES_LOG_GROUP = `/aws/events/cookbook-ses-${runSuffix}`
623
+ const SES_DECISION_KEY = 'ses_delivery_check'
624
+ const sesSender = `campaigns-${runSuffix}@bloom-salon.example.com`
625
+
626
+ // LocalStack reads the region and account from the Authorization header
627
+ // and does not check the signature, so a fixed header stands in for SigV4.
628
+ const localstack = (service: string, contentType: string, body: string, target?: string) =>
629
+ fetch(SES_ENDPOINT, {
630
+ method: 'POST',
631
+ headers: {
632
+ 'Content-Type': contentType,
633
+ Authorization:
634
+ `AWS4-HMAC-SHA256 Credential=localkey/20260101/us-east-1/${service}/aws4_request, ` +
635
+ 'SignedHeaders=content-type;host, Signature=localstack',
636
+ ...(target ? { 'X-Amz-Target': target } : {}),
637
+ },
638
+ body,
639
+ })
640
+ const logs = async (action: string, payload: Record<string, unknown>) => {
641
+ const res = await localstack('logs', 'application/x-amz-json-1.1', JSON.stringify(payload), `Logs_20140328.${action}`)
642
+ const text = await res.text()
643
+ if (!res.ok && !text.includes('ResourceAlreadyExistsException')) {
644
+ throw new Error(`LocalStack logs ${action} failed (${res.status}): ${text}`)
645
+ }
646
+ }
647
+
648
+ // The SES tool, pointed at LocalStack.
649
+ const { data: sesTool } = await api.tools.create(tenantSlug, datalakeSlug, {
650
+ name: `Cookbook Campaign SES Sender ${runSuffix}`,
651
+ description: 'SES email dispatcher, pointed at LocalStack by endpoint_url.',
652
+ intent: ToolIntent.EMAIL,
653
+ status: 'active',
654
+ datalake_id: ctx.datalakeId,
655
+ body: {
656
+ tool_body_type: 'email',
657
+ provider: 'ses',
658
+ from_email: sesSender,
659
+ from_name: 'Bloom Salon',
660
+ region: 'us-east-1',
661
+ access_key_id: 'localkey',
662
+ secret_access_key: 'localsecret',
663
+ endpoint_url: SES_ENDPOINT,
664
+ },
665
+ })
666
+ ctx.sesToolId = sesTool.id!
667
+ const { data: storedSes } = await api.tools.get(tenantSlug, datalakeSlug, ctx.sesToolId)
668
+ const storedSesBody = storedSes.body as { provider?: string; endpoint_url?: string | null }
669
+ if (storedSesBody.provider !== 'ses' || storedSesBody.endpoint_url !== SES_ENDPOINT) {
670
+ throw new Error(`the SES tool must keep its endpoint, read back ${JSON.stringify(storedSesBody)}`)
671
+ }
672
+
673
+ // A test call before the sender is verified is refused; after, accepted.
674
+ const testCall = {
675
+ tool_call: {
676
+ tool_call_type: 'email_request' as const,
677
+ to: { type: 'custom' as const, body: 'test-ses-check@example.com' },
678
+ subject: { type: 'custom' as const, body: 'SES check' },
679
+ body: { type: 'custom' as const, body: 'Sent through LocalStack.' },
680
+ },
681
+ }
682
+ const tryIt = async (): Promise<{ status?: string; result?: unknown; error_message?: string | null }> => {
683
+ try {
684
+ const { data } = await api.tools.testInvocation(tenantSlug, datalakeSlug, ctx.sesToolId, testCall)
685
+ return data
686
+ } catch (err) {
687
+ return { status: 'error', error_message: JSON.stringify(err) }
688
+ }
689
+ }
690
+ const refused = await tryIt()
691
+ if (refused.status !== 'error') {
692
+ throw new Error(`a send from an unverified sender must be refused, got ${JSON.stringify(refused)}`)
693
+ }
694
+ const verified = await localstack(
695
+ 'ses',
696
+ 'application/x-www-form-urlencoded',
697
+ new URLSearchParams({ Action: 'VerifyEmailIdentity', Version: '2010-12-01', EmailAddress: sesSender }).toString(),
698
+ )
699
+ if (!verified.ok) throw new Error(`VerifyEmailIdentity failed (${verified.status}): ${await verified.text()}`)
700
+ const accepted = await tryIt()
701
+ if (accepted.status !== 'success') {
702
+ throw new Error(`a send from a verified sender must be accepted, got ${JSON.stringify(accepted)}`)
703
+ }
704
+ const inbox = (await (await fetch(`${SES_ENDPOINT}/_aws/ses`)).json()) as {
705
+ messages?: Array<{ Id?: string; Source?: string }>
706
+ }
707
+ const keptByLocalStack = (inbox.messages ?? []).filter((m) => String(m.Source ?? '').includes(sesSender))
708
+ if (keptByLocalStack.length !== 1 || !JSON.stringify(accepted.result ?? {}).includes(String(keptByLocalStack[0]!.Id))) {
709
+ throw new Error(
710
+ `the test call's message must be the one LocalStack keeps: ${JSON.stringify({ accepted, keptByLocalStack })}`,
711
+ )
712
+ }
713
+
714
+ // The bare template name is ambiguous between Mailgun and SES.
715
+ let bareName: unknown
716
+ try {
717
+ await api.templates.metadataDetails(tenantSlug, datalakeSlug, 'email_message_status', 'status_poller')
718
+ } catch (err) {
719
+ bareName = err
720
+ }
721
+ if ((bareName as { _httpStatus?: number } | undefined)?._httpStatus !== 409) {
722
+ throw new Error(`the bare email_message_status must answer 409, got ${JSON.stringify(bareName)}`)
723
+ }
724
+ if (!JSON.stringify(bareName).includes('status_poller/aws_ses/email_message_status')) {
725
+ throw new Error(`the 409 must name the SES path among its candidates: ${JSON.stringify(bareName)}`)
726
+ }
727
+
728
+ // The log group SES's events land in, its reader, and the updater.
729
+ await logs('CreateLogGroup', { logGroupName: SES_LOG_GROUP })
730
+ const { data: sesReader } = await api.tools.create(tenantSlug, datalakeSlug, {
731
+ name: `Cookbook SES Event Reader ${runSuffix}`,
732
+ description: "Reads SES's delivery events from a CloudWatch log group on LocalStack.",
733
+ intent: 'status_poller',
734
+ status: 'active',
735
+ datalake_id: ctx.datalakeId,
736
+ data_source_id: dataSourceId,
737
+ body: {
738
+ tool_body_type: 'cloud_watch_log_group',
739
+ auth_method: 'access_key',
740
+ region: 'us-east-1',
741
+ endpoint_url: SES_ENDPOINT,
742
+ access_key_id: 'localkey',
743
+ secret_access_key: 'localsecret',
744
+ },
745
+ })
746
+ const { data: sesUpdater } = await api.actionStatusUpdaters.create(tenantSlug, datalakeSlug, {
747
+ name: `Cookbook SES Delivery Updater ${runSuffix}`,
748
+ cron_expression: '*/30 * * * *',
749
+ updater_type: 'cloud_watch',
750
+ updater_tool_id: sesReader.id!,
751
+ sender_tool_ids: [ctx.sesToolId],
752
+ datalake_id: ctx.datalakeId,
753
+ updater_body: {
754
+ updater_body_type: 'cloud_watch_request',
755
+ log_group_name: SES_LOG_GROUP,
756
+ start_time: '{{ now_msec | minutes_ago: 45 }}',
757
+ end_time: '{{ now_msec }}',
758
+ },
759
+ message_config: { type: 'system', path: 'status_poller/aws_ses/email_message_status' },
760
+ action_log_config: { type: 'system', path: 'status_poller/aws_ses/email_action_log_status' },
761
+ })
762
+ ctx.sesUpdaterId = sesUpdater.id!
763
+
764
+ // One email, to Ada, from a workflow whose only action is the SES tool.
765
+ const { data: sesCheck } = await api.workflows.create(tenantSlug, datalakeSlug, {
766
+ name: `Cookbook SES Delivery Check ${runSuffix}`,
767
+ description: 'One email through the SES tool, so its delivery can be read back.',
768
+ dataset_type: 'generic_table',
769
+ generic_table_id: ctx.audienceTableId,
770
+ status: 'manual',
771
+ tags: ['marketing', 'ses'],
772
+ skip_mdm_resolution: false,
773
+ filter_config: {
774
+ type: 'custom',
775
+ body: '{% if event_dataset.email and event_dataset.email != "" %}true{% endif %}',
776
+ output_schema: { type: 'boolean' },
777
+ },
778
+ decision_config: {
779
+ type: 'custom',
780
+ body: `["${SES_DECISION_KEY}"]`,
781
+ output_schema: { type: 'array', items: { type: 'string' } },
782
+ },
783
+ actions: [
784
+ {
785
+ action_type: 'email',
786
+ tool_id: ctx.sesToolId,
787
+ decision_key: SES_DECISION_KEY,
788
+ position: 0,
789
+ trigger_template: 'now',
790
+ idempotency_template: '{{ event_dataset.end_customer_id }}/ses-delivery-check',
791
+ tool_call: {
792
+ tool_call_type: 'email_request',
793
+ to: { type: 'custom', body: '{{ event_dataset.email }}' },
794
+ subject: { type: 'custom', body: 'Your slot at Bloom Salon' },
795
+ body: { type: 'custom', body: 'Your appointment is confirmed.' },
796
+ },
797
+ },
798
+ ],
799
+ })
800
+ const { data: sesRun } = await api.workflows.run(tenantSlug, datalakeSlug, sesCheck.slug!, {
801
+ sql_where_clause: `end_customer_id = 'EC-${runSuffix}-10'`,
802
+ mode: 'live',
803
+ manual_override: false,
804
+ })
805
+ await ctx.waitForFiredRun(datalakeSlug, sesRun.workflow_run_id)
806
+
807
+ const readSesMessage = async (): Promise<Record<string, unknown> | undefined> => {
808
+ const { data } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
809
+ sql:
810
+ 'SELECT external_id, status, delivered_at FROM messages ' +
811
+ `WHERE sender_tool_id = '${ctx.sesToolId}' ORDER BY inserted_at DESC LIMIT 1`,
812
+ mode: 'unregulated',
813
+ })
814
+ if (typeof data === 'string' || data.data.length === 0) return undefined
815
+ const row = data.data[0]!
816
+ return Object.fromEntries(data.meta.columns.map((c, i) => [c, row[i]]))
817
+ }
818
+ let sesMessage: Record<string, unknown> | undefined
819
+ const sentBy = Date.now() + 120_000
820
+ while (Date.now() < sentBy) {
821
+ sesMessage = await readSesMessage()
822
+ if (sesMessage?.status === 'sent' && sesMessage.external_id) break
823
+ await new Promise((r) => setTimeout(r, 2_000))
824
+ }
825
+ if (sesMessage?.status !== 'sent' || !sesMessage.external_id) {
826
+ throw new Error(`the SES send must leave a sent message with SES's id, got ${JSON.stringify(sesMessage)}`)
827
+ }
828
+ const sesMessageId = String(sesMessage.external_id)
829
+
830
+ // SES's events, in EventBridge's envelope, written where SES would put them.
831
+ const envelope = (detailType: string, detail: Record<string, unknown>) => ({
832
+ version: '0',
833
+ id: crypto.randomUUID(),
834
+ 'detail-type': detailType,
835
+ source: 'aws.ses',
836
+ account: '000000000000',
837
+ time: new Date().toISOString(),
838
+ region: 'us-east-1',
839
+ resources: [],
840
+ detail,
841
+ })
842
+ const adaEmail = `ada-${runSuffix}@example.com`
843
+ const now = Date.now()
844
+ const sesEvents = [
845
+ envelope('Email Sent', { eventType: 'Send', mail: { messageId: sesMessageId, source: sesSender, destination: [adaEmail] }, send: {} }),
846
+ envelope('Email Delivered', {
847
+ eventType: 'Delivery',
848
+ mail: { messageId: sesMessageId, source: sesSender, destination: [adaEmail] },
849
+ delivery: { timestamp: new Date(now).toISOString(), recipients: [adaEmail], smtpResponse: '250 2.0.0 OK' },
850
+ }),
851
+ envelope('Email Bounced', {
852
+ eventType: 'Bounce',
853
+ mail: { messageId: `cookbook-other-${runSuffix}`, source: sesSender, destination: ['bounce@simulator.amazonses.com'] },
854
+ bounce: {
855
+ bounceType: 'Permanent',
856
+ bounceSubType: 'General',
857
+ bouncedRecipients: [{ emailAddress: 'bounce@simulator.amazonses.com', status: '5.1.1', diagnosticCode: 'smtp; 550 5.1.1 user unknown' }],
858
+ },
859
+ }),
860
+ ]
861
+ const stream = `cookbook-${runSuffix}`
862
+ await logs('CreateLogStream', { logGroupName: SES_LOG_GROUP, logStreamName: stream })
863
+ await logs('PutLogEvents', {
864
+ logGroupName: SES_LOG_GROUP,
865
+ logStreamName: stream,
866
+ logEvents: sesEvents.map((e, i) => ({ timestamp: now - 2_000 + i * 1_000, message: JSON.stringify(e) })),
867
+ })
868
+
869
+ // Refresh enqueues one poll; read the updater until the run is stamped.
870
+ await api.actionStatusUpdaters.refresh(tenantSlug, datalakeSlug, ctx.sesUpdaterId)
871
+ type UpdaterRun = { last_run_at?: string | null; last_run_status?: string | null; last_run_events_found?: number | null }
872
+ let sesRun2: UpdaterRun = {}
873
+ const polledBy = Date.now() + 120_000
874
+ while (Date.now() < polledBy) {
875
+ const { data } = await api.actionStatusUpdaters.get(tenantSlug, datalakeSlug, ctx.sesUpdaterId)
876
+ sesRun2 = data as UpdaterRun
877
+ if (sesRun2.last_run_at && sesRun2.last_run_status) break
878
+ await new Promise((r) => setTimeout(r, 2_000))
879
+ }
880
+ if (sesRun2.last_run_status !== 'ok' || (sesRun2.last_run_events_found ?? 0) < sesEvents.length) {
881
+ throw new Error(`the poll must read every event and end ok, got ${JSON.stringify(sesRun2)}`)
882
+ }
883
+
884
+ // The Delivery moved the message; the Bounce, for another message, did not.
885
+ const deliveredBy = Date.now() + 60_000
886
+ while (Date.now() < deliveredBy) {
887
+ sesMessage = await readSesMessage()
888
+ if (sesMessage?.status === 'delivered') break
889
+ await new Promise((r) => setTimeout(r, 2_000))
890
+ }
891
+ if (sesMessage?.status !== 'delivered' || !sesMessage.delivered_at) {
892
+ throw new Error(`SES's Delivery must leave the message delivered, got ${JSON.stringify(sesMessage)}`)
893
+ }
894
+ ```
895
+
896
+ ## 010 — the campaign workflow
557
897
 
558
898
  This is where all four rules live.
559
899
 
@@ -671,7 +1011,7 @@ if ((workflow.actions ?? []).length !== 4) {
671
1011
  }
672
1012
  ```
673
1013
 
674
- ## 010 — run the campaign once
1014
+ ## 011 — run the campaign once
675
1015
 
676
1016
  One pass over all four recipients. Both gates and the split run in
677
1017
  the same run — the two reachable, un-suppressed recipients send; the
@@ -742,12 +1082,12 @@ for (const wel of sent) {
742
1082
  }
743
1083
  ```
744
1084
 
745
- ## 011 — the per-recipient short link
1085
+ ## 012 — the per-recipient short link
746
1086
 
747
1087
  Every message the campaign rendered carries a `/t/<hash>` link. It is
748
1088
  not one campaign link shared by everyone: the platform minted a page
749
1089
  token against **this recipient's own MDM subject** — the one §006
750
- stamped and §009 resolved — so the click is attributable to one
1090
+ stamped and §010 resolved — so the click is attributable to one
751
1091
  person.
752
1092
 
753
1093
  `resolvePage` exchanges the hash for the page, and
@@ -796,7 +1136,7 @@ if (!tracked.message?.opened_at) {
796
1136
  }
797
1137
  ```
798
1138
 
799
- ## 012 — the reply comes back, and attaches
1139
+ ## 013 — the reply comes back, and attaches
800
1140
 
801
1141
  A reply arrives as an inbound message on the same handle the campaign
802
1142
  sent to. It re-ingests through a reply activation client into the
@@ -922,7 +1262,7 @@ while (Date.now() < flagDeadline) {
922
1262
  if (!flagged) throw new Error('the re-ingest did not flag the reply potential_duplicate within 120s')
923
1263
  ```
924
1264
 
925
- ## 013 — write the integration test
1265
+ ## 014 — write the integration test
926
1266
 
927
1267
  End the build with a test you keep: re-read the workflow and prove the
928
1268
  pipeline still executes — without a side effect. `mode: 'dry_run'` with a
@@ -978,9 +1318,15 @@ chase the error.
978
1318
  variant. Had it been random, a mid-campaign re-ingest would move
979
1319
  people between arms and the A/B result would be unreadable.
980
1320
  - **The ambiguous reply attaches and is flagged.** It is never
981
- withheld and never an error. §012's detection query is what finds
1321
+ withheld and never an error. §013's detection query is what finds
982
1322
  the ambiguity; the flag is what routes it to a human. The platform
983
1323
  does not silently pick a "best" match and hide the choice.
1324
+ - **An unverified SES sender is refused, not queued.** LocalStack, like
1325
+ SES, refuses a send from an address it has not verified; the tool and
1326
+ its keys are fine, and verifying the address is the whole fix.
1327
+ - **The bare `email_message_status` is a 409, not a default.** Two
1328
+ providers ship a template by that name, so name the provider's path
1329
+ (`status_poller/aws_ses/...`) rather than hoping for Mailgun's.
984
1330
  - **`provider: 'mock'` is data, not a deploy mode.** It sits on the
985
1331
  tool row, so the same manifest runs anywhere; the tool — not the
986
1332
  environment — decides where email goes.
@@ -994,7 +1340,7 @@ is cheap to reset (`mix ecto.reset` on the platform repo).
994
1340
 
995
1341
  # Outcome
996
1342
 
997
- After this cookbook's twelve steps run green:
1343
+ After this cookbook's thirteen steps run green:
998
1344
 
999
1345
  - A business roster and an end-customer audience exist as generic
1000
1346
  tables, and the foundation datalake's **system** inbound-messages
@@ -1014,6 +1360,9 @@ After this cookbook's twelve steps run green:
1014
1360
  - A reply re-ingested on the sender's handle attached back to the
1015
1361
  customer who sent it, and the duplicate-handle path flags rather
1016
1362
  than blocks
1363
+ - An SES tool pointed at LocalStack sent one email from a workflow,
1364
+ and SES's own Delivery event, read from a CloudWatch log group by
1365
+ the SES status templates, moved that message to `delivered`
1017
1366
 
1018
1367
  The business outcome — a compliant, attributable, A/B-split campaign
1019
1368
  that can be replied to — is demonstrated end-to-end, not merely
@@ -14,9 +14,12 @@ third-party APIs the platform integrates with — Athenahealth, Twilio, Mailgun,
14
14
  Stripe, SendGrid, HubSpot, QuickBooks, Airtable, Atomic FI, the LLM providers,
15
15
  and a small set of AWS endpoints LocalStack cannot serve.
16
16
 
17
- **LocalStack** serves AWS itself: `s3,lambda,sns,ssm,sqs,sts,logs,secretsmanager,cloudformation`
17
+ **LocalStack** serves AWS itself: `s3,lambda,sns,ssm,sqs,sts,logs,secretsmanager,cloudformation,ses`
18
18
  (`local-dependencies.yml`). See [Datalakes](datalakes.md) for how datalake
19
- provisioning uses it.
19
+ provisioning uses it. An SES email tool whose endpoint URL is `http://localhost:4566`
20
+ sends into LocalStack once the sender's address is verified there
21
+ (`aws ses verify-email-identity`), and LocalStack keeps each message, raw, at
22
+ `http://localhost:4566/_aws/ses`.
20
23
 
21
24
  The division is not arbitrary. LocalStack Community **cannot** serve
22
25
  `pinpoint-sms-voice-v2` (501 — Ultimate tier only), so End User Messaging points
@@ -71,7 +74,7 @@ after one:
71
74
 
72
75
  ```bash
73
76
  AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 \
74
- aws s3 mb s3://healthcare-lake --endpoint-url http://localhost:4566
77
+ aws s3 mb s3://healthcare-lake-regulated --endpoint-url http://localhost:4566
75
78
  ```
76
79
 
77
80
  ## The hosted mock — `wiremock.alvera.ai`
@@ -84,11 +84,11 @@ Sends an SMS message via the configured SMS provider.
84
84
 
85
85
  ## Tool Call Configuration (mms_request)
86
86
 
87
- Sends an MMS (media message) via AWS End User Messaging.
87
+ Sends an MMS (media message) via AWS End User Messaging or Twilio.
88
88
 
89
89
  - **to** (TemplateConfig) — Recipient phone number in E.164 format, rendered as Liquid template
90
90
  - **body** (TemplateConfig) — Message body (caption), rendered as Liquid template
91
- - **media_url** (string) — Public http(s) URL of the media to attach; verified reachable (GET 2xx + image/*) at authoring, fetched and re-staged into the tool's S3 media bucket at send
91
+ - **media_url** (string) — Public http(s) URL of the media to attach; verified reachable (GET 2xx + image/*) at authoring, fetched at send (re-staged into the tool's S3 media bucket for End User Messaging; passed to Twilio as `MediaUrl`)
92
92
 
93
93
  <!-- alvera:section kind="tool-call-config" atom="email_request" module="Platform.Tools.ToolCallConfig.EmailRequest" -->
94
94