django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +6 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/models/log.py +3 -0
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,309 @@
|
|
1
|
+
import io
|
2
|
+
from typing import Any, Dict, List, Optional, Tuple
|
3
|
+
from datetime import datetime, timezone
|
4
|
+
|
5
|
+
from email import policy
|
6
|
+
from email.message import Message
|
7
|
+
from email.parser import BytesParser
|
8
|
+
from email.utils import getaddresses, parsedate_to_datetime
|
9
|
+
|
10
|
+
from mojo.helpers import logit
|
11
|
+
from mojo.helpers.settings import settings
|
12
|
+
from mojo.helpers.aws.s3 import S3
|
13
|
+
from mojo.apps.aws.models import IncomingEmail, EmailAttachment, Mailbox
|
14
|
+
|
15
|
+
# Optional tasks manager (for async handler dispatch)
|
16
|
+
try:
|
17
|
+
from mojo.apps.tasks import get_manager as get_task_manager
|
18
|
+
except Exception: # pragma: no cover - tasks app may be optional in some environments
|
19
|
+
get_task_manager = None # type: ignore
|
20
|
+
|
21
|
+
|
22
|
+
logger = logit.get_logger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
def _safe_get_header(msg: Message, name: str) -> Optional[str]:
|
26
|
+
value = msg.get(name)
|
27
|
+
if value is None:
|
28
|
+
return None
|
29
|
+
if isinstance(value, str):
|
30
|
+
return value
|
31
|
+
try:
|
32
|
+
return str(value)
|
33
|
+
except Exception:
|
34
|
+
return None
|
35
|
+
|
36
|
+
|
37
|
+
def _parse_recipients(header_value: Optional[str]) -> List[str]:
|
38
|
+
if not header_value:
|
39
|
+
return []
|
40
|
+
# getaddresses parses "Name <email@domain>" into tuples; keep the email part
|
41
|
+
return [addr for _, addr in getaddresses([header_value]) if addr]
|
42
|
+
|
43
|
+
|
44
|
+
def _parse_date_hdr(date_value: Optional[str]) -> Optional[datetime]:
|
45
|
+
if not date_value:
|
46
|
+
return None
|
47
|
+
try:
|
48
|
+
dt = parsedate_to_datetime(date_value)
|
49
|
+
# Normalize to UTC if naive
|
50
|
+
if dt is not None and dt.tzinfo is None:
|
51
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
52
|
+
return dt
|
53
|
+
except Exception:
|
54
|
+
return None
|
55
|
+
|
56
|
+
|
57
|
+
def _collect_bodies_and_attachments(msg: Message) -> Tuple[Optional[str], Optional[str], List[Dict[str, Any]]]:
|
58
|
+
"""
|
59
|
+
Walks MIME parts and collects the best-effort text and html bodies,
|
60
|
+
along with attachment blobs and metadata.
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
(text_body, html_body, attachments)
|
64
|
+
attachments: list of dicts with keys: filename, content_type, content(bytes), size_bytes, metadata
|
65
|
+
"""
|
66
|
+
text_body: Optional[str] = None
|
67
|
+
html_body: Optional[str] = None
|
68
|
+
attachments: List[Dict[str, Any]] = []
|
69
|
+
|
70
|
+
if msg.is_multipart():
|
71
|
+
for part in msg.walk():
|
72
|
+
if part.is_multipart():
|
73
|
+
continue
|
74
|
+
|
75
|
+
content_disposition = (part.get_content_disposition() or "").lower() # 'attachment', 'inline', or None
|
76
|
+
content_type = (part.get_content_type() or "").lower()
|
77
|
+
filename = part.get_filename()
|
78
|
+
|
79
|
+
try:
|
80
|
+
payload = part.get_payload(decode=True)
|
81
|
+
except Exception:
|
82
|
+
payload = None
|
83
|
+
|
84
|
+
# Determine if this is an attachment
|
85
|
+
is_attachment = content_disposition == "attachment" or (filename is not None and content_type not in ("text/plain", "text/html"))
|
86
|
+
|
87
|
+
if is_attachment:
|
88
|
+
if not payload:
|
89
|
+
payload = b""
|
90
|
+
attachments.append({
|
91
|
+
"filename": filename or "attachment",
|
92
|
+
"content_type": content_type or "application/octet-stream",
|
93
|
+
"content": payload,
|
94
|
+
"size_bytes": len(payload),
|
95
|
+
"metadata": {
|
96
|
+
"content_id": part.get("Content-ID"),
|
97
|
+
"disposition": content_disposition or "",
|
98
|
+
"content_type": content_type,
|
99
|
+
}
|
100
|
+
})
|
101
|
+
continue
|
102
|
+
|
103
|
+
# Collect bodies
|
104
|
+
if content_type == "text/plain" and payload is not None:
|
105
|
+
try:
|
106
|
+
text_body = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
107
|
+
except Exception:
|
108
|
+
text_body = (text_body or "") + "\n[Error decoding text/plain part]"
|
109
|
+
elif content_type == "text/html" and payload is not None:
|
110
|
+
try:
|
111
|
+
html_body = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
112
|
+
except Exception:
|
113
|
+
html_body = (html_body or "") + "\n<!-- Error decoding text/html part -->"
|
114
|
+
else:
|
115
|
+
# Single part message
|
116
|
+
content_type = (msg.get_content_type() or "").lower()
|
117
|
+
try:
|
118
|
+
payload = msg.get_payload(decode=True)
|
119
|
+
except Exception:
|
120
|
+
payload = None
|
121
|
+
if content_type == "text/plain" and payload is not None:
|
122
|
+
text_body = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
123
|
+
elif content_type == "text/html" and payload is not None:
|
124
|
+
html_body = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
125
|
+
|
126
|
+
return text_body, html_body, attachments
|
127
|
+
|
128
|
+
|
129
|
+
def _flatten_headers(msg: Message) -> Dict[str, str]:
|
130
|
+
"""
|
131
|
+
Convert headers to a dict. If multiple headers share a name,
|
132
|
+
concatenate values with commas to avoid losing data.
|
133
|
+
"""
|
134
|
+
headers: Dict[str, str] = {}
|
135
|
+
for k, v in msg.items():
|
136
|
+
if k in headers:
|
137
|
+
headers[k] = f"{headers[k]}, {v}"
|
138
|
+
else:
|
139
|
+
headers[k] = v
|
140
|
+
return headers
|
141
|
+
|
142
|
+
|
143
|
+
def _compose_s3_url(bucket: str, key: str) -> str:
|
144
|
+
return f"s3://{bucket}/{key}"
|
145
|
+
|
146
|
+
|
147
|
+
def _attachment_s3_key(base_prefix: str, incoming_id: int, filename: str, index: int) -> str:
|
148
|
+
"""
|
149
|
+
Build the S3 object key for an attachment, under the same base prefix as the raw message.
|
150
|
+
- base_prefix: directory-like key prefix (e.g., 'inbound/example.com/2025/08/27/')
|
151
|
+
"""
|
152
|
+
safe_filename = filename or f"part-{index}"
|
153
|
+
return f"{base_prefix}attachments/{incoming_id}/{safe_filename}"
|
154
|
+
|
155
|
+
|
156
|
+
def _get_base_prefix_from_key(key: str) -> str:
|
157
|
+
"""
|
158
|
+
Return the prefix (directory path) for a given S3 key.
|
159
|
+
If no slash, return empty string; otherwise include trailing slash.
|
160
|
+
"""
|
161
|
+
if "/" not in key:
|
162
|
+
return ""
|
163
|
+
return key.rsplit("/", 1)[0].rstrip("/") + "/"
|
164
|
+
|
165
|
+
|
166
|
+
def _match_mailbox(recipients: List[str]) -> Optional[Mailbox]:
|
167
|
+
"""
|
168
|
+
Find the first mailbox that matches any of the recipient addresses (case-insensitive).
|
169
|
+
"""
|
170
|
+
for addr in recipients:
|
171
|
+
mb = Mailbox.objects.filter(email__iexact=addr).first()
|
172
|
+
if mb:
|
173
|
+
return mb
|
174
|
+
return None
|
175
|
+
|
176
|
+
|
177
|
+
def _enqueue_async_handler(mailbox: Mailbox, incoming_email_id: int):
|
178
|
+
"""
|
179
|
+
Publish a task to the configured tasks system for the mailbox's async handler.
|
180
|
+
"""
|
181
|
+
handler = (mailbox.async_handler or "").strip()
|
182
|
+
if not handler or get_task_manager is None:
|
183
|
+
return
|
184
|
+
|
185
|
+
try:
|
186
|
+
manager = get_task_manager()
|
187
|
+
channel = settings.get("EMAIL_TASK_CHANNEL", "email")
|
188
|
+
payload = {
|
189
|
+
"incoming_email_id": incoming_email_id,
|
190
|
+
"mailbox_id": mailbox.id,
|
191
|
+
"mailbox": mailbox.email,
|
192
|
+
"domain": mailbox.domain.name if mailbox.domain_id else None,
|
193
|
+
}
|
194
|
+
# Publish to the mailbox's configured handler
|
195
|
+
manager.publish(handler, payload, channel=channel)
|
196
|
+
except Exception as e:
|
197
|
+
logger.error(f"Failed to enqueue async handler for incoming_email={incoming_email_id}: {e}")
|
198
|
+
|
199
|
+
|
200
|
+
def process_inbound_email_from_s3(
|
201
|
+
bucket: str,
|
202
|
+
key: str,
|
203
|
+
recipients_hint: Optional[List[str]] = None,
|
204
|
+
received_at: Optional[datetime] = None,
|
205
|
+
) -> IncomingEmail:
|
206
|
+
"""
|
207
|
+
Process an inbound email stored as a raw MIME file in S3.
|
208
|
+
Steps:
|
209
|
+
1) Fetch the S3 object bytes
|
210
|
+
2) Parse MIME headers, bodies, and attachments
|
211
|
+
3) Store IncomingEmail and EmailAttachment rows
|
212
|
+
4) If any recipient matches a Mailbox (and allow_inbound), associate and enqueue its async handler
|
213
|
+
|
214
|
+
Args:
|
215
|
+
bucket: S3 bucket containing the raw MIME message
|
216
|
+
key: S3 key for the raw MIME message
|
217
|
+
recipients_hint: Optional list of recipients from SES event (receipt.recipients or mail.destination)
|
218
|
+
received_at: Optional timestamp for when SES received the message
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
IncomingEmail instance
|
222
|
+
"""
|
223
|
+
# 1) Get raw MIME from S3
|
224
|
+
logger.info(f"Processing inbound email from s3://{bucket}/{key}")
|
225
|
+
obj = S3.client.get_object(Bucket=bucket, Key=key)
|
226
|
+
body = obj["Body"].read()
|
227
|
+
size_bytes = len(body)
|
228
|
+
s3_url = _compose_s3_url(bucket, key)
|
229
|
+
|
230
|
+
# 2) Parse MIME
|
231
|
+
parser = BytesParser(policy=policy.default)
|
232
|
+
msg: Message = parser.parsebytes(body)
|
233
|
+
|
234
|
+
message_id = _safe_get_header(msg, "Message-ID") or _safe_get_header(msg, "Message-Id")
|
235
|
+
subject = _safe_get_header(msg, "Subject")
|
236
|
+
from_address = _safe_get_header(msg, "From")
|
237
|
+
to_header = _safe_get_header(msg, "To")
|
238
|
+
cc_header = _safe_get_header(msg, "Cc")
|
239
|
+
date_header = _parse_date_hdr(_safe_get_header(msg, "Date"))
|
240
|
+
headers = _flatten_headers(msg)
|
241
|
+
|
242
|
+
to_addresses = _parse_recipients(to_header)
|
243
|
+
cc_addresses = _parse_recipients(cc_header)
|
244
|
+
|
245
|
+
# Use SES-provided recipients if supplied (they are authoritative)
|
246
|
+
if recipients_hint:
|
247
|
+
# Merge hints and header addresses, deduplicating
|
248
|
+
known = set(addr.lower() for addr in to_addresses + cc_addresses)
|
249
|
+
for r in recipients_hint:
|
250
|
+
if r and r.lower() not in known:
|
251
|
+
to_addresses.append(r)
|
252
|
+
|
253
|
+
text_body, html_body, attachments = _collect_bodies_and_attachments(msg)
|
254
|
+
|
255
|
+
# 3) Determine mailbox (first match) and allow_inbound
|
256
|
+
mailbox: Optional[Mailbox] = _match_mailbox(to_addresses + cc_addresses)
|
257
|
+
if mailbox and not mailbox.allow_inbound:
|
258
|
+
mailbox = None # Respect mailbox inbound policy
|
259
|
+
|
260
|
+
# 4) Create IncomingEmail row
|
261
|
+
inc = IncomingEmail.objects.create(
|
262
|
+
mailbox=mailbox,
|
263
|
+
s3_object_url=s3_url,
|
264
|
+
message_id=(message_id or "").strip() or None,
|
265
|
+
from_address=from_address,
|
266
|
+
to_addresses=to_addresses or [],
|
267
|
+
cc_addresses=cc_addresses or [],
|
268
|
+
subject=subject,
|
269
|
+
date_header=date_header,
|
270
|
+
headers=headers,
|
271
|
+
text_body=text_body,
|
272
|
+
html_body=html_body,
|
273
|
+
size_bytes=size_bytes,
|
274
|
+
received_at=received_at or datetime.now(timezone.utc),
|
275
|
+
processed=False,
|
276
|
+
process_status="pending",
|
277
|
+
)
|
278
|
+
|
279
|
+
# 5) Store attachments to the same inbound S3 bucket under base_prefix/attachments/<incoming_id>/
|
280
|
+
base_prefix = _get_base_prefix_from_key(key)
|
281
|
+
for idx, att in enumerate(attachments, start=1):
|
282
|
+
att_key = _attachment_s3_key(base_prefix, inc.id, att["filename"], idx)
|
283
|
+
content_bytes: bytes = att.get("content") or b""
|
284
|
+
content_type: str = att.get("content_type") or "application/octet-stream"
|
285
|
+
|
286
|
+
# Upload to S3
|
287
|
+
S3.client.put_object(
|
288
|
+
Bucket=bucket,
|
289
|
+
Key=att_key,
|
290
|
+
Body=io.BytesIO(content_bytes),
|
291
|
+
ContentType=content_type,
|
292
|
+
)
|
293
|
+
|
294
|
+
# Create EmailAttachment row
|
295
|
+
EmailAttachment.objects.create(
|
296
|
+
incoming_email=inc,
|
297
|
+
filename=att["filename"] or None,
|
298
|
+
content_type=content_type,
|
299
|
+
size_bytes=len(content_bytes),
|
300
|
+
stored_as=_compose_s3_url(bucket, att_key),
|
301
|
+
metadata=att.get("metadata") or {},
|
302
|
+
)
|
303
|
+
|
304
|
+
# 6) Enqueue async handler if mailbox is set and has a handler
|
305
|
+
if mailbox and mailbox.async_handler:
|
306
|
+
_enqueue_async_handler(mailbox, inc.id)
|
307
|
+
|
308
|
+
logger.info(f"Stored IncomingEmail id={inc.id}, attachments={len(attachments)}")
|
309
|
+
return inc
|