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,799 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amsdal_mail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal email integration plugin for AMSDAL Framework
|
|
5
|
+
Author: AMSDAL Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: amsdal,email,mail,smtp
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: amsdal
|
|
17
|
+
Requires-Dist: amsdal-utils
|
|
18
|
+
Requires-Dist: pydantic[email]~=2.12
|
|
19
|
+
Provides-Extra: all
|
|
20
|
+
Requires-Dist: aioboto3~=15.4; extra == 'all'
|
|
21
|
+
Requires-Dist: aiosmtplib~=5.1; extra == 'all'
|
|
22
|
+
Requires-Dist: boto3~=1.40; extra == 'all'
|
|
23
|
+
Provides-Extra: ses
|
|
24
|
+
Requires-Dist: aioboto3~=15.4; extra == 'ses'
|
|
25
|
+
Requires-Dist: boto3~=1.40; extra == 'ses'
|
|
26
|
+
Provides-Extra: smtp
|
|
27
|
+
Requires-Dist: aiosmtplib~=5.1; extra == 'smtp'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# AMSDAL Mail
|
|
31
|
+
|
|
32
|
+
Universal email integration plugin for AMSDAL Framework. Provides a unified interface for sending emails through multiple backends (SMTP, AWS SES, etc.).
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Multiple Backends**: SMTP, AWS SES, Console, Dummy
|
|
37
|
+
- **Unified API**: Single interface for all email services
|
|
38
|
+
- **Async Support**: Native async/await support for all backends
|
|
39
|
+
- **Template Support**: Send templated emails with variable substitution (SES)
|
|
40
|
+
- **Tags & Metadata**: Tag and track emails for analytics and filtering
|
|
41
|
+
- **Click/Open Tracking**: Monitor email opens and link clicks
|
|
42
|
+
- **Inline Images**: Embed images in HTML via CID references
|
|
43
|
+
- **Type Safe**: Full Pydantic validation with type hints
|
|
44
|
+
- **Django-like API**: Familiar interface for Django developers
|
|
45
|
+
- **AMSDAL Integration**: Seamless integration with AMSDAL Framework lifecycle
|
|
46
|
+
- **Extensible**: Easy to add custom backends
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
- **[Quick Start](#quick-start)** - Get started quickly
|
|
51
|
+
- **[Configuration](#configuration)** - Backend and environment setup
|
|
52
|
+
- **[SMTP Usage Guide](docs/SMTP_USAGE.md)** - Detailed SMTP examples (Gmail, etc.)
|
|
53
|
+
- **[Architecture](docs/ARCHITECTURE.md)** - System design and patterns
|
|
54
|
+
- **[Tracking Guide](docs/TRACKING.md)** - Email click/open tracking setup
|
|
55
|
+
- **[Webhooks](#webhooks)** - Receiving tracking events from ESPs
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Basic installation
|
|
61
|
+
pip install amsdal-mail
|
|
62
|
+
|
|
63
|
+
# With specific backend support
|
|
64
|
+
pip install amsdal-mail[smtp]
|
|
65
|
+
pip install amsdal-mail[ses]
|
|
66
|
+
|
|
67
|
+
# All backends
|
|
68
|
+
pip install amsdal-mail[all]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
### Basic Usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from amsdal_mail import send_mail
|
|
77
|
+
|
|
78
|
+
# Send a simple email
|
|
79
|
+
status = send_mail(
|
|
80
|
+
subject='Hello from AMSDAL Mail',
|
|
81
|
+
message='This is a test email',
|
|
82
|
+
from_email='sender@example.com',
|
|
83
|
+
recipient_list=['recipient@example.com'],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
print(status.is_success) # True
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Async Usage
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import asyncio
|
|
93
|
+
from amsdal_mail import asend_mail
|
|
94
|
+
|
|
95
|
+
async def send_async_email():
|
|
96
|
+
status = await asend_mail(
|
|
97
|
+
subject='Async Email',
|
|
98
|
+
message='Sent asynchronously',
|
|
99
|
+
from_email='sender@example.com',
|
|
100
|
+
recipient_list=['recipient@example.com'],
|
|
101
|
+
)
|
|
102
|
+
print(status.is_success)
|
|
103
|
+
|
|
104
|
+
asyncio.run(send_async_email())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### HTML Emails
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from amsdal_mail import send_mail
|
|
111
|
+
|
|
112
|
+
send_mail(
|
|
113
|
+
subject='HTML Email',
|
|
114
|
+
message='Plain text version',
|
|
115
|
+
html_message='<h1>HTML version</h1>',
|
|
116
|
+
from_email='sender@example.com',
|
|
117
|
+
recipient_list=['recipient@example.com'],
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Advanced Usage
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from amsdal_mail import get_connection, EmailMessage, Attachment
|
|
125
|
+
|
|
126
|
+
# Get connection to specific backend
|
|
127
|
+
with get_connection('smtp') as conn:
|
|
128
|
+
# Create message with attachments
|
|
129
|
+
message = EmailMessage(
|
|
130
|
+
subject='Email with Attachment',
|
|
131
|
+
body='See attached document',
|
|
132
|
+
from_email='sender@example.com',
|
|
133
|
+
to=['recipient@example.com'],
|
|
134
|
+
cc=['cc@example.com'],
|
|
135
|
+
bcc=['bcc@example.com'],
|
|
136
|
+
reply_to=['reply@example.com'],
|
|
137
|
+
attachments=[
|
|
138
|
+
Attachment(
|
|
139
|
+
filename='document.pdf',
|
|
140
|
+
content=b'PDF content...',
|
|
141
|
+
mimetype='application/pdf',
|
|
142
|
+
)
|
|
143
|
+
],
|
|
144
|
+
headers={'X-Custom-Header': 'value'},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Send multiple messages in one connection
|
|
148
|
+
status = conn.send_messages([message])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Template Support
|
|
152
|
+
|
|
153
|
+
Send emails using ESP templates with variable substitution:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from amsdal_mail import EmailMessage, get_connection
|
|
157
|
+
|
|
158
|
+
# Basic template with global variables
|
|
159
|
+
message = EmailMessage(
|
|
160
|
+
subject='Welcome!',
|
|
161
|
+
from_email='noreply@example.com',
|
|
162
|
+
to=['user@example.com'],
|
|
163
|
+
template_id='welcome-template',
|
|
164
|
+
merge_global_data={
|
|
165
|
+
'company': 'Acme Inc',
|
|
166
|
+
'year': '2024',
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Template with per-recipient variables
|
|
171
|
+
message = EmailMessage(
|
|
172
|
+
subject='Order Confirmation',
|
|
173
|
+
from_email='orders@example.com',
|
|
174
|
+
to=['customer@example.com'],
|
|
175
|
+
template_id='order-confirmation',
|
|
176
|
+
merge_data={
|
|
177
|
+
'customer@example.com': {
|
|
178
|
+
'name': 'John Doe',
|
|
179
|
+
'order_id': '12345',
|
|
180
|
+
'total': '$99.99',
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
merge_global_data={
|
|
184
|
+
'company': 'Acme Inc',
|
|
185
|
+
'support_email': 'support@example.com',
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
backend = get_connection('ses')
|
|
190
|
+
backend.send_messages([message])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Note**: Template support is currently available for AWS SES backend. Templates must be created in your ESP dashboard first.
|
|
194
|
+
|
|
195
|
+
### Tags and Metadata
|
|
196
|
+
|
|
197
|
+
Add tags and metadata for tracking and analytics:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from amsdal_mail import EmailMessage
|
|
201
|
+
|
|
202
|
+
message = EmailMessage(
|
|
203
|
+
subject='Marketing Newsletter',
|
|
204
|
+
body='Newsletter content...',
|
|
205
|
+
from_email='marketing@example.com',
|
|
206
|
+
to=['subscriber@example.com'],
|
|
207
|
+
tags=['newsletter', 'marketing', 'q4-2024'],
|
|
208
|
+
metadata={
|
|
209
|
+
'campaign_id': 'fall-sale-2024',
|
|
210
|
+
'user_id': '12345',
|
|
211
|
+
'ab_test_variant': 'A',
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Tags and metadata are passed to the ESP and can be used for:
|
|
217
|
+
- Filtering and categorizing emails
|
|
218
|
+
- Analytics and reporting
|
|
219
|
+
- A/B testing tracking
|
|
220
|
+
- Custom event data
|
|
221
|
+
|
|
222
|
+
### Click and Open Tracking
|
|
223
|
+
|
|
224
|
+
Enable tracking to monitor email opens and link clicks:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from amsdal_mail import EmailMessage, get_connection
|
|
228
|
+
|
|
229
|
+
message = EmailMessage(
|
|
230
|
+
subject='Product Launch',
|
|
231
|
+
body='Check out our new product!',
|
|
232
|
+
html_body='<h1>New Product</h1><a href="https://example.com/product">Learn More</a>',
|
|
233
|
+
from_email='marketing@example.com',
|
|
234
|
+
to=['customer@example.com'],
|
|
235
|
+
track_opens=True, # Track when email is opened
|
|
236
|
+
track_clicks=True, # Track when links are clicked
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
backend = get_connection('ses')
|
|
240
|
+
backend.send_messages([message])
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Important Notes:**
|
|
244
|
+
- **Open tracking** only works for HTML emails (inserts invisible tracking pixel)
|
|
245
|
+
- **Click tracking** rewrites URLs to track clicks before redirecting
|
|
246
|
+
- **AWS SES**: Requires Configuration Set setup (see [docs/TRACKING.md](docs/TRACKING.md))
|
|
247
|
+
|
|
248
|
+
For detailed tracking setup instructions, see [docs/TRACKING.md](docs/TRACKING.md).
|
|
249
|
+
|
|
250
|
+
### Inline Images
|
|
251
|
+
|
|
252
|
+
Embed images directly in HTML emails using CID references:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from amsdal_mail import EmailMessage, Attachment, get_connection
|
|
256
|
+
|
|
257
|
+
message = EmailMessage(
|
|
258
|
+
subject='Email with Embedded Image',
|
|
259
|
+
body='Please enable HTML to view this email.',
|
|
260
|
+
html_body='<img src="cid:logo"><h1>Welcome!</h1>',
|
|
261
|
+
from_email='sender@example.com',
|
|
262
|
+
to=['recipient@example.com'],
|
|
263
|
+
attachments=[
|
|
264
|
+
Attachment(
|
|
265
|
+
filename='logo.png',
|
|
266
|
+
content=open('logo.png', 'rb').read(),
|
|
267
|
+
mimetype='image/png',
|
|
268
|
+
content_id='logo', # Referenced as cid:logo in HTML
|
|
269
|
+
),
|
|
270
|
+
],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
connection = get_connection('smtp')
|
|
274
|
+
status = connection.send_messages([message])
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
For more examples see [docs/SMTP_USAGE.md](docs/SMTP_USAGE.md).
|
|
278
|
+
|
|
279
|
+
## Configuration
|
|
280
|
+
|
|
281
|
+
### Environment Variables
|
|
282
|
+
|
|
283
|
+
#### Backend Selection
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
AMSDAL_EMAIL_BACKEND=smtp # console, smtp, dummy, ses
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### SMTP Configuration
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
AMSDAL_EMAIL_HOST=smtp.gmail.com
|
|
293
|
+
AMSDAL_EMAIL_PORT=587
|
|
294
|
+
AMSDAL_EMAIL_USER=your-email@gmail.com
|
|
295
|
+
AMSDAL_EMAIL_PASSWORD=your-password
|
|
296
|
+
AMSDAL_EMAIL_USE_TLS=true
|
|
297
|
+
AMSDAL_EMAIL_USE_SSL=false
|
|
298
|
+
AMSDAL_EMAIL_TIMEOUT=30
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### AWS SES Configuration
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
AMSDAL_EMAIL_BACKEND=ses
|
|
305
|
+
AWS_ACCESS_KEY_ID=your-access-key
|
|
306
|
+
AWS_SECRET_ACCESS_KEY=your-secret-key
|
|
307
|
+
AWS_REGION=us-east-1
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Programmatic Configuration
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from amsdal_mail import get_connection
|
|
314
|
+
|
|
315
|
+
# Override configuration
|
|
316
|
+
connection = get_connection(
|
|
317
|
+
backend='smtp',
|
|
318
|
+
host='smtp.example.com',
|
|
319
|
+
port=587,
|
|
320
|
+
username='user@example.com',
|
|
321
|
+
password='secret',
|
|
322
|
+
use_tls=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
connection.send_messages([message])
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Backends
|
|
329
|
+
|
|
330
|
+
### Console Backend
|
|
331
|
+
|
|
332
|
+
**Purpose**: Development and debugging
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from amsdal_mail import send_mail
|
|
336
|
+
|
|
337
|
+
# Outputs to stdout
|
|
338
|
+
send_mail(
|
|
339
|
+
subject='Test',
|
|
340
|
+
message='This will print to console',
|
|
341
|
+
from_email='sender@example.com',
|
|
342
|
+
recipient_list=['recipient@example.com'],
|
|
343
|
+
)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Output**:
|
|
347
|
+
```
|
|
348
|
+
Subject: Test
|
|
349
|
+
From: sender@example.com
|
|
350
|
+
To: recipient@example.com
|
|
351
|
+
Body:
|
|
352
|
+
This will print to console
|
|
353
|
+
----------------------------------------
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### SMTP Backend
|
|
357
|
+
|
|
358
|
+
**Purpose**: Generic SMTP servers (Gmail, Outlook, etc.)
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# Configuration
|
|
362
|
+
export AMSDAL_EMAIL_BACKEND=smtp
|
|
363
|
+
export AMSDAL_EMAIL_HOST=smtp.gmail.com
|
|
364
|
+
export AMSDAL_EMAIL_PORT=587
|
|
365
|
+
export AMSDAL_EMAIL_USER=your-email@gmail.com
|
|
366
|
+
export AMSDAL_EMAIL_PASSWORD=your-app-password
|
|
367
|
+
export AMSDAL_EMAIL_USE_TLS=true
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Gmail Example**:
|
|
371
|
+
1. Enable 2FA in Google Account
|
|
372
|
+
2. Generate App Password
|
|
373
|
+
3. Use App Password in `AMSDAL_EMAIL_PASSWORD`
|
|
374
|
+
|
|
375
|
+
For detailed SMTP examples, see [docs/SMTP_USAGE.md](docs/SMTP_USAGE.md).
|
|
376
|
+
|
|
377
|
+
### AWS SES Backend
|
|
378
|
+
|
|
379
|
+
**Purpose**: Amazon Simple Email Service
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
export AMSDAL_EMAIL_BACKEND=ses
|
|
383
|
+
export AWS_ACCESS_KEY_ID=your-key
|
|
384
|
+
export AWS_SECRET_ACCESS_KEY=your-secret
|
|
385
|
+
export AWS_REGION=us-east-1
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Dummy Backend
|
|
389
|
+
|
|
390
|
+
**Purpose**: Testing (no actual sending)
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
from amsdal_mail import get_connection
|
|
394
|
+
|
|
395
|
+
# Nothing is sent, but returns success status
|
|
396
|
+
connection = get_connection('dummy')
|
|
397
|
+
status = connection.send_messages([message])
|
|
398
|
+
print(status.is_success) # True
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
## AMSDAL Framework Integration
|
|
402
|
+
|
|
403
|
+
### Register Plugin
|
|
404
|
+
|
|
405
|
+
Add to your AMSDAL application configuration:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
# settings.py or .env
|
|
409
|
+
AMSDAL_CONTRIBS = 'amsdal_mail.app.MailAppConfig'
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Or multiple plugins:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
# .env
|
|
416
|
+
AMSDAL_CONTRIBS=amsdal.contrib.auth.app.AuthAppConfig,amsdal_mail.app.MailAppConfig
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Use in AMSDAL App
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
from amsdal_mail import send_mail
|
|
423
|
+
|
|
424
|
+
# Now available throughout your AMSDAL application
|
|
425
|
+
send_mail(
|
|
426
|
+
subject='Welcome',
|
|
427
|
+
message='Thanks for signing up!',
|
|
428
|
+
from_email='noreply@example.com',
|
|
429
|
+
recipient_list=[user.email],
|
|
430
|
+
)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## API Reference
|
|
434
|
+
|
|
435
|
+
### send_mail()
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
def send_mail(
|
|
439
|
+
subject: str,
|
|
440
|
+
message: str,
|
|
441
|
+
from_email: str,
|
|
442
|
+
recipient_list: list[str] | str,
|
|
443
|
+
fail_silently: bool = False,
|
|
444
|
+
html_message: str | None = None,
|
|
445
|
+
connection = None,
|
|
446
|
+
**kwargs,
|
|
447
|
+
) -> SendStatus
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Send a single email message.
|
|
451
|
+
|
|
452
|
+
**Parameters**:
|
|
453
|
+
- `subject`: Email subject line
|
|
454
|
+
- `message`: Plain text body
|
|
455
|
+
- `from_email`: Sender email address
|
|
456
|
+
- `recipient_list`: List of recipient email addresses (or single string)
|
|
457
|
+
- `fail_silently`: If True, suppress exceptions and return empty SendStatus
|
|
458
|
+
- `html_message`: HTML version of body (optional)
|
|
459
|
+
- `connection`: Reuse existing connection (optional)
|
|
460
|
+
- `**kwargs`: Additional arguments passed to EmailMessage
|
|
461
|
+
|
|
462
|
+
**Returns**: `SendStatus` object with message IDs, statuses, and ESP response details
|
|
463
|
+
|
|
464
|
+
**Example**:
|
|
465
|
+
```python
|
|
466
|
+
status = send_mail(
|
|
467
|
+
'Welcome',
|
|
468
|
+
'Hello!',
|
|
469
|
+
'noreply@example.com',
|
|
470
|
+
['user@example.com'],
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Access send details
|
|
474
|
+
print(status.message_id) # ESP message ID (e.g., 'msg-123')
|
|
475
|
+
print(status.is_success) # True if all sent successfully
|
|
476
|
+
print(status.status) # {'sent'}
|
|
477
|
+
print(status.recipients) # Per-recipient details
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### asend_mail()
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
async def asend_mail(...) -> SendStatus
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Async version of `send_mail()`. Returns `SendStatus` object with send details.
|
|
487
|
+
|
|
488
|
+
### get_connection()
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
def get_connection(
|
|
492
|
+
backend: str | None = None,
|
|
493
|
+
fail_silently: bool = False,
|
|
494
|
+
**kwargs,
|
|
495
|
+
)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Get email backend connection.
|
|
499
|
+
|
|
500
|
+
**Parameters**:
|
|
501
|
+
- `backend`: Backend name (`'smtp'`, `'ses'`, `'console'`, `'dummy'`) or full class path
|
|
502
|
+
- `fail_silently`: Suppress errors if True
|
|
503
|
+
- `**kwargs`: Backend-specific configuration
|
|
504
|
+
|
|
505
|
+
**Returns**: Backend instance
|
|
506
|
+
|
|
507
|
+
### EmailMessage
|
|
508
|
+
|
|
509
|
+
```python
|
|
510
|
+
from amsdal_mail import EmailMessage, Attachment
|
|
511
|
+
|
|
512
|
+
message = EmailMessage(
|
|
513
|
+
subject: str,
|
|
514
|
+
body: str,
|
|
515
|
+
from_email: EmailStr | str,
|
|
516
|
+
to: list[EmailStr | str],
|
|
517
|
+
cc: list[EmailStr | str] = [],
|
|
518
|
+
bcc: list[EmailStr | str] = [],
|
|
519
|
+
reply_to: list[EmailStr | str] = [],
|
|
520
|
+
attachments: list[Attachment] = [],
|
|
521
|
+
headers: dict[str, str] = {},
|
|
522
|
+
html_body: str | None = None,
|
|
523
|
+
)
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Pydantic model representing an email message.
|
|
527
|
+
|
|
528
|
+
### Attachment
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from amsdal_mail import Attachment
|
|
532
|
+
|
|
533
|
+
attachment = Attachment(
|
|
534
|
+
filename: str, # File name
|
|
535
|
+
content: bytes, # File content
|
|
536
|
+
mimetype: str, # MIME type (e.g., 'application/pdf')
|
|
537
|
+
content_id: str | None, # Content-ID for inline images (e.g., 'logo' for cid:logo)
|
|
538
|
+
)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### SendStatus
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
from amsdal_mail import SendStatus, RecipientStatus
|
|
545
|
+
|
|
546
|
+
# Returned by send_mail() and asend_mail()
|
|
547
|
+
status = send_mail(...)
|
|
548
|
+
|
|
549
|
+
# SendStatus attributes:
|
|
550
|
+
status.message_id # str | set[str] | None - ESP message ID(s)
|
|
551
|
+
status.status # set[SendStatusType] | None - Set of statuses
|
|
552
|
+
status.recipients # dict[str, RecipientStatus] - Per-recipient details
|
|
553
|
+
status.esp_response # Any - Raw ESP API response
|
|
554
|
+
|
|
555
|
+
# Helper methods:
|
|
556
|
+
status.is_success # True if all sent/queued
|
|
557
|
+
status.has_failures # True if any failed/rejected/invalid
|
|
558
|
+
status.get_successful_recipients() # List of successful emails
|
|
559
|
+
status.get_failed_recipients() # List of failed emails
|
|
560
|
+
|
|
561
|
+
# RecipientStatus attributes:
|
|
562
|
+
recipient = status.recipients['user@example.com']
|
|
563
|
+
recipient.message_id # str | None - ESP message ID for this recipient
|
|
564
|
+
recipient.status # 'sent' | 'queued' | 'failed' | 'rejected' | 'invalid' | 'unknown'
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**Status Types**:
|
|
568
|
+
- `sent` - ESP has sent the message (queued for delivery)
|
|
569
|
+
- `queued` - ESP will try to send later
|
|
570
|
+
- `invalid` - Recipient email address is not valid
|
|
571
|
+
- `rejected` - Recipient is blacklisted or rejected by ESP
|
|
572
|
+
- `failed` - Send attempt failed for some other reason
|
|
573
|
+
- `unknown` - Status could not be determined
|
|
574
|
+
|
|
575
|
+
## Testing
|
|
576
|
+
|
|
577
|
+
### Using Console Backend
|
|
578
|
+
|
|
579
|
+
```python
|
|
580
|
+
# In your test configuration
|
|
581
|
+
import os
|
|
582
|
+
os.environ['AMSDAL_EMAIL_BACKEND'] = 'console'
|
|
583
|
+
|
|
584
|
+
# Emails will print to stdout
|
|
585
|
+
from amsdal_mail import send_mail
|
|
586
|
+
send_mail(...) # Prints to console
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Using Dummy Backend
|
|
590
|
+
|
|
591
|
+
```python
|
|
592
|
+
import os
|
|
593
|
+
os.environ['AMSDAL_EMAIL_BACKEND'] = 'dummy'
|
|
594
|
+
|
|
595
|
+
# Emails are "sent" but do nothing
|
|
596
|
+
from amsdal_mail import send_mail
|
|
597
|
+
status = send_mail(...) # Returns SendStatus, does nothing
|
|
598
|
+
print(status.is_success) # True
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### Mocking in Tests
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
import pytest
|
|
605
|
+
from amsdal_mail import send_mail, SendStatus
|
|
606
|
+
|
|
607
|
+
def test_email_sending(mocker):
|
|
608
|
+
# Mock the backend
|
|
609
|
+
mock_backend = mocker.patch('amsdal_mail.backends.get_connection')
|
|
610
|
+
mock_backend.return_value.send_messages.return_value = SendStatus()
|
|
611
|
+
|
|
612
|
+
# Test your code
|
|
613
|
+
result = send_mail(
|
|
614
|
+
subject='Test',
|
|
615
|
+
message='Test message',
|
|
616
|
+
from_email='sender@example.com',
|
|
617
|
+
recipient_list=['recipient@example.com'],
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
mock_backend.return_value.send_messages.assert_called_once()
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## Creating Custom Backends
|
|
624
|
+
|
|
625
|
+
### Define Backend Class
|
|
626
|
+
|
|
627
|
+
```python
|
|
628
|
+
from amsdal_mail.backends.base import BaseEmailBackend
|
|
629
|
+
from amsdal_mail import EmailMessage, SendStatus
|
|
630
|
+
|
|
631
|
+
class MyCustomBackend(BaseEmailBackend):
|
|
632
|
+
def __init__(self, api_key: str, **kwargs):
|
|
633
|
+
super().__init__(**kwargs)
|
|
634
|
+
self.api_key = api_key
|
|
635
|
+
|
|
636
|
+
def send_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
637
|
+
status = SendStatus()
|
|
638
|
+
# Your custom sending logic
|
|
639
|
+
return status
|
|
640
|
+
|
|
641
|
+
async def asend_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
|
|
642
|
+
status = SendStatus()
|
|
643
|
+
# Async implementation
|
|
644
|
+
return status
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### Register Backend
|
|
648
|
+
|
|
649
|
+
```python
|
|
650
|
+
from amsdal_mail.backends import BACKENDS
|
|
651
|
+
BACKENDS['mycustom'] = 'myapp.backends.MyCustomBackend'
|
|
652
|
+
|
|
653
|
+
# Now you can use it
|
|
654
|
+
from amsdal_mail import get_connection
|
|
655
|
+
connection = get_connection('mycustom', api_key='secret')
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Or use full import path:
|
|
659
|
+
|
|
660
|
+
```python
|
|
661
|
+
connection = get_connection('myapp.backends.MyCustomBackend', api_key='secret')
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## Examples
|
|
665
|
+
|
|
666
|
+
### Bulk Email Sending
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
from amsdal_mail import get_connection, EmailMessage
|
|
670
|
+
|
|
671
|
+
def send_bulk_emails(users: list):
|
|
672
|
+
# Reuse connection for efficiency
|
|
673
|
+
with get_connection('smtp') as conn:
|
|
674
|
+
messages = [
|
|
675
|
+
EmailMessage(
|
|
676
|
+
subject=f'Hello {user.name}',
|
|
677
|
+
body=f'Welcome {user.name}!',
|
|
678
|
+
from_email='noreply@example.com',
|
|
679
|
+
to=[user.email],
|
|
680
|
+
)
|
|
681
|
+
for user in users
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
# Send all at once
|
|
685
|
+
status = conn.send_messages(messages)
|
|
686
|
+
print(f'Success: {status.is_success}')
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
### Transactional Email
|
|
690
|
+
|
|
691
|
+
```python
|
|
692
|
+
from amsdal_mail import send_mail
|
|
693
|
+
|
|
694
|
+
def send_password_reset(user, reset_link: str):
|
|
695
|
+
send_mail(
|
|
696
|
+
subject='Password Reset Request',
|
|
697
|
+
message=f'''
|
|
698
|
+
Hello {user.name},
|
|
699
|
+
|
|
700
|
+
You requested a password reset. Click the link below:
|
|
701
|
+
{reset_link}
|
|
702
|
+
|
|
703
|
+
If you didn't request this, ignore this email.
|
|
704
|
+
''',
|
|
705
|
+
html_message=f'''
|
|
706
|
+
<h2>Password Reset Request</h2>
|
|
707
|
+
<p>Hello {user.name},</p>
|
|
708
|
+
<p>You requested a password reset.</p>
|
|
709
|
+
<a href="{reset_link}">Reset Password</a>
|
|
710
|
+
<p>If you didn't request this, ignore this email.</p>
|
|
711
|
+
''',
|
|
712
|
+
from_email='noreply@example.com',
|
|
713
|
+
recipient_list=[user.email],
|
|
714
|
+
)
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
### Async Bulk Sending
|
|
718
|
+
|
|
719
|
+
```python
|
|
720
|
+
import asyncio
|
|
721
|
+
from amsdal_mail import asend_mail
|
|
722
|
+
|
|
723
|
+
async def send_notifications(users: list):
|
|
724
|
+
tasks = [
|
|
725
|
+
asend_mail(
|
|
726
|
+
subject='New Update',
|
|
727
|
+
message=f'Hi {user.name}, check out our new features!',
|
|
728
|
+
from_email='notifications@example.com',
|
|
729
|
+
recipient_list=[user.email],
|
|
730
|
+
)
|
|
731
|
+
for user in users
|
|
732
|
+
]
|
|
733
|
+
|
|
734
|
+
# Send concurrently
|
|
735
|
+
results = await asyncio.gather(*tasks)
|
|
736
|
+
print(f'All succeeded: {all(s.is_success for s in results)}')
|
|
737
|
+
|
|
738
|
+
# Run
|
|
739
|
+
asyncio.run(send_notifications(users))
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
## Troubleshooting
|
|
743
|
+
|
|
744
|
+
### Gmail SMTP Issues
|
|
745
|
+
|
|
746
|
+
**Error**: "Username and Password not accepted"
|
|
747
|
+
|
|
748
|
+
**Solution**:
|
|
749
|
+
1. Enable 2-Factor Authentication
|
|
750
|
+
2. Generate App Password (Settings > Security > App Passwords)
|
|
751
|
+
3. Use App Password instead of regular password
|
|
752
|
+
|
|
753
|
+
### TLS/SSL Issues
|
|
754
|
+
|
|
755
|
+
**Error**: "STARTTLS extension not supported"
|
|
756
|
+
|
|
757
|
+
**Solution**:
|
|
758
|
+
```bash
|
|
759
|
+
# Try different port and TLS settings
|
|
760
|
+
AMSDAL_EMAIL_PORT=465
|
|
761
|
+
AMSDAL_EMAIL_USE_SSL=true
|
|
762
|
+
AMSDAL_EMAIL_USE_TLS=false
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Connection Timeout
|
|
766
|
+
|
|
767
|
+
**Error**: "Connection timed out"
|
|
768
|
+
|
|
769
|
+
**Solution**:
|
|
770
|
+
```bash
|
|
771
|
+
# Increase timeout
|
|
772
|
+
AMSDAL_EMAIL_TIMEOUT=60
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
## Development
|
|
776
|
+
|
|
777
|
+
### Setup
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
git clone <repo-url>
|
|
781
|
+
cd amsdal_mail
|
|
782
|
+
|
|
783
|
+
# Install dependencies
|
|
784
|
+
pip install uv hatch
|
|
785
|
+
hatch env create
|
|
786
|
+
hatch run sync
|
|
787
|
+
|
|
788
|
+
# Run checks
|
|
789
|
+
hatch run all # style + typing
|
|
790
|
+
hatch run test # tests
|
|
791
|
+
hatch run cov # tests with coverage
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Release Workflow
|
|
795
|
+
|
|
796
|
+
1. Develop on a feature branch, create PR to `main` — CI runs lint + tests
|
|
797
|
+
2. When ready to release, create a `release/X.Y.Z` branch, bump version in `amsdal_mail/__about__.py`, update `CHANGELOG.md`
|
|
798
|
+
3. Merge to `main` — tag `vX.Y.Z` is auto-created from the version in `__about__.py`
|
|
799
|
+
4. Tag push triggers release — builds, publishes to PyPI, creates GitHub Release with changelog
|