Skip to content
Merged
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
183 changes: 178 additions & 5 deletions README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions millionsend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,33 @@
from .errors import (
AllRecipientsSuppressedError,
ApplicationError,
ApprovalRequiredError,
ConcurrentIdempotentRequestsError,
ConflictError,
DailyQuotaExceededError,
EmailBlockedError,
EmailQuarantinedError,
ForbiddenError,
InboxSendCapReachedError,
InboxSendPolicyError,
InternalServerError,
InvalidApiKeyError,
InvalidIdempotentRequestError,
InvalidParameterError,
InvalidPayloadError,
MillionSendError,
MissingApiKeyError,
NameReviewUnavailableError,
NotFoundError,
PayloadTooLargeError,
PlanLimitReachedError,
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 +83,7 @@
"allow_insecure_http",
"Emails",
"Batch",
"Inboxes",
"Contacts",
"ContactProperties",
"Topics",
Expand Down Expand Up @@ -106,6 +115,13 @@
"PlanLimitReachedError",
"InvalidIdempotentRequestError",
"ConcurrentIdempotentRequestsError",
"InboxSendPolicyError",
"InboxSendCapReachedError",
"ApprovalRequiredError",
"EmailQuarantinedError",
"EmailBlockedError",
"TooManyWaitsError",
"NameReviewUnavailableError",
"InternalServerError",
"ApplicationError",
"__version__",
Expand Down
65 changes: 60 additions & 5 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 @@ -190,5 +245,5 @@ def request(
parsed = text

if not 200 <= resp.status_code < 300:
raise_api_error(resp.status_code, parsed)
return _wrap(parsed)
raise_api_error(resp.status_code, parsed, resp.headers.get("retry-after"))
return resp.status_code, parsed
3 changes: 2 additions & 1 deletion millionsend/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
class ApiKeys:
@classmethod
def create(cls, params: Dict[str, Any]) -> Any:
"""POST /api-keys — name, permission? (full_access|sending_access), domain_id?"""
"""POST /api-keys — name, permission? (full_access|sending_access), domain_id? (one of the
team's own domains: a GET /domains row with ``shared: False``), inbox_id?"""
return request("POST", "/api-keys", body=params)

@classmethod
Expand Down
2 changes: 2 additions & 0 deletions millionsend/broadcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Targeting is an optional ``segment_id`` and/or ``topic_id`` on create/update;
neither set means every contact of the team. ``update`` and ``send`` take
``(broadcast_id, ...)`` or resend-python's single ``{"broadcast_id": ..., **fields}`` dict.
Broadcasts never send from the shared domain: ``send`` (or ``create`` with ``send: True``)
raises ``InboxSendPolicyError``.
"""

from typing import Any, Dict, Optional, Tuple, Union
Expand Down
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)
8 changes: 7 additions & 1 deletion millionsend/domains.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Sending domains — create returns the DNS records to publish; verify re-checks them."""
"""Sending domains — create returns the DNS records to publish; verify re-checks them.

GET /domains ends with the team's subdomain of the shared domain (``shared: True``), which
only sends from an inbox's own address. That row is read-only: ``update``, ``verify`` and
``remove`` raise ``ForbiddenError``. Servers before MillionSend v0.14.0 send no ``shared`` key.
"""

from typing import Any, Dict, Optional

Expand All @@ -22,6 +27,7 @@ def list(
after: Optional[str] = None,
before: Optional[str] = None,
) -> Any:
"""GET /domains — the team's own domains newest first, then the shared row."""
return request("GET", "/domains", query=list_query(limit, after, before))

@classmethod
Expand Down
13 changes: 12 additions & 1 deletion millionsend/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@
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,
params: Dict[str, Any],
options: Options = None,
idempotency_key: Optional[str] = None,
) -> Any:
"""POST /emails — supports an Idempotency-Key."""
"""POST /emails — supports an Idempotency-Key.

``from`` may be an inbox's address on the shared domain while its ``sending_mode`` is
``"open"``: that exact address (no +tag), with no display name or the inbox's
``from_name``, no ``reply_to``, ``attachments``, ``headers`` or ``scheduled_at``, and at
most 10 distinct recipients across to, cc and bcc.
"""
return request("POST", "/emails", body=params, **request_options(options, idempotency_key))

@classmethod
Expand Down Expand Up @@ -72,6 +82,7 @@ def send(

``batch_validation`` is ``"strict"`` (default: any invalid item rejects the
batch) or ``"permissive"`` (valid items are sent, the rest come back in ``errors``).
Items never send from the shared domain: such an item is refused as ``inbox_send_policy``.
"""
return request(
"POST",
Expand Down
54 changes: 51 additions & 3 deletions millionsend/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ class MillionSendError(Exception):
or ``None`` for a client-side failure.
message: human-readable message.
status_code: HTTP status, or ``None`` when the request never reached the API.
retry_after: the response's ``retry-after`` header in seconds, or ``None`` when it
had none. The SDK never retries by itself.
"""

def __init__(
self,
message: str,
code: Optional[str] = None,
status_code: Optional[int] = None,
retry_after: Optional[int] = None,
) -> None:
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.retry_after = retry_after


class MissingApiKeyError(MillionSendError):
Expand Down Expand Up @@ -99,6 +103,36 @@ class ConcurrentIdempotentRequestsError(MillionSendError):
pass


class InboxSendPolicyError(MillionSendError):
"""403: the inbox's rules hold this send (a draft a person approves can still go out), or
the shared domain's sending mode refuses it."""


class InboxSendCapReachedError(MillionSendError):
"""429: the inbox's daily send cap, or the team's daily limit for mail from the shared
domain, is reached; don't retry before ``retry_after``."""


class NameReviewUnavailableError(MillionSendError):
"""503: a name on the shared domain can't be reviewed right now; retry after ``retry_after``."""


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,13 +160,20 @@ 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,
"name_review_unavailable": NameReviewUnavailableError,
"internal_server_error": InternalServerError,
"application_error": ApplicationError,
}


def raise_api_error(status: int, parsed: object) -> None:
"""Coerce a parsed non-2xx body into the canonical error and raise it."""
def raise_api_error(status: int, parsed: object, retry_after: Optional[str] = None) -> None:
"""Coerce a parsed non-2xx body and its ``retry-after`` header into the canonical error and raise it."""
if isinstance(parsed, dict):
name = parsed.get("name")
name = name if isinstance(name, str) else "application_error"
Expand All @@ -144,4 +185,11 @@ def raise_api_error(status: int, parsed: object) -> None:
name = "application_error"
message = f"Request failed with status {status}"
status_code = status
raise ERROR_TYPES.get(name, MillionSendError)(message, code=name, status_code=status_code)
# Only delta-seconds, the form the API sends; an HTTP-date or junk reads as no hint.
seconds = (retry_after or "").strip()
raise ERROR_TYPES.get(name, MillionSendError)(
message,
code=name,
status_code=status_code,
retry_after=int(seconds) if seconds.isdecimal() else None,
)
Loading
Loading