From f8da552cfe38e493b43f8c8b0960055fb366e14e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 29 Sep 2026 02:06:54 +0000 Subject: [PATCH] contact messages: mail server setting, browser notifications for new messages The mail server field is plumbing only (mailbridge URL in settings + editor UI); actual email sending is deferred. Browser notifications: permission is asked on the user's first send, then the visitor is notified of admin replies while not viewing the thread and the admin of new visitor messages while the inbox is open. --- AGENTS.md | 2 +- docs/messages.md | 15 +++++++-- frontend/src/MessagesView.vue | 16 ++++++++++ frontend/src/SiteEditor.vue | 58 ++++++++++++++++++++++++----------- frontend/src/contact-main.js | 11 +++++++ frontend/src/notify.js | 33 ++++++++++++++++++++ pagerite/api.py | 4 +++ pagerite/data.py | 4 +++ 8 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 frontend/src/notify.js diff --git a/AGENTS.md b/AGENTS.md index 7826759..3946b37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). - `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown). - - `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected only while open). + - `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected only while open). Both sides notify new messages via the browser Notification API (`notify.js`), permission asked on first send. - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly. - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. diff --git a/docs/messages.md b/docs/messages.md index d6b60fa..48302fe 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -13,7 +13,7 @@ existing WebSockets; there is no REST polling anywhere. count and the `/_contact/files` upload route. - `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`) and the site settings (`contact_label` — which doubles as the on/off - switch — `contact_email`, `contact_subject`). + switch — `contact_email`, `contact_subject`, `contact_mailserver`). - `pagerite/tracking.py` — the sockets the protocol rides (see below). - `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin icon badge seed — the probe runs on every page load, no polling) and the @@ -55,8 +55,17 @@ trigger buttons, no visitor corner icon, sends rejected; admins keep the inbox for existing threads) — `contact_email` (a mailto link in the visitor dialog: the recipient is addressed `Brand `, the link text the bare address, the subject prefilled) and `contact_subject` -(defaults to the label). Saves re-render the page in place, so button -and dialog strings update without a reload. +(defaults to the label). `contact_mailserver` is a mailbridge URL +(`smtp://user:pass@host:port`, a bare `host[:port]`, or a backend URL +like `sendgrid:API-KEY`) reserved for email notifications — the actual +sending is not implemented yet. Saves re-render the page in place, so +button and dialog strings update without a reload. + +Both sides also get browser notifications (the Notification API, +`frontend/src/notify.js`): permission is asked when the user sends their +first message, then the visitor is notified of new admin replies while +not viewing the thread and the admin of new visitor messages while the +inbox is open; clicking a notification focuses the tab. The two strings localize like titles: translations live in the chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue index 66e7933..00c0227 100644 --- a/frontend/src/MessagesView.vue +++ b/frontend/src/MessagesView.vue @@ -13,6 +13,7 @@ import { formatWhen } from './analytics/format.js' import Composer from './Composer.vue' import ConnNote from './ConnNote.vue' import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' +import { askNotifyPermission, messageText, notifyMessage } from './notify.js' // The shared store (contact-main): adminUnread mirrors the inbox's unread // count onto the corner icon's badge. @@ -31,6 +32,8 @@ const reconnects = reconnectPolicy() let mounted = false let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail let hiddenAt = 0 +// 'threads' full-state pushes are the baseline, history never notifies. +const seenLastMsg = new Map() // The inbox is live data over its socket: while it is connecting, waiting // to reconnect or logged out, say so (ConnNote) instead of showing a @@ -56,6 +59,15 @@ function upsertThread(thread) { threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a))) } +function notifyThread(t) { + const last = t.messages[t.messages.length - 1] + const prev = seenLastMsg.get(t.id) + seenLastMsg.set(t.id, last?.id || '') + // prev undefined = a brand-new thread, which should notify. + if (!last || last.admin || prev === last.id) return + notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html)) +} + function wsSend(obj) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)) } @@ -81,7 +93,9 @@ function connect() { // The type-less snapshot is analytics data, not for this view. if (msg?.type === 'threads') { threads.value = msg.threads + for (const t of msg.threads) seenLastMsg.set(t.id, lastMsg(t)?.id || '') } else if (msg?.type === 'thread') { + notifyThread(msg) upsertThread(msg) } else if (msg?.type === 'contact-error') { console.warn('contact:', msg.detail) @@ -256,6 +270,8 @@ function preview(t) { const fmtTime = (t) => formatWhen(t) function send(md) { + // First reply doubles as the notification-permission gesture. + askNotifyPermission() if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md }) } diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 551137e..bf5e2e3 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -62,17 +62,22 @@ const brand = ref('') const theme = ref('') // --- Messaging (contact) --------------------------------------------------- -// One-line contact settings. The label (the {contact:} buttons' label and -// the visitor dialog's title) doubles as the on/off switch: empty = -// disabled. The label and email subject are per-language strings — the -// LangSelect (the article editor's flag dropdown, on the shared shell-wide -// editorLang) switches the two fields between the original and a -// translation override (stored in the chunk-keyed trans store; auto -// translators fill the same store, so this is the manual-override UI). -// The email is global; its reveal after the PoW solve is server-side. +// Contact messaging settings: a two-row grid (labels above, the flag +// selector and the boxes below). The label (the {contact:} buttons' +// label and the visitor dialog's title) doubles as the on/off switch: +// empty = disabled. The label and email subject are per-language +// strings — the LangSelect (the article editor's flag dropdown, on the +// shared shell-wide editorLang) switches the two fields between the +// original and a translation override (stored in the chunk-keyed trans +// store; auto translators fill the same store, so this is the +// manual-override UI). The email and mail server are global: the +// email's reveal after the PoW solve is server-side, and the mail +// server (a mailbridge URL, docs/messages.md; empty = no email) is +// reserved for email notifications. const contactEmail = ref('') const contactLabel = ref('') const contactSubject = ref('') +const contactMailserver = ref('') const contactLabelOrig = ref('') const contactSubjectOrig = ref('') // Password managers offer credential autofill on these boxes no matter @@ -184,6 +189,7 @@ async function loadSettings() { transition.value = s.transition || 'cube' transitionOptions.value = s.transitions || [] contactEmail.value = s.contact_email || '' + contactMailserver.value = s.contact_mailserver || '' contactLabelOrig.value = s.contact_label || '' contactSubjectOrig.value = s.contact_subject || '' if (!translating.value) { @@ -360,6 +366,7 @@ async function saveSettings(opts = {}) { brand_html: brandHtml.value, transition: transition.value, contact_email: contactEmail.value, + contact_mailserver: contactMailserver.value, // The originals: while a translation override is being edited the // input refs hold the override, not the original. contact_label: contactLabelOrig.value, @@ -775,7 +782,7 @@ onUnmounted(() => { + @@ -964,15 +986,15 @@ onUnmounted(() => { article editor's flag selector and the boxes on the second, so the flag centers with the boxes and every label sits directly above its own field (contact button label = the enable switch, email subject, - the mailto "our email" last, where the planned SMTP address joins - it). The flag column sizes to the flag; when only one language is - configured the selector renders nothing and the column collapses. + then the email group: our email + mail server). The flag column sizes + to the flag; when only one language is configured the selector renders + nothing and the column collapses. The boxes are readonly until first interaction (+ vendor ignore attributes): password managers offer credential autofill on them no matter the autocomplete setting. */ .msg-grid { display: grid; - grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: auto repeat(4, minmax(0, 1fr)); align-items: center; gap: 0.15rem 0.6rem; } diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index db0bfd4..31e3d2d 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -10,6 +10,7 @@ import { createApp, reactive, watch } from 'vue' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import ContactOverlay from './ContactOverlay.vue' +import { askNotifyPermission, messageText, notifyMessage } from './notify.js' const TOKEN_KEY = 'pagerite-contact-token' const SEEN_KEY = 'pagerite-contact-seen' @@ -116,6 +117,8 @@ async function submitProof() { } function sendMessage(md, name = '', email = '') { + // First send doubles as the notification-permission gesture. + askNotifyPermission() unsent = { md, name, email, retried: false } bridge.send({ token, msg: md, ...(name && { name, email }) }) } @@ -173,6 +176,8 @@ export function refreshBadges() { icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread) } +let notifyBaseline = '' // the first push after load is history, only baselines + function onThread(thread) { state.thread = thread unsent = null // the send landed (a thread push acknowledges it) @@ -180,6 +185,12 @@ function onThread(thread) { if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t) const viewing = state.open && state.mode === 'visitor' state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing + // ISO timestamps compare lexicographically. + if (lastAdmin && lastAdmin.t > notifyBaseline) { + const fresh = Boolean(notifyBaseline) + notifyBaseline = lastAdmin.t + if (fresh && !viewing) notifyMessage(state.label, messageText(lastAdmin.html)) + } if (viewing) markSeen() else refreshBadges() } diff --git a/frontend/src/notify.js b/frontend/src/notify.js new file mode 100644 index 0000000..3933d86 --- /dev/null +++ b/frontend/src/notify.js @@ -0,0 +1,33 @@ +// Browser notifications for contact messaging (docs/messages.md): the +// visitor is notified of admin replies, the admin of new visitor +// messages. Permission is requested from the user's first send (the +// Notification API requires a user gesture), at most once ever — a +// dismissed prompt leaves the permission "default", and the flag stops +// us from nagging on every later send. +const ASKED_KEY = 'pagerite-notify-asked' + +export function askNotifyPermission() { + if (!('Notification' in window) || Notification.permission !== 'default') return + if (localStorage.getItem(ASKED_KEY)) return + localStorage.setItem(ASKED_KEY, '1') + Notification.requestPermission() +} + +// Fire a notification (a no-op without a granted permission). Clicking +// it focuses the site tab. +export function notifyMessage(title, body) { + if (!('Notification' in window) || Notification.permission !== 'granted') return + const n = new Notification(title, { body, icon: '/favicon.ico' }) + n.onclick = () => { + window.focus() + n.close() + } +} + +// The visible text of a server-rendered strict-markdown message (safe to +// parse: the stored html is markdown-it output, never user markup). +export function messageText(html) { + const div = document.createElement('div') + div.innerHTML = html + return (div.textContent || '').trim() +} diff --git a/pagerite/api.py b/pagerite/api.py index a117cd0..bddef77 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -306,6 +306,7 @@ async def get_settings(lang: str | None = None) -> dict: "contact_label": data.contact_label, "contact_email": data.contact_email, "contact_subject": data.contact_subject, + "contact_mailserver": data.contact_mailserver, # The site default primary language: the front page's resolved # setting (every page may override it, inherited down the tree). "primary_lang": i18n.primary_lang(data.menu, ""), @@ -334,6 +335,7 @@ class SettingsIn(BaseModel): contact_label: str | None = None contact_email: str | None = None contact_subject: str | None = None + contact_mailserver: str | None = None @router.put("/_api/settings", status_code=204) @@ -376,6 +378,8 @@ async def put_settings( data.contact_email = settings.contact_email.strip() if settings.contact_subject is not None: data.contact_subject = settings.contact_subject.strip() + if settings.contact_mailserver is not None: + data.contact_mailserver = settings.contact_mailserver.strip() if settings.translate_langs is not None: # Any language may be a target — including the site default # (an article in another language can be translated INTO it); diff --git a/pagerite/data.py b/pagerite/data.py index 71a2c4a..37c3fd8 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -224,6 +224,10 @@ class Data(msgspec.Struct): #: Subject for the mailto link; empty = the (localized) label. #: Translated like contact_label. contact_subject: str = "" + #: Mail server for email notifications (docs/messages.md): a mailbridge + #: URL — a bare host[:port] or smtp://user:pass@host:port for plain + #: SMTP, or a backend URL like sendgrid:API-KEY. Empty = no email. + contact_mailserver: str = "" def node_markdown(data: Data, node: Node) -> str | None: