amsdal_mail 0.1.0__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.
- amsdal_mail/Third-Party Materials - AMSDAL Dependencies - License Notices.md +29 -0
- amsdal_mail/__about__.py +1 -0
- amsdal_mail/__init__.py +164 -0
- amsdal_mail/app.py +36 -0
- amsdal_mail/backends/__init__.py +103 -0
- amsdal_mail/backends/base.py +87 -0
- amsdal_mail/backends/console.py +91 -0
- amsdal_mail/backends/dummy.py +56 -0
- amsdal_mail/backends/ses.py +433 -0
- amsdal_mail/backends/smtp.py +305 -0
- amsdal_mail/events.py +46 -0
- amsdal_mail/exceptions.py +25 -0
- amsdal_mail/message.py +167 -0
- amsdal_mail/py.typed +0 -0
- amsdal_mail/settings.py +21 -0
- amsdal_mail/status.py +189 -0
- amsdal_mail/webhooks/__init__.py +0 -0
- amsdal_mail/webhooks/base.py +32 -0
- amsdal_mail/webhooks/handler.py +43 -0
- amsdal_mail/webhooks/listener.py +41 -0
- amsdal_mail/webhooks/registry.py +21 -0
- amsdal_mail/webhooks/ses.py +201 -0
- amsdal_mail-0.1.0.dist-info/METADATA +799 -0
- amsdal_mail-0.1.0.dist-info/RECORD +25 -0
- amsdal_mail-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""SMTP email backend."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import smtplib
|
|
5
|
+
import ssl
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import aiosmtplib
|
|
10
|
+
except ImportError:
|
|
11
|
+
aiosmtplib = None # type: ignore[assignment]
|
|
12
|
+
|
|
13
|
+
from amsdal_mail.backends.base import BaseEmailBackend
|
|
14
|
+
from amsdal_mail.exceptions import EmailConnectionError
|
|
15
|
+
from amsdal_mail.exceptions import SendError
|
|
16
|
+
from amsdal_mail.message import EmailMessage
|
|
17
|
+
from amsdal_mail.status import RecipientStatus
|
|
18
|
+
from amsdal_mail.status import SendStatus
|
|
19
|
+
|
|
20
|
+
_SMTP_PERMANENT_ERROR_CODE = 500
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SMTPBackend(BaseEmailBackend):
|
|
24
|
+
"""
|
|
25
|
+
Email backend that sends messages via SMTP.
|
|
26
|
+
|
|
27
|
+
Supports TLS/SSL encryption and authentication.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
host: str | None = None,
|
|
34
|
+
port: int | None = None,
|
|
35
|
+
username: str | None = None,
|
|
36
|
+
password: str | None = None,
|
|
37
|
+
use_tls: bool | None = None,
|
|
38
|
+
use_ssl: bool | None = None,
|
|
39
|
+
timeout: int | None = None,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Initialize the SMTP backend.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
host: SMTP server hostname (env: AMSDAL_EMAIL_HOST)
|
|
47
|
+
port: SMTP server port (env: AMSDAL_EMAIL_PORT)
|
|
48
|
+
username: Authentication username (env: AMSDAL_EMAIL_USER)
|
|
49
|
+
password: Authentication password (env: AMSDAL_EMAIL_PASSWORD)
|
|
50
|
+
use_tls: Use STARTTLS (env: AMSDAL_EMAIL_USE_TLS)
|
|
51
|
+
use_ssl: Use SSL/TLS from start (env: AMSDAL_EMAIL_USE_SSL)
|
|
52
|
+
timeout: Connection timeout in seconds (env: AMSDAL_EMAIL_TIMEOUT)
|
|
53
|
+
**kwargs: Additional backend options
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(**kwargs)
|
|
56
|
+
|
|
57
|
+
# Load configuration from environment if not provided
|
|
58
|
+
self.host = host or os.environ.get('AMSDAL_EMAIL_HOST', 'localhost')
|
|
59
|
+
self.port = port or int(os.environ.get('AMSDAL_EMAIL_PORT', '25'))
|
|
60
|
+
self.username = username or os.environ.get('AMSDAL_EMAIL_USER')
|
|
61
|
+
self.password = password or os.environ.get('AMSDAL_EMAIL_PASSWORD')
|
|
62
|
+
self.use_tls = use_tls if use_tls is not None else os.environ.get('AMSDAL_EMAIL_USE_TLS', '').lower() == 'true'
|
|
63
|
+
self.use_ssl = use_ssl if use_ssl is not None else os.environ.get('AMSDAL_EMAIL_USE_SSL', '').lower() == 'true'
|
|
64
|
+
self.timeout = timeout or int(os.environ.get('AMSDAL_EMAIL_TIMEOUT', '30'))
|
|
65
|
+
|
|
66
|
+
self.connection: smtplib.SMTP | smtplib.SMTP_SSL | None = None
|
|
67
|
+
|
|
68
|
+
def open(self) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Open a connection to the SMTP server.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
EmailConnectionError: If connection fails and fail_silently is False
|
|
74
|
+
"""
|
|
75
|
+
if self.connection:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
if self.use_ssl:
|
|
80
|
+
# Create SSL context
|
|
81
|
+
context = ssl.create_default_context()
|
|
82
|
+
self.connection = smtplib.SMTP_SSL(
|
|
83
|
+
self.host,
|
|
84
|
+
self.port,
|
|
85
|
+
timeout=self.timeout,
|
|
86
|
+
context=context,
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
self.connection = smtplib.SMTP(
|
|
90
|
+
self.host,
|
|
91
|
+
self.port,
|
|
92
|
+
timeout=self.timeout,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if self.use_tls:
|
|
96
|
+
# Upgrade connection to TLS
|
|
97
|
+
context = ssl.create_default_context()
|
|
98
|
+
self.connection.starttls(context=context)
|
|
99
|
+
|
|
100
|
+
# Authenticate if credentials provided
|
|
101
|
+
if self.username and self.password:
|
|
102
|
+
self.connection.login(self.username, self.password)
|
|
103
|
+
|
|
104
|
+
except (smtplib.SMTPException, OSError, TimeoutError) as e:
|
|
105
|
+
if not self.fail_silently:
|
|
106
|
+
msg = f'Failed to connect to SMTP server: {e}'
|
|
107
|
+
raise EmailConnectionError(msg) from e
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""Close the connection to the SMTP server."""
|
|
111
|
+
if self.connection:
|
|
112
|
+
try:
|
|
113
|
+
self.connection.quit()
|
|
114
|
+
except (smtplib.SMTPException, OSError):
|
|
115
|
+
# If quit() fails, try close() instead
|
|
116
|
+
try:
|
|
117
|
+
self.connection.close()
|
|
118
|
+
except (smtplib.SMTPException, OSError):
|
|
119
|
+
pass
|
|
120
|
+
finally:
|
|
121
|
+
self.connection = None
|
|
122
|
+
|
|
123
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
124
|
+
"""
|
|
125
|
+
Send one or more email messages via SMTP.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
email_messages: List of EmailMessage instances to send
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
SendStatus with per-recipient information
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
SendError: If sending fails and fail_silently is False
|
|
135
|
+
"""
|
|
136
|
+
status = SendStatus()
|
|
137
|
+
|
|
138
|
+
if not email_messages:
|
|
139
|
+
return status
|
|
140
|
+
|
|
141
|
+
# Open connection
|
|
142
|
+
self.open()
|
|
143
|
+
|
|
144
|
+
if not self.connection:
|
|
145
|
+
return status
|
|
146
|
+
|
|
147
|
+
new_connection = True
|
|
148
|
+
recipients = {}
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
for message in email_messages:
|
|
152
|
+
try:
|
|
153
|
+
# Convert to MIME format
|
|
154
|
+
mime_message = message.as_mime()
|
|
155
|
+
|
|
156
|
+
# Send message - returns dict of refused recipients
|
|
157
|
+
refused = self.connection.sendmail(
|
|
158
|
+
str(message.from_email),
|
|
159
|
+
message.recipients(),
|
|
160
|
+
mime_message.as_string(),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Process results for each recipient
|
|
164
|
+
for recipient in message.recipients():
|
|
165
|
+
if recipient in refused:
|
|
166
|
+
# Recipient was refused
|
|
167
|
+
smtp_code, _smtp_msg = refused[recipient]
|
|
168
|
+
recipients[recipient] = RecipientStatus(
|
|
169
|
+
message_id=None,
|
|
170
|
+
status='rejected' if smtp_code >= _SMTP_PERMANENT_ERROR_CODE else 'failed',
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
# Recipient was accepted
|
|
174
|
+
recipients[recipient] = RecipientStatus(
|
|
175
|
+
message_id=None, # SMTP doesn't provide message IDs
|
|
176
|
+
status='sent',
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
except (smtplib.SMTPException, OSError) as e:
|
|
180
|
+
# All recipients failed for this message
|
|
181
|
+
for recipient in message.recipients():
|
|
182
|
+
recipients[recipient] = RecipientStatus(
|
|
183
|
+
message_id=None,
|
|
184
|
+
status='failed',
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not self.fail_silently:
|
|
188
|
+
msg = f'Failed to send email: {e}'
|
|
189
|
+
raise SendError(msg) from e
|
|
190
|
+
|
|
191
|
+
finally:
|
|
192
|
+
# Close connection if we opened it
|
|
193
|
+
if new_connection:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
status.set_recipient_status(recipients)
|
|
197
|
+
return status
|
|
198
|
+
|
|
199
|
+
async def asend_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
200
|
+
"""
|
|
201
|
+
Send one or more email messages via SMTP asynchronously.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
email_messages: List of EmailMessage instances to send
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
SendStatus with per-recipient information
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
SendError: If sending fails and fail_silently is False
|
|
211
|
+
ImportError: If aiosmtplib is not installed
|
|
212
|
+
"""
|
|
213
|
+
if aiosmtplib is None:
|
|
214
|
+
msg = 'aiosmtplib is required for async SMTP. Install with: pip install amsdal-mail[smtp]'
|
|
215
|
+
raise ImportError(msg)
|
|
216
|
+
|
|
217
|
+
status = SendStatus()
|
|
218
|
+
|
|
219
|
+
if not email_messages:
|
|
220
|
+
return status
|
|
221
|
+
|
|
222
|
+
recipients = {}
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
# Create async SMTP client
|
|
226
|
+
if self.use_ssl:
|
|
227
|
+
smtp = aiosmtplib.SMTP(
|
|
228
|
+
hostname=self.host,
|
|
229
|
+
port=self.port,
|
|
230
|
+
timeout=self.timeout,
|
|
231
|
+
use_tls=True,
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
smtp = aiosmtplib.SMTP(
|
|
235
|
+
hostname=self.host,
|
|
236
|
+
port=self.port,
|
|
237
|
+
timeout=self.timeout,
|
|
238
|
+
use_tls=False,
|
|
239
|
+
start_tls=self.use_tls,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Connect and authenticate
|
|
243
|
+
await smtp.connect()
|
|
244
|
+
|
|
245
|
+
if self.use_tls and not self.use_ssl:
|
|
246
|
+
await smtp.starttls(validate_certs=True)
|
|
247
|
+
|
|
248
|
+
if self.username and self.password:
|
|
249
|
+
await smtp.login(self.username, self.password)
|
|
250
|
+
|
|
251
|
+
# Send messages
|
|
252
|
+
for message in email_messages:
|
|
253
|
+
try:
|
|
254
|
+
# Convert to MIME format
|
|
255
|
+
mime_message = message.as_mime()
|
|
256
|
+
|
|
257
|
+
# Send message - aiosmtplib returns (response, message) tuple or errors dict
|
|
258
|
+
result = await smtp.sendmail(
|
|
259
|
+
str(message.from_email),
|
|
260
|
+
message.recipients(),
|
|
261
|
+
mime_message.as_string(),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# aiosmtplib sendmail returns errors dict (like smtplib)
|
|
265
|
+
# Empty dict means all recipients accepted
|
|
266
|
+
refused: dict[str, Any] = result if isinstance(result, dict) else {}
|
|
267
|
+
|
|
268
|
+
# Process results for each recipient
|
|
269
|
+
for recipient in message.recipients():
|
|
270
|
+
if recipient in refused:
|
|
271
|
+
# Recipient was refused
|
|
272
|
+
smtp_code, _smtp_msg = refused[recipient]
|
|
273
|
+
recipients[recipient] = RecipientStatus(
|
|
274
|
+
message_id=None,
|
|
275
|
+
status='rejected' if smtp_code >= _SMTP_PERMANENT_ERROR_CODE else 'failed',
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
# Recipient was accepted
|
|
279
|
+
recipients[recipient] = RecipientStatus(
|
|
280
|
+
message_id=None, # SMTP doesn't provide message IDs
|
|
281
|
+
status='sent',
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
except (aiosmtplib.SMTPException, OSError) as e:
|
|
285
|
+
# All recipients failed for this message
|
|
286
|
+
for recipient in message.recipients():
|
|
287
|
+
recipients[recipient] = RecipientStatus(
|
|
288
|
+
message_id=None,
|
|
289
|
+
status='failed',
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if not self.fail_silently:
|
|
293
|
+
msg = f'Failed to send email: {e}'
|
|
294
|
+
raise SendError(msg) from e
|
|
295
|
+
|
|
296
|
+
# Close connection
|
|
297
|
+
await smtp.quit()
|
|
298
|
+
|
|
299
|
+
except (aiosmtplib.SMTPException, OSError, TimeoutError) as e:
|
|
300
|
+
if not self.fail_silently:
|
|
301
|
+
msg = f'Failed to connect to SMTP server: {e}'
|
|
302
|
+
raise EmailConnectionError(msg) from e
|
|
303
|
+
|
|
304
|
+
status.set_recipient_status(recipients)
|
|
305
|
+
return status
|
amsdal_mail/events.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Webhook tracking event definitions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from amsdal_utils.events import ErrorStrategy
|
|
8
|
+
from amsdal_utils.events import Event
|
|
9
|
+
from amsdal_utils.events import EventContext
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TrackingEventType(StrEnum):
|
|
14
|
+
QUEUED = 'queued'
|
|
15
|
+
SENT = 'sent'
|
|
16
|
+
DELIVERED = 'delivered'
|
|
17
|
+
DEFERRED = 'deferred'
|
|
18
|
+
BOUNCED = 'bounced'
|
|
19
|
+
REJECTED = 'rejected'
|
|
20
|
+
FAILED = 'failed'
|
|
21
|
+
OPENED = 'opened'
|
|
22
|
+
CLICKED = 'clicked'
|
|
23
|
+
COMPLAINED = 'complained'
|
|
24
|
+
UNSUBSCRIBED = 'unsubscribed'
|
|
25
|
+
UNKNOWN = 'unknown'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EmailTrackingContext(EventContext):
|
|
29
|
+
event_type: TrackingEventType
|
|
30
|
+
message_id: str
|
|
31
|
+
recipient: str
|
|
32
|
+
timestamp: datetime
|
|
33
|
+
esp_name: str
|
|
34
|
+
tags: list[str] = Field(default_factory=list)
|
|
35
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
36
|
+
reject_reason: str | None = None
|
|
37
|
+
description: str | None = None
|
|
38
|
+
click_url: str | None = None
|
|
39
|
+
user_agent: str | None = None
|
|
40
|
+
raw_event: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EmailTrackingEvent(Event[EmailTrackingContext]):
|
|
44
|
+
"""Emitted when webhook receives tracking event from ESP."""
|
|
45
|
+
|
|
46
|
+
default_error_strategy = ErrorStrategy.LOG_AND_CONTINUE
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception classes for amsdal_mail."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmailError(Exception):
|
|
5
|
+
"""Base exception for all email-related errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(EmailError):
|
|
11
|
+
"""Raised when an email backend is misconfigured."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailConnectionError(EmailError):
|
|
17
|
+
"""Raised when a connection to an email service fails."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SendError(EmailError):
|
|
23
|
+
"""Raised when sending an email message fails."""
|
|
24
|
+
|
|
25
|
+
pass
|
amsdal_mail/message.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Email message data models."""
|
|
2
|
+
|
|
3
|
+
from email.mime.base import MIMEBase
|
|
4
|
+
from email.mime.multipart import MIMEMultipart
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
from pydantic import EmailStr
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from pydantic import field_validator
|
|
13
|
+
from pydantic import model_validator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Attachment(BaseModel):
|
|
17
|
+
"""Represents an email attachment."""
|
|
18
|
+
|
|
19
|
+
filename: str = Field(..., description='Attachment filename')
|
|
20
|
+
content: bytes = Field(..., description='File content as bytes')
|
|
21
|
+
mimetype: str = Field(..., description='MIME type (e.g., application/pdf)')
|
|
22
|
+
content_id: str | None = Field(default=None, description='Content-ID for inline images (used in cid: URLs)')
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmailMessage(BaseModel):
|
|
28
|
+
"""Represents an email message."""
|
|
29
|
+
|
|
30
|
+
subject: str = Field(..., description='Email subject line')
|
|
31
|
+
body: str = Field(default='', description='Plain text body (optional if template_id is provided)')
|
|
32
|
+
from_email: EmailStr | str = Field(..., description='Sender email address')
|
|
33
|
+
to: list[EmailStr | str] = Field(..., description='Primary recipients')
|
|
34
|
+
cc: list[EmailStr | str] = Field(default_factory=list, description='Carbon copy recipients')
|
|
35
|
+
bcc: list[EmailStr | str] = Field(default_factory=list, description='Blind carbon copy recipients')
|
|
36
|
+
reply_to: list[EmailStr | str] = Field(default_factory=list, description='Reply-to addresses')
|
|
37
|
+
attachments: list[Attachment] = Field(default_factory=list, description='File attachments')
|
|
38
|
+
headers: dict[str, str] = Field(default_factory=dict, description='Custom email headers')
|
|
39
|
+
html_body: str | None = Field(default=None, description='HTML version of body')
|
|
40
|
+
tags: list[str] = Field(default_factory=list, description='Message tags for categorization and filtering')
|
|
41
|
+
metadata: dict[str, str] = Field(default_factory=dict, description='Custom key-value metadata for tracking')
|
|
42
|
+
template_id: str | None = Field(default=None, description='ESP template ID for templated emails')
|
|
43
|
+
merge_data: dict[str, dict[str, Any]] | None = Field(
|
|
44
|
+
default=None, description='Per-recipient template variables (recipient email -> variables)'
|
|
45
|
+
)
|
|
46
|
+
merge_global_data: dict[str, Any] | None = Field(
|
|
47
|
+
default=None, description='Global template variables for all recipients'
|
|
48
|
+
)
|
|
49
|
+
track_opens: bool = Field(
|
|
50
|
+
default=False, description='Enable open tracking (ESP inserts tracking pixel in HTML emails)'
|
|
51
|
+
)
|
|
52
|
+
track_clicks: bool = Field(default=False, description='Enable click tracking (ESP rewrites URLs to track clicks)')
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra='allow')
|
|
55
|
+
|
|
56
|
+
@field_validator('to', 'cc', 'bcc', 'reply_to', mode='before')
|
|
57
|
+
@classmethod
|
|
58
|
+
def ensure_list(cls, v: Any) -> list[str]:
|
|
59
|
+
"""Ensure email fields are lists."""
|
|
60
|
+
if isinstance(v, str):
|
|
61
|
+
return [v]
|
|
62
|
+
return v or []
|
|
63
|
+
|
|
64
|
+
@model_validator(mode='after')
|
|
65
|
+
def validate_body_or_template(self) -> 'EmailMessage':
|
|
66
|
+
"""Ensure either body or template_id is provided."""
|
|
67
|
+
if not self.body and not self.template_id:
|
|
68
|
+
msg = 'Either body or template_id must be provided'
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def as_mime(self) -> MIMEMultipart | MIMEText:
|
|
73
|
+
"""Convert EmailMessage to MIME message for SMTP sending."""
|
|
74
|
+
from email import encoders
|
|
75
|
+
|
|
76
|
+
# Determine message structure
|
|
77
|
+
has_html = self.html_body is not None
|
|
78
|
+
inline_attachments = [a for a in self.attachments if a.content_id]
|
|
79
|
+
regular_attachments = [a for a in self.attachments if not a.content_id]
|
|
80
|
+
has_inline = len(inline_attachments) > 0
|
|
81
|
+
has_regular = len(regular_attachments) > 0
|
|
82
|
+
|
|
83
|
+
msg: MIMEMultipart | MIMEText
|
|
84
|
+
|
|
85
|
+
# Build the body part (text or alternative)
|
|
86
|
+
body_part: MIMEMultipart | MIMEText
|
|
87
|
+
if has_html:
|
|
88
|
+
body_part = MIMEMultipart('alternative')
|
|
89
|
+
body_part.attach(MIMEText(self.body, 'plain', 'utf-8'))
|
|
90
|
+
body_part.attach(MIMEText(self.html_body or '', 'html', 'utf-8'))
|
|
91
|
+
else:
|
|
92
|
+
body_part = MIMEText(self.body, 'plain', 'utf-8')
|
|
93
|
+
|
|
94
|
+
# Wrap with related if inline attachments exist
|
|
95
|
+
content_part: MIMEMultipart | MIMEText
|
|
96
|
+
if has_inline:
|
|
97
|
+
related_part = MIMEMultipart('related')
|
|
98
|
+
related_part.attach(body_part)
|
|
99
|
+
for attachment in inline_attachments:
|
|
100
|
+
part = MIMEBase(*attachment.mimetype.split('/', 1))
|
|
101
|
+
part.set_payload(attachment.content)
|
|
102
|
+
encoders.encode_base64(part)
|
|
103
|
+
part.add_header('Content-Disposition', 'inline', filename=attachment.filename)
|
|
104
|
+
part.add_header('Content-ID', f'<{attachment.content_id}>')
|
|
105
|
+
related_part.attach(part)
|
|
106
|
+
content_part = related_part
|
|
107
|
+
else:
|
|
108
|
+
content_part = body_part
|
|
109
|
+
|
|
110
|
+
# Wrap with mixed if regular attachments exist
|
|
111
|
+
if has_regular:
|
|
112
|
+
msg = MIMEMultipart('mixed')
|
|
113
|
+
msg.attach(content_part)
|
|
114
|
+
for attachment in regular_attachments:
|
|
115
|
+
part = MIMEBase(*attachment.mimetype.split('/', 1))
|
|
116
|
+
part.set_payload(attachment.content)
|
|
117
|
+
encoders.encode_base64(part)
|
|
118
|
+
part.add_header('Content-Disposition', f'attachment; filename="{attachment.filename}"')
|
|
119
|
+
msg.attach(part)
|
|
120
|
+
else:
|
|
121
|
+
msg = content_part
|
|
122
|
+
|
|
123
|
+
# Set headers
|
|
124
|
+
msg['Subject'] = self.subject
|
|
125
|
+
msg['From'] = self.from_email
|
|
126
|
+
msg['To'] = ', '.join(str(addr) for addr in self.to)
|
|
127
|
+
|
|
128
|
+
if self.cc:
|
|
129
|
+
msg['Cc'] = ', '.join(str(addr) for addr in self.cc)
|
|
130
|
+
|
|
131
|
+
if self.reply_to:
|
|
132
|
+
msg['Reply-To'] = ', '.join(str(addr) for addr in self.reply_to)
|
|
133
|
+
|
|
134
|
+
# Add custom headers
|
|
135
|
+
for key, value in self.headers.items():
|
|
136
|
+
msg[key] = value
|
|
137
|
+
|
|
138
|
+
return msg
|
|
139
|
+
|
|
140
|
+
def recipients(self) -> list[str]:
|
|
141
|
+
"""Get all recipients (to, cc, bcc)."""
|
|
142
|
+
return [str(addr) for addr in self.to + self.cc + self.bcc]
|
|
143
|
+
|
|
144
|
+
def get_template_data(self, recipient: str) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
Get merged template data for a specific recipient.
|
|
147
|
+
|
|
148
|
+
Combines merge_global_data with recipient-specific merge_data.
|
|
149
|
+
Recipient-specific data takes precedence over global data.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
recipient: Email address of the recipient
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Dictionary of template variables for the recipient
|
|
156
|
+
"""
|
|
157
|
+
data: dict[str, Any] = {}
|
|
158
|
+
|
|
159
|
+
# Start with global data
|
|
160
|
+
if self.merge_global_data:
|
|
161
|
+
data.update(self.merge_global_data)
|
|
162
|
+
|
|
163
|
+
# Override with recipient-specific data
|
|
164
|
+
if self.merge_data and recipient in self.merge_data:
|
|
165
|
+
data.update(self.merge_data[recipient])
|
|
166
|
+
|
|
167
|
+
return data
|
amsdal_mail/py.typed
ADDED
|
File without changes
|
amsdal_mail/settings.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Mail plugin settings."""
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
from pydantic_settings import SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailSettings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(
|
|
9
|
+
env_prefix='AMSDAL_MAIL_',
|
|
10
|
+
case_sensitive=True,
|
|
11
|
+
env_file='.env',
|
|
12
|
+
env_file_encoding='utf-8',
|
|
13
|
+
extra='ignore',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
WEBHOOK_ENABLED: bool = False
|
|
17
|
+
WEBHOOK_BASE_PATH: str = '/webhooks/mail'
|
|
18
|
+
WEBHOOK_SES_SECRET: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
mail_settings = MailSettings()
|