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,29 @@
1
+ # Third-Party Materials - AMSDAL Dependencies - License Notices
2
+
3
+ ## **pydantic v2.12 or later**
4
+
5
+ ### [https://github.com/pydantic/pydantic](https://github.com/pydantic/pydantic)
6
+
7
+ ### **MIT License**
8
+
9
+ The MIT License (MIT)
10
+
11
+ Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
@@ -0,0 +1,164 @@
1
+ """AMSDAL Mail - Universal email integration for AMSDAL Framework."""
2
+
3
+ from amsdal_mail.backends import get_connection
4
+ from amsdal_mail.events import EmailTrackingContext
5
+ from amsdal_mail.events import EmailTrackingEvent
6
+ from amsdal_mail.events import TrackingEventType
7
+ from amsdal_mail.exceptions import ConfigurationError
8
+ from amsdal_mail.exceptions import EmailConnectionError
9
+ from amsdal_mail.exceptions import EmailError
10
+ from amsdal_mail.exceptions import SendError
11
+ from amsdal_mail.message import Attachment
12
+ from amsdal_mail.message import EmailMessage
13
+ from amsdal_mail.settings import mail_settings
14
+ from amsdal_mail.status import RecipientStatus
15
+ from amsdal_mail.status import SendStatus
16
+ from amsdal_mail.status import SendStatusType
17
+
18
+ __all__ = [
19
+ 'Attachment',
20
+ 'ConfigurationError',
21
+ 'EmailConnectionError',
22
+ 'EmailError',
23
+ 'EmailMessage',
24
+ 'EmailTrackingContext',
25
+ 'EmailTrackingEvent',
26
+ 'RecipientStatus',
27
+ 'SendError',
28
+ 'SendStatus',
29
+ 'SendStatusType',
30
+ 'TrackingEventType',
31
+ 'asend_mail',
32
+ 'get_connection',
33
+ 'mail_settings',
34
+ 'send_mail',
35
+ ]
36
+
37
+
38
+ def send_mail(
39
+ subject: str,
40
+ message: str,
41
+ from_email: str,
42
+ recipient_list: list[str] | str,
43
+ *,
44
+ fail_silently: bool = False,
45
+ html_message: str | None = None,
46
+ connection=None,
47
+ **kwargs,
48
+ ) -> SendStatus:
49
+ """
50
+ Send a single email message.
51
+
52
+ This is a convenience function for sending simple emails. For more control,
53
+ use EmailMessage and get_connection() directly.
54
+
55
+ Args:
56
+ subject: Email subject line
57
+ message: Plain text body
58
+ from_email: Sender email address
59
+ recipient_list: List of recipient email addresses (or single string)
60
+ fail_silently: If True, suppress exceptions and return empty SendStatus on failure
61
+ html_message: HTML version of body (optional)
62
+ connection: Reuse existing connection (optional)
63
+ **kwargs: Additional arguments passed to EmailMessage
64
+
65
+ Returns:
66
+ SendStatus object with message IDs, statuses, and ESP response
67
+
68
+ Raises:
69
+ SendError: If sending fails and fail_silently is False
70
+
71
+ Examples:
72
+ >>> status = send_mail(
73
+ ... 'Hello',
74
+ ... 'This is a test email',
75
+ ... 'sender@example.com',
76
+ ... ['recipient@example.com'],
77
+ ... )
78
+ >>> status.message_id
79
+ 'msg-123'
80
+ >>> status.is_success
81
+ True
82
+ """
83
+ # Get or create connection
84
+ connection = connection or get_connection(fail_silently=fail_silently)
85
+
86
+ # Normalize recipient_list to list
87
+ if isinstance(recipient_list, str):
88
+ recipient_list = [recipient_list]
89
+
90
+ # Create email message
91
+ mail = EmailMessage(
92
+ subject=subject,
93
+ body=message,
94
+ from_email=from_email,
95
+ to=recipient_list,
96
+ html_body=html_message,
97
+ **kwargs,
98
+ )
99
+
100
+ # Send message
101
+ return connection.send_messages([mail])
102
+
103
+
104
+ async def asend_mail(
105
+ subject: str,
106
+ message: str,
107
+ from_email: str,
108
+ recipient_list: list[str] | str,
109
+ *,
110
+ fail_silently: bool = False,
111
+ html_message: str | None = None,
112
+ connection=None,
113
+ **kwargs,
114
+ ) -> SendStatus:
115
+ """
116
+ Send a single email message asynchronously.
117
+
118
+ Async version of send_mail(). See send_mail() for parameter documentation.
119
+
120
+ Args:
121
+ subject: Email subject line
122
+ message: Plain text body
123
+ from_email: Sender email address
124
+ recipient_list: List of recipient email addresses (or single string)
125
+ fail_silently: If True, suppress exceptions and return empty SendStatus on failure
126
+ html_message: HTML version of body (optional)
127
+ connection: Reuse existing connection (optional)
128
+ **kwargs: Additional arguments passed to EmailMessage
129
+
130
+ Returns:
131
+ SendStatus object with message IDs, statuses, and ESP response
132
+
133
+ Raises:
134
+ SendError: If sending fails and fail_silently is False
135
+
136
+ Examples:
137
+ >>> status = await asend_mail(
138
+ ... 'Hello',
139
+ ... 'This is a test email',
140
+ ... 'sender@example.com',
141
+ ... ['recipient@example.com'],
142
+ ... )
143
+ >>> status.message_id
144
+ 'msg-123'
145
+ """
146
+ # Get or create connection
147
+ connection = connection or get_connection(fail_silently=fail_silently)
148
+
149
+ # Normalize recipient_list to list
150
+ if isinstance(recipient_list, str):
151
+ recipient_list = [recipient_list]
152
+
153
+ # Create email message
154
+ mail = EmailMessage(
155
+ subject=subject,
156
+ body=message,
157
+ from_email=from_email,
158
+ to=recipient_list,
159
+ html_body=html_message,
160
+ **kwargs,
161
+ )
162
+
163
+ # Send message asynchronously
164
+ return await connection.asend_messages([mail])
amsdal_mail/app.py ADDED
@@ -0,0 +1,36 @@
1
+ """AMSDAL integration for amsdal_mail plugin."""
2
+
3
+ from amsdal.contrib.app_config import AppConfig
4
+
5
+
6
+ class MailAppConfig(AppConfig):
7
+ """
8
+ AMSDAL plugin configuration for amsdal_mail.
9
+
10
+ Register this in your AMSDAL application settings:
11
+ AMSDAL_CONTRIBS = 'amsdal_mail.app.MailAppConfig'
12
+ """
13
+
14
+ name = 'amsdal_mail'
15
+ verbose_name = 'AMSDAL Email Integration'
16
+
17
+ def on_setup(self) -> None:
18
+ """Called when the plugin is loaded by AMSDAL."""
19
+ from amsdal_mail.settings import mail_settings
20
+
21
+ if mail_settings.WEBHOOK_ENABLED:
22
+ self._setup_webhooks()
23
+
24
+ @staticmethod
25
+ def _setup_webhooks() -> None:
26
+ from amsdal_server.apps.common.events.server import RouterSetupEvent # type: ignore[import-not-found]
27
+ from amsdal_utils.events import EventBus
28
+
29
+ from amsdal_mail.settings import mail_settings
30
+ from amsdal_mail.webhooks.listener import WebhookRouterListener
31
+ from amsdal_mail.webhooks.registry import WebhookRegistry
32
+ from amsdal_mail.webhooks.ses import SESWebhookParser
33
+
34
+ WebhookRegistry.register('ses', SESWebhookParser(secret=mail_settings.WEBHOOK_SES_SECRET))
35
+
36
+ EventBus.subscribe(RouterSetupEvent, WebhookRouterListener)
@@ -0,0 +1,103 @@
1
+ """Backend registry and connection utilities."""
2
+
3
+ import importlib
4
+ import os
5
+ from typing import Any
6
+
7
+ from amsdal_mail.backends.base import BaseEmailBackend
8
+
9
+ # Registry of available backends
10
+ BACKENDS: dict[str, str] = {
11
+ 'console': 'amsdal_mail.backends.console.ConsoleBackend',
12
+ 'dummy': 'amsdal_mail.backends.dummy.DummyBackend',
13
+ 'ses': 'amsdal_mail.backends.ses.SESBackend',
14
+ 'smtp': 'amsdal_mail.backends.smtp.SMTPBackend',
15
+ }
16
+
17
+
18
+ def import_string(dotted_path: str) -> Any:
19
+ """
20
+ Import a class from a dotted module path.
21
+
22
+ Args:
23
+ dotted_path: Full dotted path to class (e.g., 'package.module.ClassName')
24
+
25
+ Returns:
26
+ The imported class
27
+
28
+ Raises:
29
+ ImportError: If the module or class cannot be imported
30
+ """
31
+ try:
32
+ module_path, class_name = dotted_path.rsplit('.', 1)
33
+ except ValueError as e:
34
+ msg = f"'{dotted_path}' doesn't look like a module path"
35
+ raise ImportError(msg) from e
36
+
37
+ try:
38
+ module = importlib.import_module(module_path)
39
+ except ImportError as e:
40
+ msg = f"Module '{module_path}' could not be imported"
41
+ raise ImportError(msg) from e
42
+
43
+ try:
44
+ return getattr(module, class_name)
45
+ except AttributeError as e:
46
+ msg = f"Module '{module_path}' does not define a '{class_name}' class"
47
+ raise ImportError(msg) from e
48
+
49
+
50
+ def get_connection(
51
+ backend: str | None = None,
52
+ *,
53
+ fail_silently: bool = False,
54
+ **kwargs: Any,
55
+ ) -> BaseEmailBackend:
56
+ """
57
+ Load an email backend and return an instance.
58
+
59
+ Args:
60
+ backend: Backend name (from BACKENDS registry) or full dotted path.
61
+ If None, uses AMSDAL_EMAIL_BACKEND environment variable.
62
+ Defaults to 'console' if not specified.
63
+ fail_silently: If True, suppress exceptions in send operations
64
+ **kwargs: Backend-specific configuration options
65
+
66
+ Returns:
67
+ An instance of the specified backend
68
+
69
+ Raises:
70
+ ImportError: If the backend cannot be imported
71
+ ConfigurationError: If the backend is misconfigured
72
+
73
+ Examples:
74
+ >>> # Use registered backend name
75
+ >>> conn = get_connection('smtp', host='smtp.gmail.com', port=587)
76
+
77
+ >>> # Use full dotted path
78
+ >>> conn = get_connection('myapp.backends.CustomBackend')
79
+
80
+ >>> # Use environment variable
81
+ >>> os.environ['AMSDAL_EMAIL_BACKEND'] = 'smtp'
82
+ >>> conn = get_connection()
83
+ """
84
+ # Resolve backend name
85
+ if backend is None:
86
+ backend = os.environ.get('AMSDAL_EMAIL_BACKEND', 'console')
87
+
88
+ # Check if it's a registered backend name
89
+ if backend in BACKENDS:
90
+ backend = BACKENDS[backend]
91
+
92
+ # Import and instantiate the backend
93
+ backend_cls = import_string(backend)
94
+
95
+ return backend_cls(fail_silently=fail_silently, **kwargs)
96
+
97
+
98
+ __all__ = [
99
+ 'BACKENDS',
100
+ 'BaseEmailBackend',
101
+ 'get_connection',
102
+ 'import_string',
103
+ ]
@@ -0,0 +1,87 @@
1
+ """Base email backend interface."""
2
+
3
+ from abc import ABC
4
+ from abc import abstractmethod
5
+ from typing import TYPE_CHECKING
6
+ from typing import Any
7
+
8
+ if TYPE_CHECKING:
9
+ from amsdal_mail.message import EmailMessage
10
+ from amsdal_mail.status import SendStatus
11
+
12
+
13
+ class BaseEmailBackend(ABC):
14
+ """Abstract base class for all email backends."""
15
+
16
+ def __init__(self, *, fail_silently: bool = False, **kwargs: Any) -> None:
17
+ """
18
+ Initialize the email backend.
19
+
20
+ Args:
21
+ fail_silently: If True, suppress exceptions and return 0 for failed sends
22
+ **kwargs: Backend-specific configuration options
23
+ """
24
+ self.fail_silently = fail_silently
25
+ self.extra_kwargs = kwargs
26
+
27
+ def open(self) -> None:
28
+ """
29
+ Open a network connection to the email service
30
+
31
+ This method is called automatically when using the backend as a context manager.
32
+ Override this method if your backend needs to establish a persistent connection.
33
+ """
34
+ pass
35
+
36
+ def close(self) -> None:
37
+ """
38
+ Close the network connection to the email service.
39
+
40
+ This method is called automatically when exiting the context manager.
41
+ Override this method if your backend needs to clean up resources.
42
+ """
43
+ pass
44
+
45
+ def __enter__(self) -> 'BaseEmailBackend':
46
+ """Enter context manager - open connection."""
47
+ self.open()
48
+ return self
49
+
50
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
51
+ """Exit context manager - close connection."""
52
+ self.close()
53
+
54
+ @abstractmethod
55
+ def send_messages(self, email_messages: list['EmailMessage']) -> 'SendStatus':
56
+ """
57
+ Send one or more email messages.
58
+
59
+ Args:
60
+ email_messages: List of EmailMessage instances to send
61
+
62
+ Returns:
63
+ SendStatus object with message IDs, statuses, and ESP response data
64
+
65
+ Raises:
66
+ SendError: If sending fails and fail_silently is False
67
+ """
68
+ msg = 'Subclasses must implement send_messages()'
69
+ raise NotImplementedError(msg)
70
+
71
+ async def asend_messages(self, email_messages: list['EmailMessage']) -> 'SendStatus':
72
+ """
73
+ Send one or more email messages asynchronously.
74
+
75
+ Default implementation falls back to synchronous send_messages().
76
+ Override this method to provide true async implementation.
77
+
78
+ Args:
79
+ email_messages: List of EmailMessage instances to send
80
+
81
+ Returns:
82
+ SendStatus object with message IDs, statuses, and ESP response data
83
+
84
+ Raises:
85
+ SendError: If sending fails and fail_silently is False
86
+ """
87
+ return self.send_messages(email_messages)
@@ -0,0 +1,91 @@
1
+ """Console email backend - writes emails to stdout."""
2
+
3
+ import sys
4
+ from typing import IO
5
+ from typing import Any
6
+
7
+ from amsdal_mail.backends.base import BaseEmailBackend
8
+ from amsdal_mail.message import EmailMessage
9
+ from amsdal_mail.status import RecipientStatus
10
+ from amsdal_mail.status import SendStatus
11
+
12
+
13
+ class ConsoleBackend(BaseEmailBackend):
14
+ """
15
+ Email backend that writes messages to a stream (default: stdout).
16
+
17
+ Useful for development and debugging.
18
+ """
19
+
20
+ def __init__(self, stream: IO[str] | None = None, **kwargs: Any) -> None:
21
+ """
22
+ Initialize the console backend.
23
+
24
+ Args:
25
+ stream: Output stream (defaults to sys.stdout)
26
+ **kwargs: Additional backend options (passed to parent)
27
+ """
28
+ super().__init__(**kwargs)
29
+ self.stream = stream or sys.stdout
30
+
31
+ def send_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
32
+ """
33
+ Write email messages to the stream.
34
+
35
+ Args:
36
+ email_messages: List of EmailMessage instances to output
37
+
38
+ Returns:
39
+ SendStatus with 'sent' status for all recipients
40
+ """
41
+ status = SendStatus()
42
+ recipients = {}
43
+
44
+ for message in email_messages:
45
+ self.stream.write('-' * 79 + '\n')
46
+ self.stream.write(f'Subject: {message.subject}\n')
47
+ self.stream.write(f'From: {message.from_email}\n')
48
+ self.stream.write(f'To: {", ".join(str(addr) for addr in message.to)}\n')
49
+
50
+ if message.cc:
51
+ self.stream.write(f'Cc: {", ".join(str(addr) for addr in message.cc)}\n')
52
+
53
+ if message.bcc:
54
+ self.stream.write(f'Bcc: {", ".join(str(addr) for addr in message.bcc)}\n')
55
+
56
+ if message.reply_to:
57
+ self.stream.write(f'Reply-To: {", ".join(str(addr) for addr in message.reply_to)}\n')
58
+
59
+ if message.headers:
60
+ for key, value in message.headers.items():
61
+ self.stream.write(f'{key}: {value}\n')
62
+
63
+ self.stream.write('\n')
64
+
65
+ if message.html_body:
66
+ self.stream.write('Content-Type: multipart/alternative\n')
67
+ self.stream.write('\n--- Plain Text ---\n')
68
+ self.stream.write(message.body)
69
+ self.stream.write('\n\n--- HTML ---\n')
70
+ self.stream.write(message.html_body)
71
+ else:
72
+ self.stream.write(message.body)
73
+
74
+ if message.attachments:
75
+ self.stream.write('\n\n--- Attachments ---\n')
76
+ for attachment in message.attachments:
77
+ size_kb = len(attachment.content) / 1024
78
+ self.stream.write(f'- {attachment.filename} ({attachment.mimetype}, {size_kb:.1f} KB)\n')
79
+
80
+ self.stream.write('\n' + '-' * 79 + '\n\n')
81
+
82
+ # Add recipient status (console backend always succeeds)
83
+ for recipient in message.recipients():
84
+ recipients[recipient] = RecipientStatus(
85
+ message_id=None, # Console backend doesn't generate message IDs
86
+ status='sent',
87
+ )
88
+
89
+ self.stream.flush()
90
+ status.set_recipient_status(recipients)
91
+ return status
@@ -0,0 +1,56 @@
1
+ """Dummy email backend - does not send emails."""
2
+
3
+ from typing import Any
4
+
5
+ from amsdal_mail.backends.base import BaseEmailBackend
6
+ from amsdal_mail.message import EmailMessage
7
+ from amsdal_mail.status import RecipientStatus
8
+ from amsdal_mail.status import SendStatus
9
+
10
+
11
+ class DummyBackend(BaseEmailBackend):
12
+ """
13
+ Email backend that does not actually send emails.
14
+
15
+ Useful for testing - pretends to send messages successfully but does nothing.
16
+ Optionally stores messages for inspection during tests.
17
+ """
18
+
19
+ def __init__(self, *, store_messages: bool = False, **kwargs: Any) -> None:
20
+ """
21
+ Initialize the dummy backend.
22
+
23
+ Args:
24
+ store_messages: If True, store sent messages in self.messages list
25
+ **kwargs: Additional backend options (passed to parent)
26
+ """
27
+ super().__init__(**kwargs)
28
+ self.store_messages = store_messages
29
+ self.messages: list[EmailMessage] = []
30
+
31
+ def send_messages(self, email_messages: list[EmailMessage]) -> SendStatus:
32
+ """
33
+ Pretend to send email messages.
34
+
35
+ Args:
36
+ email_messages: List of EmailMessage instances to "send"
37
+
38
+ Returns:
39
+ SendStatus with 'sent' status for all recipients
40
+ """
41
+ if self.store_messages:
42
+ self.messages.extend(email_messages)
43
+
44
+ status = SendStatus()
45
+ recipients = {}
46
+
47
+ # Mark all recipients as sent (but don't actually send)
48
+ for message in email_messages:
49
+ for recipient in message.recipients():
50
+ recipients[recipient] = RecipientStatus(
51
+ message_id=None, # Dummy backend doesn't generate message IDs
52
+ status='sent',
53
+ )
54
+
55
+ status.set_recipient_status(recipients)
56
+ return status