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,76 @@
|
||||
"""Snooze endpoints: hide a conversation from the Inbox until a set time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import snooze
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.events import get_event_bus
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import Thread
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["mail"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class SnoozeIn(BaseModel):
|
||||
until: dt.datetime
|
||||
|
||||
|
||||
class SnoozeOut(BaseModel):
|
||||
thread_id: uuid.UUID
|
||||
snoozed_until: dt.datetime | None
|
||||
|
||||
|
||||
async def _thread(db: AsyncSession, user_id: uuid.UUID, thread_id: uuid.UUID) -> Thread:
|
||||
thread = await db.scalar(
|
||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
|
||||
)
|
||||
if thread is None:
|
||||
raise_api_error(404, "not_found", "thread not found")
|
||||
return thread
|
||||
|
||||
|
||||
async def _announce(user_id: uuid.UUID, thread_id: uuid.UUID) -> None:
|
||||
await get_event_bus().publish(
|
||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{thread_id}/snooze", response_model=SnoozeOut)
|
||||
async def snooze_thread(
|
||||
thread_id: uuid.UUID, body: SnoozeIn, user: CurrentUser, db: DbSession
|
||||
) -> SnoozeOut:
|
||||
until = body.until
|
||||
if until.tzinfo is None:
|
||||
raise_api_error(400, "invalid_time", "the time needs a time zone")
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
if until <= now:
|
||||
raise_api_error(400, "invalid_time", "pick a time in the future")
|
||||
if until > now + snooze.MAX_SNOOZE:
|
||||
raise_api_error(400, "invalid_time", "snooze for at most a year")
|
||||
thread = await _thread(db, user.id, thread_id)
|
||||
snooze.snooze(thread, until, now)
|
||||
await db.commit()
|
||||
await _announce(user.id, thread_id)
|
||||
return SnoozeOut(thread_id=thread_id, snoozed_until=thread.snoozed_until)
|
||||
|
||||
|
||||
@router.post("/{thread_id}/unsnooze", response_model=SnoozeOut)
|
||||
async def unsnooze_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> SnoozeOut:
|
||||
thread = await _thread(db, user.id, thread_id)
|
||||
if thread.snoozed_until is not None:
|
||||
snooze.wake(thread, dt.datetime.now(dt.UTC))
|
||||
await db.commit()
|
||||
await _announce(user.id, thread_id)
|
||||
return SnoozeOut(thread_id=thread_id, snoozed_until=None)
|
||||
Reference in New Issue
Block a user