mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-13 06:25:20 +02:00
Render every outgoing e-mail from an editable text template
The wording of the dunning and SEPA pre-notification mails was hard-coded in
Python, so adjusting a single sentence required a new release. Both texts now
live in plain text templates that ship as defaults, are copied into the store's
templates/mail/ directory on first start and can be edited there or under
Optionen -> E-Mail-Vorlagen; an existing file is never overwritten and a deleted
one is restored from the shipped default.
A template carries its subject in the first line and the body after a blank
line. Placeholders use the same {{ ... }} syntax as the document templates and
share their member/organization values, so a name means the same thing in a
letter and in the mail that carries it. Unknown placeholders are rejected while
editing instead of during a send run, a line holding nothing but placeholders
that render empty is dropped, and {{#claims}} ... {{/claims}} repeats per entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3807bf7dc
commit
d6cd58a788
@@ -63,6 +63,20 @@ def write_json_atomic(path: Path, data: Any) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
@@ -35,6 +35,16 @@ from ccma.domain.dates import (
|
||||
parse_month_input,
|
||||
validate_member_dates,
|
||||
)
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplate,
|
||||
MailTemplateError,
|
||||
default_mail_template,
|
||||
parse_mail_template,
|
||||
serialize_mail_template,
|
||||
template_spec,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.domain.models import (
|
||||
ASSET_CUSTODY_TYPE_LABELS,
|
||||
ASSET_OWNER_TYPE_LABELS,
|
||||
@@ -46,7 +56,12 @@ from ccma.domain.models import (
|
||||
Event,
|
||||
Member,
|
||||
)
|
||||
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
|
||||
from ccma.storage.atomic import (
|
||||
json_content_hash_matches,
|
||||
read_json,
|
||||
write_json_atomic,
|
||||
write_text_atomic,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryError(RuntimeError):
|
||||
@@ -183,6 +198,18 @@ class MemberRepository:
|
||||
destination = templates_root / destination_name
|
||||
if source.is_file() and not destination.exists():
|
||||
shutil.copyfile(source, destination)
|
||||
mail_templates_root = self.mail_templates_root
|
||||
mail_templates_root.mkdir(parents=True, exist_ok=True)
|
||||
for spec in MAIL_TEMPLATES:
|
||||
destination = mail_templates_root / spec.filename
|
||||
if not destination.exists():
|
||||
try:
|
||||
template = default_mail_template(spec.key)
|
||||
write_text_atomic(
|
||||
destination, serialize_mail_template(template.subject, template.body)
|
||||
)
|
||||
except (MailTemplateError, OSError):
|
||||
continue
|
||||
config_path = self.root / "repository.json"
|
||||
if not config_path.exists():
|
||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||
@@ -2722,6 +2749,42 @@ class MemberRepository:
|
||||
}
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
|
||||
@property
|
||||
def mail_templates_root(self) -> Path:
|
||||
return self.root / "templates" / "mail"
|
||||
|
||||
def get_mail_template(self, key: str) -> MailTemplate:
|
||||
"""Reads the store's copy of a mail template. Stores created before mail
|
||||
templates existed (or with a deleted file) fall back to the shipped default
|
||||
and get the file written back, so the board always has an editable copy."""
|
||||
spec = template_spec(key)
|
||||
path = self.mail_templates_root / spec.filename
|
||||
try:
|
||||
return parse_mail_template(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
template = default_mail_template(key)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(template.subject, template.body))
|
||||
except OSError:
|
||||
pass
|
||||
return template
|
||||
|
||||
def save_mail_template(self, key: str, *, subject: str, body: str) -> None:
|
||||
spec = template_spec(key)
|
||||
try:
|
||||
validate_mail_template(key, subject, body)
|
||||
except MailTemplateError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
path = self.mail_templates_root / spec.filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(subject, body))
|
||||
|
||||
def reset_mail_template(self, key: str) -> MailTemplate:
|
||||
template = default_mail_template(key)
|
||||
self.save_mail_template(key, subject=template.subject, body=template.body)
|
||||
return template
|
||||
|
||||
def save_organization(self, values: dict[str, str]) -> None:
|
||||
organization = {key: str(value).strip() for key, value in values.items()}
|
||||
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
||||
|
||||
Reference in New Issue
Block a user