cdp-python-sdk 0.1.1__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cdp-python-sdk
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
5
5
  Author-email: Codematic Engineering <engineering@codematic.io>
6
6
  License-Expression: MIT
@@ -165,6 +165,25 @@ await client.send_email(EmailPayload(
165
165
  ))
166
166
  ```
167
167
 
168
+ #### Attachments
169
+
170
+ Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
171
+
172
+ ```python
173
+ payload = EmailPayload(
174
+ to="user@example.com",
175
+ identifiers=Identifiers(id="user-123"),
176
+ transactional_message_id="INVOICE_EMAIL",
177
+ )
178
+ payload.attach_file("./invoice.pdf") # named "invoice.pdf"
179
+ payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
180
+ payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
181
+
182
+ await client.send_email(payload)
183
+ ```
184
+
185
+ You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
186
+
168
187
  ---
169
188
 
170
189
  ### Send Push Notification
@@ -197,6 +216,27 @@ await client.send_sms(SmsPayload(
197
216
 
198
217
  ---
199
218
 
219
+ ### Send WhatsApp
220
+
221
+ WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
222
+
223
+ ```python
224
+ from cdp_client import WhatsAppPayload, Identifiers
225
+
226
+ result = await client.send_whatsapp(WhatsAppPayload(
227
+ identifiers=Identifiers(id="user-123"),
228
+ transactional_message_id="ORDER_WHATSAPP",
229
+ to="+14155551234", # Optional: overrides the profile phone number
230
+ message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
231
+ ))
232
+ ```
233
+
234
+ - **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
235
+ - `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
236
+ - Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
237
+
238
+ ---
239
+
200
240
  ### Clear Identity / Logout
201
241
 
202
242
  To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
@@ -213,22 +253,25 @@ client = CDPClient(config)
213
253
 
214
254
  ## Error Handling
215
255
 
216
- By default, the Python SDK raises exceptions on HTTP errors or network failures. Wrap calls in `try/except` to handle them gracefully:
256
+ By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
217
257
 
218
258
  ```python
219
- import httpx
259
+ from cdp_client import CDPError, CDPValidationError
260
+
261
+ config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
262
+ client = CDPClient(config)
220
263
 
221
264
  try:
222
265
  await client.identify("user-123", {"email": "user@example.com"})
223
- except httpx.HTTPStatusError as e:
224
- # Server returned 4xx or 5xx
225
- print(f"API error {e.response.status_code}: {e.response.text}")
226
- except Exception as e:
227
- # Network error, timeout, etc.
228
- print(f"Unexpected error: {e}")
266
+ except CDPValidationError as e:
267
+ # Invalid input, caught before any request is sent
268
+ print(f"Invalid {e.field}: {e}")
269
+ except CDPError as e:
270
+ # status_code is the HTTP status, or None when the gateway could not be reached
271
+ print(f"API error {e.status_code}: {e}")
229
272
  ```
230
273
 
231
- > All methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `register_device`) raise on failure. Dual-write Customer.io errors are non-fatal and only emit a warning log.
274
+ > This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
232
275
 
233
276
  ---
234
277
 
@@ -147,6 +147,25 @@ await client.send_email(EmailPayload(
147
147
  ))
148
148
  ```
149
149
 
150
+ #### Attachments
151
+
152
+ Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
153
+
154
+ ```python
155
+ payload = EmailPayload(
156
+ to="user@example.com",
157
+ identifiers=Identifiers(id="user-123"),
158
+ transactional_message_id="INVOICE_EMAIL",
159
+ )
160
+ payload.attach_file("./invoice.pdf") # named "invoice.pdf"
161
+ payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
162
+ payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
163
+
164
+ await client.send_email(payload)
165
+ ```
166
+
167
+ You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
168
+
150
169
  ---
151
170
 
152
171
  ### Send Push Notification
@@ -179,6 +198,27 @@ await client.send_sms(SmsPayload(
179
198
 
180
199
  ---
181
200
 
201
+ ### Send WhatsApp
202
+
203
+ WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
204
+
205
+ ```python
206
+ from cdp_client import WhatsAppPayload, Identifiers
207
+
208
+ result = await client.send_whatsapp(WhatsAppPayload(
209
+ identifiers=Identifiers(id="user-123"),
210
+ transactional_message_id="ORDER_WHATSAPP",
211
+ to="+14155551234", # Optional: overrides the profile phone number
212
+ message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
213
+ ))
214
+ ```
215
+
216
+ - **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
217
+ - `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
218
+ - Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
219
+
220
+ ---
221
+
182
222
  ### Clear Identity / Logout
183
223
 
184
224
  To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
@@ -195,22 +235,25 @@ client = CDPClient(config)
195
235
 
196
236
  ## Error Handling
197
237
 
198
- By default, the Python SDK raises exceptions on HTTP errors or network failures. Wrap calls in `try/except` to handle them gracefully:
238
+ By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
199
239
 
200
240
  ```python
201
- import httpx
241
+ from cdp_client import CDPError, CDPValidationError
242
+
243
+ config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
244
+ client = CDPClient(config)
202
245
 
203
246
  try:
204
247
  await client.identify("user-123", {"email": "user@example.com"})
205
- except httpx.HTTPStatusError as e:
206
- # Server returned 4xx or 5xx
207
- print(f"API error {e.response.status_code}: {e.response.text}")
208
- except Exception as e:
209
- # Network error, timeout, etc.
210
- print(f"Unexpected error: {e}")
248
+ except CDPValidationError as e:
249
+ # Invalid input, caught before any request is sent
250
+ print(f"Invalid {e.field}: {e}")
251
+ except CDPError as e:
252
+ # status_code is the HTTP status, or None when the gateway could not be reached
253
+ print(f"API error {e.status_code}: {e}")
211
254
  ```
212
255
 
213
- > All methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `register_device`) raise on failure. Dual-write Customer.io errors are non-fatal and only emit a warning log.
256
+ > This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
214
257
 
215
258
  ---
216
259
 
@@ -5,8 +5,10 @@ from .models import (
5
5
  CustomerIoConfig,
6
6
  DeviceRegistrationParameters,
7
7
  EmailPayload,
8
+ Identifiers,
8
9
  PushPayload,
9
10
  SmsPayload,
11
+ WhatsAppPayload,
10
12
  )
11
13
 
12
14
  __all__ = [
@@ -17,6 +19,8 @@ __all__ = [
17
19
  "CustomerIoConfig",
18
20
  "DeviceRegistrationParameters",
19
21
  "EmailPayload",
22
+ "Identifiers",
20
23
  "PushPayload",
21
24
  "SmsPayload",
25
+ "WhatsAppPayload",
22
26
  ]
@@ -15,11 +15,26 @@ from .models import (
15
15
  EmailPayload,
16
16
  PushPayload,
17
17
  SmsPayload,
18
+ WhatsAppPayload,
19
+ )
20
+ from .validators import (
21
+ validate_email_attachments,
22
+ validate_event_name,
23
+ validate_identifier,
24
+ validate_properties,
25
+ validate_whatsapp_payload,
18
26
  )
19
- from .validators import validate_event_name, validate_identifier, validate_properties
20
27
 
21
28
  logger = logging.getLogger("CDPClient")
22
29
 
30
+ # Cloudflare (in front of the primary host) reports these when it never sent the request to the
31
+ # gateway: 521 refused, 523 unreachable, 525/526 TLS failure. Generic 502/503
32
+ # are excluded because a proxy can return them after the gateway has already queued the message,
33
+ # and 522/524 because Cloudflare may already have sent the request when it timed out.
34
+ _SEND_RETRYABLE_STATUSES = (521, 523, 525, 526)
35
+ # Raised before the request was sent. Read timeouts are excluded: the request may have been accepted.
36
+ _SEND_RETRYABLE_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout)
37
+
23
38
 
24
39
  class CDPClient:
25
40
  """Client for the OpenCDP Data Gateway with optional Customer.io dual-write."""
@@ -49,7 +64,7 @@ class CDPClient:
49
64
  return {
50
65
  "Authorization": self.config.cdp_api_key,
51
66
  "Content-Type": "application/json",
52
- "User-Agent": "cdp-python-sdk/0.1.1",
67
+ "User-Agent": "cdp-python-sdk/0.2.0",
53
68
  }
54
69
 
55
70
  def _handle_error(self, message: str, exc: Exception) -> None:
@@ -66,33 +81,47 @@ class CDPClient:
66
81
  path: str,
67
82
  *,
68
83
  json_body: dict | None = None,
84
+ send_safe: bool = False,
69
85
  ) -> httpx.Response:
86
+ """With send_safe, only fail over when the host provably never processed the request.
87
+
88
+ Message sends are not idempotent: retrying after a read timeout, a redirect, or a 4xx/5xx
89
+ outside _SEND_RETRYABLE_STATUSES could deliver the same message twice.
90
+ """
70
91
  last_error: Exception | None = None
71
92
  async with httpx.AsyncClient(
72
93
  timeout=self._timeout,
73
94
  headers=self._headers(),
95
+ # A followed redirect would turn a failed connection to the redirect target into a
96
+ # "never reached" error, and failover would deliver the message twice.
97
+ follow_redirects=False,
74
98
  ) as client:
75
99
  for base_url in self._base_urls:
76
100
  url = f"{base_url}{path}"
77
101
  try:
78
102
  response = await client.request(method, url, json=json_body)
79
- if 200 <= response.status_code < 300:
80
- return response
81
- last_error = httpx.HTTPStatusError(
82
- f"HTTP {response.status_code}",
83
- request=response.request,
84
- response=response,
85
- )
86
- if self.config.debug:
87
- logger.debug(
88
- "Gateway %s returned %s, trying next host",
89
- base_url,
90
- response.status_code,
91
- )
92
103
  except Exception as e:
93
104
  last_error = e
105
+ if send_safe and not isinstance(e, _SEND_RETRYABLE_ERRORS):
106
+ raise
94
107
  if self.config.debug:
95
108
  logger.debug("Gateway %s unreachable: %s", base_url, e)
109
+ continue
110
+ if 200 <= response.status_code < 300:
111
+ return response
112
+ last_error = httpx.HTTPStatusError(
113
+ f"HTTP {response.status_code}",
114
+ request=response.request,
115
+ response=response,
116
+ )
117
+ if send_safe and response.status_code not in _SEND_RETRYABLE_STATUSES:
118
+ raise last_error
119
+ if self.config.debug:
120
+ logger.debug(
121
+ "Gateway %s returned %s, trying next host",
122
+ base_url,
123
+ response.status_code,
124
+ )
96
125
  assert last_error is not None
97
126
  raise last_error
98
127
 
@@ -192,10 +221,17 @@ class CDPClient:
192
221
  self._handle_error("Error registering device", e)
193
222
 
194
223
  async def send_email(self, payload: EmailPayload) -> None:
224
+ try:
225
+ validate_email_attachments(payload.attachments)
226
+ except CDPValidationError as e:
227
+ if self.config.fail_on_exception:
228
+ raise
229
+ logger.error("Email validation failed: %s", e)
230
+ return
195
231
  payload.check_unsupported_fields()
196
232
  try:
197
233
  data = payload.model_dump(exclude_none=True, by_alias=True)
198
- await self._request("POST", "/v1/send/email", json_body=data)
234
+ await self._request("POST", "/v1/send/email", json_body=data, send_safe=True)
199
235
  except Exception as e:
200
236
  self._handle_error("Error sending email", e)
201
237
 
@@ -203,7 +239,7 @@ class CDPClient:
203
239
  payload.check_unsupported_fields()
204
240
  try:
205
241
  data = payload.model_dump(exclude_none=True, by_alias=True)
206
- await self._request("POST", "/v1/send/push", json_body=data)
242
+ await self._request("POST", "/v1/send/push", json_body=data, send_safe=True)
207
243
  except Exception as e:
208
244
  self._handle_error("Error sending push", e)
209
245
 
@@ -211,10 +247,44 @@ class CDPClient:
211
247
  payload.check_unsupported_fields()
212
248
  try:
213
249
  data = payload.model_dump(exclude_none=True, by_alias=True)
214
- await self._request("POST", "/v1/send/sms", json_body=data)
250
+ await self._request("POST", "/v1/send/sms", json_body=data, send_safe=True)
215
251
  except Exception as e:
216
252
  self._handle_error("Error sending SMS", e)
217
253
 
254
+ async def send_whatsapp(self, payload: WhatsAppPayload) -> dict[str, Any] | None:
255
+ """Queue a WhatsApp transactional send.
256
+
257
+ Returns the gateway acknowledgement (the transactional execution record). A successful
258
+ return means the message was queued, not delivered. Returns None on failure unless
259
+ fail_on_exception is set.
260
+ """
261
+ try:
262
+ validate_whatsapp_payload(payload)
263
+ except CDPValidationError as e:
264
+ if self.config.fail_on_exception:
265
+ raise
266
+ logger.error("WhatsApp validation failed: %s", e)
267
+ return None
268
+ data = payload.model_dump(exclude_none=True)
269
+ data["transactional_message_id"] = str(payload.transactional_message_id)
270
+ try:
271
+ response = await self._request("POST", "/v1/send/whatsapp", json_body=data, send_safe=True)
272
+ return response.json() if response.content else None
273
+ except Exception as e:
274
+ self._handle_error(f"Error sending WhatsApp: {_gateway_message(e)}", e)
275
+ return None
276
+
218
277
  async def close(self) -> None:
219
278
  """No persistent client; kept for API compatibility."""
220
279
  return
280
+
281
+
282
+ def _gateway_message(exc: Exception) -> str:
283
+ if isinstance(exc, httpx.HTTPStatusError):
284
+ try:
285
+ message = exc.response.json().get("message")
286
+ except ValueError:
287
+ message = None
288
+ if message:
289
+ return str(message)
290
+ return str(exc) or type(exc).__name__
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import logging
5
+ import os
4
6
  from typing import Any, Literal
5
7
 
6
8
  from pydantic import BaseModel, ConfigDict, Field
@@ -55,7 +57,6 @@ class BaseMessagePayload(BaseModel):
55
57
  headers: str | None = None
56
58
  disable_message_retention: bool | None = None
57
59
  queue_draft: bool | None = None
58
- attachments: dict[str, Any] | None = None
59
60
 
60
61
  def check_unsupported_fields(self):
61
62
  unsupported = [
@@ -66,7 +67,6 @@ class BaseMessagePayload(BaseModel):
66
67
  "headers",
67
68
  "disable_message_retention",
68
69
  "queue_draft",
69
- "attachments",
70
70
  ]
71
71
  found = [field for field in unsupported if getattr(self, field) is not None]
72
72
  if found:
@@ -83,6 +83,24 @@ class EmailPayload(BaseMessagePayload):
83
83
  body: str | None = None
84
84
  body_plain: str | None = None
85
85
  reply_to: str | None = None
86
+ # Filename -> base64 content. Max 5 files, 2 MB decoded in total.
87
+ attachments: dict[str, str] | None = None
88
+
89
+ def attach(self, filename: str, data: bytes | str, encode: bool = True) -> None:
90
+ """Attach a file. Bytes are always base64-encoded; strings are encoded unless encode=False,
91
+ in which case they must already be base64 (same semantics as customerio's attach)."""
92
+ if isinstance(data, str):
93
+ content = base64.b64encode(data.encode()).decode() if encode else data
94
+ else:
95
+ content = base64.b64encode(data).decode()
96
+ if self.attachments is None:
97
+ self.attachments = {}
98
+ self.attachments[filename] = content
99
+
100
+ def attach_file(self, path: str | os.PathLike[str], filename: str | None = None) -> None:
101
+ """Read a file from disk and attach it, named after the file unless filename is given."""
102
+ with open(path, "rb") as f:
103
+ self.attach(filename or os.path.basename(path), f.read())
86
104
 
87
105
 
88
106
  class PushPayload(BaseMessagePayload):
@@ -96,6 +114,20 @@ class SmsPayload(BaseMessagePayload):
96
114
  body: str | None = None
97
115
 
98
116
 
117
+ class WhatsAppPayload(BaseModel):
118
+ """Only the fields the gateway's WhatsApp endpoint accepts; it rejects any other key."""
119
+
120
+ identifiers: Identifiers
121
+ transactional_message_id: str | int
122
+ to: str | None = None
123
+ # {"header" | "body" | "button": {"1": value, "2": value, ...}}. Replaces the variables
124
+ # saved on the transactional entirely when given.
125
+ template_variables: dict[str, dict[str, Any]] | None = None
126
+ message_data: dict[str, Any] | None = None
127
+
128
+ model_config = ConfigDict(extra="forbid")
129
+
130
+
99
131
  class DeviceRegistrationParameters(BaseModel):
100
132
  device_id: str = Field(..., alias="deviceId")
101
133
  platform: Literal["android", "ios", "web"]
@@ -0,0 +1,119 @@
1
+ """Input validation — aligned with Flutter SDK rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .errors import CDPValidationError
9
+
10
+ if TYPE_CHECKING:
11
+ from .models import WhatsAppPayload
12
+
13
+ _EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
14
+ _PHONE_RE = re.compile(r"^\+?[1-9]\d{1,14}$")
15
+ _SLOT_RE = re.compile(r"^[1-9]\d*$")
16
+ _TEMPLATE_SECTIONS = ("header", "body", "button")
17
+
18
+ # Mirrors the gateway's limits (backend integrations/email-attachments.ts) so bad input fails before a network call.
19
+ MAX_EMAIL_ATTACHMENTS = 5
20
+ MAX_EMAIL_ATTACHMENTS_DECODED_BYTES = 2 * 1024 * 1024 # 2 MB
21
+ _MAX_EMAIL_ATTACHMENTS_ENCODED_LENGTH = -(-MAX_EMAIL_ATTACHMENTS_DECODED_BYTES // 3) * 4
22
+ # Standard or url-safe alphabet (not both), padding optional, "=" only at the end. Lenient decoders skip invalid
23
+ # characters and anything after padding, so decoding cannot be used to validate.
24
+ _BASE64_RE = re.compile(r"(?:[A-Za-z0-9+/_-]{4})*(?:[A-Za-z0-9+/_-]{2}(?:==)?|[A-Za-z0-9+/_-]{3}=?)?")
25
+
26
+
27
+ def validate_identifier(identifier: str) -> str:
28
+ if identifier is None or str(identifier).strip() == "":
29
+ raise CDPValidationError("Identifier cannot be empty", "identifier")
30
+ value = str(identifier).strip()
31
+ if _EMAIL_RE.match(value):
32
+ raise CDPValidationError(
33
+ "Identifier must not be an email address",
34
+ "identifier",
35
+ )
36
+ return value
37
+
38
+
39
+ def validate_event_name(event_name: str) -> str:
40
+ if not event_name or not event_name.strip():
41
+ raise CDPValidationError("Event name cannot be empty", "eventName")
42
+ return event_name.strip()
43
+
44
+
45
+ def validate_properties(properties: dict | None) -> dict:
46
+ if properties is None:
47
+ return {}
48
+ if not isinstance(properties, dict):
49
+ raise CDPValidationError("Properties must be a dictionary", "properties")
50
+ return properties
51
+
52
+
53
+ def validate_whatsapp_payload(payload: WhatsAppPayload) -> None:
54
+ identifiers = payload.identifiers
55
+ provided = [v for v in (identifiers.id, identifiers.email, identifiers.cdp_id) if v not in (None, "")]
56
+ if len(provided) != 1:
57
+ raise CDPValidationError(
58
+ "identifiers must contain exactly one of: id, email, or cdp_id",
59
+ "identifiers",
60
+ )
61
+
62
+ if payload.transactional_message_id is None or str(payload.transactional_message_id).strip() == "":
63
+ raise CDPValidationError("transactional_message_id is required", "transactional_message_id")
64
+
65
+ if payload.to is not None and not _PHONE_RE.match(payload.to):
66
+ raise CDPValidationError(
67
+ "Phone number must be in international format (e.g., +1234567890)",
68
+ "to",
69
+ )
70
+
71
+ for section, slots in (payload.template_variables or {}).items():
72
+ if section not in _TEMPLATE_SECTIONS:
73
+ raise CDPValidationError(
74
+ "template_variables may only contain header, body, and button",
75
+ "template_variables",
76
+ )
77
+ # The gateway sends parameters by position and drops non-numeric button keys.
78
+ for slot in slots:
79
+ if not _SLOT_RE.match(slot):
80
+ raise CDPValidationError(
81
+ f'template_variables.{section} keys must be positional slot numbers ("1", "2", ...), got "{slot}"',
82
+ "template_variables",
83
+ )
84
+
85
+
86
+ def validate_email_attachments(attachments: object) -> None:
87
+ if attachments is None:
88
+ return
89
+ if not isinstance(attachments, dict):
90
+ raise CDPValidationError("attachments must be an object", "attachments")
91
+ if len(attachments) > MAX_EMAIL_ATTACHMENTS:
92
+ raise CDPValidationError(
93
+ f"attachments may contain at most {MAX_EMAIL_ATTACHMENTS} files",
94
+ "attachments",
95
+ )
96
+
97
+ total_decoded_bytes = 0
98
+ for filename, content in attachments.items():
99
+ if not filename or "/" in filename or "\\" in filename or ".." in filename:
100
+ raise CDPValidationError(f"invalid attachment filename: {filename or '(empty)'}", "attachments")
101
+ if not isinstance(content, str) or content == "":
102
+ raise CDPValidationError(f'attachment "{filename}" must be a non-empty base64 string', "attachments")
103
+ normalized = re.sub(r"\s", "", content)
104
+ # Check the length before the pattern so oversized content fails fast with the size error.
105
+ if len(normalized) > _MAX_EMAIL_ATTACHMENTS_ENCODED_LENGTH:
106
+ raise CDPValidationError(
107
+ f"attachments decoded size exceeds {MAX_EMAIL_ATTACHMENTS_DECODED_BYTES} bytes (2 MB)",
108
+ "attachments",
109
+ )
110
+ # Mixing "+/" with "-_" is valid in neither the standard nor the url-safe alphabet.
111
+ mixes_alphabets = bool(re.search(r"[+/]", normalized)) and bool(re.search(r"[-_]", normalized))
112
+ if not normalized or not _BASE64_RE.fullmatch(normalized) or mixes_alphabets:
113
+ raise CDPValidationError(f'attachment "{filename}" must be a valid base64 string', "attachments")
114
+ total_decoded_bytes += len(normalized.rstrip("=")) * 3 // 4
115
+ if total_decoded_bytes > MAX_EMAIL_ATTACHMENTS_DECODED_BYTES:
116
+ raise CDPValidationError(
117
+ f"attachments decoded size exceeds {MAX_EMAIL_ATTACHMENTS_DECODED_BYTES} bytes (2 MB)",
118
+ "attachments",
119
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cdp-python-sdk
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration.
5
5
  Author-email: Codematic Engineering <engineering@codematic.io>
6
6
  License-Expression: MIT
@@ -165,6 +165,25 @@ await client.send_email(EmailPayload(
165
165
  ))
166
166
  ```
167
167
 
168
+ #### Attachments
169
+
170
+ Attach up to 5 files (2 MB decoded in total). Bytes are base64-encoded for you:
171
+
172
+ ```python
173
+ payload = EmailPayload(
174
+ to="user@example.com",
175
+ identifiers=Identifiers(id="user-123"),
176
+ transactional_message_id="INVOICE_EMAIL",
177
+ )
178
+ payload.attach_file("./invoice.pdf") # named "invoice.pdf"
179
+ payload.attach("notes.txt", "Plain text content") # strings are base64-encoded by default
180
+ payload.attach("report.csv", existing_base64, encode=False) # already base64, sent as-is
181
+
182
+ await client.send_email(payload)
183
+ ```
184
+
185
+ You can also pass `attachments={"invoice.pdf": "<base64>"}` directly. `send_email` validates attachments before sending: at most 5 files, at most 2 MB decoded in total, filenames without `/`, `\` or `..`, and non-empty base64 content. Invalid attachments raise `CDPValidationError` when `fail_on_exception=True`; otherwise the error is logged and nothing is sent. The content type is inferred from the file extension.
186
+
168
187
  ---
169
188
 
170
189
  ### Send Push Notification
@@ -197,6 +216,27 @@ await client.send_sms(SmsPayload(
197
216
 
198
217
  ---
199
218
 
219
+ ### Send WhatsApp
220
+
221
+ WhatsApp sends use a saved WhatsApp transactional, so `transactional_message_id` is required.
222
+
223
+ ```python
224
+ from cdp_client import WhatsAppPayload, Identifiers
225
+
226
+ result = await client.send_whatsapp(WhatsAppPayload(
227
+ identifiers=Identifiers(id="user-123"),
228
+ transactional_message_id="ORDER_WHATSAPP",
229
+ to="+14155551234", # Optional: overrides the profile phone number
230
+ message_data={"order_number": "12345"}, # Available in the template as {{trigger.order_number}}
231
+ ))
232
+ ```
233
+
234
+ - **A successful return means the message was queued, not delivered.** Delivery runs asynchronously, so a missing WhatsApp provider, no phone number, or a template rejected by Meta does not fail this call. `result` holds the transactional execution record; keep its id to trace the send.
235
+ - `template_variables` (`{"header": {...}, "body": {...}, "button": {...}}`) sets the template slots from code. Keys must be slot numbers (`"1"`, `"2"`, ...), and values may use Liquid such as `{{customer.first_name}}`. Passing it **replaces all variables saved on the transactional**, so include every section the template needs.
236
+ - Sends are not retried on another gateway host after a read, write, or pool timeout or an HTTP error, because the message may already have been queued. The exceptions are connection failures (including connection timeouts) and the Cloudflare errors 521, 523, 525 and 526, which mean the gateway never received the request.
237
+
238
+ ---
239
+
200
240
  ### Clear Identity / Logout
201
241
 
202
242
  To reset the client's user context (e.g., on logout), close the current client and re-initialize without a user session:
@@ -213,22 +253,25 @@ client = CDPClient(config)
213
253
 
214
254
  ## Error Handling
215
255
 
216
- By default, the Python SDK raises exceptions on HTTP errors or network failures. Wrap calls in `try/except` to handle them gracefully:
256
+ By default (`fail_on_exception=False`) the SDK logs errors and does not raise, so a failed call never crashes your app. Set `fail_on_exception=True` to raise instead:
217
257
 
218
258
  ```python
219
- import httpx
259
+ from cdp_client import CDPError, CDPValidationError
260
+
261
+ config = CDPConfig(cdp_api_key="...", fail_on_exception=True)
262
+ client = CDPClient(config)
220
263
 
221
264
  try:
222
265
  await client.identify("user-123", {"email": "user@example.com"})
223
- except httpx.HTTPStatusError as e:
224
- # Server returned 4xx or 5xx
225
- print(f"API error {e.response.status_code}: {e.response.text}")
226
- except Exception as e:
227
- # Network error, timeout, etc.
228
- print(f"Unexpected error: {e}")
266
+ except CDPValidationError as e:
267
+ # Invalid input, caught before any request is sent
268
+ print(f"Invalid {e.field}: {e}")
269
+ except CDPError as e:
270
+ # status_code is the HTTP status, or None when the gateway could not be reached
271
+ print(f"API error {e.status_code}: {e}")
229
272
  ```
230
273
 
231
- > All methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `register_device`) raise on failure. Dual-write Customer.io errors are non-fatal and only emit a warning log.
274
+ > This applies to all methods (`identify`, `track`, `send_email`, `send_push`, `send_sms`, `send_whatsapp`, `register_device`). Dual-write Customer.io errors are non-fatal and only emit a warning log.
232
275
 
233
276
  ---
234
277
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdp-python-sdk"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "A Python client library for Codematic's Customer Data Platform (CDP) with optional Customer.io integration."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Codematic Engineering", email = "engineering@codematic.io" }]
@@ -2,6 +2,6 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="cdp-python-sdk",
5
- version="0.1.1",
5
+ version="0.2.0",
6
6
  packages=find_packages(),
7
7
  )
@@ -1,35 +0,0 @@
1
- """Input validation — aligned with Flutter SDK rules."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
-
7
- from .errors import CDPValidationError
8
-
9
- _EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
10
-
11
-
12
- def validate_identifier(identifier: str) -> str:
13
- if identifier is None or str(identifier).strip() == "":
14
- raise CDPValidationError("Identifier cannot be empty", "identifier")
15
- value = str(identifier).strip()
16
- if _EMAIL_RE.match(value):
17
- raise CDPValidationError(
18
- "Identifier must not be an email address",
19
- "identifier",
20
- )
21
- return value
22
-
23
-
24
- def validate_event_name(event_name: str) -> str:
25
- if not event_name or not event_name.strip():
26
- raise CDPValidationError("Event name cannot be empty", "eventName")
27
- return event_name.strip()
28
-
29
-
30
- def validate_properties(properties: dict | None) -> dict:
31
- if properties is None:
32
- return {}
33
- if not isinstance(properties, dict):
34
- raise CDPValidationError("Properties must be a dictionary", "properties")
35
- return properties
File without changes