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.
@@ -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)