django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__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.
Files changed (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.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