fix: preserve transparent email logos
This commit is contained in:
@@ -10,12 +10,15 @@ copy goes through the shared `JsonI18n` instance so translations live in
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import io
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -42,6 +45,7 @@ _LOGO_CONTENT_TYPES = {
|
|||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
}
|
}
|
||||||
|
_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
content_type, body = _email_logo_payload(filename, content_type, body)
|
||||||
|
|
||||||
return EmailInlineImage(
|
return EmailInlineImage(
|
||||||
content_id=_EMAIL_LOGO_CONTENT_ID,
|
content_id=_EMAIL_LOGO_CONTENT_ID,
|
||||||
content_type=content_type,
|
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, ...]]:
|
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
|
||||||
inline_logo = _inline_uploaded_logo(settings)
|
inline_logo = _inline_uploaded_logo(settings)
|
||||||
if inline_logo:
|
if inline_logo:
|
||||||
@@ -198,7 +233,7 @@ def _layout(
|
|||||||
logo_block = (
|
logo_block = (
|
||||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||||
f'border-radius:16px;">'
|
f'border-radius:16px;background:transparent;background-color:transparent;">'
|
||||||
)
|
)
|
||||||
|
|
||||||
layout_html = f"""<!DOCTYPE html>
|
layout_html = f"""<!DOCTYPE html>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import json
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
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 import email_templates as email_templates_module
|
||||||
from bot.services.email_templates import (
|
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
|
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():
|
def test_public_https_webapp_logo_remains_external():
|
||||||
settings = _settings()
|
settings = _settings()
|
||||||
settings.WEBAPP_LOGO_URL = "https://cdn.example.com/logo.png"
|
settings.WEBAPP_LOGO_URL = "https://cdn.example.com/logo.png"
|
||||||
|
|||||||
Reference in New Issue
Block a user