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,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})"
|
openmail/models/task.py
ADDED
|
@@ -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
|
openmail/smtp/builder.py
ADDED
|
@@ -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
|
openmail/smtp/client.py
ADDED
|
@@ -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,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 ""
|