DEV Community

Cover image for Teaching an AI agent what our newsletters said: a Mailchimp connector for cognee
Anand Prakash Srivastava
Anand Prakash Srivastava

Posted on AI-assisted

Teaching an AI agent what our newsletters said: a Mailchimp connector for cognee

Every company that sends newsletters has the same small problem. Someone asks "when did we tell customers the new pricing starts?" and the answer is sitting in a campaign from two months ago. Nobody remembers which one. You end up scrolling through Mailchimp's campaign list and opening emails one by one.

For Mergetober I wrote a Mailchimp connector for cognee, an open source memory layer for AI agents. It pulls your campaigns (and, if you want, the notes you keep on contacts) into cognee, so an agent can answer questions about what you actually sent. It is PR #428 in cognee-community, for issue #4784.

Most of the work turned out not to be "call the API". It was getting clean text out of an email, and working out when a campaign has changed when Mailchimp does not tell you.

How I use it

I set up a small product launch across three tools to try this properly: the discussion in Twist, the tasks in Basecamp, and the customer announcement in Mailchimp. I wrote connectors for all three (the Twist and Basecamp posts are separate). The Mailchimp part is the customer facing side: what did we actually tell people, and when.

My account has a campaign called "Checkout launch(march)" whose email says the new checkout ships on 15 March and that the payments team owns it. After a sync this is the document cognee gets. Real output from my account:

Mailchimp campaign: Checkout launch(march)
Subject: New checkout is coming
Audience: Anand Test Workspace
Status: draft; a send started on 2026-10-16 was stopped

Checkout launch

The new checkout flow ships on 15 March. The payments team owns it.
Enter fullscreen mode Exit fullscreen mode

That status line is real too, and there is a story behind it further down.

Using it looks like this:

import os
import cognee
from cognee_community_connector_mailchimp import mailchimp_source

api_key = open(os.path.expanduser("~/.mailchimp_key")).read().strip()

await cognee.remember(
    mailchimp_source(api_key, include_unsent=True, include_notes=True),
    dataset_name="mailchimp",
    write_disposition="merge",
)
print(await cognee.recall("When does the new checkout ship, and who owns it?", datasets=["mailchimp"]))
Enter fullscreen mode Exit fullscreen mode

For this test I used Gemini for extraction and answers, with local embeddings. I added a second draft, "Pricing update", through the API so I could test a hand-coded HTML campaign too. Then I asked questions, deleted that draft, synced again and asked the same question:

Step Question Answer from cognee
Sync (2 campaigns + 1 note) When does the new checkout flow ship, and who owns it? "The new checkout flow ships on 15 March (2026) and is owned by the payments team."
When does the new pricing start and what will the Starter plan cost? "The new pricing starts on 1 April, and the Starter plan will cost 9 EUR a month."
What does the contact prefer to hear about? "Updates about the checkout redesign."
Delete "Pricing update", sync again When does the new pricing start and what will the Starter plan cost? "...there is no information about when the new pricing starts or what the Starter plan will cost."

After the delete I also checked the graph directly. There was no node left for Starter, pricing, 9 EUR or April, while "new checkout flow" and "payments team" were still there. That is the behaviour I wanted: deleting a campaign in Mailchimp should make the agent forget it, not just hide it.

How it is put together

  • One document per campaign, keyed campaign:<id>: title, subject, preview text, audience, sent date or status, then the email text, plus the archive URL.
  • With include_notes=True, one document per contact note. The note is tied to the contact's name. The email address is only included if you also pass include_contact_email=True, and address, phone and birthday fields are never requested at all.
  • Every row carries a node set mailchimp:<audience_id>, so you can scope recall to one audience. I used the audience id, not the name, so renaming an audience does not split its documents in two.
  • It is a dlt source, loaded with write_disposition="merge". Deletes are rows with _deleted: True, which dlt removes and cognee then forgets.

The API key decides the host

Mailchimp keys look like <32 hex characters>-us22. The part after the dash is the data centre, and the API host is https://us22.api.mailchimp.com/3.0. I build the host only from that suffix, after checking the key's shape, so user input never ends up in a URL:

_API_KEY = re.compile(r"^[0-9a-f]{32}-([a-z]{2,}[0-9]+)$")

def data_centre(api_key: str) -> str:
    match = _API_KEY.match(api_key.strip())
    if not match:
        raise ValueError(
            "That does not look like a Mailchimp API key "
            "(32 hex characters, a dash and the data centre, e.g. ...-us22)."
        )
    return match.group(1)
Enter fullscreen mode Exit fullscreen mode

The key never goes into a log line or an error message. Error messages name the endpoint with ids replaced (campaigns/{id}/content), so a stack trace does not leak which campaign or contact it was.

Getting the text the author wrote

I assumed plain_text would be the easy answer. It is not. Campaigns made in Mailchimp's current builder have no plain_text at all until they are sent. And after sending, this is what you get:

One sentence of content, surrounded by "View this email in your browser", two "Logo" lines, a footer, and unresolved merge tags like *|UNSUB|* and *|LIST:COMPANY|*. Send that to an LLM for graph extraction and you are asking for entities like "Logo" and "mailing address" next to your real content.

archive_html turned out to be better. It exists before sending, and its merge tags are already resolved. So the connector parses archive_html with Python's HTMLParser and skips three things:

  1. the hidden preheader (display:none),
  2. the footer block (Mailchimp marks it with classes like mceFooterSection),
  3. the "View this email in your browser" link, which points to campaign-archive.com before sending and mailchi.mp after.

The tests use two real archive_html captures from my account (ids replaced), so if Mailchimp changes its template markup, a test should notice.

Knowing when a campaign changed

This was the real design problem. Mailchimp campaigns have no "updated at" field. The listing can filter by send time or create time, and the issue suggested since_send_time as a cursor. I tried it on the live account:

  • It returned 0 campaigns while two drafts existed.
  • It missed a rename of a sent campaign.
  • It can never show you a delete.

So I dropped it. Every run lists every campaign (1000 per request, asking only for the fields I need), and for each one compares a small set of fields with the previous run:

def _signal(campaign: dict) -> list:
    settings = campaign.get("settings") or {}
    recipients = campaign.get("recipients") or {}
    status = "sent" if campaign.get("status") in SENT_STATUSES else campaign.get("status")
    return [
        status,
        campaign.get("send_time") or "",
        settings.get("title") or "",
        settings.get("subject_line") or "",
        settings.get("preview_text") or "",
        recipients.get("list_id") or "",
        recipients.get("list_name") or "",
    ]
Enter fullscreen mode Exit fullscreen mode

A sent campaign cannot be edited, so its text is fetched once and again only if one of these fields moves. Drafts are trickier: I edited a draft's body and no field changed at all. So drafts (opt-in with include_unsent=True, because unfinished drafts are often full of template placeholder text) have their content read every run and compared by a content hash. Notes are similar: adding or deleting a note never moves the contact's last_changed, so notes are read every run, but only for contacts that have a last_note.

Two live runs back to back, today:

The send that got halted

Here is the story behind that status line. While I was testing, I sent the checkout campaign to my own test audience. Mailchimp put the new account under review and halted the send. On the API side:

  • the status went from sending back to save, but send_time was kept,
  • and archive_html stopped being HTML. It became plain text with one <br /> per line.

My HTML parser let the whole footer through on that run, because there were no footer classes left to match. I would never have thought to test for this. Now the connector detects an archive_html with no tags other than <br>, treats it as plain text and trims it like plain_text, and the status line says "draft; a send started on 2026-10-16 was stopped" so the agent does not claim it was sent. There is a test built from that exact capture.

These were my live runs on the account:

Run Change in Mailchimp Result
1 First sync (campaign in sending, one contact note) 1 campaign + 1 note, without the email address
2 Nothing 0 changed
3 Send halted by Mailchimp; a new empty draft created both re-read (this is where the footer leaked; fixed and rerun)
4 Template applied to the new draft 1 changed (content hash)
5 Draft text edited 1 changed, next run 0
6 Halted campaign renamed 1 changed, next run 0
7 Draft deleted 1 deleted, row gone; next run 0 deleted

Forgetting, carefully

A campaign or note missing from a complete listing becomes a _deleted row. Three things stop that from going wrong:

  • A 404 is never a delete. /reports/{id} keeps answering for a deleted campaign, so it is never used to check.
  • Offset paging can skip an item. If a campaign is deleted between page 1 and page 2, everything shifts and one item can be missed for that run. So if a listing's total changed while it was being read, the run forgets nothing and the next run decides.
  • Mass deletes wait one run. If more than half of what was synced is suddenly gone (and at least 10 items), which is what a wrong audience filter looks like, the deletes are held and reported as held_back. The next run forgets the ones still gone and keeps the ones that came back.

I did not get that last one right the first time. My Twist connector had the same logic, and the reviewer there found that a batch was only confirmed if the next run saw exactly the same items gone. Delete a couple more campaigns in between and the batch was held again, forever. I fixed it in Twist and then applied the same fix here, along with the Retry-After edge cases from the same review (0, negative and nan now use backoff, and any wait over 60 seconds stops the run as a rate limit, since cognee holds its staging lock while a source runs). Each fix has a test that fails when the fix is reverted.

Tests

The tests run against an in-memory fake of the Mailchimp API built from what the live account returned. No key needed. One test goes through cognee itself, with the LLM mocked, and checks that a deleted campaign and a deleted note leave both the Data records and the graph.

Limits

  • Every run lists every campaign. With drafts on, each draft's content is fetched every run; with notes on, one request per contact that has notes.
  • Images are dropped. Automations, landing pages, templates and reports are not synced.
  • Archived campaigns follow Mailchimp's documented archived status and are covered by tests, but I could not check them live. Mailchimp only lets you archive sent campaigns, and sending was disabled on my account while it was under review.
  • Anyone who can read the cognee dataset can read the synced text and notes, so keep it in its own dataset.

Try it

The code is in packages/connector/mailchimp/ in cognee-community. Create a key under Account & billing, Extras, API keys, then run examples/example.py. If you have years of newsletters, I am curious what you end up asking it.

Top comments (0)