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
102 changes: 99 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ except MillionSendError as e:
- `e.status_code` is the HTTP status, or `None` for client-side/transport failures (connection refused, DNS, timeout).
- `Emails.send` / `Batch.send` raise `AllRecipientsSuppressedError` (422 `all_recipients_suppressed`) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`.

Subclasses: `MissingApiKeyError`, `InvalidApiKeyError`, `ValidationError`, `AllRecipientsSuppressedError`, `InvalidParameterError`, `InvalidPayloadError`, `PayloadTooLargeError`, `NotFoundError`, `ConflictError`, `ForbiddenError`, `RestrictedApiKeyError`, `SendingPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, `PlanLimitReachedError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, `ApplicationError`. Unknown names raise the base `MillionSendError`.
Subclasses: `MissingApiKeyError`, `InvalidApiKeyError`, `ValidationError`, `AllRecipientsSuppressedError`, `InvalidParameterError`, `InvalidPayloadError`, `PayloadTooLargeError`, `NotFoundError`, `ConflictError`, `ForbiddenError`, `RestrictedApiKeyError`, `SendingPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, `PlanLimitReachedError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InboxSendPolicyError`, `InboxSendCapReachedError`, `ApprovalRequiredError`, `EmailQuarantinedError`, `EmailBlockedError`, `TooManyWaitsError`, `InternalServerError`, `ApplicationError`. Unknown names raise the base `MillionSendError`.

## Resources

Expand Down Expand Up @@ -125,6 +125,98 @@ millionsend.Batch.send([payload_a, payload_b], batch_validation="permissive") #

`to` / `cc` / `bcc` / `reply_to` accept a string or a list of strings. `template` is passed through too; the server answers 422 until templates can be sent from.

### Receiving

Mail received at your domains and inboxes. Everything in a received email was written by its sender: show its `html` only in a sandbox, and give an LLM the `extracted_text` (hidden text, quotes and the signature removed) rather than the `html`.

```python
page = millionsend.Emails.Receiving.list(
{"from": "example.com"}, # `from` is a Python keyword, so it goes in the dict
inbox_id=inbox.id,
subject_contains="code",
wait=30, # long-poll: answer as soon as a match arrives, up to 50 s
)
page.data # newest first
page.held # with wait: [{id, inbox_id, reason, quarantine_reason, created_at}] held mail

email = millionsend.Emails.Receiving.get(email_id, html_format="cid")
email.extracted_text # the new part as plain text, hidden text removed (still untrusted)
email.extracted.code # a verification code, on inboxes with extract_codes
email.screening.sender_trust # owner | allowlisted | known_thread | external | unauthenticated | failed

millionsend.Emails.Receiving.Attachments.list(email_id) # each with a signed download_url (1 hour)
millionsend.Emails.Receiving.Attachments.get(email_id, attachment_id)
millionsend.Emails.Receiving.remove(email_id) # erase now (MillionSend extension)

# Reply and forward server-side (MillionSend extension). The inbox's rules may hold the message
# as a draft that waits for a person: the API answers 202 and `kind` is "draft".
outcome = millionsend.Emails.Receiving.reply(email_id, {"text": "Thanks!"}, idempotency_key="reply-42")
if outcome.kind == "draft":
outcome.draft.approval_reasons # e.g. ["send_policy"]
else:
outcome.email.id
millionsend.Emails.Receiving.forward(email_id, {"to": "ops@acme.dev", "text": "FYI", "mode": "attachment"})
```

Held mail never reaches an inbox-scoped key or an agent: reading it raises `EmailQuarantinedError`. A full-access team key sees it with `include_quarantined=True`.

### Inboxes

An inbox is one address that receives mail, read by an agent (`"kind": "agent"`, the default) or by people (`"kind": "human"`). Threads, labels, drafts and their paths mirror Resend's Inboxes beta; `Safety`, `Codes` and `Quarantine` are MillionSend extensions.

```python
inbox = millionsend.Inboxes.create({
"name": "Signup bot", # no email_address: a random address on the shared domain
"expires_in": "24h", # "<n>h" or "<n>d"; None = never (custom domains only)
"safety": {"accept_from": "anyone"},
})
millionsend.Inboxes.create({"email_address": "support@acme.dev", "name": "Support", "kind": "human"})
millionsend.Inboxes.list(kind="agent")
millionsend.Inboxes.get("support@acme.dev") # by id or address
millionsend.Inboxes.update(inbox.id, {"from_name": "Acme", "expires_in": "7d"})
millionsend.Inboxes.remove(inbox.id) # also revokes its inbox-scoped keys

# Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s)
code = millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30)
code.status # "found" | "none" | "ambiguous" | "timeout"; code.code, code.url

# Safety rules: who can write in, what the inbox may send
millionsend.Inboxes.Safety.get(inbox.id)
millionsend.Inboxes.Safety.update(inbox.id, {"accept_from": "allowlist", "allowlist": ["*@acme.dev"]})

# Quarantine: held mail, kept until a person releases it
millionsend.Inboxes.Quarantine.list(inbox.id)
millionsend.Inboxes.Quarantine.release(inbox.id, email_id, trust_sender=True)

# Threads and their messages
millionsend.Inboxes.Threads.list(inbox.id, folders=["inbox", "archive"], read=False)
millionsend.Inboxes.Threads.search(inbox.id, {"query": "invoice", "from": ["example.com"]})
millionsend.Inboxes.Threads.get(inbox.id, thread_id)
millionsend.Inboxes.Threads.update(inbox.id, thread_id, {"read": True, "folder": "archive", "label_id": label_id})
millionsend.Inboxes.Threads.remove(inbox.id, thread_id) # to trash, purged after 30 days
millionsend.Inboxes.Threads.Emails.list(inbox.id, thread_id) # oldest first; include_quarantined=True adds held mail
millionsend.Inboxes.Threads.Emails.get(inbox.id, thread_id, email_id)
millionsend.Inboxes.Threads.Emails.reply(inbox.id, thread_id, email_id, {"text": "Done."}, idempotency_key=key)
millionsend.Inboxes.Threads.Emails.forward(inbox.id, thread_id, email_id, {"to": "ops@acme.dev"})

# Labels (up to 100 per inbox)
millionsend.Inboxes.Labels.create(inbox.id, {"name": "Billing", "color": "#FF8800"})
millionsend.Inboxes.Labels.list(inbox.id)
millionsend.Inboxes.Labels.update(inbox.id, label_id, {"name": "Invoices"})
millionsend.Inboxes.Labels.remove(inbox.id, label_id)

# Drafts, and the ones that wait for approval
millionsend.Inboxes.Drafts.create(inbox.id, {"to": "ada@example.com", "subject": "Hi", "text": "..."})
millionsend.Inboxes.Drafts.list(inbox.id)
millionsend.Inboxes.Drafts.get(inbox.id, draft_id)
millionsend.Inboxes.Drafts.update(inbox.id, draft_id, {"cc": None}) # None clears
millionsend.Inboxes.Drafts.send(inbox.id, draft_id, idempotency_key=key) # {"kind": "sent" | "draft", ...}
millionsend.Inboxes.Drafts.reject(inbox.id, draft_id)
millionsend.Inboxes.Drafts.remove(inbox.id, draft_id)
```

Replies, forwards and `Drafts.send` return `{"kind": "sent", "email": ...}` or, when the inbox's rules hold the message for a person (HTTP 202), `{"kind": "draft", "draft": ...}`. A key restricted to the inbox cannot send a draft that waits for approval (`ApprovalRequiredError`); a person in the dashboard or a full-access team key approves it. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed: MillionSend runs no agent of its own.

### Contacts

Contacts are team-global — one record per email address, no audiences to manage.
Expand Down Expand Up @@ -290,6 +382,7 @@ millionsend.Domains.list()
millionsend.Domains.get(domain.id)
millionsend.Domains.verify(domain.id)
millionsend.Domains.update({"id": domain.id, "open_tracking": False, "tracking_subdomain": None})
millionsend.Domains.update({"id": domain.id, "receiving_catch_all": True}) # accept mail for addresses without an inbox
millionsend.Domains.remove(domain.id)
```

Expand All @@ -315,11 +408,14 @@ rotated.signing_secret
rotated.previous_secret_expires_at # None once the old secret stops signing; also on get()
```

Events include `email.received` and the `inbox.*` events (`inbox.created`, `inbox.updated`, `inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`, `inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`, `inbox.email.quarantined`, `inbox.draft.created`, `inbox.draft.updated`, `inbox.draft.sent`, `inbox.draft.deleted`). Inbox events reach only endpoints that list them by name. `inbox_ids` limits an endpoint's inbox events and `email.received` to those inboxes (`None` = every inbox).

### API keys

```python
key = millionsend.ApiKeys.create({"name": "ci", "permission": "sending_access", "domain_id": domain.id})
key.token # shown once
millionsend.ApiKeys.create({"name": "signup-agent", "permission": "full_access", "inbox_id": inbox.id})
millionsend.ApiKeys.list()
millionsend.ApiKeys.remove(key.id)
```
Expand Down Expand Up @@ -386,8 +482,8 @@ Method names and payloads match. Notes:

- **Same resources**: `Emails`, `Batch`, `Contacts` (with `.Topics`, `.Segments`), `ContactProperties`, `Topics`, `Broadcasts`, `Suppressions` (with `.Batch`), `Domains`, `Webhooks`, `ApiKeys`, `Templates`. Payloads are sent verbatim, so a resend-python payload works as-is.
- **No audiences**: contacts are team-global, so there is no `Audiences` resource and no `audience_id` params. The API's `/audiences/...` routes are a compatibility shim for raw HTTP callers and are deliberately not exposed here. Resend's `Segments` is an alias of audiences; MillionSend's `Segments` is the distinct dynamic-filter feature.
- **MillionSend extensions** (no Resend equivalent): `Segments`, `Contacts.Batch`, `Contacts.list(segment_id=...)`, `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`, `Emails.get_insights`.
- **Not available**: Resend's `ApiKeys.update`, `Webhooks` event history/replay/`verify`, `Emails.share` / `Emails.metrics` / receiving, `Broadcasts.recipients` / `clicked_links`, `Contacts.Segments.list`, `DomainClaims`, `ContactImports`, `Automations`, `Events`, `Logs`, `OAuthGrants`, and the `*_async` variants.
- **MillionSend extensions** (no Resend equivalent): `Segments`, `Contacts.Batch`, `Contacts.list(segment_id=...)`, `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`, `Emails.get_insights`, `Emails.Receiving.reply` / `forward` / `remove`, and `Inboxes` (Resend's Inboxes are not in resend-python yet).
- **Not available**: Resend's `ApiKeys.update`, `Webhooks` event history/replay/`verify`, `Emails.share` / `Emails.metrics`, `Broadcasts.recipients` / `clicked_links`, `Contacts.Segments.list`, `DomainClaims`, `ContactImports`, `Automations`, `Events`, `Logs`, `OAuthGrants`, and the `*_async` variants.
- MillionSend raises on API errors just like `resend`; the exception carries `.code` / `.status_code` / `.message`.

## License
Expand Down
14 changes: 14 additions & 0 deletions millionsend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@
from .errors import (
AllRecipientsSuppressedError,
ApplicationError,
ApprovalRequiredError,
ConcurrentIdempotentRequestsError,
ConflictError,
DailyQuotaExceededError,
EmailBlockedError,
EmailQuarantinedError,
ForbiddenError,
InboxSendCapReachedError,
InboxSendPolicyError,
InternalServerError,
InvalidApiKeyError,
InvalidIdempotentRequestError,
Expand All @@ -51,8 +56,10 @@
RateLimitExceededError,
RestrictedApiKeyError,
SendingPausedError,
TooManyWaitsError,
ValidationError,
)
from .inboxes import Inboxes
from .segments import Segments
from .suppressions import Suppressions
from .templates import Templates
Expand All @@ -75,6 +82,7 @@
"allow_insecure_http",
"Emails",
"Batch",
"Inboxes",
"Contacts",
"ContactProperties",
"Topics",
Expand Down Expand Up @@ -106,6 +114,12 @@
"PlanLimitReachedError",
"InvalidIdempotentRequestError",
"ConcurrentIdempotentRequestsError",
"InboxSendPolicyError",
"InboxSendCapReachedError",
"ApprovalRequiredError",
"EmailQuarantinedError",
"EmailBlockedError",
"TooManyWaitsError",
"InternalServerError",
"ApplicationError",
"__version__",
Expand Down
63 changes: 59 additions & 4 deletions millionsend/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

from .errors import MillionSendError, MissingApiKeyError, raise_api_error

VERSION = "0.8.0"
VERSION = "0.9.0"
DEFAULT_BASE_URL = "https://api.millionsend.com"
DEFAULT_TIMEOUT = 60.0
# Seconds a long-poll answer may take to arrive after the server's own ``wait`` deadline.
LONG_POLL_MARGIN = 10.0

_JSON_METHODS = ("POST", "PATCH")
_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
Expand Down Expand Up @@ -91,6 +93,15 @@ def path_id(value: Any) -> str:
return quote(str(value), safe="")


def _has_dot_segment(path: str) -> bool:
"""Whether a path has an empty, "." or ".." segment.

URL normalization resolves those away, so an id such as ".." would send the
request to another resource (DELETE /inboxes/{id}/labels/.. deletes the inbox).
"""
return any(segment in ("", ".", "..") for segment in path.split("/")[1:])


def split_id(params: Dict[str, Any], *keys: str) -> Tuple[str, Dict[str, Any]]:
"""Pop the target id out of a resend-python style ``{"id": ..., **fields}`` dict.

Expand Down Expand Up @@ -120,10 +131,21 @@ def list_query(
else:
query = {"limit": limit, "after": after, "before": before}
query.update({k: v for k, v in extra.items() if v is not None})
query = {k: v for k, v in query.items() if v is not None}
query = {k: _query_value(v) for k, v in query.items() if v is not None}
# An empty list stays off the wire: ``labels=`` is not "no labels".
query = {k: v for k, v in query.items() if v != ""}
return query or None


def _query_value(value: Any) -> Any:
"""Booleans as the API reads them ("true"/"false", not Python's "True"); lists comma-separated."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (list, tuple)):
return ",".join(str(v) for v in value)
return value


def request_options(
options: Options = None,
idempotency_key: Optional[str] = None,
Expand Down Expand Up @@ -151,6 +173,32 @@ def request(
batch_validation: Optional[str] = None,
) -> Any:
"""Dispatch one request and return the wrapped JSON body, or raise on error."""
_status, parsed = _dispatch(method, path, body, query, idempotency_key, batch_validation)
return _wrap(parsed)


def request_outcome(path: str, *, body: Any = None, idempotency_key: Optional[str] = None) -> Response:
"""POST a send a person may have to approve (replies, forwards, draft sends).

Returns ``{"kind": "sent", "email": ...}``, or on an HTTP 202
``{"kind": "draft", "draft": ...}``: the draft the API held for approval.
"""
status, parsed = _dispatch("POST", path, body, None, idempotency_key, None)
if status == 202:
return Response(kind="draft", draft=_wrap(parsed))
return Response(kind="sent", email=_wrap(parsed))


def _dispatch(
method: str,
path: str,
body: Any,
query: Optional[Dict[str, Any]],
idempotency_key: Optional[str],
batch_validation: Optional[str],
) -> Tuple[int, Any]:
if _has_dot_segment(path):
raise ValueError('an id in the request path is empty, "." or ".."')
url = _base_url() + path
headers = {
"Authorization": f"Bearer {_api_key()}",
Expand All @@ -167,14 +215,21 @@ def request(
if batch_validation and method == "POST":
headers["x-batch-validation"] = batch_validation

timeout = _config("timeout") or DEFAULT_TIMEOUT
# A ``wait`` query asks the API to hold its answer up to that many seconds
# (long-poll), so the deadline has to outlast it.
wait = (query or {}).get("wait")
if isinstance(wait, (int, float)) and not isinstance(wait, bool) and wait > 0:
timeout = max(timeout, wait + LONG_POLL_MARGIN)

try:
resp = requests.request(
method,
url,
headers=headers,
data=data,
params=query,
timeout=_config("timeout") or DEFAULT_TIMEOUT,
timeout=timeout,
)
except requests.exceptions.RequestException as exc:
raise MillionSendError(
Expand All @@ -191,4 +246,4 @@ def request(

if not 200 <= resp.status_code < 300:
raise_api_error(resp.status_code, parsed)
return _wrap(parsed)
return resp.status_code, parsed
2 changes: 0 additions & 2 deletions millionsend/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,5 @@ def list(
"""
query = list_query(limit, after, before, include=include) or {}
segment_id = query.pop("segment_id", segment_id)
if isinstance(query.get("include"), list):
query["include"] = ",".join(query["include"])
path = f"/segments/{path_id(segment_id)}/contacts" if segment_id else "/contacts"
return request("GET", path, query=query or None)
4 changes: 4 additions & 0 deletions millionsend/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
from typing import Any, Dict, List, Optional

from ._client import Options, list_query, path_id, request, request_options, split_id
from .receiving import Receiving


class Emails:
# Mirrors resend-python: Emails.Receiving.list(), Emails.Receiving.Attachments.get(...).
Receiving = Receiving

@classmethod
def send(
cls,
Expand Down
30 changes: 30 additions & 0 deletions millionsend/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,30 @@ class ConcurrentIdempotentRequestsError(MillionSendError):
pass


class InboxSendPolicyError(MillionSendError):
"""403: the inbox's rules hold this send; create a draft for a person to approve instead."""


class InboxSendCapReachedError(MillionSendError):
"""429: the inbox reached its daily send cap."""


class ApprovalRequiredError(MillionSendError):
"""403: a key restricted to an inbox cannot send a draft that waits for approval."""


class EmailQuarantinedError(MillionSendError):
"""403: the email is held in quarantine until a person releases it."""


class EmailBlockedError(MillionSendError):
"""410: downloads of an email that carries malware are blocked."""


class TooManyWaitsError(MillionSendError):
"""429: too many concurrent long-polls (``wait``) for the team."""


class InternalServerError(MillionSendError):
pass

Expand Down Expand Up @@ -126,6 +150,12 @@ class ApplicationError(MillionSendError):
"plan_limit_reached": PlanLimitReachedError,
"invalid_idempotent_request": InvalidIdempotentRequestError,
"concurrent_idempotent_requests": ConcurrentIdempotentRequestsError,
"inbox_send_policy": InboxSendPolicyError,
"inbox_send_cap_reached": InboxSendCapReachedError,
"approval_required": ApprovalRequiredError,
"email_quarantined": EmailQuarantinedError,
"email_blocked": EmailBlockedError,
"too_many_waits": TooManyWaitsError,
"internal_server_error": InternalServerError,
"application_error": ApplicationError,
}
Expand Down
Loading
Loading