fix: embed uploaded logo in emails
This commit is contained in:
@@ -10,13 +10,13 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import Optional
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_templates import EmailContent, render_login_code
|
from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code
|
||||||
from bot.services.message_audit import log_user_message_delivery
|
from bot.services.message_audit import log_user_message_delivery
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import security_dal, user_dal
|
from db.dal import security_dal, user_dal
|
||||||
@@ -453,6 +453,7 @@ class EmailAuthService:
|
|||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
html_body: Optional[str] = None,
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._send_custom_email_sync,
|
self._send_custom_email_sync,
|
||||||
@@ -460,6 +461,7 @@ class EmailAuthService:
|
|||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
|
inline_images=inline_images,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def send_rendered_email(
|
async def send_rendered_email(
|
||||||
@@ -473,6 +475,7 @@ class EmailAuthService:
|
|||||||
subject=content.subject,
|
subject=content.subject,
|
||||||
body=content.text,
|
body=content.text,
|
||||||
html_body=content.html,
|
html_body=content.html,
|
||||||
|
inline_images=content.inline_images,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _send_code_email_sync(
|
def _send_code_email_sync(
|
||||||
@@ -493,17 +496,13 @@ class EmailAuthService:
|
|||||||
i18n=self.i18n,
|
i18n=self.i18n,
|
||||||
)
|
)
|
||||||
|
|
||||||
message = EmailMessage()
|
message = self._build_email_message(
|
||||||
message["Subject"] = content.subject
|
email=email,
|
||||||
message["From"] = formataddr(
|
subject=content.subject,
|
||||||
(
|
body=content.text,
|
||||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
html_body=content.html,
|
||||||
self.settings.SMTP_FROM_EMAIL or "",
|
inline_images=content.inline_images,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
message["To"] = email
|
|
||||||
message.set_content(content.text)
|
|
||||||
message.add_alternative(content.html, subtype="html")
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
smtp_host = self.settings.SMTP_HOST
|
smtp_host = self.settings.SMTP_HOST
|
||||||
@@ -554,19 +553,15 @@ class EmailAuthService:
|
|||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
html_body: Optional[str] = None,
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
message = EmailMessage()
|
message = self._build_email_message(
|
||||||
message["Subject"] = subject
|
email=email,
|
||||||
message["From"] = formataddr(
|
subject=subject,
|
||||||
(
|
body=body,
|
||||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
html_body=html_body,
|
||||||
self.settings.SMTP_FROM_EMAIL or "",
|
inline_images=inline_images,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
message["To"] = email
|
|
||||||
message.set_content(body)
|
|
||||||
if html_body:
|
|
||||||
message.add_alternative(html_body, subtype="html")
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
smtp_host = self.settings.SMTP_HOST
|
smtp_host = self.settings.SMTP_HOST
|
||||||
@@ -610,6 +605,64 @@ class EmailAuthService:
|
|||||||
if last_error:
|
if last_error:
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|
||||||
|
def _build_email_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
|
) -> EmailMessage:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr(
|
||||||
|
(
|
||||||
|
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||||
|
self.settings.SMTP_FROM_EMAIL or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message["To"] = email
|
||||||
|
message.set_content(body)
|
||||||
|
if html_body:
|
||||||
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
self._attach_inline_images(message, inline_images)
|
||||||
|
return message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_inline_images(
|
||||||
|
message: EmailMessage,
|
||||||
|
inline_images: Sequence[EmailInlineImage],
|
||||||
|
) -> None:
|
||||||
|
if not inline_images:
|
||||||
|
return
|
||||||
|
html_part = message.get_body(("html",))
|
||||||
|
if html_part is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for image in inline_images:
|
||||||
|
content_type = (image.content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
if "/" not in content_type:
|
||||||
|
continue
|
||||||
|
maintype, subtype = content_type.split("/", 1)
|
||||||
|
if maintype != "image" or not subtype:
|
||||||
|
continue
|
||||||
|
|
||||||
|
body = bytes(image.data or b"")
|
||||||
|
content_id = (image.content_id or "").strip()
|
||||||
|
if not body or not content_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cid_header = content_id
|
||||||
|
if not (cid_header.startswith("<") and cid_header.endswith(">")):
|
||||||
|
cid_header = f"<{cid_header}>"
|
||||||
|
html_part.add_related(
|
||||||
|
body,
|
||||||
|
maintype=maintype,
|
||||||
|
subtype=subtype,
|
||||||
|
cid=cid_header,
|
||||||
|
)
|
||||||
|
|
||||||
def _send_message_via_smtp(
|
def _send_message_via_smtp(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
@@ -27,6 +28,27 @@ _TEXT_MUTED = "#9aa3b2"
|
|||||||
_TEXT_DIM = "#5d6573"
|
_TEXT_DIM = "#5d6573"
|
||||||
_DEFAULT_ACCENT = "#00fe7a"
|
_DEFAULT_ACCENT = "#00fe7a"
|
||||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||||
|
_EMAIL_LOGO_CONTENT_ID = "webapp-logo"
|
||||||
|
_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||||
|
_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads"
|
||||||
|
_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)")
|
||||||
|
_LOGO_CONTENT_TYPES = {
|
||||||
|
".gif": "image/gif",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailInlineImage:
|
||||||
|
content_id: str
|
||||||
|
content_type: str
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -34,6 +56,13 @@ class EmailContent:
|
|||||||
subject: str
|
subject: str
|
||||||
text: str
|
text: str
|
||||||
html: str
|
html: str
|
||||||
|
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _EmailLayout:
|
||||||
|
html: str
|
||||||
|
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def _safe_color(value: Optional[str]) -> str:
|
def _safe_color(value: Optional[str]) -> str:
|
||||||
@@ -57,6 +86,55 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||||
|
parsed = urlsplit(str(url or ""))
|
||||||
|
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||||
|
prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||||
|
if not path.startswith(prefix):
|
||||||
|
return None
|
||||||
|
filename = path.removeprefix(prefix)
|
||||||
|
return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
|
||||||
|
filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip())
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower())
|
||||||
|
if not content_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve()
|
||||||
|
logo_path = (uploads_dir / filename).resolve()
|
||||||
|
logo_path.relative_to(uploads_dir)
|
||||||
|
body = logo_path.read_bytes()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return EmailInlineImage(
|
||||||
|
content_id=_EMAIL_LOGO_CONTENT_ID,
|
||||||
|
content_type=content_type,
|
||||||
|
data=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
|
||||||
|
inline_logo = _inline_uploaded_logo(settings)
|
||||||
|
if inline_logo:
|
||||||
|
return f"cid:{inline_logo.content_id}", (inline_logo,)
|
||||||
|
|
||||||
|
public_url = _public_logo_url(settings)
|
||||||
|
if public_url:
|
||||||
|
return public_url, ()
|
||||||
|
|
||||||
|
return None, ()
|
||||||
|
|
||||||
|
|
||||||
def _brand_title(settings: Settings) -> str:
|
def _brand_title(settings: Settings) -> str:
|
||||||
title = (settings.WEBAPP_TITLE or "").strip()
|
title = (settings.WEBAPP_TITLE or "").strip()
|
||||||
return title or "Subscription"
|
return title or "Subscription"
|
||||||
@@ -96,10 +174,10 @@ def _layout(
|
|||||||
intro_html: str,
|
intro_html: str,
|
||||||
body_html: str,
|
body_html: str,
|
||||||
footer_html: str,
|
footer_html: str,
|
||||||
) -> str:
|
) -> _EmailLayout:
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand_title = html.escape(_brand_title(settings))
|
brand_title = html.escape(_brand_title(settings))
|
||||||
logo_url = _public_logo_url(settings)
|
logo_url, inline_images = _email_logo(settings)
|
||||||
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||||
logo_block = ""
|
logo_block = ""
|
||||||
if logo_url:
|
if logo_url:
|
||||||
@@ -109,7 +187,7 @@ def _layout(
|
|||||||
f'border-radius:16px;">'
|
f'border-radius:16px;">'
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
layout_html = f"""<!DOCTYPE html>
|
||||||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -149,6 +227,16 @@ def _layout(
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
|
return _EmailLayout(html=layout_html, inline_images=inline_images)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent:
|
||||||
|
return EmailContent(
|
||||||
|
subject=subject,
|
||||||
|
text=text,
|
||||||
|
html=layout.html,
|
||||||
|
inline_images=layout.inline_images,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||||
@@ -317,7 +405,7 @@ def render_login_code(
|
|||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_account_merged(
|
def render_account_merged(
|
||||||
@@ -370,7 +458,7 @@ def render_account_merged(
|
|||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text=text, html=rendered)
|
return _email_content(subject=subject, text=text, layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_payment_success(
|
def render_payment_success(
|
||||||
@@ -504,7 +592,7 @@ def render_payment_success(
|
|||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_user_notification(
|
def render_user_notification(
|
||||||
@@ -565,7 +653,7 @@ def render_user_notification(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_subscription_expiring(
|
def render_subscription_expiring(
|
||||||
@@ -629,7 +717,7 @@ def render_subscription_expiring(
|
|||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _subscription_lifecycle_title(
|
def _subscription_lifecycle_title(
|
||||||
@@ -731,7 +819,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _support_email(
|
def _support_email(
|
||||||
@@ -783,7 +871,7 @@ def _support_email(
|
|||||||
]
|
]
|
||||||
if safe_url:
|
if safe_url:
|
||||||
text_lines.extend(["", safe_url])
|
text_lines.extend(["", safe_url])
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_support_new_ticket_admin(
|
def render_support_new_ticket_admin(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
|
from bot.services.email_templates import EmailInlineImage
|
||||||
|
|
||||||
|
|
||||||
|
def _settings():
|
||||||
|
return SimpleNamespace(
|
||||||
|
SMTP_FROM_NAME="Mini Shop",
|
||||||
|
SMTP_FROM_EMAIL="noreply@example.com",
|
||||||
|
WEBAPP_TITLE="Mini Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_email_message_attaches_inline_images_to_html_part():
|
||||||
|
service = EmailAuthService(_settings())
|
||||||
|
|
||||||
|
message = service._build_email_message(
|
||||||
|
email="user@example.com",
|
||||||
|
subject="Login code",
|
||||||
|
body="Your code: 123456",
|
||||||
|
html_body='<img src="cid:webapp-logo" alt="">',
|
||||||
|
inline_images=(
|
||||||
|
EmailInlineImage(
|
||||||
|
content_id="webapp-logo",
|
||||||
|
content_type="image/png",
|
||||||
|
data=b"\x89PNG\r\n\x1a\nlogo",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
related_part = message.get_body(("related",))
|
||||||
|
assert related_part is not None
|
||||||
|
html_part = related_part.get_body(("html",))
|
||||||
|
assert html_part is not None
|
||||||
|
related_images = [part for part in related_part.iter_attachments()]
|
||||||
|
|
||||||
|
assert len(related_images) == 1
|
||||||
|
assert related_images[0].get_content_type() == "image/png"
|
||||||
|
assert related_images[0]["Content-ID"] == "<webapp-logo>"
|
||||||
|
assert related_images[0].get_content_disposition() == "inline"
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.services import email_templates as email_templates_module
|
||||||
from bot.services.email_templates import (
|
from bot.services.email_templates import (
|
||||||
EmailContent,
|
EmailContent,
|
||||||
render_account_merged,
|
render_account_merged,
|
||||||
@@ -255,6 +256,49 @@ def test_all_email_template_variants_render_without_raw_locale_keys():
|
|||||||
_assert_content_is_localized(content, language)
|
_assert_content_is_localized(content, language)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch):
|
||||||
|
uploads_dir = tmp_path / "uploads"
|
||||||
|
uploads_dir.mkdir()
|
||||||
|
filename = "logo-1111111111111111.png"
|
||||||
|
logo_body = b"\x89PNG\r\n\x1a\nlogo"
|
||||||
|
(uploads_dir / filename).write_bytes(logo_body)
|
||||||
|
monkeypatch.setattr(email_templates_module, "_WEBAPP_UPLOADED_LOGO_DIR", uploads_dir)
|
||||||
|
|
||||||
|
settings = _settings()
|
||||||
|
settings.WEBAPP_LOGO_URL = f"/webapp-uploaded-logo/{filename}"
|
||||||
|
|
||||||
|
content = render_login_code(
|
||||||
|
settings,
|
||||||
|
code="123456",
|
||||||
|
language_code="en",
|
||||||
|
purpose="login",
|
||||||
|
i18n=_i18n("en"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'src="cid:webapp-logo"' in content.html
|
||||||
|
assert len(content.inline_images) == 1
|
||||||
|
inline_logo = content.inline_images[0]
|
||||||
|
assert inline_logo.content_id == "webapp-logo"
|
||||||
|
assert inline_logo.content_type == "image/png"
|
||||||
|
assert inline_logo.data == logo_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_https_webapp_logo_remains_external():
|
||||||
|
settings = _settings()
|
||||||
|
settings.WEBAPP_LOGO_URL = "https://cdn.example.com/logo.png"
|
||||||
|
|
||||||
|
content = render_login_code(
|
||||||
|
settings,
|
||||||
|
code="123456",
|
||||||
|
language_code="en",
|
||||||
|
purpose="login",
|
||||||
|
i18n=_i18n("en"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'src="https://cdn.example.com/logo.png"' in content.html
|
||||||
|
assert content.inline_images == ()
|
||||||
|
|
||||||
|
|
||||||
def test_support_email_templates_use_russian_copy_for_russian_recipients():
|
def test_support_email_templates_use_russian_copy_for_russian_recipients():
|
||||||
subjects = [content.subject for content in _all_rendered_email_variants("ru")[-4:]]
|
subjects = [content.subject for content in _all_rendered_email_variants("ru")[-4:]]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user