openmail 0.1.5__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.
- openmail/__init__.py +6 -0
- openmail/assistants/__init__.py +35 -0
- openmail/assistants/classify_emails.py +83 -0
- openmail/assistants/compose_email.py +43 -0
- openmail/assistants/detect_phishing_for_email.py +61 -0
- openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
- openmail/assistants/extract_tasks_from_emails.py +126 -0
- openmail/assistants/generate_follow_up_for_email.py +54 -0
- openmail/assistants/natural_language_query.py +699 -0
- openmail/assistants/prioritize_emails.py +89 -0
- openmail/assistants/reply.py +58 -0
- openmail/assistants/reply_suggestions.py +46 -0
- openmail/assistants/rewrite_email.py +50 -0
- openmail/assistants/summarize_attachments_for_email.py +101 -0
- openmail/assistants/summarize_thread_emails.py +55 -0
- openmail/assistants/summary.py +44 -0
- openmail/assistants/summary_multi.py +57 -0
- openmail/assistants/translate_email.py +54 -0
- openmail/auth/__init__.py +6 -0
- openmail/auth/base.py +34 -0
- openmail/auth/no_auth.py +19 -0
- openmail/auth/oauth2.py +58 -0
- openmail/auth/password.py +26 -0
- openmail/config.py +26 -0
- openmail/email_assistant.py +418 -0
- openmail/email_manager.py +777 -0
- openmail/email_query.py +279 -0
- openmail/errors.py +16 -0
- openmail/imap/__init__.py +5 -0
- openmail/imap/attachment_parts.py +55 -0
- openmail/imap/bodystructure.py +296 -0
- openmail/imap/client.py +806 -0
- openmail/imap/fetch_response.py +115 -0
- openmail/imap/inline_cid.py +106 -0
- openmail/imap/pagination.py +16 -0
- openmail/imap/parser.py +298 -0
- openmail/imap/query.py +233 -0
- openmail/llm/__init__.py +3 -0
- openmail/llm/claude.py +35 -0
- openmail/llm/costs.py +108 -0
- openmail/llm/gemini.py +34 -0
- openmail/llm/gpt.py +33 -0
- openmail/llm/groq.py +36 -0
- openmail/llm/model.py +126 -0
- openmail/llm/xai.py +35 -0
- openmail/logger.py +20 -0
- openmail/models/__init__.py +20 -0
- openmail/models/attachment.py +128 -0
- openmail/models/message.py +113 -0
- openmail/models/subscription.py +45 -0
- openmail/models/task.py +24 -0
- openmail/py.typed +0 -0
- openmail/smtp/__init__.py +7 -0
- openmail/smtp/builder.py +41 -0
- openmail/smtp/client.py +218 -0
- openmail/smtp/templates.py +16 -0
- openmail/subscription/__init__.py +7 -0
- openmail/subscription/detector.py +58 -0
- openmail/subscription/parser.py +32 -0
- openmail/subscription/service.py +237 -0
- openmail/types.py +30 -0
- openmail/utils/__init__.py +39 -0
- openmail/utils/utils.py +295 -0
- openmail-0.1.5.dist-info/METADATA +180 -0
- openmail-0.1.5.dist-info/RECORD +67 -0
- openmail-0.1.5.dist-info/WHEEL +4 -0
- openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from openmail.models import UnsubscribeMethod
|
|
7
|
+
|
|
8
|
+
_LIST_UNSUB_RE = re.compile(r"<\s*([^>]+?)\s*>")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_list_unsubscribe(value: str) -> List[UnsubscribeMethod]:
|
|
12
|
+
"""
|
|
13
|
+
Parses:
|
|
14
|
+
<mailto:unsubscribe@x.com>, <https://x.com/unsub>
|
|
15
|
+
"""
|
|
16
|
+
if not value:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
methods: List[UnsubscribeMethod] = []
|
|
20
|
+
|
|
21
|
+
for item in _LIST_UNSUB_RE.findall(value):
|
|
22
|
+
item = item.strip()
|
|
23
|
+
|
|
24
|
+
if item.lower().startswith("mailto:"):
|
|
25
|
+
addr = item[len("mailto:") :].split("?", 1)[0].strip()
|
|
26
|
+
if addr:
|
|
27
|
+
methods.append(UnsubscribeMethod("mailto", addr))
|
|
28
|
+
|
|
29
|
+
elif item.lower().startswith(("http://", "https://")):
|
|
30
|
+
methods.append(UnsubscribeMethod("http", item))
|
|
31
|
+
|
|
32
|
+
return methods
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from email.message import EmailMessage as PyEmailMessage
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from openmail.models import (
|
|
9
|
+
UnsubscribeActionResult,
|
|
10
|
+
UnsubscribeCandidate,
|
|
11
|
+
UnsubscribeMethod,
|
|
12
|
+
)
|
|
13
|
+
from openmail.smtp import SMTPClient
|
|
14
|
+
from openmail.types import SendResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _http_unsubscribe_flow(url: str, timeout: int = 10) -> tuple[bool, str]:
|
|
18
|
+
"""
|
|
19
|
+
Best-effort HTTP unsubscribe:
|
|
20
|
+
|
|
21
|
+
1. GET the URL
|
|
22
|
+
2. If HTML, try to:
|
|
23
|
+
- find and submit an 'unsubscribe' / 'opt out' form
|
|
24
|
+
- otherwise, follow an 'unsubscribe' / 'opt out' link/button
|
|
25
|
+
3. Fall back to treating the initial GET as the unsubscribe action.
|
|
26
|
+
"""
|
|
27
|
+
session = requests.Session()
|
|
28
|
+
|
|
29
|
+
r = session.get(url, timeout=timeout, allow_redirects=True)
|
|
30
|
+
status = r.status_code
|
|
31
|
+
|
|
32
|
+
content_type = r.headers.get("Content-Type", "")
|
|
33
|
+
if "html" not in content_type.lower():
|
|
34
|
+
ok = 200 <= status < 300
|
|
35
|
+
return ok, f"GET {r.url} -> HTTP {status} (non-HTML content)"
|
|
36
|
+
|
|
37
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
38
|
+
|
|
39
|
+
def _is_unsub_text(s: str) -> bool:
|
|
40
|
+
s = s.lower()
|
|
41
|
+
return "unsubscribe" in s or "opt out" in s or "opt-out" in s or "optout" in s
|
|
42
|
+
|
|
43
|
+
unsub_form = None
|
|
44
|
+
for form in soup.find_all("form"):
|
|
45
|
+
text_parts = [
|
|
46
|
+
form.get("id", ""),
|
|
47
|
+
form.get("name", ""),
|
|
48
|
+
form.get("action", ""),
|
|
49
|
+
form.get_text(" ", strip=True),
|
|
50
|
+
]
|
|
51
|
+
combined = " ".join(text_parts)
|
|
52
|
+
if _is_unsub_text(combined):
|
|
53
|
+
unsub_form = form
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
if unsub_form is not None:
|
|
57
|
+
action_attr = unsub_form.get("action") or ""
|
|
58
|
+
action_url = urljoin(r.url, action_attr) if action_attr else r.url
|
|
59
|
+
method = (unsub_form.get("method") or "post").lower()
|
|
60
|
+
|
|
61
|
+
data: dict[str, str] = {}
|
|
62
|
+
for inp in unsub_form.find_all("input"):
|
|
63
|
+
name = inp.get("name")
|
|
64
|
+
if not name:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
itype = (inp.get("type") or "text").lower()
|
|
68
|
+
value = inp.get("value", "")
|
|
69
|
+
|
|
70
|
+
if itype in ("checkbox", "radio"):
|
|
71
|
+
# Only send checked ones
|
|
72
|
+
if inp.has_attr("checked"):
|
|
73
|
+
data[name] = value or "on"
|
|
74
|
+
else:
|
|
75
|
+
data[name] = value
|
|
76
|
+
|
|
77
|
+
for sel in unsub_form.find_all("select"):
|
|
78
|
+
name = sel.get("name")
|
|
79
|
+
if not name:
|
|
80
|
+
continue
|
|
81
|
+
selected = sel.find("option", selected=True) or sel.find("option")
|
|
82
|
+
if selected and selected.get("value") is not None:
|
|
83
|
+
data[name] = selected.get("value")
|
|
84
|
+
|
|
85
|
+
if method == "post":
|
|
86
|
+
r2 = session.post(action_url, data=data, timeout=timeout)
|
|
87
|
+
else:
|
|
88
|
+
r2 = session.get(action_url, params=data, timeout=timeout)
|
|
89
|
+
|
|
90
|
+
soup2 = BeautifulSoup(r2.text, "html.parser")
|
|
91
|
+
text = soup2.get_text(" ", strip=True)
|
|
92
|
+
text = " ".join(text.split())
|
|
93
|
+
|
|
94
|
+
ok = 200 <= r2.status_code < 300
|
|
95
|
+
detail = (
|
|
96
|
+
f"{method.upper()} {action_url} -> HTTP {r2.status_code} "
|
|
97
|
+
f"(submitted unsubscribe form). "
|
|
98
|
+
f"Final confirmation page body: \n {text[:2000]}"
|
|
99
|
+
)
|
|
100
|
+
return ok, detail
|
|
101
|
+
|
|
102
|
+
unsub_link = None
|
|
103
|
+
for tag in soup.find_all(["a", "button"]):
|
|
104
|
+
text = tag.get_text(" ", strip=True) or ""
|
|
105
|
+
if _is_unsub_text(text):
|
|
106
|
+
unsub_link = tag
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
if unsub_link is not None and unsub_link.name == "a":
|
|
110
|
+
href = unsub_link.get("href")
|
|
111
|
+
if href:
|
|
112
|
+
click_url = urljoin(r.url, href)
|
|
113
|
+
r2 = session.get(click_url, timeout=timeout, allow_redirects=True)
|
|
114
|
+
ok = 200 <= r2.status_code < 300
|
|
115
|
+
detail = f"GET {click_url} -> HTTP {r2.status_code} (clicked unsubscribe link)"
|
|
116
|
+
return ok, detail
|
|
117
|
+
|
|
118
|
+
ok = 200 <= status < 300
|
|
119
|
+
return ok, f"GET {r.url} -> HTTP {status} (no explicit form/link found)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SubscriptionService:
|
|
123
|
+
def __init__(self, smtp: SMTPClient):
|
|
124
|
+
self.smtp = smtp
|
|
125
|
+
|
|
126
|
+
def unsubscribe(
|
|
127
|
+
self,
|
|
128
|
+
candidates: List[UnsubscribeCandidate],
|
|
129
|
+
*,
|
|
130
|
+
prefer: str = "mailto",
|
|
131
|
+
from_addr: Optional[str] = None,
|
|
132
|
+
) -> Dict[str, List[UnsubscribeActionResult]]:
|
|
133
|
+
"""
|
|
134
|
+
Executes unsubscribe actions.
|
|
135
|
+
|
|
136
|
+
Behavior:
|
|
137
|
+
- mailto: Sends an email to the unsubscribe address.
|
|
138
|
+
- http: Performs an HTTP GET request to the unsubscribe URL.
|
|
139
|
+
"""
|
|
140
|
+
results: Dict[str, List[UnsubscribeActionResult]] = {
|
|
141
|
+
"sent": [],
|
|
142
|
+
"http": [],
|
|
143
|
+
"skipped": [],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for cand in candidates:
|
|
147
|
+
method = _choose_method(cand.methods, prefer)
|
|
148
|
+
if not method:
|
|
149
|
+
results["skipped"].append(
|
|
150
|
+
UnsubscribeActionResult(
|
|
151
|
+
ref=cand.ref,
|
|
152
|
+
method=None,
|
|
153
|
+
sent=False,
|
|
154
|
+
note="No supported unsubscribe method",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if method.kind == "mailto":
|
|
160
|
+
msg = PyEmailMessage()
|
|
161
|
+
msg["To"] = method.value
|
|
162
|
+
msg["Subject"] = "Unsubscribe"
|
|
163
|
+
if from_addr:
|
|
164
|
+
msg["From"] = from_addr
|
|
165
|
+
msg.set_content("Please unsubscribe me.")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
send_res = self.smtp.send(msg, [method.value])
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
send_res = SendResult(ok=False, detail=f"error: {exc!r}")
|
|
171
|
+
|
|
172
|
+
results["sent"].append(
|
|
173
|
+
UnsubscribeActionResult(
|
|
174
|
+
ref=cand.ref,
|
|
175
|
+
method=method,
|
|
176
|
+
sent=True,
|
|
177
|
+
send_result=send_res,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
elif method.kind == "http":
|
|
182
|
+
url = method.value
|
|
183
|
+
try:
|
|
184
|
+
ok, detail = _http_unsubscribe_flow(url)
|
|
185
|
+
send_result = SendResult(
|
|
186
|
+
ok=ok,
|
|
187
|
+
detail=detail,
|
|
188
|
+
)
|
|
189
|
+
results["http"].append(
|
|
190
|
+
UnsubscribeActionResult(
|
|
191
|
+
ref=cand.ref,
|
|
192
|
+
method=method,
|
|
193
|
+
sent=ok,
|
|
194
|
+
send_result=send_result,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
results["http"].append(
|
|
199
|
+
UnsubscribeActionResult(
|
|
200
|
+
ref=cand.ref,
|
|
201
|
+
method=method,
|
|
202
|
+
sent=False,
|
|
203
|
+
send_result=SendResult(ok=False, detail=str(e)),
|
|
204
|
+
note="HTTP request failed",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
else:
|
|
209
|
+
results["skipped"].append(
|
|
210
|
+
UnsubscribeActionResult(
|
|
211
|
+
ref=cand.ref,
|
|
212
|
+
method=method,
|
|
213
|
+
sent=False,
|
|
214
|
+
note=f"Unsupported method kind: {method.kind}",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return results
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _choose_method(methods: List[UnsubscribeMethod], prefer: str) -> Optional[UnsubscribeMethod]:
|
|
222
|
+
prefer = prefer.lower()
|
|
223
|
+
if prefer == "mailto":
|
|
224
|
+
for m in methods:
|
|
225
|
+
if m.kind == "mailto":
|
|
226
|
+
return m
|
|
227
|
+
for m in methods:
|
|
228
|
+
if m.kind == "http":
|
|
229
|
+
return m
|
|
230
|
+
elif prefer == "http":
|
|
231
|
+
for m in methods:
|
|
232
|
+
if m.kind == "http":
|
|
233
|
+
return m
|
|
234
|
+
for m in methods:
|
|
235
|
+
if m.kind == "mailto":
|
|
236
|
+
return m
|
|
237
|
+
return methods[0] if methods else None
|
openmail/types.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EmailRef:
|
|
9
|
+
uid: int
|
|
10
|
+
mailbox: str = "INBOX"
|
|
11
|
+
|
|
12
|
+
def to_dict(self) -> dict:
|
|
13
|
+
return {
|
|
14
|
+
"uid": self.uid,
|
|
15
|
+
"mailbox": self.mailbox,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SendResult:
|
|
21
|
+
ok: bool
|
|
22
|
+
message_id: Optional[str] = None
|
|
23
|
+
detail: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"ok": self.ok,
|
|
28
|
+
"message_id": self.message_id,
|
|
29
|
+
"detail": self.detail,
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from openmail.utils.utils import (
|
|
2
|
+
best_effort_date,
|
|
3
|
+
build_email_context,
|
|
4
|
+
build_references,
|
|
5
|
+
dedup_addrs,
|
|
6
|
+
ensure_forward_subject,
|
|
7
|
+
ensure_reply_subject,
|
|
8
|
+
get_header,
|
|
9
|
+
iso_days_ago,
|
|
10
|
+
looks_binary,
|
|
11
|
+
parse_addrs,
|
|
12
|
+
parse_list_mailbox_name,
|
|
13
|
+
quote_forward_html,
|
|
14
|
+
quote_forward_text,
|
|
15
|
+
quote_original_reply_html,
|
|
16
|
+
quote_original_reply_text,
|
|
17
|
+
remove_addr,
|
|
18
|
+
safe_decode,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"iso_days_ago",
|
|
23
|
+
"ensure_forward_subject",
|
|
24
|
+
"ensure_reply_subject",
|
|
25
|
+
"parse_addrs",
|
|
26
|
+
"dedup_addrs",
|
|
27
|
+
"remove_addr",
|
|
28
|
+
"get_header",
|
|
29
|
+
"build_references",
|
|
30
|
+
"build_email_context",
|
|
31
|
+
"quote_original_reply_text",
|
|
32
|
+
"quote_original_reply_html",
|
|
33
|
+
"quote_forward_text",
|
|
34
|
+
"quote_forward_html",
|
|
35
|
+
"parse_list_mailbox_name",
|
|
36
|
+
"safe_decode",
|
|
37
|
+
"looks_binary",
|
|
38
|
+
"best_effort_date",
|
|
39
|
+
]
|
openmail/utils/utils.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import html as _html
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from email.utils import formataddr, getaddresses, parsedate_to_datetime
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from openmail.models import EmailMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def iso_days_ago(days: int) -> str:
|
|
11
|
+
return (datetime.now(timezone.utc) - timedelta(days=days)).date().isoformat()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_forward_subject(subject: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Ensure the subject is prefixed with 'Fwd:' (or 'Fw:') exactly once.
|
|
17
|
+
"""
|
|
18
|
+
if not subject:
|
|
19
|
+
return "Fwd:"
|
|
20
|
+
lower = subject.lower()
|
|
21
|
+
if lower.startswith("fwd:") or lower.startswith("fw:"):
|
|
22
|
+
return subject
|
|
23
|
+
return f"Fwd: {subject}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_reply_subject(subj: Optional[str]) -> str:
|
|
27
|
+
if not subj:
|
|
28
|
+
return "Re:"
|
|
29
|
+
s = subj.strip()
|
|
30
|
+
if s.lower().startswith("re:"):
|
|
31
|
+
return subj
|
|
32
|
+
return f"Re: {subj}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_addrs(*values: Optional[str]) -> List[tuple[str, str]]:
|
|
36
|
+
out: List[tuple[str, str]] = []
|
|
37
|
+
for v in values:
|
|
38
|
+
if v:
|
|
39
|
+
out.extend(getaddresses([v]))
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dedup_addrs(pairs: List[tuple[str, str]]) -> List[str]:
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
result: List[str] = []
|
|
46
|
+
for name, addr in pairs:
|
|
47
|
+
addr_norm = addr.strip().lower()
|
|
48
|
+
if not addr_norm or addr_norm in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(addr_norm)
|
|
51
|
+
result.append(formataddr((name, addr)) if name else addr)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remove_addr(pairs: List[tuple[str, str]], remove: Optional[str]) -> List[tuple[str, str]]:
|
|
56
|
+
if not remove:
|
|
57
|
+
return pairs
|
|
58
|
+
rm_norm = remove.strip().lower()
|
|
59
|
+
return [(n, a) for (n, a) in pairs if a.strip().lower() != rm_norm]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_header(headers: Dict[str, str], key: str) -> Optional[str]:
|
|
63
|
+
"""Case-insensitive header lookup from EmailMessage.headers."""
|
|
64
|
+
key_lower = key.lower()
|
|
65
|
+
for k, v in headers.items():
|
|
66
|
+
if k.lower() == key_lower:
|
|
67
|
+
return v
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_references(existing_refs: Optional[str], orig_mid: str) -> str:
|
|
72
|
+
if not existing_refs:
|
|
73
|
+
return orig_mid
|
|
74
|
+
if orig_mid in existing_refs:
|
|
75
|
+
return existing_refs
|
|
76
|
+
return f"{existing_refs} {orig_mid}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_email_context(msg: EmailMessage) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Build a text block representing one email for prompts.
|
|
82
|
+
"""
|
|
83
|
+
subject = msg.subject
|
|
84
|
+
from_addr = msg.from_email
|
|
85
|
+
date = msg.received_at
|
|
86
|
+
body = msg.text
|
|
87
|
+
|
|
88
|
+
date_part = f"Date: {date}\n" if date else ""
|
|
89
|
+
|
|
90
|
+
return f"From: {from_addr}\n" f"Subject: {subject}\n" f"{date_part}" f"Body:\n{body}\n"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quote_original_reply_text(original: EmailMessage) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Build a plain-text quoted block of the original email, e.g.:
|
|
96
|
+
|
|
97
|
+
On 2026-01-12T10:00:00+00:00, alice@example.com wrote:
|
|
98
|
+
> line 1
|
|
99
|
+
> line 2
|
|
100
|
+
"""
|
|
101
|
+
if original.received_at:
|
|
102
|
+
date_str = original.received_at.isoformat()
|
|
103
|
+
else:
|
|
104
|
+
date_str = "an earlier date"
|
|
105
|
+
|
|
106
|
+
header = f"On {date_str}, {original.from_email} wrote:"
|
|
107
|
+
|
|
108
|
+
# Body to quote
|
|
109
|
+
body = original.text or ""
|
|
110
|
+
if not body and original.html:
|
|
111
|
+
body = "[original HTML body omitted]"
|
|
112
|
+
|
|
113
|
+
quoted_body_lines = [f"> {line}" for line in body.splitlines()] if body else []
|
|
114
|
+
return "\n".join([header, *quoted_body_lines])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def quote_original_reply_html(original: EmailMessage) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Build an HTML quoted block of the original email.
|
|
120
|
+
|
|
121
|
+
Uses <blockquote> for the body, and a short 'On ..., X wrote:' header.
|
|
122
|
+
"""
|
|
123
|
+
if original.received_at:
|
|
124
|
+
date_str = original.received_at.isoformat()
|
|
125
|
+
else:
|
|
126
|
+
date_str = "an earlier date"
|
|
127
|
+
|
|
128
|
+
header_html = f"On {_html.escape(date_str)}, " f"{_html.escape(original.from_email)} wrote:"
|
|
129
|
+
|
|
130
|
+
if original.html:
|
|
131
|
+
body_html = f"<blockquote>{original.html}</blockquote>"
|
|
132
|
+
elif original.text:
|
|
133
|
+
body_html = "<blockquote><pre>" + _html.escape(original.text) + "</pre></blockquote>"
|
|
134
|
+
else:
|
|
135
|
+
body_html = "<blockquote><em>(no body)</em></blockquote>"
|
|
136
|
+
|
|
137
|
+
return f"<p>{header_html}</p>\n{body_html}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def quote_forward_text(original: EmailMessage) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Build the quoted plain text block for a forwarded message.
|
|
143
|
+
"""
|
|
144
|
+
quoted_lines: List[str] = []
|
|
145
|
+
quoted_lines.append("")
|
|
146
|
+
quoted_lines.append("---- Forwarded message ----")
|
|
147
|
+
quoted_lines.append(f"From: {original.from_email}")
|
|
148
|
+
if original.to:
|
|
149
|
+
quoted_lines.append(f"To: {', '.join(original.to)}")
|
|
150
|
+
if original.cc:
|
|
151
|
+
quoted_lines.append(f"Cc: {', '.join(original.cc)}")
|
|
152
|
+
if original.received_at:
|
|
153
|
+
quoted_lines.append(f"Date: {original.received_at.isoformat()}")
|
|
154
|
+
if original.subject:
|
|
155
|
+
quoted_lines.append(f"Subject: {original.subject}")
|
|
156
|
+
quoted_lines.append("")
|
|
157
|
+
if original.text:
|
|
158
|
+
quoted_lines.append(original.text)
|
|
159
|
+
|
|
160
|
+
return "\n".join(quoted_lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def quote_forward_html(original: EmailMessage) -> Optional[str]:
|
|
164
|
+
"""
|
|
165
|
+
Build the quoted HTML block for a forwarded message.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
if not (original.html or original.text):
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
html_parts: List[str] = []
|
|
172
|
+
|
|
173
|
+
html_parts.append("<hr>")
|
|
174
|
+
html_parts.append("<p>---- Forwarded message ----</p>")
|
|
175
|
+
|
|
176
|
+
header_lines: List[str] = []
|
|
177
|
+
header_lines.append(f"From: {original.from_email}")
|
|
178
|
+
if original.to:
|
|
179
|
+
header_lines.append(f"To: {', '.join(original.to)}")
|
|
180
|
+
if original.cc:
|
|
181
|
+
header_lines.append(f"Cc: {', '.join(original.cc)}")
|
|
182
|
+
if original.received_at:
|
|
183
|
+
header_lines.append(f"Date: {original.received_at.isoformat()}")
|
|
184
|
+
if original.subject:
|
|
185
|
+
header_lines.append(f"Subject: {original.subject}")
|
|
186
|
+
|
|
187
|
+
header_html = "<br>".join(_html.escape(line) for line in header_lines)
|
|
188
|
+
html_parts.append(f"<p>{header_html}</p>")
|
|
189
|
+
|
|
190
|
+
if original.html:
|
|
191
|
+
html_parts.append(original.html)
|
|
192
|
+
else:
|
|
193
|
+
html_parts.append("<pre>" + _html.escape(original.text or "") + "</pre>")
|
|
194
|
+
|
|
195
|
+
return "\n".join(html_parts)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_list_mailbox_name(raw: bytes | str) -> str | None:
|
|
199
|
+
"""
|
|
200
|
+
Parse a single IMAP LIST response line and extract the mailbox name.
|
|
201
|
+
Handles typical formats like:
|
|
202
|
+
(\\HasNoChildren) "/" "INBOX"
|
|
203
|
+
(\\Noselect) "/" "[Gmail]/All Mail"
|
|
204
|
+
(\\HasNoChildren) "/" INBOX
|
|
205
|
+
Returns the decoded mailbox name or None if it can't be parsed.
|
|
206
|
+
"""
|
|
207
|
+
if isinstance(raw, bytes):
|
|
208
|
+
s = raw.decode(errors="ignore")
|
|
209
|
+
else:
|
|
210
|
+
s = str(raw)
|
|
211
|
+
|
|
212
|
+
s = s.strip()
|
|
213
|
+
|
|
214
|
+
m = re.match(r'\((?P<flags>.*?)\)\s+(?P<delim>NIL|".*?"|\S+)\s+(?P<name>.+)', s)
|
|
215
|
+
if not m:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
name = m.group("name").strip()
|
|
219
|
+
|
|
220
|
+
if name.startswith('"') and name.endswith('"'):
|
|
221
|
+
name = name[1:-1]
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
from imaplib import _decode_utf7 as decode_utf7 # type: ignore[attr-defined]
|
|
225
|
+
|
|
226
|
+
name = decode_utf7(name)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
return name or None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def safe_decode(data: bytes) -> Optional[str]:
|
|
234
|
+
"""
|
|
235
|
+
Try UTF-8 decode, fallback to latin-1.
|
|
236
|
+
Returns decoded string or None if decoding fails.
|
|
237
|
+
"""
|
|
238
|
+
if not data:
|
|
239
|
+
return ""
|
|
240
|
+
try:
|
|
241
|
+
return data.decode("utf-8")
|
|
242
|
+
except UnicodeDecodeError:
|
|
243
|
+
try:
|
|
244
|
+
return data.decode("latin-1")
|
|
245
|
+
except Exception:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def looks_binary(text: str) -> bool:
|
|
250
|
+
"""
|
|
251
|
+
Heuristic to detect binary-like decoded content.
|
|
252
|
+
If >30% of characters are control chars or weird unicode blocks.
|
|
253
|
+
"""
|
|
254
|
+
if not text:
|
|
255
|
+
return False
|
|
256
|
+
control_chars = sum(ch < " " for ch in text)
|
|
257
|
+
return (control_chars / len(text)) > 0.3
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def best_effort_date(date_header: str | None, internaldate_raw: str | None) -> datetime | None:
|
|
261
|
+
"""
|
|
262
|
+
Pick the best possible date for an email, trying:
|
|
263
|
+
1. Header Date
|
|
264
|
+
2. IMAP INTERNALDATE
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def _parse_one(raw: str | None) -> datetime | None:
|
|
268
|
+
if not raw:
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
dt = parsedate_to_datetime(raw)
|
|
272
|
+
if dt is None:
|
|
273
|
+
return None
|
|
274
|
+
if dt.tzinfo is None:
|
|
275
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
276
|
+
else:
|
|
277
|
+
dt = dt.astimezone(timezone.utc)
|
|
278
|
+
return dt
|
|
279
|
+
except (TypeError, ValueError, OverflowError):
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
header_dt = _parse_one(date_header)
|
|
283
|
+
internal_dt = _parse_one(internaldate_raw)
|
|
284
|
+
|
|
285
|
+
# Prefer header date if it looks sane
|
|
286
|
+
if header_dt is not None:
|
|
287
|
+
earliest = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
288
|
+
latest = datetime.now(timezone.utc) + timedelta(days=1)
|
|
289
|
+
if earliest <= header_dt <= latest:
|
|
290
|
+
return header_dt
|
|
291
|
+
|
|
292
|
+
if internal_dt is not None:
|
|
293
|
+
return internal_dt
|
|
294
|
+
|
|
295
|
+
return None
|