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.
Files changed (67) hide show
  1. openmail/__init__.py +6 -0
  2. openmail/assistants/__init__.py +35 -0
  3. openmail/assistants/classify_emails.py +83 -0
  4. openmail/assistants/compose_email.py +43 -0
  5. openmail/assistants/detect_phishing_for_email.py +61 -0
  6. openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
  7. openmail/assistants/extract_tasks_from_emails.py +126 -0
  8. openmail/assistants/generate_follow_up_for_email.py +54 -0
  9. openmail/assistants/natural_language_query.py +699 -0
  10. openmail/assistants/prioritize_emails.py +89 -0
  11. openmail/assistants/reply.py +58 -0
  12. openmail/assistants/reply_suggestions.py +46 -0
  13. openmail/assistants/rewrite_email.py +50 -0
  14. openmail/assistants/summarize_attachments_for_email.py +101 -0
  15. openmail/assistants/summarize_thread_emails.py +55 -0
  16. openmail/assistants/summary.py +44 -0
  17. openmail/assistants/summary_multi.py +57 -0
  18. openmail/assistants/translate_email.py +54 -0
  19. openmail/auth/__init__.py +6 -0
  20. openmail/auth/base.py +34 -0
  21. openmail/auth/no_auth.py +19 -0
  22. openmail/auth/oauth2.py +58 -0
  23. openmail/auth/password.py +26 -0
  24. openmail/config.py +26 -0
  25. openmail/email_assistant.py +418 -0
  26. openmail/email_manager.py +777 -0
  27. openmail/email_query.py +279 -0
  28. openmail/errors.py +16 -0
  29. openmail/imap/__init__.py +5 -0
  30. openmail/imap/attachment_parts.py +55 -0
  31. openmail/imap/bodystructure.py +296 -0
  32. openmail/imap/client.py +806 -0
  33. openmail/imap/fetch_response.py +115 -0
  34. openmail/imap/inline_cid.py +106 -0
  35. openmail/imap/pagination.py +16 -0
  36. openmail/imap/parser.py +298 -0
  37. openmail/imap/query.py +233 -0
  38. openmail/llm/__init__.py +3 -0
  39. openmail/llm/claude.py +35 -0
  40. openmail/llm/costs.py +108 -0
  41. openmail/llm/gemini.py +34 -0
  42. openmail/llm/gpt.py +33 -0
  43. openmail/llm/groq.py +36 -0
  44. openmail/llm/model.py +126 -0
  45. openmail/llm/xai.py +35 -0
  46. openmail/logger.py +20 -0
  47. openmail/models/__init__.py +20 -0
  48. openmail/models/attachment.py +128 -0
  49. openmail/models/message.py +113 -0
  50. openmail/models/subscription.py +45 -0
  51. openmail/models/task.py +24 -0
  52. openmail/py.typed +0 -0
  53. openmail/smtp/__init__.py +7 -0
  54. openmail/smtp/builder.py +41 -0
  55. openmail/smtp/client.py +218 -0
  56. openmail/smtp/templates.py +16 -0
  57. openmail/subscription/__init__.py +7 -0
  58. openmail/subscription/detector.py +58 -0
  59. openmail/subscription/parser.py +32 -0
  60. openmail/subscription/service.py +237 -0
  61. openmail/types.py +30 -0
  62. openmail/utils/__init__.py +39 -0
  63. openmail/utils/utils.py +295 -0
  64. openmail-0.1.5.dist-info/METADATA +180 -0
  65. openmail-0.1.5.dist-info/RECORD +67 -0
  66. openmail-0.1.5.dist-info/WHEEL +4 -0
  67. 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
+ ]
@@ -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