Translation on Firefox Translations models; new logo

- Email translation moves from LibreTranslate to our own translator
  service (translator/): Mozilla's Firefox Translations models on the
  Bergamot engine, fastText language detection (176 languages). Polish,
  English, German, French, Spanish and Italian on first start (~350 MB
  instead of ~10 GB); Admin panel > Email translation installs and
  removes languages. Built and pushed as the `translator` image.
- Every message is translated paragraph by paragraph as plain text;
  names travel with their sentence and are kept only where the model
  changed them; a sentence start the model copied ("Enclosed") is tried
  again in lower case; list markers are not sent
- The Translate button shows only for installed languages
- New logo: favicon, app/maskable/iPhone icons, notification badge,
  email logo and calendar copy regenerated; BIMI logo rebuilt in SVG
  Tiny PS with its gradients; masks render in browsers
- Launch screen background is the app's dark grey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:04:04 +02:00
co-authored by Claude Opus 5.5
parent 8a1b9979e9
commit 972e10876b
38 changed files with 1627 additions and 223 deletions
+2
View File
@@ -21,3 +21,5 @@ RSPAMD_CONTROLLER_PASSWORD=devpass
# Spelling/grammar check in the compose window (LanguageTool). Empty = off.
LANGUAGETOOL_URL=http://127.0.0.1:8010
# Email translation (the translator service, translator/). Empty = off.
TRANSLATOR_URL=http://127.0.0.1:5005
+1 -1
View File
@@ -1,5 +1,5 @@
up: ## start services
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool libretranslate
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool translator
down: ## stop services (data kept)
docker compose -f docker/docker-compose.yml down
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
+61 -1
View File
@@ -15,10 +15,12 @@ import re
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import translation
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
@@ -332,3 +334,61 @@ async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) ->
except MailServerError as exc:
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
return {"status": "deleted"}
# --- email translation languages (translator service) ------------------------------
class InstallProgressOut(BaseModel):
done: int
total: int
class AdminTranslationOut(BaseModel):
enabled: bool # TRANSLATOR_URL is set
reachable: bool
installed: list[str] = []
installing: dict[str, InstallProgressOut] = {}
available: list[str] = [] # what Mozilla has models for (empty: offline)
errors: dict[str, str] = {} # why a language's last install failed
_LANG = Path(pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
@router.get("/translation", response_model=AdminTranslationOut)
async def translation_status(admin: CurrentAdmin) -> AdminTranslationOut:
if not translation.enabled():
return AdminTranslationOut(enabled=False, reachable=False)
try:
data = await translation.status()
except translation.TranslationError:
return AdminTranslationOut(enabled=True, reachable=False)
return AdminTranslationOut(enabled=True, reachable=True, **data)
@router.post(
"/translation/languages/{code}",
response_model=AdminTranslationOut,
dependencies=[Depends(require_csrf)],
)
async def install_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
"""Download a language's models (to and from English) in the background."""
try:
await translation.install(code)
except translation.TranslationError as exc:
raise_api_error(502, "translator_failed", str(exc))
return await translation_status(admin)
@router.delete(
"/translation/languages/{code}",
response_model=AdminTranslationOut,
dependencies=[Depends(require_csrf)],
)
async def remove_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
try:
await translation.remove(code)
except translation.TranslationError as exc:
raise_api_error(502, "translator_failed", str(exc))
return await translation_status(admin)
+1 -1
View File
@@ -25,6 +25,6 @@ class TranslationInfoOut(BaseModel):
async def translation_languages(user: CurrentUser) -> TranslationInfoOut:
found = await translation.languages()
return TranslationInfoOut(
available=bool(found),
available=len(found) > 1, # English alone translates nothing
languages=[LanguageOut(code=x.code, name=x.name) for x in found],
)
+3 -3
View File
@@ -71,9 +71,9 @@ class Settings(BaseSettings):
# Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# Email translation: a LibreTranslate server (app/translation.py), e.g.
# http://libretranslate:5000. Empty: translation is off.
libretranslate_url: str = Field(default="", alias="LIBRETRANSLATE_URL")
# Email translation: the translator service (translator/, app/translation.py),
# e.g. http://translator:5000. Empty: translation is off.
translator_url: str = Field(default="", alias="TRANSLATOR_URL")
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

+209 -89
View File
@@ -1,19 +1,22 @@
"""Email translation, on this server: LibreTranslate (Argos models) in its own
container, reached only by the API. A message's text never leaves the server.
"""Email translation, on this server: the translator service (translator/:
Mozilla's Firefox Translations models and fastText language identification)
in its own container, reached only by the API. A message's text never leaves
the server.
- A message's language is detected the first time it is shown (from its own
text: quoted replies and signatures left out) and kept on the message, so the
reading pane can offer "Translate" when it differs from the interface language.
- A translation is of the message HTML the user sees (already sanitized; the
result is sanitized again), cached per message, target language and whether
remote images were shown.
remote images were shown. It is made paragraph by paragraph, as plain text
(translation_prep.split_pieces), each paragraph from its own language.
- The languages on offer are those installed on the service (admin panel).
Off when ``LIBRETRANSLATE_URL`` is empty.
Off when ``TRANSLATOR_URL`` is empty.
"""
from __future__ import annotations
import asyncio
import html as html_lib
import logging
import re
@@ -30,10 +33,10 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
DETECT_CHARS = 1500 # of the message's own text: plenty to tell the language
MIN_CONFIDENCE = 50.0 # below this, LibreTranslate is guessing
MIN_CONFIDENCE = 50.0 # below this, the detector is guessing
MAX_HTML_CHARS = 150_000 # a translation request bigger than this is refused
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # long messages take a while on a CPU
_LANG_CACHE_S = 600
_LANG_CACHE_S = 60
class TranslationError(Exception):
@@ -47,65 +50,108 @@ class Language:
def enabled() -> bool:
return bool(get_settings().libretranslate_url.strip())
return bool(get_settings().translator_url.strip())
def _url(path: str) -> str:
return get_settings().libretranslate_url.rstrip("/") + path
return get_settings().translator_url.rstrip("/") + path
async def _post(path: str, payload: dict[str, object]) -> object:
async def _call(method: str, path: str, payload: dict[str, object] | None = None) -> object:
if not enabled():
raise TranslationError("translation is not set up on this server")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.post(_url(path), json=payload)
resp = await client.request(method, _url(path), json=payload)
except httpx.HTTPError as exc:
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
if resp.status_code != 200:
if resp.status_code not in (200, 202):
detail = resp.text[:200]
raise TranslationError(f"the translation service refused: {resp.status_code} {detail}")
return resp.json()
async def _post(path: str, payload: dict[str, object]) -> object:
return await _call("POST", path, payload)
_languages: tuple[float, list[Language]] = (0.0, [])
async def languages() -> list[Language]:
"""What the service can translate between (cached for a few minutes)."""
"""The installed languages, English among them (cached for a minute). The
browser names them in the interface language; ``name`` is the code."""
global _languages
if not enabled():
return []
stamp, cached = _languages
if cached and time.monotonic() - stamp < _LANG_CACHE_S:
return cached
if not enabled():
return []
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
resp = await client.get(_url("/languages"))
resp.raise_for_status()
rows = resp.json()
except (httpx.HTTPError, ValueError) as exc:
codes = resp.json()["installed"]
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
logger.info("translation languages unavailable: %s", exc)
return []
found = [Language(code=str(r["code"]), name=str(r["name"])) for r in rows if "code" in r]
found = [Language(code=str(c), name=str(c)) for c in codes]
_languages = (time.monotonic(), found)
return found
def forget_languages() -> None:
"""After an install or removal: ask the service again."""
global _languages
_languages = (0.0, [])
async def status() -> dict[str, object]:
"""The service's own account (admin panel): installed, installing (bytes
done of total), available from Mozilla, and why an install failed."""
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=3.0)) as client:
try:
resp = await client.get(_url("/languages"))
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
return data if isinstance(data, dict) else {}
async def install(code: str) -> None:
await _call("POST", f"/languages/{code}")
forget_languages()
async def remove(code: str) -> None:
await _call("DELETE", f"/languages/{code}")
forget_languages()
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]")
MIN_LETTERS = 25 # fewer, and the detector guesses ("Jacqueline Lian" → Esperanto)
MAX_DETECT = 60 # pieces of one message asked about
MIN_SHARE = 0.15 # of a message's letters, for its language to count
async def _detect_many(texts: list[str]) -> list[str]:
"""The language of each of ``texts`` ('' where unsure), in one request."""
if not texts:
return []
result = await _post("/detect", {"q": [t[:DETECT_CHARS] for t in texts]})
if not isinstance(result, list) or len(result) != len(texts):
return [""] * len(texts)
return [
str(r.get("language", ""))
if isinstance(r, dict) and float(r.get("confidence", 0)) >= MIN_CONFIDENCE
else ""
for r in result
]
async def _detect_one(text: str) -> str:
result = await _post("/detect", {"q": text[:DETECT_CHARS]})
if not isinstance(result, list) or not result:
return ""
best = result[0]
if not isinstance(best, dict) or float(best.get("confidence", 0)) < MIN_CONFIDENCE:
return ""
return str(best.get("language", ""))
return (await _detect_many([text]))[0]
def _weight(text: str) -> int:
@@ -126,13 +172,8 @@ async def languages_of(parts: list[str]) -> list[str]:
for i, part in enumerate(parts)
if _CJK.search(part) or sum(c.isalpha() for c in part) >= MIN_LETTERS
][:MAX_DETECT]
gate = asyncio.Semaphore(4)
async def one(i: int) -> None:
async with gate:
told[i] = await _detect_one(parts[i])
await asyncio.gather(*(one(i) for i in asked))
for i, language in zip(asked, await _detect_many([parts[i] for i in asked]), strict=True):
told[i] = language
out = list(told)
following = ""
for i in reversed(range(len(parts))):
@@ -178,47 +219,163 @@ def _base(code: str) -> str:
return code.lower().split("-")[0]
async def _translate_paragraphs(html: str, target: str) -> str:
"""``html`` in more than one language, in ``target``: each paragraph from
its own language; paragraphs already in ``target`` are left as they are."""
async def _texts(texts: list[str], source: str, target: str) -> list[str]:
"""``texts`` translated, in batches."""
out: list[str] = []
for start in range(0, len(texts), 100):
batch = texts[start : start + 100]
result = await _post("/translate", {"q": batch, "source": source, "target": target})
got = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(got, list) or len(got) != len(batch):
raise TranslationError("the translation service sent no translation")
out += [str(x) for x in got]
return out
def _kept(name: str, translated: str) -> bool:
"""Whether ``name`` came through a translation: each of its words still
there, give or take an ending ("Lukas" → "Lukasie": yes; "Milan" →
"Mediolan": no)."""
return all(
word[: max(3, len(word) - 2)] in translated for word in name.split() if len(word) >= 2
)
# A capitalised word that starts a sentence ("Enclosed is a summary…").
_SENTENCE_START = re.compile(r"(?:^|(?<=[.!?…]\s))(?P<word>[^\W\d_][^\W\d_'’-]{2,})\b")
def _copied_starts(core: str, text: str, names: list[str]) -> list[str]:
"""Words that start a sentence of ``core`` and came through ``text``
untranslated, and are no known name: the models take a capitalised
sentence start for a name ("Enclosed is…" → "Enclosed jest…")."""
found: list[str] = []
for m in _SENTENCE_START.finditer(core):
word = m.group("word")
if not word[0].isupper() or word.isupper() or any(word in n for n in names):
continue
if re.search(rf"(?<!\w){re.escape(word)}(?!\w)", text):
found.append(word)
return found
def _lowered(core: str, words: list[str]) -> str:
"""``core`` with ``words`` lower-cased where they start a sentence."""
return _SENTENCE_START.sub(
lambda m: m.group(0).lower() if m.group("word") in words else m.group(0), core
)
def _capitalised(text: str) -> str:
"""Sentence starts capitalised again (after ``_lowered``): the first
letter, and one after a sentence's end — a full stop after a word of four
letters or more, so not after "r.", "np.", "tzw." (abbreviations)."""
text = re.sub(r"^(\W*)([^\W\d_])", lambda m: m.group(1) + m.group(2).upper(), text)
return re.sub(
r"((?:[^\W\d_]{4,}[.…]|[!?])\s+)([^\W\d_])",
lambda m: m.group(1) + m.group(2).upper(),
text,
)
def _as_before(text: str, first: str) -> str:
"""A word after a comma capitalised in ``text`` but not in ``first`` (the
first try) is lower-cased: lower-cased input makes the models capitalise
elsewhere ("…2026, od 13 lipca" → "…2026 r., Od 13 lipca")."""
def repl(m: re.Match[str]) -> str:
word = m.group(2)
lower = word[0].lower() + word[1:]
return m.group(1) + (lower if f", {lower}" in first else word)
return re.sub(r"(,\s+)([^\W\d_][\w'’-]*)", repl, text)
async def _with_names_kept(core: str, names: list[str], source: str, target: str) -> str:
"""``core`` translated around ``names``, which are left as they are."""
pattern = re.compile("(" + "|".join(re.escape(n) for n in names) + ")")
parts = pattern.split(core)
todo = [
(i, m.groups())
for i, part in enumerate(parts)
if i % 2 == 0 and (m := _EDGE_SPACE.match(part)) and any(c.isalnum() for c in part)
]
done = await _texts([groups[1] for _, groups in todo], source, target)
for (i, (lead, _, trail)), text in zip(todo, done, strict=True):
parts[i] = f"{lead}{text}{trail}"
return "".join(parts)
async def _translate_paragraphs(
html: str, target: str, sources: list[str], names: Iterable[str] = ()
) -> str:
"""``html`` in ``target``, as plain text paragraph by paragraph: from the
one language in ``sources``, or (several, or none known) each paragraph
from its own. Paragraphs already in ``target``, or in a language that is
not installed, are left as they are.
``names`` go along with their sentence, which then translates as a whole
(kept apart, "I came across <Imaginary Pixels>" lost its verb: "Przyszedłem
Imaginary Pixels"). Only a piece where one came out changed ("Milan" →
"Mediolan") is translated again with it left as it is."""
watch = sorted({n.strip() for n in names if len(n.strip()) >= 2}, key=len, reverse=True)
pieces, paragraphs = prep.split_pieces(html)
found = await languages_of([p.text for p in paragraphs])
if len(sources) == 1:
found = [sources[0]] * len(paragraphs)
else:
found = await languages_of([p.text for p in paragraphs])
installed = {x.code for x in await languages()}
# Chinese and Japanese go without spaces: two pieces of one sentence
# ("…奎琳,</span>我从…") need one between them once translated.
followed = {i for p in paragraphs for i in p.pieces[:-1]}
by_language: dict[str, list[int]] = {}
for paragraph, language in zip(paragraphs, found, strict=True):
if language and _base(language) != _base(target):
if language in installed and _base(language) != _base(target):
by_language.setdefault(language, []).extend(paragraph.pieces)
for language, where in by_language.items():
todo: list[tuple[int, str, str, str]] = []
for i in where:
m = _EDGE_SPACE.match(html_lib.unescape(pieces[i]))
lead, core, trail = m.groups() if m else ("", "", "")
# A list marker stays out: the models repeat it ("1. Thursday" →
# "1. 1. Czwartek").
marker = _LIST_MARKER.match(core)
if marker:
lead, core = lead + marker.group(0), core[marker.end() :]
if not trail and i in followed and _base(language) in _NO_SPACES:
trail = " "
if any(c.isalnum() for c in core):
todo.append((i, lead, core, trail))
for start in range(0, len(todo), 100):
batch = todo[start : start + 100]
result = await _post(
"/translate",
{"q": [core for _, _, core, _ in batch], "source": language, "target": target},
done = await _texts([core for _, _, core, _ in todo], language, target)
# Sentence starts that came through untranslated: once more, lower-case.
again = [
(k, words)
for k, ((_, _, core, _), text) in enumerate(zip(todo, done, strict=True))
if (words := _copied_starts(core, text, watch))
]
if again:
retried = await _texts(
[_lowered(todo[k][2], words) for k, words in again], language, target
)
out = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(out, list) or len(out) != len(batch):
raise TranslationError("the translation service sent no translation")
for (i, lead, _, trail), text in zip(batch, out, strict=True):
pieces[i] = html_lib.escape(f"{lead}{text}{trail}", quote=False)
for (k, words), text in zip(again, retried, strict=True):
# Still there: a name after all, and the first try stands.
if len(_copied_starts(todo[k][2], text, watch)) < len(words):
done[k] = _as_before(_capitalised(text), done[k])
for (i, lead, core, trail), text in zip(todo, done, strict=True):
lost = [n for n in watch if n in core and not _kept(n, text)]
if lost:
text = await _with_names_kept(core, lost, language, target)
pieces[i] = html_lib.escape(f"{lead}{text}{trail}", quote=False)
return "".join(pieces)
_NO_SPACES = {"zh", "ja", "th"}
_LIST_MARKER = re.compile(r"(?:\d{1,3}[.)]|[a-zA-Z][.)]|[-•*–])\s+(?=\S)")
_EDGE_SPACE = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
# Web and mail addresses in the text: the models break them up ("https: / /
# vimeo.com"), so they are marked translate="no", which LibreTranslate keeps.
# vimeo.com"), so they are marked translate="no" and not sent.
_ADDRESS = re.compile(r"(?:https?://|www\.)[^\s<>\"']+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+", re.IGNORECASE)
_TAG = re.compile(r"(<[^>]*>)")
@@ -243,36 +400,9 @@ def protect_addresses(html: str) -> str:
return "".join(out)
_PUNCT = re.compile(r"[\W_]+")
MAX_NAMES = 200 # names checked for one message
async def names_to_keep(names: Iterable[str], source: str, target: str) -> set[str]:
"""Of ``names``, those the model changes when it translates them alone
("Milan" → "Mediolan", "Imaginary Pixels" → "Imaginanal Pixels"); one
request for them all. If the check fails, all of them."""
wanted = sorted({n.strip() for n in names if len(n.strip()) >= 2})[:MAX_NAMES]
if not wanted:
return set()
try:
result = await _post(
"/translate", {"q": wanted, "source": source or "auto", "target": target}
)
except TranslationError:
return set(wanted)
out = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(out, list) or len(out) != len(wanted):
return set(wanted)
def same(a: str, b: str) -> bool:
return _PUNCT.sub("", a).casefold() == _PUNCT.sub("", b).casefold()
return {name for name, got in zip(wanted, out, strict=True) if not same(name, str(got))}
# Bumped when what goes to the service, or what is done with the result,
# changes: translations kept from before are made again.
PIPELINE_VERSION = 5
PIPELINE_VERSION = 8
async def translate_html(
@@ -288,16 +418,6 @@ async def translate_html(
prepared = prep.join_wrapped_lines(html)
for language in sources:
prepared = prep.translate_phrases(prepared, language, target)
single = sources[0] if len(sources) == 1 else "auto"
candidates = {*names, *prep.find_names(prepared, single if sources else "")}
terms = {*keep, *await names_to_keep(candidates, single, target)}
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, terms)))
if len(sources) > 1:
return prep.tidy(await _translate_paragraphs(prepared, target))
result = await _post(
"/translate",
{"q": prepared, "source": source or "auto", "target": target, "format": "html"},
)
if not isinstance(result, dict) or "translatedText" not in result:
raise TranslationError("the translation service sent no translation")
return prep.tidy(str(result["translatedText"]))
candidates = {*names, *prep.find_names(prepared, sources[0] if sources else "")}
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, keep)))
return prep.tidy(await _translate_paragraphs(prepared, target, sources, candidates))
+15 -15
View File
@@ -1,25 +1,25 @@
"""Getting a message ready for translation, and tidying what comes back.
LibreTranslate translates each piece of text between tags on its own, and its
small models stumble on things a reader would not: a sentence wrapped onto two
lines becomes two sentences ("ask you about" / "Plan for next month"),
"Best," becomes "Najlepsze," and a company name gets translated word by word.
So before a message goes out:
A message is translated as plain text, piece by piece (the text between
tags), and the models stumble on things a reader would not: a sentence
wrapped onto two lines becomes two sentences ("ask you about" / "Plan for next
month"), "Best," becomes "Najlepsze," and a company name gets translated word
by word. So before a message goes out:
- lines that carry on a sentence (no end punctuation, next starts lowercase)
are joined, in HTML (<br>, one <div> per line) and in wrapped plain text;
- greetings and sign-offs that make a whole line are put in the target
language from a small phrase table (English, Polish, German);
- names are marked translate="no", which LibreTranslate keeps — the people
on the message, the user's own names and signature names, and capitalised
multi-word names mid-sentence — but only the names the model would change
("Milan" → "Mediolan"): a kept name splits its sentence, and LibreTranslate
translates each piece alone ("Dear <kept>Lukas Remis</kept>," →
"Kochanie."), so a name the model leaves alone stays in the sentence;
- names are marked translate="no" and not sent — the people on the message,
the user's own names and signature names, and capitalised multi-word names
mid-sentence — but only the names the model would change ("Milan" →
"Mediolan"): a kept name splits its sentence into pieces translated alone
("Dear <kept>Lukas Remis</kept>," → "Kochanie."), so a name the model
leaves alone stays in the sentence;
- so are emoji, file names ("A_0023C017_p1CAC.mov") and text without a letter;
- a message in more than one language is translated paragraph by paragraph,
each from its own language (split_pieces); those in the target stay;
- and afterwards, the space LibreTranslate drops after a kept word comes back.
- the message is translated paragraph by paragraph (split_pieces), each from
its own language when there are several; those in the target stay;
- and afterwards, a kept word gets back the spaces around it.
"""
from __future__ import annotations
@@ -315,7 +315,7 @@ def split_pieces(html: str) -> tuple[list[str], list[Paragraph]]:
# --- after translation -------------------------------------------------------------
# LibreTranslate drops the space after a kept span ("Bruton</span>i twoje").
# A kept span can lose the space beside it ("Bruton</span>i twoje").
# Only kept spans: they still carry translate="no" here (the sanitizer that
# runs next takes it off); other spans are the sender's and stay as they are.
_KEPT = re.compile(r'(<span translate="no">[^<]*</span>)')
@@ -0,0 +1,81 @@
"""Email translation's languages in the admin panel (app/api/admin.py), with
the translator service replaced by a stand-in."""
from __future__ import annotations
import pytest
from app import translation
from tests.api.test_admin_users import World, world # noqa: F401 — the fixture
@pytest.fixture
def service(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]:
state: dict[str, list[str]] = {"installed": ["en", "pl"], "calls": []}
async def status() -> dict[str, object]:
return {
"installed": list(state["installed"]),
"installing": {"de": {"done": 10, "total": 40}} if "de" in state["calls"] else {},
"available": ["en", "pl", "de", "hu"],
"errors": {},
}
async def install(code: str) -> None:
state["calls"].append(code)
async def remove(code: str) -> None:
state["installed"].remove(code)
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "status", status)
monkeypatch.setattr(translation, "install", install)
monkeypatch.setattr(translation, "remove", remove)
return state
async def test_admin_installs_and_removes_languages(
world: World, # noqa: F811
service: dict[str, list[str]],
) -> None:
async with await world.client(await world.user(admin=True)) as c:
got = (await c.get("/api/admin/translation")).json()
assert got["reachable"] and got["installed"] == ["en", "pl"]
assert got["available"] == ["en", "pl", "de", "hu"]
got = (await c.post("/api/admin/translation/languages/de")).json()
assert service["calls"] == ["de"]
assert got["installing"] == {"de": {"done": 10, "total": 40}}
got = (await c.delete("/api/admin/translation/languages/pl")).json()
assert got["installed"] == ["en"]
assert (await c.post("/api/admin/translation/languages/x;rm")).status_code == 422
async def test_only_admins(world: World, service: dict[str, list[str]]) -> None: # noqa: F811
async with await world.client(await world.user()) as c:
assert (await c.get("/api/admin/translation")).status_code == 403
assert (await c.post("/api/admin/translation/languages/de")).status_code == 403
assert service["calls"] == []
async def test_unreachable_service_is_reported(
world: World, # noqa: F811
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def down() -> dict[str, object]:
raise translation.TranslationError("down")
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "status", down)
async with await world.client(await world.user(admin=True)) as c:
got = (await c.get("/api/admin/translation")).json()
assert got == {
"enabled": True,
"reachable": False,
"installed": [],
"installing": {},
"available": [],
"errors": {},
}
+113 -26
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from app import translation
from app.translation import detect, languages_of, names_to_keep, protect_addresses, translate_html
from app.translation import detect, languages_of, protect_addresses, translate_html
def test_addresses_in_text_are_kept_from_translation() -> None:
@@ -25,45 +25,55 @@ def test_style_blocks_are_left_alone() -> None:
assert protect_addresses(html) == html
async def test_only_names_the_model_changes_are_kept(monkeypatch: pytest.MonkeyPatch) -> None:
sent: list[object] = []
async def test_names_go_with_their_sentence_unless_the_model_changes_them(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sent: list[list[str]] = []
model = {
"I came across Imaginary Pixels today.": "Natknąłem się dziś na Imaginary Pixels.",
"Dear Lukas,": "Drogi Lukasie,", # an ending: still the name
"Regards, Milan": "Pozdrawiam, Mediolan", # changed: done again around it
"Regards,": "Pozdrawiam,",
}
async def post(path: str, payload: dict[str, object]) -> object:
sent.append(payload["q"])
alone = {
"Lukas Remis": "Lukas Remis.",
"Milan": "Mediolan",
"Imaginary Pixels": "Imaginanal",
}
return {"translatedText": [alone[q] for q in payload["q"]]} # type: ignore[attr-defined]
q = payload["q"]
assert isinstance(q, list)
sent.append(list(q))
return {"translatedText": [model.get(x, x) for x in q]}
async def installed() -> list[translation.Language]:
return [translation.Language(c, c) for c in ("en", "pl")]
monkeypatch.setattr(translation, "_post", post)
kept = await names_to_keep(
["Milan", "Lukas Remis", "Imaginary Pixels", "Milan", "x"], "en", "pl"
monkeypatch.setattr(translation, "languages", installed)
html = "<p>I came across Imaginary Pixels today.</p><p>Dear Lukas,</p><p>Regards, Milan</p>"
out = await translate_html(html, "en", "pl", names=["Imaginary Pixels", "Lukas", "Milan"])
assert out == (
"<p>Natknąłem się dziś na Imaginary Pixels.</p><p>Drogi Lukasie,</p>"
"<p>Pozdrawiam, Milan</p>"
)
assert kept == {"Milan", "Imaginary Pixels"} # "Lukas Remis" stays in its sentence
assert len(sent) == 1 # one request for all
async def test_all_names_kept_when_the_check_fails(monkeypatch: pytest.MonkeyPatch) -> None:
async def post(path: str, payload: dict[str, object]) -> object:
raise translation.TranslationError("down")
monkeypatch.setattr(translation, "_post", post)
assert await names_to_keep(["Milan", "Lukas Remis"], "en", "pl") == {"Milan", "Lukas Remis"}
assert sent[1:] == [["Regards,"]] # only the piece that lost a name went again
def _fake_detector(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""A detector that knows Chinese signs, Polish letters, else English."""
asked: list[str] = []
async def detect_one(text: str) -> str:
def one(text: str) -> str:
asked.append(text)
if any("\u4e00" <= c <= "\u9fff" for c in text):
return "zh-Hans"
return "pl" if any(c in "ąćęłńóśźż" for c in text.lower()) else "en"
monkeypatch.setattr(translation, "_detect_one", detect_one)
async def detect_many(texts: list[str]) -> list[str]:
return [one(t) for t in texts]
async def installed() -> list[translation.Language]:
return [translation.Language(c, c) for c in ("en", "pl", "zh-Hans")]
monkeypatch.setattr(translation, "_detect_many", detect_many)
monkeypatch.setattr(translation, "languages", installed)
return asked
@@ -102,7 +112,7 @@ async def test_each_paragraph_from_its_own_language(monkeypatch: pytest.MonkeyPa
q = payload["q"]
if path == "/translate" and isinstance(q, list) and payload.get("source") != "auto":
calls.append((str(payload["source"]), list(q)))
return {"translatedText": [f"PL({x})" for x in q]}
return {"translatedText": [f"PL({x.upper()})" for x in q]}
return {"translatedText": list(q) if isinstance(q, list) else q} # names: unchanged
monkeypatch.setattr(translation, "_post", post)
@@ -117,7 +127,84 @@ async def test_each_paragraph_from_its_own_language(monkeypatch: pytest.MonkeyPa
assert "<div>Widzę, że złożyłeś wniosek o otwarcie sklepu &amp; więcej.</div>" in out
# Two pieces of one Chinese sentence get a space between them.
assert "PL(我是速卖通的官方客户经理奎琳,) </span>PL(" in out
assert "PL(I noticed that you applied to join the platform & more.)" in out.replace(
assert "PL(I NOTICED THAT YOU APPLIED TO JOIN THE PLATFORM & MORE.)" in out.replace(
"&amp;", "&"
)
assert '<span translate="no">+48 780 549 877</span>' in out
async def test_one_language_needs_no_detector_and_skips_what_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
asked = _fake_detector(monkeypatch)
calls: list[tuple[str, list[str]]] = []
async def post(path: str, payload: dict[str, object]) -> object:
q = payload["q"]
if path == "/translate" and isinstance(q, list) and payload.get("source") == "en":
calls.append(("en", list(q)))
return {"translatedText": [f"PL({x.upper()})" for x in q]}
return {"translatedText": list(q) if isinstance(q, list) else q}
monkeypatch.setattr(translation, "_post", post)
html = "<p>Hello there, how are you?</p><p>Short one.</p><p><b>1. Thursday 2nd July</b></p>"
out = await translate_html(html, "en", "pl")
assert out == (
"<p>PL(HELLO THERE, HOW ARE YOU?)</p><p>PL(SHORT ONE.)</p>"
"<p><b>1. PL(THURSDAY 2ND JULY)</b></p>" # the list marker is not sent
)
assert asked == [] # the message's one language is known: no detection
# Hungarian is not installed: left as it is, nothing sent.
calls.clear()
assert await translate_html("<p>Jó napot kívánok</p>", "hu", "pl") == "<p>Jó napot kívánok</p>"
assert calls == []
async def test_a_sentence_start_the_model_copied_is_tried_lower_case(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = {
"Enclosed is a summary. Natalia wrote it.": (
"Enclosed jest podsumowaniem. Natalia to napisała."
),
"enclosed is a summary. natalia wrote it.": (
"załączone jest podsumowanie. Natalia to napisała."
),
"Natalia wrote it.": "Natalia to napisała.",
"natalia wrote it.": "Natalia to napisała.",
}
sent: list[str] = []
async def post(path: str, payload: dict[str, object]) -> object:
q = payload["q"]
assert isinstance(q, list)
sent.extend(q)
return {"translatedText": [model[x] for x in q]}
async def installed() -> list[translation.Language]:
return [translation.Language(c, c) for c in ("en", "pl")]
monkeypatch.setattr(translation, "_post", post)
monkeypatch.setattr(translation, "languages", installed)
out = await translate_html("<p>Enclosed is a summary. Natalia wrote it.</p>", "en", "pl")
assert out == "<p>Załączone jest podsumowanie. Natalia to napisała.</p>"
# A name comes back the same lower-case too: the first try stands.
sent.clear()
assert await translate_html("<p>Natalia wrote it.</p>", "en", "pl") == (
"<p>Natalia to napisała.</p>"
)
assert sent == ["Natalia wrote it.", "natalia wrote it."]
def test_capitalised_again_but_not_after_abbreviations() -> None:
assert translation._capitalised("załączony na lipiec 2026 r., od 13 lipca. dalej np. tak") == (
"Załączony na lipiec 2026 r., od 13 lipca. Dalej np. tak"
)
def test_a_comma_word_keeps_the_first_try_case() -> None:
first = "Enclosed jest podsumowaniem na lipiec 2026, od 13 lipca."
again = "Załączony jest podsumowanie na lipiec 2026 r., Od 13 lipca, Anna wie."
assert translation._as_before(again, first) == (
"Załączony jest podsumowanie na lipiec 2026 r., od 13 lipca, Anna wie."
)
+8 -4
View File
@@ -43,15 +43,17 @@ build() {
"$ROOT_DIR"
}
build backend Dockerfile.backend
build web Dockerfile.frontend
build backend Dockerfile.backend
build web Dockerfile.frontend
build translator Dockerfile.translator
if [ "$MODE" = "--save" ]; then
out="${ROOT_DIR}/webmail-${TAG}.tar"
echo "== saving both images to ${out}"
echo "== saving the images to ${out}"
docker save -o "$out" \
"${REGISTRY_PREFIX}/backend:${TAG}" \
"${REGISTRY_PREFIX}/web:${TAG}"
"${REGISTRY_PREFIX}/web:${TAG}" \
"${REGISTRY_PREFIX}/translator:${TAG}"
echo "done. Copy it to the NAS and run: docker load -i $(basename "$out")"
exit 0
fi
@@ -72,6 +74,8 @@ fi
echo "== pushing"
docker push "${REGISTRY_PREFIX}/backend:${TAG}"
docker push "${REGISTRY_PREFIX}/web:${TAG}"
docker push "${REGISTRY_PREFIX}/translator:${TAG}"
docker push "${REGISTRY_PREFIX}/backend:latest"
docker push "${REGISTRY_PREFIX}/web:latest"
docker push "${REGISTRY_PREFIX}/translator:latest"
echo "pushed ${TAG}. On the NAS: ./deploy/webmail.sh update ${TAG}"
+23 -1
View File
@@ -436,6 +436,24 @@ print('OFF' if not url else urllib.request.urlopen(url + '/v2/languages', timeou
printf ' %s\n' "$lt" ;;
esac
# Email translation: is the translator answering, with which languages?
tr="$(compose exec -T api python -c "
import json, os, urllib.request
url = os.environ.get('TRANSLATOR_URL', '')
if not url:
print('OFF')
else:
d = json.load(urllib.request.urlopen(url + '/languages', timeout=5))
busy = ', '.join(d.get('installing') or {})
print('OK ' + ' '.join(d['installed']) + (' (installing: ' + busy + ')' if busy else ''))
" 2>&1 | tail -1)" || true
case "$tr" in
OK*) grn " translation answering; languages: ${tr#OK }" ;;
OFF) ylw " translation is off (TRANSLATOR_URL is empty)" ;;
*) ylw " translation not answering — the Translate button stays hidden"
printf ' %s\n' "$tr" ;;
esac
if command -v ss >/dev/null 2>&1; then
local p
for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do
@@ -503,7 +521,7 @@ cmd_pull() {
Set REGISTRY_PREFIX and IMAGE_TAG in ${ENV_FILE}, or use: webmail.sh up --build"
detect_compose
IMAGE_TAG="$tag" compose pull || die "pull failed — is the registry reachable and logged in?"
grn "pulled ${REGISTRY_PREFIX}/{backend,web}:${tag}. Next: ./deploy/webmail.sh up"
grn "pulled ${REGISTRY_PREFIX}/{backend,web,translator}:${tag}. Next: ./deploy/webmail.sh up"
}
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
@@ -587,6 +605,10 @@ cmd_update() {
# then the API, then the web front end.
IMAGE_TAG="$target" compose up -d worker-sync worker-jobs
IMAGE_TAG="$target" compose up -d api web
# The translator is ours too, so it follows the tag. Named here (not left
# to --no-recreate below) so it is replaced on every update, and so init,
# which it depends on, runs again when a release adds a data directory.
IMAGE_TAG="$target" compose up -d translator
# Converge anything the ordered roll does not name — a release that adds a
# service (the antispam engine did) would otherwise never start it, and the
# stack would look updated while missing a container. --no-recreate so this
+17 -21
View File
@@ -72,7 +72,7 @@ x-backend-env: &backend-env
# Spelling and grammar check in the compose window. Empty turns it off.
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL-http://languagetool:8010}
# Email translation; empty turns it off.
LIBRETRANSLATE_URL: ${LIBRETRANSLATE_URL-http://libretranslate:5000}
TRANSLATOR_URL: ${TRANSLATOR_URL-http://translator:5000}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
UNDO_SECONDS: ${UNDO_SECONDS:-10}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
@@ -111,13 +111,13 @@ services:
- |
set -e
mkdir -p /mnt/postgres /mnt/blobs /mnt/backups /mnt/redis /mnt/meili /mnt/state \
/mnt/languagetool-ngrams /mnt/libretranslate
/mnt/languagetool-ngrams /mnt/translator
# Only recurse when ownership is actually wrong: `chown -R` over a large
# blob store on every start would be minutes of pointless disk churn.
[ "$$(stat -c %u /mnt/blobs)" = "10001" ] || chown -R 10001:10001 /mnt/blobs
[ "$$(stat -c %u /mnt/backups)" = "999" ] || chown -R 999:999 /mnt/backups
[ "$$(stat -c %u /mnt/redis)" = "999" ] || chown -R 999:999 /mnt/redis
[ "$$(stat -c %u /mnt/libretranslate)" = "1032" ] || chown -R 1032:65534 /mnt/libretranslate
[ "$$(stat -c %u /mnt/translator)" = "10001" ] || chown -R 10001:10001 /mnt/translator
chmod 700 /mnt/backups
echo "[init] data directories ready"
volumes:
@@ -316,30 +316,26 @@ services:
cpus: ${LANGUAGETOOL_CPUS:-1.0}
logging: *logging
# Email translation (app/translation.py), all on this server. The first
# start downloads every language model (a few GB) into DATA_ROOT; later
# starts reuse them. A model is loaded into memory the first time its
# language is used.
libretranslate:
image: libretranslate/libretranslate:v1.9.6
# Email translation (translator/, app/translation.py), all on this server:
# Mozilla's Firefox Translations models and fastText language detection.
# The first start downloads the default languages (about 350 MB) into
# DATA_ROOT; more are installed from the admin panel.
translator:
image: ${REGISTRY_PREFIX:-webmail}/translator:${IMAGE_TAG:-local}
build:
context: .
dockerfile: docker/Dockerfile.translator
environment:
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
LT_THREADS: ${LIBRETRANSLATE_THREADS:-2}
TRANSLATOR_LANGUAGES: ${TRANSLATOR_LANGUAGES:-pl,de,fr,es,it}
TRANSLATOR_THREADS: ${TRANSLATOR_THREADS:-2}
TZ: ${TZ:-UTC}
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/libretranslate:/home/libretranslate/.local
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
interval: 30s
timeout: 6s
retries: 5
start_period: 30m # the first start downloads the models
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/translator:/data
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${LIBRETRANSLATE_MEM_LIMIT:-6g}
cpus: ${LIBRETRANSLATE_CPUS:-2.0}
mem_limit: ${TRANSLATOR_MEM_LIMIT:-2g}
cpus: ${TRANSLATOR_CPUS:-2.0}
logging: *logging
# Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose
+38
View File
@@ -0,0 +1,38 @@
# Translation service (translator/translator.py): Mozilla's Firefox
# Translations models on the Bergamot engine, and fastText language
# identification. Python 3.10: the newest the bergamot package is built for.
#
# docker build -f docker/Dockerfile.translator -t webmail/translator .
#
# The models are not in the image: they are downloaded into /data (a bind
# mount) on the first start and from the admin panel.
FROM python:3.10-slim
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
HOME=/tmp
COPY translator/requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
# fastText's language identifier (lid.176, compressed; CC BY-SA 3.0).
ADD --checksum=sha256:8f3472cfe8738a7b6099e8e999c3cbfae0dcd15696aac7d7738a8039db603e83 \
https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz /opt/lid.176.ftz
COPY translator/translator.py /app/translator.py
# The same uid as the backend: DATA_ROOT/translator is owned by it (init).
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin translator \
&& mkdir -p /data && chown translator /data /opt/lid.176.ftz
USER translator
WORKDIR /app
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=4)"
CMD ["uvicorn", "translator:app", "--host", "0.0.0.0", "--port", "5000", "--no-access-log"]
+10 -14
View File
@@ -158,22 +158,18 @@ services:
retries: 12
restart: unless-stopped
# Email translation (app/translation.py). The first start downloads every
# language model (several GB) into the volume; later starts reuse them.
libretranslate:
image: libretranslate/libretranslate:v1.9.6
environment:
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
# Email translation (translator/, app/translation.py): Firefox Translations
# models and fastText. The first start downloads the default languages
# (about 350 MB) into the volume; the admin panel adds more.
translator:
build:
context: ..
dockerfile: docker/Dockerfile.translator
image: webmail/translator:dev
ports:
- "127.0.0.1:5005:5000"
volumes:
- ltmodels:/home/libretranslate/.local
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/languages', timeout=4)\""]
interval: 15s
timeout: 6s
retries: 240
- translatordata:/data
restart: unless-stopped
greenmail:
@@ -188,7 +184,7 @@ services:
- "127.0.0.1:3993:3993" # IMAPS
volumes:
ltmodels:
translatordata:
pgdata:
meilidata:
blobs:
+17
View File
@@ -701,6 +701,23 @@ priority, so even the first checks after a restart are served from RAM. On
mechanical disks that warm-up takes several minutes; an NVMe cache in front of
the volume makes it — and any later cache misses — fast.
### Email translation (translator)
`translator` translates email in the reading pane, entirely on this server:
Mozilla's Firefox Translations models (the ones Firefox uses for page
translation) and fastText for telling which language a message is in.
- The first start downloads the languages in `TRANSLATOR_LANGUAGES` (default
`pl,de,fr,es,it`; English is always there) into `$DATA_ROOT/translator`,
about 35 MB per language and direction (~350 MB for the default set).
- More languages are installed, and unused ones removed, from the **admin
panel → Translation**. That list is kept in `$DATA_ROOT/translator`, so a
restart does not undo it.
- Models translate to and from English; other pairs go through English.
- Capped at 2 GB of RAM and 2 CPUs (`TRANSLATOR_MEM_LIMIT`, `TRANSLATOR_CPUS`).
- `./deploy/webmail.sh status` shows whether it answers and which languages
are installed. `TRANSLATOR_URL=` (empty) in `.env` turns translation off.
Two NAS-specific realities worth knowing:
- **Disk hibernation won't happen.** Active accounts hold IMAP IDLE connections
+117 -7
View File
@@ -8,9 +8,31 @@
version="1.1"
id="svg1"
xml:space="preserve"
sodipodi:docname="ultimo-logo-signet.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="2.5488772"
inkscape:cx="89.451151"
inkscape:cy="-10.20057"
inkscape:window-width="2560"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><defs
id="defs1"><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
@@ -26,9 +48,97 @@
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1788"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1788"
sodipodi:nodetypes="cccsccc" /></mask><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1769"
id="linearGradient1789"
gradientUnits="userSpaceOnUse"
x1="37.71608"
y1="123.22854"
x2="27.709011"
y2="135.24788" /><linearGradient
id="linearGradient1769"
inkscape:collect="always"><stop
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
offset="0.00003701"
id="stop1769" /><stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop1770" /></linearGradient><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1776-6"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1776-1"
sodipodi:nodetypes="cccsccc" /></mask><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient11"
id="linearGradient13-6"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient8"
id="radialGradient816"
cx="8.0343275"
cy="13.452971"
fx="8.0343275"
fy="13.452971"
r="16.678839"
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient8"
inkscape:collect="always"><stop
style="stop-color:#fa7367;stop-opacity:1;"
offset="0"
id="stop8" /><stop
style="stop-color:#fa7367;stop-opacity:0;"
offset="1"
id="stop9" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient6"
id="radialGradient815"
cx="35.709007"
cy="12.042645"
fx="35.709007"
fy="12.042645"
r="16.740065"
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient6"
inkscape:collect="always"><stop
style="stop-color:#fab23b;stop-opacity:1;"
offset="0"
id="stop6" /><stop
style="stop-color:#fab23a;stop-opacity:0;"
offset="1"
id="stop7" /></linearGradient></defs><g
id="g816"
transform="translate(0.128676,-0.079965)"><path
id="path1-2-1-1"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z"
sodipodi:nodetypes="cccccccccccssssccccccccssssssccccccccccccscc" /><path
id="path1-2-1-9"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
id="path1-2-1-7"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

+118 -11
View File
@@ -8,10 +8,48 @@
version="1.1"
id="svg1"
xml:space="preserve"
sodipodi:docname="ultimo-logo.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.9327415"
inkscape:cx="187.29872"
inkscape:cy="135.81744"
inkscape:window-width="2560"
inkscape:window-height="1376"
inkscape:window-x="2560"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><defs
id="defs1"><linearGradient
id="linearGradient8"
inkscape:collect="always"><stop
style="stop-color:#fa7367;stop-opacity:1;"
offset="0"
id="stop8" /><stop
style="stop-color:#fa7367;stop-opacity:0;"
offset="1"
id="stop9" /></linearGradient><linearGradient
id="linearGradient6"
inkscape:collect="always"><stop
style="stop-color:#fab23b;stop-opacity:1;"
offset="0"
id="stop6" /><stop
style="stop-color:#fab23a;stop-opacity:0;"
offset="1"
id="stop7" /></linearGradient><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
offset="0.20198774"
@@ -26,13 +64,82 @@
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /><path
style="font-weight:bold;font-size:44.7887px;line-height:1.5;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';letter-spacing:0.223944px;fill:#f74e6a;stroke-width:56.4265;stroke-linecap:round"
d="m 73.758258,226.15765 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -28.35124 h 5.912108 v 28.1273 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403099,0.13436 h 4.971545 v 5.86732 z m 14.869774,0 q -1.701971,0 -3.090421,-0.85098 -1.388449,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -27.76899 h 5.86732 v 7.97239 h 10.122246 v 5.86732 H 88.314511 v 13.70534 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403098,0.13436 h 9.584782 v 5.86732 z m 13.705278,0 v 0 -25.97744 h 5.86732 v 25.97744 z m 0,-28.57519 v 0 -5.91211 h 5.86732 v 5.91211 z m 10.25654,28.57519 v 0 -25.97744 h 32.20307 q 1.74676,0 3.13521,0.85098 1.38845,0.85099 2.19465,2.23944 0.85098,1.38845 0.85098,3.09042 v 19.7966 h -5.82253 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.17916,-0.17915 -0.4031,-0.17915 h -9.27126 q -0.22395,0 -0.4031,0.17915 -0.13437,0.13437 -0.13437,0.35831 v 19.57266 h -5.91211 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 h -9.31605 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 19.57266 z m 50.07371,0 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.23944,-2.23944 -0.85098,-1.38845 -0.85098,-3.09042 v -13.61576 q 0,-1.70197 0.85098,-3.09042 0.85099,-1.38845 2.23944,-2.23944 1.38845,-0.85098 3.09042,-0.85098 h 14.01886 q 1.70197,0 3.09042,0.85098 1.43324,0.85099 2.23944,2.23944 0.85098,1.38845 0.85098,3.09042 v 13.61576 q 0,1.70197 -0.85098,3.09042 -0.8062,1.38845 -2.19465,2.23944 -1.38845,0.85098 -3.13521,0.85098 z m 0.22394,-5.86732 h 13.57098 q 0.22394,0 0.35831,-0.13436 0.17915,-0.17916 0.17915,-0.4031 v -13.16788 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 H 162.8875 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 13.16788 q 0,0.22394 0.13436,0.4031 0.17916,0.13436 0.4031,0.13436 z"
id="text13"
aria-label="ltimo" /></g></svg>
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient11"
id="linearGradient13"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1769"
id="linearGradient1789"
gradientUnits="userSpaceOnUse"
x1="37.71608"
y1="123.22854"
x2="27.709011"
y2="135.24788" /><linearGradient
id="linearGradient1769"
inkscape:collect="always"><stop
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
offset="0.00003701"
id="stop1769" /><stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop1770" /></linearGradient><mask
maskUnits="userSpaceOnUse"
id="mask1788-1"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1788-2"
sodipodi:nodetypes="cccsccc" /></mask><mask
maskUnits="userSpaceOnUse"
id="mask1776-6-6"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1776-1-2"
sodipodi:nodetypes="cccsccc" /></mask><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient6"
id="radialGradient815"
cx="35.709007"
cy="12.042645"
fx="35.709007"
fy="12.042645"
r="16.740065"
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient8"
id="radialGradient816"
cx="8.0343275"
cy="13.452971"
fx="8.0343275"
fy="13.452971"
r="16.678839"
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
gradientUnits="userSpaceOnUse" /></defs><g
id="g816"><path
id="path1-2-1"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z"
sodipodi:nodetypes="cccccccccccssssccccccccssssssccccccccccccscc" /><path
id="path1-2-1-9"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="M 3.6452555 7.14375 C 3.5159759 7.5278714 3.4447807 7.9357538 3.444751 8.3586629 L 3.444751 23.909652 C 3.444711 23.914652 3.4152451 27.753282 5.617745 31.514872 C 7.821705 35.278892 12.65614 38.897367 20.07061 38.897367 L 24.651208 38.897367 C 30.085144 38.897367 34.141887 36.930057 36.802405 34.39325 C 35.285228 33.174993 33.583741 31.797916 31.788757 30.3382 C 31.529773 30.540194 31.247156 30.732346 30.938163 30.908708 C 29.465893 31.749008 27.477765 32.239376 24.666195 32.239376 L 20.085079 32.239376 C 17.261269 32.239376 15.286427 31.759973 13.828097 30.935063 C 12.369767 30.110163 11.51428 28.931371 10.96884 27.999841 C 9.7721994 25.956191 9.7389404 23.928255 9.7389404 23.928255 L 9.7379069 23.914303 L 9.7379069 23.898283 L 9.7379069 12.201839 L 9.7379069 12.187887 L 9.7415243 12.179618 C 9.7928043 12.228318 9.92951 12.342748 9.98337 12.387358 L 15.408362 16.932817 L 20.923271 21.504114 C 21.007674 21.573954 21.108583 21.634775 21.221444 21.686532 C 16.15064 17.512631 11.679239 13.804108 9.750826 12.186336 C 7.4531167 10.290397 5.3700283 8.5696704 3.6452555 7.14375 z " /><path
id="path1-2-1-7"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="M 41.226941 7.09104 C 39.508657 8.5115809 37.437441 10.222955 35.154443 12.106755 C 33.219453 13.730045 28.724331 17.458018 23.632149 21.649325 C 23.714466 21.60631 23.788932 21.557823 23.853841 21.504114 L 29.36875 16.932817 L 34.85627 12.358936 C 34.87617 12.342536 34.897015 12.325776 34.916215 12.310876 L 34.94412 12.289689 L 35.166846 12.09952 L 35.166846 23.697778 L 35.166846 23.726717 C 35.166846 23.726717 35.100288 25.808681 33.855298 27.910441 C 33.283278 28.876161 32.409906 30.068398 30.937646 30.908708 C 29.465376 31.749008 27.477248 32.239376 24.665678 32.239376 L 20.084562 32.239376 C 17.260752 32.239376 15.28591 31.759973 13.82758 30.935063 C 13.518898 30.760458 13.236992 30.570319 12.979569 30.369722 C 11.174016 31.837687 9.466344 33.219415 7.9488688 34.437174 C 10.591399 36.955726 14.638319 38.897367 20.070093 38.897367 L 24.650692 38.897367 C 32.068392 38.897367 36.920103 35.231484 39.156783 31.455444 C 41.393463 27.679404 41.426929 23.820251 41.426929 23.820251 L 41.428996 23.786145 L 41.428996 8.3007853 C 41.429125 7.8798785 41.357396 7.4736084 41.226941 7.09104 z " /></g><text
xml:space="preserve"
style="font-size:44.7887px;line-height:1.5;font-family:Roboto;-inkscape-font-specification:Roboto;letter-spacing:0.223944px;writing-mode:lr-tb;direction:ltr;fill:#f74e6a;fill-opacity:1;stroke:#ff0000;stroke-width:56.4265;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
x="43.547977"
y="38.744164"
id="text13-7"><tspan
sodipodi:role="line"
id="tspan13-7"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';fill:#f74e6a;fill-opacity:1;stroke:none;stroke-width:56.4265"
x="43.547977"
y="38.744164">ltimo</tspan></text></svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -9
View File
@@ -1,14 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512">
<title>Ultimo Email</title>
<desc>Ultimo Email logo, for BIMI (SVG Tiny Portable/Secure)</desc>
<defs>
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="44.1352" y1="192.6008" x2="44.1352" y2="224.5123">
<stop offset="0.202" stop-color="#f89629"/>
<stop offset="1" stop-color="#f74176"/>
</linearGradient>
</defs>
<defs><linearGradient id="g0" gradientUnits="userSpaceOnUse" x1="22.4348" y1="5.1873" x2="22.4348" y2="37.0988"><stop offset="0.2020" stop-color="#f89629" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#f74176" stop-opacity="1.0000"/></linearGradient><radialGradient id="g1" gradientUnits="userSpaceOnUse" cx="8.0343" cy="13.4530" r="16.6788"><stop offset="0.0000" stop-color="#fa7367" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#fa7367" stop-opacity="0.0000"/></radialGradient><radialGradient id="g2" gradientUnits="userSpaceOnUse" cx="35.7090" cy="12.0426" r="16.7401"><stop offset="0.0000" stop-color="#fab23b" stop-opacity="1.0000"/><stop offset="1.0000" stop-color="#fab23a" stop-opacity="0.0000"/></radialGradient></defs>
<rect width="512" height="512" fill="#ffffff"/>
<g transform="translate(97.2800,104.2299) scale(7.063024) translate(-21.70051,-187.41349)">
<path fill="url(#g)" d="m 58.875622,191.58757 c -2.152336,-0.00074 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-0.00007 -0.0835,0.00059 -0.125155,0.002 v -2.78918 c -0.000166,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,0.00018 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -0.000039,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 0.0007,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 0.000782,0.001 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 0.000782,-0.00082 z"/>
</g>
<g transform="matrix(7.081810,0,0,7.081810,96.1936,104.0546)"><g transform="matrix(1,0,0,1,0.128676,-0.079965)"><path fill="url(#g0)" d="M37.1752,4.1741C35.0228,4.1733 33.1074,6.0213 33.1081,8.3007L33.1081,11.1528C33.0351,11.1478 32.9615,11.1458 32.8879,11.1458L22.3878,11.1458L11.8892,11.1458C11.8474,11.1457 11.8057,11.1464 11.7641,11.1478L11.7641,8.3586C11.7639,6.0805 9.8048,4.2338 7.6537,4.2337C5.5026,4.2339 3.4449,6.0805 3.4448,8.3586L3.4448,23.9099C3.4447,23.9149 3.4153,27.7533 5.6178,31.5149C7.8217,35.2789 12.6560,38.8974 20.0704,38.8974L24.6511,38.8974C32.0688,38.8974 36.9203,35.2313 39.1570,31.4552C41.3937,27.6792 41.4274,23.8204 41.4274,23.8204L41.4294,23.7860L41.4294,8.3006C41.4301,6.0219 39.3273,4.1742 37.1756,4.1740ZM35.1673,12.0993L35.1673,23.6979L35.1673,23.7265C35.1673,23.7265 35.1009,25.8089 33.8559,27.9106C33.2839,28.8763 32.4105,30.0682 30.9382,30.9085C29.4659,31.7488 27.4775,32.2393 24.6660,32.2393L20.0849,32.2393C17.2611,32.2393 15.2863,31.7600 13.8279,30.9350C12.3696,30.1101 11.5144,28.9316 10.9689,28.0001C9.7723,25.9564 9.7389,23.9282 9.7389,23.9282L9.7379,23.9141L9.7379,23.8984L9.7379,12.2020L9.7381,12.1878L9.7416,12.1798C9.7929,12.2285 9.9297,12.3428 9.9835,12.3874L15.4082,16.9326L20.9233,21.5040C21.5752,22.0435 23.2023,22.0435 23.8542,21.5040L29.3693,16.9326L34.8566,12.3591C34.8765,12.3427 34.8975,12.3260 34.9167,12.3111L34.9443,12.2896Z"/><g transform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"><path fill="url(#g1)" d="M27.1856,17.7752C27.6455,17.3736 27.9083,16.9462 27.9286,16.5020L28.6733,0.1670C28.6737,0.1618 28.9581,-3.8700 21.6154,-7.8536C14.2678,-11.8398 -2.0714,-15.7119 -27.3962,-15.8211L-43.0417,-15.8886C-61.6019,-15.9686 -75.5523,-13.9619 -84.7610,-11.3363C-79.6373,-10.0343 -73.8916,-8.5628 -67.8306,-7.0030C-66.9363,-7.2114 -65.9618,-7.4091 -64.8980,-7.5898C-59.8291,-8.4508 -53.0149,-8.9366 -43.4117,-8.8952L-27.7645,-8.8277C-18.1195,-8.7861 -11.3972,-8.2535 -6.4556,-7.3655C-1.5141,-6.4775 1.3515,-5.2267 3.1699,-4.2402C7.1593,-2.0759 7.1758,0.0548 7.1758,0.0548L7.1786,0.0694L7.1778,0.0863L6.6177,12.3724L6.6171,12.3870L6.6043,12.3957C6.4315,12.3438 5.9700,12.2216 5.7882,12.1739L-12.5237,7.3194L-31.1415,2.4364C-31.4264,2.3618 -31.7682,2.2965 -32.1512,2.2404C-15.0312,6.6994 0.0637,10.6607 6.5729,12.3885C14.3301,14.4138 21.3627,16.2520 27.1856,17.7752Z"/></g><g transform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.973013)"><path fill="url(#g2)" d="M56.9506,6.2383C50.3245,7.7408 42.3373,9.5509 33.5334,11.5434C26.0725,13.2601 8.7387,17.2029 -10.8988,21.6363C-10.5803,21.5905 -10.2927,21.5390 -10.0424,21.4822L11.2240,16.6473L32.3838,11.8101C32.4605,11.7927 32.5409,11.7750 32.6150,11.7592L32.7226,11.7368L33.5813,11.5357L34.0382,23.6546L34.0393,23.6848C34.0393,23.6848 33.8625,25.8609 29.1038,28.0702C26.9173,29.0853 23.5679,30.3403 17.8757,31.2340C12.1834,32.1276 4.4713,32.6610 -6.4624,32.6907L-24.2775,32.7392C-35.2588,32.7691 -42.9575,32.2891 -48.6611,31.4426C-49.8684,31.2634 -50.9722,31.0677 -51.9812,30.8609C-58.9448,32.4138 -65.5312,33.8756 -71.3844,35.1641C-61.0089,37.7678 -45.1947,39.7537 -24.0715,39.6962L-6.2584,39.6477C22.5876,39.5692 41.3106,35.6874 49.8599,31.7182C58.4092,27.7490 58.3873,23.7163 58.3873,23.7163L58.3941,23.6806L57.7841,7.5002C57.7679,7.0604 57.4730,6.6366 56.9506,6.2383Z"/></g></g></g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"background_color": "#1b1e20",
"theme_color": "#f3f4f6",
"categories": ["productivity", "business"],
"icons": [
+84 -6
View File
@@ -26,9 +26,87 @@
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1788"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1788" /></mask><linearGradient
xlink:href="#linearGradient1769"
id="linearGradient1789"
gradientUnits="userSpaceOnUse"
x1="37.71608"
y1="123.22854"
x2="27.709011"
y2="135.24788" /><linearGradient
id="linearGradient1769"><stop
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
offset="0.00003701"
id="stop1769" /><stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop1770" /></linearGradient><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1776-6"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1776-1" /></mask><linearGradient
xlink:href="#linearGradient11"
id="linearGradient13-6"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415" /><radialGradient
xlink:href="#linearGradient8"
id="radialGradient816"
cx="8.0343275"
cy="13.452971"
fx="8.0343275"
fy="13.452971"
r="16.678839"
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient8"><stop
style="stop-color:#fa7367;stop-opacity:1;"
offset="0"
id="stop8" /><stop
style="stop-color:#fa7367;stop-opacity:0;"
offset="1"
id="stop9" /></linearGradient><radialGradient
xlink:href="#linearGradient6"
id="radialGradient815"
cx="35.709007"
cy="12.042645"
fx="35.709007"
fy="12.042645"
r="16.740065"
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient6"><stop
style="stop-color:#fab23b;stop-opacity:1;"
offset="0"
id="stop6" /><stop
style="stop-color:#fab23a;stop-opacity:0;"
offset="1"
id="stop7" /></linearGradient></defs><g
id="g816"
transform="translate(0.128676,-0.079965)"><path
id="path1-2-1-1"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z" /><path
id="path1-2-1-9"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
id="path1-2-1-7"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+134 -3
View File
@@ -2,10 +2,10 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
import { languageName, useLocale, useT } from "../i18n";
import { api, ApiError } from "../lib/api";
import { useMe } from "../lib/auth";
import type { AdminMail, AdminUser } from "../lib/types";
import type { AdminMail, AdminTranslation, AdminUser } from "../lib/types";
import { useConfirm } from "../components/AppDialog";
const GIB = 1024 ** 3;
@@ -44,8 +44,9 @@ export function AdminApp() {
<Users selfId={me.data.id} />
</div>
{/* The divider runs the full height of the taller column. */}
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
<div className="min-w-0 space-y-10 lg:border-l lg:border-app-border lg:pl-12">
<SystemMail />
<Translation />
</div>
</div>
)}
@@ -287,6 +288,136 @@ function Panel({ handle }: { handle: string }) {
);
}
/** Email translation's languages: installed ones (removable, but English),
* downloads in progress, and any language Mozilla has models for to add. */
function Translation() {
const t = useT();
const { locale } = useLocale();
const queryClient = useQueryClient();
const [confirm, confirmDialog] = useConfirm();
const [adding, setAdding] = useState("");
const info = useQuery({
queryKey: ["adminTranslation"],
queryFn: api.adminTranslation,
// While something downloads, follow its progress.
refetchInterval: (q) =>
Object.keys((q.state.data as AdminTranslation | undefined)?.installing ?? {}).length ? 1500 : false,
});
const done = (data: AdminTranslation) => {
queryClient.setQueryData(["adminTranslation"], data);
void queryClient.invalidateQueries({ queryKey: ["translation-languages"] });
};
const install = useMutation({
mutationFn: api.installTranslationLanguage,
onSuccess: (data) => {
done(data);
setAdding("");
},
});
const remove = useMutation({ mutationFn: api.removeTranslationLanguage, onSuccess: done });
const name = (code: string) => languageName(code, locale);
const data = info.data;
const busy = install.isPending || remove.isPending;
const installing = Object.entries(data?.installing ?? {});
const addable = (data?.available ?? [])
.filter((c) => !data?.installed.includes(c) && !(c in (data?.installing ?? {})))
.sort((a, b) => name(a).localeCompare(name(b), locale));
const error = (install.error ?? remove.error) as unknown;
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.translation")}</h2>
<p className="text-xs text-app-faint">{t("admin.translationHelp")}</p>
{info.error instanceof ApiError && <p className="text-sm text-red-500">{info.error.message}</p>}
{data && !data.enabled && <p className="text-sm text-amber-600">{t("admin.translationOff")}</p>}
{data?.enabled && !data.reachable && (
<p className="text-sm text-amber-600">{t("admin.translationUnreachable")}</p>
)}
{data?.reachable && (
<>
<ul className="divide-y divide-app-border rounded border border-app-border">
{[...data.installed]
.sort((a, b) => name(a).localeCompare(name(b), locale))
.map((code) => (
<li key={code} className="flex items-center justify-between px-3 py-2 text-sm">
<span>
{name(code)} <span className="text-xs text-app-faint">{code}</span>
</span>
{code === "en" ? (
<span className="text-xs text-app-faint">{t("admin.translationAlways")}</span>
) : (
<button
type="button"
disabled={busy}
className="rounded border border-app-border px-2 py-0.5 text-xs hover:bg-app-hover disabled:opacity-50"
onClick={async () => {
const ok = await confirm({
title: t("admin.translationRemoveTitle", { language: name(code) }),
message: t("admin.translationRemoveConfirm"),
confirmLabel: t("admin.translationRemove"),
danger: true,
});
if (ok) remove.mutate(code);
}}
>
{t("admin.translationRemove")}
</button>
)}
</li>
))}
{installing.map(([code, p]) => (
<li key={code} className="flex items-center justify-between px-3 py-2 text-sm text-app-muted">
<span>{name(code)}</span>
<span className="text-xs tabular-nums">
{t("admin.translationInstalling", {
percent: p.total ? Math.floor((p.done / p.total) * 100) : 0,
})}
</span>
</li>
))}
</ul>
{Object.entries(data.errors).map(([code, why]) => (
<p key={code} className="text-xs text-red-500">
{t("admin.translationFailed", { language: name(code) })} {why}
</p>
))}
{addable.length > 0 ? (
<div className="flex items-center gap-2">
<select
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm"
value={adding}
onChange={(e) => setAdding(e.target.value)}
>
<option value="">{t("admin.translationChoose")}</option>
{addable.map((code) => (
<option key={code} value={code}>
{name(code)}
</option>
))}
</select>
<button
type="button"
disabled={!adding || busy}
className="rounded bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
onClick={() => install.mutate(adding)}
>
{t("admin.translationInstall")}
</button>
</div>
) : (
data.available.length === 0 && (
<p className="text-xs text-app-faint">{t("admin.translationCatalogueOffline")}</p>
)
)}
{error instanceof ApiError && <p className="text-xs text-red-500">{error.message}</p>}
</>
)}
{confirmDialog}
</section>
);
}
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
/** The SMTP account the server sends password-reset links from. */
@@ -478,7 +478,7 @@ function MessageItem({
: [];
const offerTranslate =
translateCodes.length > 0 &&
(translateTo !== null || needsTranslation(language, locale));
(translateTo !== null || needsTranslation(language, locale, translateCodes));
const target = translateCodes.find((c) => sameLanguage(c, locale)) ?? null;
const replyBtn = (kind: string, label: string, icon: string) => (
<button
+14
View File
@@ -691,6 +691,20 @@ export const en = {
"admin.testTo": "Send a test email to",
"admin.sendTest": "Send test",
"admin.testSent": "Test email sent.",
"admin.translation": "Email translation",
"admin.translationHelp":
"Languages email can be translated between, all on this server (Mozilla's Firefox Translations models). Each language is about 70 MB: models to and from English, through which other pairs go.",
"admin.translationOff": "Translation is off on this server (TRANSLATOR_URL is empty).",
"admin.translationUnreachable": "The translation service is not answering. It may still be starting.",
"admin.translationAlways": "always installed",
"admin.translationRemove": "Remove",
"admin.translationRemoveTitle": "Remove {language}?",
"admin.translationRemoveConfirm": "Its models are deleted. Mail in this language can no longer be translated until it is installed again.",
"admin.translationInstalling": "downloading… {percent}%",
"admin.translationFailed": "Installing {language} failed:",
"admin.translationChoose": "Add a language…",
"admin.translationInstall": "Install",
"admin.translationCatalogueOffline": "Mozilla's list of languages can't be reached right now, so no more can be added.",
"admin.users": "Users",
"admin.userSearch": "Search by login, email address or name…",
"admin.noUsers": "No users match.",
+8 -3
View File
@@ -68,9 +68,14 @@ export function sameLanguage(a: string, b: string): boolean {
}
/** Whether a message in ``languages`` ("zh-Hans,pl,en", as the server found
* them) has a part that is not in ``locale``: then it can be translated. */
export function needsTranslation(languages: string, locale: string): boolean {
return splitLanguages(languages).some((code) => !sameLanguage(code, locale));
* them) has a part that is not in ``locale`` but is in ``installed`` (the
* languages translation has): then it can be translated. */
export function needsTranslation(languages: string, locale: string, installed: string[]): boolean {
return splitLanguages(languages).some(
(code) =>
!sameLanguage(code, locale) &&
installed.some((have) => have === code || (!have.includes("-") && sameLanguage(have, code))),
);
}
/** "zh-Hans,pl,en" → ["zh-Hans", "pl", "en"]. */
+14
View File
@@ -702,6 +702,20 @@ export const pl: Catalog = {
"admin.testTo": "Wyślij testowy e-mail do",
"admin.sendTest": "Wyślij test",
"admin.testSent": "Wysłano testowy e-mail.",
"admin.translation": "Tłumaczenie poczty",
"admin.translationHelp":
"Języki, między którymi można tłumaczyć pocztę, w całości na tym serwerze (modele Firefox Translations od Mozilli). Każdy język to około 70 MB: modele z angielskiego i na angielski, przez który idą pozostałe pary.",
"admin.translationOff": "Tłumaczenie jest wyłączone na tym serwerze (TRANSLATOR_URL jest pusty).",
"admin.translationUnreachable": "Usługa tłumaczenia nie odpowiada. Może jeszcze się uruchamia.",
"admin.translationAlways": "zawsze zainstalowany",
"admin.translationRemove": "Usuń",
"admin.translationRemoveTitle": "Usunąć: {language}?",
"admin.translationRemoveConfirm": "Jego modele zostaną usunięte. Poczty w tym języku nie będzie można tłumaczyć, dopóki nie zostanie zainstalowany ponownie.",
"admin.translationInstalling": "pobieranie… {percent}%",
"admin.translationFailed": "Instalacja nie powiodła się: {language}.",
"admin.translationChoose": "Dodaj język…",
"admin.translationInstall": "Zainstaluj",
"admin.translationCatalogueOffline": "Lista języków Mozilli jest teraz niedostępna, więc nie można dodać kolejnych.",
"admin.users": "Użytkownicy",
"admin.userSearch": "Szukaj po loginie, adresie e-mail lub nazwie…",
"admin.noUsers": "Brak pasujących użytkowników.",
+6
View File
@@ -7,6 +7,7 @@ import type {
AppPassword,
AccountDetail,
AdminMail,
AdminTranslation,
AdminSettings,
AdminUser,
AccountOut,
@@ -136,6 +137,11 @@ export const api = {
deleteAdminUser: (id: string) =>
apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
adminMail: () => apiFetch<AdminMail>("/admin/mail"),
adminTranslation: () => apiFetch<AdminTranslation>("/admin/translation"),
installTranslationLanguage: (code: string) =>
apiFetch<AdminTranslation>(`/admin/translation/languages/${code}`, { method: "POST" }),
removeTranslationLanguage: (code: string) =>
apiFetch<AdminTranslation>(`/admin/translation/languages/${code}`, { method: "DELETE" }),
// PUT merges: omit smtp_password to keep the stored one, "" to remove it.
saveAdminMail: (body: Partial<Omit<AdminMail, "has_password" | "configured">> & { smtp_password?: string }) =>
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
+12
View File
@@ -42,6 +42,18 @@ export interface AdminMail {
configured: boolean;
}
/** Email translation's languages on the translator service (admin panel). */
export interface AdminTranslation {
enabled: boolean;
reachable: boolean;
installed: string[];
/** Downloads in progress: bytes done of total (total 0 until known). */
installing: Record<string, { done: number; total: number }>;
/** Languages Mozilla has models for; empty when the catalogue is out of reach. */
available: string[];
errors: Record<string, string>;
}
export interface AdminSettings {
registration_open: boolean;
/** Quota given to users created from now on; existing mailboxes keep theirs. */
@@ -26,9 +26,87 @@
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1788"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1788" /></mask><linearGradient
xlink:href="#linearGradient1769"
id="linearGradient1789"
gradientUnits="userSpaceOnUse"
x1="37.71608"
y1="123.22854"
x2="27.709011"
y2="135.24788" /><linearGradient
id="linearGradient1769"><stop
style="stop-color:#ffe3ac;stop-opacity:0.36078355;"
offset="0.00003701"
id="stop1769" /><stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop1770" /></linearGradient><mask
maskUnits="userSpaceOnUse"
x="-1000"
y="-1000"
width="3000"
height="3000"
id="mask1776-6"><path
style="fill:url(#linearGradient1789);fill-opacity:1;stroke-width:0.2;stroke-linecap:round"
d="m 18.20332,107.02297 c 0,0 1.930731,1.59986 4.989281,4.12846 1.725315,1.42637 3.809507,3.14827 6.108453,5.04523 5.657506,4.74616 33.20207,27.48506 33.558689,27.18811 0.241178,-0.20083 -16.275384,9.86387 -16.275384,9.86387 L 17.979141,140.69463 Z"
id="path1776-1" /></mask><linearGradient
xlink:href="#linearGradient11"
id="linearGradient13-6"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,-18.692802,-93.278335)"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415" /><radialGradient
xlink:href="#linearGradient8"
id="radialGradient816"
cx="8.0343275"
cy="13.452971"
fx="8.0343275"
fy="13.452971"
r="16.678839"
gradientTransform="matrix(-0.29271702,0.00410386,-0.01334442,-0.95181978,11.840139,23.950941)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient8"><stop
style="stop-color:#fa7367;stop-opacity:1;"
offset="0"
id="stop8" /><stop
style="stop-color:#fa7367;stop-opacity:0;"
offset="1"
id="stop9" /></linearGradient><radialGradient
xlink:href="#linearGradient6"
id="radialGradient815"
cx="35.709007"
cy="12.042645"
fx="35.709007"
fy="12.042645"
r="16.740065"
gradientTransform="matrix(0.25712152,0.00260463,-0.00969379,0.95694375,26.644192,0.97301292)"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient6"><stop
style="stop-color:#fab23b;stop-opacity:1;"
offset="0"
id="stop6" /><stop
style="stop-color:#fab23a;stop-opacity:0;"
offset="1"
id="stop7" /></linearGradient></defs><g
id="g816"
transform="translate(0.128676,-0.079965)"><path
id="path1-2-1-1"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#linearGradient13-6);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 37.175189,4.174075 c -2.15234,-7.4e-4 -4.0678,1.84724 -4.0671,4.1266 v 2.85212 c -0.073,-0.005 -0.14656,-0.007 -0.2202,-0.007 h -10.50012 -10.49855 c -0.0418,-7e-5 -0.0835,5.9e-4 -0.12516,0.002 v -2.78918 c -1.6e-4,-2.27807 -1.9592601,-4.12477 -4.1103801,-4.12494 -2.15111,1.8e-4 -4.20874,1.84687 -4.2089,4.12494 v 15.55127 c -4e-5,0.005 -0.0295,3.84339 2.173,7.60498 2.20396,3.76402 7.0382001,7.38257 14.4526701,7.38257 h 4.58067 c 7.4177,0 12.26918,-3.66617 14.50586,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.002,-0.0344 V 8.300605 c 7e-4,-2.27872 -2.10202,-4.12642 -4.25374,-4.1266 z m -2.00792,7.9252 v 11.59858 0.0286 c 0,0 -0.0664,2.0824 -1.31139,4.18416 -0.57202,0.96572 -1.44541,2.15762 -2.91767,2.99793 -1.47227,0.8403 -3.46066,1.33079 -6.27223,1.33079 h -4.58106 c -2.82381,0 -4.79864,-0.47938 -6.25697,-1.30429 -1.45833,-0.8249 -2.31357,-2.00343 -2.85901,-2.93496 -1.1966401,-2.04365 -1.2300401,-4.07193 -1.2300401,-4.07193 l -0.001,-0.0141 v -0.0157 -11.69637 l 1.96e-4,-0.0142 0.0035,-0.008 c 0.05128,0.0487 0.18806,0.16304 0.24192,0.20765 l 5.4247011,4.54513 5.51504,4.57144 c 0.65194,0.53945 2.27903,0.53945 2.93097,0 l 5.51504,-4.57144 5.487378,-4.57351 c 0.0199,-0.0164 0.04083,-0.0331 0.06003,-0.048 l 0.02765,-0.0215 z" /><path
id="path1-2-1-9"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient816);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="M 3.6452555,7.14375 C 3.5159759,7.5278714 3.4447807,7.9357538 3.444751,8.3586629 V 23.909652 c -4e-5,0.005 -0.029506,3.84363 2.172994,7.60522 2.20396,3.76402 7.038395,7.382495 14.452865,7.382495 h 4.580598 c 5.433936,0 9.490679,-1.96731 12.151197,-4.504117 -1.517177,-1.218257 -3.218664,-2.595334 -5.013648,-4.05505 -0.258984,0.201994 -0.541601,0.394146 -0.850594,0.570508 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -1.45833,-0.8249 -2.313817,-2.003692 -2.859257,-2.935222 -1.1966406,-2.04365 -1.2298996,-4.071586 -1.2298996,-4.071586 l -0.00103,-0.01395 v -0.01602 -11.696444 -0.01395 l 0.00362,-0.0083 c 0.05128,0.0487 0.1879857,0.16313 0.2418457,0.20774 l 5.4249919,4.545459 5.514909,4.571297 c 0.0844,0.06984 0.185312,0.130661 0.298173,0.182418 C 16.15064,17.512631 11.679239,13.804108 9.750826,12.186336 7.4531167,10.290397 5.3700283,8.5696704 3.6452555,7.14375 Z" /><path
id="path1-2-1-7"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:url(#radialGradient815);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000"
d="m 41.226941,7.09104 c -1.718284,1.4205409 -3.7895,3.131915 -6.072498,5.015715 -1.93499,1.62329 -6.430112,5.351263 -11.522294,9.54257 0.08232,-0.04302 0.156783,-0.0915 0.221692,-0.145211 l 5.514909,-4.571297 5.48752,-4.573881 c 0.0199,-0.0164 0.04075,-0.03316 0.05994,-0.04806 l 0.0279,-0.02119 0.222726,-0.190169 v 11.598258 0.02894 c 0,0 -0.06656,2.081964 -1.311548,4.183724 -0.57202,0.96572 -1.445392,2.157957 -2.917652,2.998267 -1.47227,0.8403 -3.460398,1.330668 -6.271968,1.330668 h -4.581116 c -2.82381,0 -4.798652,-0.479403 -6.256982,-1.304313 -0.308682,-0.174605 -0.590588,-0.364744 -0.848011,-0.565341 -1.805553,1.467965 -3.513225,2.849693 -5.0307002,4.067452 2.6425302,2.518552 6.6894502,4.460193 12.1212242,4.460193 h 4.580599 c 7.4177,0 12.269411,-3.665883 14.506091,-7.441923 2.23668,-3.77604 2.270146,-7.635193 2.270146,-7.635193 l 0.0021,-0.03411 V 8.3007853 C 41.429125,7.8798785 41.357396,7.4736084 41.226941,7.09104 Z" /></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+6
View File
@@ -0,0 +1,6 @@
# The translation service (translator.py). Python 3.10: bergamot's newest build.
bergamot==0.4.5
fasttext-predict==0.9.2.4
fastapi==0.139.2
pydantic==2.13.4
uvicorn==0.51.0
+427
View File
@@ -0,0 +1,427 @@
"""Ultimo's translation service: Mozilla's Firefox Translations models (the
Bergamot engine) and fastText language identification, behind a small HTTP
API that only the webmail API calls (backend/app/translation.py), on the
internal network. A message's text never leaves the server.
- Firefox's models translate to and from English; any other pair goes
through English (Polish → English → German). So a language is installed as
its two models, to and from English, and English is always there.
- What is installed is kept in ``$DATA_DIR/state.json``. The first start
installs ``TRANSLATOR_LANGUAGES``; after that the admin panel adds and
removes languages (``POST``/``DELETE /languages/{code}``).
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
file checked against its SHA-256; only release models (no test builds).
- Language identification: fastText's lid.176 (176 languages), baked into
the image.
API (JSON):
GET /health
GET /languages installed / installing / available, errors
POST /languages/{code} install (in the background)
DELETE /languages/{code} remove
POST /detect {"q": str | [str]} → language(s)
POST /translate {"q": str | [str], "source", "target"} → translation(s)
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
import threading
import time
import urllib.request
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import fasttext
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
def _load_engine() -> Any:
"""The Bergamot engine: only its compiled module. Importing the bergamot
package itself fetches model catalogues from translatelocally.com and
OPUS on every start (and fails without them); we load our own models."""
import importlib.machinery
import importlib.util
spec = importlib.util.find_spec("bergamot")
if spec is None or not spec.submodule_search_locations:
raise ImportError("the bergamot package is not installed")
folder = Path(next(iter(spec.submodule_search_locations)))
path = next(folder.glob("_bergamot*.so"))
loader = importlib.machinery.ExtensionFileLoader("_bergamot", str(path))
module_spec = importlib.util.spec_from_loader("_bergamot", loader)
assert module_spec is not None
module = importlib.util.module_from_spec(module_spec)
loader.exec_module(module)
return module
bergamot = _load_engine()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("translator")
DATA = Path(os.environ.get("DATA_DIR", "/data"))
MODELS = DATA / "models"
STATE = DATA / "state.json"
LID_MODEL = os.environ.get("LID_MODEL", "/opt/lid.176.ftz")
DEFAULT_LANGUAGES = [
c.strip() for c in os.environ.get("TRANSLATOR_LANGUAGES", "pl,de,fr,es,it").split(",")
]
THREADS = int(os.environ.get("TRANSLATOR_THREADS", "2"))
RECORDS_URL = os.environ.get(
"MODELS_RECORDS_URL",
"https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/"
"translations-models/records",
)
ATTACHMENTS_URL = os.environ.get(
"MODELS_ATTACHMENTS_URL", "https://firefox-settings-attachments.cdn.mozilla.net/"
)
PIVOT = "en"
_RECORDS_TTL = 3600
_CODE = re.compile(r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
_service = bergamot.Service(bergamot.ServiceConfig(numWorkers=THREADS, logLevel="off"))
_lid = fasttext.load_model(LID_MODEL)
_lock = threading.Lock() # state, loaded models, installs
_loaded: dict[str, Any] = {} # "en-pl" → bergamot model
_installing: dict[str, dict[str, int]] = {} # "pl" → {"done": bytes, "total": bytes}
_errors: dict[str, str] = {} # "pl" → why the last install failed
_records: tuple[float, list[dict[str, Any]]] = (0.0, [])
# --- what is installed --------------------------------------------------------------
def _read_state() -> dict[str, Any] | None:
try:
return json.loads(STATE.read_text())
except (OSError, ValueError):
return None
def _installed() -> list[str]:
state = _read_state() or {}
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
def _write_installed(codes: list[str]) -> None:
DATA.mkdir(parents=True, exist_ok=True)
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps({"languages": sorted(set(codes) - {PIVOT})}))
tmp.replace(STATE)
# --- Mozilla's model catalogue -----------------------------------------------------------
def _fetch(url: str, timeout: float = 60) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "ultimo-translator"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _catalogue() -> list[dict[str, Any]]:
"""Mozilla's model records (cached for an hour)."""
global _records
stamp, cached = _records
if cached and time.monotonic() - stamp < _RECORDS_TTL:
return cached
data = json.loads(_fetch(RECORDS_URL))["data"]
_records = (time.monotonic(), data)
return data
def _version(text: str) -> tuple[int, ...] | None:
""" "2.1" → (2, 1); test builds ("2.1a1") → None."""
return tuple(int(x) for x in text.split(".")) if re.fullmatch(r"\d+(\.\d+)*", text) else None
def _release_files(source: str, target: str) -> list[dict[str, Any]]:
"""The newest release model of one direction: its files' records."""
by_version: dict[tuple[int, ...], list[dict[str, Any]]] = {}
for r in _catalogue():
if r.get("fromLang") != source or r.get("toLang") != target:
continue
if r.get("filter_expression"):
continue # a test build (Nightly only)
v = _version(str(r.get("version", "")))
if v is not None:
by_version.setdefault(v, []).append(r)
if not by_version:
raise LookupError(f"no model for {source} → {target}")
return by_version[max(by_version)]
def _available() -> list[str]:
"""Languages Mozilla has release models for, both ways with English."""
pairs: set[tuple[str, str]] = set()
for r in _catalogue():
if not r.get("filter_expression") and _version(str(r.get("version", ""))):
pairs.add((r["fromLang"], r["toLang"]))
return sorted({a for a, b in pairs if b == PIVOT and (PIVOT, a) in pairs} | {PIVOT})
# --- installing ---------------------------------------------------------------------------
def _model_dir(source: str, target: str) -> Path:
return MODELS / f"{source}-{target}"
def _install_direction(code: str, source: str, target: str) -> None:
files = _release_files(source, target)
final = _model_dir(source, target)
tmp = final.with_name(final.name + ".partial")
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True)
for r in files:
att = r["attachment"]
blob = _fetch(ATTACHMENTS_URL + att["location"], timeout=600)
if hashlib.sha256(blob).hexdigest() != att["hash"]:
raise ValueError(f"{att['filename']}: checksum mismatch")
(tmp / att["filename"]).write_bytes(blob)
with _lock:
_installing[code]["done"] += len(blob)
(tmp / "files.json").write_text(
json.dumps(
{r["fileType"]: r["attachment"]["filename"] for r in files}
| {"version": files[0]["version"]}
)
)
shutil.rmtree(final, ignore_errors=True)
tmp.replace(final)
def _install(code: str) -> None:
"""Both directions of ``code`` with English, then mark it installed."""
try:
directions = [(code, PIVOT), (PIVOT, code)]
total = sum(r["attachment"]["size"] for s, t in directions for r in _release_files(s, t))
with _lock:
_installing[code]["total"] = total
for source, target in directions:
_install_direction(code, source, target)
with _lock:
_write_installed(_installed() + [code])
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None) # a reinstall: load the new files
_errors.pop(code, None)
log.info("installed %s", code)
except Exception as exc: # noqa: BLE001 — reported in /languages, tried again by hand
log.warning("installing %s failed: %s", code, exc)
with _lock:
_errors[code] = str(exc)[:300]
finally:
with _lock:
_installing.pop(code, None)
def _start_install(code: str) -> bool:
with _lock:
if code in _installing:
return False
_installing[code] = {"done": 0, "total": 0}
threading.Thread(target=_install, args=(code,), daemon=True).start()
return True
def _first_start() -> None:
"""The first start installs the default languages, one after another."""
if _read_state() is not None:
return
_write_installed([])
def run() -> None:
for code in DEFAULT_LANGUAGES:
if code and code != PIVOT:
with _lock:
_installing[code] = {"done": 0, "total": 0}
_install(code)
threading.Thread(target=run, daemon=True).start()
@asynccontextmanager
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
_first_start()
yield
app = FastAPI(title="Ultimo translator", docs_url=None, redoc_url=None, lifespan=_lifespan)
# --- translating -------------------------------------------------------------------------
def _config(directory: Path) -> str:
files = json.loads((directory / "files.json").read_text())
model = files["model"]
vocabs = (
[files["vocab"], files["vocab"]]
if "vocab" in files
else [files["srcvocab"], files["trgvocab"]]
)
# The models are 8-bit; their file name says which kind (as Firefox decides).
precision = "int8shiftAlphaAll" if model.endswith("alphas.bin") else "int8shiftAll"
lines = [
"relative-paths: false",
"models:",
f" - {directory / model}",
"vocabs:",
*[f" - {directory / v}" for v in vocabs],
"beam-size: 1",
"normalize: 1.0",
"word-penalty: 0",
"mini-batch: 64",
"maxi-batch: 1000",
"maxi-batch-sort: src",
"workspace: 2000",
"max-length-factor: 2.5",
"max-length-break: 128",
"mini-batch-words: 1024",
"skip-cost: true",
"quiet: true",
f"gemm-precision: {precision}",
]
if "lex" in files:
lines += ["shortlist:", f" - {directory / files['lex']}", " - false"]
return "\n".join(lines) + "\n"
def _model(source: str, target: str) -> Any:
pair = f"{source}-{target}"
with _lock:
if pair not in _loaded:
directory = _model_dir(source, target)
if not (directory / "files.json").exists():
raise HTTPException(400, f"{source} → {target} is not installed")
_loaded[pair] = _service.modelFromConfig(_config(directory))
return _loaded[pair]
_OPTIONS = bergamot.ResponseOptions(alignment=False, qualityScores=False, HTML=False)
def _translate(texts: list[str], source: str, target: str) -> list[str]:
todo = [i for i, t in enumerate(texts) if t.strip()]
out = list(texts)
if not todo or source == target:
return out
batch = [texts[i] for i in todo]
steps = [(source, target)] if PIVOT in (source, target) else [(source, PIVOT), (PIVOT, target)]
for s, t in steps:
model = _model(s, t)
batch = [
r.target.text for r in _service.translate(model, bergamot.VectorString(batch), _OPTIONS)
]
for i, text in zip(todo, batch, strict=True):
out[i] = text
return out
# --- identifying languages -----------------------------------------------------------------
# fastText's labels where Firefox's codes differ.
_LID_CODES = {"zh": "zh-Hans", "no": "nb", "iw": "he", "fil": "tl"}
# Signs only traditional Chinese uses (a few common ones): tell zh-Hant apart.
_TRADITIONAL = set("們這個來說會對時國過還後點學東車長門開關問電話員")
def _detect(text: str) -> dict[str, Any]:
sample = " ".join(text.split())[:2000]
if not sample:
return {"language": "", "confidence": 0.0}
labels, scores = _lid.predict(sample, k=1)
code = labels[0].replace("__label__", "") if labels else ""
code = _LID_CODES.get(code, code)
if code == "zh-Hans" and sum(c in _TRADITIONAL for c in sample) >= 2:
code = "zh-Hant"
return {"language": code, "confidence": round(float(scores[0]) * 100, 1) if scores else 0.0}
# --- the API ---------------------------------------------------------------------------------
class DetectIn(BaseModel):
q: str | list[str]
class TranslateIn(BaseModel):
q: str | list[str]
source: str
target: str
@app.get("/health")
def health() -> dict[str, bool]:
return {"ok": True}
@app.get("/languages")
def languages() -> dict[str, Any]:
try:
available = _available()
except Exception as exc: # noqa: BLE001 — offline: what is installed still works
log.info("model catalogue unavailable: %s", exc)
available = []
with _lock:
return {
"installed": _installed(),
"installing": {k: dict(v) for k, v in _installing.items()},
"available": available,
"errors": dict(_errors),
}
@app.post("/languages/{code}", status_code=202)
def install(code: str) -> dict[str, Any]:
if not _CODE.fullmatch(code) or code == PIVOT:
raise HTTPException(400, "not a language code")
try:
_release_files(code, PIVOT)
_release_files(PIVOT, code)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
return {"started": _start_install(code)}
@app.delete("/languages/{code}")
def remove(code: str) -> dict[str, bool]:
if code == PIVOT:
raise HTTPException(400, "English is always installed")
with _lock:
_write_installed([c for c in _installed() if c != code])
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None)
shutil.rmtree(MODELS / pair, ignore_errors=True)
return {"removed": True}
@app.post("/detect")
def detect(body: DetectIn) -> Any:
if isinstance(body.q, str):
return [_detect(body.q)]
return [_detect(q) for q in body.q]
@app.post("/translate")
def translate(body: TranslateIn) -> dict[str, Any]:
texts = [body.q] if isinstance(body.q, str) else body.q
source = body.source
if source == "auto":
source = _detect(" ".join(texts))["language"]
installed = _installed()
for code in (source, body.target):
if code not in installed:
raise HTTPException(400, f"{code or 'this language'} is not installed")
out = _translate(texts, source, body.target)
return {"translatedText": out[0] if isinstance(body.q, str) else out, "source": source}