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,221 @@
|
|
|
1
|
+
"""IMAP mail provider.
|
|
2
|
+
|
|
3
|
+
Connects to any IMAP server to read mail. Useful when you have
|
|
4
|
+
your own domain or existing email accounts.
|
|
5
|
+
|
|
6
|
+
Configuration (env vars or constructor kwargs):
|
|
7
|
+
IMAP_HOST - IMAP server hostname (required)
|
|
8
|
+
IMAP_PORT - port (default: 993 for SSL, 143 for plain)
|
|
9
|
+
IMAP_SSL - "1" for SSL, "0" for STARTTLS/plain (default: "1")
|
|
10
|
+
IMAP_FOLDER - mailbox folder to read (default: "INBOX")
|
|
11
|
+
IMAP_MAX_MESSAGES - max messages to fetch (default: 50, newest first)
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
provider = create_provider("imap", host="imap.example.com")
|
|
16
|
+
inbox = provider.inbox(Mailbox(email="user@example.com", password="pass"))
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import email as email_lib
|
|
21
|
+
import imaplib
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from email.header import decode_header as _decode_header
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .base import (
|
|
28
|
+
Inbox,
|
|
29
|
+
Mail,
|
|
30
|
+
MailAuthError,
|
|
31
|
+
Mailbox,
|
|
32
|
+
MailError,
|
|
33
|
+
MailProvider,
|
|
34
|
+
MailServiceUnavailable,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
38
|
+
_MULTI_SPACE_RE = re.compile(r"[ \t]+")
|
|
39
|
+
_MULTI_NL_RE = re.compile(r"\n{3,}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _html_to_text(html: str) -> str:
|
|
43
|
+
"""Rough HTML → plain text."""
|
|
44
|
+
text = html.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
|
45
|
+
text = text.replace("</p>", "\n\n").replace("</div>", "\n")
|
|
46
|
+
text = _HTML_TAG_RE.sub("", text)
|
|
47
|
+
text = _MULTI_SPACE_RE.sub(" ", text)
|
|
48
|
+
text = _MULTI_NL_RE.sub("\n\n", text)
|
|
49
|
+
return text.strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decode_header_value(raw: str | None) -> str:
|
|
53
|
+
if not raw:
|
|
54
|
+
return ""
|
|
55
|
+
parts = _decode_header(raw)
|
|
56
|
+
decoded: list[str] = []
|
|
57
|
+
for part, charset in parts:
|
|
58
|
+
if isinstance(part, bytes):
|
|
59
|
+
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
|
60
|
+
else:
|
|
61
|
+
decoded.append(part)
|
|
62
|
+
return "".join(decoded)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_body(msg: email_lib.message.Message) -> str: # type: ignore[type-arg]
|
|
66
|
+
"""Extract text body from an email message, preferring plain text."""
|
|
67
|
+
if not msg.is_multipart():
|
|
68
|
+
payload = msg.get_payload(decode=True)
|
|
69
|
+
if not payload:
|
|
70
|
+
return ""
|
|
71
|
+
text = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
|
72
|
+
if msg.get_content_type() == "text/html":
|
|
73
|
+
return _html_to_text(text)
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
plain_parts: list[str] = []
|
|
77
|
+
html_parts: list[str] = []
|
|
78
|
+
|
|
79
|
+
for part in msg.walk():
|
|
80
|
+
ct = part.get_content_type()
|
|
81
|
+
if ct == "multipart/alternative" or ct.startswith("multipart/"):
|
|
82
|
+
continue
|
|
83
|
+
payload = part.get_payload(decode=True)
|
|
84
|
+
if not payload:
|
|
85
|
+
continue
|
|
86
|
+
charset = part.get_content_charset() or "utf-8"
|
|
87
|
+
text = payload.decode(charset, errors="replace")
|
|
88
|
+
if ct == "text/plain":
|
|
89
|
+
plain_parts.append(text)
|
|
90
|
+
elif ct == "text/html":
|
|
91
|
+
html_parts.append(text)
|
|
92
|
+
|
|
93
|
+
if plain_parts:
|
|
94
|
+
return "\n".join(plain_parts)
|
|
95
|
+
if html_parts:
|
|
96
|
+
return _html_to_text("\n".join(html_parts))
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class IMAPProvider(MailProvider):
|
|
101
|
+
"""IMAP mail provider — reads mail from any IMAP server."""
|
|
102
|
+
|
|
103
|
+
name = "imap"
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
host: str | None = None,
|
|
108
|
+
port: int | None = None,
|
|
109
|
+
use_ssl: bool | None = None,
|
|
110
|
+
folder: str | None = None,
|
|
111
|
+
max_messages: int | None = None,
|
|
112
|
+
timeout: int = 30,
|
|
113
|
+
**_kwargs: Any,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.host = host or os.environ.get("IMAP_HOST", "")
|
|
116
|
+
if not self.host:
|
|
117
|
+
raise MailError("IMAP_HOST is required (env var or host= kwarg)")
|
|
118
|
+
|
|
119
|
+
ssl_env = os.environ.get("IMAP_SSL", "1")
|
|
120
|
+
self.use_ssl = use_ssl if use_ssl is not None else (ssl_env == "1")
|
|
121
|
+
|
|
122
|
+
default_port = 993 if self.use_ssl else 143
|
|
123
|
+
self.port = port or int(os.environ.get("IMAP_PORT", str(default_port)))
|
|
124
|
+
|
|
125
|
+
self.folder = folder or os.environ.get("IMAP_FOLDER", "INBOX")
|
|
126
|
+
self.max_messages = max_messages or int(os.environ.get("IMAP_MAX_MESSAGES", "50"))
|
|
127
|
+
self.timeout = timeout
|
|
128
|
+
|
|
129
|
+
def _connect(self, mailbox: Mailbox) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
|
|
130
|
+
"""Open an IMAP connection and authenticate."""
|
|
131
|
+
try:
|
|
132
|
+
if self.use_ssl:
|
|
133
|
+
conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout)
|
|
134
|
+
else:
|
|
135
|
+
conn = imaplib.IMAP4(self.host, self.port, timeout=self.timeout)
|
|
136
|
+
except (OSError, imaplib.IMAP4.error) as e:
|
|
137
|
+
raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}: {e}") from e
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
conn.login(mailbox.email, mailbox.password)
|
|
141
|
+
except imaplib.IMAP4.error as e:
|
|
142
|
+
err_msg = str(e)
|
|
143
|
+
conn.logout()
|
|
144
|
+
raise MailAuthError(f"IMAP login failed for {mailbox.email}: {err_msg}") from e
|
|
145
|
+
|
|
146
|
+
return conn
|
|
147
|
+
|
|
148
|
+
def generate(self) -> Mailbox:
|
|
149
|
+
"""IMAP cannot create mailboxes automatically.
|
|
150
|
+
|
|
151
|
+
If you need auto-generation, consider subclassing and
|
|
152
|
+
providing a pool of pre-created accounts.
|
|
153
|
+
"""
|
|
154
|
+
raise NotImplementedError(
|
|
155
|
+
"IMAP provider does not support generate(). "
|
|
156
|
+
"Create mailboxes externally and use Mailbox(email=..., password=...)."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def inbox(self, mailbox: Mailbox) -> Inbox:
|
|
160
|
+
"""Fetch messages from IMAP server."""
|
|
161
|
+
conn = self._connect(mailbox)
|
|
162
|
+
messages: list[Mail] = []
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
status, _ = conn.select(self.folder, readonly=True)
|
|
166
|
+
if status != "OK":
|
|
167
|
+
raise MailError(f"Cannot select folder {self.folder!r}")
|
|
168
|
+
|
|
169
|
+
_, msg_nums_raw = conn.search(None, "ALL")
|
|
170
|
+
all_nums = msg_nums_raw[0].split() if msg_nums_raw[0] else []
|
|
171
|
+
|
|
172
|
+
# Newest first, limited
|
|
173
|
+
fetch_nums = all_nums[-self.max_messages:]
|
|
174
|
+
fetch_nums.reverse()
|
|
175
|
+
|
|
176
|
+
if not fetch_nums:
|
|
177
|
+
return Inbox(email=mailbox.email, messages=[])
|
|
178
|
+
|
|
179
|
+
# Batch fetch for efficiency
|
|
180
|
+
num_range = ",".join(n.decode() for n in fetch_nums)
|
|
181
|
+
_, fetch_data = conn.fetch(num_range, "(RFC822)")
|
|
182
|
+
|
|
183
|
+
for item in fetch_data:
|
|
184
|
+
if not isinstance(item, tuple) or len(item) < 2:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
raw_bytes = item[1]
|
|
188
|
+
if not isinstance(raw_bytes, bytes):
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
msg = email_lib.message_from_bytes(raw_bytes)
|
|
192
|
+
|
|
193
|
+
msg_id = msg.get("Message-ID", "")
|
|
194
|
+
if not msg_id:
|
|
195
|
+
# Use IMAP sequence number from the fetch response
|
|
196
|
+
header = item[0]
|
|
197
|
+
if isinstance(header, bytes):
|
|
198
|
+
seq_match = re.match(rb"(\d+)", header)
|
|
199
|
+
msg_id = seq_match.group(1).decode() if seq_match else ""
|
|
200
|
+
|
|
201
|
+
messages.append(Mail(
|
|
202
|
+
id=msg_id,
|
|
203
|
+
sender=_decode_header_value(msg.get("From", "")),
|
|
204
|
+
subject=_decode_header_value(msg.get("Subject", "")),
|
|
205
|
+
body=_extract_body(msg),
|
|
206
|
+
date=msg.get("Date", ""),
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
except (MailError, MailAuthError):
|
|
210
|
+
raise
|
|
211
|
+
except imaplib.IMAP4.error as e:
|
|
212
|
+
raise MailError(f"IMAP error: {e}") from e
|
|
213
|
+
except Exception as e:
|
|
214
|
+
raise MailError(f"Failed to read mail: {e}") from e
|
|
215
|
+
finally:
|
|
216
|
+
try:
|
|
217
|
+
conn.logout()
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
return Inbox(email=mailbox.email, messages=messages)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Mail provider: trickadsagencyltd.com temporary email service."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
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
|
+
BASE_URL = "https://www.trickadsagencyltd.com"
|
|
21
|
+
|
|
22
|
+
HEADERS = {
|
|
23
|
+
"accept": "*/*",
|
|
24
|
+
"cache-control": "no-cache",
|
|
25
|
+
"pragma": "no-cache",
|
|
26
|
+
"origin": BASE_URL,
|
|
27
|
+
"referer": f"{BASE_URL}/tepmail",
|
|
28
|
+
"user-agent": (
|
|
29
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
30
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
31
|
+
"Chrome/142.0.0.0 Safari/537.36"
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_html_comment(text: str) -> str:
|
|
37
|
+
m = re.search(r"<!--\s*([\s\S]*?)\s*-->", text)
|
|
38
|
+
if not m:
|
|
39
|
+
return ""
|
|
40
|
+
return m.group(1).strip().splitlines()[0][:300]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_error_summary(text: str) -> str:
|
|
44
|
+
candidate = _extract_html_comment(text)
|
|
45
|
+
if candidate:
|
|
46
|
+
return candidate
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(text)
|
|
49
|
+
if isinstance(payload, dict):
|
|
50
|
+
for key in ("message", "error", "detail"):
|
|
51
|
+
value = payload.get(key)
|
|
52
|
+
if isinstance(value, str) and value.strip():
|
|
53
|
+
return value.strip()[:300]
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return text.strip().replace("\n", " ")[:300]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TrickAdsProvider(MailProvider):
|
|
60
|
+
"""Temporary email via trickadsagencyltd.com/tepmail."""
|
|
61
|
+
|
|
62
|
+
name = "trickads"
|
|
63
|
+
|
|
64
|
+
def __init__(self, session: requests.Session | None = None) -> None:
|
|
65
|
+
self._external = session is not None
|
|
66
|
+
self._session = session or requests.Session()
|
|
67
|
+
self._session.headers.update(HEADERS)
|
|
68
|
+
|
|
69
|
+
def _request(
|
|
70
|
+
self,
|
|
71
|
+
endpoint: str,
|
|
72
|
+
json_body: dict[str, Any] | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
try:
|
|
75
|
+
resp = self._session.post(f"{BASE_URL}{endpoint}", json=json_body)
|
|
76
|
+
if resp.status_code != 200:
|
|
77
|
+
body = resp.text or ""
|
|
78
|
+
summary = _extract_error_summary(body)
|
|
79
|
+
if resp.status_code == 401:
|
|
80
|
+
raise MailAuthError(f"[{resp.status_code}] {summary}")
|
|
81
|
+
if resp.status_code >= 500:
|
|
82
|
+
raise MailServiceUnavailable(f"[{resp.status_code}] {summary}")
|
|
83
|
+
raise MailError(f"[{resp.status_code}] {summary}")
|
|
84
|
+
data: dict[str, Any] = resp.json()
|
|
85
|
+
except requests.RequestException as e:
|
|
86
|
+
raise MailServiceUnavailable(f"Connection error: {e}") from e
|
|
87
|
+
|
|
88
|
+
if data.get("status") != "success":
|
|
89
|
+
msg = data.get("message", "")
|
|
90
|
+
code = data.get("code", 0)
|
|
91
|
+
if code == 401 or "password" in msg.lower() or "unauthorized" in str(data.get("status", "")).lower():
|
|
92
|
+
raise MailAuthError(f"API: {data}")
|
|
93
|
+
raise MailError(f"API: {data}")
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
def generate(self) -> Mailbox:
|
|
97
|
+
data = self._request("/tepmail/generate")
|
|
98
|
+
return Mailbox(email=data["email"], password=data["password"])
|
|
99
|
+
|
|
100
|
+
def inbox(self, mailbox: Mailbox) -> Inbox:
|
|
101
|
+
data = self._request(
|
|
102
|
+
"/tepmail/inbox",
|
|
103
|
+
json_body={"email": mailbox.email, "password": mailbox.password},
|
|
104
|
+
)
|
|
105
|
+
return Inbox(
|
|
106
|
+
email=data["email"],
|
|
107
|
+
messages=[
|
|
108
|
+
Mail(
|
|
109
|
+
id=m["id"],
|
|
110
|
+
sender=m.get("from", ""),
|
|
111
|
+
subject=m.get("subject", ""),
|
|
112
|
+
body=m.get("body", ""),
|
|
113
|
+
date=m.get("date", ""),
|
|
114
|
+
)
|
|
115
|
+
for m in data.get("messages", [])
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
if not self._external:
|
|
121
|
+
self._session.close()
|