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,777 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html as _html
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from email.message import EmailMessage as PyEmailMessage
|
|
6
|
+
from typing import Dict, List, Optional, Sequence, Set
|
|
7
|
+
|
|
8
|
+
from openmail.imap import IMAPClient, PagedSearchResult
|
|
9
|
+
from openmail.models import (
|
|
10
|
+
Attachment,
|
|
11
|
+
EmailMessage,
|
|
12
|
+
EmailOverview,
|
|
13
|
+
UnsubscribeActionResult,
|
|
14
|
+
UnsubscribeCandidate,
|
|
15
|
+
)
|
|
16
|
+
from openmail.smtp import SMTPClient
|
|
17
|
+
from openmail.subscription import SubscriptionDetector, SubscriptionService
|
|
18
|
+
from openmail.types import EmailRef, SendResult
|
|
19
|
+
from openmail.utils import (
|
|
20
|
+
build_references,
|
|
21
|
+
dedup_addrs,
|
|
22
|
+
ensure_forward_subject,
|
|
23
|
+
ensure_reply_subject,
|
|
24
|
+
get_header,
|
|
25
|
+
parse_addrs,
|
|
26
|
+
quote_forward_html,
|
|
27
|
+
quote_forward_text,
|
|
28
|
+
quote_original_reply_html,
|
|
29
|
+
quote_original_reply_text,
|
|
30
|
+
remove_addr,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from .email_query import EmailQuery
|
|
34
|
+
|
|
35
|
+
SEEN = r"\Seen"
|
|
36
|
+
ANSWERED = r"\Answered"
|
|
37
|
+
FLAGGED = r"\Flagged"
|
|
38
|
+
DELETED = r"\Deleted"
|
|
39
|
+
DRAFT = r"\Draft"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class EmailManager:
|
|
44
|
+
smtp: SMTPClient
|
|
45
|
+
imap: IMAPClient
|
|
46
|
+
|
|
47
|
+
def _set_body(
|
|
48
|
+
self,
|
|
49
|
+
msg: PyEmailMessage,
|
|
50
|
+
text: Optional[str],
|
|
51
|
+
html: Optional[str],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Set message body as:
|
|
55
|
+
- text only if no html
|
|
56
|
+
- multipart/alternative if both text and html are provided
|
|
57
|
+
- html-only if only html is provided
|
|
58
|
+
"""
|
|
59
|
+
if html is not None:
|
|
60
|
+
if text:
|
|
61
|
+
msg.set_content(text)
|
|
62
|
+
msg.add_alternative(html, subtype="html")
|
|
63
|
+
else:
|
|
64
|
+
msg.set_content(html, subtype="html")
|
|
65
|
+
else:
|
|
66
|
+
msg.set_content(text or "")
|
|
67
|
+
|
|
68
|
+
def _add_attachment(
|
|
69
|
+
self,
|
|
70
|
+
msg: PyEmailMessage,
|
|
71
|
+
attachments: Optional[Sequence[Attachment]],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Add attachments to the email message.
|
|
75
|
+
"""
|
|
76
|
+
if not attachments:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
for att in attachments:
|
|
80
|
+
content_type = att.content_type or "application/octet-stream"
|
|
81
|
+
maintype, _, subtype = content_type.partition("/")
|
|
82
|
+
data = att.data
|
|
83
|
+
filename = att.filename
|
|
84
|
+
if data is not None:
|
|
85
|
+
msg.add_attachment(
|
|
86
|
+
data,
|
|
87
|
+
maintype=maintype or "application",
|
|
88
|
+
subtype=subtype or "octet-stream",
|
|
89
|
+
filename=filename,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _extract_envelope_recipients(self, msg: PyEmailMessage) -> list[str]:
|
|
93
|
+
addr_headers = []
|
|
94
|
+
addr_headers.extend(msg.get_all("To", []))
|
|
95
|
+
addr_headers.extend(msg.get_all("Cc", []))
|
|
96
|
+
addr_headers.extend(msg.get_all("Bcc", []))
|
|
97
|
+
|
|
98
|
+
pairs = parse_addrs(*addr_headers)
|
|
99
|
+
# simple dedup by lowercase address
|
|
100
|
+
seen = set()
|
|
101
|
+
result: list[str] = []
|
|
102
|
+
for _, addr in pairs:
|
|
103
|
+
norm = addr.strip().lower()
|
|
104
|
+
if norm and norm not in seen:
|
|
105
|
+
seen.add(norm)
|
|
106
|
+
result.append(addr)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def fetch_message_by_ref(
|
|
110
|
+
self,
|
|
111
|
+
ref: EmailRef,
|
|
112
|
+
*,
|
|
113
|
+
include_attachment_meta: bool = False,
|
|
114
|
+
) -> EmailMessage:
|
|
115
|
+
"""
|
|
116
|
+
Fetch exactly one EmailMessage by EmailRef.
|
|
117
|
+
"""
|
|
118
|
+
msgs = self.imap.fetch([ref], include_attachment_meta=include_attachment_meta)
|
|
119
|
+
if not msgs:
|
|
120
|
+
raise ValueError(f"No message found for ref: {ref!r}")
|
|
121
|
+
return msgs[0]
|
|
122
|
+
|
|
123
|
+
def fetch_attachment_by_ref_and_meta(
|
|
124
|
+
self,
|
|
125
|
+
ref: EmailRef,
|
|
126
|
+
attachment_part: str,
|
|
127
|
+
) -> bytes:
|
|
128
|
+
"""
|
|
129
|
+
Fetch exactly one EmailMessage by EmailRef.
|
|
130
|
+
"""
|
|
131
|
+
attachment = self.imap.fetch_attachment(ref, attachment_part)
|
|
132
|
+
if not attachment:
|
|
133
|
+
raise ValueError(f"No attachment found for ref: {ref!r} and part: {attachment_part!r}")
|
|
134
|
+
return attachment
|
|
135
|
+
|
|
136
|
+
def fetch_messages_by_multi_refs(
|
|
137
|
+
self,
|
|
138
|
+
refs: Sequence[EmailRef],
|
|
139
|
+
*,
|
|
140
|
+
include_attachment_meta: bool = False,
|
|
141
|
+
) -> List[EmailMessage]:
|
|
142
|
+
"""
|
|
143
|
+
Fetch multiple EmailMessage by EmailRef.
|
|
144
|
+
"""
|
|
145
|
+
if not refs:
|
|
146
|
+
return []
|
|
147
|
+
return list(self.imap.fetch(refs, include_attachment_meta=include_attachment_meta))
|
|
148
|
+
|
|
149
|
+
def send(self, msg: PyEmailMessage) -> SendResult:
|
|
150
|
+
recipients = self._extract_envelope_recipients(msg)
|
|
151
|
+
|
|
152
|
+
if "Bcc" in msg:
|
|
153
|
+
del msg["Bcc"]
|
|
154
|
+
|
|
155
|
+
if not recipients:
|
|
156
|
+
raise ValueError("send(): no recipients found in To/Cc/Bcc")
|
|
157
|
+
|
|
158
|
+
return self.smtp.send(msg, recipients)
|
|
159
|
+
|
|
160
|
+
def compose(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
subject: str,
|
|
164
|
+
to: Sequence[str],
|
|
165
|
+
from_addr: Optional[str] = None,
|
|
166
|
+
cc: Sequence[str] = (),
|
|
167
|
+
bcc: Sequence[str] = (),
|
|
168
|
+
text: Optional[str] = None,
|
|
169
|
+
html: Optional[str] = None,
|
|
170
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
171
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
172
|
+
) -> PyEmailMessage:
|
|
173
|
+
"""
|
|
174
|
+
Build a new outgoing email.
|
|
175
|
+
|
|
176
|
+
- subject, to, from_addr are the main headers
|
|
177
|
+
- text/html: plain-text and/or HTML bodies
|
|
178
|
+
- attachments: list of your Attachment models
|
|
179
|
+
- extra_headers: optional extra headers (e.g. Reply-To)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
msg = PyEmailMessage()
|
|
183
|
+
|
|
184
|
+
if from_addr:
|
|
185
|
+
msg["From"] = from_addr
|
|
186
|
+
msg["To"] = ", ".join(to)
|
|
187
|
+
if cc:
|
|
188
|
+
msg["Cc"] = ", ".join(cc)
|
|
189
|
+
if bcc:
|
|
190
|
+
msg["Bcc"] = ", ".join(bcc)
|
|
191
|
+
|
|
192
|
+
msg["Subject"] = subject
|
|
193
|
+
|
|
194
|
+
if extra_headers:
|
|
195
|
+
for k, v in extra_headers.items():
|
|
196
|
+
if k.lower() in {"from", "to", "cc", "bcc", "subject"}:
|
|
197
|
+
continue
|
|
198
|
+
msg[k] = v
|
|
199
|
+
|
|
200
|
+
self._set_body(msg, text, html)
|
|
201
|
+
self._add_attachment(msg, attachments)
|
|
202
|
+
|
|
203
|
+
return msg
|
|
204
|
+
|
|
205
|
+
def compose_and_send(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
subject: str,
|
|
209
|
+
to: Sequence[str],
|
|
210
|
+
from_addr: Optional[str] = None,
|
|
211
|
+
cc: Sequence[str] = (),
|
|
212
|
+
bcc: Sequence[str] = (),
|
|
213
|
+
text: Optional[str] = None,
|
|
214
|
+
html: Optional[str] = None,
|
|
215
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
216
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
217
|
+
) -> SendResult:
|
|
218
|
+
"""
|
|
219
|
+
Convenience wrapper: compose a new email and send it.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
if not to and not cc and not bcc:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
"compose_and_send(): at least one of to/cc/bcc must contain a recipient"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
msg = self.compose(
|
|
228
|
+
subject=subject,
|
|
229
|
+
to=to,
|
|
230
|
+
from_addr=from_addr,
|
|
231
|
+
cc=cc,
|
|
232
|
+
bcc=bcc,
|
|
233
|
+
text=text,
|
|
234
|
+
html=html,
|
|
235
|
+
attachments=attachments,
|
|
236
|
+
extra_headers=extra_headers,
|
|
237
|
+
)
|
|
238
|
+
return self.send(msg)
|
|
239
|
+
|
|
240
|
+
def save_draft(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
subject: str,
|
|
244
|
+
to: Sequence[str],
|
|
245
|
+
from_addr: Optional[str] = None,
|
|
246
|
+
cc: Sequence[str] = (),
|
|
247
|
+
bcc: Sequence[str] = (),
|
|
248
|
+
text: Optional[str] = None,
|
|
249
|
+
html: Optional[str] = None,
|
|
250
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
251
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
252
|
+
mailbox: str = "Drafts",
|
|
253
|
+
) -> EmailRef:
|
|
254
|
+
"""
|
|
255
|
+
Compose an email and save it to a Drafts mailbox without sending.
|
|
256
|
+
Returns EmailRef for later .send().
|
|
257
|
+
"""
|
|
258
|
+
msg = self.compose(
|
|
259
|
+
subject=subject,
|
|
260
|
+
to=to,
|
|
261
|
+
from_addr=from_addr,
|
|
262
|
+
cc=cc,
|
|
263
|
+
bcc=bcc,
|
|
264
|
+
text=text,
|
|
265
|
+
html=html,
|
|
266
|
+
attachments=attachments,
|
|
267
|
+
extra_headers=extra_headers,
|
|
268
|
+
)
|
|
269
|
+
return self.imap.append(mailbox, msg, flags={DRAFT})
|
|
270
|
+
|
|
271
|
+
def reply(
|
|
272
|
+
self,
|
|
273
|
+
original: EmailMessage,
|
|
274
|
+
*,
|
|
275
|
+
text: str,
|
|
276
|
+
html: Optional[str] = None,
|
|
277
|
+
from_addr: Optional[str] = None,
|
|
278
|
+
quote_original: bool = False,
|
|
279
|
+
to: Optional[Sequence[str]] = None,
|
|
280
|
+
cc: Optional[Sequence[str]] = None,
|
|
281
|
+
bcc: Optional[Sequence[str]] = None,
|
|
282
|
+
subject: Optional[str] = None,
|
|
283
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
284
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
285
|
+
) -> SendResult:
|
|
286
|
+
"""
|
|
287
|
+
Reply to a single sender.
|
|
288
|
+
|
|
289
|
+
- If to/cc/bcc/subject/attachments are None, sensible defaults are derived
|
|
290
|
+
from `original`.
|
|
291
|
+
- If provided, they override the defaults but threading headers are still
|
|
292
|
+
managed here.
|
|
293
|
+
"""
|
|
294
|
+
if to is None:
|
|
295
|
+
reply_to = get_header(original.headers, "Reply-To") or original.from_email
|
|
296
|
+
if not reply_to:
|
|
297
|
+
raise ValueError("reply(): original message has no Reply-To or From address")
|
|
298
|
+
|
|
299
|
+
to_pairs = parse_addrs(reply_to)
|
|
300
|
+
to_addrs = dedup_addrs(to_pairs)
|
|
301
|
+
if not to_addrs:
|
|
302
|
+
raise ValueError("reply(): could not parse any valid reply addresses")
|
|
303
|
+
else:
|
|
304
|
+
to_addrs = list(to)
|
|
305
|
+
|
|
306
|
+
cc_addrs = list(cc) if cc is not None else []
|
|
307
|
+
bcc_addrs = list(bcc) if bcc is not None else []
|
|
308
|
+
|
|
309
|
+
final_subject = subject or ensure_reply_subject(original.subject)
|
|
310
|
+
|
|
311
|
+
headers: Dict[str, str] = {}
|
|
312
|
+
orig_mid = original.message_id
|
|
313
|
+
if orig_mid:
|
|
314
|
+
headers["In-Reply-To"] = orig_mid
|
|
315
|
+
existing_refs = get_header(original.headers, "References")
|
|
316
|
+
headers["References"] = build_references(existing_refs, orig_mid)
|
|
317
|
+
|
|
318
|
+
if extra_headers:
|
|
319
|
+
headers.update(extra_headers)
|
|
320
|
+
|
|
321
|
+
if quote_original:
|
|
322
|
+
quoted_text = quote_original_reply_text(original)
|
|
323
|
+
text_body = text + "\n\n" + quoted_text if text else quoted_text
|
|
324
|
+
|
|
325
|
+
if html is not None:
|
|
326
|
+
quoted_html = quote_original_reply_html(original)
|
|
327
|
+
html_body = html + "<br><br>" + quoted_html
|
|
328
|
+
else:
|
|
329
|
+
html_body = None
|
|
330
|
+
else:
|
|
331
|
+
text_body = text
|
|
332
|
+
html_body = html
|
|
333
|
+
|
|
334
|
+
msg = self.compose(
|
|
335
|
+
subject=final_subject,
|
|
336
|
+
to=to_addrs,
|
|
337
|
+
from_addr=from_addr,
|
|
338
|
+
cc=cc_addrs,
|
|
339
|
+
bcc=bcc_addrs,
|
|
340
|
+
text=text_body,
|
|
341
|
+
html=html_body,
|
|
342
|
+
attachments=attachments,
|
|
343
|
+
extra_headers=headers or None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return self.send(msg)
|
|
347
|
+
|
|
348
|
+
def reply_all(
|
|
349
|
+
self,
|
|
350
|
+
original: EmailMessage,
|
|
351
|
+
*,
|
|
352
|
+
text: str,
|
|
353
|
+
html: Optional[str] = None,
|
|
354
|
+
from_addr: Optional[str] = None,
|
|
355
|
+
quote_original: bool = False,
|
|
356
|
+
to: Optional[Sequence[str]] = None,
|
|
357
|
+
cc: Optional[Sequence[str]] = None,
|
|
358
|
+
bcc: Optional[Sequence[str]] = None,
|
|
359
|
+
subject: Optional[str] = None,
|
|
360
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
361
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
362
|
+
) -> SendResult:
|
|
363
|
+
"""
|
|
364
|
+
Reply to everyone.
|
|
365
|
+
|
|
366
|
+
- If to/cc/bcc are None, they are derived from original (Reply-To/From + To/Cc).
|
|
367
|
+
- If provided, we trust the UI values and do not recompute recipients.
|
|
368
|
+
"""
|
|
369
|
+
# Recipients
|
|
370
|
+
if to is None and cc is None and bcc is None:
|
|
371
|
+
# Derive default reply-all recipients
|
|
372
|
+
primary = get_header(original.headers, "Reply-To") or original.from_email
|
|
373
|
+
primary_pairs = parse_addrs(primary) if primary else []
|
|
374
|
+
|
|
375
|
+
to_str = ", ".join(original.to) if original.to else ""
|
|
376
|
+
cc_str = ", ".join(original.cc) if original.cc else ""
|
|
377
|
+
others_pairs = parse_addrs(to_str, cc_str)
|
|
378
|
+
|
|
379
|
+
if from_addr:
|
|
380
|
+
primary_pairs = remove_addr(primary_pairs, from_addr)
|
|
381
|
+
others_pairs = remove_addr(others_pairs, from_addr)
|
|
382
|
+
|
|
383
|
+
primary_set = {addr.strip().lower() for _, addr in primary_pairs}
|
|
384
|
+
cc_pairs = [(n, a) for (n, a) in others_pairs if a.strip().lower() not in primary_set]
|
|
385
|
+
|
|
386
|
+
to_addrs = dedup_addrs(primary_pairs)
|
|
387
|
+
cc_addrs = dedup_addrs(cc_pairs)
|
|
388
|
+
bcc_addrs: List[str] = []
|
|
389
|
+
else:
|
|
390
|
+
to_addrs = list(to) if to is not None else []
|
|
391
|
+
cc_addrs = list(cc) if cc is not None else []
|
|
392
|
+
bcc_addrs = list(bcc) if bcc is not None else []
|
|
393
|
+
|
|
394
|
+
if not to_addrs:
|
|
395
|
+
raise ValueError("reply_all(): no primary recipients")
|
|
396
|
+
|
|
397
|
+
final_subject = subject or ensure_reply_subject(original.subject)
|
|
398
|
+
|
|
399
|
+
headers: Dict[str, str] = {}
|
|
400
|
+
orig_mid = original.message_id
|
|
401
|
+
if orig_mid:
|
|
402
|
+
headers["In-Reply-To"] = orig_mid
|
|
403
|
+
existing_refs = get_header(original.headers, "References")
|
|
404
|
+
headers["References"] = build_references(existing_refs, orig_mid)
|
|
405
|
+
|
|
406
|
+
if extra_headers:
|
|
407
|
+
headers.update(extra_headers)
|
|
408
|
+
|
|
409
|
+
if quote_original:
|
|
410
|
+
quoted_text = quote_original_reply_text(original)
|
|
411
|
+
text_body = text + "\n\n" + quoted_text if text else quoted_text
|
|
412
|
+
|
|
413
|
+
if html is not None:
|
|
414
|
+
quoted_html = quote_original_reply_html(original)
|
|
415
|
+
html_body = html + "<br><br>" + quoted_html
|
|
416
|
+
else:
|
|
417
|
+
html_body = None
|
|
418
|
+
else:
|
|
419
|
+
text_body = text
|
|
420
|
+
html_body = html
|
|
421
|
+
|
|
422
|
+
msg = self.compose(
|
|
423
|
+
subject=final_subject,
|
|
424
|
+
to=to_addrs,
|
|
425
|
+
from_addr=from_addr,
|
|
426
|
+
cc=cc_addrs,
|
|
427
|
+
bcc=bcc_addrs,
|
|
428
|
+
text=text_body,
|
|
429
|
+
html=html_body,
|
|
430
|
+
attachments=attachments,
|
|
431
|
+
extra_headers=headers or None,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return self.send(msg)
|
|
435
|
+
|
|
436
|
+
def forward(
|
|
437
|
+
self,
|
|
438
|
+
original: EmailMessage,
|
|
439
|
+
*,
|
|
440
|
+
to: Sequence[str],
|
|
441
|
+
text: Optional[str] = None,
|
|
442
|
+
html: Optional[str] = None,
|
|
443
|
+
from_addr: Optional[str] = None,
|
|
444
|
+
include_original: bool = False,
|
|
445
|
+
include_attachments: bool = True,
|
|
446
|
+
cc: Optional[Sequence[str]] = None,
|
|
447
|
+
bcc: Optional[Sequence[str]] = None,
|
|
448
|
+
subject: Optional[str] = None,
|
|
449
|
+
attachments: Optional[Sequence[Attachment]] = None,
|
|
450
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
451
|
+
) -> SendResult:
|
|
452
|
+
"""
|
|
453
|
+
Forward an existing email.
|
|
454
|
+
"""
|
|
455
|
+
if not to:
|
|
456
|
+
raise ValueError("forward(): 'to' must contain at least one recipient")
|
|
457
|
+
|
|
458
|
+
text_parts: List[str] = []
|
|
459
|
+
if text:
|
|
460
|
+
text_parts.append(text)
|
|
461
|
+
if include_original:
|
|
462
|
+
text_parts.append(quote_forward_text(original))
|
|
463
|
+
text_body = "\n".join(text_parts)
|
|
464
|
+
|
|
465
|
+
if html is not None:
|
|
466
|
+
html_body = html
|
|
467
|
+
else:
|
|
468
|
+
html_parts: List[str] = []
|
|
469
|
+
if text:
|
|
470
|
+
html_parts.append(f"<p>{_html.escape(text)}</p>")
|
|
471
|
+
if include_original:
|
|
472
|
+
quoted_html = quote_forward_html(original)
|
|
473
|
+
if quoted_html is not None:
|
|
474
|
+
html_parts.append(quoted_html)
|
|
475
|
+
html_body = "\n".join(html_parts) if html_parts else None
|
|
476
|
+
|
|
477
|
+
# Subject default
|
|
478
|
+
final_subject = subject or ensure_forward_subject(original.subject or "")
|
|
479
|
+
|
|
480
|
+
final_attachments = []
|
|
481
|
+
if attachments is not None:
|
|
482
|
+
final_attachments.extend(attachments)
|
|
483
|
+
if include_attachments and original.attachments:
|
|
484
|
+
final_attachments.extend(original.attachments)
|
|
485
|
+
|
|
486
|
+
msg = self.compose(
|
|
487
|
+
subject=final_subject,
|
|
488
|
+
to=to,
|
|
489
|
+
from_addr=from_addr,
|
|
490
|
+
cc=cc or (),
|
|
491
|
+
bcc=bcc or (),
|
|
492
|
+
text=text_body,
|
|
493
|
+
html=html_body,
|
|
494
|
+
attachments=final_attachments,
|
|
495
|
+
extra_headers=extra_headers,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return self.send(msg)
|
|
499
|
+
|
|
500
|
+
def imap_query(self, mailbox: str = "INBOX") -> EmailQuery:
|
|
501
|
+
return EmailQuery(self, mailbox=mailbox)
|
|
502
|
+
|
|
503
|
+
def fetch_overview(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
mailbox: str = "INBOX",
|
|
507
|
+
n: int = 50,
|
|
508
|
+
before_uid: Optional[int] = None,
|
|
509
|
+
after_uid: Optional[int] = None,
|
|
510
|
+
refresh: bool = False,
|
|
511
|
+
) -> tuple[PagedSearchResult, List[EmailOverview]]:
|
|
512
|
+
"""
|
|
513
|
+
Fetch a page of EmailOverview objects with paging metadata.
|
|
514
|
+
|
|
515
|
+
- For the first (latest) page, call with refresh=True, before_uid=None.
|
|
516
|
+
- For next (older) pages, call with before_uid=prev_page.next_before_uid.
|
|
517
|
+
- For previous (newer) pages, call with after_uid=prev_page.prev_after_uid.
|
|
518
|
+
"""
|
|
519
|
+
q = self.imap_query(mailbox).limit(n)
|
|
520
|
+
page, overviews = q.fetch_overview(
|
|
521
|
+
before_uid=before_uid,
|
|
522
|
+
after_uid=after_uid,
|
|
523
|
+
refresh=refresh,
|
|
524
|
+
)
|
|
525
|
+
return page, overviews
|
|
526
|
+
|
|
527
|
+
def fetch_latest(
|
|
528
|
+
self,
|
|
529
|
+
*,
|
|
530
|
+
mailbox: str = "INBOX",
|
|
531
|
+
n: int = 50,
|
|
532
|
+
unseen_only: bool = False,
|
|
533
|
+
include_attachment_meta: bool = False,
|
|
534
|
+
before_uid: Optional[int] = None,
|
|
535
|
+
after_uid: Optional[int] = None,
|
|
536
|
+
refresh: bool = False,
|
|
537
|
+
) -> tuple[PagedSearchResult, List[EmailMessage]]:
|
|
538
|
+
"""
|
|
539
|
+
Fetch a page of latest messages plus paging metadata.
|
|
540
|
+
|
|
541
|
+
- For the first (latest) page, call with refresh=True, before_uid=None.
|
|
542
|
+
- For next (older) pages, call with before_uid=prev_page.next_before_uid.
|
|
543
|
+
- For previous (newer) pages, call with after_uid=prev_page.prev_after_uid.
|
|
544
|
+
"""
|
|
545
|
+
q = self.imap_query(mailbox).limit(n)
|
|
546
|
+
if unseen_only:
|
|
547
|
+
q.query.unseen()
|
|
548
|
+
|
|
549
|
+
page, messages = q.fetch(
|
|
550
|
+
before_uid=before_uid,
|
|
551
|
+
after_uid=after_uid,
|
|
552
|
+
refresh=refresh,
|
|
553
|
+
include_attachment_meta=include_attachment_meta,
|
|
554
|
+
)
|
|
555
|
+
return page, messages
|
|
556
|
+
|
|
557
|
+
def fetch_thread(
|
|
558
|
+
self,
|
|
559
|
+
root: EmailMessage,
|
|
560
|
+
*,
|
|
561
|
+
mailbox: str = "INBOX",
|
|
562
|
+
include_attachment_meta: bool = False,
|
|
563
|
+
) -> List[EmailMessage]:
|
|
564
|
+
"""
|
|
565
|
+
Fetch messages belonging to the same thread as `root`.
|
|
566
|
+
"""
|
|
567
|
+
if not root.message_id:
|
|
568
|
+
return [root]
|
|
569
|
+
|
|
570
|
+
q = self.imap_query(mailbox).for_thread_root(root).limit(200)
|
|
571
|
+
|
|
572
|
+
_, msgs = q.fetch(include_attachment_meta=include_attachment_meta)
|
|
573
|
+
|
|
574
|
+
# Ensure root is present exactly once
|
|
575
|
+
mid = root.message_id
|
|
576
|
+
if all(m.message_id != mid for m in msgs):
|
|
577
|
+
msgs = [root] + msgs
|
|
578
|
+
|
|
579
|
+
return msgs
|
|
580
|
+
|
|
581
|
+
def add_flags(self, refs: Sequence[EmailRef], flags: Set[str]) -> None:
|
|
582
|
+
"""Bulk add flags to refs."""
|
|
583
|
+
if not refs:
|
|
584
|
+
return
|
|
585
|
+
self.imap.add_flags(refs, flags=set(flags))
|
|
586
|
+
|
|
587
|
+
def remove_flags(self, refs: Sequence[EmailRef], flags: Set[str]) -> None:
|
|
588
|
+
"""Bulk remove flags from refs."""
|
|
589
|
+
if not refs:
|
|
590
|
+
return
|
|
591
|
+
self.imap.remove_flags(refs, flags=set(flags))
|
|
592
|
+
|
|
593
|
+
def mark_seen(self, refs: Sequence[EmailRef]) -> None:
|
|
594
|
+
self.add_flags(refs, {SEEN})
|
|
595
|
+
|
|
596
|
+
def mark_all_seen(self, mailbox: str = "INBOX", *, chunk_size: int = 500) -> int:
|
|
597
|
+
total = 0
|
|
598
|
+
|
|
599
|
+
# Build a reusable EmailQuery for UNSEEN messages in this mailbox
|
|
600
|
+
q = self.imap_query(mailbox).limit(chunk_size)
|
|
601
|
+
q.query.unseen()
|
|
602
|
+
|
|
603
|
+
before_uid: Optional[int] = None
|
|
604
|
+
refresh = True # do a real SEARCH once to build the cache
|
|
605
|
+
|
|
606
|
+
while True:
|
|
607
|
+
page = q.search(before_uid=before_uid, refresh=refresh)
|
|
608
|
+
refresh = False # all further pages come from cache
|
|
609
|
+
|
|
610
|
+
refs = page.refs
|
|
611
|
+
if not refs:
|
|
612
|
+
break
|
|
613
|
+
|
|
614
|
+
self.add_flags(refs, {SEEN})
|
|
615
|
+
total += len(refs)
|
|
616
|
+
|
|
617
|
+
if not page.has_next or page.next_before_uid is None:
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
before_uid = page.next_before_uid
|
|
621
|
+
|
|
622
|
+
return total
|
|
623
|
+
|
|
624
|
+
def mark_unseen(self, refs: Sequence[EmailRef]) -> None:
|
|
625
|
+
self.remove_flags(refs, {SEEN})
|
|
626
|
+
|
|
627
|
+
def flag(self, refs: Sequence[EmailRef]) -> None:
|
|
628
|
+
self.add_flags(refs, {FLAGGED})
|
|
629
|
+
|
|
630
|
+
def unflag(self, refs: Sequence[EmailRef]) -> None:
|
|
631
|
+
self.remove_flags(refs, {FLAGGED})
|
|
632
|
+
|
|
633
|
+
def mark_answered(self, refs: Sequence[EmailRef]) -> None:
|
|
634
|
+
if refs:
|
|
635
|
+
self.add_flags(refs, {ANSWERED})
|
|
636
|
+
|
|
637
|
+
def clear_answered(self, refs: Sequence[EmailRef]) -> None:
|
|
638
|
+
if refs:
|
|
639
|
+
self.remove_flags(refs, {ANSWERED})
|
|
640
|
+
|
|
641
|
+
def delete(self, refs: Sequence[EmailRef]) -> None:
|
|
642
|
+
self.add_flags(refs, {DELETED})
|
|
643
|
+
|
|
644
|
+
def undelete(self, refs: Sequence[EmailRef]) -> None:
|
|
645
|
+
self.remove_flags(refs, {DELETED})
|
|
646
|
+
|
|
647
|
+
def expunge(self, mailbox: str = "INBOX") -> None:
|
|
648
|
+
"""
|
|
649
|
+
Permanently remove messages flagged as \\Deleted.
|
|
650
|
+
"""
|
|
651
|
+
self.imap.expunge(mailbox)
|
|
652
|
+
|
|
653
|
+
def list_mailboxes(self) -> List[str]:
|
|
654
|
+
"""
|
|
655
|
+
Return a list of mailbox names.
|
|
656
|
+
"""
|
|
657
|
+
return self.imap.list_mailboxes()
|
|
658
|
+
|
|
659
|
+
def mailbox_status(self, mailbox: str = "INBOX") -> Dict[str, int]:
|
|
660
|
+
"""
|
|
661
|
+
Return counters, e.g. {"messages": X, "unseen": Y}.
|
|
662
|
+
"""
|
|
663
|
+
return self.imap.mailbox_status(mailbox)
|
|
664
|
+
|
|
665
|
+
def move(
|
|
666
|
+
self,
|
|
667
|
+
refs: Sequence[EmailRef],
|
|
668
|
+
*,
|
|
669
|
+
src_mailbox: str,
|
|
670
|
+
dst_mailbox: str,
|
|
671
|
+
) -> None:
|
|
672
|
+
"""
|
|
673
|
+
Move messages between mailboxes.
|
|
674
|
+
"""
|
|
675
|
+
if not refs:
|
|
676
|
+
return
|
|
677
|
+
self.imap.move(refs, src_mailbox=src_mailbox, dst_mailbox=dst_mailbox)
|
|
678
|
+
|
|
679
|
+
def copy(
|
|
680
|
+
self,
|
|
681
|
+
refs: Sequence[EmailRef],
|
|
682
|
+
*,
|
|
683
|
+
src_mailbox: str,
|
|
684
|
+
dst_mailbox: str,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""
|
|
687
|
+
Copy messages between mailboxes.
|
|
688
|
+
"""
|
|
689
|
+
if not refs:
|
|
690
|
+
return
|
|
691
|
+
self.imap.copy(refs, src_mailbox=src_mailbox, dst_mailbox=dst_mailbox)
|
|
692
|
+
|
|
693
|
+
def create_mailbox(self, name: str) -> None:
|
|
694
|
+
"""
|
|
695
|
+
Create a new mailbox/folder.
|
|
696
|
+
"""
|
|
697
|
+
self.imap.create_mailbox(name)
|
|
698
|
+
|
|
699
|
+
def delete_mailbox(self, name: str) -> None:
|
|
700
|
+
"""
|
|
701
|
+
Delete a mailbox/folder.
|
|
702
|
+
"""
|
|
703
|
+
self.imap.delete_mailbox(name)
|
|
704
|
+
|
|
705
|
+
def list_unsubscribe_candidates(
|
|
706
|
+
self,
|
|
707
|
+
*,
|
|
708
|
+
mailbox: str = "INBOX",
|
|
709
|
+
limit: int = 200,
|
|
710
|
+
since: Optional[str] = None,
|
|
711
|
+
unseen_only: bool = False,
|
|
712
|
+
) -> List[UnsubscribeCandidate]:
|
|
713
|
+
"""
|
|
714
|
+
Returns emails that expose List-Unsubscribe.
|
|
715
|
+
"""
|
|
716
|
+
detector = SubscriptionDetector(self.imap)
|
|
717
|
+
return detector.find(
|
|
718
|
+
mailbox=mailbox,
|
|
719
|
+
limit=limit,
|
|
720
|
+
since=since,
|
|
721
|
+
unseen_only=unseen_only,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def unsubscribe_selected(
|
|
725
|
+
self,
|
|
726
|
+
candidates: Sequence[UnsubscribeCandidate],
|
|
727
|
+
*,
|
|
728
|
+
prefer: str = "mailto",
|
|
729
|
+
from_addr: Optional[str] = None,
|
|
730
|
+
) -> Dict[str, List[UnsubscribeActionResult]]:
|
|
731
|
+
"""
|
|
732
|
+
Delegates unsubscribe execution to SubscriptionService.
|
|
733
|
+
"""
|
|
734
|
+
service = SubscriptionService(self.smtp)
|
|
735
|
+
return service.unsubscribe(
|
|
736
|
+
list(candidates),
|
|
737
|
+
prefer=prefer,
|
|
738
|
+
from_addr=from_addr,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
def health_check(self) -> Dict[str, bool]:
|
|
742
|
+
"""
|
|
743
|
+
Run minimal IMAP + SMTP checks.
|
|
744
|
+
"""
|
|
745
|
+
imap_ok = False
|
|
746
|
+
smtp_ok = False
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
self.imap.ping() # or list_mailboxes(), or NOOP
|
|
750
|
+
imap_ok = True
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
self.smtp.ping() # or EHLO/NOOP
|
|
756
|
+
smtp_ok = True
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
|
|
760
|
+
return {"imap": imap_ok, "smtp": smtp_ok}
|
|
761
|
+
|
|
762
|
+
def close(self) -> None:
|
|
763
|
+
# Best-effort close both
|
|
764
|
+
try:
|
|
765
|
+
self.imap.close()
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
try:
|
|
769
|
+
self.smtp.close()
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
|
|
773
|
+
def __enter__(self) -> EmailManager:
|
|
774
|
+
return self
|
|
775
|
+
|
|
776
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
777
|
+
self.close()
|