django-nativemojo 0.1.15__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 (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +281 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,390 @@
1
+ """
2
+ Simple Python service API for sending emails via Mailbox + AWS SES with Django-templated support.
3
+
4
+ This module provides a minimal, ergonomic interface for Django/Python code
5
+ to send emails using a configured Mailbox and AWS SES. It reuses existing helpers
6
+ and persists a SentMessage record for observability and webhook status updates.
7
+
8
+ Usage:
9
+
10
+ from mojo.apps.aws.services import email as email_service
11
+
12
+ # Simple send (no template)
13
+ sent = email_service.send_email(
14
+ from_email="support@example.com",
15
+ to=["user1@example.org", "user2@example.org"],
16
+ subject="Welcome",
17
+ body_text="Welcome to our service!",
18
+ body_html="<p>Welcome to our service!</p>",
19
+ )
20
+ print(sent.id, sent.ses_message_id, sent.status)
21
+
22
+ # Send with Django EmailTemplate (stored in DB)
23
+ sent = email_service.send_with_template(
24
+ from_email="support@example.com",
25
+ to="user@example.org",
26
+ template_name="welcome",
27
+ context={"first_name": "Ada"}
28
+ )
29
+
30
+ # Send with SES template (must exist in SES)
31
+ sent = email_service.send_template_email(
32
+ from_email="support@example.com",
33
+ to="user@example.org",
34
+ template_name="welcome-template",
35
+ template_context={"first_name": "Ada"}
36
+ )
37
+
38
+ Notes:
39
+ - Only Mailbox-owned addresses can send (enforced via allow_outbound flag).
40
+ - The envelope MAIL FROM and From header will be the Mailbox email.
41
+ - Domain verification is required unless allow_unverified=True is passed.
42
+ - Attachments are not supported by this simple API. To send attachments,
43
+ you can build a MIME message and call EmailSender.send_raw_email directly.
44
+ - reply_to defaults to from_email, and can be overridden.
45
+
46
+ This API stores a SentMessage row immediately with status="sending" and SES MessageId
47
+ (if available). Final delivery/bounce/complaint updates are handled asynchronously
48
+ by the SNS webhooks.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from typing import Any, Dict, List, Optional, Sequence, Union
54
+ from django.db import transaction
55
+
56
+ from mojo.apps.aws.models import Mailbox, SentMessage, EmailDomain, EmailTemplate
57
+ from mojo.helpers.aws.ses import EmailSender
58
+ from mojo.helpers.settings import settings
59
+ from mojo.helpers import logit
60
+
61
+
62
+ logger = logit.get_logger("email", "email.log")
63
+
64
+
65
+
66
+ # Exceptions
67
+
68
+ class MailboxNotFound(Exception):
69
+ pass
70
+
71
+
72
+ class OutboundNotAllowed(Exception):
73
+ pass
74
+
75
+
76
+ class DomainNotVerified(Exception):
77
+ pass
78
+
79
+
80
+ # Internal helpers
81
+
82
+ def _as_list(value: Union[str, Sequence[str], None]) -> List[str]:
83
+ if value is None:
84
+ return []
85
+ if isinstance(value, (list, tuple)):
86
+ return [str(v).strip() for v in value if str(v).strip()]
87
+ v = str(value).strip()
88
+ return [v] if v else []
89
+
90
+
91
+ def _get_mailbox(from_email: str) -> Mailbox:
92
+ mb = Mailbox.objects.select_related("domain").filter(email__iexact=from_email.strip()).first()
93
+ if not mb:
94
+ raise MailboxNotFound(f"Mailbox not found for from_email={from_email!r}")
95
+ if not mb.allow_outbound:
96
+ raise OutboundNotAllowed(f"Outbound sending is disabled for mailbox {mb.email}")
97
+ return mb
98
+
99
+
100
+ def _choose_region(mb: Mailbox, region: Optional[str]) -> str:
101
+ return region or (mb.domain.region if isinstance(mb.domain, EmailDomain) else None) or getattr(settings, "AWS_REGION", "us-east-1")
102
+
103
+
104
+ def _check_domain_verified(mb: Mailbox, allow_unverified: bool):
105
+ if allow_unverified:
106
+ return
107
+ if not mb.domain.is_verified:
108
+ raise DomainNotVerified(f"Domain {mb.domain.name if mb.domain_id else '(unknown)'} is not verified for sending (status={mb.domain.status})")
109
+
110
+
111
+ def _get_sender(access_key: Optional[str], secret_key: Optional[str], region: str) -> EmailSender:
112
+ return EmailSender(
113
+ access_key=access_key or settings.AWS_KEY,
114
+ secret_key=secret_key or settings.AWS_SECRET,
115
+ region=region,
116
+ )
117
+
118
+
119
+ # Public API
120
+
121
+ def send_email(
122
+ from_email: str,
123
+ to: Union[str, Sequence[str]],
124
+ *,
125
+ subject: Optional[str] = None,
126
+ body_text: Optional[str] = None,
127
+ body_html: Optional[str] = None,
128
+ cc: Optional[Union[str, Sequence[str]]] = None,
129
+ bcc: Optional[Union[str, Sequence[str]]] = None,
130
+ reply_to: Optional[Union[str, Sequence[str]]] = None,
131
+ allow_unverified: bool = False,
132
+ aws_access_key: Optional[str] = None,
133
+ aws_secret_key: Optional[str] = None,
134
+ region: Optional[str] = None,
135
+ ) -> SentMessage:
136
+ """
137
+ Send an email using a Mailbox (resolved by from_email) and AWS SES.
138
+
139
+ Args:
140
+ from_email: The sending address (must match a configured Mailbox).
141
+ to: One or more recipient addresses.
142
+ subject: Email subject (required if no template is used).
143
+ body_text: Optional plain text body.
144
+ body_html: Optional HTML body.
145
+ cc, bcc, reply_to: Optional addressing.
146
+ allow_unverified: If True, bypass domain verification check (use with caution).
147
+ aws_access_key, aws_secret_key: Optional per-call AWS credentials.
148
+ region: Optional AWS region; defaults to mailbox.domain.region or settings.AWS_REGION.
149
+
150
+ Returns:
151
+ SentMessage instance persisted to the database.
152
+
153
+ Raises:
154
+ MailboxNotFound, OutboundNotAllowed, DomainNotVerified on validation errors.
155
+ """
156
+ mailbox = _get_mailbox(from_email)
157
+ _check_domain_verified(mailbox, allow_unverified)
158
+
159
+ region_final = _choose_region(mailbox, region)
160
+ to_list = _as_list(to)
161
+ cc_list = _as_list(cc)
162
+ bcc_list = _as_list(bcc)
163
+ # Default reply_to to from_email if not provided
164
+ reply_to_list = _as_list(reply_to) or [mailbox.email]
165
+
166
+ if not to_list:
167
+ raise ValueError("At least one 'to' recipient is required")
168
+ if not (subject or body_text or body_html):
169
+ raise ValueError("Provide at least one of subject, body_text, or body_html")
170
+
171
+ sender = _get_sender(aws_access_key, aws_secret_key, region_final)
172
+
173
+ with transaction.atomic():
174
+ sent = SentMessage.objects.create(
175
+ mailbox=mailbox,
176
+ to_addresses=to_list,
177
+ cc_addresses=cc_list,
178
+ bcc_addresses=bcc_list,
179
+ subject=subject or None,
180
+ body_text=body_text,
181
+ body_html=body_html,
182
+ status=SentMessage.STATUS_SENDING,
183
+ )
184
+
185
+ try:
186
+ resp = sender.send_email(
187
+ source=mailbox.email,
188
+ to_addresses=to_list,
189
+ subject=subject or "",
190
+ body_text=body_text,
191
+ body_html=body_html,
192
+ cc_addresses=cc_list or None,
193
+ bcc_addresses=bcc_list or None,
194
+ reply_to_addresses=reply_to_list or None,
195
+ )
196
+ msg_id = resp.get("MessageId")
197
+ if msg_id:
198
+ sent.ses_message_id = msg_id
199
+ sent.save(update_fields=["ses_message_id", "modified"])
200
+ return sent
201
+ # Failure path
202
+ sent.status = SentMessage.STATUS_FAILED
203
+ sent.status_reason = resp.get("Error") or str(resp)
204
+ sent.save(update_fields=["status", "status_reason", "modified"])
205
+ return sent
206
+
207
+ except Exception as e:
208
+ logger.error(f"send_email error for mailbox={mailbox.email}: {e}")
209
+ sent.status = SentMessage.STATUS_FAILED
210
+ sent.status_reason = str(e)
211
+ sent.save(update_fields=["status", "status_reason", "modified"])
212
+ return sent
213
+
214
+
215
+ def send_with_template(
216
+ from_email: str,
217
+ to: Union[str, Sequence[str]],
218
+ *,
219
+ template_name: str,
220
+ context: Optional[Dict[str, Any]] = None,
221
+ cc: Optional[Union[str, Sequence[str]]] = None,
222
+ bcc: Optional[Union[str, Sequence[str]]] = None,
223
+ reply_to: Optional[Union[str, Sequence[str]]] = None,
224
+ allow_unverified: bool = False,
225
+ aws_access_key: Optional[str] = None,
226
+ aws_secret_key: Optional[str] = None,
227
+ region: Optional[str] = None,
228
+ ) -> SentMessage:
229
+ """
230
+ Send using a Django EmailTemplate stored in DB.
231
+ Renders subject/text/html with the provided context and sends via SES.
232
+ """
233
+ mailbox = _get_mailbox(from_email)
234
+ _check_domain_verified(mailbox, allow_unverified)
235
+
236
+ region_final = _choose_region(mailbox, region)
237
+ to_list = _as_list(to)
238
+ cc_list = _as_list(cc)
239
+ bcc_list = _as_list(bcc)
240
+ # Default reply_to to from_email if not provided
241
+ reply_to_list = _as_list(reply_to) or [mailbox.email]
242
+
243
+ if not to_list:
244
+ raise ValueError("At least one 'to' recipient is required")
245
+ if not template_name:
246
+ raise ValueError("template_name is required")
247
+
248
+ # Load and render template
249
+ tpl = EmailTemplate.objects.filter(name=template_name).first()
250
+ if not tpl:
251
+ raise ValueError(f"EmailTemplate not found: {template_name}")
252
+ rendered = tpl.render_all(context or {})
253
+
254
+ subject = (rendered.get("subject") or "").strip()
255
+ body_text = rendered.get("text")
256
+ body_html = rendered.get("html")
257
+
258
+ if not (subject or body_text or body_html):
259
+ raise ValueError("Rendered template produced no subject/text/html")
260
+
261
+ sender = _get_sender(aws_access_key, aws_secret_key, region_final)
262
+
263
+ with transaction.atomic():
264
+ sent = SentMessage.objects.create(
265
+ mailbox=mailbox,
266
+ to_addresses=to_list,
267
+ cc_addresses=cc_list,
268
+ bcc_addresses=bcc_list,
269
+ subject=subject or None,
270
+ body_text=body_text,
271
+ body_html=body_html,
272
+ template_name=tpl.name,
273
+ template_context=context or {},
274
+ status=SentMessage.STATUS_SENDING,
275
+ )
276
+
277
+ try:
278
+ resp = sender.send_email(
279
+ source=mailbox.email,
280
+ to_addresses=to_list,
281
+ subject=subject or "",
282
+ body_text=body_text,
283
+ body_html=body_html,
284
+ cc_addresses=cc_list or None,
285
+ bcc_addresses=bcc_list or None,
286
+ reply_to_addresses=reply_to_list or None,
287
+ )
288
+ msg_id = resp.get("MessageId")
289
+ if msg_id:
290
+ sent.ses_message_id = msg_id
291
+ sent.save(update_fields=["ses_message_id", "modified"])
292
+ return sent
293
+ # Failure path
294
+ sent.status = SentMessage.STATUS_FAILED
295
+ sent.status_reason = resp.get("Error") or str(resp)
296
+ sent.save(update_fields=["status", "status_reason", "modified"])
297
+ return sent
298
+ except Exception as e:
299
+ logger.error(f"send_with_template error for mailbox={mailbox.email}: {e}")
300
+ sent.status = SentMessage.STATUS_FAILED
301
+ sent.status_reason = str(e)
302
+ sent.save(update_fields=["status", "status_reason", "modified"])
303
+ return sent
304
+
305
+ def send_template_email(
306
+ from_email: str,
307
+ to: Union[str, Sequence[str]],
308
+ *,
309
+ template_name: str,
310
+ template_context: Optional[Dict[str, Any]] = None,
311
+ cc: Optional[Union[str, Sequence[str]]] = None,
312
+ bcc: Optional[Union[str, Sequence[str]]] = None,
313
+ reply_to: Optional[Union[str, Sequence[str]]] = None,
314
+ allow_unverified: bool = False,
315
+ aws_access_key: Optional[str] = None,
316
+ aws_secret_key: Optional[str] = None,
317
+ region: Optional[str] = None,
318
+ ) -> SentMessage:
319
+ """
320
+ Send an email using a SES template and a Mailbox (resolved by from_email).
321
+
322
+ Args:
323
+ from_email: The sending address (must match a configured Mailbox).
324
+ to: One or more recipient addresses.
325
+ template_name: Name of the SES template.
326
+ template_context: Dict used to render the SES template.
327
+ cc, bcc, reply_to: Optional addressing.
328
+ allow_unverified: If True, bypass domain verification check (use with caution).
329
+ aws_access_key, aws_secret_key: Optional per-call AWS credentials.
330
+ region: Optional AWS region; defaults to mailbox.domain.region or settings.AWS_REGION.
331
+
332
+ Returns:
333
+ SentMessage instance persisted to the database.
334
+ """
335
+ mailbox = _get_mailbox(from_email)
336
+ _check_domain_verified(mailbox, allow_unverified)
337
+
338
+ region_final = _choose_region(mailbox, region)
339
+ to_list = _as_list(to)
340
+ cc_list = _as_list(cc)
341
+ bcc_list = _as_list(bcc)
342
+ # Default reply_to to from_email if not provided
343
+ reply_to_list = _as_list(reply_to) or [mailbox.email]
344
+ template_context = template_context if isinstance(template_context, dict) else {}
345
+
346
+ if not to_list:
347
+ raise ValueError("At least one 'to' recipient is required")
348
+ if not template_name:
349
+ raise ValueError("template_name is required for template-based sending")
350
+
351
+ sender = _get_sender(aws_access_key, aws_secret_key, region_final)
352
+
353
+ with transaction.atomic():
354
+ sent = SentMessage.objects.create(
355
+ mailbox=mailbox,
356
+ to_addresses=to_list,
357
+ cc_addresses=cc_list,
358
+ bcc_addresses=bcc_list,
359
+ template_name=template_name,
360
+ template_context=template_context,
361
+ status=SentMessage.STATUS_SENDING,
362
+ )
363
+
364
+ try:
365
+ resp = sender.send_template_email(
366
+ source=mailbox.email,
367
+ to_addresses=to_list,
368
+ template_name=template_name,
369
+ template_data=template_context,
370
+ cc_addresses=cc_list or None,
371
+ bcc_addresses=bcc_list or None,
372
+ reply_to_addresses=reply_to_list or None,
373
+ )
374
+ msg_id = resp.get("MessageId")
375
+ if msg_id:
376
+ sent.ses_message_id = msg_id
377
+ sent.save(update_fields=["ses_message_id", "modified"])
378
+ return sent
379
+ # Failure path
380
+ sent.status = SentMessage.STATUS_FAILED
381
+ sent.status_reason = resp.get("Error") or str(resp)
382
+ sent.save(update_fields=["status", "status_reason", "modified"])
383
+ return sent
384
+
385
+ except Exception as e:
386
+ logger.error(f"send_template_email error for mailbox={mailbox.email}: {e}")
387
+ sent.status = SentMessage.STATUS_FAILED
388
+ sent.status_reason = str(e)
389
+ sent.save(update_fields=["status", "status_reason", "modified"])
390
+ return sent