Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings section, mail templates (Compose > More > Templates), a Subscriptions folder with bulk unsubscribe/delete, and tracker blocking with a Privacy toggle (on by default) - Own dialogs instead of browser popups; emoji picker in compose; compose windows survive a visit to Settings - Sentence completion learned from the user's sent mail (no AI) - Interface in English and Polish (one file per language, English as the fallback), picked from the browser on first run, then kept - Email translation on the server via LibreTranslate: Translate button and "Translate to...", messages in several languages translated part by part, names/addresses/emoji/file names/phone numbers kept, an animated line while translating - Antispam "Mark as spam" loads with the message, not when the menu opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""A signed-in user with an API client, for endpoint tests in this package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.main import create_app
|
||||
from app.models import Account, Mailbox, Message, User
|
||||
from app.providers.base import RemoteMessage
|
||||
|
||||
CSRF = "csrf"
|
||||
HDR = {"X-CSRF": CSRF}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserCtx:
|
||||
client: httpx.AsyncClient
|
||||
user_id: uuid.UUID
|
||||
session: AsyncSession # for arranging rows directly
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_ctx() -> AsyncIterator[UserCtx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
user = User(handle=f"t{uuid.uuid4().hex[:10]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = user.id # kept: a test may expire the session's objects
|
||||
token = await create_session(user_id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield UserCtx(client=client, user_id=user_id, session=session)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.rollback()
|
||||
await session.execute(delete(User).where(User.id == user_id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailCtx(UserCtx):
|
||||
account_id: uuid.UUID
|
||||
inbox_id: uuid.UUID
|
||||
sent_id: uuid.UUID
|
||||
|
||||
async def deliver(
|
||||
self,
|
||||
raw: bytes,
|
||||
uid: int,
|
||||
*,
|
||||
seen: bool = False,
|
||||
at: dt.datetime | None = None,
|
||||
folder: str = "inbox",
|
||||
) -> Message:
|
||||
"""Store a message the way sync does (indexer.persist_message), in the
|
||||
Inbox or (``folder="sent"``) the Sent folder."""
|
||||
remote = RemoteMessage(
|
||||
uid=uid,
|
||||
raw=raw,
|
||||
flags=frozenset({"\\Seen"}) if seen else frozenset(),
|
||||
internal_date=at or dt.datetime.now(dt.UTC),
|
||||
)
|
||||
result = await persist_message(
|
||||
self.session,
|
||||
user_id=self.user_id,
|
||||
account_id=self.account_id,
|
||||
mailbox_id=self.sent_id if folder == "sent" else self.inbox_id,
|
||||
mailbox_special_use=folder,
|
||||
remote=remote,
|
||||
)
|
||||
await self.session.commit()
|
||||
assert result is not None
|
||||
return result.message
|
||||
|
||||
|
||||
def raw_mail(
|
||||
subject: str,
|
||||
*,
|
||||
sender: str = "News <news@shop.example>",
|
||||
headers: dict[str, str] | None = None,
|
||||
mid: str | None = None,
|
||||
html: str | None = None,
|
||||
) -> bytes:
|
||||
m = EmailMessage()
|
||||
m["From"] = sender
|
||||
m["To"] = "me@example.com"
|
||||
m["Subject"] = subject
|
||||
m["Message-ID"] = mid or f"<{uuid.uuid4().hex}@shop.example>"
|
||||
for name, value in (headers or {}).items():
|
||||
m[name] = value
|
||||
m.set_content(f"{subject} body")
|
||||
if html is not None:
|
||||
m.add_alternative(html, subtype="html")
|
||||
return m.as_bytes()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mail_ctx(user_ctx: UserCtx) -> MailCtx:
|
||||
s = user_ctx.session
|
||||
account = Account(
|
||||
user_id=user_ctx.user_id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
s.add(account)
|
||||
await s.flush()
|
||||
inbox = Mailbox(
|
||||
account_id=account.id, user_id=user_ctx.user_id, path="INBOX", special_use="inbox"
|
||||
)
|
||||
trash = Mailbox(
|
||||
account_id=account.id, user_id=user_ctx.user_id, path="Trash", special_use="trash"
|
||||
)
|
||||
sent = Mailbox(account_id=account.id, user_id=user_ctx.user_id, path="Sent", special_use="sent")
|
||||
s.add_all([inbox, trash, sent])
|
||||
await s.commit()
|
||||
return MailCtx(
|
||||
client=user_ctx.client,
|
||||
user_id=user_ctx.user_id,
|
||||
session=s,
|
||||
account_id=account.id,
|
||||
inbox_id=inbox.id,
|
||||
sent_id=sent.id,
|
||||
)
|
||||
@@ -391,7 +391,7 @@ async def test_folder_counts(api: ApiCtx) -> None:
|
||||
# All three seeded inbox messages are unread; no drafts yet.
|
||||
resp = await api.client.get("/api/mailboxes/counts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"inbox_unread": 3, "drafts": 0}
|
||||
assert resp.json() == {"inbox_unread": 3, "drafts": 0, "snoozed": 0}
|
||||
|
||||
# Marking one message \Seen lowers the unread count.
|
||||
msg = await api.session.get(Message, api.message_ids[0])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Sentence completion over the API, from the user's sent mail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api import completion as api_completion
|
||||
from app.completion import build_index_job
|
||||
from tests.api.conftest import HDR, MailCtx, raw_mail
|
||||
|
||||
|
||||
async def _suggest(ctx: MailCtx, before: str) -> str | None:
|
||||
resp = await ctx.client.post("/api/completion", json={"before": before}, headers=HDR)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["suggestion"]
|
||||
|
||||
|
||||
async def test_suggests_from_sent_mail(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
queued: list[tuple[Any, ...]] = []
|
||||
|
||||
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
|
||||
queued.append((name, *args))
|
||||
|
||||
monkeypatch.setattr(api_completion, "enqueue_job", fake_enqueue)
|
||||
body = "Hi Anna,\nThe renders will be ready by Thursday evening.\n\nOn Mon Anna wrote:\n> ok"
|
||||
for uid in (1, 2):
|
||||
await mail_ctx.deliver(
|
||||
raw_mail("Renders", sender="me@example.com").replace(b"Renders body", body.encode()),
|
||||
uid,
|
||||
folder="sent",
|
||||
)
|
||||
|
||||
# No index yet: a build is asked for, and the starter phrases still answer.
|
||||
assert await _suggest(mail_ctx, "I look forward ") == "to hearing from you."
|
||||
assert queued and queued[0][0] == "build_index_job"
|
||||
|
||||
assert await build_index_job({}, str(mail_ctx.user_id)) > 0
|
||||
assert await _suggest(mail_ctx, "The renders will be re") == "ady by Thursday evening."
|
||||
assert await _suggest(mail_ctx, "> ok") is None # quoted text was not learned
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Snooze: a conversation leaves the Inbox until a set time, then comes back."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Message, Thread
|
||||
from app.snooze import wake_due_job
|
||||
from tests.api.conftest import HDR, MailCtx, raw_mail
|
||||
|
||||
|
||||
def _in(days: float = 1) -> str:
|
||||
return (dt.datetime.now(dt.UTC) + dt.timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
async def _inbox_ids(ctx: MailCtx) -> list[str]:
|
||||
resp = await ctx.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||
return [t["id"] for t in resp.json()["threads"]]
|
||||
|
||||
|
||||
async def test_snoozed_thread_leaves_the_inbox(mail_ctx: MailCtx) -> None:
|
||||
old = await mail_ctx.deliver(
|
||||
raw_mail("Old"), 1, at=dt.datetime.now(dt.UTC) - dt.timedelta(days=2)
|
||||
)
|
||||
new = await mail_ctx.deliver(raw_mail("New"), 2)
|
||||
c = mail_ctx.client
|
||||
resp = await c.post(f"/api/threads/{new.thread_id}/snooze", json={"until": _in()}, headers=HDR)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
assert await _inbox_ids(mail_ctx) == [str(old.thread_id)]
|
||||
snoozed = (await c.get("/api/threads", params={"view": "snoozed"})).json()["threads"]
|
||||
assert [t["id"] for t in snoozed] == [str(new.thread_id)]
|
||||
assert snoozed[0]["snoozed_until"] is not None
|
||||
counts = (await c.get("/api/mailboxes/counts")).json()
|
||||
assert counts["snoozed"] == 1 and counts["inbox_unread"] == 1
|
||||
|
||||
# Unsnooze: straight back, at the top.
|
||||
await c.post(f"/api/threads/{new.thread_id}/unsnooze", headers=HDR)
|
||||
assert await _inbox_ids(mail_ctx) == [str(new.thread_id), str(old.thread_id)]
|
||||
|
||||
|
||||
async def test_snooze_needs_a_future_time(mail_ctx: MailCtx) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("x"), 1)
|
||||
for until in (_in(-1), _in(400), "2030-01-01T09:00:00"):
|
||||
resp = await mail_ctx.client.post(
|
||||
f"/api/threads/{msg.thread_id}/snooze", json={"until": until}, headers=HDR
|
||||
)
|
||||
assert resp.status_code == 400, until
|
||||
|
||||
|
||||
async def test_due_thread_wakes_on_top_and_unread(mail_ctx: MailCtx) -> None:
|
||||
week_ago = dt.datetime.now(dt.UTC) - dt.timedelta(days=7)
|
||||
snoozed = await mail_ctx.deliver(raw_mail("Snoozed"), 1, seen=True, at=week_ago)
|
||||
newer = await mail_ctx.deliver(raw_mail("Newer"), 2, seen=True)
|
||||
thread = await mail_ctx.session.get(Thread, snoozed.thread_id)
|
||||
assert thread is not None
|
||||
thread.snoozed_until = dt.datetime.now(dt.UTC) - dt.timedelta(minutes=1)
|
||||
thread.snoozed_at = week_ago
|
||||
await mail_ctx.session.commit()
|
||||
|
||||
assert await wake_due_job({}) >= 1
|
||||
assert await _inbox_ids(mail_ctx) == [str(snoozed.thread_id), str(newer.thread_id)]
|
||||
snoozed_id = snoozed.id
|
||||
mail_ctx.session.expire_all()
|
||||
flags = await mail_ctx.session.scalar(select(Message.flags).where(Message.id == snoozed_id))
|
||||
assert flags is not None and "\\Seen" not in flags
|
||||
|
||||
|
||||
async def test_a_reply_wakes_a_snoozed_thread(mail_ctx: MailCtx) -> None:
|
||||
first = await mail_ctx.deliver(raw_mail("Question", mid="<q@x>"), 1)
|
||||
await mail_ctx.client.post(
|
||||
f"/api/threads/{first.thread_id}/snooze", json={"until": _in(3)}, headers=HDR
|
||||
)
|
||||
m = raw_mail("Re: Question", headers={"In-Reply-To": "<q@x>", "References": "<q@x>"})
|
||||
await mail_ctx.deliver(m, 2, at=dt.datetime.now(dt.UTC) + dt.timedelta(seconds=5))
|
||||
|
||||
await wake_due_job({})
|
||||
assert await _inbox_ids(mail_ctx) == [str(first.thread_id)]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Subscriptions: bulk senders worked out from List-* headers, and unsubscribing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.api import subscriptions as api_subs
|
||||
from app.models import Draft, Mailbox, Message
|
||||
from app.subscriptions import backfill_list_headers_job, read_list_headers, unsubscribe_targets
|
||||
from tests.api.conftest import HDR, MailCtx, raw_mail
|
||||
|
||||
NEWS = {
|
||||
"List-Id": '"Weekly news" <weekly.shop.example>',
|
||||
"List-Unsubscribe": "<https://shop.example/u/1>, <mailto:leave@shop.example?subject=stop>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
|
||||
|
||||
def test_read_list_headers() -> None:
|
||||
h = read_list_headers(raw_mail("x", headers=NEWS))
|
||||
assert h.list_id == "weekly.shop.example"
|
||||
assert h.unsubscribe.startswith("<https://shop.example/u/1>")
|
||||
assert h.one_click is True
|
||||
plain = read_list_headers(raw_mail("hi"))
|
||||
assert (plain.list_id, plain.unsubscribe, plain.one_click) == (None, "", False)
|
||||
|
||||
|
||||
def test_unsubscribe_targets() -> None:
|
||||
t = unsubscribe_targets(NEWS["List-Unsubscribe"])
|
||||
assert t.https == "https://shop.example/u/1"
|
||||
assert t.mailto == "leave@shop.example" and t.mailto_subject == "stop"
|
||||
assert unsubscribe_targets("<http://insecure.example/u>").https is None
|
||||
assert unsubscribe_targets("<mailto:a@b.c%0d%0aBcc:x@y.z>").mailto is None
|
||||
|
||||
|
||||
async def _list(ctx: MailCtx) -> list[dict[str, Any]]:
|
||||
resp = await ctx.client.get("/api/subscriptions")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return list(resp.json()["subscriptions"])
|
||||
|
||||
|
||||
async def test_groups_by_list_then_sender(mail_ctx: MailCtx) -> None:
|
||||
await mail_ctx.deliver(raw_mail("Issue 1", headers=NEWS), 1, seen=True)
|
||||
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS), 2)
|
||||
await mail_ctx.deliver(
|
||||
raw_mail(
|
||||
"Sale",
|
||||
sender="Deals <deals@other.example>",
|
||||
headers={"List-Unsubscribe": "<mailto:u@other.example>"},
|
||||
),
|
||||
3,
|
||||
)
|
||||
await mail_ctx.deliver(raw_mail("Hi from a friend", sender="ann@friend.example"), 4)
|
||||
|
||||
subs = {s["key"]: s for s in await _list(mail_ctx)}
|
||||
assert set(subs) == {"list:weekly.shop.example", "from:deals@other.example"}
|
||||
news = subs["list:weekly.shop.example"]
|
||||
assert (news["total"], news["unread"], news["name"]) == (2, 1, "News")
|
||||
assert news["one_click"] and news["has_mailto"] and news["has_web"]
|
||||
assert news["last_subject"] == "Issue 2"
|
||||
threads = await mail_ctx.client.get(
|
||||
"/api/subscriptions/threads", params={"key": "list:weekly.shop.example"}
|
||||
)
|
||||
assert len(threads.json()) == 2
|
||||
|
||||
|
||||
async def test_one_click_unsubscribe(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
|
||||
posted: list[str] = []
|
||||
|
||||
async def fake_one_click(url: str) -> bool:
|
||||
posted.append(url)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
|
||||
resp = await mail_ctx.client.post(
|
||||
"/api/subscriptions/unsubscribe", json={"key": "list:weekly.shop.example"}, headers=HDR
|
||||
)
|
||||
assert resp.json() == {"status": "done", "method": "one_click", "url": None}
|
||||
assert posted == ["https://shop.example/u/1"]
|
||||
(news,) = await _list(mail_ctx)
|
||||
assert news["unsubscribed_at"] is not None and news["still_sending"] is False
|
||||
|
||||
|
||||
async def test_mailto_unsubscribe_and_still_sending(
|
||||
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
enqueued: list[tuple[Any, ...]] = []
|
||||
|
||||
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
|
||||
enqueued.append((name, *args))
|
||||
|
||||
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
|
||||
headers = {"List-Unsubscribe": "<mailto:leave@shop.example?subject=stop>"}
|
||||
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
|
||||
resp = await mail_ctx.client.post(
|
||||
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
|
||||
)
|
||||
assert resp.json()["method"] == "mailto"
|
||||
assert [e[0] for e in enqueued] == ["send_job"]
|
||||
draft = await mail_ctx.session.scalar(select(Draft).where(Draft.user_id == mail_ctx.user_id))
|
||||
assert draft is not None
|
||||
assert draft.payload["to"] == ["leave@shop.example"] and draft.payload["subject"] == "stop"
|
||||
|
||||
# Mail still arriving after the grace period is flagged.
|
||||
later = dt.datetime.now(dt.UTC) + dt.timedelta(days=3)
|
||||
await mail_ctx.deliver(raw_mail("Issue again", headers=headers), 2, at=later)
|
||||
(sub,) = await _list(mail_ctx)
|
||||
assert sub["still_sending"] is True
|
||||
|
||||
|
||||
async def test_web_only_unsubscribe_hands_back_the_page(mail_ctx: MailCtx) -> None:
|
||||
headers = {"List-Unsubscribe": "<https://shop.example/prefs>"}
|
||||
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
|
||||
resp = await mail_ctx.client.post(
|
||||
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
|
||||
)
|
||||
assert resp.json() == {"status": "open", "method": "web", "url": "https://shop.example/prefs"}
|
||||
|
||||
|
||||
async def test_backfill_reads_older_mail(mail_ctx: MailCtx) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("Old issue", headers=NEWS), 1)
|
||||
await mail_ctx.session.execute(
|
||||
update(Message).where(Message.id == msg.id).values(list_unsubscribe=None, list_id=None)
|
||||
)
|
||||
await mail_ctx.session.commit()
|
||||
resp = await mail_ctx.client.get("/api/subscriptions")
|
||||
assert resp.json()["pending"] == 1
|
||||
|
||||
assert await backfill_list_headers_job({}, str(mail_ctx.user_id)) == 1
|
||||
resp = await mail_ctx.client.get("/api/subscriptions")
|
||||
assert resp.json()["pending"] == 0
|
||||
assert [s["key"] for s in resp.json()["subscriptions"]] == ["list:weekly.shop.example"]
|
||||
|
||||
|
||||
async def test_trash_all_leaves_mixed_conversations(
|
||||
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
moved: list[str] = []
|
||||
|
||||
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
|
||||
moved.append(args[0])
|
||||
|
||||
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
|
||||
only = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS, mid="<i1@shop>"), 1)
|
||||
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS, mid="<i2@shop>"), 2)
|
||||
reply = raw_mail(
|
||||
"Re: Issue 2",
|
||||
sender="me@example.com",
|
||||
headers={"In-Reply-To": "<i2@shop>", "References": "<i2@shop>"},
|
||||
)
|
||||
await mail_ctx.deliver(reply, 3)
|
||||
resp = await mail_ctx.client.post(
|
||||
"/api/subscriptions/trash", json={"key": "list:weekly.shop.example"}, headers=HDR
|
||||
)
|
||||
assert resp.json() == {"moved": 1}
|
||||
assert moved == [str(only.thread_id)]
|
||||
|
||||
|
||||
async def test_deleted_mail_is_left_out(mail_ctx: MailCtx) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
|
||||
trash = await mail_ctx.session.scalar(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.account_id == mail_ctx.account_id, Mailbox.special_use == "trash"
|
||||
)
|
||||
)
|
||||
await mail_ctx.session.execute(
|
||||
update(Message).where(Message.id == msg.id).values(mailbox_id=trash)
|
||||
)
|
||||
await mail_ctx.session.commit()
|
||||
assert await _list(mail_ctx) == []
|
||||
|
||||
|
||||
async def test_reading_pane_offers_unsubscribe(
|
||||
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def fake_one_click(url: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
|
||||
news = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
|
||||
plain = await mail_ctx.deliver(raw_mail("Hi", sender="ann@friend.example"), 2)
|
||||
c = mail_ctx.client
|
||||
|
||||
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
|
||||
assert env["subscription_key"] == "list:weekly.shop.example"
|
||||
assert env["unsubscribe_via"] == "one_click" and env["unsubscribed"] is False
|
||||
(other,) = (await c.get(f"/api/threads/{plain.thread_id}")).json()["messages"]
|
||||
assert other["subscription_key"] is None and other["unsubscribe_via"] is None
|
||||
|
||||
resp = await c.post(
|
||||
"/api/subscriptions/unsubscribe",
|
||||
json={"key": env["subscription_key"], "message_id": env["id"]},
|
||||
headers=HDR,
|
||||
)
|
||||
assert resp.json()["status"] == "done"
|
||||
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
|
||||
assert env["unsubscribed"] is True
|
||||
|
||||
# A message without the header can't be used.
|
||||
resp = await c.post(
|
||||
"/api/subscriptions/unsubscribe",
|
||||
json={"key": "from:ann@friend.example", "message_id": str(plain.id)},
|
||||
headers=HDR,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_senders_come_in_pages_newest_first(mail_ctx: MailCtx) -> None:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
for i, name in enumerate(["old", "mid", "new"]):
|
||||
await mail_ctx.deliver(
|
||||
raw_mail(
|
||||
f"From {name}",
|
||||
sender=f"{name}@lists.example",
|
||||
headers={"List-Unsubscribe": f"<mailto:u@{name}.example>"},
|
||||
),
|
||||
i + 1,
|
||||
at=now - dt.timedelta(days=10 - i),
|
||||
)
|
||||
c = mail_ctx.client
|
||||
first = (await c.get("/api/subscriptions", params={"limit": 2})).json()
|
||||
assert first["total"] == 3
|
||||
assert [s["email"] for s in first["subscriptions"]] == [
|
||||
"new@lists.example",
|
||||
"mid@lists.example",
|
||||
]
|
||||
rest = (await c.get("/api/subscriptions", params={"limit": 2, "offset": 2})).json()
|
||||
assert [s["email"] for s in rest["subscriptions"]] == ["old@lists.example"]
|
||||
assert (await c.get("/api/subscriptions", params={"offset": 9})).json()["subscriptions"] == []
|
||||
oldest = (await c.get("/api/subscriptions", params={"limit": 2, "order": "asc"})).json()
|
||||
assert [s["email"] for s in oldest["subscriptions"]] == [
|
||||
"old@lists.example",
|
||||
"mid@lists.example",
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Templates: saved replies from the compose window's More menu."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.api.conftest import HDR, UserCtx
|
||||
|
||||
|
||||
async def test_save_list_replace_delete(user_ctx: UserCtx) -> None:
|
||||
c = user_ctx.client
|
||||
assert (await c.get("/api/templates")).json() == []
|
||||
|
||||
resp = await c.post(
|
||||
"/api/templates",
|
||||
json={"name": " Thanks ", "subject": "Re: offer", "body_html": "<p>Thank <b>you</b></p>"},
|
||||
headers=HDR,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
first = resp.json()
|
||||
assert first["name"] == "Thanks" and first["subject"] == "Re: offer"
|
||||
|
||||
# Same name, any case: replaces rather than adding a second one.
|
||||
resp = await c.post(
|
||||
"/api/templates", json={"name": "thanks", "body_html": "<p>Cheers</p>"}, headers=HDR
|
||||
)
|
||||
assert resp.json()["id"] == first["id"]
|
||||
await c.post("/api/templates", json={"name": "Apology", "body_html": "x"}, headers=HDR)
|
||||
listed = (await c.get("/api/templates")).json()
|
||||
assert [t["name"] for t in listed] == ["Apology", "thanks"]
|
||||
assert listed[1]["body_html"] == "<p>Cheers</p>"
|
||||
|
||||
assert (await c.delete(f"/api/templates/{first['id']}", headers=HDR)).status_code == 200
|
||||
assert [t["name"] for t in (await c.get("/api/templates")).json()] == ["Apology"]
|
||||
|
||||
|
||||
async def test_template_html_is_cleaned(user_ctx: UserCtx) -> None:
|
||||
resp = await user_ctx.client.post(
|
||||
"/api/templates",
|
||||
json={
|
||||
"name": "x",
|
||||
"body_html": '<p onclick="evil()">Hi<script>alert(1)</script>'
|
||||
'<img src="x" onerror="evil()"><a href="javascript:evil()">l</a></p>',
|
||||
},
|
||||
headers=HDR,
|
||||
)
|
||||
html = resp.json()["body_html"]
|
||||
assert "script" not in html and "onclick" not in html and "onerror" not in html
|
||||
assert "javascript:" not in html and "Hi" in html
|
||||
|
||||
|
||||
async def test_template_needs_a_name(user_ctx: UserCtx) -> None:
|
||||
resp = await user_ctx.client.post(
|
||||
"/api/templates", json={"name": " ", "body_html": "x"}, headers=HDR
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tracker blocking (Settings → Privacy): on by default, and it can be switched off."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.api.conftest import HDR, MailCtx, raw_mail
|
||||
|
||||
PIXEL = "https://t.example.com/open.gif?u=42"
|
||||
HTML = f'<p>Hello</p><img src="{PIXEL}" width="1" height="1">'
|
||||
|
||||
|
||||
async def _html(ctx: MailCtx, message_id: object, images: bool = False) -> tuple[str, str]:
|
||||
resp = await ctx.client.get(
|
||||
f"/api/messages/{message_id}/html", params={"images": "1"} if images else {}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.text, resp.headers["X-Trackers-Blocked"]
|
||||
|
||||
|
||||
async def test_trackers_blocked_by_default(mail_ctx: MailCtx) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
|
||||
assert (await mail_ctx.client.get("/api/settings")).json()["block_trackers"] is True
|
||||
body, count = await _html(mail_ctx, msg.id, images=True)
|
||||
assert count == "1" and PIXEL not in body and "Hello" in body
|
||||
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
|
||||
assert env["has_remote_images"] is False # nothing left worth loading
|
||||
|
||||
|
||||
async def test_switched_off_trackers_are_ordinary_remote_images(mail_ctx: MailCtx) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
|
||||
resp = await mail_ctx.client.patch("/api/settings", json={"block_trackers": False}, headers=HDR)
|
||||
assert resp.json()["block_trackers"] is False
|
||||
|
||||
# Still a remote image: not loaded until images are allowed, but offered.
|
||||
body, count = await _html(mail_ctx, msg.id)
|
||||
assert count == "0" and PIXEL not in body
|
||||
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
|
||||
assert env["has_remote_images"] is True
|
||||
|
||||
body, count = await _html(mail_ctx, msg.id, images=True)
|
||||
assert count == "0" and PIXEL in body.replace("&", "&")
|
||||
|
||||
# Back on: gone again.
|
||||
await mail_ctx.client.patch("/api/settings", json={"block_trackers": True}, headers=HDR)
|
||||
body, count = await _html(mail_ctx, msg.id, images=True)
|
||||
assert count == "1" and PIXEL not in body
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Email translation over the API, with LibreTranslate replaced by a stand-in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app import translation
|
||||
from tests.api.conftest import MailCtx, raw_mail
|
||||
|
||||
TEXT = "Hello Lukas, I would like to ask about the project schedule for next month."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_service(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
|
||||
calls: dict[str, object] = {"detect": 0, "translate": 0}
|
||||
|
||||
async def detect(text: str) -> str:
|
||||
calls["detect"] = int(calls["detect"]) + 1 # type: ignore[call-overload]
|
||||
return "en"
|
||||
|
||||
async def translate_html(
|
||||
html: str, source: str, target: str, keep: object = (), names: object = ()
|
||||
) -> str:
|
||||
calls["translate"] = int(calls["translate"]) + 1 # type: ignore[call-overload]
|
||||
calls["keep"] = list(keep) # type: ignore[call-overload]
|
||||
calls["names"] = list(names) # type: ignore[call-overload]
|
||||
assert source == "en" and target == "pl"
|
||||
return "<p>Cześć Łukasz</p><script>alert(1)</script>"
|
||||
|
||||
monkeypatch.setattr(translation, "enabled", lambda: True)
|
||||
monkeypatch.setattr(translation, "detect", detect)
|
||||
monkeypatch.setattr(translation, "translate_html", translate_html)
|
||||
return calls
|
||||
|
||||
|
||||
async def test_language_is_found_once_and_kept(
|
||||
mail_ctx: MailCtx, fake_service: dict[str, object]
|
||||
) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
|
||||
for _ in range(2):
|
||||
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
|
||||
assert resp.headers["X-Content-Language"] == "en"
|
||||
assert fake_service["detect"] == 1
|
||||
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
|
||||
assert env["content_language"] == "en"
|
||||
|
||||
|
||||
async def test_translation_is_cleaned_and_kept(
|
||||
mail_ctx: MailCtx, fake_service: dict[str, object]
|
||||
) -> None:
|
||||
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
|
||||
for _ in range(2):
|
||||
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
|
||||
assert resp.status_code == 200
|
||||
assert "Cześć Łukasz" in resp.text and "alert(1)" not in resp.text
|
||||
assert resp.headers["X-Translated-From"] == "en"
|
||||
assert resp.headers["X-Translated-To"] == "pl"
|
||||
assert fake_service["translate"] == 1 # the second came from the cache
|
||||
# The people on the message are names to keep if the model would change them.
|
||||
assert "News" in fake_service["names"] # type: ignore[operator]
|
||||
|
||||
|
||||
async def test_service_trouble_is_a_clear_error(
|
||||
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch, fake_service: dict[str, object]
|
||||
) -> None:
|
||||
async def broken(
|
||||
html: str, source: str, target: str, keep: object = (), names: object = ()
|
||||
) -> str:
|
||||
raise translation.TranslationError("the translation service is unreachable")
|
||||
|
||||
monkeypatch.setattr(translation, "translate_html", broken)
|
||||
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
|
||||
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "translation_failed"
|
||||
# The message itself still shows.
|
||||
assert (await mail_ctx.client.get(f"/api/messages/{msg.id}/html")).status_code == 200
|
||||
|
||||
|
||||
async def test_off_without_a_service(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(translation, "enabled", lambda: False)
|
||||
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
|
||||
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
|
||||
assert "X-Content-Language" not in resp.headers
|
||||
info = (await mail_ctx.client.get("/api/translation/languages")).json()
|
||||
assert info == {"available": False, "languages": []}
|
||||
Reference in New Issue
Block a user