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/status.py ADDED
@@ -0,0 +1,189 @@
1
+ """Email sending status tracking."""
2
+
3
+ from typing import Any
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic import ConfigDict
8
+ from pydantic import Field
9
+
10
+ # Send status constants (normalized across all ESP backends)
11
+ SendStatusType = Literal[
12
+ 'sent', # ESP has sent the message (may or may not be delivered yet)
13
+ 'queued', # ESP will try to send the message later
14
+ 'invalid', # Recipient email address is not valid
15
+ 'rejected', # Recipient is blacklisted or rejected by ESP
16
+ 'failed', # Send attempt failed for some other reason
17
+ 'unknown', # Status could not be determined
18
+ ]
19
+
20
+
21
+ class RecipientStatus(BaseModel):
22
+ """
23
+ Status information for a single recipient.
24
+
25
+ Tracks the ESP message ID and send status for an individual recipient.
26
+ """
27
+
28
+ message_id: str | None = Field(
29
+ default=None,
30
+ description='ESP-assigned message ID for this recipient (None if send failed)',
31
+ )
32
+ status: SendStatusType | None = Field(
33
+ default=None,
34
+ description='Send status for this recipient (None if not yet sent to ESP)',
35
+ )
36
+
37
+
38
+ class SendStatus(BaseModel):
39
+ """
40
+ Complete status information for sent email message(s).
41
+
42
+ Contains aggregated status across all recipients, per-recipient details,
43
+ and raw ESP response data.
44
+
45
+ Attributes:
46
+ message_id: ESP message ID(s). Single string if all recipients share same ID,
47
+ set of strings if different IDs, or None if send failed.
48
+ status: Set of send statuses across all recipients.
49
+ recipients: Per-recipient status information, keyed by email address.
50
+ esp_response: Raw response from ESP API (for debugging/ESP-specific features).
51
+ """
52
+
53
+ message_id: str | set[str] | None = Field(
54
+ default=None,
55
+ description='ESP message ID(s) - single ID, set of IDs, or None',
56
+ )
57
+ status: set[SendStatusType] | None = Field(
58
+ default=None,
59
+ description='Set of send statuses across all recipients',
60
+ )
61
+ recipients: dict[str, RecipientStatus] = Field(
62
+ default_factory=dict,
63
+ description='Per-recipient status information (email -> RecipientStatus)',
64
+ )
65
+ esp_response: Any = Field(
66
+ default=None,
67
+ description='Raw ESP API response (non-portable, for debugging)',
68
+ )
69
+
70
+ model_config = ConfigDict(arbitrary_types_allowed=True)
71
+
72
+ def set_recipient_status(self, recipients: dict[str, RecipientStatus]) -> None:
73
+ """
74
+ Update recipient statuses and derive aggregated message_id and status.
75
+
76
+ This method updates the recipients dictionary and automatically computes
77
+ the aggregated message_id and status fields based on all recipients.
78
+
79
+ Args:
80
+ recipients: Dictionary of email -> RecipientStatus to add/update
81
+
82
+ Example:
83
+ >>> status = SendStatus()
84
+ >>> status.set_recipient_status({
85
+ ... 'user@example.com': RecipientStatus(
86
+ ... message_id='msg-123',
87
+ ... status='sent'
88
+ ... )
89
+ ... })
90
+ >>> status.message_id
91
+ 'msg-123'
92
+ >>> status.status
93
+ {'sent'}
94
+ """
95
+ # Update recipients dict
96
+ self.recipients.update(recipients)
97
+
98
+ # Collect unique message IDs
99
+ message_ids = {r.message_id for r in self.recipients.values() if r.message_id}
100
+
101
+ if len(message_ids) == 0:
102
+ self.message_id = None
103
+ elif len(message_ids) == 1:
104
+ # Single message ID - simplify to string
105
+ self.message_id = message_ids.pop()
106
+ else:
107
+ # Multiple message IDs - keep as set
108
+ self.message_id = message_ids
109
+
110
+ # Collect unique statuses
111
+ statuses = {r.status for r in self.recipients.values() if r.status}
112
+ self.status = statuses if statuses else None
113
+
114
+ @property
115
+ def is_success(self) -> bool:
116
+ """
117
+ Check if all recipients were successfully sent or queued.
118
+
119
+ Returns:
120
+ True if all recipients have status 'sent' or 'queued', False otherwise.
121
+
122
+ Example:
123
+ >>> status = SendStatus(status={'sent', 'queued'})
124
+ >>> status.is_success
125
+ True
126
+ >>> status = SendStatus(status={'sent', 'failed'})
127
+ >>> status.is_success
128
+ False
129
+ """
130
+ if not self.status:
131
+ return False
132
+ return self.status.issubset({'sent', 'queued'})
133
+
134
+ @property
135
+ def has_failures(self) -> bool:
136
+ """
137
+ Check if any recipients failed to send.
138
+
139
+ Returns:
140
+ True if any recipient has status 'failed', 'rejected', or 'invalid'.
141
+
142
+ Example:
143
+ >>> status = SendStatus(status={'sent', 'failed'})
144
+ >>> status.has_failures
145
+ True
146
+ """
147
+ if not self.status:
148
+ return False
149
+ return bool(self.status.intersection({'failed', 'rejected', 'invalid'}))
150
+
151
+ def get_failed_recipients(self) -> list[str]:
152
+ """
153
+ Get list of email addresses that failed to send.
154
+
155
+ Returns:
156
+ List of email addresses with failed/rejected/invalid status.
157
+
158
+ Example:
159
+ >>> status = SendStatus(recipients={
160
+ ... 'good@example.com': RecipientStatus(status='sent'),
161
+ ... 'bad@example.com': RecipientStatus(status='failed'),
162
+ ... })
163
+ >>> status.get_failed_recipients()
164
+ ['bad@example.com']
165
+ """
166
+ return [
167
+ email
168
+ for email, recipient in self.recipients.items()
169
+ if recipient.status in {'failed', 'rejected', 'invalid'}
170
+ ]
171
+
172
+ def get_successful_recipients(self) -> list[str]:
173
+ """
174
+ Get list of email addresses that were successfully sent or queued.
175
+
176
+ Returns:
177
+ List of email addresses with sent/queued status.
178
+
179
+ Example:
180
+ >>> status = SendStatus(recipients={
181
+ ... 'good@example.com': RecipientStatus(status='sent'),
182
+ ... 'bad@example.com': RecipientStatus(status='failed'),
183
+ ... })
184
+ >>> status.get_successful_recipients()
185
+ ['good@example.com']
186
+ """
187
+ return [
188
+ email for email, recipient in self.recipients.items() if recipient.status in {'sent', 'queued'}
189
+ ]
File without changes
@@ -0,0 +1,32 @@
1
+ """Abstract base class for webhook parsers."""
2
+
3
+ from abc import ABC
4
+ from abc import abstractmethod
5
+
6
+ from amsdal_mail.events import EmailTrackingContext
7
+
8
+
9
+ class BaseWebhookParser(ABC):
10
+ @abstractmethod
11
+ def verify(self, body: bytes, headers: dict[str, str]) -> bool:
12
+ """Verify webhook request authenticity.
13
+
14
+ Args:
15
+ body: Raw request body bytes.
16
+ headers: HTTP request headers.
17
+
18
+ Returns:
19
+ True if the request is valid.
20
+ """
21
+
22
+ @abstractmethod
23
+ def parse(self, body: bytes, headers: dict[str, str]) -> list[EmailTrackingContext]:
24
+ """Parse webhook payload into tracking events.
25
+
26
+ Args:
27
+ body: Raw request body bytes.
28
+ headers: HTTP request headers.
29
+
30
+ Returns:
31
+ List of normalized tracking contexts.
32
+ """
@@ -0,0 +1,43 @@
1
+ """Webhook HTTP handler for receiving ESP tracking events."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+ from amsdal_utils.events import EventBus
7
+
8
+ from amsdal_mail.events import EmailTrackingEvent
9
+ from amsdal_mail.webhooks.registry import WebhookRegistry
10
+
11
+
12
+ class SupportedESP(StrEnum):
13
+ ses = 'ses'
14
+
15
+
16
+ async def webhook_handler(esp_name: SupportedESP, request: Any) -> dict[str, Any]:
17
+ """Handle incoming webhook POST from ESP.
18
+
19
+ Args:
20
+ esp_name: Which ESP is sending the webhook (path parameter).
21
+ request: FastAPI Request object.
22
+
23
+ Returns:
24
+ JSON response with status and count of processed events.
25
+ """
26
+ from fastapi import HTTPException # type: ignore[import-not-found]
27
+
28
+ parser = WebhookRegistry.get_parser(esp_name.value)
29
+ if not parser:
30
+ raise HTTPException(status_code=404, detail=f'No parser registered for ESP: {esp_name.value}')
31
+
32
+ body = await request.body()
33
+ headers = dict(request.headers)
34
+
35
+ if not parser.verify(body, headers):
36
+ raise HTTPException(status_code=403, detail='Webhook verification failed')
37
+
38
+ events = parser.parse(body, headers)
39
+
40
+ for event in events:
41
+ await EventBus.aemit(EmailTrackingEvent, event)
42
+
43
+ return {'status': 'ok', 'events_processed': len(events)}
@@ -0,0 +1,41 @@
1
+ """Route registration listener for webhook endpoints."""
2
+
3
+ from amsdal_server.apps.common.events.server import RouterSetupContext # type: ignore[import-not-found]
4
+ from amsdal_utils.events import AsyncNextFn
5
+ from amsdal_utils.events import EventListener
6
+ from amsdal_utils.events import NextFn
7
+ from fastapi import APIRouter # type: ignore[import-not-found]
8
+ from fastapi import Request
9
+
10
+ from amsdal_mail.webhooks.handler import SupportedESP
11
+ from amsdal_mail.webhooks.handler import webhook_handler
12
+
13
+
14
+ class WebhookRouterListener(EventListener[RouterSetupContext]):
15
+ """Subscribes to RouterSetupEvent and registers the webhook route."""
16
+
17
+ def handle(
18
+ self,
19
+ context: RouterSetupContext,
20
+ next_fn: NextFn[RouterSetupContext],
21
+ ) -> RouterSetupContext:
22
+ from amsdal_mail.settings import mail_settings
23
+
24
+ base_path = mail_settings.WEBHOOK_BASE_PATH
25
+
26
+ router = APIRouter()
27
+
28
+ @router.post(f'{base_path}/{{esp_name}}', include_in_schema=False)
29
+ async def _webhook(esp_name: SupportedESP, request: Request) -> dict[str, object]:
30
+ return await webhook_handler(esp_name, request)
31
+
32
+ context.app.include_router(router)
33
+
34
+ return next_fn(context)
35
+
36
+ async def ahandle(
37
+ self,
38
+ context: RouterSetupContext,
39
+ next_fn: AsyncNextFn[RouterSetupContext],
40
+ ) -> RouterSetupContext:
41
+ return self.handle(context, next_fn) # type: ignore[arg-type,unused-ignore]
@@ -0,0 +1,21 @@
1
+ """Webhook parser registry."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from amsdal_mail.webhooks.base import BaseWebhookParser
6
+
7
+
8
+ class WebhookRegistry:
9
+ _parsers: ClassVar[dict[str, BaseWebhookParser]] = {}
10
+
11
+ @classmethod
12
+ def register(cls, esp_name: str, parser: BaseWebhookParser) -> None:
13
+ cls._parsers[esp_name] = parser
14
+
15
+ @classmethod
16
+ def get_parser(cls, esp_name: str) -> BaseWebhookParser | None:
17
+ return cls._parsers.get(esp_name)
18
+
19
+ @classmethod
20
+ def reset(cls) -> None:
21
+ cls._parsers.clear()
@@ -0,0 +1,201 @@
1
+ """AWS SES webhook parser via SNS notifications."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from amsdal_mail.events import EmailTrackingContext
9
+ from amsdal_mail.events import TrackingEventType
10
+ from amsdal_mail.webhooks.base import BaseWebhookParser
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _SES_EVENT_MAP: dict[str, TrackingEventType] = {
15
+ 'Send': TrackingEventType.SENT,
16
+ 'Delivery': TrackingEventType.DELIVERED,
17
+ 'Bounce': TrackingEventType.BOUNCED,
18
+ 'Complaint': TrackingEventType.COMPLAINED,
19
+ 'Reject': TrackingEventType.REJECTED,
20
+ 'Open': TrackingEventType.OPENED,
21
+ 'Click': TrackingEventType.CLICKED,
22
+ 'DeliveryDelay': TrackingEventType.DEFERRED,
23
+ }
24
+
25
+
26
+ class SESWebhookParser(BaseWebhookParser):
27
+ """Parse SES event notifications delivered via SNS.
28
+
29
+ SES sends events to an SNS topic, which then delivers them
30
+ as HTTP POST requests to the webhook endpoint. The payload
31
+ is an SNS message envelope containing the SES event JSON.
32
+
33
+ Args:
34
+ secret: Optional secret for HTTP basic auth verification.
35
+ """
36
+
37
+ def __init__(self, secret: str | None = None) -> None:
38
+ self.secret = secret
39
+
40
+ def verify(self, body: bytes, headers: dict[str, str]) -> bool:
41
+ """Verify SNS message by cross-validating headers against body.
42
+
43
+ Checks that SNS headers (x-amz-sns-message-type, x-amz-sns-message-id)
44
+ match the corresponding fields in the JSON body (Type, MessageId).
45
+ This prevents trivial forgery where only headers are spoofed.
46
+
47
+ TODO: add signature verification https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
48
+ """
49
+ header_type = headers.get('x-amz-sns-message-type')
50
+ header_id = headers.get('x-amz-sns-message-id')
51
+
52
+ if not header_type or not header_id:
53
+ return False
54
+
55
+ if header_type not in ('SubscriptionConfirmation', 'Notification', 'UnsubscribeConfirmation'):
56
+ return False
57
+
58
+ try:
59
+ sns_message = json.loads(body)
60
+ except (json.JSONDecodeError, UnicodeDecodeError):
61
+ return False
62
+
63
+ body_type = sns_message.get('Type')
64
+ body_id = sns_message.get('MessageId')
65
+
66
+ if header_type != body_type or header_id != body_id:
67
+ return False
68
+
69
+ return True
70
+
71
+ def parse(self, body: bytes, headers: dict[str, str]) -> list[EmailTrackingContext]:
72
+ """Parse SNS notification containing SES event.
73
+
74
+ Handles both SubscriptionConfirmation (returns empty list)
75
+ and Notification messages (returns tracking events).
76
+ """
77
+ message_type = headers.get('x-amz-sns-message-type', '')
78
+
79
+ sns_message = json.loads(body)
80
+
81
+ if message_type == 'SubscriptionConfirmation':
82
+ logger.info('SNS subscription confirmation received. SubscribeURL: %s', sns_message.get('SubscribeURL'))
83
+ return []
84
+
85
+ if message_type == 'UnsubscribeConfirmation':
86
+ logger.info('SNS unsubscribe confirmation received.')
87
+ return []
88
+
89
+ # Notification - parse the SES event from the Message field
90
+ ses_event = json.loads(sns_message.get('Message', '{}'))
91
+
92
+ return self._parse_ses_event(ses_event)
93
+
94
+ def _parse_ses_event(self, ses_event: dict[str, Any]) -> list[EmailTrackingContext]:
95
+ """Parse SES event JSON into tracking contexts.
96
+
97
+ Expands multi-recipient events (e.g. a bounce with 3 recipients)
98
+ into individual EmailTrackingContext instances.
99
+ """
100
+ event_type_str = ses_event.get('eventType', '')
101
+ tracking_type = _SES_EVENT_MAP.get(event_type_str, TrackingEventType.UNKNOWN)
102
+
103
+ mail_obj = ses_event.get('mail', {})
104
+ message_id = mail_obj.get('messageId', '')
105
+ tags = self._extract_tags(mail_obj)
106
+ metadata = self._extract_metadata(mail_obj)
107
+
108
+ detail_key = self._get_detail_key(event_type_str)
109
+ detail = ses_event.get(detail_key, {}) if detail_key else {}
110
+
111
+ timestamp = self._parse_timestamp(detail, ses_event)
112
+ recipients = self._extract_recipients(event_type_str, detail, mail_obj)
113
+ reject_reason = self._extract_reject_reason(event_type_str, detail)
114
+ description = self._extract_description(event_type_str, detail)
115
+ click_url = detail.get('link') if event_type_str == 'Click' else None
116
+ user_agent = detail.get('userAgent') if event_type_str in ('Open', 'Click') else None
117
+
118
+ contexts = []
119
+ for recipient in recipients:
120
+ contexts.append(
121
+ EmailTrackingContext(
122
+ event_type=tracking_type,
123
+ message_id=message_id,
124
+ recipient=recipient,
125
+ timestamp=timestamp,
126
+ esp_name='ses',
127
+ tags=tags,
128
+ metadata=metadata,
129
+ reject_reason=reject_reason,
130
+ description=description,
131
+ click_url=click_url,
132
+ user_agent=user_agent,
133
+ raw_event=ses_event,
134
+ )
135
+ )
136
+
137
+ return contexts
138
+
139
+ def _get_detail_key(self, event_type: str) -> str | None:
140
+ key_map = {
141
+ 'Bounce': 'bounce',
142
+ 'Complaint': 'complaint',
143
+ 'Delivery': 'delivery',
144
+ 'Send': 'send',
145
+ 'Reject': 'reject',
146
+ 'Open': 'open',
147
+ 'Click': 'click',
148
+ 'DeliveryDelay': 'deliveryDelay',
149
+ }
150
+ return key_map.get(event_type)
151
+
152
+ def _parse_timestamp(self, detail: dict[str, Any], ses_event: dict[str, Any]) -> datetime:
153
+ ts_str = detail.get('timestamp') or ses_event.get('mail', {}).get('timestamp', '')
154
+ if ts_str:
155
+ return datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
156
+ return datetime.now() # noqa: DTZ005
157
+
158
+ def _extract_recipients(
159
+ self, event_type: str, detail: dict[str, Any], mail_obj: dict[str, Any]
160
+ ) -> list[str]:
161
+ if event_type == 'Bounce':
162
+ return [r.get('emailAddress', '') for r in detail.get('bouncedRecipients', [])]
163
+ if event_type == 'Complaint':
164
+ return [r.get('emailAddress', '') for r in detail.get('complainedRecipients', [])]
165
+ if event_type == 'Delivery':
166
+ return detail.get('recipients', [])
167
+ # For Send, Reject, Open, Click, DeliveryDelay - use mail destination
168
+ return mail_obj.get('destination', [])
169
+
170
+ def _extract_tags(self, mail_obj: dict[str, Any]) -> list[str]:
171
+ tags_dict = mail_obj.get('tags', {})
172
+ result = []
173
+ for key, values in tags_dict.items():
174
+ for val in values:
175
+ result.append(f'{key}:{val}')
176
+ return result
177
+
178
+ def _extract_metadata(self, mail_obj: dict[str, Any]) -> dict[str, Any]:
179
+ headers = mail_obj.get('headers', [])
180
+ metadata: dict[str, Any] = {}
181
+ for header in headers:
182
+ name = header.get('name', '')
183
+ if name.startswith('X-Metadata-'):
184
+ metadata[name.removeprefix('X-Metadata-')] = header.get('value', '')
185
+ return metadata
186
+
187
+ def _extract_reject_reason(self, event_type: str, detail: dict[str, Any]) -> str | None:
188
+ if event_type == 'Bounce':
189
+ return detail.get('bounceSubType') or detail.get('bounceType')
190
+ if event_type == 'Reject':
191
+ return detail.get('reason')
192
+ return None
193
+
194
+ def _extract_description(self, event_type: str, detail: dict[str, Any]) -> str | None:
195
+ if event_type == 'Bounce':
196
+ recipients = detail.get('bouncedRecipients', [])
197
+ if recipients:
198
+ return recipients[0].get('diagnosticCode')
199
+ if event_type == 'DeliveryDelay':
200
+ return detail.get('delayType')
201
+ return None