diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index 47aa5a4..5b44152 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -10,12 +10,15 @@ copy goes through the shared `JsonI18n` instance so translations live in from __future__ import annotations import html +import io import re from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence, Tuple from urllib.parse import urlsplit +from PIL import Image, ImageOps, UnidentifiedImageError + if TYPE_CHECKING: from bot.middlewares.i18n import JsonI18n from config.settings import Settings @@ -42,6 +45,7 @@ _LOGO_CONTENT_TYPES = { ".svg": "image/svg+xml", ".webp": "image/webp", } +_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"} @dataclass(frozen=True) @@ -129,6 +133,8 @@ def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]: if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES: return None + content_type, body = _email_logo_payload(filename, content_type, body) + return EmailInlineImage( content_id=_EMAIL_LOGO_CONTENT_ID, content_type=content_type, @@ -136,6 +142,35 @@ def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]: ) +def _email_logo_payload(filename: str, content_type: str, body: bytes) -> Tuple[str, bytes]: + suffix = Path(filename).suffix.lower() + if suffix not in _EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS: + return content_type, body + + png_body = _static_raster_logo_to_png(body) + if png_body and len(png_body) <= _WEBAPP_LOGO_MAX_BYTES: + return "image/png", png_body + return content_type, body + + +def _static_raster_logo_to_png(body: bytes) -> Optional[bytes]: + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + if getattr(image, "is_animated", False): + return None + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError, EOFError): + return None + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + return None + + output = io.BytesIO() + source.save(output, format="PNG", optimize=True) + return output.getvalue() + + def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]: inline_logo = _inline_uploaded_logo(settings) if inline_logo: @@ -198,7 +233,7 @@ def _layout( logo_block = ( f'' + f'border-radius:16px;background:transparent;background-color:transparent;">' ) layout_html = f""" diff --git a/tests/test_email_localization.py b/tests/test_email_localization.py index 8ecf27e..5407c67 100644 --- a/tests/test_email_localization.py +++ b/tests/test_email_localization.py @@ -2,9 +2,12 @@ import json import re import subprocess import sys +from io import BytesIO from pathlib import Path from types import SimpleNamespace +from PIL import Image + from bot.middlewares.i18n import JsonI18n from bot.services import email_templates as email_templates_module from bot.services.email_templates import ( @@ -318,6 +321,39 @@ def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch): assert inline_logo.data == logo_body +def test_uploaded_webp_logo_is_embedded_as_transparent_png(tmp_path, monkeypatch): + uploads_dir = tmp_path / "uploads" + uploads_dir.mkdir() + filename = "logo-2222222222222222.webp" + source = Image.new("RGBA", (3, 3), (0, 0, 0, 0)) + source.putpixel((1, 1), (255, 0, 0, 255)) + raw = BytesIO() + source.save(raw, format="WEBP", lossless=True) + (uploads_dir / filename).write_bytes(raw.getvalue()) + 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_type == "image/png" + + with Image.open(BytesIO(inline_logo.data)) as converted: + assert converted.mode == "RGBA" + assert converted.getpixel((0, 0))[3] == 0 + assert converted.getpixel((1, 1))[3] == 255 + + def test_public_https_webapp_logo_remains_external(): settings = _settings() settings.WEBAPP_LOGO_URL = "https://cdn.example.com/logo.png"