- 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>
81 lines
3.3 KiB
Python
81 lines
3.3 KiB
Python
"""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)]
|