Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions mindsdb/integrations/handlers/brevo_handler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Brevo Handler

Brevo handler for MindsDB provides interfaces to connect to [Brevo](https://www.brevo.com/)
(formerly Sendinblue) via its Marketing API and pull email marketing data into MindsDB.

## Brevo Handler Implementation

This handler was implemented using the Brevo Marketing API v3 and the standard
Python `requests` library. No vendor SDK is required.

All endpoints are served from a single host, `https://api.brevo.com/v3`, and the
API key is sent in an `api-key` request header.

## Brevo Handler Initialization

The Brevo handler is initialized with the following required parameter:

* `api_key`: an API key generated in Brevo under **SMTP & API → API Keys**.

## Implemented Features

* `contacts` table — subscribers, with the nested `attributes` object flattened
into columns
* `lists` table — audiences and their subscriber counts
* `campaigns` table — email campaigns with send status and delivery statistics
(sent, delivered, bounces, opens, clicks, unsubscriptions, complaints), summed
across every list a campaign was sent to
* `contact_events` table — per-contact activity (opens, clicks, and so on),
built from each contact's own statistics

Collection endpoints are paginated with `limit` and `offset`. The `count` field
in each response is the **total** number of records, not the number returned, so
it is used as the stopping condition when fetching all pages.

## TODO

* Support `INSERT` on `contacts` so subscribers can be added from MindsDB
* Push more filters down to the API instead of filtering locally

All tables are read-only. Equality filters on `contacts.email`, `contacts.list_id`
and on `contact_events.email`, `campaign_id` and `event_type` are sent to Brevo
instead of being applied locally.

## Usage

To connect your Brevo account to MindsDB, use the below `CREATE DATABASE` statement:

```sql
CREATE DATABASE brevo_src
WITH ENGINE = 'brevo',
PARAMETERS = {
"api_key": "xkeysib-your-key-here"
};
```

Query your contacts:

```sql
SELECT * FROM brevo_src.contacts;
```

Apply filters and limits:

```sql
SELECT email, first_name, last_name
FROM brevo_src.contacts
WHERE list_id = 2
LIMIT 10;
```

List your audiences:

```sql
SELECT list_id, name, unique_subscribers
FROM brevo_src.lists;
```

Join Brevo contacts against customer records held in another datasource, to see
which paying customers are actually subscribed:

```sql
SELECT c.name, c.email, c.total_spent, b.email_blacklisted
FROM postgres_src.public.customers AS c
JOIN brevo_src.contacts AS b
ON c.email = b.email
ORDER BY c.total_spent DESC;
```

Campaign performance:

```sql
SELECT campaign_name, sent, delivered, open_rate
FROM brevo_src.campaigns
ORDER BY open_rate DESC;
```

Who opened a campaign:

```sql
SELECT email, event_time
FROM brevo_src.contact_events
WHERE campaign_id = 1 AND event_type = 'opened'
LIMIT 50;
```

## Notes

**`contact_events` can be slow.** Brevo only exposes events per contact. With an
`email = ...` filter this is one API call; without it, the handler makes one call
per contact. Filter by email or use `LIMIT` on large audiences.

**Authorised IP addresses.** Brevo can restrict API access to an allowlist of IP
addresses. If requests fail with HTTP 401 and a message naming an
"unrecognised IP address", the key is valid but the caller's address is not
approved — add it at
[app.brevo.com/security/authorised_ips](https://app.brevo.com/security/authorised_ips).
Home connections are often assigned a new address every day or two, so this can
reappear after it was working. `check_connection` surfaces the message from
Brevo unchanged so this is distinguishable from a bad key.

**A contact can belong to several lists.** The API returns `listIds` as an array.
Filtering by `list_id` matches a contact that belongs to that list, among others.
9 changes: 9 additions & 0 deletions mindsdb/integrations/handlers/brevo_handler/__about__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
__title__ = "MindsDB Brevo handler"
__package_name__ = "mindsdb_brevo_handler"
__version__ = "0.0.1"
__description__ = "MindsDB handler for Brevo"
__author__ = "Brejesh"
__github__ = "https://github.com/mindsdb/mindsdb"
__pypi__ = "https://pypi.org/project/mindsdb/"
__license__ = "MIT"
__copyright__ = "Copyright 2026 - mindsdb"
33 changes: 33 additions & 0 deletions mindsdb/integrations/handlers/brevo_handler/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from mindsdb.integrations.libs.const import HANDLER_SUPPORT_LEVEL, HANDLER_TYPE

from .__about__ import __description__ as description
from .__about__ import __version__ as version
from .connection_args import connection_args, connection_args_example

try:
from .brevo_handler import BrevoHandler as Handler

import_error = None
except Exception as e:
Handler = None
import_error = e

title = "Brevo"
name = "brevo"
type = HANDLER_TYPE.DATA
icon_path = "icon.svg"
support_level = HANDLER_SUPPORT_LEVEL.COMMUNITY

__all__ = [
"Handler",
"connection_args",
"connection_args_example",
"description",
"icon_path",
"import_error",
"name",
"support_level",
"title",
"type",
"version",
]
83 changes: 83 additions & 0 deletions mindsdb/integrations/handlers/brevo_handler/brevo_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import requests

from mindsdb.integrations.libs.api_handler import APIHandler
from mindsdb.integrations.libs.response import HandlerStatusResponse as StatusResponse
from mindsdb.utilities import log

from .brevo_tables import CampaignsTable, ContactEventsTable, ContactsTable, ListsTable

logger = log.getLogger(__name__)

BASE_URL = "https://api.brevo.com/v3"


class BrevoHandler(APIHandler):
"""Connects MindsDB to Brevo (email marketing) through its REST API.

Exposes four read-only tables: campaigns, contact_events, contacts and lists.
"""

name = "brevo"

def __init__(self, name: str = None, **kwargs):
"""Create the handler and register its tables.

No network call is made here; the API key is only checked when a query
runs or `check_connection` is called.

Args:
name: The name of this data source in MindsDB. Defaults to None.
**kwargs: Handler options. `connection_data` should be a dict with
the `api_key` for the Brevo account.
"""
super().__init__(name)
self.connection_data = kwargs.get("connection_data", {}) or {}
self._register_table("campaigns", CampaignsTable(self))
self._register_table("contact_events", ContactEventsTable(self))
self._register_table("contacts", ContactsTable(self))
self._register_table("lists", ListsTable(self))

def connect(self):
"""Build the HTTP headers used for every Brevo request.

Brevo has no persistent connection, so this only reads the API key and
marks the handler as connected. No network call happens here.

Returns:
A dict with the `api-key` and `accept` headers.

Raises:
ValueError: If `api_key` is missing from the connection data.
"""
api_key = self.connection_data.get("api_key")
if not api_key:
raise ValueError("The 'api_key' parameter is required to connect to Brevo.")
self.is_connected = True
return {"api-key": api_key, "accept": "application/json"}

def check_connection(self) -> StatusResponse:
"""Check that the API key works by calling Brevo's /account endpoint.

Brevo's own error text is passed through on failure, because a 401 is
often caused by the account's IP allowlist rather than a bad key.

Returns:
A StatusResponse with `success` set to True on HTTP 200. Otherwise
`success` is False and `error_message` explains why. This method
does not raise; all errors are reported through the response.
"""
response = StatusResponse(False)
try:
r = requests.get(f"{BASE_URL}/account", headers=self.connect(), timeout=30)
if r.status_code == 200:
response.success = True
else:
# Pass Brevo's own message through - a 401 here is often the IP
# allowlist, not a bad key, and the text says which.
response.error_message = f"Brevo returned {r.status_code}: {r.text[:300]}"
self.is_connected = False
except Exception as e:
logger.error(f"Error connecting to Brevo: {e}")
response.error_message = str(e)
self.is_connected = False
return response
Loading
Loading