ridian-mailbox 0.1.0a3__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.
- ridian_mailbox-0.1.0a3/LICENSE +21 -0
- ridian_mailbox-0.1.0a3/PKG-INFO +70 -0
- ridian_mailbox-0.1.0a3/README.md +52 -0
- ridian_mailbox-0.1.0a3/pyproject.toml +25 -0
- ridian_mailbox-0.1.0a3/setup.cfg +4 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox/__init__.py +4 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox/_example.py +155 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox/cli.py +11 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox/client.py +148 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox.egg-info/PKG-INFO +70 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox.egg-info/SOURCES.txt +12 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox.egg-info/dependency_links.txt +1 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox.egg-info/entry_points.txt +2 -0
- ridian_mailbox-0.1.0a3/src/ridian_mailbox.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PPJ Venture Labs UG (haftungsbeschränkt)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ridian-mailbox
|
|
3
|
+
Version: 0.1.0a3
|
|
4
|
+
Summary: Private Ridian mailbox access for Python agents and the command line
|
|
5
|
+
Author: Ridian
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://getridian.com
|
|
8
|
+
Project-URL: Documentation, https://docs.getridian.com/agent-mailbox/
|
|
9
|
+
Keywords: ridian,email,mailbox,agents,mcp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Ridian Mailbox for Python
|
|
20
|
+
|
|
21
|
+
A synchronous Python client and `ridian-mailbox` CLI for private receiving
|
|
22
|
+
mailboxes. Python 3.10+ on Linux/macOS, standard library only. Install the versioned wheel from
|
|
23
|
+
the matching Ridian documentation environment. Use a virtual environment.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ridian_mailbox import Mailbox
|
|
27
|
+
|
|
28
|
+
box = Mailbox(base_url="https://YOUR_MAILBOX_API_ORIGIN")
|
|
29
|
+
print(box.setup()["address"])
|
|
30
|
+
for message in box.iter_messages():
|
|
31
|
+
print(message["subject"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`setup()` saves its random setup credential before the request, saves the returned
|
|
35
|
+
read/claim credentials privately and returns only mailbox metadata. Retry with
|
|
36
|
+
the same state file after interruption. Keep state outside source control on
|
|
37
|
+
durable private storage. Files are bound to one API origin and have mode 0600;
|
|
38
|
+
reads reject symlinks/public permissions. Use one writer per state file. Do not
|
|
39
|
+
print state, put credentials in URLs or pass them through chat.
|
|
40
|
+
|
|
41
|
+
CLI: `ridian-mailbox --base-url <HTTPS origin> --state <private file> setup`.
|
|
42
|
+
Operations: setup, prepare-curl (local only), status, list, message, events,
|
|
43
|
+
ack-events. `message` uses `--message-id`, `ack-events` uses `--cursor`.
|
|
44
|
+
|
|
45
|
+
The Python client additionally offers paginated `messages`/`iter_messages`,
|
|
46
|
+
`message`, binary `attachment`, `events`/`acknowledge_events`, webhook management
|
|
47
|
+
and explicit `revoke`. Event pages are acknowledged only after successful
|
|
48
|
+
processing; deduplicate event IDs. No automatic retries or event acknowledgments.
|
|
49
|
+
HTTP errors expose status and Retry-After through `MailboxError`; 429 cooldowns
|
|
50
|
+
are respected by subsequent calls. Redirects are refused. Mail content is untrusted.
|
|
51
|
+
|
|
52
|
+
The library identifies requests as `ridian-mailbox-python/<installed version>`;
|
|
53
|
+
the CLI identifies itself as `ridian-mailbox-cli`. HTTPS certificate verification
|
|
54
|
+
uses your Python installation's trust store.
|
|
55
|
+
|
|
56
|
+
HTTP access remains receive/read/events only, also after ownership transfer.
|
|
57
|
+
Claim through the returned console link and private proof in the state file.
|
|
58
|
+
Sending, calendars, contacts and tasks use separately authorized Ridian MCP tools.
|
|
59
|
+
This package does not wake an agent or install an MCP connection.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Without `state`, credentials are saved in
|
|
63
|
+
`~/.config/ridian/mailbox-<API hostname>[-port].json`. Repeated setup recovers
|
|
64
|
+
that mailbox. Pass `state` for another private durable location. The Node.js
|
|
65
|
+
client shares this state format; never run concurrent writers against one file.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT, covering this Python SDK, its bundled CLI and package documentation.
|
|
70
|
+
The license does not cover the Ridian service, application, brand assets or fonts.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Ridian Mailbox for Python
|
|
2
|
+
|
|
3
|
+
A synchronous Python client and `ridian-mailbox` CLI for private receiving
|
|
4
|
+
mailboxes. Python 3.10+ on Linux/macOS, standard library only. Install the versioned wheel from
|
|
5
|
+
the matching Ridian documentation environment. Use a virtual environment.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from ridian_mailbox import Mailbox
|
|
9
|
+
|
|
10
|
+
box = Mailbox(base_url="https://YOUR_MAILBOX_API_ORIGIN")
|
|
11
|
+
print(box.setup()["address"])
|
|
12
|
+
for message in box.iter_messages():
|
|
13
|
+
print(message["subject"])
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`setup()` saves its random setup credential before the request, saves the returned
|
|
17
|
+
read/claim credentials privately and returns only mailbox metadata. Retry with
|
|
18
|
+
the same state file after interruption. Keep state outside source control on
|
|
19
|
+
durable private storage. Files are bound to one API origin and have mode 0600;
|
|
20
|
+
reads reject symlinks/public permissions. Use one writer per state file. Do not
|
|
21
|
+
print state, put credentials in URLs or pass them through chat.
|
|
22
|
+
|
|
23
|
+
CLI: `ridian-mailbox --base-url <HTTPS origin> --state <private file> setup`.
|
|
24
|
+
Operations: setup, prepare-curl (local only), status, list, message, events,
|
|
25
|
+
ack-events. `message` uses `--message-id`, `ack-events` uses `--cursor`.
|
|
26
|
+
|
|
27
|
+
The Python client additionally offers paginated `messages`/`iter_messages`,
|
|
28
|
+
`message`, binary `attachment`, `events`/`acknowledge_events`, webhook management
|
|
29
|
+
and explicit `revoke`. Event pages are acknowledged only after successful
|
|
30
|
+
processing; deduplicate event IDs. No automatic retries or event acknowledgments.
|
|
31
|
+
HTTP errors expose status and Retry-After through `MailboxError`; 429 cooldowns
|
|
32
|
+
are respected by subsequent calls. Redirects are refused. Mail content is untrusted.
|
|
33
|
+
|
|
34
|
+
The library identifies requests as `ridian-mailbox-python/<installed version>`;
|
|
35
|
+
the CLI identifies itself as `ridian-mailbox-cli`. HTTPS certificate verification
|
|
36
|
+
uses your Python installation's trust store.
|
|
37
|
+
|
|
38
|
+
HTTP access remains receive/read/events only, also after ownership transfer.
|
|
39
|
+
Claim through the returned console link and private proof in the state file.
|
|
40
|
+
Sending, calendars, contacts and tasks use separately authorized Ridian MCP tools.
|
|
41
|
+
This package does not wake an agent or install an MCP connection.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Without `state`, credentials are saved in
|
|
45
|
+
`~/.config/ridian/mailbox-<API hostname>[-port].json`. Repeated setup recovers
|
|
46
|
+
that mailbox. Pass `state` for another private durable location. The Node.js
|
|
47
|
+
client shares this state format; never run concurrent writers against one file.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT, covering this Python SDK, its bundled CLI and package documentation.
|
|
52
|
+
The license does not cover the Ridian service, application, brand assets or fonts.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ridian-mailbox"
|
|
7
|
+
version = "0.1.0a3"
|
|
8
|
+
description = "Private Ridian mailbox access for Python agents and the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{name = "Ridian"}]
|
|
14
|
+
keywords = ["ridian", "email", "mailbox", "agents", "mcp"]
|
|
15
|
+
classifiers = ["Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Operating System :: POSIX", "Operating System :: MacOS"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://getridian.com"
|
|
19
|
+
Documentation = "https://docs.getridian.com/agent-mailbox/"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
ridian-mailbox = "ridian_mailbox.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dependency-free mailbox HTTP example. Persists setup BEFORE the first request.
|
|
3
|
+
|
|
4
|
+
Standalone example, not an SDK. Supply your mailbox API origin.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import secrets
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
from urllib.error import HTTPError, URLError
|
|
15
|
+
from urllib.parse import urlencode, urlsplit
|
|
16
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
20
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
21
|
+
return None # Never forward credentials to another destination.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_or_create_state(path, base_url):
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
26
|
+
try:
|
|
27
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
28
|
+
except FileExistsError:
|
|
29
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
30
|
+
with os.fdopen(fd) as handle:
|
|
31
|
+
metadata = os.fstat(handle.fileno())
|
|
32
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
|
|
33
|
+
raise ValueError("State must be a private regular file (chmod 600).")
|
|
34
|
+
state = json.load(handle)
|
|
35
|
+
if state["base_url"] != base_url:
|
|
36
|
+
raise ValueError("State belongs to another API origin. Use a separate state file.")
|
|
37
|
+
return state
|
|
38
|
+
state = {"base_url": base_url, "setup_token": "ridian_setup_" + secrets.token_hex(32)}
|
|
39
|
+
with os.fdopen(fd, "w") as handle:
|
|
40
|
+
json.dump(state, handle)
|
|
41
|
+
handle.flush()
|
|
42
|
+
os.fsync(handle.fileno())
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_state(path, state):
|
|
47
|
+
temporary = path.with_name(path.name + "." + secrets.token_hex(8) + ".tmp")
|
|
48
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(fd, "w") as handle:
|
|
51
|
+
json.dump(state, handle)
|
|
52
|
+
handle.flush()
|
|
53
|
+
os.fsync(handle.fileno())
|
|
54
|
+
os.replace(temporary, path)
|
|
55
|
+
finally:
|
|
56
|
+
temporary.unlink(missing_ok=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def prepare_curl(path, state):
|
|
60
|
+
"""Prepare private curl files locally, reusing the durable setup identity."""
|
|
61
|
+
token = state["setup_token"]
|
|
62
|
+
if not re.fullmatch(r"ridian_setup_[a-f0-9]{64}", token):
|
|
63
|
+
raise ValueError("Invalid saved setup credential.")
|
|
64
|
+
header = Path(str(path) + ".headers")
|
|
65
|
+
response = Path(str(path) + ".response.json")
|
|
66
|
+
# Do not overwrite an existing response: it may be the only saved proof.
|
|
67
|
+
for target, content in [(header, "Authorization: Bearer " + token + "\n"), (response, "")]:
|
|
68
|
+
try:
|
|
69
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
70
|
+
except FileExistsError:
|
|
71
|
+
fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
72
|
+
with os.fdopen(fd) as handle:
|
|
73
|
+
metadata = os.fstat(handle.fileno())
|
|
74
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
|
|
75
|
+
raise ValueError("curl files must be private regular files.")
|
|
76
|
+
if target == header and handle.read() != content:
|
|
77
|
+
raise ValueError("Header belongs to a different setup credential.")
|
|
78
|
+
else:
|
|
79
|
+
with os.fdopen(fd, "w") as handle:
|
|
80
|
+
handle.write(content)
|
|
81
|
+
handle.flush()
|
|
82
|
+
os.fsync(handle.fileno())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
87
|
+
parser.add_argument("--base-url", required=True, help="Configured API origin, without /v1")
|
|
88
|
+
parser.add_argument("--state", type=Path, required=True, help="Private credential file; keep it outside version control")
|
|
89
|
+
parser.add_argument("operation", choices=["setup", "prepare-curl", "status", "list", "message", "events", "ack-events"])
|
|
90
|
+
parser.add_argument("--message-id")
|
|
91
|
+
parser.add_argument("--cursor")
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
base = args.base_url.rstrip("/")
|
|
94
|
+
parsed = urlsplit(base)
|
|
95
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
|
|
96
|
+
parser.error("Use a fixed HTTPS origin without path, credentials, query or fragment.")
|
|
97
|
+
if args.operation not in ("setup", "prepare-curl") and not args.state.exists():
|
|
98
|
+
parser.error("Run setup first with this state file.")
|
|
99
|
+
state = load_or_create_state(args.state, base)
|
|
100
|
+
if args.operation == "prepare-curl":
|
|
101
|
+
prepare_curl(args.state, state)
|
|
102
|
+
print("Private curl header and response files prepared. No network request made.")
|
|
103
|
+
return 0
|
|
104
|
+
if args.operation == "ack-events":
|
|
105
|
+
if not args.cursor or len(args.cursor) > 4096:
|
|
106
|
+
parser.error("Supply next_cursor from a fully processed events page.")
|
|
107
|
+
state["event_cursor"] = args.cursor
|
|
108
|
+
save_state(args.state, state)
|
|
109
|
+
print("Event cursor saved. Replayed events must still be deduplicated by ID.")
|
|
110
|
+
return 0
|
|
111
|
+
root = "/v1/mailboxes"
|
|
112
|
+
if args.operation == "setup":
|
|
113
|
+
method, path, token, body = "POST", root, state["setup_token"], b"{}"
|
|
114
|
+
else:
|
|
115
|
+
if "access_token" not in state:
|
|
116
|
+
parser.error("Repeat setup with the same state file to recover its response.")
|
|
117
|
+
method, token, body = "GET", state["access_token"], None
|
|
118
|
+
path = root + "/current"
|
|
119
|
+
if args.operation == "list":
|
|
120
|
+
path += "/messages" + ("?" + urlencode({"cursor": args.cursor}) if args.cursor else "")
|
|
121
|
+
if args.operation == "events":
|
|
122
|
+
cursor = args.cursor or state.get("event_cursor")
|
|
123
|
+
path += "/events" + ("?" + urlencode({"cursor": cursor}) if cursor else "")
|
|
124
|
+
if args.operation == "message":
|
|
125
|
+
if not args.message_id or not args.message_id.isalnum():
|
|
126
|
+
parser.error("Supply a message ID from list using --message-id.")
|
|
127
|
+
path += "/messages/" + args.message_id
|
|
128
|
+
request = Request(base + path, method=method, data=body, headers={
|
|
129
|
+
"Authorization": "Bearer " + token, "Content-Type": "application/json",
|
|
130
|
+
"Accept": "application/json", "User-Agent": "ridian-mailbox-cli"})
|
|
131
|
+
try:
|
|
132
|
+
with build_opener(NoRedirect).open(request, timeout=30) as response:
|
|
133
|
+
result = json.load(response)
|
|
134
|
+
except HTTPError as error:
|
|
135
|
+
# Do not print request headers or arbitrary upstream HTML/error bodies.
|
|
136
|
+
print(f"HTTP {error.code}. Retry-After: {error.headers.get('Retry-After', 'not supplied')}. Keep the same state file.", file=sys.stderr)
|
|
137
|
+
return 1
|
|
138
|
+
except URLError:
|
|
139
|
+
print("Transport interrupted. Repeat with the same state file.", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
if args.operation == "setup":
|
|
142
|
+
state.update(result)
|
|
143
|
+
save_state(args.state, state)
|
|
144
|
+
print(json.dumps(result["mailbox"], indent=2)) # Never print secrets.
|
|
145
|
+
else:
|
|
146
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
try:
|
|
152
|
+
sys.exit(main())
|
|
153
|
+
except (ValueError, OSError, KeyError):
|
|
154
|
+
print("Could not read or persist private state. Check the state path and permissions; do not replace an existing setup key.", file=sys.stderr)
|
|
155
|
+
sys.exit(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The installed CLI uses the same reviewed standalone example implementation."""
|
|
2
|
+
import sys
|
|
3
|
+
from ._example import main as example_main
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
try:
|
|
8
|
+
return example_main()
|
|
9
|
+
except (ValueError, OSError, KeyError):
|
|
10
|
+
print("Could not read or save private state. Check the file and permissions; retain the saved setup credential.", file=sys.stderr)
|
|
11
|
+
return 1
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Dependency-free, synchronous HTTP client with private durable setup state."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
11
|
+
from urllib.request import Request, build_opener
|
|
12
|
+
from ._example import NoRedirect, load_or_create_state, save_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MailboxError(Exception):
|
|
16
|
+
"""Contains status/retry metadata, never credentials or arbitrary response bodies."""
|
|
17
|
+
def __init__(self, code, status=None, retry_after=None):
|
|
18
|
+
self.code, self.status, self.retry_after = code, status, retry_after
|
|
19
|
+
super().__init__(f"Mailbox request failed: {code}" + (f" (HTTP {status})" if status else ""))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Mailbox:
|
|
23
|
+
def __init__(self, *, base_url, state=None, timeout=30):
|
|
24
|
+
url = urlsplit(base_url.rstrip("/"))
|
|
25
|
+
if url.scheme != "https" or not url.netloc or url.username or url.password or url.path or url.query or url.fragment:
|
|
26
|
+
raise ValueError("Use a fixed HTTPS origin, without path or credentials.")
|
|
27
|
+
if timeout <= 0:
|
|
28
|
+
raise ValueError("timeout must be positive")
|
|
29
|
+
default_name = "mailbox-" + url.hostname + (f"-{url.port}" if url.port else "") + ".json"
|
|
30
|
+
self.base_url, self.state_path, self.timeout = url.geturl(), (Path(state).expanduser() if state is not None else Path.home() / ".config/ridian" / default_name), timeout
|
|
31
|
+
self._blocked_until = 0
|
|
32
|
+
self._transport = build_opener(NoRedirect())
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return "Mailbox(<private state>)"
|
|
36
|
+
|
|
37
|
+
def _state(self, create=False):
|
|
38
|
+
if not create and not self.state_path.exists():
|
|
39
|
+
raise MailboxError("setup_required")
|
|
40
|
+
return load_or_create_state(self.state_path, self.base_url)
|
|
41
|
+
|
|
42
|
+
def _request(self, path, *, method="GET", token=None, body=None, binary=False):
|
|
43
|
+
if time.monotonic() < self._blocked_until:
|
|
44
|
+
raise MailboxError("rate_limit", 429, max(1, int(self._blocked_until - time.monotonic()) + 1))
|
|
45
|
+
if token is None:
|
|
46
|
+
token = self._state().get("access_token")
|
|
47
|
+
if not token:
|
|
48
|
+
raise MailboxError("repeat_setup_with_saved_state")
|
|
49
|
+
if not re.fullmatch(r"ridian_(?:setup|mailbox)_[a-f0-9]{64}", token):
|
|
50
|
+
raise MailboxError("invalid_saved_credential")
|
|
51
|
+
headers = {"Authorization": "Bearer " + token, "Accept": "application/json",
|
|
52
|
+
"User-Agent": "ridian-mailbox-python/" + version("ridian-mailbox")}
|
|
53
|
+
if body is not None:
|
|
54
|
+
headers["Content-Type"] = "application/json"
|
|
55
|
+
req = Request(self.base_url + "/v1/mailboxes" + path, method=method,
|
|
56
|
+
headers=headers, data=None if body is None else json.dumps(body).encode())
|
|
57
|
+
try:
|
|
58
|
+
with self._transport.open(req, timeout=self.timeout) as response:
|
|
59
|
+
if binary:
|
|
60
|
+
return response.read()
|
|
61
|
+
if response.status == 204:
|
|
62
|
+
return None
|
|
63
|
+
return json.load(response)
|
|
64
|
+
except HTTPError as error:
|
|
65
|
+
wait = None
|
|
66
|
+
if error.code == 429:
|
|
67
|
+
value = error.headers.get("Retry-After", "60")
|
|
68
|
+
try:
|
|
69
|
+
wait = max(1, int(value))
|
|
70
|
+
except ValueError:
|
|
71
|
+
try:
|
|
72
|
+
wait = max(1, int((parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()))
|
|
73
|
+
except (ValueError, TypeError, OverflowError):
|
|
74
|
+
wait = 60
|
|
75
|
+
self._blocked_until = time.monotonic() + wait
|
|
76
|
+
codes = {401: "unauthorized", 403: "forbidden", 404: "not_found", 409: "conflict", 429: "rate_limit", 503: "unavailable"}
|
|
77
|
+
raise MailboxError("redirect_refused" if 300 <= error.code < 400 else codes.get(error.code, "http_error"), error.code, wait) from None
|
|
78
|
+
except (URLError, TimeoutError, OSError):
|
|
79
|
+
raise MailboxError("transport_interrupted_reuse_state") from None
|
|
80
|
+
except (ValueError, UnicodeDecodeError):
|
|
81
|
+
raise MailboxError("invalid_response") from None
|
|
82
|
+
|
|
83
|
+
def setup(self):
|
|
84
|
+
"""Create/recover the same mailbox; only return public mailbox metadata."""
|
|
85
|
+
state = self._state(create=True) # persisted BEFORE first network call
|
|
86
|
+
result = self._request("", method="POST", token=state["setup_token"], body={})
|
|
87
|
+
if not isinstance(result, dict) or not isinstance(result.get("mailbox"), dict) or not all(isinstance(result.get(k), str) for k in ("access_token", "claim_secret", "claim_url")):
|
|
88
|
+
raise MailboxError("invalid_response_repeat_setup")
|
|
89
|
+
state.update(result)
|
|
90
|
+
save_state(self.state_path, state)
|
|
91
|
+
return result["mailbox"]
|
|
92
|
+
|
|
93
|
+
def status(self):
|
|
94
|
+
return self._request("/current")
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _page(cursor, limit):
|
|
98
|
+
if not isinstance(limit, int) or not 1 <= limit <= 50:
|
|
99
|
+
raise ValueError("limit must be between 1 and 50")
|
|
100
|
+
return "?" + urlencode({"limit": limit, **({"cursor": cursor} if cursor is not None else {})})
|
|
101
|
+
|
|
102
|
+
def messages(self, *, cursor=None, limit=20):
|
|
103
|
+
return self._request("/current/messages" + self._page(cursor, limit))
|
|
104
|
+
|
|
105
|
+
def iter_messages(self, *, limit=20):
|
|
106
|
+
cursor = None
|
|
107
|
+
while True:
|
|
108
|
+
page = self.messages(cursor=cursor, limit=limit)
|
|
109
|
+
yield from page["data"]
|
|
110
|
+
cursor = page["next_cursor"]
|
|
111
|
+
if cursor is None:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
def message(self, message_id):
|
|
115
|
+
return self._request("/current/messages/" + quote(str(message_id), safe=""))
|
|
116
|
+
|
|
117
|
+
def attachment(self, attachment_id):
|
|
118
|
+
"""Return authenticated bytes. Call only where this content may be held."""
|
|
119
|
+
return self._request("/current/attachments/" + quote(str(attachment_id), safe=""), binary=True)
|
|
120
|
+
|
|
121
|
+
def events(self, *, cursor=None, limit=20):
|
|
122
|
+
if cursor is None:
|
|
123
|
+
cursor = self._state().get("event_cursor")
|
|
124
|
+
return self._request("/current/events" + self._page(cursor, limit))
|
|
125
|
+
|
|
126
|
+
def acknowledge_events(self, cursor):
|
|
127
|
+
"""Persist only after processing the complete page; deduplicate event IDs."""
|
|
128
|
+
if not isinstance(cursor, str) or not cursor or len(cursor) > 4096:
|
|
129
|
+
raise ValueError("Use next_cursor from a fully processed events page")
|
|
130
|
+
state = self._state()
|
|
131
|
+
state["event_cursor"] = cursor
|
|
132
|
+
save_state(self.state_path, state)
|
|
133
|
+
|
|
134
|
+
def webhook(self):
|
|
135
|
+
return self._request("/current/webhook")
|
|
136
|
+
|
|
137
|
+
def set_webhook(self, *, url, signing_secret):
|
|
138
|
+
return self._request("/current/webhook", method="POST", body={"url": url, "signing_secret": signing_secret})
|
|
139
|
+
|
|
140
|
+
def resume_webhook(self):
|
|
141
|
+
return self._request("/current/webhook/resume", method="POST", body={})
|
|
142
|
+
|
|
143
|
+
def delete_webhook(self):
|
|
144
|
+
return self._request("/current/webhook", method="DELETE")
|
|
145
|
+
|
|
146
|
+
def revoke(self):
|
|
147
|
+
"""Permanently revoke this credential and its copies; preserve mailbox data."""
|
|
148
|
+
return self._request("/current/access", method="DELETE")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ridian-mailbox
|
|
3
|
+
Version: 0.1.0a3
|
|
4
|
+
Summary: Private Ridian mailbox access for Python agents and the command line
|
|
5
|
+
Author: Ridian
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://getridian.com
|
|
8
|
+
Project-URL: Documentation, https://docs.getridian.com/agent-mailbox/
|
|
9
|
+
Keywords: ridian,email,mailbox,agents,mcp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Ridian Mailbox for Python
|
|
20
|
+
|
|
21
|
+
A synchronous Python client and `ridian-mailbox` CLI for private receiving
|
|
22
|
+
mailboxes. Python 3.10+ on Linux/macOS, standard library only. Install the versioned wheel from
|
|
23
|
+
the matching Ridian documentation environment. Use a virtual environment.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ridian_mailbox import Mailbox
|
|
27
|
+
|
|
28
|
+
box = Mailbox(base_url="https://YOUR_MAILBOX_API_ORIGIN")
|
|
29
|
+
print(box.setup()["address"])
|
|
30
|
+
for message in box.iter_messages():
|
|
31
|
+
print(message["subject"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`setup()` saves its random setup credential before the request, saves the returned
|
|
35
|
+
read/claim credentials privately and returns only mailbox metadata. Retry with
|
|
36
|
+
the same state file after interruption. Keep state outside source control on
|
|
37
|
+
durable private storage. Files are bound to one API origin and have mode 0600;
|
|
38
|
+
reads reject symlinks/public permissions. Use one writer per state file. Do not
|
|
39
|
+
print state, put credentials in URLs or pass them through chat.
|
|
40
|
+
|
|
41
|
+
CLI: `ridian-mailbox --base-url <HTTPS origin> --state <private file> setup`.
|
|
42
|
+
Operations: setup, prepare-curl (local only), status, list, message, events,
|
|
43
|
+
ack-events. `message` uses `--message-id`, `ack-events` uses `--cursor`.
|
|
44
|
+
|
|
45
|
+
The Python client additionally offers paginated `messages`/`iter_messages`,
|
|
46
|
+
`message`, binary `attachment`, `events`/`acknowledge_events`, webhook management
|
|
47
|
+
and explicit `revoke`. Event pages are acknowledged only after successful
|
|
48
|
+
processing; deduplicate event IDs. No automatic retries or event acknowledgments.
|
|
49
|
+
HTTP errors expose status and Retry-After through `MailboxError`; 429 cooldowns
|
|
50
|
+
are respected by subsequent calls. Redirects are refused. Mail content is untrusted.
|
|
51
|
+
|
|
52
|
+
The library identifies requests as `ridian-mailbox-python/<installed version>`;
|
|
53
|
+
the CLI identifies itself as `ridian-mailbox-cli`. HTTPS certificate verification
|
|
54
|
+
uses your Python installation's trust store.
|
|
55
|
+
|
|
56
|
+
HTTP access remains receive/read/events only, also after ownership transfer.
|
|
57
|
+
Claim through the returned console link and private proof in the state file.
|
|
58
|
+
Sending, calendars, contacts and tasks use separately authorized Ridian MCP tools.
|
|
59
|
+
This package does not wake an agent or install an MCP connection.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Without `state`, credentials are saved in
|
|
63
|
+
`~/.config/ridian/mailbox-<API hostname>[-port].json`. Repeated setup recovers
|
|
64
|
+
that mailbox. Pass `state` for another private durable location. The Node.js
|
|
65
|
+
client shares this state format; never run concurrent writers against one file.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT, covering this Python SDK, its bundled CLI and package documentation.
|
|
70
|
+
The license does not cover the Ridian service, application, brand assets or fonts.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ridian_mailbox/__init__.py
|
|
5
|
+
src/ridian_mailbox/_example.py
|
|
6
|
+
src/ridian_mailbox/cli.py
|
|
7
|
+
src/ridian_mailbox/client.py
|
|
8
|
+
src/ridian_mailbox.egg-info/PKG-INFO
|
|
9
|
+
src/ridian_mailbox.egg-info/SOURCES.txt
|
|
10
|
+
src/ridian_mailbox.egg-info/dependency_links.txt
|
|
11
|
+
src/ridian_mailbox.egg-info/entry_points.txt
|
|
12
|
+
src/ridian_mailbox.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ridian_mailbox
|