izteamslots 1.1.0
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.
- package/.env.example +1 -0
- package/CONTRIBUTING.md +128 -0
- package/README.md +249 -0
- package/app.py +25 -0
- package/backend/__init__.py +3 -0
- package/backend/__main__.py +3 -0
- package/backend/account_store.py +448 -0
- package/backend/chatgpt_workspace_api.py +104 -0
- package/backend/dto.py +106 -0
- package/backend/file_logger.py +82 -0
- package/backend/jobs.py +77 -0
- package/backend/mail/__init__.py +98 -0
- package/backend/mail/base.py +86 -0
- package/backend/mail/boomlify.py +178 -0
- package/backend/mail/imap.py +221 -0
- package/backend/mail/trickads.py +121 -0
- package/backend/openai_web_auth.py +1402 -0
- package/backend/rpc_protocol.py +78 -0
- package/backend/rpc_server.py +233 -0
- package/backend/slot_orchestrator.py +400 -0
- package/backend/ui_facade.py +368 -0
- package/bin/izteamslots.sh +16 -0
- package/package.json +30 -0
- package/requirements.txt +2 -0
- package/scripts/setup.sh +82 -0
- package/ui/package.json +19 -0
- package/ui/src/main.ts +4 -0
- package/ui/src/menus/format.ts +163 -0
- package/ui/src/menus/mainMenus.ts +221 -0
- package/ui/src/menus/types.ts +75 -0
- package/ui/src/screens/MainScreen.ts +1175 -0
- package/ui/src/transport/stdioClient.ts +162 -0
- package/ui/tsconfig.json +13 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import PROJECT_ROOT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _timestamp() -> str:
|
|
14
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _safe_title(value: str) -> str:
|
|
18
|
+
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in value.strip().lower())
|
|
19
|
+
while "__" in cleaned:
|
|
20
|
+
cleaned = cleaned.replace("__", "_")
|
|
21
|
+
return cleaned.strip("_") or "job"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FileLogger:
|
|
25
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
26
|
+
self.root = root or (PROJECT_ROOT / "logs")
|
|
27
|
+
self.jobs_dir = self.root / "jobs"
|
|
28
|
+
self.app_log = self.root / "app.log"
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
def _append(self, path: Path, lines: list[str]) -> None:
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
with self._lock:
|
|
35
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
36
|
+
for line in lines:
|
|
37
|
+
fh.write(line.rstrip("\n") + "\n")
|
|
38
|
+
|
|
39
|
+
def info(self, message: str) -> None:
|
|
40
|
+
self._append(self.app_log, [f"[{_timestamp()}] INFO {message}"])
|
|
41
|
+
|
|
42
|
+
def error(self, message: str, traceback_text: str | None = None) -> None:
|
|
43
|
+
lines = [f"[{_timestamp()}] ERROR {message}"]
|
|
44
|
+
if traceback_text:
|
|
45
|
+
lines.extend(traceback_text.rstrip().splitlines())
|
|
46
|
+
self._append(self.app_log, lines)
|
|
47
|
+
|
|
48
|
+
def create_job_logger(self, job_id: str, title: str) -> "JobFileLogger":
|
|
49
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
50
|
+
filename = f"{stamp}-{job_id[:8]}-{_safe_title(title)}.log"
|
|
51
|
+
path = self.jobs_dir / filename
|
|
52
|
+
rel_path = path.relative_to(PROJECT_ROOT).as_posix()
|
|
53
|
+
logger = JobFileLogger(path=path, rel_path=rel_path, title=title, root_logger=self)
|
|
54
|
+
logger.log(f"JOB START: {title}")
|
|
55
|
+
self.info(f"Job created: {title} [{job_id}] -> {rel_path}")
|
|
56
|
+
return logger
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class JobFileLogger:
|
|
61
|
+
path: Path
|
|
62
|
+
rel_path: str
|
|
63
|
+
title: str
|
|
64
|
+
root_logger: FileLogger
|
|
65
|
+
|
|
66
|
+
def log(self, message: str) -> None:
|
|
67
|
+
self.root_logger._append(self.path, [f"[{_timestamp()}] {message}"])
|
|
68
|
+
|
|
69
|
+
def progress(self, current: int, total: int, message: str | None = None) -> None:
|
|
70
|
+
suffix = f" {message}" if message else ""
|
|
71
|
+
self.log(f"PROGRESS {current}/{total}{suffix}")
|
|
72
|
+
|
|
73
|
+
def done(self, result: Any) -> None:
|
|
74
|
+
rendered = json.dumps(result, ensure_ascii=False, default=str) if result is not None else "null"
|
|
75
|
+
self.log(f"JOB DONE: {rendered}")
|
|
76
|
+
|
|
77
|
+
def error(self, message: str, traceback_text: str | None = None) -> None:
|
|
78
|
+
lines = [f"[{_timestamp()}] JOB ERROR: {message}"]
|
|
79
|
+
if traceback_text:
|
|
80
|
+
lines.extend(traceback_text.rstrip().splitlines())
|
|
81
|
+
self.root_logger._append(self.path, lines)
|
|
82
|
+
self.root_logger.error(f"{self.title}: {message}", traceback_text=traceback_text)
|
package/backend/jobs.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import traceback
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .file_logger import FileLogger, JobFileLogger
|
|
10
|
+
|
|
11
|
+
EmitFunc = Callable[[str, dict[str, Any]], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class JobContext:
|
|
16
|
+
job_id: str
|
|
17
|
+
_emit: EmitFunc
|
|
18
|
+
_logger: JobFileLogger
|
|
19
|
+
|
|
20
|
+
def log(self, message: str) -> None:
|
|
21
|
+
rendered = str(message)
|
|
22
|
+
self._logger.log(rendered)
|
|
23
|
+
self._emit(
|
|
24
|
+
"job.log",
|
|
25
|
+
{
|
|
26
|
+
"job_id": self.job_id,
|
|
27
|
+
"message": rendered,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def progress(self, current: int, total: int, message: str | None = None) -> None:
|
|
32
|
+
self._logger.progress(current, total, message)
|
|
33
|
+
payload: dict[str, Any] = {
|
|
34
|
+
"job_id": self.job_id,
|
|
35
|
+
"current": current,
|
|
36
|
+
"total": total,
|
|
37
|
+
}
|
|
38
|
+
if message:
|
|
39
|
+
payload["message"] = message
|
|
40
|
+
self._emit("job.progress", payload)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JobManager:
|
|
44
|
+
def __init__(self, emit: EmitFunc, file_logger: FileLogger | None = None) -> None:
|
|
45
|
+
self._emit = emit
|
|
46
|
+
self._file_logger = file_logger or FileLogger()
|
|
47
|
+
|
|
48
|
+
def start(self, title: str, handler: Callable[[JobContext], Any]) -> str:
|
|
49
|
+
job_id = uuid.uuid4().hex
|
|
50
|
+
job_logger = self._file_logger.create_job_logger(job_id, title)
|
|
51
|
+
self._emit("job.started", {"job_id": job_id, "title": title, "log_path": job_logger.rel_path})
|
|
52
|
+
|
|
53
|
+
def runner() -> None:
|
|
54
|
+
ctx = JobContext(job_id=job_id, _emit=self._emit, _logger=job_logger)
|
|
55
|
+
try:
|
|
56
|
+
result = handler(ctx)
|
|
57
|
+
job_logger.done(result)
|
|
58
|
+
self._emit("job.done", {"job_id": job_id, "result": result, "log_path": job_logger.rel_path})
|
|
59
|
+
except Exception as e:
|
|
60
|
+
message = str(e)
|
|
61
|
+
if len(message) > 1200:
|
|
62
|
+
message = message[:1200] + "…"
|
|
63
|
+
tb = traceback.format_exc()
|
|
64
|
+
job_logger.error(message, traceback_text=tb)
|
|
65
|
+
self._emit(
|
|
66
|
+
"job.error",
|
|
67
|
+
{
|
|
68
|
+
"job_id": job_id,
|
|
69
|
+
"error": message,
|
|
70
|
+
"traceback": tb,
|
|
71
|
+
"log_path": job_logger.rel_path,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
thread = threading.Thread(target=runner, daemon=True)
|
|
76
|
+
thread.start()
|
|
77
|
+
return job_id
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pluggable mail provider system.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from backend.mail import create_provider, Mailbox
|
|
6
|
+
|
|
7
|
+
provider = create_provider() # generic/default: "trickads"
|
|
8
|
+
mailbox = provider.generate() # create disposable mailbox
|
|
9
|
+
inbox = provider.inbox(mailbox) # fetch messages
|
|
10
|
+
provider.close()
|
|
11
|
+
|
|
12
|
+
slot_provider = create_slot_provider() # slots default: "boomlify"
|
|
13
|
+
|
|
14
|
+
Select provider via env var or name::
|
|
15
|
+
|
|
16
|
+
MAIL_PROVIDER=imap IMAP_HOST=imap.example.com python app.py
|
|
17
|
+
|
|
18
|
+
provider = create_provider("imap", host="imap.example.com")
|
|
19
|
+
|
|
20
|
+
Available providers:
|
|
21
|
+
trickads - trickadsagencyltd.com temp mail (generic/default)
|
|
22
|
+
boomlify - Boomlify temp mail API (slots default)
|
|
23
|
+
imap - any IMAP server (env: IMAP_HOST, IMAP_PORT, IMAP_SSL)
|
|
24
|
+
|
|
25
|
+
Custom providers:
|
|
26
|
+
Subclass ``MailProvider`` from ``backend.mail.base`` and implement
|
|
27
|
+
``generate()`` and ``inbox()``.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .base import Inbox, Mail, MailAuthError, Mailbox, MailError, MailProvider, MailServiceUnavailable
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Inbox",
|
|
38
|
+
"Mail",
|
|
39
|
+
"MailAuthError",
|
|
40
|
+
"MailError",
|
|
41
|
+
"MailProvider",
|
|
42
|
+
"MailServiceUnavailable",
|
|
43
|
+
"Mailbox",
|
|
44
|
+
"create_provider",
|
|
45
|
+
"create_provider_for_mailbox",
|
|
46
|
+
"create_slot_provider",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_BUILTIN_PROVIDERS = ("boomlify", "trickads", "imap")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_provider(name: str | None = None, **kwargs: Any) -> MailProvider:
|
|
53
|
+
"""Create a mail provider instance by name.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
name: Provider name. Defaults to ``MAIL_PROVIDER`` env var,
|
|
57
|
+
then falls back to ``"trickads"``.
|
|
58
|
+
**kwargs: Passed to provider constructor.
|
|
59
|
+
"""
|
|
60
|
+
provider_name = name or os.environ.get("MAIL_PROVIDER", "trickads")
|
|
61
|
+
|
|
62
|
+
if provider_name == "boomlify":
|
|
63
|
+
from .boomlify import BoomlifyProvider
|
|
64
|
+
return BoomlifyProvider(**kwargs)
|
|
65
|
+
|
|
66
|
+
if provider_name == "trickads":
|
|
67
|
+
from .trickads import TrickAdsProvider
|
|
68
|
+
return TrickAdsProvider(**kwargs)
|
|
69
|
+
|
|
70
|
+
if provider_name == "imap":
|
|
71
|
+
from .imap import IMAPProvider
|
|
72
|
+
return IMAPProvider(**kwargs)
|
|
73
|
+
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Unknown mail provider: {provider_name!r}. "
|
|
76
|
+
f"Available: {', '.join(_BUILTIN_PROVIDERS)}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_slot_provider(name: str | None = None, **kwargs: Any) -> MailProvider:
|
|
81
|
+
"""Create provider for worker/slot mailboxes.
|
|
82
|
+
|
|
83
|
+
Defaults to ``SLOT_MAIL_PROVIDER``, then falls back to ``"boomlify"``.
|
|
84
|
+
"""
|
|
85
|
+
provider_name = name or os.environ.get("SLOT_MAIL_PROVIDER", "boomlify")
|
|
86
|
+
return create_provider(provider_name, **kwargs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_provider_for_mailbox(mailbox: Mailbox, **kwargs: Any) -> MailProvider:
|
|
90
|
+
"""Pick provider based on stored mailbox credentials.
|
|
91
|
+
|
|
92
|
+
Boomlify mailboxes store their email id inside ``Mailbox.password`` as
|
|
93
|
+
``boomlify:<uuid>``. Everything else falls back to the generic provider.
|
|
94
|
+
"""
|
|
95
|
+
password = mailbox.password.strip()
|
|
96
|
+
if password.startswith("boomlify:"):
|
|
97
|
+
return create_provider("boomlify", **kwargs)
|
|
98
|
+
return create_provider(**kwargs)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailError(Exception):
|
|
8
|
+
"""Base exception for all mail provider errors."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MailAuthError(MailError):
|
|
12
|
+
"""Authentication failed (wrong password, expired token, etc.)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MailServiceUnavailable(MailError):
|
|
16
|
+
"""Mail service is temporarily unavailable."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Mailbox:
|
|
21
|
+
"""Credentials for a single mailbox."""
|
|
22
|
+
email: str
|
|
23
|
+
password: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Mail:
|
|
28
|
+
"""A single email message."""
|
|
29
|
+
id: str
|
|
30
|
+
sender: str
|
|
31
|
+
subject: str
|
|
32
|
+
body: str
|
|
33
|
+
date: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Inbox:
|
|
38
|
+
"""Inbox contents for a mailbox."""
|
|
39
|
+
email: str
|
|
40
|
+
messages: list[Mail]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MailProvider(ABC):
|
|
44
|
+
"""Abstract base class for mail providers.
|
|
45
|
+
|
|
46
|
+
To create a custom provider, subclass this and implement
|
|
47
|
+
``generate()`` and ``inbox()``.
|
|
48
|
+
|
|
49
|
+
Minimal example::
|
|
50
|
+
|
|
51
|
+
from backend.mail.base import MailProvider, Mailbox, Inbox
|
|
52
|
+
|
|
53
|
+
class MyProvider(MailProvider):
|
|
54
|
+
name = "my_provider"
|
|
55
|
+
|
|
56
|
+
def generate(self) -> Mailbox:
|
|
57
|
+
# create a new disposable mailbox
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def inbox(self, mailbox: Mailbox) -> Inbox:
|
|
61
|
+
# fetch messages for the mailbox
|
|
62
|
+
...
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
name: str = "base"
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def generate(self) -> Mailbox:
|
|
69
|
+
"""Create a new disposable mailbox.
|
|
70
|
+
|
|
71
|
+
Returns a ``Mailbox`` with email and password (or token)
|
|
72
|
+
that can later be passed to ``inbox()``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def inbox(self, mailbox: Mailbox) -> Inbox:
|
|
77
|
+
"""Fetch current messages for a mailbox."""
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""Release resources (HTTP sessions, connections, etc.)."""
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> MailProvider:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *exc: object) -> None:
|
|
86
|
+
self.close()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Mail provider: Boomlify Temp Mail API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .base import (
|
|
11
|
+
Inbox,
|
|
12
|
+
Mail,
|
|
13
|
+
MailAuthError,
|
|
14
|
+
Mailbox,
|
|
15
|
+
MailError,
|
|
16
|
+
MailProvider,
|
|
17
|
+
MailServiceUnavailable,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://v1.boomlify.com"
|
|
21
|
+
DEFAULT_TIME = "permanent"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unwrap_payload(data: Any) -> Any:
|
|
25
|
+
if isinstance(data, dict):
|
|
26
|
+
for key in ("data", "result", "email", "message", "messages"):
|
|
27
|
+
value = data.get(key)
|
|
28
|
+
if value is not None:
|
|
29
|
+
return value
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
34
|
+
return value if isinstance(value, dict) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _as_list(value: Any) -> list[Any]:
|
|
38
|
+
return value if isinstance(value, list) else []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pick_first_str(obj: dict[str, Any], keys: tuple[str, ...]) -> str:
|
|
42
|
+
for key in keys:
|
|
43
|
+
value = obj.get(key)
|
|
44
|
+
if isinstance(value, str) and value.strip():
|
|
45
|
+
return value.strip()
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BoomlifyProvider(MailProvider):
|
|
50
|
+
"""Temporary email via Boomlify API."""
|
|
51
|
+
|
|
52
|
+
name = "boomlify"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
base_url: str | None = None,
|
|
58
|
+
mailbox_time: str | None = None,
|
|
59
|
+
domain: str | None = None,
|
|
60
|
+
session: requests.Session | None = None,
|
|
61
|
+
**_kwargs: Any,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.api_key = api_key or os.environ.get("BOOMLIFY_API_KEY", "").strip()
|
|
64
|
+
if not self.api_key:
|
|
65
|
+
raise MailAuthError("BOOMLIFY_API_KEY is required for Boomlify mail provider")
|
|
66
|
+
|
|
67
|
+
self.base_url = (base_url or os.environ.get("BOOMLIFY_BASE_URL", DEFAULT_BASE_URL)).rstrip("/")
|
|
68
|
+
self.mailbox_time = mailbox_time or os.environ.get("BOOMLIFY_TIME", DEFAULT_TIME)
|
|
69
|
+
self.domain = domain or os.environ.get("BOOMLIFY_DOMAIN", "").strip()
|
|
70
|
+
self._external = session is not None
|
|
71
|
+
self._session = session or requests.Session()
|
|
72
|
+
self._session.headers.update({
|
|
73
|
+
"X-API-Key": self.api_key,
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
"User-Agent": "izTeamSlots/1.0",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
def _request(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
endpoint: str,
|
|
83
|
+
*,
|
|
84
|
+
json_body: dict[str, Any] | None = None,
|
|
85
|
+
query: dict[str, Any] | None = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
url = f"{self.base_url}{endpoint}"
|
|
88
|
+
if query:
|
|
89
|
+
clean_query = {k: v for k, v in query.items() if v is not None and v != ""}
|
|
90
|
+
if clean_query:
|
|
91
|
+
url = f"{url}?{urlencode(clean_query)}"
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
resp = self._session.request(method, url, json=json_body, timeout=30)
|
|
95
|
+
except requests.RequestException as e:
|
|
96
|
+
raise MailServiceUnavailable(f"Connection error: {e}") from e
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
data = resp.json()
|
|
100
|
+
except ValueError:
|
|
101
|
+
data = {"error": resp.text.strip()[:500]}
|
|
102
|
+
|
|
103
|
+
if resp.status_code in (401, 403):
|
|
104
|
+
raise MailAuthError(f"[{resp.status_code}] {data}")
|
|
105
|
+
if resp.status_code in (429, 500, 502, 503, 504):
|
|
106
|
+
raise MailServiceUnavailable(f"[{resp.status_code}] {data}")
|
|
107
|
+
if resp.status_code >= 400:
|
|
108
|
+
raise MailError(f"[{resp.status_code}] {data}")
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
def _extract_mailbox_id(self, mailbox: Mailbox) -> str:
|
|
112
|
+
password = mailbox.password.strip()
|
|
113
|
+
if password.startswith("boomlify:"):
|
|
114
|
+
return password.split(":", 1)[1].strip()
|
|
115
|
+
if password:
|
|
116
|
+
return password
|
|
117
|
+
raise MailError(
|
|
118
|
+
f"Mailbox {mailbox.email} does not contain Boomlify mailbox id. "
|
|
119
|
+
"Expected Mailbox.password to store the Boomlify email id."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def generate(self) -> Mailbox:
|
|
123
|
+
payload: dict[str, Any] = {"time": self.mailbox_time}
|
|
124
|
+
if self.domain:
|
|
125
|
+
payload["domain"] = self.domain
|
|
126
|
+
|
|
127
|
+
raw = self._request("POST", "/api/v1/emails/create", json_body=payload)
|
|
128
|
+
email_data = _as_dict(_unwrap_payload(raw))
|
|
129
|
+
email = _pick_first_str(email_data, ("email", "address"))
|
|
130
|
+
email_id = _pick_first_str(email_data, ("id", "email_id", "uuid"))
|
|
131
|
+
|
|
132
|
+
if not email or not email_id:
|
|
133
|
+
raise MailError(f"Unexpected Boomlify create response: {raw}")
|
|
134
|
+
|
|
135
|
+
return Mailbox(email=email, password=f"boomlify:{email_id}")
|
|
136
|
+
|
|
137
|
+
def inbox(self, mailbox: Mailbox) -> Inbox:
|
|
138
|
+
email_id = self._extract_mailbox_id(mailbox)
|
|
139
|
+
raw = self._request(
|
|
140
|
+
"GET",
|
|
141
|
+
f"/api/v1/emails/{email_id}/messages",
|
|
142
|
+
query={"limit": 100, "offset": 0},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
messages_raw: list[Any] = []
|
|
146
|
+
if isinstance(raw, dict):
|
|
147
|
+
messages_raw = _as_list(raw.get("messages")) or _as_list(raw.get("items"))
|
|
148
|
+
if not messages_raw:
|
|
149
|
+
payload = _unwrap_payload(raw)
|
|
150
|
+
messages_raw = _as_list(payload)
|
|
151
|
+
|
|
152
|
+
messages: list[Mail] = []
|
|
153
|
+
for item in messages_raw:
|
|
154
|
+
msg = _as_dict(item)
|
|
155
|
+
sender_value = msg.get("from")
|
|
156
|
+
sender = _pick_first_str(msg, ("from_email", "from_name"))
|
|
157
|
+
if not sender:
|
|
158
|
+
if isinstance(sender_value, dict):
|
|
159
|
+
sender = _pick_first_str(sender_value, ("email", "address", "name"))
|
|
160
|
+
elif isinstance(sender_value, str):
|
|
161
|
+
sender = sender_value.strip()
|
|
162
|
+
|
|
163
|
+
body = _pick_first_str(msg, ("body_html", "html", "body_text", "body", "text", "content"))
|
|
164
|
+
messages.append(
|
|
165
|
+
Mail(
|
|
166
|
+
id=_pick_first_str(msg, ("id", "message_id", "uuid")) or body[:32],
|
|
167
|
+
sender=sender,
|
|
168
|
+
subject=_pick_first_str(msg, ("subject", "title")),
|
|
169
|
+
body=body,
|
|
170
|
+
date=_pick_first_str(msg, ("date", "created_at", "received_at", "timestamp")),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return Inbox(email=mailbox.email, messages=messages)
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
if not self._external:
|
|
178
|
+
self._session.close()
|