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,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ def _normalize_content_id(cid: Optional[str]) -> Optional[str]:
8
+ if not cid:
9
+ return None
10
+ c = cid.strip()
11
+ # handle "<...>"
12
+ if c.startswith("<") and c.endswith(">") and len(c) >= 2:
13
+ c = c[1:-1].strip()
14
+ return c or None
15
+
16
+
17
+ def _normalize_disposition(d: Optional[str]) -> Optional[str]:
18
+ if not d:
19
+ return None
20
+ d2 = d.strip().lower()
21
+ if d2 in ("inline", "attachment"):
22
+ return d2
23
+ return d2 or None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AttachmentMeta:
28
+ idx: int
29
+ part: str
30
+ filename: str
31
+ content_type: str
32
+ size: int
33
+ content_id: Optional[str] = None
34
+ disposition: Optional[str] = None
35
+ is_inline: bool = False
36
+ content_location: Optional[str] = None
37
+
38
+ def __post_init__(self) -> None:
39
+ # frozen=True, so use object.__setattr__
40
+ object.__setattr__(self, "content_id", _normalize_content_id(self.content_id))
41
+ object.__setattr__(self, "disposition", _normalize_disposition(self.disposition))
42
+
43
+ # If caller forgot to set is_inline, infer a sensible default:
44
+ # - explicit inline disposition OR
45
+ # - has content_id and is an image (typical CID-inline case)
46
+ inferred_inline = (self.disposition == "inline") or (
47
+ self.content_id is not None and self.content_type.lower().startswith("image/")
48
+ )
49
+ # Only override if it looks unset / default
50
+ if self.is_inline is False and inferred_inline:
51
+ object.__setattr__(self, "is_inline", True)
52
+
53
+ def __repr__(self) -> str:
54
+ extra = []
55
+ if self.disposition:
56
+ extra.append(f"disposition={self.disposition!r}")
57
+ if self.is_inline:
58
+ extra.append("is_inline=True")
59
+ if self.content_id:
60
+ extra.append(f"content_id={self.content_id!r}")
61
+ if self.content_location:
62
+ extra.append(f"content_location={self.content_location!r}")
63
+
64
+ extra_s = (", " + ", ".join(extra)) if extra else ""
65
+ return (
66
+ f"AttachmentMeta("
67
+ f"idx={self.idx!r}, "
68
+ f"part={self.part!r}, "
69
+ f"filename={self.filename!r}, "
70
+ f"content_type={self.content_type!r}, "
71
+ f"size={self.size} bytes"
72
+ f"{extra_s})"
73
+ )
74
+
75
+ def to_dict(self) -> dict:
76
+ # Preserve existing keys + add new ones.
77
+ return {
78
+ "idx": self.idx,
79
+ "part": self.part,
80
+ "filename": self.filename,
81
+ "content_type": self.content_type,
82
+ "size": self.size,
83
+ "content_id": self.content_id,
84
+ "disposition": self.disposition,
85
+ "is_inline": self.is_inline,
86
+ "content_location": self.content_location,
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Attachment(AttachmentMeta):
92
+ data: bytes = b""
93
+
94
+ def __repr__(self) -> str:
95
+ # Keep Attachment repr aligned with AttachmentMeta, but still indicate it’s Attachment.
96
+ extra = []
97
+ if self.disposition:
98
+ extra.append(f"disposition={self.disposition!r}")
99
+ if self.is_inline:
100
+ extra.append("is_inline=True")
101
+ if self.content_id:
102
+ extra.append(f"content_id={self.content_id!r}")
103
+ if self.content_location:
104
+ extra.append(f"content_location={self.content_location!r}")
105
+
106
+ extra_s = (", " + ", ".join(extra)) if extra else ""
107
+ return (
108
+ f"Attachment("
109
+ f"idx={self.idx!r}, "
110
+ f"part={self.part!r}, "
111
+ f"filename={self.filename!r}, "
112
+ f"content_type={self.content_type!r}, "
113
+ f"size={self.size} bytes"
114
+ f"{extra_s})"
115
+ )
116
+
117
+ def to_dict(self) -> dict:
118
+ return {
119
+ "idx": self.idx,
120
+ "part": self.part,
121
+ "filename": self.filename,
122
+ "content_type": self.content_type,
123
+ "size": self.size,
124
+ "content_id": self.content_id,
125
+ "disposition": self.disposition,
126
+ "is_inline": self.is_inline,
127
+ "content_location": self.content_location,
128
+ }
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set
6
+
7
+ from openmail.types import EmailRef
8
+
9
+ if TYPE_CHECKING:
10
+ from openmail.models.attachment import Attachment
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EmailAddress:
15
+ email: str
16
+ name: Optional[str] = None
17
+
18
+ @property
19
+ def display(self) -> str:
20
+ if self.name:
21
+ return f"{self.name} <{self.email}>"
22
+ return self.email
23
+
24
+ def __str__(self) -> str:
25
+ return self.display
26
+
27
+ def __repr__(self) -> str:
28
+ return f"EmailAddress(email={self.email!r}, name={self.name!r})"
29
+
30
+ def to_dict(self) -> dict:
31
+ return {
32
+ "email": self.email,
33
+ "name": self.name,
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class EmailMessage:
39
+ ref: EmailRef
40
+ subject: str
41
+ from_email: EmailAddress
42
+ to: Sequence[EmailAddress]
43
+ cc: Sequence[EmailAddress] = field(default_factory=list)
44
+ bcc: Sequence[EmailAddress] = field(default_factory=list)
45
+ text: Optional[str] = None
46
+ html: Optional[str] = None
47
+ attachments: List[Attachment] = field(default_factory=list)
48
+
49
+ # IMAP metadata
50
+ received_at: Optional[datetime] = None
51
+ sent_at: Optional[datetime] = None
52
+ message_id: Optional[str] = None
53
+ headers: Dict[str, str] = field(default_factory=dict)
54
+
55
+ def __repr__(self) -> str:
56
+ return (
57
+ f"EmailMessage("
58
+ f"subject={self.subject!r}, "
59
+ f"from={self.from_email!r}, "
60
+ f"to={list(self.to)!r}, "
61
+ f"received_at={self.received_at!r})"
62
+ f"attachments={len(self.attachments)})"
63
+ )
64
+
65
+ def to_dict(self) -> dict:
66
+ return {
67
+ "ref": self.ref.to_dict(),
68
+ "subject": self.subject,
69
+ "from_email": self.from_email.to_dict(),
70
+ "to": [addr.to_dict() for addr in self.to],
71
+ "cc": [addr.to_dict() for addr in self.cc],
72
+ "bcc": [addr.to_dict() for addr in self.bcc],
73
+ "text": self.text,
74
+ "html": self.html,
75
+ "attachments": [att.to_dict() for att in self.attachments],
76
+ "received_at": self.received_at.isoformat() if self.received_at else None,
77
+ "sent_at": self.sent_at.isoformat() if self.sent_at else None,
78
+ "message_id": self.message_id,
79
+ "headers": self.headers,
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class EmailOverview:
85
+ ref: EmailRef
86
+ subject: str
87
+ from_email: EmailAddress
88
+ to: Sequence[EmailAddress]
89
+ flags: Set[str]
90
+ headers: Dict[str, str]
91
+ received_at: Optional[datetime] = None
92
+ sent_at: Optional[datetime] = None
93
+
94
+ def __repr__(self) -> str:
95
+ return (
96
+ f"EmailOverview("
97
+ f"subject={self.subject!r}, "
98
+ f"from={self.from_email!r}, "
99
+ f"to={list(self.to)!r}, "
100
+ f"received_at={self.received_at!r})"
101
+ )
102
+
103
+ def to_dict(self) -> dict:
104
+ return {
105
+ "ref": self.ref.to_dict(),
106
+ "subject": self.subject,
107
+ "from_email": self.from_email.to_dict(),
108
+ "to": [addr.to_dict() for addr in self.to],
109
+ "flags": list(self.flags),
110
+ "headers": self.headers,
111
+ "received_at": self.received_at.isoformat() if self.received_at else None,
112
+ "sent_at": self.sent_at.isoformat() if self.sent_at else None,
113
+ }
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional
5
+
6
+ from openmail.types import EmailRef, SendResult
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class UnsubscribeMethod:
11
+ """
12
+ One unsubscribe mechanism from List-Unsubscribe.
13
+ """
14
+
15
+ kind: str # "mailto" | "http"
16
+ value: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class UnsubscribeCandidate:
21
+ """
22
+ An email that supports unsubscribe.
23
+ """
24
+
25
+ ref: EmailRef
26
+ from_email: str
27
+ subject: str
28
+ methods: List[UnsubscribeMethod]
29
+
30
+ def __repr__(self) -> str:
31
+ kinds = "; ".join({m.kind for m in self.methods})
32
+ return f"UnsubscribeCandidate(" f"from={self.from_email!r}, " f"methods={kinds})"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class UnsubscribeActionResult:
37
+ ref: EmailRef
38
+ method: Optional[UnsubscribeMethod]
39
+ sent: bool
40
+ send_result: Optional[SendResult] = None
41
+ note: Optional[str] = None
42
+
43
+ def __repr__(self) -> str:
44
+ detail = self.send_result.detail if self.send_result else "None"
45
+ return "UnsubscribeActionResult(" f"sent={self.sent!r}, " f"detail={detail!r})"
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class Task:
9
+ """
10
+ Common task structure that can be used in different domains
11
+ """
12
+
13
+ id: Optional[str] = None
14
+ title: Optional[str] = None
15
+ description: Optional[str] = None
16
+ due_date: Optional[str] = None
17
+ priority: Optional[str] = None
18
+ status: Optional[str] = None
19
+ assignee: Optional[str] = None
20
+ tags: List[str] = field(default_factory=list)
21
+ source_system: Optional[str] = None
22
+ source_id: Optional[str] = None
23
+ source_link: Optional[str] = None
24
+ metadata: Dict[str, str] = field(default_factory=dict)
openmail/py.typed ADDED
File without changes
@@ -0,0 +1,7 @@
1
+ from openmail.smtp.client import SMTPClient
2
+ from openmail.smtp.templates import RenderedTemplate
3
+
4
+ __all__ = [
5
+ "SMTPClient",
6
+ "RenderedTemplate",
7
+ ]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from email.message import EmailMessage as PyEmailMessage
4
+ from email.utils import make_msgid
5
+
6
+ from openmail.models import EmailMessage
7
+
8
+
9
+ def build_mime_message(msg: EmailMessage) -> PyEmailMessage:
10
+ m = PyEmailMessage()
11
+ m["Subject"] = msg.subject
12
+ m["From"] = msg.from_email
13
+ m["To"] = ", ".join(msg.to)
14
+ if msg.cc:
15
+ m["Cc"] = ", ".join(msg.cc)
16
+ if msg.message_id:
17
+ m["Message-ID"] = msg.message_id
18
+ else:
19
+ m["Message-ID"] = make_msgid()
20
+
21
+ # headers
22
+ for k, v in msg.headers.items():
23
+ if k.lower() not in {"subject", "from", "to", "cc", "bcc", "message-id"}:
24
+ m[k] = v
25
+
26
+ # body
27
+ if msg.text is not None and msg.html is not None:
28
+ m.set_content(msg.text)
29
+ m.add_alternative(msg.html, subtype="html")
30
+ elif msg.html is not None:
31
+ m.set_content("This email contains HTML content.")
32
+ m.add_alternative(msg.html, subtype="html")
33
+ else:
34
+ m.set_content(msg.text or "")
35
+
36
+ # attachments
37
+ for a in msg.attachments:
38
+ maintype, subtype = (a.content_type.split("/", 1) + ["octet-stream"])[:2]
39
+ m.add_attachment(a.data, maintype=maintype, subtype=subtype, filename=a.filename)
40
+
41
+ return m
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import smtplib
5
+ import ssl
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from email.message import EmailMessage as PyEmailMessage
9
+ from email.utils import parseaddr
10
+ from typing import Iterable, List
11
+
12
+ from openmail import SMTPConfig
13
+ from openmail.auth import AuthContext
14
+ from openmail.errors import AuthError, ConfigError, SMTPError
15
+ from openmail.types import SendResult
16
+
17
+
18
+ @dataclass
19
+ class SMTPClient:
20
+ config: SMTPConfig
21
+ _server: smtplib.SMTP | None = field(default=None, init=False, repr=False)
22
+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
23
+ _sent_since_connect: int = field(default=0, init=False, repr=False)
24
+
25
+ max_messages_per_connection: int = 100
26
+
27
+ @classmethod
28
+ def from_config(cls, config: SMTPConfig) -> SMTPClient:
29
+ if not config.host:
30
+ raise ConfigError("SMTP host required")
31
+ if config.use_ssl and config.use_starttls:
32
+ raise ConfigError("Choose use_ssl or use_starttls (not both)")
33
+ return cls(config)
34
+
35
+ def _from_email(self) -> str:
36
+ if self.config.from_email:
37
+ return self.config.from_email
38
+ raise ConfigError("No from_email set")
39
+
40
+ def _open_new_server(self) -> smtplib.SMTP:
41
+ cfg = self.config
42
+ try:
43
+ if cfg.use_ssl:
44
+ ctx = ssl.create_default_context()
45
+ server = smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=ctx)
46
+ server.ehlo()
47
+ else:
48
+ server = smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout)
49
+ server.ehlo()
50
+ if cfg.use_starttls:
51
+ ctx = ssl.create_default_context()
52
+ server.starttls(context=ctx)
53
+ server.ehlo()
54
+
55
+ if cfg.auth is None:
56
+ raise ConfigError("SMTPConfig.auth is required (PasswordAuth or OAuth2Auth)")
57
+
58
+ try:
59
+ cfg.auth.apply_smtp(server, AuthContext(host=cfg.host, port=cfg.port))
60
+ except smtplib.SMTPAuthenticationError as e:
61
+ try:
62
+ server.quit()
63
+ except Exception:
64
+ pass
65
+ raise AuthError(f"SMTP auth failed: {e}") from e
66
+
67
+ self._sent_since_connect = 0
68
+
69
+ return server
70
+
71
+ except AuthError:
72
+ raise
73
+ except smtplib.SMTPException as e:
74
+ raise SMTPError(f"SMTP connection failed: {e}") from e
75
+ except OSError as e:
76
+ raise SMTPError(f"SMTP network error: {e}") from e
77
+
78
+ def _get_server(self) -> smtplib.SMTP:
79
+ # Must be called with self._lock held
80
+ if self._server is not None:
81
+ return self._server
82
+ self._server = self._open_new_server()
83
+ return self._server
84
+
85
+ def _reset_server(self) -> None:
86
+ # Must be called with self._lock held
87
+ if self._server is not None:
88
+ try:
89
+ self._server.quit()
90
+ except Exception:
91
+ pass
92
+ self._server = None
93
+ self._sent_since_connect = 0
94
+
95
+ def _run_with_server(self, op):
96
+ """
97
+ Run an operation with a server, handling:
98
+ - thread-safety (RLock)
99
+ - reconnect-on-disconnect (retry once)
100
+
101
+ `op` is a callable taking a single `smtplib.SMTP` argument.
102
+ """
103
+ last_exc: BaseException | None = None
104
+
105
+ for _ in range(2):
106
+ with self._lock:
107
+ server = self._get_server()
108
+ try:
109
+ result = op(server)
110
+ if self._sent_since_connect >= self.max_messages_per_connection:
111
+ self._reset_server()
112
+ return result
113
+ except smtplib.SMTPServerDisconnected as e:
114
+ # Connection dropped; reset and retry with a fresh one.
115
+ last_exc = e
116
+ self._reset_server()
117
+ except AuthError:
118
+ raise
119
+ except smtplib.SMTPException as e:
120
+ raise SMTPError(f"SMTP operation failed: {e}") from e
121
+
122
+ # If we get here, we had repeated disconnects
123
+ raise SMTPError(f"SMTP connection repeatedly disconnected: {last_exc}") from last_exc
124
+
125
+ def _send_with_known_server(
126
+ self, server: smtplib.SMTP, msg: PyEmailMessage, from_email: str, recipients: list[str]
127
+ ) -> SendResult:
128
+ try:
129
+ server.send_message(msg, from_addr=from_email, to_addrs=recipients)
130
+ except smtplib.SMTPAuthenticationError as e:
131
+ raise AuthError(f"SMTP auth failed during send: {e}") from e
132
+
133
+ self._sent_since_connect += 1
134
+ return SendResult(ok=True, message_id=str(msg["Message-ID"]))
135
+
136
+ def send(self, msg: PyEmailMessage, recipients: List[str]) -> SendResult:
137
+ if not recipients:
138
+ raise ConfigError("send(): recipients list is empty")
139
+
140
+ hdr_from = msg.get("From")
141
+ if hdr_from:
142
+ _, from_email = parseaddr(hdr_from)
143
+ if not from_email:
144
+ from_email = self._from_email()
145
+ else:
146
+ from_email = self._from_email()
147
+ # keep the message self-consistent for debugging/logging
148
+ msg = copy.deepcopy(msg)
149
+ msg["From"] = from_email
150
+
151
+ def _impl(server: smtplib.SMTP) -> SendResult:
152
+ return self._send_with_known_server(server, msg, from_email, recipients)
153
+
154
+ return self._run_with_server(_impl)
155
+
156
+ def send_many(self, batch: Iterable[tuple[PyEmailMessage, Iterable[str]]]) -> list[SendResult]:
157
+ """
158
+ Send multiple messages in a single (or minimal) SMTP session.
159
+ """
160
+ prepared: list[tuple[PyEmailMessage, str, list[str]]] = []
161
+
162
+ for msg, rcpts_iter in batch:
163
+ rcpts = list(rcpts_iter)
164
+ if not rcpts:
165
+ raise ConfigError("send_many(): one of the messages has no recipients")
166
+
167
+ hdr_from = msg.get("From")
168
+ if hdr_from:
169
+ _, from_email = parseaddr(hdr_from)
170
+ if not from_email:
171
+ from_email = self._from_email()
172
+ final_msg = msg
173
+ else:
174
+ from_email = self._from_email()
175
+ final_msg = copy.deepcopy(msg)
176
+ final_msg["From"] = from_email
177
+
178
+ prepared.append((final_msg, from_email, rcpts))
179
+
180
+ results: list[SendResult] = []
181
+ i = 0
182
+
183
+ def _impl(server: smtplib.SMTP) -> list[SendResult]:
184
+ nonlocal i, results
185
+ while i < len(prepared):
186
+ msg, from_email, recipients = prepared[i]
187
+ res = self._send_with_known_server(server, msg, from_email, recipients)
188
+ results.append(res)
189
+ i += 1
190
+ return results
191
+
192
+ return self._run_with_server(_impl)
193
+
194
+ def ping(self) -> None:
195
+ """
196
+ Minimal SMTP health check.
197
+ """
198
+
199
+ def _impl(server: smtplib.SMTP) -> None:
200
+ try:
201
+ code, reply = server.noop()
202
+ except smtplib.SMTPAuthenticationError as e:
203
+ raise AuthError(f"SMTP auth failed during ping: {e}") from e
204
+ if code != 250:
205
+ raise SMTPError(f"SMTP NOOP failed: {code} {reply!r}")
206
+
207
+ self._run_with_server(_impl)
208
+
209
+ def close(self) -> None:
210
+ with self._lock:
211
+ self._reset_server()
212
+
213
+ def __enter__(self) -> SMTPClient:
214
+ # lazy connect;
215
+ return self
216
+
217
+ def __exit__(self, exc_type, exc, tb) -> None:
218
+ self.close()
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Mapping, Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RenderedTemplate:
9
+ subject: str
10
+ text: Optional[str] = None
11
+ html: Optional[str] = None
12
+
13
+
14
+ def render_template(name: str, context: Mapping[str, object]) -> RenderedTemplate:
15
+ # Backbone placeholder. Later: Jinja2 file loader.
16
+ raise NotImplementedError("Template rendering not implemented yet")
@@ -0,0 +1,7 @@
1
+ from openmail.subscription.detector import SubscriptionDetector
2
+ from openmail.subscription.service import SubscriptionService
3
+
4
+ __all__ = [
5
+ "SubscriptionDetector",
6
+ "SubscriptionService",
7
+ ]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ from openmail.imap import IMAPClient, IMAPQuery
6
+ from openmail.models import UnsubscribeCandidate
7
+ from openmail.subscription.parser import parse_list_unsubscribe
8
+
9
+
10
+ class SubscriptionDetector:
11
+ def __init__(self, imap: IMAPClient):
12
+ self.imap = imap
13
+
14
+ def find(
15
+ self,
16
+ *,
17
+ mailbox: str = "INBOX",
18
+ limit: int = 200,
19
+ since: Optional[str] = None,
20
+ unseen_only: bool = False,
21
+ ) -> List[UnsubscribeCandidate]:
22
+ q = IMAPQuery()
23
+ if unseen_only:
24
+ q.unseen()
25
+ if since:
26
+ q.since(since)
27
+
28
+ page = self.imap.search_page_cached(mailbox=mailbox, query=q, page_size=limit)
29
+ msgs = self.imap.fetch(page.refs)
30
+
31
+ out: List[UnsubscribeCandidate] = []
32
+ for ref, msg in zip(page.refs, msgs):
33
+ headers = msg.headers
34
+ lu = _get_header(headers, "List-Unsubscribe")
35
+ if not lu:
36
+ continue
37
+
38
+ methods = parse_list_unsubscribe(lu)
39
+ if not methods:
40
+ continue
41
+
42
+ out.append(
43
+ UnsubscribeCandidate(
44
+ ref=ref,
45
+ from_email=msg.from_email,
46
+ subject=msg.subject,
47
+ methods=methods,
48
+ )
49
+ )
50
+ return out
51
+
52
+
53
+ def _get_header(headers: dict, name: str) -> str:
54
+ name = name.lower()
55
+ for k, v in headers.items():
56
+ if k.lower() == name:
57
+ return v
58
+ return ""