@alvera-ai/platform-sdk 0.17.0 → 0.17.2
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 (§
|
|
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 (§
|
|
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: §
|
|
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 (§
|
|
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 §
|
|
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 §
|
|
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 —
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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 §
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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. §
|
|
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
|
|
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
|
package/.agent/mock-services.md
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1896,12 +1896,12 @@ type GenericTableListResponse = {
|
|
|
1896
1896
|
/**
|
|
1897
1897
|
* MMSCallResponse
|
|
1898
1898
|
*
|
|
1899
|
-
* MMS tool-call config — Liquid-templated recipient
|
|
1899
|
+
* MMS tool-call config — Liquid-templated recipient, optional caption, and plain public media URL.
|
|
1900
1900
|
*/
|
|
1901
1901
|
type MmsCallResponse = {
|
|
1902
|
-
body
|
|
1902
|
+
body?: SimpleTemplateConfigResponse;
|
|
1903
1903
|
/**
|
|
1904
|
-
* Public http(s) URL of the media to attach —
|
|
1904
|
+
* Public http(s) URL of the media to attach — re-staged into the tool's S3 media bucket (End User Messaging) or fetched by Twilio directly
|
|
1905
1905
|
*/
|
|
1906
1906
|
media_url: string;
|
|
1907
1907
|
to: SimpleTemplateConfigResponse;
|
|
@@ -3416,7 +3416,7 @@ type EmailResponse = {
|
|
|
3416
3416
|
*/
|
|
3417
3417
|
domain?: string;
|
|
3418
3418
|
/**
|
|
3419
|
-
* Custom Mailgun API base URL (e.g., https://api.eu.mailgun.net/v3 for EU domains, or a WireMock endpoint for integration tests)
|
|
3419
|
+
* Custom provider endpoint. Mailgun: the API base URL, which the sending domain is appended to (e.g., https://api.eu.mailgun.net/v3 for EU domains, or a WireMock endpoint for integration tests). SES: the whole request URL (e.g., https://email.us-west-2.amazonaws.com, or http://localhost:4566 for LocalStack). Leave blank for the provider's own endpoint.
|
|
3420
3420
|
*/
|
|
3421
3421
|
endpoint_url?: string | null;
|
|
3422
3422
|
/**
|
|
@@ -3850,13 +3850,21 @@ type ToolCloudWatchLogGroupResponse = CloudWatchLogGroupResponse & {
|
|
|
3850
3850
|
/**
|
|
3851
3851
|
* TwilioRequest
|
|
3852
3852
|
*
|
|
3853
|
-
* Twilio tool configuration for sending SMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity. Request
|
|
3853
|
+
* Twilio tool configuration for sending SMS and MMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity. Request
|
|
3854
3854
|
*/
|
|
3855
3855
|
type TwilioRequest = {
|
|
3856
3856
|
/**
|
|
3857
3857
|
* Twilio Account SID (required on a primary; supplied by the primary on a variant)
|
|
3858
3858
|
*/
|
|
3859
3859
|
account_sid?: string | null;
|
|
3860
|
+
/**
|
|
3861
|
+
* Twilio API Key SID (required when auth_method is api_key)
|
|
3862
|
+
*/
|
|
3863
|
+
api_key_sid?: string | null;
|
|
3864
|
+
/**
|
|
3865
|
+
* How this primary authenticates to Twilio. Defaults to auth_token.
|
|
3866
|
+
*/
|
|
3867
|
+
auth_method?: 'auth_token' | 'api_key';
|
|
3860
3868
|
base_message?: ComplexTemplateConfigRequest;
|
|
3861
3869
|
/**
|
|
3862
3870
|
* Custom Twilio API base URL (e.g., http://localhost:8080 for WireMock); leave blank for https://api.twilio.com
|
|
@@ -4474,13 +4482,21 @@ type DataSourceResponse = {
|
|
|
4474
4482
|
/**
|
|
4475
4483
|
* TwilioResponse
|
|
4476
4484
|
*
|
|
4477
|
-
* Twilio tool configuration for sending SMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity.
|
|
4485
|
+
* Twilio tool configuration for sending SMS and MMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity.
|
|
4478
4486
|
*/
|
|
4479
4487
|
type TwilioResponse = {
|
|
4480
4488
|
/**
|
|
4481
4489
|
* Twilio Account SID (required on a primary; supplied by the primary on a variant)
|
|
4482
4490
|
*/
|
|
4483
4491
|
account_sid?: string | null;
|
|
4492
|
+
/**
|
|
4493
|
+
* Twilio API Key SID (required when auth_method is api_key)
|
|
4494
|
+
*/
|
|
4495
|
+
api_key_sid?: string | null;
|
|
4496
|
+
/**
|
|
4497
|
+
* How this primary authenticates to Twilio. Defaults to auth_token.
|
|
4498
|
+
*/
|
|
4499
|
+
auth_method?: 'auth_token' | 'api_key';
|
|
4484
4500
|
base_message?: ComplexTemplateConfigResponse | null;
|
|
4485
4501
|
/**
|
|
4486
4502
|
* Custom Twilio API base URL (e.g., http://localhost:8080 for WireMock); leave blank for https://api.twilio.com
|
|
@@ -4796,12 +4812,12 @@ type InteroperabilityContractAiAgentRequestWritable = {
|
|
|
4796
4812
|
/**
|
|
4797
4813
|
* MMSCallRequest
|
|
4798
4814
|
*
|
|
4799
|
-
* MMS tool-call config — Liquid-templated recipient
|
|
4815
|
+
* MMS tool-call config — Liquid-templated recipient, optional caption, and plain public media URL. Request
|
|
4800
4816
|
*/
|
|
4801
4817
|
type MmsCallRequestWritable = {
|
|
4802
|
-
body
|
|
4818
|
+
body?: SimpleTemplateConfigRequest;
|
|
4803
4819
|
/**
|
|
4804
|
-
* Public http(s) URL of the media to attach —
|
|
4820
|
+
* Public http(s) URL of the media to attach — re-staged into the tool's S3 media bucket (End User Messaging) or fetched by Twilio directly
|
|
4805
4821
|
*/
|
|
4806
4822
|
media_url: string;
|
|
4807
4823
|
to: SimpleTemplateConfigRequest;
|
|
@@ -5160,7 +5176,7 @@ type EmailRequestWritable = {
|
|
|
5160
5176
|
*/
|
|
5161
5177
|
domain?: string;
|
|
5162
5178
|
/**
|
|
5163
|
-
* Custom Mailgun API base URL (e.g., https://api.eu.mailgun.net/v3 for EU domains, or a WireMock endpoint for integration tests)
|
|
5179
|
+
* Custom provider endpoint. Mailgun: the API base URL, which the sending domain is appended to (e.g., https://api.eu.mailgun.net/v3 for EU domains, or a WireMock endpoint for integration tests). SES: the whole request URL (e.g., https://email.us-west-2.amazonaws.com, or http://localhost:4566 for LocalStack). Leave blank for the provider's own endpoint.
|
|
5164
5180
|
*/
|
|
5165
5181
|
endpoint_url?: string | null;
|
|
5166
5182
|
/**
|
|
@@ -5960,13 +5976,25 @@ type ActionAwsLambdaCallRequestWritable = AwsLambdaCallRequestWritable & {
|
|
|
5960
5976
|
/**
|
|
5961
5977
|
* TwilioRequest
|
|
5962
5978
|
*
|
|
5963
|
-
* Twilio tool configuration for sending SMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity. Request
|
|
5979
|
+
* Twilio tool configuration for sending SMS and MMS via the Programmable Messaging API. A primary holds the account credential; a variant names its primary and carries only its own sender identity. Request
|
|
5964
5980
|
*/
|
|
5965
5981
|
type TwilioRequestWritable = {
|
|
5966
5982
|
/**
|
|
5967
5983
|
* Twilio Account SID (required on a primary; supplied by the primary on a variant)
|
|
5968
5984
|
*/
|
|
5969
5985
|
account_sid?: string | null;
|
|
5986
|
+
/**
|
|
5987
|
+
* Twilio API Key Secret (required when auth_method is api_key)
|
|
5988
|
+
*/
|
|
5989
|
+
api_key_secret?: string | null;
|
|
5990
|
+
/**
|
|
5991
|
+
* Twilio API Key SID (required when auth_method is api_key)
|
|
5992
|
+
*/
|
|
5993
|
+
api_key_sid?: string | null;
|
|
5994
|
+
/**
|
|
5995
|
+
* How this primary authenticates to Twilio. Defaults to auth_token.
|
|
5996
|
+
*/
|
|
5997
|
+
auth_method?: 'auth_token' | 'api_key';
|
|
5970
5998
|
/**
|
|
5971
5999
|
* Twilio Auth Token (required on a primary; supplied by the primary on a variant)
|
|
5972
6000
|
*/
|