ntc-dev-kit 1.0.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.
- ntc/__init__.py +5 -0
- ntc/app_creator.py +296 -0
- ntc/cli.py +4 -0
- ntc/cli_enhanced.py +820 -0
- ntc/composer/__init__.py +0 -0
- ntc/composer/main.py +443 -0
- ntc/deployment/__init__.py +0 -0
- ntc/templates/__init__.py +2 -0
- ntc/templates/auth_app/__init__.py +0 -0
- ntc/templates/auth_app/admin.py +24 -0
- ntc/templates/auth_app/apps.py +6 -0
- ntc/templates/auth_app/models.py +30 -0
- ntc/templates/auth_app/serializers.py +101 -0
- ntc/templates/auth_app/urls.py +19 -0
- ntc/templates/auth_app/views.py +175 -0
- ntc/templates/drf_structure/__init__.py +1 -0
- ntc/templates/drf_structure/drf_structure.md +83 -0
- ntc/templates/drf_structure/main.py +389 -0
- ntc/templates/drf_structure/structure.json +126 -0
- ntc/templates/drf_structure/templates/.entry/start_core_backend.sh +37 -0
- ntc/templates/drf_structure/templates/.entry/start_tasks_backend.sh +30 -0
- ntc/templates/drf_structure/templates/CLAUDE.md +194 -0
- ntc/templates/drf_structure/templates/README.md +117 -0
- ntc/templates/drf_structure/templates/apps/app_admin.py +3 -0
- ntc/templates/drf_structure/templates/apps/app_apps.py +6 -0
- ntc/templates/drf_structure/templates/apps/app_models.py +4 -0
- ntc/templates/drf_structure/templates/apps/app_urls.py +8 -0
- ntc/templates/drf_structure/templates/apps/app_views.py +6 -0
- ntc/templates/drf_structure/templates/apps/common_apps.py +6 -0
- ntc/templates/drf_structure/templates/apps/common_models.py +8 -0
- ntc/templates/drf_structure/templates/config/gunicorn_config.py +9 -0
- ntc/templates/drf_structure/templates/config/http.conf +33 -0
- ntc/templates/drf_structure/templates/core/base_model.py +9 -0
- ntc/templates/drf_structure/templates/core/formatter.py +5 -0
- ntc/templates/drf_structure/templates/core/logger.py +8 -0
- ntc/templates/drf_structure/templates/core/pagination.py +6 -0
- ntc/templates/drf_structure/templates/core/response_wrapper.py +7 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/backend.py +108 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/client.py +296 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/notifier.py +102 -0
- ntc/templates/drf_structure/templates/core/services/object_storage.py +285 -0
- ntc/templates/drf_structure/templates/django_project/asgi.py +5 -0
- ntc/templates/drf_structure/templates/django_project/celery.py +8 -0
- ntc/templates/drf_structure/templates/django_project/dev_settings.py +2 -0
- ntc/templates/drf_structure/templates/django_project/django_settings.txt +322 -0
- ntc/templates/drf_structure/templates/django_project/management_command.py +114 -0
- ntc/templates/drf_structure/templates/django_project/prod_settings.py +3 -0
- ntc/templates/drf_structure/templates/django_project/schema.py +7 -0
- ntc/templates/drf_structure/templates/django_project/urls.py +6 -0
- ntc/templates/drf_structure/templates/django_project/wsgi.py +5 -0
- ntc/templates/drf_structure/templates/docker/docker-compose.yml +11 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_description.md +201 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_design_pattern.md +722 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_structure.md +151 -0
- ntc/templates/drf_structure/templates/root/README.md +307 -0
- ntc/templates/drf_structure/templates/root/django_env.txt +98 -0
- ntc/templates/drf_structure/templates/root/django_requirements.txt +24 -0
- ntc/templates/drf_structure/templates/root/gitignore.txt +6 -0
- ntc/templates/drf_structure/templates/root/manage.py +11 -0
- ntc/templates/flutter/.gitignore +44 -0
- ntc/templates/flutter/.knowledge/knowledge_description.md +215 -0
- ntc/templates/flutter/.knowledge/knowledge_design_pattern.md +310 -0
- ntc/templates/flutter/.knowledge/knowledge_structure.md +83 -0
- ntc/templates/flutter/CLAUDE.md +190 -0
- ntc/templates/flutter/__init__.py +0 -0
- ntc/templates/flutter/analysis_options.yaml +9 -0
- ntc/templates/flutter/main.py +187 -0
- ntc/templates/flutter/pubspec.yaml +65 -0
- ntc/templates/flutter/structure.json +51 -0
- ntc/templates/nextjs/.env.example +10 -0
- ntc/templates/nextjs/.gitignore +36 -0
- ntc/templates/nextjs/CLAUDE.md +181 -0
- ntc/templates/nextjs/README.md +94 -0
- ntc/templates/nextjs/__init__.py +0 -0
- ntc/templates/nextjs/knowledge/knowledge_description.md +208 -0
- ntc/templates/nextjs/knowledge/knowledge_design_pattern.md +460 -0
- ntc/templates/nextjs/knowledge/knowledge_structure.md +75 -0
- ntc/templates/nextjs/main.py +224 -0
- ntc/templates/nextjs/package.json +25 -0
- ntc/templates/nextjs/src/app/globals.css +26 -0
- ntc/templates/nextjs/src/app/layout.tsx +59 -0
- ntc/templates/nextjs/src/app/page.tsx +21 -0
- ntc/templates/nextjs/src/components/Header.tsx +16 -0
- ntc/templates/nextjs/src/services/api.ts +81 -0
- ntc/templates/nextjs/src/utils/constants.ts +1 -0
- ntc/templates/nextjs/structure.json +32 -0
- ntc/templates/nextjs/tsconfig.json +27 -0
- ntc/templates/react_admin/.env.example +1 -0
- ntc/templates/react_admin/.gitignore +10 -0
- ntc/templates/react_admin/CLAUDE.md +210 -0
- ntc/templates/react_admin/README.md +72 -0
- ntc/templates/react_admin/__init__.py +0 -0
- ntc/templates/react_admin/index.html +13 -0
- ntc/templates/react_admin/knowledge/knowledge_description.md +202 -0
- ntc/templates/react_admin/knowledge/knowledge_design_pattern.md +448 -0
- ntc/templates/react_admin/knowledge/knowledge_structure.md +97 -0
- ntc/templates/react_admin/main.py +208 -0
- ntc/templates/react_admin/package.json +35 -0
- ntc/templates/react_admin/src/App.tsx +60 -0
- ntc/templates/react_admin/src/components/ProtectedRoute.tsx +16 -0
- ntc/templates/react_admin/src/components/Sidebar.tsx +90 -0
- ntc/templates/react_admin/src/components/TopNav.tsx +44 -0
- ntc/templates/react_admin/src/context/AuthContext.tsx +107 -0
- ntc/templates/react_admin/src/context/ThemeContext.tsx +34 -0
- ntc/templates/react_admin/src/context/WorkspaceContext.tsx +28 -0
- ntc/templates/react_admin/src/hooks/useApi.ts +39 -0
- ntc/templates/react_admin/src/index.css +34 -0
- ntc/templates/react_admin/src/layouts/AuthLayout.tsx +23 -0
- ntc/templates/react_admin/src/layouts/DashboardLayout.tsx +24 -0
- ntc/templates/react_admin/src/main.tsx +5 -0
- ntc/templates/react_admin/src/pages/DashboardPage.tsx +10 -0
- ntc/templates/react_admin/src/pages/auth/LoginPage.tsx +79 -0
- ntc/templates/react_admin/src/pages/auth/RegisterPage.tsx +93 -0
- ntc/templates/react_admin/src/services/api.service.ts +44 -0
- ntc/templates/react_admin/src/services/base.service.ts +193 -0
- ntc/templates/react_admin/structure.json +52 -0
- ntc/templates/react_admin/tsconfig.app.json +25 -0
- ntc/templates/react_admin/tsconfig.json +7 -0
- ntc/templates/react_admin/tsconfig.node.json +19 -0
- ntc/templates/react_admin/vite.config.ts +13 -0
- ntc/templates/react_structure/CLAUDE.md +176 -0
- ntc/templates/react_structure/__init__.py +1 -0
- ntc/templates/react_structure/index.css +21 -0
- ntc/templates/react_structure/index.html +13 -0
- ntc/templates/react_structure/knowledge/knowledge_description.md +170 -0
- ntc/templates/react_structure/knowledge/knowledge_design_pattern.md +411 -0
- ntc/templates/react_structure/knowledge/knowledge_structure.md +74 -0
- ntc/templates/react_structure/main.py +217 -0
- ntc/templates/react_structure/package.json +32 -0
- ntc/templates/react_structure/src/App.tsx +63 -0
- ntc/templates/react_structure/src/components/Header.tsx +51 -0
- ntc/templates/react_structure/src/main.tsx +5 -0
- ntc/templates/react_structure/src/services/api.ts +90 -0
- ntc/templates/react_structure/src/utils/constants.ts +1 -0
- ntc/templates/react_structure/structure.json +65 -0
- ntc/templates/react_structure/tsconfig.app.json +25 -0
- ntc/templates/react_structure/tsconfig.json +7 -0
- ntc/templates/react_structure/tsconfig.node.json +19 -0
- ntc/templates/react_structure/vite.config.ts +12 -0
- ntc/utils/__init__.py +1 -0
- ntc/utils/environment.py +67 -0
- ntc/utils/output.py +41 -0
- ntc_dev_kit-1.0.0.dist-info/METADATA +188 -0
- ntc_dev_kit-1.0.0.dist-info/RECORD +148 -0
- ntc_dev_kit-1.0.0.dist-info/WHEEL +5 -0
- ntc_dev_kit-1.0.0.dist-info/entry_points.txt +2 -0
- ntc_dev_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
- ntc_dev_kit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Django email backend that sends through Azure Communication Services.
|
|
2
|
+
|
|
3
|
+
Configure with:
|
|
4
|
+
|
|
5
|
+
EMAIL_BACKEND = 'core.services.azure.email.backend.AzureCommunicationEmailBackend'
|
|
6
|
+
|
|
7
|
+
This backend is a drop-in replacement for the SMTP / console backends.
|
|
8
|
+
It supports ``EmailMessage`` and ``EmailMultiAlternatives`` (HTML +
|
|
9
|
+
text). When ``fail_silently=False`` (the default), Azure errors bubble
|
|
10
|
+
up; otherwise they are logged and counted as 0 successful sends.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from django.core.mail.backends.base import BaseEmailBackend
|
|
18
|
+
from django.core.mail.message import EmailMessage, EmailMultiAlternatives
|
|
19
|
+
|
|
20
|
+
from core.services.azure.email.client import (
|
|
21
|
+
AzureEmailClient,
|
|
22
|
+
AzureEmailNotConfigured,
|
|
23
|
+
AzureEmailSendError,
|
|
24
|
+
EmailMessageSpec,
|
|
25
|
+
EmailRecipient,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_recipients(addresses: Iterable[str]):
|
|
32
|
+
return [EmailRecipient(address=a) for a in addresses if a]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _split_html_text(message: EmailMessage):
|
|
36
|
+
"""Return ``(plain_text, html)`` from a Django ``EmailMessage``."""
|
|
37
|
+
plain = None
|
|
38
|
+
html = None
|
|
39
|
+
if isinstance(message, EmailMultiAlternatives):
|
|
40
|
+
plain = message.body or None
|
|
41
|
+
for content, mime in message.alternatives or []:
|
|
42
|
+
if mime == 'text/html':
|
|
43
|
+
html = content
|
|
44
|
+
break
|
|
45
|
+
else:
|
|
46
|
+
if (message.content_subtype or 'plain') == 'html':
|
|
47
|
+
html = message.body or None
|
|
48
|
+
else:
|
|
49
|
+
plain = message.body or None
|
|
50
|
+
return plain, html
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AzureCommunicationEmailBackend(BaseEmailBackend):
|
|
54
|
+
"""Send Django ``EmailMessage`` instances through Azure ACS Email."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, fail_silently: bool = False, **kwargs):
|
|
57
|
+
super().__init__(fail_silently=fail_silently)
|
|
58
|
+
self._client = AzureEmailClient()
|
|
59
|
+
|
|
60
|
+
def send_messages(self, email_messages):
|
|
61
|
+
if not email_messages:
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
sent = 0
|
|
65
|
+
for message in email_messages:
|
|
66
|
+
if self._send(message):
|
|
67
|
+
sent += 1
|
|
68
|
+
return sent
|
|
69
|
+
|
|
70
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
71
|
+
def _send(self, message: EmailMessage) -> bool:
|
|
72
|
+
if not message.recipients():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
plain, html = _split_html_text(message)
|
|
76
|
+
spec = EmailMessageSpec(
|
|
77
|
+
subject=message.subject or '(no subject)',
|
|
78
|
+
to=_to_recipients(message.to),
|
|
79
|
+
cc=_to_recipients(message.cc),
|
|
80
|
+
bcc=_to_recipients(message.bcc),
|
|
81
|
+
reply_to=_to_recipients(message.reply_to),
|
|
82
|
+
sender=message.from_email or None,
|
|
83
|
+
plain_text=plain,
|
|
84
|
+
html=html,
|
|
85
|
+
headers=dict(message.extra_headers or {}),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
self._client.send(spec)
|
|
90
|
+
return True
|
|
91
|
+
except AzureEmailNotConfigured:
|
|
92
|
+
if not self.fail_silently:
|
|
93
|
+
raise
|
|
94
|
+
logger.warning(
|
|
95
|
+
'Azure ACS email is not configured; dropping message to %s',
|
|
96
|
+
message.to,
|
|
97
|
+
)
|
|
98
|
+
return False
|
|
99
|
+
except AzureEmailSendError as exc:
|
|
100
|
+
if not self.fail_silently:
|
|
101
|
+
raise
|
|
102
|
+
logger.error('ACS email send failed: %s', exc)
|
|
103
|
+
return False
|
|
104
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
105
|
+
if not self.fail_silently:
|
|
106
|
+
raise
|
|
107
|
+
logger.exception('Unexpected error sending ACS email: %s', exc)
|
|
108
|
+
return False
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Thin wrapper around Azure Communication Services Email.
|
|
2
|
+
|
|
3
|
+
This module hides the Azure SDK behind a small, mockable surface so the
|
|
4
|
+
rest of the codebase only has to know about :class:`AzureEmailClient`.
|
|
5
|
+
|
|
6
|
+
Configuration (read from ``django.conf.settings``):
|
|
7
|
+
|
|
8
|
+
- ``AZURE_COMMUNICATION_ENDPOINT`` (e.g. ``https://xxx.communication.azure.com``)
|
|
9
|
+
- ``AZURE_COMMUNICATION_ACCESS_KEY``
|
|
10
|
+
- ``AZURE_COMMUNICATION_EMAIL_SENDER`` (the verified MailFrom address,
|
|
11
|
+
e.g. ``DoNotReply@xxxxxxxx.azurecomm.net``) — or the per-purpose
|
|
12
|
+
``AZURE_COMMUNICATION_EMAIL_SENDER_*`` slots resolved via
|
|
13
|
+
:func:`resolve_sender`.
|
|
14
|
+
|
|
15
|
+
Optional tuning:
|
|
16
|
+
|
|
17
|
+
- ``AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS`` (default ``2``)
|
|
18
|
+
- ``AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS`` (default ``60``)
|
|
19
|
+
|
|
20
|
+
When the endpoint or access key is missing, the client raises
|
|
21
|
+
:class:`AzureEmailNotConfigured` so callers (or the Django backend) can
|
|
22
|
+
fall back to ``django.core.mail.backends.console.EmailBackend`` in dev.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Iterable, List, Optional
|
|
30
|
+
|
|
31
|
+
from django.conf import settings
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AzureEmailNotConfigured(RuntimeError):
|
|
37
|
+
"""Raised when the Azure Communication Services email client is used
|
|
38
|
+
without a connection string + sender configured."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AzureEmailSendError(RuntimeError):
|
|
42
|
+
"""Raised when Azure rejects a send or polling fails."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class EmailRecipient:
|
|
47
|
+
"""An ACS recipient row (``{address, displayName}``)."""
|
|
48
|
+
|
|
49
|
+
address: str
|
|
50
|
+
display_name: str = ''
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
d = {'address': self.address}
|
|
54
|
+
if self.display_name:
|
|
55
|
+
d['displayName'] = self.display_name
|
|
56
|
+
return d
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class EmailMessageSpec:
|
|
61
|
+
"""Strict, validated message spec passed to :meth:`AzureEmailClient.send`.
|
|
62
|
+
|
|
63
|
+
Bodies are optional individually but at least one of ``html`` / ``plain_text``
|
|
64
|
+
must be set.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
subject: str
|
|
68
|
+
to: List[EmailRecipient]
|
|
69
|
+
plain_text: Optional[str] = None
|
|
70
|
+
html: Optional[str] = None
|
|
71
|
+
cc: List[EmailRecipient] = field(default_factory=list)
|
|
72
|
+
bcc: List[EmailRecipient] = field(default_factory=list)
|
|
73
|
+
reply_to: List[EmailRecipient] = field(default_factory=list)
|
|
74
|
+
sender: Optional[str] = None
|
|
75
|
+
headers: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def to_acs_payload(self, default_sender: str) -> dict:
|
|
78
|
+
if not self.subject:
|
|
79
|
+
raise ValueError('subject is required')
|
|
80
|
+
if not self.to:
|
|
81
|
+
raise ValueError('at least one "to" recipient is required')
|
|
82
|
+
if not self.html and not self.plain_text:
|
|
83
|
+
raise ValueError('one of html or plain_text body is required')
|
|
84
|
+
|
|
85
|
+
content: dict = {'subject': self.subject}
|
|
86
|
+
if self.plain_text:
|
|
87
|
+
content['plainText'] = self.plain_text
|
|
88
|
+
if self.html:
|
|
89
|
+
content['html'] = self.html
|
|
90
|
+
|
|
91
|
+
recipients: dict = {'to': [r.to_dict() for r in self.to]}
|
|
92
|
+
if self.cc:
|
|
93
|
+
recipients['cc'] = [r.to_dict() for r in self.cc]
|
|
94
|
+
if self.bcc:
|
|
95
|
+
recipients['bcc'] = [r.to_dict() for r in self.bcc]
|
|
96
|
+
|
|
97
|
+
message: dict = {
|
|
98
|
+
'senderAddress': self.sender or default_sender,
|
|
99
|
+
'content': content,
|
|
100
|
+
'recipients': recipients,
|
|
101
|
+
}
|
|
102
|
+
if self.reply_to:
|
|
103
|
+
message['replyTo'] = [r.to_dict() for r in self.reply_to]
|
|
104
|
+
if self.headers:
|
|
105
|
+
# ACS exposes headers under ``content`` for some SDK versions; we
|
|
106
|
+
# keep them as ``headers`` which the SDK normalises.
|
|
107
|
+
message['headers'] = [
|
|
108
|
+
{'name': k, 'value': v} for k, v in self.headers.items()
|
|
109
|
+
]
|
|
110
|
+
return message
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _coerce_recipients(addresses: Iterable[str]) -> List[EmailRecipient]:
|
|
114
|
+
return [EmailRecipient(address=a) for a in addresses if a]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Canonical sender purposes. Keep in sync with
|
|
118
|
+
# ``settings.AZURE_COMMUNICATION_EMAIL_SENDERS``.
|
|
119
|
+
SENDER_NO_REPLY = 'no_reply'
|
|
120
|
+
SENDER_NOTIFICATION = 'notification'
|
|
121
|
+
SENDER_SUPPORT = 'support'
|
|
122
|
+
SENDER_MARKETING = 'marketing'
|
|
123
|
+
VALID_SENDER_KEYS = frozenset({
|
|
124
|
+
SENDER_NO_REPLY, SENDER_NOTIFICATION, SENDER_SUPPORT, SENDER_MARKETING,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_sender(sender: Optional[str]) -> str:
|
|
129
|
+
"""Turn a purpose keyword or literal address into a concrete MailFrom.
|
|
130
|
+
|
|
131
|
+
- ``None`` → default sender (``AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY``
|
|
132
|
+
or legacy ``AZURE_COMMUNICATION_EMAIL_SENDER``).
|
|
133
|
+
- A key in ``VALID_SENDER_KEYS`` → looked up in
|
|
134
|
+
``AZURE_COMMUNICATION_EMAIL_SENDERS``.
|
|
135
|
+
- Anything containing ``@`` → treated as a literal address and
|
|
136
|
+
returned verbatim.
|
|
137
|
+
- Unknown keys → ``ValueError``.
|
|
138
|
+
"""
|
|
139
|
+
senders = getattr(settings, 'AZURE_COMMUNICATION_EMAIL_SENDERS', {}) or {}
|
|
140
|
+
default_key = getattr(
|
|
141
|
+
settings, 'AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY', 'notification',
|
|
142
|
+
)
|
|
143
|
+
legacy = getattr(settings, 'AZURE_COMMUNICATION_EMAIL_SENDER', '') or ''
|
|
144
|
+
|
|
145
|
+
if sender is None or sender == '':
|
|
146
|
+
return senders.get(default_key) or legacy
|
|
147
|
+
|
|
148
|
+
if '@' in sender:
|
|
149
|
+
return sender
|
|
150
|
+
|
|
151
|
+
if sender not in VALID_SENDER_KEYS:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f'Unknown sender key {sender!r}. Expected one of '
|
|
154
|
+
f'{sorted(VALID_SENDER_KEYS)} or an email address.'
|
|
155
|
+
)
|
|
156
|
+
resolved = senders.get(sender) or legacy
|
|
157
|
+
if not resolved:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f'Sender {sender!r} is not configured. Set '
|
|
160
|
+
f'AZURE_COMMUNICATION_EMAIL_SENDER_{sender.upper()}.'
|
|
161
|
+
)
|
|
162
|
+
return resolved
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AzureEmailClient:
|
|
166
|
+
"""Wraps :class:`azure.communication.email.EmailClient`.
|
|
167
|
+
|
|
168
|
+
The client is lazily instantiated so importing this module never
|
|
169
|
+
forces an Azure SDK dependency check at start-up.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
endpoint: Optional[str] = None,
|
|
175
|
+
access_key: Optional[str] = None,
|
|
176
|
+
sender: Optional[str] = None,
|
|
177
|
+
polling_interval_seconds: Optional[int] = None,
|
|
178
|
+
timeout_seconds: Optional[int] = None,
|
|
179
|
+
):
|
|
180
|
+
self._endpoint = endpoint if endpoint is not None else getattr(
|
|
181
|
+
settings, 'AZURE_COMMUNICATION_ENDPOINT', '',
|
|
182
|
+
)
|
|
183
|
+
self._access_key = access_key if access_key is not None else getattr(
|
|
184
|
+
settings, 'AZURE_COMMUNICATION_ACCESS_KEY', '',
|
|
185
|
+
)
|
|
186
|
+
self._sender = sender or getattr(
|
|
187
|
+
settings, 'AZURE_COMMUNICATION_EMAIL_SENDER', '',
|
|
188
|
+
)
|
|
189
|
+
self._polling_interval = polling_interval_seconds or getattr(
|
|
190
|
+
settings, 'AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS', 2,
|
|
191
|
+
)
|
|
192
|
+
self._timeout = timeout_seconds or getattr(
|
|
193
|
+
settings, 'AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS', 60,
|
|
194
|
+
)
|
|
195
|
+
self._client = None
|
|
196
|
+
|
|
197
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
198
|
+
def _has_credentials(self) -> bool:
|
|
199
|
+
return bool(self._endpoint and self._access_key)
|
|
200
|
+
|
|
201
|
+
def _ensure_client(self):
|
|
202
|
+
if self._client is not None:
|
|
203
|
+
return self._client
|
|
204
|
+
|
|
205
|
+
if not self._has_credentials() or not self._sender:
|
|
206
|
+
raise AzureEmailNotConfigured(
|
|
207
|
+
'Azure Communication Services email is not configured. '
|
|
208
|
+
'Set AZURE_COMMUNICATION_ENDPOINT, '
|
|
209
|
+
'AZURE_COMMUNICATION_ACCESS_KEY and '
|
|
210
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER (or one of the '
|
|
211
|
+
'per-purpose AZURE_COMMUNICATION_EMAIL_SENDER_* slots).'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Imported lazily so the rest of the codebase doesn't hard-fail
|
|
215
|
+
# if the Azure SDK ever gets removed.
|
|
216
|
+
from azure.communication.email import EmailClient
|
|
217
|
+
from azure.core.credentials import AzureKeyCredential
|
|
218
|
+
|
|
219
|
+
self._client = EmailClient(
|
|
220
|
+
endpoint=self._endpoint,
|
|
221
|
+
credential=AzureKeyCredential(self._access_key),
|
|
222
|
+
)
|
|
223
|
+
return self._client
|
|
224
|
+
|
|
225
|
+
# ── Public ────────────────────────────────────────────────────────────
|
|
226
|
+
@property
|
|
227
|
+
def is_configured(self) -> bool:
|
|
228
|
+
return self._has_credentials() and bool(self._sender)
|
|
229
|
+
|
|
230
|
+
def send(self, message: EmailMessageSpec) -> str:
|
|
231
|
+
"""Send ``message`` via ACS and return the operation ``messageId``.
|
|
232
|
+
|
|
233
|
+
Blocks (politely) until ACS reports the operation completed or
|
|
234
|
+
the timeout is reached.
|
|
235
|
+
"""
|
|
236
|
+
client = self._ensure_client()
|
|
237
|
+
payload = message.to_acs_payload(default_sender=self._sender)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
poller = client.begin_send(payload)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
raise AzureEmailSendError(f'ACS rejected message: {exc}') from exc
|
|
243
|
+
|
|
244
|
+
# ``begin_send`` returns an LRO poller. ``result`` blocks until done.
|
|
245
|
+
deadline = time.monotonic() + self._timeout
|
|
246
|
+
while not poller.done():
|
|
247
|
+
if time.monotonic() > deadline:
|
|
248
|
+
raise AzureEmailSendError(
|
|
249
|
+
f'ACS send polling timed out after {self._timeout}s',
|
|
250
|
+
)
|
|
251
|
+
time.sleep(self._polling_interval)
|
|
252
|
+
|
|
253
|
+
result = poller.result()
|
|
254
|
+
# ``result`` is a dict-like with at least ``id`` and ``status``.
|
|
255
|
+
status = (result.get('status') or '').lower() if isinstance(result, dict) else ''
|
|
256
|
+
msg_id = (result.get('id') if isinstance(result, dict) else None) or ''
|
|
257
|
+
if status not in ('', 'succeeded'):
|
|
258
|
+
raise AzureEmailSendError(
|
|
259
|
+
f'ACS send finished with status={status!r} id={msg_id!r}',
|
|
260
|
+
)
|
|
261
|
+
return msg_id
|
|
262
|
+
|
|
263
|
+
# ── Convenience wrapper ───────────────────────────────────────────────
|
|
264
|
+
def send_simple(
|
|
265
|
+
self,
|
|
266
|
+
*,
|
|
267
|
+
subject: str,
|
|
268
|
+
to: Iterable[str],
|
|
269
|
+
html: Optional[str] = None,
|
|
270
|
+
text: Optional[str] = None,
|
|
271
|
+
cc: Iterable[str] = (),
|
|
272
|
+
bcc: Iterable[str] = (),
|
|
273
|
+
reply_to: Iterable[str] = (),
|
|
274
|
+
headers: Optional[dict] = None,
|
|
275
|
+
) -> str:
|
|
276
|
+
return self.send(EmailMessageSpec(
|
|
277
|
+
subject=subject,
|
|
278
|
+
to=_coerce_recipients(to),
|
|
279
|
+
cc=_coerce_recipients(cc),
|
|
280
|
+
bcc=_coerce_recipients(bcc),
|
|
281
|
+
reply_to=_coerce_recipients(reply_to),
|
|
282
|
+
html=html,
|
|
283
|
+
plain_text=text,
|
|
284
|
+
headers=headers or {},
|
|
285
|
+
))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_default_client: Optional[AzureEmailClient] = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_default_client() -> AzureEmailClient:
|
|
292
|
+
"""Process-global singleton, lazily constructed."""
|
|
293
|
+
global _default_client
|
|
294
|
+
if _default_client is None:
|
|
295
|
+
_default_client = AzureEmailClient()
|
|
296
|
+
return _default_client
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""High-level templated email sender.
|
|
2
|
+
|
|
3
|
+
The rest of the codebase should call :func:`send_template_email` rather
|
|
4
|
+
than touching the Azure SDK or :class:`EmailMultiAlternatives` directly.
|
|
5
|
+
The function renders ``<template>.html`` and (optionally) ``<template>.txt``
|
|
6
|
+
under the configured template loader and sends through Django's
|
|
7
|
+
``EMAIL_BACKEND`` — which is wired to Azure ACS in production.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Iterable, Optional
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.core.mail import EmailMultiAlternatives
|
|
16
|
+
from django.template import TemplateDoesNotExist
|
|
17
|
+
from django.template.loader import render_to_string
|
|
18
|
+
|
|
19
|
+
from core.services.azure.email.client import resolve_sender
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def send_template_email(
|
|
25
|
+
*,
|
|
26
|
+
subject: str,
|
|
27
|
+
to: Iterable[str] | str,
|
|
28
|
+
template: str,
|
|
29
|
+
context: Optional[dict] = None,
|
|
30
|
+
cc: Iterable[str] = (),
|
|
31
|
+
bcc: Iterable[str] = (),
|
|
32
|
+
reply_to: Iterable[str] = (),
|
|
33
|
+
from_email: Optional[str] = None,
|
|
34
|
+
sender: Optional[str] = None,
|
|
35
|
+
headers: Optional[dict] = None,
|
|
36
|
+
fail_silently: bool = False,
|
|
37
|
+
) -> int:
|
|
38
|
+
"""Render ``template`` and send the resulting message.
|
|
39
|
+
|
|
40
|
+
``template`` is a basename. The function looks up
|
|
41
|
+
``<template>.html`` (required) and ``<template>.txt`` (optional).
|
|
42
|
+
If the ``.txt`` file is missing, a stripped-text fallback is
|
|
43
|
+
generated from the HTML so non-HTML clients still see something.
|
|
44
|
+
|
|
45
|
+
Sender resolution (in order of precedence):
|
|
46
|
+
|
|
47
|
+
1. Explicit ``from_email`` (literal address).
|
|
48
|
+
2. ``sender`` keyword — one of ``no_reply`` / ``notification`` /
|
|
49
|
+
``support`` / ``marketing`` — resolved against
|
|
50
|
+
``settings.AZURE_COMMUNICATION_EMAIL_SENDERS``.
|
|
51
|
+
3. ``settings.AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY`` →
|
|
52
|
+
``AZURE_COMMUNICATION_EMAIL_SENDERS`` lookup.
|
|
53
|
+
4. ``settings.DEFAULT_FROM_EMAIL``.
|
|
54
|
+
|
|
55
|
+
Returns the number of messages successfully delivered (0 or 1).
|
|
56
|
+
"""
|
|
57
|
+
context = context or {}
|
|
58
|
+
if isinstance(to, str):
|
|
59
|
+
to_list = [to]
|
|
60
|
+
else:
|
|
61
|
+
to_list = [addr for addr in to if addr]
|
|
62
|
+
if not to_list:
|
|
63
|
+
logger.warning('send_template_email called without recipients')
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
html_body = render_to_string(f'{template}.html', context)
|
|
68
|
+
except TemplateDoesNotExist:
|
|
69
|
+
# Re-raise loudly: a missing HTML template is a programmer error.
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
text_body = render_to_string(f'{template}.txt', context)
|
|
74
|
+
except TemplateDoesNotExist:
|
|
75
|
+
# Lightweight HTML→text fallback. Avoids a html2text dependency
|
|
76
|
+
# while still beating "no plain part at all".
|
|
77
|
+
from html import unescape
|
|
78
|
+
import re
|
|
79
|
+
|
|
80
|
+
text_body = unescape(re.sub(r'<[^>]+>', '', html_body)).strip()
|
|
81
|
+
|
|
82
|
+
if from_email:
|
|
83
|
+
resolved_from = from_email
|
|
84
|
+
else:
|
|
85
|
+
try:
|
|
86
|
+
resolved_from = resolve_sender(sender) or settings.DEFAULT_FROM_EMAIL
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
logger.error('send_template_email: %s', exc)
|
|
89
|
+
resolved_from = settings.DEFAULT_FROM_EMAIL
|
|
90
|
+
|
|
91
|
+
msg = EmailMultiAlternatives(
|
|
92
|
+
subject=subject,
|
|
93
|
+
body=text_body,
|
|
94
|
+
from_email=resolved_from,
|
|
95
|
+
to=to_list,
|
|
96
|
+
cc=list(cc) or None,
|
|
97
|
+
bcc=list(bcc) or None,
|
|
98
|
+
reply_to=list(reply_to) or None,
|
|
99
|
+
headers=headers or None,
|
|
100
|
+
)
|
|
101
|
+
msg.attach_alternative(html_body, 'text/html')
|
|
102
|
+
return msg.send(fail_silently=fail_silently)
|