forge-mvc-mail 1.0.0b16__tar.gz
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.
- forge_mvc_mail-1.0.0b16/LICENSE +36 -0
- forge_mvc_mail-1.0.0b16/PKG-INFO +47 -0
- forge_mvc_mail-1.0.0b16/README.md +25 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/__init__.py +51 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/cli.py +432 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/config.py +123 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/exceptions.py +18 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/log.py +94 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/mailer.py +101 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/message.py +101 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/smtp.py +78 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/templates.py +94 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail/transports.py +178 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail.egg-info/PKG-INFO +47 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail.egg-info/SOURCES.txt +18 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail.egg-info/dependency_links.txt +1 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail.egg-info/requires.txt +2 -0
- forge_mvc_mail-1.0.0b16/forge_mvc_mail.egg-info/top_level.txt +1 -0
- forge_mvc_mail-1.0.0b16/pyproject.toml +35 -0
- forge_mvc_mail-1.0.0b16/setup.cfg +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Forge — Licence propriétaire / source disponible
|
|
2
|
+
|
|
3
|
+
Copyright (c) Roger Lequette.
|
|
4
|
+
Tous droits réservés.
|
|
5
|
+
|
|
6
|
+
Le code source de Forge est mis à disposition uniquement pour lecture, étude,
|
|
7
|
+
évaluation et usage éducatif personnel.
|
|
8
|
+
|
|
9
|
+
Sauf autorisation écrite préalable de Roger Lequette, il est interdit de :
|
|
10
|
+
|
|
11
|
+
- utiliser Forge dans un cadre professionnel, commercial ou institutionnel ;
|
|
12
|
+
- utiliser Forge pour une prestation client ;
|
|
13
|
+
- intégrer Forge dans un produit, service, SaaS, application vendue ou solution
|
|
14
|
+
déployée pour un tiers ;
|
|
15
|
+
- redistribuer Forge, modifié ou non ;
|
|
16
|
+
- publier une version modifiée de Forge ;
|
|
17
|
+
- vendre, louer, sous-licencier ou exploiter commercialement Forge ;
|
|
18
|
+
- supprimer ou modifier les mentions de copyright et de licence.
|
|
19
|
+
|
|
20
|
+
Les usages autorisés sans autorisation écrite préalable sont limités à :
|
|
21
|
+
|
|
22
|
+
- lire le code ;
|
|
23
|
+
- étudier son fonctionnement ;
|
|
24
|
+
- tester Forge à titre personnel ;
|
|
25
|
+
- l'utiliser dans un cadre éducatif personnel ou pédagogique non commercial ;
|
|
26
|
+
- évaluer le framework avant une éventuelle demande d'autorisation.
|
|
27
|
+
|
|
28
|
+
Toute utilisation non explicitement autorisée par cette licence nécessite une
|
|
29
|
+
autorisation écrite préalable de Roger Lequette.
|
|
30
|
+
|
|
31
|
+
Cette licence pourra évoluer ultérieurement. Toute version publiée de Forge
|
|
32
|
+
reste soumise à la licence présente dans son dépôt au moment de sa récupération.
|
|
33
|
+
|
|
34
|
+
LE LOGICIEL EST FOURNI « TEL QUEL », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
|
|
35
|
+
IMPLICITE. EN AUCUN CAS LE DÉTENTEUR DU COPYRIGHT NE POURRA ÊTRE TENU
|
|
36
|
+
RESPONSABLE DE TOUT DOMMAGE DÉCOULANT DE L'UTILISATION DE CE LOGICIEL.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-mail
|
|
3
|
+
Version: 1.0.0b16
|
|
4
|
+
Summary: Forge mail — composition d'emails, transports interchangeables, templates Jinja et CLI mail:*.
|
|
5
|
+
Author: Roger Lequette
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://forgemvc.com/docs/forge/
|
|
10
|
+
Keywords: python,mvc,forge,mail,email,smtp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b16
|
|
20
|
+
Requires-Dist: jinja2<4,>=3.1
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# forge-mvc-mail
|
|
24
|
+
|
|
25
|
+
Opt-in Forge pour l'**envoi d'emails**. Extrait du core (ADR-022) : le core ne
|
|
26
|
+
contient que les primitives générales ; l'email est une brique spécialisée,
|
|
27
|
+
optionnelle.
|
|
28
|
+
|
|
29
|
+
## Contenu
|
|
30
|
+
|
|
31
|
+
- `MailMessage` : composition d'un message (destinataires, sujet, texte, HTML).
|
|
32
|
+
- Transports interchangeables : `ConsoleTransport` (affichage), `SmtpTransport`
|
|
33
|
+
/ `SMTPMailer` (SMTP réel), `LogTransport`, `NullTransport`, `FakeTransport`
|
|
34
|
+
(tests).
|
|
35
|
+
- `MailTemplateRenderer` : rendu de templates d'email via Jinja2.
|
|
36
|
+
- `Mailer` : orchestration envoi + journalisation (`MailLogger`).
|
|
37
|
+
- `MailConfig` : configuration depuis l'environnement.
|
|
38
|
+
- CLI `mail:init`, `mail:test`, `mail:render`, `mail:doctor`, `mail:logs`.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install --pre forge-mvc-mail
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Le parcours pédagogique `welcome-mail` (`forge starter:build mail-welcome`)
|
|
47
|
+
montre l'usage pas à pas.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# forge-mvc-mail
|
|
2
|
+
|
|
3
|
+
Opt-in Forge pour l'**envoi d'emails**. Extrait du core (ADR-022) : le core ne
|
|
4
|
+
contient que les primitives générales ; l'email est une brique spécialisée,
|
|
5
|
+
optionnelle.
|
|
6
|
+
|
|
7
|
+
## Contenu
|
|
8
|
+
|
|
9
|
+
- `MailMessage` : composition d'un message (destinataires, sujet, texte, HTML).
|
|
10
|
+
- Transports interchangeables : `ConsoleTransport` (affichage), `SmtpTransport`
|
|
11
|
+
/ `SMTPMailer` (SMTP réel), `LogTransport`, `NullTransport`, `FakeTransport`
|
|
12
|
+
(tests).
|
|
13
|
+
- `MailTemplateRenderer` : rendu de templates d'email via Jinja2.
|
|
14
|
+
- `Mailer` : orchestration envoi + journalisation (`MailLogger`).
|
|
15
|
+
- `MailConfig` : configuration depuis l'environnement.
|
|
16
|
+
- CLI `mail:init`, `mail:test`, `mail:render`, `mail:doctor`, `mail:logs`.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install --pre forge-mvc-mail
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Le parcours pédagogique `welcome-mail` (`forge starter:build mail-welcome`)
|
|
25
|
+
montre l'usage pas à pas.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""forge-mvc-mail — Envoi d'emails opt-in (extrait du core, ADR-022).
|
|
2
|
+
|
|
3
|
+
Composition de messages, transports interchangeables (console, SMTP, log…),
|
|
4
|
+
rendu de templates Jinja, journalisation, et CLI `mail:*`.
|
|
5
|
+
"""
|
|
6
|
+
from forge_mvc_mail.config import MailConfig
|
|
7
|
+
from forge_mvc_mail.log import MailLogRecord, MailLogger
|
|
8
|
+
from forge_mvc_mail.exceptions import (
|
|
9
|
+
MailConfigurationError,
|
|
10
|
+
MailError,
|
|
11
|
+
MailSendError,
|
|
12
|
+
MailTemplateError,
|
|
13
|
+
MailValidationError,
|
|
14
|
+
)
|
|
15
|
+
from forge_mvc_mail.mailer import Mailer
|
|
16
|
+
from forge_mvc_mail.message import MailMessage
|
|
17
|
+
from forge_mvc_mail.smtp import SMTPMailer
|
|
18
|
+
from forge_mvc_mail.templates import MailTemplateRenderer
|
|
19
|
+
from forge_mvc_mail.transports import (
|
|
20
|
+
BaseTransport,
|
|
21
|
+
ConsoleTransport,
|
|
22
|
+
FakeTransport,
|
|
23
|
+
LogTransport,
|
|
24
|
+
NullTransport,
|
|
25
|
+
SmtpTransport,
|
|
26
|
+
TransportResult,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BaseTransport",
|
|
31
|
+
"ConsoleTransport",
|
|
32
|
+
"FakeTransport",
|
|
33
|
+
"LogTransport",
|
|
34
|
+
"MailConfig",
|
|
35
|
+
"MailConfigurationError",
|
|
36
|
+
"MailLogRecord",
|
|
37
|
+
"MailLogger",
|
|
38
|
+
"MailError",
|
|
39
|
+
"MailMessage",
|
|
40
|
+
"MailSendError",
|
|
41
|
+
"MailTemplateError",
|
|
42
|
+
"MailTemplateRenderer",
|
|
43
|
+
"MailValidationError",
|
|
44
|
+
"Mailer",
|
|
45
|
+
"NullTransport",
|
|
46
|
+
"SMTPMailer",
|
|
47
|
+
"SmtpTransport",
|
|
48
|
+
"TransportResult",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
__version__ = "1.0.0b16"
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Commandes CLI mail de Forge.
|
|
2
|
+
|
|
3
|
+
forge mail:init — crée les dossiers et templates d'exemple
|
|
4
|
+
forge mail:test — envoie un message de test via Mailer
|
|
5
|
+
forge mail:render — affiche le rendu d'un template sans envoyer
|
|
6
|
+
forge mail:doctor — vérifie la configuration mail
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import forge_cli.output as out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_TEMPLATES_DIR = Path("mvc") / "mail" / "templates"
|
|
21
|
+
_STORAGE_DIR = Path("storage") / "mail"
|
|
22
|
+
_SQL_DIR = Path("mvc") / "models" / "sql"
|
|
23
|
+
_VALID_TRANSPORTS = frozenset({"null", "fake", "console", "log", "smtp"})
|
|
24
|
+
|
|
25
|
+
_MAIL_LOG_SQL = """\
|
|
26
|
+
CREATE TABLE IF NOT EXISTS mail_log (
|
|
27
|
+
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
28
|
+
message_type VARCHAR(100) NOT NULL DEFAULT '',
|
|
29
|
+
to_email VARCHAR(255) NOT NULL DEFAULT '',
|
|
30
|
+
subject VARCHAR(500) NOT NULL DEFAULT '',
|
|
31
|
+
transport VARCHAR(50) NOT NULL DEFAULT '',
|
|
32
|
+
status ENUM('sent', 'failed', 'skipped') NOT NULL,
|
|
33
|
+
error_message TEXT,
|
|
34
|
+
related_entity VARCHAR(100),
|
|
35
|
+
related_id INT,
|
|
36
|
+
created_at DATETIME NOT NULL,
|
|
37
|
+
sent_at DATETIME
|
|
38
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_SAMPLE_SUBJECT = "Test Forge — {{ date }}"
|
|
42
|
+
_SAMPLE_TEXT = """\
|
|
43
|
+
Ceci est un message de test envoyé par Forge le {{ date }}.
|
|
44
|
+
|
|
45
|
+
Transport : {{ transport }}
|
|
46
|
+
Destinataire: {{ to }}
|
|
47
|
+
|
|
48
|
+
— Forge
|
|
49
|
+
"""
|
|
50
|
+
_SAMPLE_HTML = """\
|
|
51
|
+
<p>Ceci est un message de test envoyé par <strong>Forge</strong> le {{ date }}.</p>
|
|
52
|
+
<p>Transport : <code>{{ transport }}</code></p>
|
|
53
|
+
<p>Destinataire : {{ to }}</p>
|
|
54
|
+
"""
|
|
55
|
+
_SAMPLE_CONTEXT = {
|
|
56
|
+
"date": "2026-05-01",
|
|
57
|
+
"transport": "console",
|
|
58
|
+
"to": "test@example.com",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Helpers internes ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _write_if_new(path: Path, content: str) -> None:
|
|
65
|
+
if path.exists():
|
|
66
|
+
print(out.preserved(path.as_posix(), "← fichier existant, non touché"))
|
|
67
|
+
else:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
path.write_text(content, encoding="utf-8")
|
|
70
|
+
print(out.created(path.as_posix()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_env_and_configure_forge(root: Path) -> None:
|
|
74
|
+
"""Charge config.py (side-effect dotenv : peuple os.environ).
|
|
75
|
+
|
|
76
|
+
ADR-031 : le mail ne passe plus par le registre `core.forge`. Charger
|
|
77
|
+
l'environnement suffit ; `MailConfig.from_env()` le lit directement.
|
|
78
|
+
"""
|
|
79
|
+
from forge_cli.project_config import ProjectConfigError, load_project_config
|
|
80
|
+
try:
|
|
81
|
+
load_project_config(root)
|
|
82
|
+
except ProjectConfigError as exc:
|
|
83
|
+
sys.exit(f"Erreur : {exc}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── mail:init ─────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def cmd_mail_init(args: list[str], root: Path | None = None) -> None:
|
|
89
|
+
root = root or Path.cwd()
|
|
90
|
+
storage_dir = root / _STORAGE_DIR
|
|
91
|
+
templates_dir = root / _TEMPLATES_DIR
|
|
92
|
+
|
|
93
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
(storage_dir / ".gitkeep").touch(exist_ok=True)
|
|
95
|
+
print(out.ok(f"Dossier prêt : {_STORAGE_DIR.as_posix()}"))
|
|
96
|
+
|
|
97
|
+
templates_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
print(out.ok(f"Dossier prêt : {_TEMPLATES_DIR.as_posix()}"))
|
|
99
|
+
|
|
100
|
+
_write_if_new(templates_dir / "test_subject.txt", _SAMPLE_SUBJECT)
|
|
101
|
+
_write_if_new(templates_dir / "test_text.txt", _SAMPLE_TEXT)
|
|
102
|
+
_write_if_new(templates_dir / "test_html.html", _SAMPLE_HTML)
|
|
103
|
+
|
|
104
|
+
sql_dir = root / _SQL_DIR
|
|
105
|
+
sql_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
_write_if_new(sql_dir / "mail_log.sql", _MAIL_LOG_SQL)
|
|
107
|
+
|
|
108
|
+
ctx_path = root / "sample.json"
|
|
109
|
+
if not ctx_path.exists():
|
|
110
|
+
ctx_path.write_text(
|
|
111
|
+
json.dumps(_SAMPLE_CONTEXT, ensure_ascii=False, indent=2),
|
|
112
|
+
encoding="utf-8",
|
|
113
|
+
)
|
|
114
|
+
print(out.created("sample.json"))
|
|
115
|
+
else:
|
|
116
|
+
print(out.preserved("sample.json", "← fichier existant, non touché"))
|
|
117
|
+
|
|
118
|
+
print()
|
|
119
|
+
print(out.info("Commandes suivantes :"))
|
|
120
|
+
print(out.info(" forge mail:doctor"))
|
|
121
|
+
print(out.info(" forge mail:test --to vous@exemple.com"))
|
|
122
|
+
print(out.info(" forge mail:render test --context sample.json"))
|
|
123
|
+
print(out.info(" forge db:apply # pour créer la table mail_log"))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── mail:test ─────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def cmd_mail_test(args: list[str], root: Path | None = None) -> None:
|
|
129
|
+
if "--to" not in args:
|
|
130
|
+
sys.exit("Usage : forge mail:test --to adresse@example.com")
|
|
131
|
+
idx = args.index("--to")
|
|
132
|
+
if idx + 1 >= len(args):
|
|
133
|
+
sys.exit("Usage : forge mail:test --to adresse@example.com")
|
|
134
|
+
to = args[idx + 1]
|
|
135
|
+
|
|
136
|
+
root = root or Path.cwd()
|
|
137
|
+
_load_env_and_configure_forge(root)
|
|
138
|
+
|
|
139
|
+
from datetime import datetime
|
|
140
|
+
|
|
141
|
+
from forge_mvc_mail.config import MailConfig
|
|
142
|
+
from forge_mvc_mail.exceptions import MailConfigurationError
|
|
143
|
+
from forge_mvc_mail.mailer import Mailer
|
|
144
|
+
from forge_mvc_mail.message import MailMessage
|
|
145
|
+
|
|
146
|
+
config = MailConfig.from_env()
|
|
147
|
+
transport = config.build_transport()
|
|
148
|
+
mailer = Mailer(transport)
|
|
149
|
+
|
|
150
|
+
print(out.info(f"Transport : {transport.name}"))
|
|
151
|
+
print(out.info(f"MAIL_ENABLED : {config.enabled}"))
|
|
152
|
+
|
|
153
|
+
msg = MailMessage(
|
|
154
|
+
subject=f"Test Forge — {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
155
|
+
to=to,
|
|
156
|
+
body_text=(
|
|
157
|
+
f"Ceci est un message de test envoyé par Forge.\n\n"
|
|
158
|
+
f"Transport : {transport.name}\n"
|
|
159
|
+
f"Destinataire : {to}\n"
|
|
160
|
+
),
|
|
161
|
+
body_html=(
|
|
162
|
+
f"<p>Ceci est un message de test envoyé par <strong>Forge</strong>.</p>"
|
|
163
|
+
f"<p>Transport : <code>{transport.name}</code></p>"
|
|
164
|
+
f"<p>Destinataire : {to}</p>"
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
result = mailer.send(msg)
|
|
170
|
+
except MailConfigurationError as exc:
|
|
171
|
+
print(out.error(str(exc)))
|
|
172
|
+
raise SystemExit(1) from None
|
|
173
|
+
|
|
174
|
+
if result.skipped:
|
|
175
|
+
print(out.warn("Mail non envoyé — MAIL_ENABLED=false ou transport null."))
|
|
176
|
+
print(out.info("Définissez MAIL_ENABLED=true dans env/dev pour activer l'envoi."))
|
|
177
|
+
elif result.success:
|
|
178
|
+
print(out.ok(f"Mail envoyé via {result.transport} → {to}"))
|
|
179
|
+
if result.detail:
|
|
180
|
+
print(out.info(result.detail))
|
|
181
|
+
else:
|
|
182
|
+
print(out.error(f"Échec de l'envoi : {result.detail}"))
|
|
183
|
+
raise SystemExit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── mail:render ───────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def cmd_mail_render(args: list[str], root: Path | None = None) -> None:
|
|
189
|
+
if not args or args[0].startswith("-"):
|
|
190
|
+
sys.exit("Usage : forge mail:render <template> [--context fichier.json]")
|
|
191
|
+
|
|
192
|
+
template_name = args[0]
|
|
193
|
+
context: dict = {}
|
|
194
|
+
|
|
195
|
+
if "--context" in args:
|
|
196
|
+
idx = args.index("--context")
|
|
197
|
+
if idx + 1 >= len(args):
|
|
198
|
+
sys.exit("Usage : forge mail:render <template> [--context fichier.json]")
|
|
199
|
+
ctx_path = Path(args[idx + 1])
|
|
200
|
+
if not ctx_path.exists():
|
|
201
|
+
sys.exit(f"Erreur : fichier contexte introuvable : {ctx_path}")
|
|
202
|
+
try:
|
|
203
|
+
context = json.loads(ctx_path.read_text(encoding="utf-8"))
|
|
204
|
+
except json.JSONDecodeError as exc:
|
|
205
|
+
sys.exit(f"Erreur : JSON invalide dans {ctx_path} — {exc}")
|
|
206
|
+
|
|
207
|
+
root = root or Path.cwd()
|
|
208
|
+
_load_env_and_configure_forge(root)
|
|
209
|
+
|
|
210
|
+
from forge_mvc_mail.exceptions import MailTemplateError
|
|
211
|
+
from forge_mvc_mail.templates import MailTemplateRenderer
|
|
212
|
+
|
|
213
|
+
renderer = MailTemplateRenderer()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
msg = renderer.render(template_name, context, to="preview@localhost")
|
|
217
|
+
except MailTemplateError as exc:
|
|
218
|
+
sys.exit(f"Erreur : {exc}")
|
|
219
|
+
|
|
220
|
+
sep = "─" * 64
|
|
221
|
+
print(sep)
|
|
222
|
+
print(f"Template : {template_name}")
|
|
223
|
+
print(f"Sujet : {msg.subject}")
|
|
224
|
+
print(sep)
|
|
225
|
+
print("[TEXTE]")
|
|
226
|
+
print(msg.body_text or "(aucun)")
|
|
227
|
+
if msg.body_html is not None:
|
|
228
|
+
print(sep)
|
|
229
|
+
print("[HTML]")
|
|
230
|
+
print(msg.body_html)
|
|
231
|
+
print(sep)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── mail:doctor — checks ──────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class MailCheckResult:
|
|
238
|
+
status: Literal["ok", "warn", "fail", "skip"]
|
|
239
|
+
label: str
|
|
240
|
+
detail: str = ""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _check_enabled(enabled: bool, transport_name: str) -> MailCheckResult:
|
|
244
|
+
if not enabled:
|
|
245
|
+
return MailCheckResult(
|
|
246
|
+
"warn", "MAIL_ENABLED",
|
|
247
|
+
"false — aucun mail ne sera envoyé (NullTransport activé)",
|
|
248
|
+
)
|
|
249
|
+
return MailCheckResult("ok", "MAIL_ENABLED", f"true — transport : {transport_name}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_transport(transport_name: str) -> MailCheckResult:
|
|
253
|
+
if transport_name in _VALID_TRANSPORTS:
|
|
254
|
+
return MailCheckResult("ok", "MAIL_TRANSPORT", transport_name)
|
|
255
|
+
return MailCheckResult(
|
|
256
|
+
"fail", "MAIL_TRANSPORT",
|
|
257
|
+
f"{transport_name!r} inconnu — valides : {', '.join(sorted(_VALID_TRANSPORTS))}",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_templates_dir(root: Path) -> MailCheckResult:
|
|
262
|
+
d = root / _TEMPLATES_DIR
|
|
263
|
+
if d.is_dir():
|
|
264
|
+
count = len(list(d.glob("*.txt")) + list(d.glob("*.html")))
|
|
265
|
+
return MailCheckResult(
|
|
266
|
+
"ok", "Dossier templates",
|
|
267
|
+
f"{_TEMPLATES_DIR.as_posix()} — {count} fichier(s)",
|
|
268
|
+
)
|
|
269
|
+
return MailCheckResult(
|
|
270
|
+
"warn", "Dossier templates",
|
|
271
|
+
f"{_TEMPLATES_DIR.as_posix()} absent — lancez forge mail:init",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _check_storage_mail(root: Path) -> MailCheckResult:
|
|
276
|
+
d = root / _STORAGE_DIR
|
|
277
|
+
if d.is_dir():
|
|
278
|
+
return MailCheckResult("ok", "Stockage mail", f"{_STORAGE_DIR.as_posix()} présent")
|
|
279
|
+
try:
|
|
280
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
return MailCheckResult("ok", "Stockage mail", f"{_STORAGE_DIR.as_posix()} créé")
|
|
282
|
+
except OSError as exc:
|
|
283
|
+
return MailCheckResult(
|
|
284
|
+
"fail", "Stockage mail",
|
|
285
|
+
f"Impossible de créer {_STORAGE_DIR.as_posix()} — {exc}",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_from_address(from_email: str) -> MailCheckResult:
|
|
290
|
+
if from_email:
|
|
291
|
+
return MailCheckResult("ok", "MAIL_FROM", from_email)
|
|
292
|
+
return MailCheckResult("warn", "MAIL_FROM", "vide — requis pour les transports smtp et log")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _check_smtp_config(host: str, port: int) -> list[MailCheckResult]:
|
|
296
|
+
results: list[MailCheckResult] = []
|
|
297
|
+
if host:
|
|
298
|
+
results.append(MailCheckResult("ok", "MAIL_HOST", host))
|
|
299
|
+
else:
|
|
300
|
+
results.append(MailCheckResult("fail", "MAIL_HOST", "vide — requis si MAIL_TRANSPORT=smtp"))
|
|
301
|
+
results.append(MailCheckResult("ok", "MAIL_PORT", str(port)))
|
|
302
|
+
return results
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_mail_doctor(args: list[str], root: Path | None = None) -> None:
|
|
306
|
+
root = root or Path.cwd()
|
|
307
|
+
_load_env_and_configure_forge(root)
|
|
308
|
+
|
|
309
|
+
from forge_mvc_mail.config import MailConfig, mail_log_enabled
|
|
310
|
+
config = MailConfig.from_env()
|
|
311
|
+
|
|
312
|
+
checks: list[MailCheckResult] = [
|
|
313
|
+
_check_enabled(config.enabled, config.transport_name),
|
|
314
|
+
_check_transport(config.transport_name),
|
|
315
|
+
_check_templates_dir(root),
|
|
316
|
+
_check_storage_mail(root),
|
|
317
|
+
_check_from_address(config.from_email),
|
|
318
|
+
]
|
|
319
|
+
if config.transport_name == "smtp":
|
|
320
|
+
checks.extend(_check_smtp_config(config.host, config.port))
|
|
321
|
+
|
|
322
|
+
if mail_log_enabled():
|
|
323
|
+
checks.append(MailCheckResult(
|
|
324
|
+
"ok",
|
|
325
|
+
"MAIL_LOG_ENABLED",
|
|
326
|
+
"true — table mail_log requise (forge db:apply si pas encore fait)",
|
|
327
|
+
))
|
|
328
|
+
else:
|
|
329
|
+
checks.append(MailCheckResult(
|
|
330
|
+
"skip",
|
|
331
|
+
"MAIL_LOG_ENABLED",
|
|
332
|
+
"false — journalisation désactivée",
|
|
333
|
+
))
|
|
334
|
+
|
|
335
|
+
print("\nForge mail:doctor\n")
|
|
336
|
+
for r in checks:
|
|
337
|
+
status_tag = f"[{r.status.upper()}]".ljust(7)
|
|
338
|
+
detail = f" — {r.detail}" if r.detail else ""
|
|
339
|
+
print(f" {status_tag} {r.label}{detail}")
|
|
340
|
+
|
|
341
|
+
warns = sum(1 for r in checks if r.status == "warn")
|
|
342
|
+
fails = sum(1 for r in checks if r.status == "fail")
|
|
343
|
+
print(f"\n{warns} avertissement(s), {fails} erreur(s).\n")
|
|
344
|
+
|
|
345
|
+
if fails:
|
|
346
|
+
raise SystemExit(1)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── mail:logs ─────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
def cmd_mail_logs(args: list[str], root: Path | None = None) -> None:
|
|
352
|
+
limit = 20
|
|
353
|
+
if "--limit" in args:
|
|
354
|
+
idx = args.index("--limit")
|
|
355
|
+
if idx + 1 >= len(args):
|
|
356
|
+
sys.exit("Usage : forge mail:logs [--limit N]")
|
|
357
|
+
try:
|
|
358
|
+
limit = int(args[idx + 1])
|
|
359
|
+
except ValueError:
|
|
360
|
+
sys.exit("--limit doit être un entier positif")
|
|
361
|
+
|
|
362
|
+
root = root or Path.cwd()
|
|
363
|
+
_load_env_and_configure_forge(root)
|
|
364
|
+
|
|
365
|
+
from forge_mvc_mail.log import MailLogger
|
|
366
|
+
|
|
367
|
+
if not MailLogger.is_enabled():
|
|
368
|
+
print(out.warn("MAIL_LOG_ENABLED=false — journalisation désactivée."))
|
|
369
|
+
print(out.info("Ajoutez MAIL_LOG_ENABLED=true dans env/dev pour activer."))
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
rows = MailLogger.fetch_recent(limit)
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
msg = str(exc)
|
|
376
|
+
hint = ""
|
|
377
|
+
if "mail_log" in msg.lower() or "doesn't exist" in msg.lower() or "doesn't exist" in msg:
|
|
378
|
+
hint = "\n La table mail_log n'existe pas encore — lancez : forge db:apply"
|
|
379
|
+
sys.exit(f"Erreur : impossible de lire mail_log : {exc}{hint}")
|
|
380
|
+
|
|
381
|
+
if not rows:
|
|
382
|
+
print("Aucun enregistrement dans mail_log.")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
_print_logs_table(rows)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _print_logs_table(rows: list[dict]) -> None:
|
|
389
|
+
_STATUS_LABELS = {"sent": "[OK] ", "failed": "[FAIL] ", "skipped": "[SKIP] "}
|
|
390
|
+
print(f"\n{'ID':<6} {'DATE':>19} {'STATUS':<8} {'TRANSPORT':<12} {'TO':<30} SUJET")
|
|
391
|
+
print("-" * 100)
|
|
392
|
+
for row in rows:
|
|
393
|
+
row_id = str(row.get("id", ""))
|
|
394
|
+
created = str(row.get("created_at", ""))[:19]
|
|
395
|
+
status = str(row.get("status", ""))
|
|
396
|
+
label = _STATUS_LABELS.get(status, status)
|
|
397
|
+
transport = str(row.get("transport", ""))[:12]
|
|
398
|
+
to_email = str(row.get("to_email", ""))[:30]
|
|
399
|
+
subject = str(row.get("subject", ""))[:50]
|
|
400
|
+
print(f"{row_id:<6} {created:>19} {label} {transport:<12} {to_email:<30} {subject}")
|
|
401
|
+
print()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def main(args: list[str]) -> None:
|
|
407
|
+
command = args[0] if args else ""
|
|
408
|
+
|
|
409
|
+
if command == "mail:init":
|
|
410
|
+
cmd_mail_init(args[1:])
|
|
411
|
+
return
|
|
412
|
+
if command == "mail:test":
|
|
413
|
+
cmd_mail_test(args[1:])
|
|
414
|
+
return
|
|
415
|
+
if command == "mail:render":
|
|
416
|
+
cmd_mail_render(args[1:])
|
|
417
|
+
return
|
|
418
|
+
if command == "mail:doctor":
|
|
419
|
+
cmd_mail_doctor(args[1:])
|
|
420
|
+
return
|
|
421
|
+
if command == "mail:logs":
|
|
422
|
+
cmd_mail_logs(args[1:])
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
print(
|
|
426
|
+
"Usage : forge mail:init | "
|
|
427
|
+
"mail:test --to <email> | "
|
|
428
|
+
"mail:render <template> [--context ctx.json] | "
|
|
429
|
+
"mail:doctor | "
|
|
430
|
+
"mail:logs [--limit N]"
|
|
431
|
+
)
|
|
432
|
+
raise SystemExit(1)
|