mailwire-sdk 0.1.1__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,28 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+
5
+ __pycache__/
6
+ *.pyc
7
+ .venv/
8
+ venv/
9
+ *.egg-info/
10
+ build/
11
+ dist/
12
+
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .mypy_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ scripts/.last_thread_id
20
+ /inbound/
21
+
22
+ /var/
23
+ infra/.bootstrap_admin_key.txt
24
+
25
+ .DS_Store
26
+
27
+ .claude/
28
+
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailwire-sdk
3
+ Version: 0.1.1
4
+ Summary: Python SDK for mailwire-x1
5
+ Requires-Python: <3.13,>=3.12
6
+ Requires-Dist: httpx<0.28,>=0.27
7
+ Provides-Extra: mcp
8
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
@@ -0,0 +1,120 @@
1
+ # mailwire-sdk
2
+
3
+ Python SDK for [mailwire-x1](https://github.com/anthropics/mailwire-x1).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install mailwire-sdk
9
+ ```
10
+
11
+ To also install the MCP server entry point:
12
+
13
+ ```bash
14
+ pip install 'mailwire-sdk[mcp]'
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from mailwire_sdk import Client
21
+
22
+ with Client(token="mw_live_...", base_url="https://api.example.com") as mw:
23
+ # Create an inbox on a verified tenant domain
24
+ inbox = mw.inboxes.create(
25
+ client_id="lena.taiwo",
26
+ display_name="Lena",
27
+ domain_id="dom_...",
28
+ shareable_local_part="lena",
29
+ private_address_count=2,
30
+ )
31
+
32
+ # Send a message
33
+ msg = mw.messages.send(
34
+ inbox_id=inbox["id"],
35
+ from_address="Lena <lena@someonehq.com>",
36
+ to=["recipient@example.com"],
37
+ subject="Catching up",
38
+ text="Hi — thinking of you.",
39
+ idempotency_key="20260503-lena-001",
40
+ )
41
+
42
+ # List recent messages
43
+ for m in mw.messages.list(inbox_id=inbox["id"], limit=20):
44
+ print(m["id"], m["subject"])
45
+ ```
46
+
47
+ ### Sub-clients
48
+
49
+ | Attribute | Resource |
50
+ |----------------|-------------------------------------------------------|
51
+ | `mw.tenants` | tenant CRUD (`admin` scope) |
52
+ | `mw.api_keys` | mint / list / get / revoke (`admin` scope) |
53
+ | `mw.domains` | register, fetch DNS records, verify, delete |
54
+ | `mw.inboxes` | create, list, get, update, archive, add_address |
55
+ | `mw.messages` | send, list, get |
56
+
57
+ ### Errors
58
+
59
+ Every non-2xx response from mailwire becomes a typed exception:
60
+
61
+ ```python
62
+ from mailwire_sdk import (
63
+ Client,
64
+ AuthError,
65
+ PermissionError,
66
+ NotFoundError,
67
+ ConflictError,
68
+ ValidationError,
69
+ ProviderError,
70
+ MailwireError, # base — catch-all
71
+ )
72
+
73
+ try:
74
+ mw.messages.send(...)
75
+ except ConflictError as exc:
76
+ # idempotency-key reused with a different body
77
+ print(exc.code, exc.details)
78
+ except PermissionError as exc:
79
+ # token is missing the required scope
80
+ print("missing:", exc.details["required_scope"])
81
+ except MailwireError as exc:
82
+ # any other API error — exc.type, exc.status_code, exc.request_id available
83
+ raise
84
+ ```
85
+
86
+ ## MCP server
87
+
88
+ `mailwire-sdk[mcp]` ships a stdio MCP server. Wire it into Claude Desktop / your
89
+ IDE:
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "mailwire": {
95
+ "command": "mailwire-mcp",
96
+ "env": {
97
+ "MAILWIRE_BASE_URL": "https://api.example.com",
98
+ "MAILWIRE_TOKEN": "mw_live_..."
99
+ }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ Tools exposed:
106
+
107
+ - `mailwire_list_inboxes`
108
+ - `mailwire_get_inbox`
109
+ - `mailwire_send_message`
110
+ - `mailwire_list_messages`
111
+ - `mailwire_get_message`
112
+
113
+ See [`docs/mcp.md`](../../docs/mcp.md) for tool argument schemas and the
114
+ end-to-end agent loop.
115
+
116
+ ## Versioning
117
+
118
+ `mailwire-sdk 0.1.0` targets the mailwire-x1 v1 API. Breaking server changes go
119
+ to a new `/v2/` surface and a major-version SDK bump. Additive changes (new
120
+ optional fields, new endpoints) ship in patch / minor versions.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "mailwire-sdk"
3
+ version = "0.1.1"
4
+ description = "Python SDK for mailwire-x1"
5
+ requires-python = ">=3.12,<3.13"
6
+ dependencies = [
7
+ "httpx>=0.27,<0.28",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ mcp = ["mcp>=1.0"]
12
+
13
+ [project.scripts]
14
+ mailwire-mcp = "mailwire_sdk.mcp_server:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/mailwire_sdk"]
@@ -0,0 +1,33 @@
1
+ """Python SDK for mailwire-x1.
2
+
3
+ Public surface:
4
+ - `Client` — synchronous client (use as context manager).
5
+ - `MailwireError` and subclasses for catching API errors.
6
+ """
7
+
8
+ from mailwire_sdk.client import Client
9
+ from mailwire_sdk.errors import (
10
+ AuthError,
11
+ ConflictError,
12
+ MailwireError,
13
+ NotFoundError,
14
+ PermissionError,
15
+ ProviderError,
16
+ ServerError,
17
+ ValidationError,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "AuthError",
24
+ "Client",
25
+ "ConflictError",
26
+ "MailwireError",
27
+ "NotFoundError",
28
+ "PermissionError",
29
+ "ProviderError",
30
+ "ServerError",
31
+ "ValidationError",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,69 @@
1
+ """HTTP transport — wraps httpx.Client with auth + error envelope decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from mailwire_sdk.errors import MailwireError, from_envelope
10
+
11
+
12
+ class Transport:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ token: str,
17
+ base_url: str,
18
+ timeout: float = 30.0,
19
+ user_agent: str = "mailwire-sdk-python/0.1.0",
20
+ ) -> None:
21
+ self._client = httpx.Client(
22
+ base_url=base_url.rstrip("/"),
23
+ headers={
24
+ "Authorization": f"Bearer {token}",
25
+ "User-Agent": user_agent,
26
+ "Accept": "application/json",
27
+ },
28
+ timeout=timeout,
29
+ )
30
+
31
+ def request(
32
+ self,
33
+ method: str,
34
+ path: str,
35
+ *,
36
+ params: dict[str, Any] | None = None,
37
+ json: dict[str, Any] | None = None,
38
+ headers: dict[str, str] | None = None,
39
+ ) -> dict[str, Any]:
40
+ try:
41
+ response = self._client.request(
42
+ method, path, params=params, json=json, headers=headers
43
+ )
44
+ except httpx.RequestError as exc:
45
+ raise MailwireError(f"network error: {exc}") from exc
46
+
47
+ if response.status_code == 204 or not response.content:
48
+ return {}
49
+
50
+ try:
51
+ payload = response.json()
52
+ except ValueError as exc:
53
+ raise MailwireError(
54
+ f"non-JSON response (HTTP {response.status_code}): {response.text[:200]!r}"
55
+ ) from exc
56
+
57
+ if response.status_code >= 400:
58
+ raise from_envelope(payload, status_code=response.status_code)
59
+
60
+ return payload # type: ignore[no-any-return]
61
+
62
+ def close(self) -> None:
63
+ self._client.close()
64
+
65
+ def __enter__(self) -> Transport:
66
+ return self
67
+
68
+ def __exit__(self, *exc: object) -> None:
69
+ self.close()
@@ -0,0 +1,76 @@
1
+ """Top-level mailwire SDK client.
2
+
3
+ The synchronous `Client` is the v0 surface — async support and generated typed
4
+ models land in Sprint 5. Usage:
5
+
6
+ from mailwire_sdk import Client
7
+ with Client(token="mw_live_...", base_url="https://api.example.com") as mw:
8
+ inbox = mw.inboxes.create(
9
+ client_id="lena.taiwo",
10
+ display_name="Lena",
11
+ domain_id="dom_...",
12
+ shareable_local_part="lena",
13
+ private_address_count=2,
14
+ )
15
+ mw.messages.send(
16
+ inbox_id=inbox["id"],
17
+ from_address="Lena <lena@someonehq.com>",
18
+ to=["recipient@example.com"],
19
+ subject="Catching up",
20
+ text="...",
21
+ )
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from mailwire_sdk._transport import Transport
29
+ from mailwire_sdk.resources import (
30
+ ApiKeysResource,
31
+ DomainsResource,
32
+ InboxesResource,
33
+ MessagesResource,
34
+ TenantsResource,
35
+ ThreadsResource,
36
+ WebhooksResource,
37
+ )
38
+
39
+
40
+ class Client:
41
+ """Synchronous mailwire client.
42
+
43
+ Use as a context manager (recommended) or call `.close()` when done. Each
44
+ `Client` owns a pooled `httpx.Client` under the hood.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ token: str,
51
+ base_url: str = "http://localhost:8083",
52
+ timeout: float = 30.0,
53
+ ) -> None:
54
+ self._transport = Transport(token=token, base_url=base_url, timeout=timeout)
55
+ self.tenants = TenantsResource(self._transport)
56
+ self.api_keys = ApiKeysResource(self._transport)
57
+ self.domains = DomainsResource(self._transport)
58
+ self.inboxes = InboxesResource(self._transport)
59
+ self.messages = MessagesResource(self._transport)
60
+ self.threads = ThreadsResource(self._transport)
61
+ self.webhooks = WebhooksResource(self._transport)
62
+
63
+ def healthz(self) -> dict[str, Any]:
64
+ """Service health probe — no auth required server-side, but the SDK
65
+ still sends the bearer header. Returns `{"status": "ok", "version": ...}`.
66
+ """
67
+ return self._transport.request("GET", "/healthz")
68
+
69
+ def close(self) -> None:
70
+ self._transport.close()
71
+
72
+ def __enter__(self) -> Client:
73
+ return self
74
+
75
+ def __exit__(self, *exc: object) -> None:
76
+ self.close()
@@ -0,0 +1,90 @@
1
+ """SDK error hierarchy mirrors the API error envelope.
2
+
3
+ Server response:
4
+
5
+ { "error": { "type": "validation_error", "code": "...", "message": "...",
6
+ "request_id": "...", "details": {...} } }
7
+
8
+ is parsed and raised as a typed subclass of `MailwireError`. Callers can either
9
+ catch the broad `MailwireError` or be specific (`AuthError`, `NotFoundError`,
10
+ `ConflictError`, etc.).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ class MailwireError(Exception):
19
+ """Base class for every error raised by the SDK."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ type: str | None = None,
26
+ code: str | None = None,
27
+ status_code: int | None = None,
28
+ details: dict[str, Any] | None = None,
29
+ request_id: str | None = None,
30
+ ) -> None:
31
+ super().__init__(message)
32
+ self.type = type
33
+ self.code = code
34
+ self.status_code = status_code
35
+ self.details: dict[str, Any] = details or {}
36
+ self.request_id = request_id
37
+
38
+
39
+ class AuthError(MailwireError):
40
+ """401 — missing or invalid bearer token."""
41
+
42
+
43
+ class PermissionError(MailwireError):
44
+ """403 — token lacks required scope."""
45
+
46
+
47
+ class NotFoundError(MailwireError):
48
+ """404 — resource missing or owned by another tenant."""
49
+
50
+
51
+ class ConflictError(MailwireError):
52
+ """409 — uniqueness conflict or idempotency-key reuse with a different body."""
53
+
54
+
55
+ class ValidationError(MailwireError):
56
+ """422 / 400 — request body failed validation."""
57
+
58
+
59
+ class ProviderError(MailwireError):
60
+ """502/503-ish — upstream provider failed."""
61
+
62
+
63
+ class ServerError(MailwireError):
64
+ """5xx — internal error."""
65
+
66
+
67
+ _BY_TYPE: dict[str, type[MailwireError]] = {
68
+ "auth_error": AuthError,
69
+ "permission_error": PermissionError,
70
+ "not_found": NotFoundError,
71
+ "conflict": ConflictError,
72
+ "validation_error": ValidationError,
73
+ "request_error": ValidationError,
74
+ "provider_error": ProviderError,
75
+ "server_error": ServerError,
76
+ }
77
+
78
+
79
+ def from_envelope(envelope: dict[str, Any], *, status_code: int) -> MailwireError:
80
+ """Construct the right subclass from an `{"error": {...}}` payload."""
81
+ err = envelope.get("error", envelope)
82
+ cls = _BY_TYPE.get(err.get("type", ""), MailwireError)
83
+ return cls(
84
+ err.get("message", "request failed"),
85
+ type=err.get("type"),
86
+ code=err.get("code"),
87
+ status_code=status_code,
88
+ details=err.get("details"),
89
+ request_id=err.get("request_id"),
90
+ )
@@ -0,0 +1,278 @@
1
+ """mailwire MCP server (stdio transport).
2
+
3
+ Exposes the inbox + message read/write surface as MCP tools so an AI agent host
4
+ (Claude Desktop, IDE extensions, etc.) can drive mailwire on a user's behalf.
5
+
6
+ Run via the `mailwire-mcp` console script:
7
+
8
+ MAILWIRE_BASE_URL=https://api.example.com \\
9
+ MAILWIRE_TOKEN=mw_live_... \\
10
+ mailwire-mcp
11
+
12
+ Or wire it into an MCP host config:
13
+
14
+ {
15
+ "mcpServers": {
16
+ "mailwire": {
17
+ "command": "mailwire-mcp",
18
+ "env": {
19
+ "MAILWIRE_BASE_URL": "https://api.example.com",
20
+ "MAILWIRE_TOKEN": "mw_live_..."
21
+ }
22
+ }
23
+ }
24
+ }
25
+
26
+ The server is intentionally thin — every tool is a direct passthrough to the
27
+ SDK with the response JSON-encoded back to the caller. No business logic lives
28
+ here.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import contextlib
35
+ import json
36
+ import os
37
+ import sys
38
+ from typing import Any
39
+
40
+ from mcp.server import Server
41
+ from mcp.server.stdio import stdio_server
42
+ from mcp.types import TextContent, Tool
43
+
44
+ from mailwire_sdk import Client, MailwireError
45
+
46
+ _TOOLS: list[dict[str, Any]] = [
47
+ {
48
+ "name": "mailwire_list_inboxes",
49
+ "description": (
50
+ "List inboxes for the authenticated tenant. Optionally filter by "
51
+ "workspace_id or project_id. Returns a JSON array of inbox objects "
52
+ "with their addresses inlined."
53
+ ),
54
+ "inputSchema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "workspace_id": {"type": "string"},
58
+ "project_id": {"type": "string"},
59
+ "limit": {"type": "integer", "default": 50, "maximum": 200},
60
+ },
61
+ "additionalProperties": False,
62
+ },
63
+ },
64
+ {
65
+ "name": "mailwire_get_inbox",
66
+ "description": "Fetch a single inbox by its public ID (inb_*).",
67
+ "inputSchema": {
68
+ "type": "object",
69
+ "properties": {"inbox_id": {"type": "string"}},
70
+ "required": ["inbox_id"],
71
+ "additionalProperties": False,
72
+ },
73
+ },
74
+ {
75
+ "name": "mailwire_send_message",
76
+ "description": (
77
+ "Send an outbound email. `from_address` may be bare "
78
+ "(`name@domain`) or display-name form (`Name <name@domain>`). "
79
+ "Provide `text` or `html` (or both). Pass `idempotency_key` if "
80
+ "you want safe retries."
81
+ ),
82
+ "inputSchema": {
83
+ "type": "object",
84
+ "properties": {
85
+ "inbox_id": {"type": "string"},
86
+ "from_address": {"type": "string"},
87
+ "to": {"type": "array", "items": {"type": "string"}, "minItems": 1},
88
+ "cc": {"type": "array", "items": {"type": "string"}},
89
+ "bcc": {"type": "array", "items": {"type": "string"}},
90
+ "subject": {"type": "string"},
91
+ "text": {"type": "string"},
92
+ "html": {"type": "string"},
93
+ "sending_domain_id": {"type": "string"},
94
+ "tags": {"type": "array", "items": {"type": "string"}},
95
+ "metadata": {"type": "object", "additionalProperties": {"type": "string"}},
96
+ "idempotency_key": {"type": "string"},
97
+ },
98
+ "required": ["inbox_id", "from_address", "to"],
99
+ "additionalProperties": False,
100
+ },
101
+ },
102
+ {
103
+ "name": "mailwire_list_messages",
104
+ "description": (
105
+ "List recent messages for the tenant. Filter by inbox_id, "
106
+ "thread_id, or direction (`outbound` / `inbound`)."
107
+ ),
108
+ "inputSchema": {
109
+ "type": "object",
110
+ "properties": {
111
+ "inbox_id": {"type": "string"},
112
+ "thread_id": {"type": "string"},
113
+ "direction": {"type": "string", "enum": ["inbound", "outbound"]},
114
+ "limit": {"type": "integer", "default": 50, "maximum": 200},
115
+ },
116
+ "additionalProperties": False,
117
+ },
118
+ },
119
+ {
120
+ "name": "mailwire_get_message",
121
+ "description": "Fetch a single message by its public ID (msg_*).",
122
+ "inputSchema": {
123
+ "type": "object",
124
+ "properties": {"message_id": {"type": "string"}},
125
+ "required": ["message_id"],
126
+ "additionalProperties": False,
127
+ },
128
+ },
129
+ {
130
+ "name": "mailwire_list_webhooks",
131
+ "description": (
132
+ "List active webhook subscribers for the tenant. Each entry "
133
+ "carries the URL, events_subscribed, secret_prefix, and "
134
+ "last delivery status."
135
+ ),
136
+ "inputSchema": {
137
+ "type": "object",
138
+ "properties": {},
139
+ "additionalProperties": False,
140
+ },
141
+ },
142
+ {
143
+ "name": "mailwire_register_webhook",
144
+ "description": (
145
+ "Register a new webhook subscriber. Returns the plaintext signing "
146
+ "secret EXACTLY ONCE — record it on the subscriber side."
147
+ ),
148
+ "inputSchema": {
149
+ "type": "object",
150
+ "properties": {
151
+ "name": {"type": "string"},
152
+ "url": {"type": "string"},
153
+ "events_subscribed": {
154
+ "type": "array",
155
+ "items": {"type": "string"},
156
+ "minItems": 1,
157
+ },
158
+ "environment": {"type": "string", "enum": ["live", "test"]},
159
+ "description": {"type": "string"},
160
+ },
161
+ "required": ["name", "url", "events_subscribed"],
162
+ "additionalProperties": False,
163
+ },
164
+ },
165
+ {
166
+ "name": "mailwire_get_webhook_deliveries",
167
+ "description": (
168
+ "Fetch the most recent delivery attempts for a webhook subscriber "
169
+ "(useful for debugging why an event didn't arrive)."
170
+ ),
171
+ "inputSchema": {
172
+ "type": "object",
173
+ "properties": {"endpoint_id": {"type": "string"}},
174
+ "required": ["endpoint_id"],
175
+ "additionalProperties": False,
176
+ },
177
+ },
178
+ ]
179
+
180
+
181
+ def _dispatch(client: Client, name: str, args: dict[str, Any]) -> Any: # noqa: PLR0911 — pure dispatch table
182
+ """Call the right SDK method for `name` and return the raw response."""
183
+ if name == "mailwire_list_inboxes":
184
+ return client.inboxes.list(
185
+ workspace_id=args.get("workspace_id"),
186
+ project_id=args.get("project_id"),
187
+ limit=args.get("limit", 50),
188
+ )
189
+ if name == "mailwire_get_inbox":
190
+ return client.inboxes.get(args["inbox_id"])
191
+ if name == "mailwire_send_message":
192
+ return client.messages.send(
193
+ inbox_id=args["inbox_id"],
194
+ from_address=args["from_address"],
195
+ to=args["to"],
196
+ cc=args.get("cc"),
197
+ bcc=args.get("bcc"),
198
+ subject=args.get("subject", ""),
199
+ text=args.get("text"),
200
+ html=args.get("html"),
201
+ sending_domain_id=args.get("sending_domain_id"),
202
+ tags=args.get("tags"),
203
+ metadata=args.get("metadata"),
204
+ idempotency_key=args.get("idempotency_key"),
205
+ )
206
+ if name == "mailwire_list_messages":
207
+ return client.messages.list(
208
+ inbox_id=args.get("inbox_id"),
209
+ thread_id=args.get("thread_id"),
210
+ direction=args.get("direction"),
211
+ limit=args.get("limit", 50),
212
+ )
213
+ if name == "mailwire_get_message":
214
+ return client.messages.get(args["message_id"])
215
+ if name == "mailwire_list_webhooks":
216
+ return client.webhooks.list()
217
+ if name == "mailwire_register_webhook":
218
+ return client.webhooks.create(
219
+ name=args["name"],
220
+ url=args["url"],
221
+ events_subscribed=args["events_subscribed"],
222
+ environment=args.get("environment", "live"),
223
+ description=args.get("description"),
224
+ )
225
+ if name == "mailwire_get_webhook_deliveries":
226
+ return client.webhooks.deliveries(args["endpoint_id"])
227
+ raise ValueError(f"unknown tool: {name}")
228
+
229
+
230
+ async def _run() -> None:
231
+ base_url = os.environ.get("MAILWIRE_BASE_URL", "http://localhost:8083")
232
+ token = os.environ.get("MAILWIRE_TOKEN")
233
+ if not token:
234
+ print(
235
+ "MAILWIRE_TOKEN env var is required to run the mailwire MCP server",
236
+ file=sys.stderr,
237
+ )
238
+ raise SystemExit(2)
239
+
240
+ client = Client(token=token, base_url=base_url)
241
+ server: Server = Server("mailwire")
242
+
243
+ @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
244
+ async def _list() -> list[Tool]:
245
+ return [Tool(**spec) for spec in _TOOLS]
246
+
247
+ @server.call_tool() # type: ignore[untyped-decorator]
248
+ async def _call(name: str, arguments: dict[str, Any]) -> list[TextContent]:
249
+ try:
250
+ result = await asyncio.to_thread(_dispatch, client, name, arguments or {})
251
+ except MailwireError as exc:
252
+ payload = {
253
+ "ok": False,
254
+ "error": {
255
+ "type": exc.type,
256
+ "code": exc.code,
257
+ "message": str(exc),
258
+ "details": exc.details,
259
+ "request_id": exc.request_id,
260
+ },
261
+ }
262
+ return [TextContent(type="text", text=json.dumps(payload, indent=2))]
263
+ return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
264
+
265
+ async with stdio_server() as (read, write):
266
+ await server.run(read, write, server.create_initialization_options())
267
+
268
+ client.close()
269
+
270
+
271
+ def main() -> None:
272
+ """Console entry point — `mailwire-mcp`."""
273
+ with contextlib.suppress(KeyboardInterrupt):
274
+ asyncio.run(_run())
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
File without changes
@@ -0,0 +1,19 @@
1
+ """Per-resource SDK clients."""
2
+
3
+ from mailwire_sdk.resources.api_keys import ApiKeysResource
4
+ from mailwire_sdk.resources.domains import DomainsResource
5
+ from mailwire_sdk.resources.inboxes import InboxesResource
6
+ from mailwire_sdk.resources.messages import MessagesResource
7
+ from mailwire_sdk.resources.tenants import TenantsResource
8
+ from mailwire_sdk.resources.threads import ThreadsResource
9
+ from mailwire_sdk.resources.webhooks import WebhooksResource
10
+
11
+ __all__ = [
12
+ "ApiKeysResource",
13
+ "DomainsResource",
14
+ "InboxesResource",
15
+ "MessagesResource",
16
+ "TenantsResource",
17
+ "ThreadsResource",
18
+ "WebhooksResource",
19
+ ]
@@ -0,0 +1,55 @@
1
+ """API-key CRUD under `/v1/tenants/{t}/api_keys` — admin-scoped.
2
+
3
+ The mint response includes a `token` field exactly once. Surface it to the user
4
+ immediately; the server keeps only an Argon2id hash.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from mailwire_sdk._transport import Transport
13
+
14
+
15
+ class ApiKeysResource:
16
+ def __init__(self, transport: Transport) -> None:
17
+ self._t = transport
18
+
19
+ def create(
20
+ self,
21
+ tenant_id: str,
22
+ *,
23
+ name: str,
24
+ scopes: list[str],
25
+ environment: str = "live",
26
+ workspace_id: str | None = None,
27
+ project_id: str | None = None,
28
+ inbox_id: str | None = None,
29
+ expires_at: datetime | None = None,
30
+ metadata: dict[str, Any] | None = None,
31
+ ) -> dict[str, Any]:
32
+ body: dict[str, Any] = {
33
+ "name": name,
34
+ "scopes": scopes,
35
+ "environment": environment,
36
+ "metadata": metadata or {},
37
+ }
38
+ if workspace_id is not None:
39
+ body["workspace_id"] = workspace_id
40
+ if project_id is not None:
41
+ body["project_id"] = project_id
42
+ if inbox_id is not None:
43
+ body["inbox_id"] = inbox_id
44
+ if expires_at is not None:
45
+ body["expires_at"] = expires_at.isoformat()
46
+ return self._t.request("POST", f"/v1/tenants/{tenant_id}/api_keys", json=body)
47
+
48
+ def list(self, tenant_id: str) -> list[dict[str, Any]]:
49
+ return self._t.request("GET", f"/v1/tenants/{tenant_id}/api_keys")["data"] # type: ignore[no-any-return]
50
+
51
+ def get(self, tenant_id: str, key_id: str) -> dict[str, Any]:
52
+ return self._t.request("GET", f"/v1/tenants/{tenant_id}/api_keys/{key_id}")
53
+
54
+ def revoke(self, tenant_id: str, key_id: str) -> None:
55
+ self._t.request("DELETE", f"/v1/tenants/{tenant_id}/api_keys/{key_id}")
@@ -0,0 +1,44 @@
1
+ """Domain registration + verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mailwire_sdk._transport import Transport
8
+
9
+
10
+ class DomainsResource:
11
+ def __init__(self, transport: Transport) -> None:
12
+ self._t = transport
13
+
14
+ def register(
15
+ self,
16
+ tenant_id: str,
17
+ *,
18
+ domain_name: str,
19
+ purpose: str,
20
+ region: str = "us",
21
+ metadata: dict[str, Any] | None = None,
22
+ ) -> dict[str, Any]:
23
+ body = {
24
+ "domain_name": domain_name,
25
+ "purpose": purpose,
26
+ "region": region,
27
+ "metadata": metadata or {},
28
+ }
29
+ return self._t.request("POST", f"/v1/tenants/{tenant_id}/domains", json=body)
30
+
31
+ def list_for_tenant(self, tenant_id: str) -> list[dict[str, Any]]:
32
+ return self._t.request("GET", f"/v1/tenants/{tenant_id}/domains")["data"] # type: ignore[no-any-return]
33
+
34
+ def get(self, domain_id: str) -> dict[str, Any]:
35
+ return self._t.request("GET", f"/v1/domains/{domain_id}")
36
+
37
+ def get_dns_records(self, domain_id: str) -> dict[str, Any]:
38
+ return self._t.request("GET", f"/v1/domains/{domain_id}/dns_records")
39
+
40
+ def verify(self, domain_id: str) -> dict[str, Any]:
41
+ return self._t.request("POST", f"/v1/domains/{domain_id}/verify")
42
+
43
+ def delete(self, domain_id: str) -> None:
44
+ self._t.request("DELETE", f"/v1/domains/{domain_id}")
@@ -0,0 +1,81 @@
1
+ """Inbox + address management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mailwire_sdk._transport import Transport
8
+
9
+
10
+ class InboxesResource:
11
+ def __init__(self, transport: Transport) -> None:
12
+ self._t = transport
13
+
14
+ def create(
15
+ self,
16
+ *,
17
+ client_id: str,
18
+ display_name: str,
19
+ domain_id: str,
20
+ shareable_local_part: str | None = None,
21
+ private_address_count: int = 0,
22
+ workspace_id: str | None = None,
23
+ project_id: str | None = None,
24
+ signature_text: str | None = None,
25
+ signature_html: str | None = None,
26
+ timezone: str = "UTC",
27
+ language: str = "en",
28
+ avatar_url: str | None = None,
29
+ metadata: dict[str, Any] | None = None,
30
+ ) -> dict[str, Any]:
31
+ body: dict[str, Any] = {
32
+ "client_id": client_id,
33
+ "display_name": display_name,
34
+ "domain_id": domain_id,
35
+ "private_address_count": private_address_count,
36
+ "timezone": timezone,
37
+ "language": language,
38
+ "metadata": metadata or {},
39
+ }
40
+ if shareable_local_part is not None:
41
+ body["shareable_address"] = {"local_part": shareable_local_part}
42
+ for k, v in (
43
+ ("workspace_id", workspace_id),
44
+ ("project_id", project_id),
45
+ ("signature_text", signature_text),
46
+ ("signature_html", signature_html),
47
+ ("avatar_url", avatar_url),
48
+ ):
49
+ if v is not None:
50
+ body[k] = v
51
+ return self._t.request("POST", "/v1/inboxes", json=body)
52
+
53
+ def list(
54
+ self,
55
+ *,
56
+ workspace_id: str | None = None,
57
+ project_id: str | None = None,
58
+ limit: int = 50,
59
+ ) -> list[dict[str, Any]]:
60
+ params: dict[str, Any] = {"limit": limit}
61
+ if workspace_id is not None:
62
+ params["workspace_id"] = workspace_id
63
+ if project_id is not None:
64
+ params["project_id"] = project_id
65
+ return self._t.request("GET", "/v1/inboxes", params=params)["data"] # type: ignore[no-any-return]
66
+
67
+ def get(self, inbox_id: str) -> dict[str, Any]:
68
+ return self._t.request("GET", f"/v1/inboxes/{inbox_id}")
69
+
70
+ def update(
71
+ self,
72
+ inbox_id: str,
73
+ **fields: Any,
74
+ ) -> dict[str, Any]:
75
+ return self._t.request("PATCH", f"/v1/inboxes/{inbox_id}", json=fields)
76
+
77
+ def archive(self, inbox_id: str) -> None:
78
+ self._t.request("DELETE", f"/v1/inboxes/{inbox_id}")
79
+
80
+ def add_address(self, inbox_id: str) -> dict[str, Any]:
81
+ return self._t.request("POST", f"/v1/inboxes/{inbox_id}/addresses")
@@ -0,0 +1,78 @@
1
+ """Outbound + read endpoints for messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mailwire_sdk._transport import Transport
8
+
9
+
10
+ class MessagesResource:
11
+ def __init__(self, transport: Transport) -> None:
12
+ self._t = transport
13
+
14
+ def send(
15
+ self,
16
+ *,
17
+ inbox_id: str,
18
+ from_address: str,
19
+ to: list[str],
20
+ subject: str = "",
21
+ text: str | None = None,
22
+ html: str | None = None,
23
+ cc: list[str] | None = None,
24
+ bcc: list[str] | None = None,
25
+ sending_domain_id: str | None = None,
26
+ in_reply_to_message_id: str | None = None,
27
+ headers: dict[str, str] | None = None,
28
+ attachments: list[dict[str, Any]] | None = None,
29
+ tags: list[str] | None = None,
30
+ tracking_opens: bool = False,
31
+ tracking_clicks: bool = False,
32
+ metadata: dict[str, str] | None = None,
33
+ idempotency_key: str | None = None,
34
+ ) -> dict[str, Any]:
35
+ body: dict[str, Any] = {
36
+ "inbox_id": inbox_id,
37
+ "from_address": from_address,
38
+ "to": to,
39
+ "cc": cc or [],
40
+ "bcc": bcc or [],
41
+ "subject": subject,
42
+ "headers": headers or {},
43
+ "attachments": attachments or [],
44
+ "tags": tags or [],
45
+ "tracking": {"opens": tracking_opens, "clicks": tracking_clicks},
46
+ "metadata": metadata or {},
47
+ }
48
+ if sending_domain_id is not None:
49
+ body["sending_domain_id"] = sending_domain_id
50
+ if in_reply_to_message_id is not None:
51
+ body["in_reply_to_message_id"] = in_reply_to_message_id
52
+ if text is not None:
53
+ body["text"] = text
54
+ if html is not None:
55
+ body["html"] = html
56
+ request_headers = (
57
+ {"Idempotency-Key": idempotency_key} if idempotency_key is not None else None
58
+ )
59
+ return self._t.request(
60
+ "POST", "/v1/messages", json=body, headers=request_headers
61
+ )
62
+
63
+ def list(
64
+ self,
65
+ *,
66
+ inbox_id: str | None = None,
67
+ thread_id: str | None = None,
68
+ direction: str | None = None,
69
+ limit: int = 50,
70
+ ) -> list[dict[str, Any]]:
71
+ params: dict[str, Any] = {"limit": limit}
72
+ for k, v in (("inbox_id", inbox_id), ("thread_id", thread_id), ("direction", direction)):
73
+ if v is not None:
74
+ params[k] = v
75
+ return self._t.request("GET", "/v1/messages", params=params)["data"] # type: ignore[no-any-return]
76
+
77
+ def get(self, message_id: str) -> dict[str, Any]:
78
+ return self._t.request("GET", f"/v1/messages/{message_id}")
@@ -0,0 +1,48 @@
1
+ """Tenant CRUD — admin-scoped."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mailwire_sdk._transport import Transport
8
+
9
+
10
+ class TenantsResource:
11
+ def __init__(self, transport: Transport) -> None:
12
+ self._t = transport
13
+
14
+ def create(
15
+ self,
16
+ *,
17
+ name: str,
18
+ slug: str | None = None,
19
+ metadata: dict[str, Any] | None = None,
20
+ ) -> dict[str, Any]:
21
+ body: dict[str, Any] = {"name": name, "metadata": metadata or {}}
22
+ if slug is not None:
23
+ body["slug"] = slug
24
+ return self._t.request("POST", "/v1/tenants", json=body)
25
+
26
+ def list(self, *, limit: int = 50) -> list[dict[str, Any]]:
27
+ return self._t.request("GET", "/v1/tenants", params={"limit": limit})["data"] # type: ignore[no-any-return]
28
+
29
+ def get(self, tenant_id: str) -> dict[str, Any]:
30
+ return self._t.request("GET", f"/v1/tenants/{tenant_id}")
31
+
32
+ def update(
33
+ self,
34
+ tenant_id: str,
35
+ *,
36
+ name: str | None = None,
37
+ status: str | None = None,
38
+ metadata: dict[str, Any] | None = None,
39
+ ) -> dict[str, Any]:
40
+ body = {
41
+ k: v
42
+ for k, v in (("name", name), ("status", status), ("metadata", metadata))
43
+ if v is not None
44
+ }
45
+ return self._t.request("PATCH", f"/v1/tenants/{tenant_id}", json=body)
46
+
47
+ def delete(self, tenant_id: str) -> None:
48
+ self._t.request("DELETE", f"/v1/tenants/{tenant_id}")
@@ -0,0 +1,35 @@
1
+ """Thread list / detail — `/v1/threads`. Gmail-style inbox view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mailwire_sdk._transport import Transport
8
+
9
+
10
+ class ThreadsResource:
11
+ def __init__(self, transport: Transport) -> None:
12
+ self._t = transport
13
+
14
+ def get(
15
+ self, thread_id: str, *, include_last_message: bool = True
16
+ ) -> dict[str, Any]:
17
+ params = {"include_last_message": "true" if include_last_message else "false"}
18
+ return self._t.request("GET", f"/v1/threads/{thread_id}", params=params)
19
+
20
+ # `list` defined LAST so other annotations bind to the builtin
21
+ # (Python class scope is built top-down).
22
+ def list(
23
+ self,
24
+ *,
25
+ inbox_id: str,
26
+ limit: int = 50,
27
+ include_last_message: bool = True,
28
+ ) -> list[dict[str, Any]]:
29
+ params: dict[str, Any] = {
30
+ "inbox_id": inbox_id,
31
+ "limit": limit,
32
+ "include_last_message": "true" if include_last_message else "false",
33
+ }
34
+ data = self._t.request("GET", "/v1/threads", params=params).get("data", [])
35
+ return list(data)
@@ -0,0 +1,85 @@
1
+ """Webhook subscriber CRUD — `/v1/webhooks`.
2
+
3
+ The `secret` field is returned EXACTLY ONCE on `create` and `rotate_secret`.
4
+ Listings + reads expose only `secret_prefix`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from mailwire_sdk._transport import Transport
12
+
13
+
14
+ class WebhooksResource:
15
+ def __init__(self, transport: Transport) -> None:
16
+ self._t = transport
17
+
18
+ def create(
19
+ self,
20
+ *,
21
+ name: str,
22
+ url: str,
23
+ events_subscribed: list[str],
24
+ environment: str = "live",
25
+ workspace_id: str | None = None,
26
+ description: str | None = None,
27
+ metadata: dict[str, Any] | None = None,
28
+ ) -> dict[str, Any]:
29
+ body: dict[str, Any] = {
30
+ "name": name,
31
+ "url": url,
32
+ "events_subscribed": events_subscribed,
33
+ "environment": environment,
34
+ "metadata": metadata or {},
35
+ }
36
+ if workspace_id is not None:
37
+ body["workspace_id"] = workspace_id
38
+ if description is not None:
39
+ body["description"] = description
40
+ return self._t.request("POST", "/v1/webhooks", json=body)
41
+
42
+ def get(self, endpoint_id: str) -> dict[str, Any]:
43
+ return self._t.request("GET", f"/v1/webhooks/{endpoint_id}")
44
+
45
+ def update(
46
+ self,
47
+ endpoint_id: str,
48
+ *,
49
+ url: str | None = None,
50
+ events_subscribed: list[str] | None = None,
51
+ enabled: bool | None = None,
52
+ description: str | None = None,
53
+ ) -> dict[str, Any]:
54
+ body: dict[str, Any] = {}
55
+ if url is not None:
56
+ body["url"] = url
57
+ if events_subscribed is not None:
58
+ body["events_subscribed"] = events_subscribed
59
+ if enabled is not None:
60
+ body["enabled"] = enabled
61
+ if description is not None:
62
+ body["description"] = description
63
+ return self._t.request("PATCH", f"/v1/webhooks/{endpoint_id}", json=body)
64
+
65
+ def revoke(self, endpoint_id: str) -> None:
66
+ self._t.request("DELETE", f"/v1/webhooks/{endpoint_id}")
67
+
68
+ def rotate_secret(self, endpoint_id: str) -> dict[str, Any]:
69
+ return self._t.request("POST", f"/v1/webhooks/{endpoint_id}/rotate_secret")
70
+
71
+ def deliveries(
72
+ self, endpoint_id: str, *, limit: int = 50
73
+ ) -> list[dict[str, Any]]:
74
+ data = self._t.request(
75
+ "GET",
76
+ f"/v1/webhooks/{endpoint_id}/deliveries",
77
+ params={"limit": limit},
78
+ ).get("data", [])
79
+ return list(data)
80
+
81
+ # `list` is defined LAST so other methods' `list[...]` annotations bind
82
+ # to the builtin (Python class scope is built top-down).
83
+ def list(self) -> list[dict[str, Any]]:
84
+ data = self._t.request("GET", "/v1/webhooks").get("data", [])
85
+ return list(data)