Sender avatars, PDF password preview, contacts autosave, account-view + Trash/Spam auto-purge, UI polish
Sender avatars (privacy-safe, server-fetched, cached): - New GET /api/avatar resolves Gravatar (d=404) then the sender domain's favicon under the SSRF guard, Redis-cached (hit+miss); gated by SENDER_AVATARS_ENABLED. GuardedResponse gains raw bytes. Shown in the mail list, reading pane, People panel, and Contacts (overlay that falls back to initials on a miss). PDF preview: - Password-protected PDFs now prompt in-place and retry (pdf.js PasswordException), with a clear error for other load failures instead of a blank overlay. The password is remembered per attachment until the thread is left; clicking the field no longer dismisses the overlay. Contacts: - Autosave reply recipients: message.sent now carries to/cc (name+email); the contacts plugin subscribes and adds any non-contact recipients to the Autosaved group. Contact avatars show the gravatar/favicon via /api/avatar. Mail views / retention: - Clicking an account shows all its mail except Trash/Spam (was inbox-only). - Daily cron autopurge_trash_spam permanently deletes Trash/Spam older than TRASH_RETENTION_DAYS (default 30; 0 disables) — server EXPUNGE + local rows. UI polish: - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header and collapsed rail; consistent 11px top margin and vertically-aligned header row across states (fixed-size flex icon buttons); SVG favicon on the browser tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
||||
from webmail_plugin_contacts.hook import on_message_sent
|
||||
from webmail_plugin_contacts.router import router
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
@@ -22,6 +23,7 @@ plugin = Plugin(
|
||||
name="contacts",
|
||||
version="0.1.0",
|
||||
builtin=True,
|
||||
hooks={"message.sent": on_message_sent},
|
||||
router=router,
|
||||
ui_manifest=UiManifest(
|
||||
pages=[
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""message.sent hook: autosave reply recipients as contacts (SPEC App. B).
|
||||
|
||||
When the user sends a reply, its recipients (To/Cc) are people they've now
|
||||
corresponded with, so any who aren't already contacts are added to the default
|
||||
book's "Autosaved" group. Runs in the sync worker wherever message.sent fires;
|
||||
uses the RLS-scoped SDK session, so it can only ever touch this user's rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.plugins.sdk import sdk
|
||||
from webmail_plugin_contacts.router import _ensure_book
|
||||
|
||||
logger = logging.getLogger("plg.contacts")
|
||||
|
||||
_SDK = sdk("contacts")
|
||||
|
||||
|
||||
def _emails_value(email: str) -> str:
|
||||
return json.dumps([{"value": email, "type": "", "pref": True}])
|
||||
|
||||
|
||||
async def on_message_sent(payload: dict[str, Any]) -> None:
|
||||
user_raw = payload.get("user_id")
|
||||
if not isinstance(user_raw, str):
|
||||
return
|
||||
# De-dupe within this send; keep the first display name seen for each address.
|
||||
recipients: dict[str, str] = {}
|
||||
for field in ("to", "cc"):
|
||||
for addr in payload.get(field) or []:
|
||||
if not isinstance(addr, dict):
|
||||
continue
|
||||
email = str(addr.get("email") or "").strip().lower()
|
||||
if "@" in email and email not in recipients:
|
||||
recipients[email] = str(addr.get("name") or "").strip()
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
user_id = uuid.UUID(user_raw)
|
||||
async with _SDK.session(user_id) as session:
|
||||
book_id = await _ensure_book(session, user_id) # also ensures the Autosaved group
|
||||
group_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id FROM plg_contacts_groups "
|
||||
"WHERE user_id = :u AND name = 'Autosaved' LIMIT 1"
|
||||
),
|
||||
{"u": str(user_id)},
|
||||
)
|
||||
).first()
|
||||
autosaved_gid = str(group_row[0]) if group_row is not None else None
|
||||
|
||||
for email, name in recipients.items():
|
||||
already = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT 1 FROM plg_contacts_contacts "
|
||||
"WHERE user_id = :u "
|
||||
"AND (lower(default_email) = :e OR emails @> cast(:match as jsonb)) "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"u": str(user_id), "e": email, "match": json.dumps([{"value": email}])},
|
||||
)
|
||||
).first()
|
||||
if already is not None:
|
||||
continue
|
||||
contact_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO plg_contacts_contacts "
|
||||
"(user_id, book_id, vcard_uid, name_display, emails, default_email) "
|
||||
"VALUES (:u, :book, :uid, :name, :emails, :defemail) RETURNING id"
|
||||
),
|
||||
{
|
||||
"u": str(user_id),
|
||||
"book": book_id,
|
||||
"uid": str(uuid.uuid4()),
|
||||
"name": name or email,
|
||||
"emails": _emails_value(email),
|
||||
"defemail": email,
|
||||
},
|
||||
)
|
||||
).scalar_one()
|
||||
if autosaved_gid is not None:
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
|
||||
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
|
||||
),
|
||||
{"u": str(user_id), "g": autosaved_gid, "c": str(contact_id)},
|
||||
)
|
||||
logger.info("contacts: autosaved %s from a sent message", email)
|
||||
@@ -50,7 +50,12 @@ function initials(name) {
|
||||
}
|
||||
function avatarHtml(name, email, size) {
|
||||
const s = size || 40;
|
||||
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
|
||||
// Overlay the server-resolved gravatar/favicon (if any) on the initials circle;
|
||||
// a miss (404) is removed by the error handler wired in wire(), leaving initials.
|
||||
const img = email && email.includes("@")
|
||||
? `<img class="ct-avimg" data-cav src="/api/avatar?email=${encodeURIComponent(email)}" alt="" aria-hidden="true" loading="lazy"/>`
|
||||
: "";
|
||||
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}${img}</span>`;
|
||||
}
|
||||
function fmtBirthday(s) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
|
||||
@@ -81,7 +86,8 @@ const CSS = `
|
||||
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
|
||||
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
|
||||
.ct-railnew:hover{opacity:.9}
|
||||
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
|
||||
.ct-av{position:relative;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
|
||||
.ct-avimg{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover;background:#fff}
|
||||
.ct-listcol{flex:0 0 auto;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
|
||||
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
||||
@@ -700,6 +706,11 @@ export default function mount(el, ctx) {
|
||||
|
||||
function wire() {
|
||||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||||
// Drop avatar images that fail to resolve (no gravatar/favicon) so the initials show.
|
||||
el.querySelectorAll("img[data-cav]").forEach((img) => {
|
||||
img.addEventListener("error", () => img.remove());
|
||||
if (img.complete && img.naturalWidth === 0) img.remove();
|
||||
});
|
||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false); };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user