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