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.
@@ -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
@@ -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
+ }