langchain-atomicmail 0.3.14__py3-none-any.whl
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.
- atomicmail/__init__.py +32 -0
- atomicmail/auth_http.py +177 -0
- atomicmail/cli.py +236 -0
- atomicmail/config.py +112 -0
- atomicmail/constants.py +32 -0
- atomicmail/credentials.py +158 -0
- atomicmail/help.py +118 -0
- atomicmail/jmap_request.py +918 -0
- atomicmail/jwt_utils.py +34 -0
- atomicmail/mcp_server.py +341 -0
- atomicmail/pow.py +71 -0
- atomicmail/session.py +595 -0
- atomicmail/shared_assets.py +67 -0
- atomicmail/vendor/shared/consts.json +11 -0
- atomicmail/vendor/shared/fixtures/pow_vectors.json +32 -0
- atomicmail/vendor/shared/help/fragments/inbox_cron_agent_prompt.md +1 -0
- atomicmail/vendor/shared/help/fragments/post_register_cron_reminder.md +5 -0
- atomicmail/vendor/shared/help/readme_stub.md +3 -0
- atomicmail/vendor/shared/help/topics/auth.md +8 -0
- atomicmail/vendor/shared/help/topics/cron.md +217 -0
- atomicmail/vendor/shared/help/topics/installation.md +35 -0
- atomicmail/vendor/shared/help/topics/jmap_cheatsheet.md +19 -0
- atomicmail/vendor/shared/help/topics/multi_account.md +9 -0
- atomicmail/vendor/shared/help/topics/overview.md +27 -0
- atomicmail/vendor/shared/help/topics/presets.md +12 -0
- atomicmail/vendor/shared/help/topics/tools.md +16 -0
- atomicmail/vendor/shared/help/topics/troubleshooting.md +6 -0
- atomicmail/vendor/shared/manifest.json +31 -0
- atomicmail/vendor/shared/messages/errors.json +68 -0
- atomicmail/vendor/shared/messages/hints.json +8 -0
- atomicmail/vendor/shared/presets/list_inbox.json +46 -0
- atomicmail/vendor/shared/presets/reply.json +97 -0
- atomicmail/vendor/shared/presets/send_mail.json +70 -0
- atomicmail/vendor/shared/presets/send_mail_attachment.json +92 -0
- atomicmail/vendor/shared/presets/send_mail_blob_attachment.json +74 -0
- atomicmail/vendor/shared/skill/SKILL.template.md +202 -0
- atomicmail/vendor/shared/skill/manifest.json +89 -0
- langchain_atomicmail/__init__.py +23 -0
- langchain_atomicmail/toolkit.py +15 -0
- langchain_atomicmail/tools.py +251 -0
- langchain_atomicmail-0.3.14.dist-info/METADATA +136 -0
- langchain_atomicmail-0.3.14.dist-info/RECORD +44 -0
- langchain_atomicmail-0.3.14.dist-info/WHEEL +5 -0
- langchain_atomicmail-0.3.14.dist-info/top_level.txt +2 -0
atomicmail/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Atomic Mail Python shared foundation package."""
|
|
2
|
+
|
|
3
|
+
from .config import ResolvedAgentConfig, resolve_agent_config_from_env
|
|
4
|
+
from .credentials import (
|
|
5
|
+
CredentialStore,
|
|
6
|
+
Credentials,
|
|
7
|
+
SkillFiles,
|
|
8
|
+
default_files_from_out_dir,
|
|
9
|
+
)
|
|
10
|
+
from .help import help
|
|
11
|
+
from .jmap_request import JmapAttachmentInput, JmapRequestResult, jmap_request, run_jmap_request
|
|
12
|
+
from .mcp_server import handle_tool_call
|
|
13
|
+
from .session import AgentSession, RegisterResult, create_agent_session, register
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AgentSession",
|
|
17
|
+
"Credentials",
|
|
18
|
+
"CredentialStore",
|
|
19
|
+
"JmapRequestResult",
|
|
20
|
+
"JmapAttachmentInput",
|
|
21
|
+
"RegisterResult",
|
|
22
|
+
"ResolvedAgentConfig",
|
|
23
|
+
"SkillFiles",
|
|
24
|
+
"default_files_from_out_dir",
|
|
25
|
+
"help",
|
|
26
|
+
"handle_tool_call",
|
|
27
|
+
"jmap_request",
|
|
28
|
+
"run_jmap_request",
|
|
29
|
+
"register",
|
|
30
|
+
"create_agent_session",
|
|
31
|
+
"resolve_agent_config_from_env",
|
|
32
|
+
]
|
atomicmail/auth_http.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Auth-service HTTP helpers: challenge -> session -> capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, Mapping
|
|
8
|
+
from urllib.error import HTTPError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .jwt_utils import decode_jwt_payload
|
|
12
|
+
from .pow import solve_pow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ChallengeResponse:
|
|
17
|
+
challengeJWT: str
|
|
18
|
+
challenge: str
|
|
19
|
+
difficulty: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SessionResponse:
|
|
24
|
+
sessionJWT: str
|
|
25
|
+
apiKey: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fetch_challenge(auth_url: str) -> ChallengeResponse:
|
|
29
|
+
base = auth_url.rstrip("/")
|
|
30
|
+
status, text, headers = _http_post(f"{base}/api/v1/challenge")
|
|
31
|
+
if status < 200 or status >= 300:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"auth-service /api/v1/challenge returned {status}: {text}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
challenge_jwt = _read_bearer_token(
|
|
37
|
+
headers.get("Authorization"),
|
|
38
|
+
"Challenge response missing Authorization bearer token.",
|
|
39
|
+
)
|
|
40
|
+
payload = decode_jwt_payload(challenge_jwt)
|
|
41
|
+
challenge = payload.get("jti")
|
|
42
|
+
difficulty = payload.get("difficulty")
|
|
43
|
+
if not isinstance(challenge, str) or not isinstance(difficulty, (int, float)):
|
|
44
|
+
raise ValueError("Challenge JWT payload malformed (missing jti or difficulty).")
|
|
45
|
+
|
|
46
|
+
return ChallengeResponse(
|
|
47
|
+
challengeJWT=challenge_jwt,
|
|
48
|
+
challenge=challenge,
|
|
49
|
+
difficulty=int(difficulty),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def exchange_session(
|
|
54
|
+
auth_url: str,
|
|
55
|
+
*,
|
|
56
|
+
challenge_jwt: str,
|
|
57
|
+
pow_hex: str,
|
|
58
|
+
nonce: str,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
username: str | None = None,
|
|
61
|
+
) -> SessionResponse:
|
|
62
|
+
base = auth_url.rstrip("/")
|
|
63
|
+
payload: dict[str, str] = {"powHex": pow_hex, "nonce": nonce}
|
|
64
|
+
if api_key:
|
|
65
|
+
payload["apiKey"] = api_key
|
|
66
|
+
if username:
|
|
67
|
+
payload["username"] = username
|
|
68
|
+
|
|
69
|
+
status, text, headers = _http_post(
|
|
70
|
+
f"{base}/api/v1/session",
|
|
71
|
+
headers={"Authorization": f"Bearer {challenge_jwt}"},
|
|
72
|
+
json_body=payload,
|
|
73
|
+
)
|
|
74
|
+
if status < 200 or status >= 300:
|
|
75
|
+
raise ValueError(f"auth-service /api/v1/session returned {status}: {text}")
|
|
76
|
+
|
|
77
|
+
session_jwt = _read_bearer_token(
|
|
78
|
+
headers.get("Authorization"),
|
|
79
|
+
"Session response missing Authorization bearer token.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
data: dict[str, object] = {}
|
|
83
|
+
if text.strip():
|
|
84
|
+
try:
|
|
85
|
+
parsed = json.loads(text)
|
|
86
|
+
except json.JSONDecodeError as err:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"auth-service /api/v1/session returned non-JSON body."
|
|
89
|
+
) from err
|
|
90
|
+
if not isinstance(parsed, dict):
|
|
91
|
+
raise ValueError("auth-service /api/v1/session returned non-JSON body.")
|
|
92
|
+
data = parsed
|
|
93
|
+
|
|
94
|
+
api_key_out = data.get("apiKey")
|
|
95
|
+
return SessionResponse(
|
|
96
|
+
sessionJWT=session_jwt,
|
|
97
|
+
apiKey=api_key_out if isinstance(api_key_out, str) else None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fetch_capability(auth_url: str, session_jwt: str) -> str:
|
|
102
|
+
base = auth_url.rstrip("/")
|
|
103
|
+
status, text, headers = _http_post(
|
|
104
|
+
f"{base}/api/v1/capability",
|
|
105
|
+
headers={"Authorization": f"Bearer {session_jwt}"},
|
|
106
|
+
)
|
|
107
|
+
if status < 200 or status >= 300:
|
|
108
|
+
raise ValueError(f"auth-service /api/v1/capability returned {status}: {text}")
|
|
109
|
+
|
|
110
|
+
return _read_bearer_token(
|
|
111
|
+
headers.get("Authorization"),
|
|
112
|
+
"Capability response missing Authorization bearer token.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def perform_pow_and_session(
|
|
117
|
+
*,
|
|
118
|
+
auth_url: str,
|
|
119
|
+
scrypt_salt: str,
|
|
120
|
+
api_key: str | None = None,
|
|
121
|
+
username: str | None = None,
|
|
122
|
+
on_pow_progress: Callable[[int], None] | None = None,
|
|
123
|
+
) -> SessionResponse:
|
|
124
|
+
challenge = fetch_challenge(auth_url)
|
|
125
|
+
solved = solve_pow(
|
|
126
|
+
challenge=challenge.challenge,
|
|
127
|
+
difficulty=challenge.difficulty,
|
|
128
|
+
salt=scrypt_salt,
|
|
129
|
+
on_progress=on_pow_progress,
|
|
130
|
+
)
|
|
131
|
+
return exchange_session(
|
|
132
|
+
auth_url,
|
|
133
|
+
challenge_jwt=challenge.challengeJWT,
|
|
134
|
+
pow_hex=solved.powHex,
|
|
135
|
+
nonce=solved.nonce,
|
|
136
|
+
api_key=api_key,
|
|
137
|
+
username=username,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read_bearer_token(header_value: str | None, missing_error: str) -> str:
|
|
142
|
+
if not header_value:
|
|
143
|
+
raise ValueError(missing_error)
|
|
144
|
+
|
|
145
|
+
raw = header_value.strip()
|
|
146
|
+
prefix = "bearer "
|
|
147
|
+
if not raw.lower().startswith(prefix):
|
|
148
|
+
raise ValueError("Authorization header must use Bearer scheme.")
|
|
149
|
+
|
|
150
|
+
token = raw[len(prefix) :].strip()
|
|
151
|
+
if not token:
|
|
152
|
+
raise ValueError("Authorization header must use Bearer scheme.")
|
|
153
|
+
return token
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _http_post(
|
|
157
|
+
url: str,
|
|
158
|
+
*,
|
|
159
|
+
headers: Mapping[str, str] | None = None,
|
|
160
|
+
json_body: object | None = None,
|
|
161
|
+
) -> tuple[int, str, Mapping[str, str]]:
|
|
162
|
+
req_headers = dict(headers or {})
|
|
163
|
+
body_bytes: bytes | None = None
|
|
164
|
+
if json_body is not None:
|
|
165
|
+
body_bytes = json.dumps(json_body).encode("utf-8")
|
|
166
|
+
req_headers.setdefault("Content-Type", "application/json")
|
|
167
|
+
|
|
168
|
+
req = Request(url, data=body_bytes, headers=req_headers, method="POST")
|
|
169
|
+
try:
|
|
170
|
+
with urlopen(req) as response:
|
|
171
|
+
return (
|
|
172
|
+
int(response.getcode()),
|
|
173
|
+
response.read().decode("utf-8"),
|
|
174
|
+
response.headers,
|
|
175
|
+
)
|
|
176
|
+
except HTTPError as err:
|
|
177
|
+
return err.code, err.read().decode("utf-8", errors="replace"), err.headers
|
atomicmail/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Atomic Mail CLI adapter for Python library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from .help import HELP_TOPIC_LIST, help as get_help, normalize_help_topic
|
|
11
|
+
from .jmap_request import DEFAULT_JMAP_USING, JmapAttachmentInput, jmap_request
|
|
12
|
+
from .shared_assets import try_read_shared_json
|
|
13
|
+
from .session import register
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _shared_errors() -> dict[str, str]:
|
|
17
|
+
loaded = try_read_shared_json("messages/errors.json")
|
|
18
|
+
if isinstance(loaded, dict):
|
|
19
|
+
return {k: v for k, v in loaded.items() if isinstance(k, str) and isinstance(v, str)}
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_ERRORS = _shared_errors()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _error(key: str, fallback: str) -> str:
|
|
27
|
+
return _ERRORS.get(key, fallback)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _error_template(key: str, fallback: str, values: Mapping[str, str | int]) -> str:
|
|
31
|
+
out = _ERRORS.get(key, fallback)
|
|
32
|
+
for name, value in values.items():
|
|
33
|
+
out = out.replace(f"{{{name}}}", str(value))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_user_vars_json(raw: str) -> dict[str, str]:
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(raw)
|
|
40
|
+
except json.JSONDecodeError as err:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
_error_template(
|
|
43
|
+
"vars_invalid_json_template",
|
|
44
|
+
"--vars is not valid JSON: {details}",
|
|
45
|
+
{"details": str(err)},
|
|
46
|
+
)
|
|
47
|
+
) from err
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise ValueError(_error("vars_not_object", "--vars must be a JSON object of { VAR_NAME: string }."))
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for key, item in value.items():
|
|
52
|
+
if not isinstance(key, str) or not key:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
_error_template(
|
|
55
|
+
"vars_key_invalid_template",
|
|
56
|
+
"--vars key '{key}' must match /^[A-Z][A-Z0-9_]*$/.",
|
|
57
|
+
{"key": str(key)},
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
if not key[0].isalpha() or not key[0].isupper():
|
|
61
|
+
raise ValueError(
|
|
62
|
+
_error_template(
|
|
63
|
+
"vars_key_invalid_template",
|
|
64
|
+
"--vars key '{key}' must match /^[A-Z][A-Z0-9_]*$/.",
|
|
65
|
+
{"key": key},
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
if any((not ch.isdigit()) and (not ch.isupper()) and ch != "_" for ch in key[1:]):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
_error_template(
|
|
71
|
+
"vars_key_invalid_template",
|
|
72
|
+
"--vars key '{key}' must match /^[A-Z][A-Z0-9_]*$/.",
|
|
73
|
+
{"key": key},
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
if not isinstance(item, str):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
_error_template(
|
|
79
|
+
"vars_value_not_string_template",
|
|
80
|
+
"--vars value for '{key}' must be a string.",
|
|
81
|
+
{"key": key},
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
out[key] = item
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
89
|
+
parser = argparse.ArgumentParser(prog="atomicmail", description="Atomic Mail Python CLI")
|
|
90
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
91
|
+
|
|
92
|
+
register_cmd = subparsers.add_parser(
|
|
93
|
+
"register", help="PoW signup or API-key login and persist credentials"
|
|
94
|
+
)
|
|
95
|
+
register_mode = register_cmd.add_mutually_exclusive_group(required=True)
|
|
96
|
+
register_mode.add_argument("--username", help="Desired username (5-21 characters).")
|
|
97
|
+
register_mode.add_argument("--api-key", help="Existing API key for login.")
|
|
98
|
+
register_cmd.add_argument("--credentials-dir", help="Credential directory for this command.")
|
|
99
|
+
register_cmd.add_argument(
|
|
100
|
+
"--forced",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="Allow replacing existing credentials for a different username (username mode only).",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
jmap_cmd = subparsers.add_parser("jmap_request", help="Send a JMAP request")
|
|
106
|
+
jmap_cmd.add_argument("--credentials-dir", help="Credential directory for this command.")
|
|
107
|
+
jmap_ops = jmap_cmd.add_mutually_exclusive_group(required=True)
|
|
108
|
+
jmap_ops.add_argument("--ops", help="Inline JMAP JSON.")
|
|
109
|
+
jmap_ops.add_argument("--ops-file", help="JMAP ops file path or bundled preset name.")
|
|
110
|
+
jmap_cmd.add_argument(
|
|
111
|
+
"--using",
|
|
112
|
+
help="Comma-separated capability URNs used when ops does not provide using.",
|
|
113
|
+
)
|
|
114
|
+
jmap_cmd.add_argument("--vars", help="JSON object with VAR_NAME -> string placeholder values.")
|
|
115
|
+
jmap_cmd.add_argument(
|
|
116
|
+
"--attachment",
|
|
117
|
+
action="append",
|
|
118
|
+
dest="attachments",
|
|
119
|
+
help="Attachment path; repeat for multiple files.",
|
|
120
|
+
)
|
|
121
|
+
jmap_cmd.add_argument(
|
|
122
|
+
"--attachment-path-base",
|
|
123
|
+
help="Base directory for resolving relative attachment paths.",
|
|
124
|
+
)
|
|
125
|
+
jmap_cmd.add_argument(
|
|
126
|
+
"--dry-run",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help="Resolve envelope and print request without sending it.",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
help_cmd = subparsers.add_parser("help", help="Print Atomic Mail help topic")
|
|
132
|
+
help_cmd.add_argument("--topic", help="Help topic; omit for overview.")
|
|
133
|
+
|
|
134
|
+
return parser
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _cmd_register(args: argparse.Namespace) -> int:
|
|
138
|
+
if args.api_key and args.forced:
|
|
139
|
+
raise ValueError("--forced can only be used with --username.")
|
|
140
|
+
|
|
141
|
+
result = register(
|
|
142
|
+
username=args.username,
|
|
143
|
+
api_key=args.api_key,
|
|
144
|
+
credentials_dir=args.credentials_dir,
|
|
145
|
+
forced=bool(args.forced),
|
|
146
|
+
)
|
|
147
|
+
sys.stdout.write(json.dumps(result.__dict__, indent=2) + "\n")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_jmap_request(args: argparse.Namespace) -> int:
|
|
152
|
+
attachments: list[JmapAttachmentInput] | None = None
|
|
153
|
+
if args.attachments:
|
|
154
|
+
attachments = [JmapAttachmentInput(path=item) for item in args.attachments]
|
|
155
|
+
if args.dry_run and attachments:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
_error(
|
|
158
|
+
"cli_dry_run_with_attachment",
|
|
159
|
+
"--dry-run cannot be combined with --attachment.",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
using = list(DEFAULT_JMAP_USING)
|
|
164
|
+
if args.using:
|
|
165
|
+
using = [item.strip() for item in args.using.split(",") if item.strip()]
|
|
166
|
+
|
|
167
|
+
vars_map: dict[str, str] | None = None
|
|
168
|
+
if args.vars is not None:
|
|
169
|
+
vars_map = _parse_user_vars_json(args.vars)
|
|
170
|
+
|
|
171
|
+
result = jmap_request(
|
|
172
|
+
ops=args.ops,
|
|
173
|
+
ops_file=args.ops_file,
|
|
174
|
+
vars=vars_map,
|
|
175
|
+
dry_run=bool(args.dry_run),
|
|
176
|
+
attachments=attachments,
|
|
177
|
+
attachment_path_base=args.attachment_path_base,
|
|
178
|
+
using=using,
|
|
179
|
+
credentials_dir=args.credentials_dir,
|
|
180
|
+
)
|
|
181
|
+
sys.stdout.write(result.bodyText if result.bodyText.endswith("\n") else f"{result.bodyText}\n")
|
|
182
|
+
if result.ok:
|
|
183
|
+
return 0
|
|
184
|
+
sys.stderr.write(f"Error: JMAP request failed (HTTP {result.status}): {result.bodyText}\n")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cmd_help(args: argparse.Namespace) -> int:
|
|
189
|
+
topic = args.topic
|
|
190
|
+
if topic is not None and normalize_help_topic(topic) == "readme":
|
|
191
|
+
# Python package does not bundle npm README lookup;
|
|
192
|
+
# we intentionally return the same shared stub text as get_help("readme").
|
|
193
|
+
sys.stdout.write(get_help("readme") + "\n")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
sys.stdout.write(get_help(topic) + "\n")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
201
|
+
parser = _build_parser()
|
|
202
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
203
|
+
|
|
204
|
+
if not args.command:
|
|
205
|
+
parser.print_help(sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if args.command == "register":
|
|
210
|
+
return _cmd_register(args)
|
|
211
|
+
if args.command == "jmap_request":
|
|
212
|
+
return _cmd_jmap_request(args)
|
|
213
|
+
if args.command == "help":
|
|
214
|
+
return _cmd_help(args)
|
|
215
|
+
message = _error_template(
|
|
216
|
+
"cli_unknown_command_template",
|
|
217
|
+
"Unknown command: {cmd}",
|
|
218
|
+
{"cmd": str(args.command)},
|
|
219
|
+
)
|
|
220
|
+
sys.stderr.write(f"Error: {message}\n")
|
|
221
|
+
return 2
|
|
222
|
+
except ValueError as err:
|
|
223
|
+
sys.stderr.write(f"Error: {err}\n")
|
|
224
|
+
return 2
|
|
225
|
+
except Exception as err: # pragma: no cover - defensive CLI adapter guard
|
|
226
|
+
sys.stderr.write(f"Error: {err}\n")
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def console_main() -> None:
|
|
231
|
+
raise SystemExit(main())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def help_topics_for_cli() -> str:
|
|
235
|
+
"""Expose topics for tests/help text parity checks."""
|
|
236
|
+
return ", ".join(HELP_TOPIC_LIST)
|
atomicmail/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Configuration resolution for auth/api/credential defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, Mapping
|
|
9
|
+
|
|
10
|
+
from .constants import (
|
|
11
|
+
DEFAULT_API_URL,
|
|
12
|
+
DEFAULT_AUTH_URL,
|
|
13
|
+
DEFAULT_POW_SCRYPT_SALT_HEX,
|
|
14
|
+
)
|
|
15
|
+
from .credentials import SkillFiles, default_files_from_out_dir, try_read_credentials
|
|
16
|
+
|
|
17
|
+
ConfigSource = Literal["credentials-file", "env", "mixed", "defaults"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResolvedAgentConfig:
|
|
22
|
+
authUrl: str
|
|
23
|
+
apiUrl: str
|
|
24
|
+
scryptSalt: str
|
|
25
|
+
apiKey: str | None
|
|
26
|
+
inboxId: str | None
|
|
27
|
+
credentialDir: str
|
|
28
|
+
files: SkillFiles
|
|
29
|
+
source: ConfigSource
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_credential_dir() -> str:
|
|
33
|
+
from_env = os.getenv("ATOMIC_MAIL_CREDENTIALS_DIR")
|
|
34
|
+
if from_env:
|
|
35
|
+
return from_env
|
|
36
|
+
|
|
37
|
+
home = os.getenv("HOME") or os.getenv("USERPROFILE")
|
|
38
|
+
if not home:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Cannot determine default credential directory: HOME and USERPROFILE "
|
|
41
|
+
"are both unset. Set ATOMIC_MAIL_CREDENTIALS_DIR explicitly."
|
|
42
|
+
)
|
|
43
|
+
home_base = home.rstrip("/\\")
|
|
44
|
+
return f"{home_base}/.atomicmail"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def expand_credential_dir_input(dir_value: str | None = None) -> str:
|
|
48
|
+
raw = dir_value or os.getenv("ATOMIC_MAIL_CREDENTIALS_DIR") or "~/.atomicmail"
|
|
49
|
+
if raw == "~":
|
|
50
|
+
return str(Path.home())
|
|
51
|
+
return str(Path(raw).expanduser().resolve())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_url(url: str) -> str:
|
|
55
|
+
return url.rstrip("/")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pick_env(env: Mapping[str, str], key: str) -> str | None:
|
|
59
|
+
value = env.get(key)
|
|
60
|
+
return value if value else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_agent_config_from_env(
|
|
64
|
+
env: Mapping[str, str] | None = None,
|
|
65
|
+
credential_dir: str | None = None,
|
|
66
|
+
) -> ResolvedAgentConfig:
|
|
67
|
+
current_env = env or os.environ
|
|
68
|
+
resolved_credential_dir = (
|
|
69
|
+
expand_credential_dir_input(credential_dir)
|
|
70
|
+
if credential_dir is not None
|
|
71
|
+
else resolve_credential_dir()
|
|
72
|
+
)
|
|
73
|
+
files = default_files_from_out_dir(resolved_credential_dir)
|
|
74
|
+
file_creds = try_read_credentials(files.credentialsFile)
|
|
75
|
+
|
|
76
|
+
env_auth_url = _pick_env(current_env, "ATOMIC_MAIL_AUTH_URL")
|
|
77
|
+
env_api_url = _pick_env(current_env, "ATOMIC_MAIL_API_URL")
|
|
78
|
+
env_salt = _pick_env(current_env, "ATOMIC_MAIL_SCRYPT_SALT")
|
|
79
|
+
env_api_key = _pick_env(current_env, "ATOMIC_MAIL_API_KEY")
|
|
80
|
+
|
|
81
|
+
auth_url = env_auth_url or (file_creds.authUrl if file_creds else None) or DEFAULT_AUTH_URL
|
|
82
|
+
api_url = env_api_url or (file_creds.apiUrl if file_creds else None) or DEFAULT_API_URL
|
|
83
|
+
scrypt_salt = (
|
|
84
|
+
env_salt
|
|
85
|
+
or (file_creds.scryptSalt if file_creds else None)
|
|
86
|
+
or DEFAULT_POW_SCRYPT_SALT_HEX
|
|
87
|
+
)
|
|
88
|
+
api_key = env_api_key or (file_creds.apiKey if file_creds else None)
|
|
89
|
+
inbox_id = file_creds.inboxId if file_creds else None
|
|
90
|
+
|
|
91
|
+
using_file = file_creds is not None
|
|
92
|
+
using_env = any((env_auth_url, env_api_url, env_salt, env_api_key))
|
|
93
|
+
|
|
94
|
+
if using_file and using_env:
|
|
95
|
+
source: ConfigSource = "mixed"
|
|
96
|
+
elif using_file:
|
|
97
|
+
source = "credentials-file"
|
|
98
|
+
elif using_env:
|
|
99
|
+
source = "env"
|
|
100
|
+
else:
|
|
101
|
+
source = "defaults"
|
|
102
|
+
|
|
103
|
+
return ResolvedAgentConfig(
|
|
104
|
+
authUrl=_normalize_url(auth_url),
|
|
105
|
+
apiUrl=_normalize_url(api_url),
|
|
106
|
+
scryptSalt=scrypt_salt,
|
|
107
|
+
apiKey=api_key,
|
|
108
|
+
inboxId=inbox_id,
|
|
109
|
+
credentialDir=resolved_credential_dir,
|
|
110
|
+
files=files,
|
|
111
|
+
source=source,
|
|
112
|
+
)
|
atomicmail/constants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Cross-language constants loaded from shared assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .shared_assets import try_read_shared_json
|
|
6
|
+
|
|
7
|
+
_DEFAULTS = {
|
|
8
|
+
"DEFAULT_POW_SCRYPT_SALT_HEX": "0b980734412c292d6549110276b604ab1dea4883bd460d77d1b984adf8bca083",
|
|
9
|
+
"DEFAULT_AUTH_URL": "https://auth.atomicmail.ai",
|
|
10
|
+
"DEFAULT_API_URL": "https://api.atomicmail.ai",
|
|
11
|
+
"ONE_SEC_MS": 1_000,
|
|
12
|
+
"ONE_MIN_MS": 60_000,
|
|
13
|
+
"ONE_HOUR_MS": 3_600_000,
|
|
14
|
+
"ONE_DAY_MS": 86_400_000,
|
|
15
|
+
"ONE_MONTH_MS": 2_592_000_000,
|
|
16
|
+
"ONE_YEAR_MS": 31_536_000_000,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_SHARED = try_read_shared_json("consts.json") or {}
|
|
20
|
+
|
|
21
|
+
DEFAULT_POW_SCRYPT_SALT_HEX = _SHARED.get(
|
|
22
|
+
"DEFAULT_POW_SCRYPT_SALT_HEX", _DEFAULTS["DEFAULT_POW_SCRYPT_SALT_HEX"]
|
|
23
|
+
)
|
|
24
|
+
DEFAULT_AUTH_URL = _SHARED.get("DEFAULT_AUTH_URL", _DEFAULTS["DEFAULT_AUTH_URL"])
|
|
25
|
+
DEFAULT_API_URL = _SHARED.get("DEFAULT_API_URL", _DEFAULTS["DEFAULT_API_URL"])
|
|
26
|
+
|
|
27
|
+
ONE_SEC_MS = int(_SHARED.get("ONE_SEC_MS", _DEFAULTS["ONE_SEC_MS"]))
|
|
28
|
+
ONE_MIN_MS = int(_SHARED.get("ONE_MIN_MS", _DEFAULTS["ONE_MIN_MS"]))
|
|
29
|
+
ONE_HOUR_MS = int(_SHARED.get("ONE_HOUR_MS", _DEFAULTS["ONE_HOUR_MS"]))
|
|
30
|
+
ONE_DAY_MS = int(_SHARED.get("ONE_DAY_MS", _DEFAULTS["ONE_DAY_MS"]))
|
|
31
|
+
ONE_MONTH_MS = int(_SHARED.get("ONE_MONTH_MS", _DEFAULTS["ONE_MONTH_MS"]))
|
|
32
|
+
ONE_YEAR_MS = int(_SHARED.get("ONE_YEAR_MS", _DEFAULTS["ONE_YEAR_MS"]))
|