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,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
@@ -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()