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.
@@ -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)
@@ -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()