imap-agent-cli 0.1.0__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.
- imap_agent_cli/__init__.py +3 -0
- imap_agent_cli/cli.py +406 -0
- imap_agent_cli/config.py +310 -0
- imap_agent_cli/errors.py +18 -0
- imap_agent_cli/imap_client.py +303 -0
- imap_agent_cli/mime.py +319 -0
- imap_agent_cli/models.py +50 -0
- imap_agent_cli/render.py +39 -0
- imap_agent_cli/search.py +30 -0
- imap_agent_cli-0.1.0.dist-info/METADATA +164 -0
- imap_agent_cli-0.1.0.dist-info/RECORD +14 -0
- imap_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- imap_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- imap_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
imap_agent_cli/config.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import ConfigError
|
|
10
|
+
from .models import Config, Defaults, Profile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ENV_PREFIX = "IMAP_AGENT_CLI_"
|
|
14
|
+
CONFIG_DIR = Path.home() / ".imap-agent-cli"
|
|
15
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
19
|
+
value = os.environ.get(name)
|
|
20
|
+
if value is None or value == "":
|
|
21
|
+
return default
|
|
22
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_int(name: str, default: int) -> int:
|
|
26
|
+
value = os.environ.get(name)
|
|
27
|
+
if value is None or value == "":
|
|
28
|
+
return default
|
|
29
|
+
try:
|
|
30
|
+
return int(value)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise ConfigError(f"{name} must be an integer.") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce_defaults(data: dict[str, Any]) -> Defaults:
|
|
36
|
+
allowed = set(Defaults.__dataclass_fields__)
|
|
37
|
+
values = {key: value for key, value in data.items() if key in allowed}
|
|
38
|
+
if "exclude_special_folders_from_all" in values:
|
|
39
|
+
values["exclude_special_folders_from_all"] = tuple(values["exclude_special_folders_from_all"])
|
|
40
|
+
return Defaults(**values)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _coerce_profile(name: str, data: dict[str, Any], defaults: Defaults) -> Profile:
|
|
44
|
+
if not data.get("host"):
|
|
45
|
+
raise ConfigError(f"profile '{name}' is missing host.")
|
|
46
|
+
port = int(data.get("port", 993))
|
|
47
|
+
password_env = str(data.get("password_env") or f"{ENV_PREFIX}PASSWORD")
|
|
48
|
+
password = os.environ.get(password_env)
|
|
49
|
+
return Profile(
|
|
50
|
+
name=name,
|
|
51
|
+
host=str(data["host"]),
|
|
52
|
+
port=port,
|
|
53
|
+
username=str(data.get("username", "")),
|
|
54
|
+
password=password,
|
|
55
|
+
password_env=password_env,
|
|
56
|
+
tls=bool(data.get("tls", True)),
|
|
57
|
+
ssl_mode=str(data.get("ssl_mode", "required")),
|
|
58
|
+
drafts_folder=str(data.get("drafts_folder", "")),
|
|
59
|
+
connect_timeout_seconds=int(data.get("connect_timeout_seconds", defaults.connect_timeout_seconds)),
|
|
60
|
+
read_timeout_seconds=int(data.get("read_timeout_seconds", defaults.read_timeout_seconds)),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _env_profile() -> Profile | None:
|
|
65
|
+
host = os.environ.get(f"{ENV_PREFIX}HOST")
|
|
66
|
+
if not host:
|
|
67
|
+
return None
|
|
68
|
+
defaults = Defaults(
|
|
69
|
+
max_results=_env_int(f"{ENV_PREFIX}MAX_RESULTS", 25),
|
|
70
|
+
max_body_chars=_env_int(f"{ENV_PREFIX}MAX_BODY_CHARS", 12000),
|
|
71
|
+
connect_timeout_seconds=_env_int(f"{ENV_PREFIX}CONNECT_TIMEOUT_SECONDS", 15),
|
|
72
|
+
read_timeout_seconds=_env_int(f"{ENV_PREFIX}READ_TIMEOUT_SECONDS", 30),
|
|
73
|
+
)
|
|
74
|
+
return Profile(
|
|
75
|
+
name="default",
|
|
76
|
+
host=host,
|
|
77
|
+
port=_env_int(f"{ENV_PREFIX}PORT", 993),
|
|
78
|
+
username=os.environ.get(f"{ENV_PREFIX}USERNAME", ""),
|
|
79
|
+
password=os.environ.get(f"{ENV_PREFIX}PASSWORD"),
|
|
80
|
+
password_env=f"{ENV_PREFIX}PASSWORD",
|
|
81
|
+
tls=_env_bool(f"{ENV_PREFIX}TLS", True),
|
|
82
|
+
ssl_mode=os.environ.get(f"{ENV_PREFIX}SSL_MODE", "required"),
|
|
83
|
+
drafts_folder=os.environ.get(f"{ENV_PREFIX}DRAFTS_FOLDER", ""),
|
|
84
|
+
connect_timeout_seconds=defaults.connect_timeout_seconds,
|
|
85
|
+
read_timeout_seconds=defaults.read_timeout_seconds,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(path: Path | None = None) -> Config:
|
|
90
|
+
path = path or CONFIG_PATH
|
|
91
|
+
defaults = Defaults()
|
|
92
|
+
profiles: dict[str, Profile] = {}
|
|
93
|
+
|
|
94
|
+
if path.exists():
|
|
95
|
+
try:
|
|
96
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
97
|
+
except tomllib.TOMLDecodeError as exc:
|
|
98
|
+
raise ConfigError(f"invalid TOML in {path}: {exc}") from exc
|
|
99
|
+
defaults = _coerce_defaults(raw.get("defaults", {}))
|
|
100
|
+
raw_profiles = raw.get("profiles", {})
|
|
101
|
+
if not isinstance(raw_profiles, dict):
|
|
102
|
+
raise ConfigError("[profiles] must be a TOML table.")
|
|
103
|
+
profiles = {
|
|
104
|
+
name: _coerce_profile(name, profile_data, defaults)
|
|
105
|
+
for name, profile_data in raw_profiles.items()
|
|
106
|
+
if isinstance(profile_data, dict)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
env_profile = _env_profile()
|
|
110
|
+
if env_profile and "default" not in profiles:
|
|
111
|
+
profiles["default"] = env_profile
|
|
112
|
+
|
|
113
|
+
return Config(defaults=defaults, profiles=profiles, path=path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_raw(path: Path) -> dict[str, Any]:
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return {"defaults": {}, "profiles": {}}
|
|
119
|
+
try:
|
|
120
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
121
|
+
except tomllib.TOMLDecodeError as exc:
|
|
122
|
+
raise ConfigError(f"invalid TOML in {path}: {exc}") from exc
|
|
123
|
+
raw.setdefault("defaults", {})
|
|
124
|
+
raw.setdefault("profiles", {})
|
|
125
|
+
if not isinstance(raw["defaults"], dict) or not isinstance(raw["profiles"], dict):
|
|
126
|
+
raise ConfigError("config must contain [defaults] and [profiles] tables.")
|
|
127
|
+
return raw
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _toml_value(value: Any) -> str:
|
|
131
|
+
if isinstance(value, bool):
|
|
132
|
+
return "true" if value else "false"
|
|
133
|
+
if isinstance(value, int):
|
|
134
|
+
return str(value)
|
|
135
|
+
if isinstance(value, (list, tuple)):
|
|
136
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
137
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
138
|
+
return f'"{escaped}"'
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_raw(path: Path, raw: dict[str, Any]) -> None:
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
defaults = raw.get("defaults", {})
|
|
145
|
+
lines.append("[defaults]")
|
|
146
|
+
for key, value in defaults.items():
|
|
147
|
+
lines.append(f"{key} = {_toml_value(value)}")
|
|
148
|
+
lines.append("")
|
|
149
|
+
profiles = raw.get("profiles", {})
|
|
150
|
+
for name, profile in profiles.items():
|
|
151
|
+
lines.append(f"[profiles.{name}]")
|
|
152
|
+
for key, value in profile.items():
|
|
153
|
+
lines.append(f"{key} = {_toml_value(value)}")
|
|
154
|
+
lines.append("")
|
|
155
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_profile(
|
|
159
|
+
config: Config,
|
|
160
|
+
name: str | None,
|
|
161
|
+
*,
|
|
162
|
+
host: str | None = None,
|
|
163
|
+
port: int | None = None,
|
|
164
|
+
username: str | None = None,
|
|
165
|
+
password: str | None = None,
|
|
166
|
+
tls: bool | None = None,
|
|
167
|
+
ssl_mode: str | None = None,
|
|
168
|
+
) -> Profile:
|
|
169
|
+
profile_name = name or config.defaults.profile
|
|
170
|
+
base = config.profiles.get(profile_name)
|
|
171
|
+
if base is None and not host:
|
|
172
|
+
raise ConfigError(
|
|
173
|
+
f"profile '{profile_name}' was not found. Set IMAP_AGENT_CLI_HOST or run 'imap-agent-cli config init'."
|
|
174
|
+
)
|
|
175
|
+
if base is None:
|
|
176
|
+
base = Profile(name=profile_name, host=host or "", username=username or "")
|
|
177
|
+
values = asdict(base)
|
|
178
|
+
if host is not None:
|
|
179
|
+
values["host"] = host
|
|
180
|
+
if port is not None:
|
|
181
|
+
values["port"] = port
|
|
182
|
+
if username is not None:
|
|
183
|
+
values["username"] = username
|
|
184
|
+
if password is not None:
|
|
185
|
+
values["password"] = password
|
|
186
|
+
if tls is not None:
|
|
187
|
+
values["tls"] = tls
|
|
188
|
+
if ssl_mode is not None:
|
|
189
|
+
values["ssl_mode"] = ssl_mode
|
|
190
|
+
if not values["host"]:
|
|
191
|
+
raise ConfigError(f"profile '{profile_name}' is missing host.")
|
|
192
|
+
if not values["username"]:
|
|
193
|
+
raise ConfigError(f"profile '{profile_name}' is missing username.")
|
|
194
|
+
return Profile(**values)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def config_status(config: Config) -> dict[str, Any]:
|
|
198
|
+
return {
|
|
199
|
+
"path": str(config.path or CONFIG_PATH),
|
|
200
|
+
"defaults": asdict(config.defaults),
|
|
201
|
+
"profiles": [
|
|
202
|
+
{
|
|
203
|
+
"name": profile.name,
|
|
204
|
+
"host": profile.host,
|
|
205
|
+
"port": profile.port,
|
|
206
|
+
"username": profile.username,
|
|
207
|
+
"password_env": profile.password_env,
|
|
208
|
+
"has_password": bool(profile.password),
|
|
209
|
+
"tls": profile.tls,
|
|
210
|
+
"ssl_mode": profile.ssl_mode,
|
|
211
|
+
"drafts_folder": profile.drafts_folder,
|
|
212
|
+
"complete": bool(profile.host and profile.username and profile.password),
|
|
213
|
+
}
|
|
214
|
+
for profile in config.profiles.values()
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def init_config(path: Path | None = None, *, from_env: bool = False) -> Path:
|
|
220
|
+
path = path or CONFIG_PATH
|
|
221
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
if path.exists():
|
|
223
|
+
return path
|
|
224
|
+
host = os.environ.get(f"{ENV_PREFIX}HOST", "imap.example.com") if from_env else "imap.example.com"
|
|
225
|
+
port = os.environ.get(f"{ENV_PREFIX}PORT", "993") if from_env else "993"
|
|
226
|
+
username = os.environ.get(f"{ENV_PREFIX}USERNAME", "me@example.com") if from_env else "me@example.com"
|
|
227
|
+
content = f"""[defaults]
|
|
228
|
+
profile = "default"
|
|
229
|
+
format = "json"
|
|
230
|
+
default_folder = "INBOX"
|
|
231
|
+
max_results = 25
|
|
232
|
+
max_body_chars = 12000
|
|
233
|
+
connect_timeout_seconds = 15
|
|
234
|
+
read_timeout_seconds = 30
|
|
235
|
+
body_format = "html"
|
|
236
|
+
include_attachments = false
|
|
237
|
+
exclude_special_folders_from_all = ["junk", "spam"]
|
|
238
|
+
|
|
239
|
+
[profiles.default]
|
|
240
|
+
host = "{host}"
|
|
241
|
+
port = {port}
|
|
242
|
+
username = "{username}"
|
|
243
|
+
password_env = "IMAP_AGENT_CLI_PASSWORD"
|
|
244
|
+
tls = true
|
|
245
|
+
ssl_mode = "required"
|
|
246
|
+
drafts_folder = ""
|
|
247
|
+
"""
|
|
248
|
+
path.write_text(content, encoding="utf-8")
|
|
249
|
+
return path
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def set_default_profile(name: str, path: Path | None = None) -> Path:
|
|
253
|
+
path = path or CONFIG_PATH
|
|
254
|
+
raw = _load_raw(path)
|
|
255
|
+
if name not in raw["profiles"]:
|
|
256
|
+
raise ConfigError(f"profile '{name}' does not exist.")
|
|
257
|
+
raw["defaults"]["profile"] = name
|
|
258
|
+
_write_raw(path, raw)
|
|
259
|
+
return path
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def add_profile(
|
|
263
|
+
name: str,
|
|
264
|
+
*,
|
|
265
|
+
host: str,
|
|
266
|
+
port: int = 993,
|
|
267
|
+
username: str,
|
|
268
|
+
password_env: str,
|
|
269
|
+
tls: bool = True,
|
|
270
|
+
ssl_mode: str = "required",
|
|
271
|
+
drafts_folder: str = "",
|
|
272
|
+
path: Path | None = None,
|
|
273
|
+
) -> Path:
|
|
274
|
+
path = path or CONFIG_PATH
|
|
275
|
+
raw = _load_raw(path)
|
|
276
|
+
raw.setdefault("defaults", {})
|
|
277
|
+
raw.setdefault("profiles", {})
|
|
278
|
+
raw["profiles"][name] = {
|
|
279
|
+
"host": host,
|
|
280
|
+
"port": port,
|
|
281
|
+
"username": username,
|
|
282
|
+
"password_env": password_env,
|
|
283
|
+
"tls": tls,
|
|
284
|
+
"ssl_mode": ssl_mode,
|
|
285
|
+
"drafts_folder": drafts_folder,
|
|
286
|
+
}
|
|
287
|
+
raw["defaults"].setdefault("profile", name)
|
|
288
|
+
raw["defaults"].setdefault("format", "json")
|
|
289
|
+
raw["defaults"].setdefault("default_folder", "INBOX")
|
|
290
|
+
raw["defaults"].setdefault("max_results", 25)
|
|
291
|
+
raw["defaults"].setdefault("max_body_chars", 12000)
|
|
292
|
+
raw["defaults"].setdefault("connect_timeout_seconds", 15)
|
|
293
|
+
raw["defaults"].setdefault("read_timeout_seconds", 30)
|
|
294
|
+
raw["defaults"].setdefault("body_format", "html")
|
|
295
|
+
raw["defaults"].setdefault("include_attachments", False)
|
|
296
|
+
raw["defaults"].setdefault("exclude_special_folders_from_all", ["junk", "spam"])
|
|
297
|
+
_write_raw(path, raw)
|
|
298
|
+
return path
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def remove_profile(name: str, path: Path | None = None) -> Path:
|
|
302
|
+
path = path or CONFIG_PATH
|
|
303
|
+
raw = _load_raw(path)
|
|
304
|
+
if name not in raw["profiles"]:
|
|
305
|
+
raise ConfigError(f"profile '{name}' does not exist.")
|
|
306
|
+
del raw["profiles"][name]
|
|
307
|
+
if raw["defaults"].get("profile") == name:
|
|
308
|
+
raw["defaults"]["profile"] = next(iter(raw["profiles"]), "default")
|
|
309
|
+
_write_raw(path, raw)
|
|
310
|
+
return path
|
imap_agent_cli/errors.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppError(Exception):
|
|
5
|
+
"""Known user-facing error."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, code: str, message: str, *, retryable: bool = False, exit_code: int = 1) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.retryable = retryable
|
|
12
|
+
self.exit_code = exit_code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(AppError):
|
|
16
|
+
def __init__(self, message: str) -> None:
|
|
17
|
+
super().__init__("config_invalid", message)
|
|
18
|
+
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import AbstractContextManager
|
|
4
|
+
from email.message import EmailMessage
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import AppError
|
|
9
|
+
from .mime import attachment_infos, message_headers, parse_message, render_body, save_attachments
|
|
10
|
+
from .models import Defaults, Profile
|
|
11
|
+
from .search import build_criteria
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SPECIAL_USE_MAP = {
|
|
15
|
+
"\\Inbox": "inbox",
|
|
16
|
+
"\\Sent": "sent",
|
|
17
|
+
"\\Drafts": "drafts",
|
|
18
|
+
"\\Trash": "trash",
|
|
19
|
+
"\\Junk": "junk",
|
|
20
|
+
"\\Archive": "archive",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImapSession(AbstractContextManager["ImapSession"]):
|
|
25
|
+
def __init__(self, profile: Profile, defaults: Defaults) -> None:
|
|
26
|
+
self.profile = profile
|
|
27
|
+
self.defaults = defaults
|
|
28
|
+
self.server: Any = None
|
|
29
|
+
|
|
30
|
+
def __enter__(self) -> "ImapSession":
|
|
31
|
+
try:
|
|
32
|
+
from imapclient import IMAPClient
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise AppError(
|
|
35
|
+
"config_invalid",
|
|
36
|
+
"IMAPClient is not installed. Run with 'uv run' or install imap-agent-cli with dependencies.",
|
|
37
|
+
) from exc
|
|
38
|
+
if not self.profile.password:
|
|
39
|
+
raise AppError("auth_failed", f"missing password for profile '{self.profile.name}'.")
|
|
40
|
+
try:
|
|
41
|
+
self.server = IMAPClient(
|
|
42
|
+
self.profile.host,
|
|
43
|
+
port=self.profile.port,
|
|
44
|
+
ssl=self.profile.tls,
|
|
45
|
+
timeout=self.profile.connect_timeout_seconds,
|
|
46
|
+
)
|
|
47
|
+
self.server.login(self.profile.username, self.profile.password)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
raise AppError("connection_failed", f"failed to connect or authenticate: {exc}") from exc
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
53
|
+
if self.server is None:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
self.server.logout()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def _select(self, folder: str, *, readonly: bool = True) -> None:
|
|
61
|
+
# SELECT does not mutate message state; no-seen behavior is enforced by
|
|
62
|
+
# BODY.PEEK[] fetches. Some servers, including pymap on Windows, hang on
|
|
63
|
+
# the IMAPClient EXAMINE/read-only path.
|
|
64
|
+
try:
|
|
65
|
+
self.server.select_folder(folder, readonly=False)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise AppError("folder_not_found", f"folder '{folder}' could not be selected: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
def folders(self) -> dict[str, Any]:
|
|
70
|
+
folders = []
|
|
71
|
+
for flags, delimiter, name in self.server.list_folders():
|
|
72
|
+
flag_values = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in flags]
|
|
73
|
+
special = None
|
|
74
|
+
for flag in flag_values:
|
|
75
|
+
special = SPECIAL_USE_MAP.get(flag, special)
|
|
76
|
+
selectable = "\\Noselect" not in flag_values
|
|
77
|
+
total = None
|
|
78
|
+
unread = None
|
|
79
|
+
if selectable:
|
|
80
|
+
try:
|
|
81
|
+
status = self.server.folder_status(name, ["MESSAGES", "UNSEEN"])
|
|
82
|
+
total = _lookup_status(status, "MESSAGES")
|
|
83
|
+
unread = _lookup_status(status, "UNSEEN")
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
folders.append(
|
|
87
|
+
{
|
|
88
|
+
"name": name,
|
|
89
|
+
"delimiter": delimiter,
|
|
90
|
+
"selectable": selectable,
|
|
91
|
+
"special_use": special,
|
|
92
|
+
"flags": flag_values,
|
|
93
|
+
"total": total,
|
|
94
|
+
"unread": unread,
|
|
95
|
+
"children": [],
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return {"profile": self.profile.name, "folders": folders}
|
|
99
|
+
|
|
100
|
+
def _folder_names_for_scope(self, folder: str, scope: str) -> list[str]:
|
|
101
|
+
if scope == "folder":
|
|
102
|
+
return [folder]
|
|
103
|
+
all_folders = self.folders()["folders"]
|
|
104
|
+
selectable = [item for item in all_folders if item["selectable"]]
|
|
105
|
+
if scope == "recursive":
|
|
106
|
+
return [item["name"] for item in selectable if item["name"] == folder or item["name"].startswith(folder)]
|
|
107
|
+
if scope == "all":
|
|
108
|
+
excluded = set(self.defaults.exclude_special_folders_from_all)
|
|
109
|
+
return [item["name"] for item in selectable if item.get("special_use") not in excluded]
|
|
110
|
+
raise AppError("invalid_request", f"unsupported folder scope '{scope}'.")
|
|
111
|
+
|
|
112
|
+
def search(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
folder: str,
|
|
116
|
+
scope: str,
|
|
117
|
+
subject: str | None,
|
|
118
|
+
sender: str | None,
|
|
119
|
+
since: str | None,
|
|
120
|
+
before: str | None,
|
|
121
|
+
max_results: int,
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
criteria = build_criteria(subject=subject, sender=sender, since=since, before=before)
|
|
124
|
+
results: list[dict[str, Any]] = []
|
|
125
|
+
for current_folder in self._folder_names_for_scope(folder, scope):
|
|
126
|
+
self._select(current_folder, readonly=True)
|
|
127
|
+
try:
|
|
128
|
+
uids = list(self.server.search(criteria))
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
raise AppError("server_error", f"search failed in folder '{current_folder}': {exc}") from exc
|
|
131
|
+
for uid in reversed(uids):
|
|
132
|
+
if len(results) >= max_results:
|
|
133
|
+
break
|
|
134
|
+
results.append(self._summary(current_folder, int(uid)))
|
|
135
|
+
if len(results) >= max_results:
|
|
136
|
+
break
|
|
137
|
+
return {
|
|
138
|
+
"profile": self.profile.name,
|
|
139
|
+
"query": {
|
|
140
|
+
"folder": folder,
|
|
141
|
+
"scope": scope,
|
|
142
|
+
"subject": subject,
|
|
143
|
+
"from": sender,
|
|
144
|
+
"since": since,
|
|
145
|
+
"before": before,
|
|
146
|
+
"max_results": max_results,
|
|
147
|
+
},
|
|
148
|
+
"results": results,
|
|
149
|
+
"truncated": len(results) >= max_results,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def _fetch_raw(self, folder: str, uid: int) -> bytes:
|
|
153
|
+
self._select(folder, readonly=True)
|
|
154
|
+
try:
|
|
155
|
+
data = self.server.fetch([uid], ["BODY.PEEK[]"])
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise AppError("message_not_found", f"message UID {uid} could not be fetched from '{folder}'.") from exc
|
|
158
|
+
entry = data.get(uid) or data.get(str(uid)) or data.get(bytes(str(uid), "ascii"))
|
|
159
|
+
if not entry:
|
|
160
|
+
raise AppError("message_not_found", f"message UID {uid} was not found in '{folder}'.")
|
|
161
|
+
for key, value in entry.items():
|
|
162
|
+
key_text = key.decode() if isinstance(key, bytes) else str(key)
|
|
163
|
+
if "BODY" in key_text.upper():
|
|
164
|
+
return value
|
|
165
|
+
raise AppError("server_error", f"server did not return message body for UID {uid}.")
|
|
166
|
+
|
|
167
|
+
def _summary(self, folder: str, uid: int) -> dict[str, Any]:
|
|
168
|
+
raw = self._fetch_raw(folder, uid)
|
|
169
|
+
message = parse_message(raw)
|
|
170
|
+
headers = message_headers(message)
|
|
171
|
+
attachments = attachment_infos(message)
|
|
172
|
+
return {
|
|
173
|
+
"folder": folder,
|
|
174
|
+
"uid": uid,
|
|
175
|
+
"message_id": str(message.get("message-id", "")),
|
|
176
|
+
"date": str(message.get("date", "")),
|
|
177
|
+
"from": headers["from"],
|
|
178
|
+
"to": headers["to"],
|
|
179
|
+
"cc": headers["cc"],
|
|
180
|
+
"subject": headers["subject"],
|
|
181
|
+
"has_attachments": bool(attachments),
|
|
182
|
+
"attachment_count": len(attachments),
|
|
183
|
+
"size_bytes": len(raw),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def read(self, *, folder: str, uid: int, body_format: str, max_body_chars: int) -> dict[str, Any]:
|
|
187
|
+
raw = self._fetch_raw(folder, uid)
|
|
188
|
+
message = parse_message(raw)
|
|
189
|
+
return message_payload(
|
|
190
|
+
profile=self.profile.name,
|
|
191
|
+
folder=folder,
|
|
192
|
+
uid=uid,
|
|
193
|
+
message=message,
|
|
194
|
+
body_format=body_format,
|
|
195
|
+
max_body_chars=max_body_chars,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def attachments(self, *, folder: str, uid: int) -> dict[str, Any]:
|
|
199
|
+
raw = self._fetch_raw(folder, uid)
|
|
200
|
+
message = parse_message(raw)
|
|
201
|
+
return {
|
|
202
|
+
"profile": self.profile.name,
|
|
203
|
+
"folder": folder,
|
|
204
|
+
"uid": uid,
|
|
205
|
+
"attachments": [info for info in attachment_infos(message)],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def download_attachments(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
folder: str,
|
|
212
|
+
uid: int,
|
|
213
|
+
output_dir: Path,
|
|
214
|
+
part_id: str | None,
|
|
215
|
+
all_parts: bool,
|
|
216
|
+
include_inline: bool,
|
|
217
|
+
overwrite: bool,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
raw = self._fetch_raw(folder, uid)
|
|
220
|
+
message = parse_message(raw)
|
|
221
|
+
return {
|
|
222
|
+
"profile": self.profile.name,
|
|
223
|
+
"folder": folder,
|
|
224
|
+
"uid": uid,
|
|
225
|
+
"saved": save_attachments(
|
|
226
|
+
message,
|
|
227
|
+
output_dir,
|
|
228
|
+
part_id=part_id,
|
|
229
|
+
all_parts=all_parts,
|
|
230
|
+
include_inline=include_inline,
|
|
231
|
+
overwrite=overwrite,
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def resolve_drafts_folder(self, override: str | None = None) -> str:
|
|
236
|
+
if override:
|
|
237
|
+
return override
|
|
238
|
+
if self.profile.drafts_folder:
|
|
239
|
+
return self.profile.drafts_folder
|
|
240
|
+
folders = self.folders()["folders"]
|
|
241
|
+
for item in folders:
|
|
242
|
+
if item.get("special_use") == "drafts":
|
|
243
|
+
return str(item["name"])
|
|
244
|
+
names = {str(item["name"]).lower(): str(item["name"]) for item in folders}
|
|
245
|
+
for candidate in ("drafts", "inbox.drafts", "[gmail]/drafts"):
|
|
246
|
+
if candidate in names:
|
|
247
|
+
return names[candidate]
|
|
248
|
+
raise AppError("drafts_folder_not_found", "could not auto-detect Drafts folder; configure drafts_folder.")
|
|
249
|
+
|
|
250
|
+
def append_draft(self, message: EmailMessage, *, drafts_folder: str | None = None) -> dict[str, Any]:
|
|
251
|
+
folder = self.resolve_drafts_folder(drafts_folder)
|
|
252
|
+
raw = message.as_bytes(policy=message.policy)
|
|
253
|
+
try:
|
|
254
|
+
result = self.server.append(folder, raw)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
raise AppError("server_error", f"failed to append draft to '{folder}': {exc}") from exc
|
|
257
|
+
return {
|
|
258
|
+
"profile": self.profile.name,
|
|
259
|
+
"drafts_folder": folder,
|
|
260
|
+
"created": True,
|
|
261
|
+
"append_uid": _extract_append_uid(result),
|
|
262
|
+
"message_id": str(message.get("message-id", "")),
|
|
263
|
+
"subject": str(message.get("subject", "")),
|
|
264
|
+
"to": message.get("to", ""),
|
|
265
|
+
"attachment_count": len(list(message.iter_attachments())),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _lookup_status(status: dict[Any, Any], name: str) -> int | None:
|
|
270
|
+
for key, value in status.items():
|
|
271
|
+
key_text = key.decode() if isinstance(key, bytes) else str(key)
|
|
272
|
+
if key_text.upper() == name:
|
|
273
|
+
return int(value)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extract_append_uid(result: Any) -> int | None:
|
|
278
|
+
if isinstance(result, (list, tuple)):
|
|
279
|
+
for item in result:
|
|
280
|
+
if isinstance(item, int):
|
|
281
|
+
return item
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def message_payload(
|
|
286
|
+
*,
|
|
287
|
+
profile: str,
|
|
288
|
+
folder: str,
|
|
289
|
+
uid: int,
|
|
290
|
+
message: EmailMessage,
|
|
291
|
+
body_format: str,
|
|
292
|
+
max_body_chars: int,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
return {
|
|
295
|
+
"profile": profile,
|
|
296
|
+
"folder": folder,
|
|
297
|
+
"uid": uid,
|
|
298
|
+
"message_id": str(message.get("message-id", "")),
|
|
299
|
+
"date": str(message.get("date", "")),
|
|
300
|
+
"headers": message_headers(message),
|
|
301
|
+
"body": render_body(message, body_format, max_body_chars),
|
|
302
|
+
"attachments": [info for info in attachment_infos(message)],
|
|
303
|
+
}
|