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,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",
|
||||
]
|
||||
Reference in New Issue
Block a user