Skip to content

Commit 036bc51

Browse files
authored
chore(release): 0.37.0 — mailchimp campaign-loop actions (reports, content, segments) (#92)
Adds the actions that close a generate→send→measure→optimize email loop on the existing Mailchimp connector: - reports.get / reports.list — opens, clicks, unsubscribes, abuse_reports (complaint guardrail) and ecommerce revenue (reward signal). - campaigns.set-content — campaigns.create only makes an empty shell; this PUTs the generated email body in. - segments.list — target a slice and carve out a control/holdout slice. reports.*/segments.list are reads; campaigns.set-content is an idempotent PUT mutation. campaigns.create/send already shipped in the write-side fanout. typecheck clean; full suite 4061 pass; build green. (These changes were first merged to the stale `main` branch as #91; this lands them on `develop` — the real release line — and cuts 0.37.0.)
1 parent da6a1ee commit 036bc51

3 files changed

Lines changed: 154 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.36.0",
3+
"version": "0.37.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {

src/connectors/adapters/__tests__/mailchimp.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,38 @@ describe('mailchimp adapter', () => {
4646
'campaigns.create',
4747
'campaigns.list',
4848
'campaigns.send',
49+
'campaigns.set-content',
4950
'lists.get',
5051
'lists.list',
5152
'members.delete-permanent',
5253
'members.get',
5354
'members.search',
5455
'members.update-tags',
5556
'members.upsert',
57+
'reports.get',
58+
'reports.list',
59+
'segments.list',
5660
])
5761
const readers = mailchimpConnector.manifest.capabilities.filter((c) => c.class === 'read').map((c) => c.name).sort()
5862
const mutators = mailchimpConnector.manifest.capabilities.filter((c) => c.class === 'mutation').map((c) => c.name).sort()
59-
expect(readers).toEqual(['campaigns.list', 'lists.get', 'lists.list', 'members.get', 'members.search'])
60-
expect(mutators).toEqual(['campaigns.create', 'campaigns.send', 'members.delete-permanent', 'members.update-tags', 'members.upsert'])
63+
expect(readers).toEqual([
64+
'campaigns.list',
65+
'lists.get',
66+
'lists.list',
67+
'members.get',
68+
'members.search',
69+
'reports.get',
70+
'reports.list',
71+
'segments.list',
72+
])
73+
expect(mutators).toEqual([
74+
'campaigns.create',
75+
'campaigns.send',
76+
'campaigns.set-content',
77+
'members.delete-permanent',
78+
'members.update-tags',
79+
'members.upsert',
80+
])
6181
})
6282

6383
it('exposes both executeRead and executeMutation handlers', () => {
@@ -129,6 +149,49 @@ describe('mailchimp adapter', () => {
129149
expect(url.pathname).toBe('/3.0/campaigns/camp_99/actions/send')
130150
expect(init.method).toBe('POST')
131151
})
152+
153+
it('reads a campaign performance report (the loop feedback signal) by campaign id', async () => {
154+
const fetchMock = mockFetch({
155+
id: 'camp_99',
156+
opens: { open_rate: 0.41 },
157+
clicks: { click_rate: 0.12 },
158+
unsubscribed: 3,
159+
abuse_reports: 0,
160+
ecommerce: { total_orders: 18, total_revenue: 2940.5 },
161+
})
162+
const invocation: ConnectorInvocation = {
163+
source,
164+
capabilityName: 'reports.get',
165+
args: { campaignId: 'camp_99' },
166+
idempotencyKey: 'report_1',
167+
}
168+
169+
await mailchimpConnector.executeRead!(invocation)
170+
171+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
172+
expect(url.origin).toBe('https://us20.api.mailchimp.com')
173+
expect(url.pathname).toBe('/3.0/reports/camp_99')
174+
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer mc_token_xyz' })
175+
})
176+
177+
it('sets draft campaign content with PUT against the content endpoint and forwards the body verbatim', async () => {
178+
const fetchMock = mockFetch({ html: '<h1>Hi</h1>' })
179+
const contentBody = { html: '<h1>Hi {{FNAME}}</h1><p>New drop.</p>' }
180+
const invocation: ConnectorInvocation = {
181+
source,
182+
capabilityName: 'campaigns.set-content',
183+
args: { campaignId: 'camp_99', fields: contentBody },
184+
idempotencyKey: 'content_1',
185+
}
186+
187+
const result = await mailchimpConnector.executeMutation!(invocation)
188+
expect(result.status).toBe('committed')
189+
190+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
191+
expect(url.pathname).toBe('/3.0/campaigns/camp_99/content')
192+
expect(init.method).toBe('PUT')
193+
expect(JSON.parse(String(init.body))).toEqual(contentBody)
194+
})
132195
})
133196

134197
function mockFetch(body: unknown, init: { status?: number; headers?: Record<string, string> } = {}) {

src/connectors/adapters/mailchimp.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { declarativeRestConnector } from './declarative-rest.js'
22

33
/**
4-
* Mailchimp Marketing API v3.0 — audience/list management, member upsert,
5-
* campaign send. Auth is OAuth2 with a per-account datacenter prefix; after
4+
* Mailchimp Marketing API v3.0 — audience/list/segment management, member
5+
* upsert, campaign create/content/send, and campaign performance reports
6+
* (the open/click/unsubscribe/abuse rates plus ecommerce revenue that close a
7+
* generate→send→measure→optimize loop). Auth is OAuth2 with a per-account
8+
* datacenter prefix; after
69
* the token exchange callers MUST hit `https://login.mailchimp.com/oauth2/metadata`
710
* with the access token and persist `api_endpoint` (e.g. `https://us20.api.mailchimp.com`)
811
* into the data source `metadata.apiEndpoint` field. The declarative REST
@@ -62,6 +65,26 @@ export const mailchimpConnector = declarativeRestConnector({
6265
},
6366
request: { method: 'GET', path: '/3.0/lists/{listId}' },
6467
},
68+
{
69+
name: 'segments.list',
70+
class: 'read',
71+
description:
72+
'List the segments of a Mailchimp audience. Use a segment id in `campaigns.create` recipients to target a slice — and to carve out a control/holdout slice for measuring true lift.',
73+
parameters: {
74+
type: 'object',
75+
properties: {
76+
listId: { type: 'string' },
77+
count: { type: 'number' },
78+
offset: { type: 'number' },
79+
},
80+
required: ['listId'],
81+
},
82+
request: {
83+
method: 'GET',
84+
path: '/3.0/lists/{listId}/segments',
85+
query: { count: '{count}', offset: '{offset}' },
86+
},
87+
},
6588
{
6689
name: 'members.search',
6790
class: 'read',
@@ -219,5 +242,68 @@ export const mailchimpConnector = declarativeRestConnector({
219242
cas: 'native-idempotency',
220243
externalEffect: true,
221244
},
245+
{
246+
name: 'campaigns.set-content',
247+
class: 'mutation',
248+
description:
249+
'Set the content of a draft Mailchimp campaign. `campaigns.create` only makes an empty shell — this puts the generated email body in. Body matches the v3.0 campaign-content schema, e.g. `{ html }` or `{ plain_text }` or `{ template: { id, sections } }`.',
250+
parameters: {
251+
type: 'object',
252+
properties: {
253+
campaignId: { type: 'string' },
254+
fields: {
255+
type: 'object',
256+
description: 'Campaign content body; typically `{ html }`. PUT replaces the content wholesale.',
257+
},
258+
},
259+
required: ['campaignId', 'fields'],
260+
},
261+
request: {
262+
method: 'PUT',
263+
path: '/3.0/campaigns/{campaignId}/content',
264+
body: '{fields}',
265+
},
266+
cas: 'native-idempotency',
267+
},
268+
{
269+
name: 'reports.list',
270+
class: 'read',
271+
description:
272+
'List campaign performance reports. Each entry carries the open/click/unsubscribe/abuse rates and (when an ecommerce store is connected) revenue — the feedback signal the optimizer reads to learn what worked.',
273+
parameters: {
274+
type: 'object',
275+
properties: {
276+
count: { type: 'number' },
277+
offset: { type: 'number' },
278+
type: { type: 'string', description: 'Filter by campaign type, e.g. regular, plaintext, rss.' },
279+
since_send_time: {
280+
type: 'string',
281+
description: 'ISO 8601 lower bound on send time, e.g. 2026-06-01T00:00:00Z — read only recent sends.',
282+
},
283+
},
284+
},
285+
request: {
286+
method: 'GET',
287+
path: '/3.0/reports',
288+
query: {
289+
count: '{count}',
290+
offset: '{offset}',
291+
type: '{type}',
292+
since_send_time: '{since_send_time}',
293+
},
294+
},
295+
},
296+
{
297+
name: 'reports.get',
298+
class: 'read',
299+
description:
300+
'Read the performance report for one sent campaign: opens, clicks, unsubscribes, abuse_reports (the complaint signal), bounces, list_stats, and ecommerce { total_orders, total_spent, total_revenue }. This is the reward+guardrail signal for the optimization loop.',
301+
parameters: {
302+
type: 'object',
303+
properties: { campaignId: { type: 'string' } },
304+
required: ['campaignId'],
305+
},
306+
request: { method: 'GET', path: '/3.0/reports/{campaignId}' },
307+
},
222308
],
223309
})

0 commit comments

Comments
 (0)