Coverage for src / couchers / templates / v2.py: 97%
97 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +0000
1"""
2template mailer/push notification formatter v2
3"""
5import logging
6import re
7from datetime import date, datetime
8from html import escape
9from pathlib import Path
10from typing import Any
11from zoneinfo import ZoneInfo
13import phonenumbers
14from google.protobuf.timestamp_pb2 import Timestamp
15from jinja2 import Environment, FileSystemLoader, pass_context
16from jinja2.runtime import Context
17from markdown_it import MarkdownIt
18from sqlalchemy.orm import Session
20from couchers import urls
21from couchers.config import config
22from couchers.email import queue_email
23from couchers.i18n.i18n import localize_string
24from couchers.models import User
25from couchers.utils import get_tz_as_text, now, to_aware_datetime
27logger = logging.getLogger(__name__)
29template_folder = Path(__file__).parent / ".." / ".." / ".." / "templates" / "v2"
31loader = FileSystemLoader(template_folder)
32env = Environment(loader=loader, trim_blocks=True)
34md = MarkdownIt("zero", {"typographer": True}).enable(["smartquotes", "heading", "hr", "list", "link", "emphasis"])
37# Special context values expected by v2 filters
38CONTEXT_YEAR_KEY = "_year"
39CONTEXT_TIMEZONE_DISPLAY_KEY = "_timezone_display"
40CONTEXT_TRANSLATION_LANGUAGE_KEY = "_lang"
41CONTEXT_PLAINTEXT_KEY = "_plain"
44def v2esc(value: Any) -> str:
45 return escape(str(value))
48def v2multiline(value: str) -> str:
49 return "<br />".join(value.splitlines())
52def v2sf(value: str) -> str:
53 return value
56def v2url(value: str) -> str:
57 return value
60def v2phone(value: str) -> str:
61 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)
64def v2date(value: date | str, user: User) -> str:
65 # todo: user locale-based date formatting
66 if isinstance(value, str): 66 ↛ 68line 66 didn't jump to line 68 because the condition on line 66 was always true
67 value = date.fromisoformat(value)
68 return value.strftime("%A %-d %B %Y")
71def v2time(value: datetime, user: User) -> str:
72 tz = ZoneInfo(user.timezone or "Etc/UTC")
73 return value.astimezone(tz=tz).strftime("%-I:%M %p (%H:%M)")
76def v2timestamp(value: Timestamp, user: User) -> str:
77 tz = ZoneInfo(user.timezone or "Etc/UTC")
78 return to_aware_datetime(value).astimezone(tz=tz).strftime("%A %-d %B %Y at %-I:%M %p (%H:%M)")
81def v2avatar(user: Any) -> str:
82 if not user.avatar_thumbnail_url: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 return urls.icon_url()
84 return user.avatar_thumbnail_url # type: ignore[no-any-return]
87def v2quote(value: str) -> str:
88 """
89 Multiline quote, use in place of markdown in plaintext emails
90 """
91 return "\n> ".join([""] + value.splitlines())
94def v2markdown(value: str) -> str:
95 return md.render(value) # type: ignore[no-any-return]
98def replace_tag(match: re.Match[str]) -> str:
99 tag = match.group(1)
100 inner_text = match.group(2)
101 if tag.lower() == "a":
102 # <a href="url">text</a> -> <text>
103 return f"<{inner_text}>"
104 else:
105 # <b>hello</b> -> hello
106 return inner_text
109@pass_context
110def v2translate(context: Context, key: str, **kwargs: Any) -> str:
111 """
112 Jinja2 filter to translate a string key with substitutions.
114 Usage in template:
115 {{ "greeting_key"|v2translate(name=user.name) }}
116 """
118 lang: str = context[CONTEXT_TRANSLATION_LANGUAGE_KEY]
120 # Prevent html injection
121 escaped_substitutions = {k: escape(str(v)) for k, v in kwargs.items()}
123 translated = localize_string(lang, key, substitutions=escaped_substitutions)
125 # Translations may include simple formatting HTML like <b> or <a>,
126 # but those should not appear in plain text emails.
127 if context.parent.get(CONTEXT_PLAINTEXT_KEY) == True:
128 # Doesn't support nesting, but should be sufficient for our needs
129 translated = re.sub(r"<(\w+).*?>(.*?)</\1>", replace_tag, translated)
130 translated = re.sub(r"<br\s*/?>", "\n", translated)
132 else:
133 # HTML support, email flavored
134 # mjml rendering converts <br /> to <br>, so prefer that form.
135 translated = translated.replace("\n", "<br>")
137 return translated
140def add_filters(env: Environment) -> None:
141 env.filters["v2esc"] = v2esc
142 env.filters["v2multiline"] = v2multiline
143 env.filters["v2sf"] = v2sf
144 env.filters["v2url"] = v2url
145 env.filters["v2phone"] = v2phone
146 env.filters["v2date"] = v2date
147 env.filters["v2time"] = v2time
148 env.filters["v2timestamp"] = v2timestamp
149 env.filters["v2avatar"] = v2avatar
150 env.filters["v2quote"] = v2quote
151 env.filters["v2markdown"] = v2markdown
152 env.filters["v2translate"] = v2translate
155add_filters(env)
158def send_simple_pretty_email(
159 session: Session, recipient: str, subject: str, template_name: str, template_args: dict[str, Any]
160) -> None:
161 """
162 This is a simplified version of couchers.notifications.background._send_email_notification
164 It's for the few security emails where we don't have a user to email but send directly to an email address.
165 """
166 template_args[CONTEXT_TRANSLATION_LANGUAGE_KEY] = "en" # Not yet localizable
167 template_args[CONTEXT_YEAR_KEY] = now().year
168 template_args[CONTEXT_TIMEZONE_DISPLAY_KEY] = get_tz_as_text("Etc/UTC")
169 template_args["footer_email_is_critical"] = True # Results in no unsubscribe footer.
171 plain_tmplt = (template_folder / f"{template_name}.txt").read_text()
172 plain_tmplt_footer = (template_folder / "_footer.txt").read_text()
173 plain = env.from_string(plain_tmplt + plain_tmplt_footer).render(template_args)
175 html_tmplt = (template_folder / "generated_html" / f"{template_name}.html").read_text()
176 html = env.from_string(html_tmplt).render(template_args)
178 queue_email(
179 session,
180 sender_name=config["NOTIFICATION_EMAIL_SENDER"],
181 sender_email=config["NOTIFICATION_EMAIL_ADDRESS"],
182 recipient=recipient,
183 subject=config["NOTIFICATION_PREFIX"] + subject,
184 plain=plain,
185 html=html,
186 source_data=config["VERSION"] + f"/{template_name}",
187 )