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.
@@ -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