sendkit 1.0.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.
@@ -0,0 +1,62 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ['[0-9]*']
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ tests:
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ matrix:
16
+ python: ['3.10', '3.11', '3.12', '3.13']
17
+
18
+ name: Python ${{ matrix.python }}
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Setup Python
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: ${{ matrix.python }}
27
+
28
+ - name: Install package and test dependencies
29
+ run: pip install -e . && pip install pytest
30
+
31
+ - name: Run tests
32
+ run: python -m pytest -v
33
+
34
+ release:
35
+ needs: tests
36
+ runs-on: ubuntu-latest
37
+ if: startsWith(github.ref, 'refs/tags/')
38
+ permissions:
39
+ contents: write
40
+ id-token: write
41
+
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+
45
+ - name: Create GitHub Release
46
+ run: gh release create ${{ github.ref_name }} --generate-notes
47
+ env:
48
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49
+
50
+ - name: Setup Python
51
+ uses: actions/setup-python@v5
52
+ with:
53
+ python-version: '3.13'
54
+
55
+ - name: Install build tools
56
+ run: pip install build
57
+
58
+ - name: Build package
59
+ run: python -m build
60
+
61
+ - name: Publish to PyPI
62
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .DS_Store
@@ -0,0 +1,38 @@
1
+ # SendKit Python SDK
2
+
3
+ ## Project Overview
4
+
5
+ Python SDK for the SendKit email API. Uses `urllib` (stdlib), zero external dependencies.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ src/sendkit/
11
+ ├── __init__.py # Public exports (SendKit, SendKitError)
12
+ ├── client.py # SendKit client: holds API key, _post() method
13
+ ├── emails.py # Emails service (send, send_mime)
14
+ └── errors.py # SendKitError exception class
15
+ ```
16
+
17
+ - `SendKit` class is the entry point, accepts api_key + optional base_url
18
+ - `client.emails` exposes email operations
19
+ - Uses keyword-only arguments for send() params
20
+ - `from_` parameter (trailing underscore) avoids Python reserved word conflict
21
+ - API errors raise `SendKitError` with name, message, status_code
22
+ - `POST /v1/emails` for structured emails, `POST /v1/emails/mime` for raw MIME
23
+
24
+ ## Testing
25
+
26
+ - Tests use `unittest` + `http.server` for mock HTTP servers
27
+ - Run tests: `python -m pytest`
28
+ - No external test dependencies beyond pytest
29
+
30
+ ## Releasing
31
+
32
+ - Tags use numeric format: `1.0.0` (no `v` prefix)
33
+ - CI runs tests on Python 3.10, 3.11, 3.12, 3.13
34
+ - Pushing a tag creates GitHub Release + publishes to PyPI via trusted publishing
35
+
36
+ ## Git
37
+
38
+ - NEVER add `Co-Authored-By` lines to commit messages
sendkit-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendkit
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the SendKit email API
5
+ Project-URL: Homepage, https://sendkit.com
6
+ Project-URL: Repository, https://github.com/sendkitdev/sendkit-python
7
+ Project-URL: Issues, https://github.com/sendkitdev/sendkit-python/issues
8
+ License-Expression: MIT
9
+ Keywords: api,email,sdk,sendkit
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # SendKit Python SDK
23
+
24
+ Official Python SDK for the [SendKit](https://sendkit.com) email API.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install sendkit
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Create a Client
35
+
36
+ ```python
37
+ from sendkit import SendKit
38
+
39
+ client = SendKit("sk_your_api_key")
40
+ ```
41
+
42
+ ### Send an Email
43
+
44
+ ```python
45
+ result = client.emails.send(
46
+ from_="you@example.com",
47
+ to="recipient@example.com",
48
+ subject="Hello from SendKit",
49
+ html="<h1>Welcome!</h1>",
50
+ )
51
+
52
+ print(result["id"])
53
+ ```
54
+
55
+ ### Send a MIME Email
56
+
57
+ ```python
58
+ result = client.emails.send_mime(
59
+ envelope_from="you@example.com",
60
+ envelope_to="recipient@example.com",
61
+ raw_message=mime_string,
62
+ )
63
+ ```
64
+
65
+ ### Error Handling
66
+
67
+ API errors raise `SendKitError`:
68
+
69
+ ```python
70
+ from sendkit import SendKit, SendKitError
71
+
72
+ client = SendKit("sk_your_api_key")
73
+
74
+ try:
75
+ client.emails.send(...)
76
+ except SendKitError as e:
77
+ print(e.name) # e.g. "validation_error"
78
+ print(e.message) # e.g. "The to field is required."
79
+ print(e.status_code) # e.g. 422
80
+ ```
81
+
82
+ ### Configuration
83
+
84
+ ```python
85
+ # Read API key from SENDKIT_API_KEY environment variable
86
+ client = SendKit()
87
+
88
+ # Custom base URL
89
+ client = SendKit("sk_...", base_url="https://custom.api.com")
90
+ ```
@@ -0,0 +1,69 @@
1
+ # SendKit Python SDK
2
+
3
+ Official Python SDK for the [SendKit](https://sendkit.com) email API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install sendkit
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Create a Client
14
+
15
+ ```python
16
+ from sendkit import SendKit
17
+
18
+ client = SendKit("sk_your_api_key")
19
+ ```
20
+
21
+ ### Send an Email
22
+
23
+ ```python
24
+ result = client.emails.send(
25
+ from_="you@example.com",
26
+ to="recipient@example.com",
27
+ subject="Hello from SendKit",
28
+ html="<h1>Welcome!</h1>",
29
+ )
30
+
31
+ print(result["id"])
32
+ ```
33
+
34
+ ### Send a MIME Email
35
+
36
+ ```python
37
+ result = client.emails.send_mime(
38
+ envelope_from="you@example.com",
39
+ envelope_to="recipient@example.com",
40
+ raw_message=mime_string,
41
+ )
42
+ ```
43
+
44
+ ### Error Handling
45
+
46
+ API errors raise `SendKitError`:
47
+
48
+ ```python
49
+ from sendkit import SendKit, SendKitError
50
+
51
+ client = SendKit("sk_your_api_key")
52
+
53
+ try:
54
+ client.emails.send(...)
55
+ except SendKitError as e:
56
+ print(e.name) # e.g. "validation_error"
57
+ print(e.message) # e.g. "The to field is required."
58
+ print(e.status_code) # e.g. 422
59
+ ```
60
+
61
+ ### Configuration
62
+
63
+ ```python
64
+ # Read API key from SENDKIT_API_KEY environment variable
65
+ client = SendKit()
66
+
67
+ # Custom base URL
68
+ client = SendKit("sk_...", base_url="https://custom.api.com")
69
+ ```
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sendkit"
7
+ version = "1.0.0"
8
+ description = "Python SDK for the SendKit email API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["sendkit", "email", "api", "sdk"]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://sendkit.com"
27
+ Repository = "https://github.com/sendkitdev/sendkit-python"
28
+ Issues = "https://github.com/sendkitdev/sendkit-python/issues"
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ from .client import SendKit
2
+ from .errors import SendKitError
3
+
4
+ __all__ = ["SendKit", "SendKitError"]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any
6
+ from urllib.error import HTTPError
7
+ from urllib.request import Request, urlopen
8
+
9
+ from .emails import Emails
10
+ from .errors import SendKitError
11
+
12
+ _DEFAULT_BASE_URL = "https://api.sendkit.com"
13
+
14
+
15
+ class SendKit:
16
+ """SendKit API client.
17
+
18
+ Args:
19
+ api_key: Your SendKit API key. If not provided, reads from
20
+ the ``SENDKIT_API_KEY`` environment variable.
21
+ base_url: Override the API base URL.
22
+ """
23
+
24
+ def __init__(self, api_key: str | None = None, *, base_url: str = _DEFAULT_BASE_URL) -> None:
25
+ self.api_key = api_key or os.environ.get("SENDKIT_API_KEY", "")
26
+
27
+ if not self.api_key:
28
+ raise ValueError(
29
+ 'Missing API key. Pass it to the constructor `SendKit("sk_...")` '
30
+ "or set the SENDKIT_API_KEY environment variable."
31
+ )
32
+
33
+ self.base_url = base_url
34
+ self.emails = Emails(self)
35
+
36
+ def _post(self, path: str, body: dict[str, Any]) -> Any:
37
+ data = json.dumps(body).encode()
38
+ req = Request(
39
+ f"{self.base_url}{path}",
40
+ data=data,
41
+ method="POST",
42
+ headers={
43
+ "Authorization": f"Bearer {self.api_key}",
44
+ "Content-Type": "application/json",
45
+ },
46
+ )
47
+
48
+ try:
49
+ with urlopen(req) as resp:
50
+ return json.loads(resp.read())
51
+ except HTTPError as exc:
52
+ try:
53
+ error_body = json.loads(exc.read())
54
+ raise SendKitError(
55
+ message=error_body.get("message", exc.reason),
56
+ status_code=exc.code,
57
+ name=error_body.get("name", "application_error"),
58
+ ) from None
59
+ except (json.JSONDecodeError, AttributeError):
60
+ raise SendKitError(
61
+ message=str(exc.reason),
62
+ status_code=exc.code,
63
+ ) from None
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from .client import SendKit
7
+
8
+
9
+ class Emails:
10
+ """Handles communication with the email related methods of the SendKit API."""
11
+
12
+ def __init__(self, client: SendKit) -> None:
13
+ self._client = client
14
+
15
+ def send(
16
+ self,
17
+ *,
18
+ from_: str,
19
+ to: str | list[str],
20
+ subject: str,
21
+ html: str | None = None,
22
+ text: str | None = None,
23
+ cc: str | list[str] | None = None,
24
+ bcc: str | list[str] | None = None,
25
+ reply_to: str | None = None,
26
+ headers: dict[str, str] | None = None,
27
+ tags: list[str] | None = None,
28
+ scheduled_at: str | None = None,
29
+ attachments: list[dict[str, Any]] | None = None,
30
+ ) -> dict[str, str]:
31
+ """Send a structured email.
32
+
33
+ Returns a dict with the email ``id``.
34
+ """
35
+ payload: dict[str, Any] = {
36
+ "from": from_,
37
+ "to": to,
38
+ "subject": subject,
39
+ }
40
+
41
+ if html is not None:
42
+ payload["html"] = html
43
+ if text is not None:
44
+ payload["text"] = text
45
+ if cc is not None:
46
+ payload["cc"] = cc
47
+ if bcc is not None:
48
+ payload["bcc"] = bcc
49
+ if reply_to is not None:
50
+ payload["reply_to"] = reply_to
51
+ if headers is not None:
52
+ payload["headers"] = headers
53
+ if tags is not None:
54
+ payload["tags"] = tags
55
+ if scheduled_at is not None:
56
+ payload["scheduled_at"] = scheduled_at
57
+ if attachments is not None:
58
+ payload["attachments"] = attachments
59
+
60
+ return self._client._post("/v1/emails", payload)
61
+
62
+ def send_mime(
63
+ self,
64
+ *,
65
+ envelope_from: str,
66
+ envelope_to: str,
67
+ raw_message: str,
68
+ ) -> dict[str, str]:
69
+ """Send a raw MIME email.
70
+
71
+ Returns a dict with the email ``id``.
72
+ """
73
+ return self._client._post("/v1/emails/mime", {
74
+ "envelope_from": envelope_from,
75
+ "envelope_to": envelope_to,
76
+ "raw_message": raw_message,
77
+ })
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SendKitError(Exception):
5
+ """Represents an error response from the SendKit API."""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None, name: str = "application_error") -> None:
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.status_code = status_code
11
+ self.name = name
12
+
13
+ def __repr__(self) -> str:
14
+ return f"SendKitError(name={self.name!r}, status_code={self.status_code}, message={self.message!r})"
File without changes
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from threading import Thread
7
+ from typing import Any
8
+ from unittest import TestCase
9
+
10
+ from sendkit import SendKit, SendKitError
11
+
12
+
13
+ def _make_server(handler_class: type[BaseHTTPRequestHandler]) -> tuple[HTTPServer, str]:
14
+ server = HTTPServer(("127.0.0.1", 0), handler_class)
15
+ port = server.server_address[1]
16
+ thread = Thread(target=server.serve_forever, daemon=True)
17
+ thread.start()
18
+ return server, f"http://127.0.0.1:{port}"
19
+
20
+
21
+ class TestNewClient(TestCase):
22
+ def test_with_api_key(self) -> None:
23
+ client = SendKit("sk_test_123")
24
+ self.assertEqual(client.api_key, "sk_test_123")
25
+
26
+ def test_missing_api_key(self) -> None:
27
+ os.environ.pop("SENDKIT_API_KEY", None)
28
+ with self.assertRaises(ValueError):
29
+ SendKit()
30
+
31
+ def test_from_env_variable(self) -> None:
32
+ os.environ["SENDKIT_API_KEY"] = "sk_from_env"
33
+ try:
34
+ client = SendKit()
35
+ self.assertEqual(client.api_key, "sk_from_env")
36
+ finally:
37
+ del os.environ["SENDKIT_API_KEY"]
38
+
39
+ def test_custom_base_url(self) -> None:
40
+ client = SendKit("sk_test_123", base_url="https://custom.api.com")
41
+ self.assertEqual(client.base_url, "https://custom.api.com")
42
+
43
+
44
+ class TestEmailsSend(TestCase):
45
+ def test_send_email(self) -> None:
46
+ captured: dict[str, Any] = {}
47
+
48
+ class Handler(BaseHTTPRequestHandler):
49
+ def do_POST(self) -> None:
50
+ captured["path"] = self.path
51
+ captured["method"] = self.command
52
+ captured["auth"] = self.headers.get("Authorization")
53
+ length = int(self.headers.get("Content-Length", 0))
54
+ captured["body"] = json.loads(self.rfile.read(length))
55
+ self.send_response(200)
56
+ self.end_headers()
57
+ self.wfile.write(json.dumps({"id": "email-uuid-123"}).encode())
58
+
59
+ def log_message(self, *args: Any) -> None:
60
+ pass
61
+
62
+ server, url = _make_server(Handler)
63
+ try:
64
+ client = SendKit("sk_test_123", base_url=url)
65
+ result = client.emails.send(
66
+ from_="sender@example.com",
67
+ to="recipient@example.com",
68
+ subject="Test Email",
69
+ html="<p>Hello</p>",
70
+ )
71
+
72
+ self.assertEqual(result["id"], "email-uuid-123")
73
+ self.assertEqual(captured["path"], "/v1/emails")
74
+ self.assertEqual(captured["method"], "POST")
75
+ self.assertEqual(captured["auth"], "Bearer sk_test_123")
76
+ self.assertEqual(captured["body"]["from"], "sender@example.com")
77
+ self.assertEqual(captured["body"]["to"], "recipient@example.com")
78
+ self.assertEqual(captured["body"]["subject"], "Test Email")
79
+ finally:
80
+ server.shutdown()
81
+
82
+ def test_send_with_optional_fields(self) -> None:
83
+ captured: dict[str, Any] = {}
84
+
85
+ class Handler(BaseHTTPRequestHandler):
86
+ def do_POST(self) -> None:
87
+ length = int(self.headers.get("Content-Length", 0))
88
+ captured["body"] = json.loads(self.rfile.read(length))
89
+ self.send_response(200)
90
+ self.end_headers()
91
+ self.wfile.write(json.dumps({"id": "email-uuid-456"}).encode())
92
+
93
+ def log_message(self, *args: Any) -> None:
94
+ pass
95
+
96
+ server, url = _make_server(Handler)
97
+ try:
98
+ client = SendKit("sk_test_123", base_url=url)
99
+ client.emails.send(
100
+ from_="sender@example.com",
101
+ to="recipient@example.com",
102
+ subject="Test",
103
+ html="<p>Hi</p>",
104
+ reply_to="reply@example.com",
105
+ scheduled_at="2026-03-01T10:00:00Z",
106
+ )
107
+
108
+ self.assertEqual(captured["body"]["reply_to"], "reply@example.com")
109
+ self.assertEqual(captured["body"]["scheduled_at"], "2026-03-01T10:00:00Z")
110
+ finally:
111
+ server.shutdown()
112
+
113
+ def test_send_mime_email(self) -> None:
114
+ captured: dict[str, Any] = {}
115
+
116
+ class Handler(BaseHTTPRequestHandler):
117
+ def do_POST(self) -> None:
118
+ captured["path"] = self.path
119
+ length = int(self.headers.get("Content-Length", 0))
120
+ captured["body"] = json.loads(self.rfile.read(length))
121
+ self.send_response(200)
122
+ self.end_headers()
123
+ self.wfile.write(json.dumps({"id": "mime-uuid-789"}).encode())
124
+
125
+ def log_message(self, *args: Any) -> None:
126
+ pass
127
+
128
+ server, url = _make_server(Handler)
129
+ try:
130
+ client = SendKit("sk_test_123", base_url=url)
131
+ result = client.emails.send_mime(
132
+ envelope_from="sender@example.com",
133
+ envelope_to="recipient@example.com",
134
+ raw_message="From: sender@example.com\r\nTo: recipient@example.com\r\n\r\nHello",
135
+ )
136
+
137
+ self.assertEqual(result["id"], "mime-uuid-789")
138
+ self.assertEqual(captured["path"], "/v1/emails/mime")
139
+ self.assertEqual(captured["body"]["envelope_from"], "sender@example.com")
140
+ self.assertEqual(captured["body"]["envelope_to"], "recipient@example.com")
141
+ finally:
142
+ server.shutdown()
143
+
144
+ def test_api_error(self) -> None:
145
+ class Handler(BaseHTTPRequestHandler):
146
+ def do_POST(self) -> None:
147
+ self.send_response(422)
148
+ self.end_headers()
149
+ self.wfile.write(json.dumps({
150
+ "name": "validation_error",
151
+ "message": "The to field is required.",
152
+ "statusCode": 422,
153
+ }).encode())
154
+
155
+ def log_message(self, *args: Any) -> None:
156
+ pass
157
+
158
+ server, url = _make_server(Handler)
159
+ try:
160
+ client = SendKit("sk_test_123", base_url=url)
161
+ with self.assertRaises(SendKitError) as ctx:
162
+ client.emails.send(
163
+ from_="sender@example.com",
164
+ to="",
165
+ subject="Test",
166
+ html="<p>Hi</p>",
167
+ )
168
+
169
+ err = ctx.exception
170
+ self.assertEqual(err.name, "validation_error")
171
+ self.assertEqual(err.status_code, 422)
172
+ self.assertEqual(err.message, "The to field is required.")
173
+ finally:
174
+ server.shutdown()