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,433 @@
|
|
|
1
|
+
"""AWS SES email backend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import aioboto3
|
|
9
|
+
import boto3
|
|
10
|
+
except ImportError:
|
|
11
|
+
boto3 = None # type: ignore[assignment]
|
|
12
|
+
aioboto3 = None # type: ignore[assignment]
|
|
13
|
+
|
|
14
|
+
from amsdal_mail.backends.base import BaseEmailBackend
|
|
15
|
+
from amsdal_mail.exceptions import ConfigurationError
|
|
16
|
+
from amsdal_mail.exceptions import EmailConnectionError
|
|
17
|
+
from amsdal_mail.exceptions import SendError
|
|
18
|
+
from amsdal_mail.message import EmailMessage
|
|
19
|
+
from amsdal_mail.status import RecipientStatus
|
|
20
|
+
from amsdal_mail.status import SendStatus
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SESBackend(BaseEmailBackend):
|
|
24
|
+
"""
|
|
25
|
+
Email backend that sends messages via AWS Simple Email Service (SES).
|
|
26
|
+
|
|
27
|
+
Uses boto3 for synchronous operations and aioboto3 for async operations.
|
|
28
|
+
Sends raw MIME messages to support full email features including attachments.
|
|
29
|
+
|
|
30
|
+
Note on Click/Open Tracking:
|
|
31
|
+
SES does not support track_opens/track_clicks parameters directly in the API.
|
|
32
|
+
To enable click and open tracking with SES:
|
|
33
|
+
1. Create a Configuration Set in AWS SES Console
|
|
34
|
+
2. Add Event Destinations (SNS, CloudWatch, Kinesis, or Firehose)
|
|
35
|
+
3. Enable "Click" and "Open" event types in the configuration set
|
|
36
|
+
4. Pass the configuration_set name when initializing this backend
|
|
37
|
+
|
|
38
|
+
The track_opens and track_clicks fields in EmailMessage are included for
|
|
39
|
+
compatibility with other backends (SendGrid, Mailgun) but are ignored by SES.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
region_name: str | None = None,
|
|
46
|
+
aws_access_key_id: str | None = None,
|
|
47
|
+
aws_secret_access_key: str | None = None,
|
|
48
|
+
configuration_set: str | None = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Initialize the SES backend.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
region_name: AWS region (env: AWS_REGION or AWS_DEFAULT_REGION)
|
|
56
|
+
aws_access_key_id: AWS access key (env: AWS_ACCESS_KEY_ID)
|
|
57
|
+
aws_secret_access_key: AWS secret key (env: AWS_SECRET_ACCESS_KEY)
|
|
58
|
+
configuration_set: SES configuration set name (env: AWS_SES_CONFIGURATION_SET)
|
|
59
|
+
**kwargs: Additional backend options
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ImportError: If boto3 or aioboto3 is not installed
|
|
63
|
+
ConfigurationError: If required configuration is missing
|
|
64
|
+
"""
|
|
65
|
+
super().__init__(**kwargs)
|
|
66
|
+
|
|
67
|
+
if boto3 is None:
|
|
68
|
+
msg = 'boto3 is required for SES backend. Install with: pip install amsdal-mail[ses]'
|
|
69
|
+
raise ImportError(msg)
|
|
70
|
+
|
|
71
|
+
# Load configuration from environment if not provided
|
|
72
|
+
self.region_name = (
|
|
73
|
+
region_name or os.environ.get('AWS_REGION') or os.environ.get('AWS_DEFAULT_REGION', 'us-east-1')
|
|
74
|
+
)
|
|
75
|
+
self.aws_access_key_id = aws_access_key_id or os.environ.get('AWS_ACCESS_KEY_ID')
|
|
76
|
+
self.aws_secret_access_key = aws_secret_access_key or os.environ.get('AWS_SECRET_ACCESS_KEY')
|
|
77
|
+
self.configuration_set = configuration_set or os.environ.get('AWS_SES_CONFIGURATION_SET')
|
|
78
|
+
|
|
79
|
+
# Validate configuration
|
|
80
|
+
if not self.aws_access_key_id or not self.aws_secret_access_key:
|
|
81
|
+
msg = (
|
|
82
|
+
'AWS credentials are required. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY '
|
|
83
|
+
'environment variables or pass them as arguments.'
|
|
84
|
+
)
|
|
85
|
+
raise ConfigurationError(msg)
|
|
86
|
+
|
|
87
|
+
# Create boto3 client (lazy loaded in send_messages)
|
|
88
|
+
self.client = None
|
|
89
|
+
|
|
90
|
+
def _get_client(self):
|
|
91
|
+
"""Get or create boto3 SES client."""
|
|
92
|
+
if self.client is None:
|
|
93
|
+
self.client = boto3.client(
|
|
94
|
+
'ses',
|
|
95
|
+
region_name=self.region_name,
|
|
96
|
+
aws_access_key_id=self.aws_access_key_id,
|
|
97
|
+
aws_secret_access_key=self.aws_secret_access_key,
|
|
98
|
+
)
|
|
99
|
+
return self.client
|
|
100
|
+
|
|
101
|
+
def _send_templated_message(self, client: Any, message: EmailMessage) -> dict[str, Any]:
|
|
102
|
+
"""
|
|
103
|
+
Send a templated email via SES send_templated_email API.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
client: boto3 SES client
|
|
107
|
+
message: EmailMessage with template_id
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
SES API response
|
|
111
|
+
"""
|
|
112
|
+
# Prepare template data - combine global and per-recipient data
|
|
113
|
+
template_data = message.get_template_data(str(message.to[0]))
|
|
114
|
+
|
|
115
|
+
send_params: dict[str, Any] = {
|
|
116
|
+
'Source': str(message.from_email),
|
|
117
|
+
'Destination': {
|
|
118
|
+
'ToAddresses': [str(addr) for addr in message.to],
|
|
119
|
+
},
|
|
120
|
+
'Template': message.template_id,
|
|
121
|
+
'TemplateData': json.dumps(template_data),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Add CC/BCC if present
|
|
125
|
+
if message.cc:
|
|
126
|
+
send_params['Destination']['CcAddresses'] = [str(addr) for addr in message.cc]
|
|
127
|
+
if message.bcc:
|
|
128
|
+
send_params['Destination']['BccAddresses'] = [str(addr) for addr in message.bcc]
|
|
129
|
+
|
|
130
|
+
# Add reply-to if present
|
|
131
|
+
if message.reply_to:
|
|
132
|
+
send_params['ReplyToAddresses'] = [str(addr) for addr in message.reply_to]
|
|
133
|
+
|
|
134
|
+
# Add configuration set if specified
|
|
135
|
+
if self.configuration_set:
|
|
136
|
+
send_params['ConfigurationSetName'] = self.configuration_set
|
|
137
|
+
|
|
138
|
+
# Add tags if specified
|
|
139
|
+
if message.tags:
|
|
140
|
+
send_params['Tags'] = [{'Name': tag, 'Value': 'true'} for tag in message.tags]
|
|
141
|
+
|
|
142
|
+
# Add metadata as tags
|
|
143
|
+
if message.metadata:
|
|
144
|
+
metadata_tags = [{'Name': f'metadata:{key}', 'Value': value} for key, value in message.metadata.items()]
|
|
145
|
+
if 'Tags' in send_params:
|
|
146
|
+
send_params['Tags'].extend(metadata_tags)
|
|
147
|
+
else:
|
|
148
|
+
send_params['Tags'] = metadata_tags
|
|
149
|
+
|
|
150
|
+
return client.send_templated_email(**send_params)
|
|
151
|
+
|
|
152
|
+
def _send_raw_message(self, client: Any, message: EmailMessage) -> dict[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
Send a raw MIME email via SES send_raw_email API.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
client: boto3 SES client
|
|
158
|
+
message: EmailMessage
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
SES API response
|
|
162
|
+
"""
|
|
163
|
+
# Convert to MIME format
|
|
164
|
+
mime_message = message.as_mime()
|
|
165
|
+
|
|
166
|
+
# Prepare send_raw_email parameters
|
|
167
|
+
raw_message = {'Data': mime_message.as_string()}
|
|
168
|
+
|
|
169
|
+
send_params: dict[str, Any] = {
|
|
170
|
+
'Source': str(message.from_email),
|
|
171
|
+
'Destinations': message.recipients(),
|
|
172
|
+
'RawMessage': raw_message,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Add configuration set if specified
|
|
176
|
+
if self.configuration_set:
|
|
177
|
+
send_params['ConfigurationSetName'] = self.configuration_set
|
|
178
|
+
|
|
179
|
+
# Add tags if specified
|
|
180
|
+
if message.tags:
|
|
181
|
+
send_params['Tags'] = [{'Name': tag, 'Value': 'true'} for tag in message.tags]
|
|
182
|
+
|
|
183
|
+
# Add metadata as tags (SES supports up to 50 tags total)
|
|
184
|
+
if message.metadata:
|
|
185
|
+
metadata_tags = [{'Name': f'metadata:{key}', 'Value': value} for key, value in message.metadata.items()]
|
|
186
|
+
if 'Tags' in send_params:
|
|
187
|
+
send_params['Tags'].extend(metadata_tags)
|
|
188
|
+
else:
|
|
189
|
+
send_params['Tags'] = metadata_tags
|
|
190
|
+
|
|
191
|
+
return client.send_raw_email(**send_params)
|
|
192
|
+
|
|
193
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
194
|
+
"""
|
|
195
|
+
Send one or more email messages via AWS SES.
|
|
196
|
+
|
|
197
|
+
Supports both templated emails (via send_templated_email) and
|
|
198
|
+
regular emails (via send_raw_email).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
email_messages: List of EmailMessage instances to send
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
SendStatus with message IDs and statuses from SES
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
SendError: If sending fails and fail_silently is False
|
|
208
|
+
EmailConnectionError: If connection to SES fails
|
|
209
|
+
"""
|
|
210
|
+
status = SendStatus()
|
|
211
|
+
|
|
212
|
+
if not email_messages:
|
|
213
|
+
return status
|
|
214
|
+
|
|
215
|
+
client = self._get_client()
|
|
216
|
+
recipients = {}
|
|
217
|
+
|
|
218
|
+
for message in email_messages:
|
|
219
|
+
try:
|
|
220
|
+
# Choose API based on whether template is used
|
|
221
|
+
if message.template_id:
|
|
222
|
+
response = self._send_templated_message(client, message)
|
|
223
|
+
else:
|
|
224
|
+
response = self._send_raw_message(client, message)
|
|
225
|
+
|
|
226
|
+
# Extract message ID from SES response
|
|
227
|
+
message_id = response.get('MessageId')
|
|
228
|
+
|
|
229
|
+
if message_id:
|
|
230
|
+
# Mark all recipients as sent with the message ID
|
|
231
|
+
for recipient in message.recipients():
|
|
232
|
+
recipients[recipient] = RecipientStatus(
|
|
233
|
+
message_id=message_id,
|
|
234
|
+
status='sent', # SES queues immediately, consider it sent
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Store raw ESP response
|
|
238
|
+
if not status.esp_response:
|
|
239
|
+
status.esp_response = response
|
|
240
|
+
elif not self.fail_silently:
|
|
241
|
+
msg = 'SES did not return a MessageId'
|
|
242
|
+
raise SendError(msg)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
# Mark all recipients as failed
|
|
246
|
+
for recipient in message.recipients():
|
|
247
|
+
recipients[recipient] = RecipientStatus(
|
|
248
|
+
message_id=None,
|
|
249
|
+
status='failed',
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if not self.fail_silently:
|
|
253
|
+
if 'Client' in str(type(e).__name__):
|
|
254
|
+
msg = f'Failed to connect to AWS SES: {e}'
|
|
255
|
+
raise EmailConnectionError(msg) from e
|
|
256
|
+
msg = f'Failed to send email via SES: {e}'
|
|
257
|
+
raise SendError(msg) from e
|
|
258
|
+
|
|
259
|
+
status.set_recipient_status(recipients)
|
|
260
|
+
return status
|
|
261
|
+
|
|
262
|
+
async def _asend_templated_message(self, client: Any, message: EmailMessage) -> dict[str, Any]:
|
|
263
|
+
"""
|
|
264
|
+
Send a templated email via SES send_templated_email API (async).
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
client: aioboto3 SES client
|
|
268
|
+
message: EmailMessage with template_id
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
SES API response
|
|
272
|
+
"""
|
|
273
|
+
# Prepare template data - combine global and per-recipient data
|
|
274
|
+
template_data = message.get_template_data(str(message.to[0]))
|
|
275
|
+
|
|
276
|
+
send_params: dict[str, Any] = {
|
|
277
|
+
'Source': str(message.from_email),
|
|
278
|
+
'Destination': {
|
|
279
|
+
'ToAddresses': [str(addr) for addr in message.to],
|
|
280
|
+
},
|
|
281
|
+
'Template': message.template_id,
|
|
282
|
+
'TemplateData': json.dumps(template_data),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Add CC/BCC if present
|
|
286
|
+
if message.cc:
|
|
287
|
+
send_params['Destination']['CcAddresses'] = [str(addr) for addr in message.cc]
|
|
288
|
+
if message.bcc:
|
|
289
|
+
send_params['Destination']['BccAddresses'] = [str(addr) for addr in message.bcc]
|
|
290
|
+
|
|
291
|
+
# Add reply-to if present
|
|
292
|
+
if message.reply_to:
|
|
293
|
+
send_params['ReplyToAddresses'] = [str(addr) for addr in message.reply_to]
|
|
294
|
+
|
|
295
|
+
# Add configuration set if specified
|
|
296
|
+
if self.configuration_set:
|
|
297
|
+
send_params['ConfigurationSetName'] = self.configuration_set
|
|
298
|
+
|
|
299
|
+
# Add tags if specified
|
|
300
|
+
if message.tags:
|
|
301
|
+
send_params['Tags'] = [{'Name': tag, 'Value': 'true'} for tag in message.tags]
|
|
302
|
+
|
|
303
|
+
# Add metadata as tags
|
|
304
|
+
if message.metadata:
|
|
305
|
+
metadata_tags = [{'Name': f'metadata:{key}', 'Value': value} for key, value in message.metadata.items()]
|
|
306
|
+
if 'Tags' in send_params:
|
|
307
|
+
send_params['Tags'].extend(metadata_tags)
|
|
308
|
+
else:
|
|
309
|
+
send_params['Tags'] = metadata_tags
|
|
310
|
+
|
|
311
|
+
return await client.send_templated_email(**send_params)
|
|
312
|
+
|
|
313
|
+
async def _asend_raw_message(self, client: Any, message: EmailMessage) -> dict[str, Any]:
|
|
314
|
+
"""
|
|
315
|
+
Send a raw MIME email via SES send_raw_email API (async).
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
client: aioboto3 SES client
|
|
319
|
+
message: EmailMessage
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
SES API response
|
|
323
|
+
"""
|
|
324
|
+
# Convert to MIME format
|
|
325
|
+
mime_message = message.as_mime()
|
|
326
|
+
|
|
327
|
+
# Prepare send_raw_email parameters
|
|
328
|
+
raw_message = {'Data': mime_message.as_string()}
|
|
329
|
+
|
|
330
|
+
send_params: dict[str, Any] = {
|
|
331
|
+
'Source': str(message.from_email),
|
|
332
|
+
'Destinations': message.recipients(),
|
|
333
|
+
'RawMessage': raw_message,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# Add configuration set if specified
|
|
337
|
+
if self.configuration_set:
|
|
338
|
+
send_params['ConfigurationSetName'] = self.configuration_set
|
|
339
|
+
|
|
340
|
+
# Add tags if specified
|
|
341
|
+
if message.tags:
|
|
342
|
+
send_params['Tags'] = [{'Name': tag, 'Value': 'true'} for tag in message.tags]
|
|
343
|
+
|
|
344
|
+
# Add metadata as tags (SES supports up to 50 tags total)
|
|
345
|
+
if message.metadata:
|
|
346
|
+
metadata_tags = [{'Name': f'metadata:{key}', 'Value': value} for key, value in message.metadata.items()]
|
|
347
|
+
if 'Tags' in send_params:
|
|
348
|
+
send_params['Tags'].extend(metadata_tags)
|
|
349
|
+
else:
|
|
350
|
+
send_params['Tags'] = metadata_tags
|
|
351
|
+
|
|
352
|
+
return await client.send_raw_email(**send_params)
|
|
353
|
+
|
|
354
|
+
async def asend_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
355
|
+
"""
|
|
356
|
+
Send one or more email messages via AWS SES asynchronously.
|
|
357
|
+
|
|
358
|
+
Supports both templated emails (via send_templated_email) and
|
|
359
|
+
regular emails (via send_raw_email).
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
email_messages: List of EmailMessage instances to send
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
SendStatus with message IDs and statuses from SES
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
SendError: If sending fails and fail_silently is False
|
|
369
|
+
EmailConnectionError: If connection to SES fails
|
|
370
|
+
ImportError: If aioboto3 is not installed
|
|
371
|
+
"""
|
|
372
|
+
if aioboto3 is None:
|
|
373
|
+
msg = 'aioboto3 is required for async SES. Install with: pip install amsdal-mail[ses]'
|
|
374
|
+
raise ImportError(msg)
|
|
375
|
+
|
|
376
|
+
status = SendStatus()
|
|
377
|
+
|
|
378
|
+
if not email_messages:
|
|
379
|
+
return status
|
|
380
|
+
|
|
381
|
+
recipients = {}
|
|
382
|
+
|
|
383
|
+
# Create async session
|
|
384
|
+
session = aioboto3.Session(
|
|
385
|
+
region_name=self.region_name,
|
|
386
|
+
aws_access_key_id=self.aws_access_key_id,
|
|
387
|
+
aws_secret_access_key=self.aws_secret_access_key,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
async with session.client('ses') as client:
|
|
391
|
+
for message in email_messages:
|
|
392
|
+
try:
|
|
393
|
+
# Choose API based on whether template is used
|
|
394
|
+
if message.template_id:
|
|
395
|
+
response = await self._asend_templated_message(client, message)
|
|
396
|
+
else:
|
|
397
|
+
response = await self._asend_raw_message(client, message)
|
|
398
|
+
|
|
399
|
+
# Extract message ID from SES response
|
|
400
|
+
message_id = response.get('MessageId')
|
|
401
|
+
|
|
402
|
+
if message_id:
|
|
403
|
+
# Mark all recipients as sent with the message ID
|
|
404
|
+
for recipient in message.recipients():
|
|
405
|
+
recipients[recipient] = RecipientStatus(
|
|
406
|
+
message_id=message_id,
|
|
407
|
+
status='sent', # SES queues immediately, consider it sent
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Store raw ESP response
|
|
411
|
+
if not status.esp_response:
|
|
412
|
+
status.esp_response = response
|
|
413
|
+
elif not self.fail_silently:
|
|
414
|
+
msg = 'SES did not return a MessageId'
|
|
415
|
+
raise SendError(msg)
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
# Mark all recipients as failed
|
|
419
|
+
for recipient in message.recipients():
|
|
420
|
+
recipients[recipient] = RecipientStatus(
|
|
421
|
+
message_id=None,
|
|
422
|
+
status='failed',
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if not self.fail_silently:
|
|
426
|
+
if 'Client' in str(type(e).__name__):
|
|
427
|
+
msg = f'Failed to connect to AWS SES: {e}'
|
|
428
|
+
raise EmailConnectionError(msg) from e
|
|
429
|
+
msg = f'Failed to send email via SES: {e}'
|
|
430
|
+
raise SendError(msg) from e
|
|
431
|
+
|
|
432
|
+
status.set_recipient_status(recipients)
|
|
433
|
+
return status
|