Compare commits

2 Commits
main ... dev

26 changed files with 2005 additions and 1 deletions

View File

@ -1,3 +1,14 @@
# django-courriel # django-courriel
A django app allowing to generate email messages frommarkdown templates A django app allowing to generate email messages from markdown templates
## installation and tests
```sh
poetry env use python@3.12
poetry install --with test
# run tests with
poetry run pytest
poetry run pytest --cov=django_courriel # with coverage report
```

View File

7
django_courriel/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CourrielConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "django_courriel"
verbose_name = "Courriel"

372
django_courriel/md.py Normal file
View File

@ -0,0 +1,372 @@
"""
markdown mail template parsing
"""
import logging
import warnings
from typing import Any
import frontmatter
import markdown
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import EmailMultiAlternatives
from django.template import Context, Template
from django.template.base import Variable, VariableNode
from django.template.loader import get_template, render_to_string
from .validation import (
CourrielError,
InvalidAddressError,
InvalidFrontMatter,
InvalidGabarit,
InvalidRecipientsError,
InvalidSenderError,
MarkdownEmailError,
UnresolvedVariableError,
ValidAddress,
ValidAddressList,
)
InvalidAddress = InvalidAddressError
logger = logging.getLogger(__name__)
class Courriel(EmailMultiAlternatives):
"""
django EmailMultiAlternatives with a french touch.
shall not accept invalid addresses or from_email.
And no empty subjects nor body, we're not barbarians.
"""
def __init__(self, *args, **kw):
super().__setattr__('_from_email', None)
self._to = ValidAddressList()
self._cc = ValidAddressList()
self._bcc = ValidAddressList()
self._reply_to = ValidAddressList()
super().__init__(*args, **kw)
@property
def from_email(self):
return self._from_email
@from_email.setter
def from_email(self, value):
self._from_email = ValidAddress.factory(value) if value else None
class MarkdownTemplate:
"""
Modèle de mail Markdown avec Front Matter YAML.
S'initialise avec le nom d'un gabarit sans contexte en validant sa structure.
"""
def __init__(self, template_name: str):
# find template file
# parse markdown, separate metadata from body
# validate minimal requirements
# validate and import hard coded addresses
# resolve, validate and import group addresses
self.template_name = template_name
self.raw_content = self.load_raw_template(template_name)
self.metadata, self.md_body = self.parse_template(self.raw_content)
self.validate()
self._from_email = self._init_from_email()
self._to = self._init_address_list("to")
self._cc = self._init_address_list("cc")
self._bcc = self._init_address_list("bcc")
self._reply_to = self._init_address_list("reply_to")
def _init_from_email(self) -> ValidAddress | None:
raw_from = self.metadata.get("from_email")
if raw_from:
return ValidAddress.factory(raw_from)
return None
def _init_address_list(self, key: str) -> ValidAddressList:
raw_val = self.metadata.get(key)
if not raw_val:
return ValidAddressList()
items = [raw_val] if isinstance(raw_val, str) else list(raw_val)
return ValidAddressList(items)
@property
def from_email(self) -> ValidAddress | None:
return self._from_email
@property
def to(self) -> ValidAddressList:
return self._to
@property
def cc(self) -> ValidAddressList:
return self._cc
@property
def bcc(self) -> ValidAddressList:
return self._bcc
@property
def reply_to(self) -> ValidAddressList:
return self._reply_to
@classmethod
def load_raw_template(cls, template_name: str) -> str:
"""Charge le code source brut du template sans l'évaluer."""
template_obj = get_template(template_name)
raw_content = getattr(getattr(template_obj, "template", template_obj), "source", "")
if not raw_content and hasattr(template_obj, "render"):
raw_content = template_obj.render({})
if not raw_content:
raise MarkdownEmailError(f"Le gabarit '{template_name}' est introuvable ou vide.")
return raw_content
@classmethod
def parse_template(cls, raw_content: str) -> tuple[dict[str, Any], str]:
"""Extrait le Front Matter YAML et le corps Markdown."""
try:
post = frontmatter.loads(raw_content)
return post.metadata or {}, post.content or ""
except Exception as err:
raise InvalidFrontMatter(f"Failed to parse Front Matter YAML : {err}") from err
def validate(self) -> None:
"""Vérifie la validité structurelle intrinsèque du modèle (métadonnées existantes)."""
if not self.metadata:
raise InvalidFrontMatter(
f"Le gabarit '{self.template_name}' ne contient pas de Front Matter YAML valide (délimité par ---)."
)
elif 'subject' not in self.metadata:
raise InvalidFrontMatter(f"Missing a 'subject' key in {self.template_name} front matter")
@classmethod
def extract_template_variable_names(cls, template_obj: Template) -> set[str]:
"""Extrait les noms de variables de premier niveau via l'API native des nœuds de template Django."""
variables = set()
for node in template_obj.nodelist.get_nodes_by_type(VariableNode):
var_obj = node.filter_expression.var
if isinstance(var_obj, Variable) and var_obj.literal is None and var_obj.var:
root_var = str(var_obj.var).split(".")[0]
variables.add(root_var)
return variables
@classmethod
def render_template_scalar(
cls,
template_str: str,
context: dict[str, Any],
field_name: str,
required: bool = False,
) -> str:
"""Rend une chaîne scalaire avec le contexte en gérant les variables manquantes."""
if not isinstance(template_str, str):
return template_str
template = Template(template_str)
vars_used = cls.extract_template_variable_names(template)
unresolved = [v for v in vars_used if v not in context or context[v] is None]
if unresolved:
if required:
raise UnresolvedVariableError(
f"Variable requise manquante ou non résolue pour '{field_name}': {', '.join(unresolved)}"
)
else:
warnings.warn(
f"Variable optionnelle non résolue pour '{field_name}': {', '.join(unresolved)}",
UserWarning,
stacklevel=3,
)
result = template.render(Context(context)).strip()
if required and not result:
raise UnresolvedVariableError(f"Le champ requis '{field_name}' ne peut être vide.")
return result
@classmethod
def resolve_group_emails(cls, group_name: str, required: bool = False) -> list[str]:
"""Résout les adresses e-mail des utilisateurs actifs appartenant à un groupe Django auth."""
Group = apps.get_model("auth", "Group")
User = get_user_model()
try:
group = Group.objects.get(name=group_name)
except Group.DoesNotExist:
if required:
raise UnresolvedVariableError(f"Le groupe Django auth '{group_name}' n'existe pas.")
warnings.warn(
f"Le groupe Django auth optionnel '{group_name}' n'existe pas.",
UserWarning,
stacklevel=3,
)
return []
emails = list(
User.objects.filter(groups=group, is_active=True)
.exclude(email="")
.exclude(email__isnull=True)
.values_list("email", flat=True)
)
if required and not emails:
raise UnresolvedVariableError(
f"Le groupe requis '{group_name}' ne contient aucun utilisateur actif avec une adresse e-mail."
)
return emails
@classmethod
def normalize_recipient_list(
cls,
raw_val: str | list[str] | None,
context: dict[str, Any],
field_name: str,
required: bool = False,
) -> list[str]:
"""Normalise un champ de destinataires (chaîne ou liste) en liste d'adresses résolues."""
if not raw_val:
if required:
raise UnresolvedVariableError(f"Le champ '{field_name}' est obligatoire et vide.")
return []
items = [raw_val] if isinstance(raw_val, str) else list(raw_val)
resolved_list = []
for item in items:
rendered = cls.render_template_scalar(item, context, field_name=field_name, required=required)
if rendered:
resolved_list.append(rendered)
if required and not resolved_list:
raise UnresolvedVariableError(f"Aucune adresse valide résolue pour '{field_name}'.")
return resolved_list
@classmethod
def resolve_groups_recipients(
cls,
raw_groups: str | list[str] | None,
field_name: str,
required: bool = False,
) -> list[str]:
"""Résout une liste de groupes d'utilisateurs en adresses e-mail uniques."""
if not raw_groups:
return []
groups = [raw_groups] if isinstance(raw_groups, str) else list(raw_groups)
resolved_emails = []
for grp in groups:
emails = cls.resolve_group_emails(grp, required=required)
resolved_emails.extend(emails)
return list(dict.fromkeys(resolved_emails))
def build_message(
self,
context: dict[str, Any] | None = None,
subject: str | None = None,
body: str | None = None,
from_email: str | None = None,
to: list[str] | None = None,
bcc: list[str] | None = None,
connection: Any = None,
attachments: Any = None,
headers: dict[str, Any] | None = None,
cc: list[str] | None = None,
reply_to: list[str] | None = None,
format: str | None = None,
) -> Courriel:
"""Génère et retourne une instance de Courriel prête à l'envoi pour le contexte donné."""
ctx = context.copy() if context else {}
# 1. Sujet
rendered_subject = subject or self.render_template_scalar(
self.metadata.get("subject", ""), ctx, field_name="subject", required=True
)
# 2. Destinataires directs et groupes
direct_to = to or self.normalize_recipient_list(
self.metadata.get("to"), ctx, field_name="to", required=False
)
group_to = self.resolve_groups_recipients(
self.metadata.get("to_groups"),
field_name="to_groups",
required=bool(not direct_to),
)
final_to = list(dict.fromkeys(direct_to + group_to))
if not final_to:
raise UnresolvedVariableError("Aucun destinataire 'to' spécifié ou résolu.")
direct_cc = cc or self.normalize_recipient_list(self.metadata.get("cc"), ctx, field_name="cc", required=False)
group_cc = self.resolve_groups_recipients(self.metadata.get("cc_groups"), field_name="cc_groups", required=False)
final_cc = list(dict.fromkeys(direct_cc + group_cc))
direct_bcc = bcc or self.normalize_recipient_list(self.metadata.get("bcc"), ctx, field_name="bcc", required=False)
group_bcc = self.resolve_groups_recipients(self.metadata.get("bcc_groups"), field_name="bcc_groups", required=False)
final_bcc = list(dict.fromkeys(direct_bcc + group_bcc))
final_reply_to = reply_to or self.normalize_recipient_list(
self.metadata.get("reply_to"), ctx, field_name="reply_to", required=False
)
final_from_email = from_email or self.render_template_scalar(
self.metadata.get("from_email", ""), ctx, field_name="from_email", required=False
) or None
# 3. Format de message (both, html, text)
mail_format = format or self.metadata.get("format", "both")
# 4. Évaluation du corps Markdown avec le contexte
rendered_markdown_body = body or Template(self.md_body).render(Context(ctx)).strip()
# 5. Rendu HTML Markdown
extensions = getattr(settings, "MARKDOWN_EMAIL_EXTENSIONS", ["extra", "nl2br"])
html_body = markdown.markdown(rendered_markdown_body, extensions=extensions)
# 6. Layout HTML optionnel
layout_template = self.metadata.get(
"layout",
getattr(settings, "MARKDOWN_EMAIL_DEFAULT_LAYOUT", "courriel/base.dj.html"),
)
if layout_template and layout_template != "none":
html_body = render_to_string(layout_template, {"content_html": html_body, **ctx})
# 7. Inlining CSS avec premailer si disponible
try:
from premailer import transform
html_body = transform(html_body)
except ImportError:
pass
# 8. Instanciation de Courriel
if mail_format == "text":
main_body = rendered_markdown_body
alternatives = []
elif mail_format == "html":
main_body = html_body
alternatives = []
else: # "both"
main_body = rendered_markdown_body
alternatives = [(html_body, "text/html")]
message = Courriel(
subject=rendered_subject,
body=main_body,
from_email=final_from_email,
to=final_to,
bcc=final_bcc,
connection=connection,
attachments=attachments,
headers=headers,
cc=final_cc,
reply_to=final_reply_to if final_reply_to else None,
)
for content, mimetype in alternatives:
message.attach_alternative(content, mimetype)
return message

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>{% block style %}{% endblock %}</style>
</head>
<body>
{% block body %}{{ content_html|safe }}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,107 @@
from email import errors as email_errors
from email import headerregistry as email_reg
from email import utils as email_utils
from django.core import exceptions, validators
class CourrielError(Exception):
""" Something went wrong and you'll have to take a guess """
class InvalidAddressError(exceptions.ValidationError, CourrielError):
""" Failed to parse an address definition """
class InvalidRecipientsError(CourrielError):
""" Can't be send to recipients """
class InvalidSenderError(CourrielError):
""" Missing or invalid sender """
class InvalidGabarit(CourrielError):
""" Something is wrong with the template """
class InvalidFrontMatter(InvalidGabarit):
""" Failed to parse template front matter """
class MarkdownEmailError(InvalidGabarit):
""" Error in markdown email or template loading """
class UnresolvedVariableError(CourrielError):
""" A template variable could not be resolved """
class ValidAddress(email_reg.Address):
""" extends email.headerregistry.Address so it can be used where django expect an email address.
Except it should never be invalid """
def __new__(cls, display_name='', username='', domain='', addr_spec=None):
""" Builds a ValidAddress from either an Address or a string, or raise InvalidAddressError """
try:
spec = addr_spec or f"{username}@{domain}"
cls._validate_spec(spec)
instance = super().__new__(cls)
return instance
except email_errors.InvalidHeaderDefect as e:
raise InvalidAddressError(f"{spec}") from e
@classmethod
def _validate_spec(cls, spec):
try:
validators.validate_email(spec)
except exceptions.ValidationError as e:
raise InvalidAddressError(f"{spec}") from e
@classmethod
def factory(cls, input) -> 'ValidAddress':
""" ensure Address instance from str, None or Address """
if isinstance(input, ValidAddress):
return input
if isinstance(input, email_reg.Address): # converting to subclass
return ValidAddress(display_name=input.display_name, username=input.username, domain=input.domain)
if input is None:
return ValidAddress(display_name='')
if isinstance(input, str):
try:
name, spec = email_utils.parseaddr(input)
return cls(name, addr_spec=spec)
except email_errors.InvalidHeaderDefect:
# parsing failed and may have split username, let's keep full input as display name
return cls(display_name=input)
raise TypeError(f"Cannot create ValidAddress from {type(input).__name__}")
@property
def email(self):
""" shorthand for addr_spec"""
return self.addr_spec
class ValidAddressList(list):
""" only accepts valid addresses """
def _validate(self, value):
""" converts to ValidAddress, may raise InvalidAddress """
return ValidAddress.factory(value)
def __init__(self, iterable=()):
validated = [self._validate(i) for i in iterable]
super().__init__(validated)
def append(self, item):
super().append(self._validate(item))
def insert(self, index, item):
super().insert(index, self._validate(item))
def extend(self, items):
for item in items:
super().append(self._validate(item))
def __setitem__(self, index, item):
super().__setitem__(index, self._validate(item))

1049
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
pyproject.toml Normal file
View File

@ -0,0 +1,33 @@
[project]
name = "django-courriel"
version = "0.1.0"
description = "A django app for generating email from markdown templates including frofrontmatter data"
authors = [
{name = "Jérémie Ducastel",email = "jeremie@ducastel.work"}
]
license = "EUPL"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"python-frontmatter (>=1.3.0,<2.0.0)",
"premailer (>=3.10.0,<4.0.0)",
"django (>=4.2,<5.0)",
"markdown (>=3.11,<4.0)"
]
[dependency-groups]
test = [
"pytest",
"pytest-django",
"pytest-cov"
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
python_files = ["tests.py", "test_*.py", "*_test.py"]
addopts = "--verbose"
pythonpath = ["."]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# Test configuration package

66
tests/settings.py Normal file
View File

@ -0,0 +1,66 @@
"""
Django settings for testing django-courriel package.
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
SECRET_KEY = 'test-secret-key-for-django-courriel-testing-only'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django_courriel',
]
MIDDLEWARE = []
ROOT_URLCONF = ''
# Database
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Internationalization
USE_I18N = True
USE_TZ = True
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Django templates
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'django_courriel', 'templates'),
os.path.join(BASE_DIR, 'tests', 'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [],
},
},
]
# Markdown email settings
MARKDOWN_EMAIL_EXTENSIONS = ['extra', 'nl2br']
MARKDOWN_EMAIL_DEFAULT_LAYOUT = 'courriel/base.dj.html'
# Use in-memory storage for testing
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'

View File

@ -0,0 +1,5 @@
---
subject: "Test from_email invalide"
from_email: "invalid-email-address"
---
Contenu de test.

View File

@ -0,0 +1,4 @@
---
subject is invalid
---
This shall raise a InvalidFrontMatter exception

View File

@ -0,0 +1,7 @@
---
subject: "Test adresse invalide dans to"
to:
- "valid@example.org"
- "bad address"
---
Contenu de test.

View File

@ -0,0 +1,4 @@
---
subject: "Hello"
---
Hello world !

View File

@ -0,0 +1,2 @@
# Gabarit sans front matter délimité
Ce fichier n'a pas de front matter valide.

View File

@ -0,0 +1,5 @@
---
to: "test@example.com"
cc: "another@example.com"
---
This shall raise a MissingSubject exception

View File

@ -0,0 +1,4 @@
---
to: "test@example.com"
---
Corps de test sans sujet.

View File

@ -0,0 +1,8 @@
---
subject: "Notification de groupe"
to_groups:
- "managers"
cc:
- "{{ extra_email }}"
---
Message envoyé au groupe.

View File

@ -0,0 +1,5 @@
---
subject: "Sujet présent"
to: "{{ missing_recipient }}"
---
Corps de test.

View File

@ -0,0 +1,14 @@
---
subject: "Bienvenue à la session {{ session_title }}"
to: "{{ recipient_email }}"
reply_to: "contact@gobelins.fr"
format: both
---
# Formation {{ session_title }}
Bonjour {{ recipient_name }},
Voici le programme :
{% for item in items %}
* {{ item }}
{% endfor %}

View File

@ -0,0 +1,6 @@
---
subject: "Notification inexistante"
to_groups:
- "inexistant"
---
Message.

View File

@ -0,0 +1,6 @@
---
subject: "Sujet présent"
to: "{{ recipient_email }}"
reply_to: "{{ missing_reply_to }}"
---
Corps de test.

View File

@ -0,0 +1,11 @@
---
subject: "Test adresses"
to:
- "Victor Hugo <hugo@les-miserables.fr>"
- "antoine@saint-exupery.aero"
cc: "Aimé Césaire <cesaire@cahiers.net>"
bcc:
- "boris@vian.jazz"
reply_to: "contact@example.org"
---
Contenu de test pour les adresses.

View File

@ -0,0 +1,5 @@
---
subject: "Test from_email valide"
from_email: "Victor Hugo <hugo@les-miserables.fr>"
---
Contenu de test.

106
tests/test_md.py Normal file
View File

@ -0,0 +1,106 @@
import unittest
from django.template.exceptions import TemplateDoesNotExist
from django.test import SimpleTestCase
from django_courriel.md import MarkdownTemplate
from django_courriel.validation import (
InvalidAddressError,
InvalidFrontMatter,
ValidAddress,
ValidAddressList,
)
class MarkdownTemplateTest(SimpleTestCase):
def test_init_valid(self):
"""A valid markdown template initializes with metadata and markdown body."""
template = MarkdownTemplate("minimal.md")
self.assertEqual(template.template_name, "minimal.md")
self.assertIn("subject", template.metadata)
self.assertEqual(template.metadata["subject"], "Hello")
self.assertIn("Hello world !", template.md_body)
def test_init_template_does_not_exist(self):
"""Instantiating with an unresolvable template raises TemplateDoesNotExist."""
with self.assertRaises(TemplateDoesNotExist):
MarkdownTemplate("does_not_exist.md")
def test_init_missing_front_matter(self):
"""A template without frontmatter delimiters raises InvalidFrontMatter."""
with self.assertRaises(InvalidFrontMatter):
MarkdownTemplate("missing_front_matter.md")
def test_init_invalid_front_matter(self):
"""A template with malformed frontmatter raises InvalidFrontMatter."""
with self.assertRaises(InvalidFrontMatter):
MarkdownTemplate("invalid_front_matter.md")
def test_init_missing_subject(self):
"""A template frontmatter without a 'subject' key raises InvalidFrontMatter."""
with self.assertRaises(InvalidFrontMatter) as ctx:
MarkdownTemplate("missing_subject.md")
self.assertIn("subject", str(ctx.exception).lower())
def test_from_email_none_when_omitted(self):
"""When from_email is not specified in frontmatter, getter returns None."""
template = MarkdownTemplate("minimal.md")
self.assertIsNone(template.from_email)
def test_from_email_valid(self):
"""When from_email is valid in frontmatter, getter returns a ValidAddress instance."""
template = MarkdownTemplate("valid_from_email.md")
self.assertIsInstance(template.from_email, ValidAddress)
self.assertEqual(template.from_email.display_name, "Victor Hugo")
self.assertEqual(template.from_email.addr_spec, "hugo@les-miserables.fr")
def test_from_email_invalid_raises_error(self):
"""When from_email is invalid in frontmatter, __init__ raises InvalidAddressError."""
with self.assertRaises(InvalidAddressError):
MarkdownTemplate("invalid_from_email.md")
def test_recipient_addresses_empty_when_omitted_or_empty(self):
"""When to, cc, bcc, reply_to are omitted or empty, getters return empty ValidAddressList instances."""
template = MarkdownTemplate("minimal.md")
self.assertIsInstance(template.to, ValidAddressList)
self.assertEqual(len(template.to), 0)
self.assertIsInstance(template.cc, ValidAddressList)
self.assertEqual(len(template.cc), 0)
self.assertIsInstance(template.bcc, ValidAddressList)
self.assertEqual(len(template.bcc), 0)
self.assertIsInstance(template.reply_to, ValidAddressList)
self.assertEqual(len(template.reply_to), 0)
def test_recipient_addresses_valid(self):
"""When address lists or single addresses are defined, getters return ValidAddressList."""
template = MarkdownTemplate("valid_addresses.md")
self.assertIsInstance(template.to, ValidAddressList)
self.assertEqual(len(template.to), 2)
self.assertEqual(template.to[0].display_name, "Victor Hugo")
self.assertEqual(template.to[1].addr_spec, "antoine@saint-exupery.aero")
self.assertIsInstance(template.cc, ValidAddressList)
self.assertEqual(len(template.cc), 1)
self.assertEqual(template.cc[0].addr_spec, "cesaire@cahiers.net")
self.assertIsInstance(template.bcc, ValidAddressList)
self.assertEqual(len(template.bcc), 1)
self.assertEqual(template.bcc[0].addr_spec, "boris@vian.jazz")
self.assertIsInstance(template.reply_to, ValidAddressList)
self.assertEqual(len(template.reply_to), 1)
self.assertEqual(template.reply_to[0].addr_spec, "contact@example.org")
def test_recipient_addresses_invalid_raises_error(self):
"""When any recipient contains an invalid address, __init__ raises InvalidAddressError."""
with self.assertRaises(InvalidAddressError):
MarkdownTemplate("invalid_recipient.md")
@unittest.skip("Will be implemented in the rendering slice")
def test_subject_rendering(self):
raise NotImplementedError()

155
tests/test_validation.py Normal file
View File

@ -0,0 +1,155 @@
from email.headerregistry import Address
from django.test import SimpleTestCase
from django_courriel.validation import (
CourrielError,
InvalidAddressError,
InvalidRecipientsError,
InvalidSenderError,
ValidAddress,
ValidAddressList,
)
# test data
valid_inputs = (
ValidAddress('Boris Vian', 'boris', 'vian.jazz'),
'antoine@saint-exupery.aero',
Address('Savinien de Cyrano', 'savinien', 'cyrano.lune'),
'Aimé Césaire <cesaire@cahiers.net>',
'Victor Hugo <victor-hugo@les-misérables.fr>',
)
invalid_inputs = (
Address(),
Address('Charles Baudelaire'),
Address('Charles', 'charles', 'les fleurs du mal'),
'Vernon Sullivan',
'simone@bovary',
)
class TestValidAddress(SimpleTestCase):
def setUp(self) -> None:
self.invalid_specs = [
'boris@vian',
'bad adress',
]
def test_interface(self):
""" respects parent email.headerregistry.Address interface """
try:
a = ValidAddress('Boris Vian', 'boris', 'vian.jazz')
self.assertEqual(a.display_name, 'Boris Vian')
self.assertEqual(a.addr_spec, 'boris@vian.jazz')
self.assertEqual(a.username, 'boris')
self.assertEqual(a.domain, 'vian.jazz')
self.assertEqual(str(a), 'Boris Vian <boris@vian.jazz>')
except Exception as e:
self.fail(f"should not have raised {e}")
with self.assertRaises(AttributeError): # should not be mutable
a.addr_spec = "<vernon@affreux.org>"
def test_new(self):
""" __new__ shall raise InvalidAddress errors """
with self.assertRaises(InvalidAddressError):
ValidAddress()
with self.assertRaises(InvalidAddressError):
ValidAddress('Simone Veil')
with self.assertRaises(InvalidAddressError):
ValidAddress(username='Simone Veil', domain='cafe.philo')
with self.assertRaises(InvalidAddressError):
ValidAddress(username='simone', domain='veil')
# some more bad specs
for spec in self.invalid_specs:
with self.assertRaises(InvalidAddressError):
ValidAddress(addr_spec=spec)
def test_factory(self):
""" builder from string or Address """
# invalid cases
inputs = self.invalid_specs + [None, Address()]
for input in inputs:
with self.assertRaises(InvalidAddressError):
ValidAddress.factory(input)
# valid cases
try:
a = ValidAddress.factory('antoine@saint-exupery.aero')
self.assertEqual(a.addr_spec, 'antoine@saint-exupery.aero')
a = ValidAddress.factory('"Victor Hugo" <hugo@miserables.paris>')
self.assertEqual(a.display_name, 'Victor Hugo')
self.assertEqual(a.addr_spec, 'hugo@miserables.paris')
# from an address
old = Address(display_name='Savinien de Cyrano', username='savinien', domain='cyrano.lune')
a = ValidAddress.factory(old)
self.assertEqual(str(a), 'Savinien de Cyrano <savinien@cyrano.lune>')
except InvalidAddressError as e:
self.fail(f"should not have raised {e}")
class TestValidAddressList(SimpleTestCase):
def setUp(self) -> None:
self.test_list = ValidAddressList()
def test__init(self):
# valid values
try:
ValidAddressList()
ValidAddressList(['victor@hugo.paris', 'Aimé Césaire <cesaire@cahiers.net>'])
except InvalidAddressError as e:
self.fail(f"should not have raised {e}")
# invalid values
with self.assertRaises(InvalidAddressError):
ValidAddressList(['invalid@address'])
def test_append(self):
# valid values
try:
for i in valid_inputs:
self.test_list.append(i)
except InvalidAddressError as e:
self.fail(f"should not have raised {e} when appending {i}")
# invalid values
for i in invalid_inputs:
with self.assertRaises(InvalidAddressError):
self.test_list.append(i)
# checking conversions
for item in self.test_list:
self.assertIsInstance(item, ValidAddress)
def test_insert(self):
for i in valid_inputs:
try:
self.test_list.insert(0, i)
except InvalidAddressError as e:
self.fail(f"should not have raised {e} when inserting {i}")
for i in invalid_inputs:
with self.assertRaises(InvalidAddressError):
self.test_list.insert(0, i)
# checking conversions
for item in self.test_list:
self.assertIsInstance(item, ValidAddress)
def test_set_item(self):
# cannot set item on empty list !?
self.test_list = ValidAddressList(['virginie@subutex.livre'])
for i in valid_inputs:
try:
self.test_list[0] = i
except InvalidAddressError as e:
self.fail(f"should not have raised {e} when inserting {i}")
for i in invalid_inputs:
with self.assertRaises(InvalidAddressError):
self.test_list[0] = i
# checking conversions
for item in self.test_list:
self.assertIsInstance(item, ValidAddress)
def test_extend(self):
try:
self.test_list.extend(valid_inputs)
except InvalidAddressError as e:
self.fail(f"should not have raised {e} when extending {valid_inputs}")
with self.assertRaises(InvalidAddressError):
self.test_list.extend(invalid_inputs)