mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
main
349
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
13303b247e |
v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches
A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit
|
||
|
|
3b78d277d7 |
A send that fails for a passing reason is retried; one that finally fails is marked on the draft (#242)
Every sender turned every failure into `false`, so a one-minute Gmail rate limit or a short outage lost the send like a refused recipient. The three senders now classify through MailDeliveryInterface::deliver(): deferred (rate limits, 5xx, a connection that never opened, SMTP 4xx), refused (other 4xx, SMTP 5xx, too large) or outcome unknown (a lost answer, 502/504). send() stays a bool for invitations, alerts and read receipts. SendMessageHandler retries a deferral for at most two minutes, keeping the provider's Retry-After. A lost answer is never resent blind: Gmail and Graph look for the Message-ID in Sent first (now committed before the first attempt), SMTP reports it as possibly sent. A final failure, from the handler or from SendGaveUpSubscriber when the ladder runs out, marks the draft, words the toast by its reason, and shows a red warning triangle at the start of the draft's row and on Drafts in the sidebar. Migration: four nullable columns and a partial index on message. Closes #125 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cdd0cd4fd8 |
The installed app's icons follow the chosen logo, drawn by the browser (#229)
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141. Co-authored-by: mani <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f3a2bb2a4a |
Delete a folder-backed label's folder on the server, after moving its mail (#224)
* Delete a folder-backed label's folder on the server, after moving its mail Deleting a label that is a folder on IMAP or Outlook left the folder on the server, and the next sync made the label again. The delete dialog now asks where the folder's mail goes (Inbox, Archive, Trash or another folder); a background job moves it, deletes the folder once the server reports it empty (children first), and removes the label last so no sync can resurrect it. A folder that never empties is left alone and the job says which. Also: an Outlook category delete now addresses the category id (it used the folder id and never reached Outlook), and nested labels are propagated too. Closes #40 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Label delete: end at once on a refusal, show what it waits for - A server that refuses the folder delete (IMAP NO/BAD or a dropped connection, Graph/Gmail 4xx) ends the job immediately with its reason instead of retrying for ten minutes; throttling and 5xx are still waited out. - While waiting for a folder to empty, the jobs indicator names the folder. - The destination Archive/Trash is created on the IMAP server before any mail moves, so the moved rows point at it; before, they kept pointing at the folder being deleted and the job waited on them until it gave up. - A folder plMail creates starts with nothing to import, so the indicator no longer shows it as "Importing" until the next sync. - A folder still being imported cannot be deleted yet; the dialog says so. - confirm-dialog.spec drives the account delete form, as the label delete no longer uses a browser confirm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a6f8181c0d |
What's new: one X on a full-size screenshot (#197)
The picture brought its own X into the corner of the dialog body, just below the modal's X. It is gone; while a picture is up, the modal's X is relabelled "Close the screenshot" and its first press puts the picture back, the second closes What's new, as Escape already did. Closes #187 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0277f72b29 |
What's new: a screenshot grows to fill the dialog instead of opening a tab (#181)
Clicking a highlight's screenshot opened the PNG in a new tab. It now grows out of its thumbnail until it fills the What's new dialog, with an X; the X, Escape or Back shrink it back, and What's new stays open. Back goes through a history entry of its own (assets/history_layer.js), imported first in app.js so its popstate listener runs before Turbo's, which would otherwise restore the page and close the dialog. Closes #179 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0fd9e84a11 |
What's new: the ? menu above the calendar, main builds, and feedback with a screenshot (#167)
* The ? menu draws above the calendar, and a main build announces its newest release The menu was an ordinary dropdown inside the header's z-20 stacking context, so the calendar's day headings painted over it. It now opens in the top layer through ui--user-menu, like the account menu, and closes when an item is chosen. A build that is not a tag (main, sha-…, a checkout) counted as no release, so installs that follow main never saw the dialog. It now counts as the newest tagged release in its own changelog; Unreleased is still never shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Feedback with a screenshot stays in the dialog, whatever comes back Sending with a screenshot closed the dialog without the sent screen. The form is now posted with fetch() and its answer drawn into the dialog; anything that is not the dialog (a proxy refusing a large body, a server error) is said in the form with its status, and the dialog stays. The screenshot is a JPEG at most 1600 px wide instead of a full-size PNG, a fraction of the size, so a reverse proxy's default body limit is far away. The From is the first field of the account that is an address; an account with none is refused in the form instead of throwing at send time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Find the open conversation's toolbar by type in the keyboard-shortcuts spec The spec looked for the conversation by id alone, and the messages inside it carry the same attribute with their own ids. On CI a message id equalled the conversation's, so two elements matched and the strict locator failed on the first try and the retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
994c00b5cd |
What's new opens after an update, with a way to send feedback (#166)
* What's new opens after an update, with a way to send feedback The release notes from CHANGELOG.md, read from the image, in a dialog that opens by itself the first time somebody uses plMail after an update and never on a fresh install. A ? in the top bar opens it again, with a dot while something is unseen; "Don't show this after updates" silences it, and Settings -> General -> What's new undoes that. Highlights are marked in the changelog with an HTML comment and may carry a screenshot taken by tests/e2e/whats-new-screenshots.spec.ts. Send feedback is a mail from one of the person's own accounts to APP_FEEDBACK_TO (feedback@pluetzner.de by default), with an optional rating and a screenshot of the page behind the dialog. Existing users are recorded as having seen 0.3.1 by a migration; anyone created later starts on the release running at their first request. Closes #165. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Plain changelog headlines from 0.3.2 on, and highlights with screenshots Every bold headline users see under "New to you" now says what changed in a few words. Each release gets a handful of highlights, eight of them with a picture cropped to the card or pane they are about, taken by tests/e2e/whats-new-screenshots.spec.ts. Relative links in the notes keep their words and lose the target, and the parsed changelog's cache key carries a format version so a new image never serves the previous one's parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep highlight pictures for ten releases, and none that nothing names Every file in public/whats-new ships in every image. ChangelogReaderTest now fails on a picture no highlight names, and on one whose release is more than ten back; the release pull request that crosses the line deletes it and leaves the entry a highlight without a picture. Documented in CONTRIBUTING.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Credit #166 in the changelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Match the Back button by its whole name in the back-navigation specs Playwright matches a string name as a substring, so "Back" also found the new "Help and feedback" button in the top bar, which comes first, and the specs opened its menu instead of going back to the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
550dd9cf89 |
Pull the mail list down to sync on a phone (#163)
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged. Takes over #115. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
19266223e8 |
Choose a profile picture through the compose file picker (#65)
Settings → Profile opens the same file picker as attaching to a mail, in an avatar mode: albums, people, search and paging, one picture at a time, files that cannot be a picture greyed out. The choice is posted straight back to the profile form, and the connection is matched against the user's own. From Paperless-ngx the original is taken rather than the archived PDF. The setup wizard keeps its small grid for now. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fdbe79c231 |
Keep a message header on three lines in a narrow pane (#73)
Below 36rem of pane width the date, the menu and the arrow move onto a row of their own under the sender, so the sender gets the full width. A container query on the message list, so it follows the pane, not the window. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1b04e697f6 |
All Mail: every conversation outside Spam and the bin, as an opt-in system label (#57)
Archived mail had no list to be browsed in, only search. All Mail lists every conversation outside Spam and the bin (what arrived, what was sent, drafts and archived mail), as Gmail's All Mail does, across the reader's active accounts or within one. It is LabelRole::AllMail, a view and not a place, like Unread: nothing attaches it to mail, it is created hidden when label settings is opened, it is bound to no account and it refuses moves. Its eye in Settings → Mail → Labels shows or hides both the row beside Drafts and the row at the top of each account's folders. Archiving or snoozing from it leaves the row in place. One list scope, `all_mail`, with the account id as its value or empty for every account; AllMailView holds the rules the row buttons, the bulk toolbar, Move to and select-all ask. No migration. Co-authored-by: shady2k <3366415+shady2k@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
549ddefb20 |
Take a list refresh asked for during another, instead of dropping it
bulk-select-all's "the pager agrees with the rows after a bulk action" failed now and then under load. Not the job: it archived the 67 conversations in six seconds. A nudge from the job started a list refresh while it was still writing, the job's "done" arrived while that fetch was out, and _refreshList() returned early because one was already in flight. The fetch came back without the archive, and nothing was left to refresh the list — it showed the archived conversations until something unrelated happened. A request that arrives during a refresh is now remembered and taken once the current fetch lands, keeping the most urgent of whatever asked in the meantime. list-refresh-queue.spec.ts holds the first fetch at the network so the second request always arrives during it; it fails without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
aa735e88a0 |
Settings → Categories: one page for the inbox tabs
What sorts mail into tabs and the tabs themselves were two cards under General. They are their own page now, in the Mail group: the sorting card, then every tab on one list in the order the inbox draws them. - Built-in tabs carry an On/Off switch. Off releases conversations somebody dragged into the category and queues the re-sort the other sorting choices queue; on queues it too, and the mail comes back. - The reader's own tabs keep rename and delete. - Everything but Primary is dragged into place, or moved with buttons from a keyboard or a phone, as on the account list. Primary sits above the list: always first, never off. The order endpoint keeps only keys the reader has, so a request cannot plant a key naming somebody else's category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7898182e00 |
Merge branch 'pr-87' into review-87
# Conflicts: # docs/de/features/mail.md # docs/de/install/config-backup.md # docs/de/internals/mail-ingest.md |
||
|
|
5550727888 |
AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent rester synchronisés (le commentaire de translation.yaml le dit). Le sélecteur de langue des réglages et celui de l'installation lisent AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre modification. Deux réglages suivent la langue et devaient connaître le français : - ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures par défaut comme l'allemand. Sans ça, un utilisateur francophone qui n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest. - Le sélecteur d'emoji charge ses noms et mots-clés par langue (la recherche « cœur » ne trouve rien dans les données anglaises). assets/emoji/fr/data.json vient de emoji-picker-element-data, comme en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet pour octet à ceux publiés par le paquet, donc fr/ vient bien de la même source. Le gabarit choisit maintenant de, fr ou en d'après la langue au lieu du seul test « starts with 'de' ». Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le contenu des mails selon leur langue, et non la langue de l'interface. Apprendre les dates françaises (« le 4 août ») est un autre chantier. Docs : README, features/appearance.md (et sa traduction allemande, avec le hash mis à jour), et une entrée de CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni |
||
|
|
855d0faedc |
Draw pasted secrets as masked text fields instead of password inputs
Reported from Admin → SSO after the previous fix: Chrome no longer filled the saved login in on load, but a click in the client secret still listed every saved password, Bitwarden offered to generate a password into it, and Bitwarden opened its menu in "Name on the button" and "Scopes" below. All of it follows from the field being type="password", and three rounds of attributes each moved the symptom. `off` is ignored by Chrome on a password field. `new-password` stops the fill and makes Bitwarden read the form as a sign-up. The vendor opt-outs stop extensions filling and not Bitwarden's menu opening. So client secrets, API tokens and mail-server passwords are a new SecretType: a text input drawn as dots by CSS, never rendered back, not trimmed. With no password input in the form there is nothing to classify. The sign-in form, the first administrator's password and the change-password form stay real password inputs, where offering to save is the point. Bitwarden's menu in the two text fields had a second cause: they were the only ones with no `autocomplete` at all, and the two that said `off` were left alone. The form theme now says `off` for every text field that asks for nothing. PasswordManagerIgnore::SECRET goes; nothing uses it. The test added with the previous fix is replaced by two: a secret renders as masked text and comes back empty, and no form outside the three login ones uses PasswordType. Checked in a browser that the field masks and the frame holds no password input. Not checked: Bitwarden and Chrome's password manager themselves, which this environment does not have. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6eaa33a534 |
Tell the sidebar whenever mail changes, instead of when a button remembers to
The badges are patched from /mail/sidebar/counts, and what made a tab ask was a list: the sync's hub events, and announceWrite() called by each controller that wrote. Whatever was not on the list had a stale badge. Measured in a browser (#86): a draft autosaved, the server counted 1, the badge stayed at 0, and in forty seconds the page made no counts request at all. The composer saves through a Turbo form on a timer and had no controller to call from; the send that follows is a worker's, ten seconds later, and a mail read on a phone is another client's. No browser code can announce those. Every change to a user's mail already passes one place, StateManager, because a phone's /changes has to be complete. That is now what tells the browser too: X-Mail-Changed: 1 on the response to any request that recorded a change (MailChangedHeaderSubscriber). mail_writes.js reads it off every fetch and every Turbo request and announces the write mail.changed on the hub, from the drain that already tells the phones (MailChangeNotifier), for a change this tab did not make state:sequence the change sequence the counts were read at, in the counts answer and on the rendered sidebar. A tab ignores a nudge that is not newer The sequence is what keeps it to one request per change. The hub's nudge for a tab's own write was measured arriving before that tab's counts request had left, so the comparison waits 400 ms and for whatever is on the wire. announceWrite() gathers calls for 120 ms, since a write is now announced by the header and, where one exists, by its controller. Opening a conversation marked it read every time, read or not: seenAt was replaced, the provider told again and a JMAP change recorded. With the header that became a counts request behind every click on read mail. ThreadStatusUpdater::markRead() now writes only the messages that change, keeps the first seenAt, and records nothing when nothing moved. One query more on a page that draws a sidebar (the sequence); none on a list-frame navigation. Not exercised end to end here: the worker's own send. The test stack's send transport is in-memory, so the browser test for "a change made elsewhere" uses a second tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
76bf97c82e |
Answer from the address a mail came in for, under its own name, and take a signature as HTML
Three things about who a mail is from, grouped as v0.3.5. The sender name (#70). EmailAlias::$displayName was shown in the From selector and published as the JMAP identity name, and nothing sent it: MessageSendService built From with the account's name whatever the address. There was also no field to set it in the browser. Account::senderNameFor() the alias's name, else the account's; the selector, the draft row and the From header all read it Settings -> Aliases a sender name field under each address EmailSubmission/set writes fromName with the address it resolves The address a reply goes out from (#41, the reply half). Every window was pre-set from the account alone, so a reply to mail for an alias opened on the primary address, and a draft saved from an alias reopened on the primary and was stored that way by the next autosave. ReplyFromResolver the first of From (own mail), To, Cc, then Delivered-To / X-Original-To / Envelope-To that the account may send as SenderResolver::tokenFor() opens a draft on the address it carries catch-all domains an account setting, not an alias row: any address at one may be answered from, is left out of a reply-all, and signs with the account signature. ComposeType admits such a token on submit by the same rule. Not offered for Gmail and Microsoft accounts. Typing an arbitrary From into a new message is not in this. Signatures as HTML (#32). Pasting source into the contenteditable gave the source back, and a linked image never showed, the page's img-src being 'self' data: blob:. Rendered | HTML a textarea beside the editor. Rendered asks the server (signature/preview) and shows what a save would keep, with the elements and attributes the sanitiser dropped named underneath SignatureImporter sanitises, and fetches https images through ImageProxyFetcher into data: sources, 256 KB each and 768 KB a signature; the rest stay linked and are counted DataImageEmbedder on send, a data: image becomes an inline part and a cid: reference, since Gmail and Outlook draw no data URI. It reads the entity-encoded form the sanitiser stores (= for the padding). No migration: the catch-all domains live in Account::$settings and are carried by a config backup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
73ee0525b9 |
Update the open background-work list where it stands, so it does not flash
Re-reading the indicator replaced the whole frame, dropdown included, and opened the new dropdown again. Now that an import re-reads it every five seconds, somebody watching saw the list go away and come back for as long as they looked. With the list open the new rows are fetched and put into the list that is already there, and the button's label and icon are changed only where they differ. The frame is still replaced when the list is shut, and when there is nothing left to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0235d9f4d3 |
Re-read the import line on a timer, so its figures move while watched
The indicator is re-read when the server publishes, and an import publishes when a page of its listing is done. A Gmail import spends most of its time fetching what the listing named and says nothing meanwhile, so the count, the messages a minute and the seconds until Google lets it continue all stood still. While the indicator shows an import it is now re-read every five seconds with the list open and every thirty with it shut; never in a hidden tab, and never when nothing is importing. The import line's own test spent more than the pacer's reserve and was made to wait eight seconds for it; it spends less. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
74e5e28984 |
feat(rules): add a "File in category" filter action
A filter can now file the mail it matches under one of the reader's own categories. RuleCategoryFiler decides whether it may: only a message whose sender passes authentication (a forged From line must not sit in a tab named after the real sender), only into a category the rule's owner owns, and, on a Gmail label, only when the reader asked plMail to overrule the provider. A refusal leaves the message to the ordinary rules. When two filters match, the one that runs last decides. The editor offers the action only to somebody who has a category (or whose rule already carries one), so nobody else's editor changes. Ownership voting, the plain-English restatement and the three catalogues learn the new action. RuleActionExecutor takes the filer as a constructor argument, so the one test that builds it by hand passes a real one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
2a765d8b32 |
Let the calendar button skip the split view or the full-width calendar (#80)
The button in the top bar is a three-way switch: mail, the calendar beside the mail, the calendar full width. Settings → Calendar → Behaviour has one choice, Calendar button, that can make it a two-way one: All three (the default), Full width only, or Split only. The position the button leaves out is not removed: a stored one closes to the mail on the first press, and the drag handle still reaches both ends. A mail link clicked while the calendar is full width goes back to the mail when the split is skipped. One value in the user's settings bag (calendar.switch_skip), included in the config backup. Nothing is added to the database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6c719b3327 |
Give the mail row two lines between a phone's three and a wide list's one (#62)
A row went to one line once the list was 36rem wide, which with the reading pane open left the subject about 120px. One line now starts at 75rem. Below that the row is two lines, sender and subject then the preview across the whole row, and under 36rem the three it always was. The hover actions show on any pointer that can hover, not only in one-line rows. A scheduled draft keeps its snippet beside the badge from 36rem. With the two-line preview setting on, a one-line row no longer wraps its preview. Co-authored-by: Walid Mani <Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f19818fa50 |
Put the Delete forever buttons behind a Disposal setting, off by default (#71)
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off. A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again. The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load. The choice lives in the user's settings bag and a config backup carries it. No migration. Continues #56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
99d2da3da2 |
Show each inbox tab's unread count, with a setting to switch it off (#54)
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync. This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where. On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ff1344ceb9 |
Keep the open message open when the list beside it changes (#63)
With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message being read. All of those swap one Turbo frame, and the mail pane answered every swap by showing the list, which is right while the message covers the list and wrong beside it. The swap still adopts the new list's URL, so the back arrow returns to the folder that was switched to. With one pane at a time nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
88197578d5 |
Let search by meaning use a model host of its own
One host held both models. The two jobs often want different machines: writing wants the largest model somebody can run or rent, and search wants a small one that is asked constantly, for every query and once for every message of a mailbox. Writing from a hosted endpoint with search on an Ollama at home indexes the mailbox on a machine the operator owns and bills nothing per message, and one host for both could not express it. Admin → AI has a tick box that gives search its own provider, address, token and extra headers. AiSettings::connectionFor() returns that connection for search when the box is ticked, and nothing else had to change, because every call already asked for the connection of its feature. The second host is sent only its own token and headers, and none when it has none. A feature is usable when the host it runs on is filled in. Test asks both hosts, each for the model that lives on it. Stored vectors stay filed under the model name alone, so moving search to a host that serves the same model keeps the index and no stored row is touched. Five columns on ai_settings, unused until the box is ticked; the migration is reversible. The idea of a separate embedding connection comes from #58. Its vector identity, which hashed the address into every stored row, is left out: it forced a full re-embed whenever a host changed its address. Co-authored-by: shady <shady2k@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3c7a03b843 |
Talk to an OpenAI-compatible endpoint as well as Ollama
The language model had to be an Ollama container. Admin → AI now has a Provider field, and the second choice is the request format OpenAI published and nearly everything else has copied: llama.cpp, vLLM, LM Studio, LiteLLM, the hosted providers, and Ollama itself under /v1. OpenAiClient speaks three paths, /chat/completions, /embeddings and /models, and answers in the value objects and error categories OllamaClient already uses. AiAssistant reads the connection for the feature being served and picks the client; nothing above it knows there are two. The body carries only what the format defines, because several servers answer 400 to a field they do not know. The stream is read a line at a time, skips comment lines, does not require [DONE], and fails on an error object delivered inside a 200. Redirects are never followed. What only Ollama can be asked is not asked of the other dialect: no keep-alive is sent, the composer is not told to expect a cold start, Warm up refuses with its own reason, and the panel reads no /api/ps. The page puts those controls away while the other provider is chosen and keeps their values. A stored token is dropped when the provider changes, as it is when the address does. One column on ai_settings, defaulting to ollama; the migration is reversible. The SSE handling, the strict reading of an embedding and the rule that a key stays with its endpoint come from #58. Co-authored-by: shady <shady2k@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
493ac0dbcc |
Only use the bundled emoji font where the browser can draw it
The emoji picker was almost empty on an iPhone and in Safari: two of nine category tabs, a handful of emoji scattered through a blank grid. The compose body names the same family, so emoji typed there were at risk of the same (#39). Noto Color Emoji is vendored as COLRv1, which WebKit does not draw. Safari loaded the font, found the code point in it and painted nothing, and a browser does not fall through to the next family for a glyph it believes it has. The faces are now declared inside `@supports font-tech(color-COLRv1)`. Where that is false the two families do not exist, and the lists that name them fall through to the system's emoji. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
896f30eb02 |
Import an account's history without holding up new mail
A first sync used to be the import: every IMAP folder from its oldest message to its newest, every Gmail message listed before one was fetched, every Microsoft folder enumerated to its end, all in one job on the queue new mail arrives on. A large account showed an empty Inbox for most of an hour and every other account's mail waited behind it (#42). The sync now only arranges for new mail to be seen. History is MailImporter's, a page at a time on a new `ingest_backlog` queue with a worker of its own, newest mail first and the Inbox before anything else; Spam and the bin come last. Accounts take turns, a page that fails costs itself, and every sync restarts an import that has gone quiet. - IMAP walks each folder down from the top in pages of fifty. - Gmail asks for the newest 200 of the Inbox, then pages the listing. - Microsoft reads each folder's first delta round a page at a time; a folder being read is asked only what it received since. Removals that cannot be judged while a folder is unread are kept and judged later. - Sync requests a later sync has already covered are skipped. - A chip beside the search box shows that an import is running and how far it has got. The indicator never updated on mail pages, because its controller was not mounted there. Also from #42: an account can have a separate password for sending, behind a button under the SMTP fields. One field by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
24d3c19b1d |
Choose which IMAP folders to sync (#52)
Mailbox::$isSyncEnabled already decides whether a folder is polled, held
with IDLE and counted by the remote-deletion check, but nothing let a
person change it. Settings → Accounts has a button on every IMAP
account's row that opens the account's folders, each with a switch.
Switching one off stops plMail fetching it; mail already stored stays,
and switching it back on resumes from where it stopped.
The inbox is listed with its switch disabled, and the controller refuses
it too. Gmail and Microsoft accounts sync over their provider's API and
have no folder rows, so neither route exists for them. The path carries
two ids: the account is checked for ownership, then the folder against
that account, then a per-folder CSRF token.
Review added, in the same change:
Tree the list is drawn as the tree the paths describe, with
connector lines, each folder under its nearest listed
ancestor.
Placeholders a \Noselect folder is greyed out with no switch to
press. Mailbox::$isSelectable records what the server
said; one migration adds the column.
Dialog a narrow modal preset and striped rows.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
|
||
|
|
d919d02bee |
Show the open message beside the list, as a per-user option (#49)
A per-user Reading pane setting, Off or Right. Right shows the message
list and the open message side by side when the mail card is at least
52rem wide, with a divider that is dragged, moved with the arrow keys or
reset with a double-click. The message's share of the card is stored as
a percentage, 25 to 75, so it keeps its meaning when the calendar pane
opens or is resized. Off is the default and changes nothing.
The mode and the share live in the user's settings bag, not in the
Appearance embeddable: where a message is drawn is not part of a theme
somebody might export. A config backup carries both. No migration.
A message opened by its own address has no list behind it, so beside
the list it is shown next to the Inbox.
Review added, in the same change:
Divider one pixel of layout with the grip laid over it, so no
strip of bare card sits between the list and the line.
Toolbars the list's and the open message's are the same height.
Selection Archive, Delete and Move to stay above a selection; the
other six actions are under More.
Shortcuts a key acts on ticked rows wherever the list is on
screen, and ticking a row with the mouse no longer
leaves every key after it dead.
Settings a Features group with a page each for the reading pane
and the keyboard shortcuts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
cb34fa8e28 |
Let the category tabs scroll instead of overlapping on a phone (#53)
The tab strip let every tab shrink (min-w-0) while keeping its label on one line, so on a narrow card the labels ran underneath the next tab's icon. Tabs now keep their own width and the strip scrolls inside itself, with tighter padding below the list's @xl breakpoint. A small Stimulus controller puts the current tab in view, since the strip is re-rendered on every tab change and would otherwise reset to the left edge. The strip has no visible scrollbar and a wheel only reports vertical movement, so with a mouse on a narrow window the tabs past the edge could not be reached (touch and a trackpad's sideways swipe already worked). A vertical wheel over the strip now scrolls it sideways, and hands the wheel back to the page once the strip has run out in that direction. Covered by a Pixel 5 e2e spec: no tab is squeezed, the last tab is reachable by scrolling, the current tab is in view on load, and the wheel scrolls the strip until it runs out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1bae047d77 |
Snooze the open conversation from its toolbar, and with b
Every row offered a snooze menu and the conversation itself did not, so "not now" after reading a mail meant going back to the list to find its row. It was also the last key in #36 with nothing to press: b did nothing in an open conversation. The toolbar gets the clock button and the same menu, handled by mail--message-actions. Snoozing closes the pane, as archiving does, since the conversation has left the list it was opened from; clearing a snooze leaves the reader where they are. Offered where the row offers it, not for mail in the bin or in Spam. The menu opens from the button's left edge there. It opened from the right everywhere, which suits a button at the end of a row and would run off the pane from one at the start of a toolbar. Refs #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7d54acf4d2 |
Star a selection from its toolbar, and with s
Every shortcut was tried in every context after a turned out to press a link that is not always there. One more had the same shape: s stars a row through the row's own star, and the toolbar above a selection had no star at all, so with rows ticked the key found nothing. The toolbar gets a Star button, posting the bulk star a drop on Starred already uses. It stars and does not toggle, since a selection is a mix of both, and is not offered to a whole-view selection, which that endpoint refuses. Refs #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7c0cf33dfd |
Answer the a shortcut on a message with one recipient
Reported from the demo: r and f worked in an open conversation and a did not. The Reply-all link is only drawn for a message with more than one recipient, and a shortcut presses the control that is there, so on every other message a found nothing and did nothing. It falls back to Reply, as Gmail does. Every test fixture has a single recipient, which is why the specs for #36 never met a Reply-all link either way. Refs #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c6f0f42357 |
Add Gmail's keyboard shortcuts
j and k move a cursor through a list, o opens, u goes back, x selects, e archives, # deletes, ! is spam, s stars, Shift+i and Shift+u mark read and unread, l v b open label, move and snooze, r a f reply, c composes, / searches, g then a letter goes to a list, z presses Undo and ? lists all of it. A key presses the control the page already has and does nothing else. ui--shortcuts archives nothing itself: it finds the toolbar's Archive, the row's star, the Reply link, and clicks it. The request, the Undo toast and the refusal where an action is not offered are the button's, so a shortcut cannot drift from the button it stands for. What it acts on, first that exists: the selection through the list toolbar, the open conversation through its own, the row under the cursor through the row's buttons. Label, move and snooze have no button on a row, so the row is ticked and the toolbar's menu opened. The cursor is an attribute repainted from a remembered id, not focus: rows are replaced by every refresh, and focus would be dropped to body each time. Keys are left alone while something is being typed into, while a dialog or a menu is open, and with Ctrl, Alt or the Command key held. A message body is a sandboxed frame with a document of its own, so its script relays the facts of a keydown to the parent; otherwise the shortcuts stopped the moment a mail was clicked on. On for everybody, off per person under Settings, General. Off is the controller not being mounted, so there is no flag for the script to forget to read. Stored in the settings bag as a false only, so no migration, and carried by config backups. Closes #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
de87014d10 |
Confirm a copied error reference with a tick that exists
The copy button in an error toast drew its icon in Font Awesome's regular style. ui--clipboard confirms by exchanging the glyph class alone, fa-copy for fa-check, and the free set has no regular fa-check: the confirmation was an empty box. Every other copy button was already fa-solid, which has both. The copy itself always worked. Fixes #33 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8f7d2cb073 |
Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back after the next sync on IMAP, is left for an issue of its own: there a label is a folder, and what deleting one should do to the mail in it needs deciding first. A selection survives the pager. It was nothing but the ticked checkboxes, so "next page" replaced the rows and the selection with them. The rows that are not on screen are now remembered in assets/mail_selection.js, per list, and the toolbar, the label menu, the move menu and a drag all read the selection from there. The checkbox stays the truth for anything visible. The whole view can be selected from the select menu. It was only offered by a banner that appears once a page is fully ticked. And a whole-view selection no longer survives a row being unticked: the action still ran on everything. A sync no longer logs a stored message as one it could not build. MessageSyncer reads the UIDs a folder holds once, before it fetches, and a move made meanwhile files rows under UIDs that list does not have. The INSERT was refused on (mailbox, uid), rightly, and each message was then held back as a failure. The list is read again when a batch is refused. A pairing code and the JMAP address under App passwords carry the configured public address, and the browser's only when none is set. This reverses the earlier choice in DevicePairingController, which handed a phone paired at home a LAN address. Hiding quoted text gives the height back. scrollHeight cannot report less than the frame it is measured in, so the collapse reports the root element's box instead and holds other reports until the parent has resized. Mail in Sent shows its images without asking. Read off the Sent label and not the From line, which a forged mail fills in as it likes. Spam still wins. Smaller: placeholders are lighter than typed text, the actions on a connected integration are buttons on one baseline, Save signature and four composer buttons no longer hover to white on white, the Spam badge loses the accent, and German says "vor 12s". Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
34f6ec5951 |
Read a recipient's name from the chip the app actually draws
Reported from the demo: a template inserted into a reply to Priya Raman kept its "First name" placeholder, with her name in To. recipientValues() parsed `Name (address)`. That is how Contact::__toString() spells a contact, and it is not what the To field shows: ContactAutocompleteField's choice_label writes `Name <address>`. So no chip for a named contact ever parsed, and neither the name nor — for those contacts — the address was filled in. Only an address typed by hand worked, because that chip is the bare address. It now reads the angle-bracket form first and still accepts the parenthesised one, and strips the quotes address syntax puts round a name with a comma in it. The browser test for this passed all along, because it wrote its own chip label in the format the parser expected: it agreed with the code and not with the app. It now uses the real format, and the reply test asserts the first name of a recipient the SERVER put in To, which is a label no test chooses. The preview's sample recipient in the three catalogues was in the same wrong format and is corrected with it. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0e3bf1cfe8 |
Put what a toolbar button inserts on the line, not inside its line break
Reported as "in a reply the template does not load". The request succeeded, the picker closed and the reply stayed empty. A reply opens with the caret parked by _focusCursorAtTop() at offset 0 of the first node of the first block, and in a fresh reply that node is the <br> of `<p><br></p>`. _insertAtCaret() inserted at the range it was given, so the node went INSIDE the <br> — where the DOM holds it and nothing renders or serialises it. Typing never showed it, because the browser moves the caret out of a <br> before it inserts a character. A script has to do that itself, and now does: a range that starts in an element which cannot have children is moved to just before it. It was not only templates. The same path serves the emoji picker and inline images, and an emoji picked in a reply nobody had clicked into has been vanishing the same way since before templates existed. That one gets its own test. Both tests read innerText on purpose. textContent, which is what Playwright's toContainText compares by default, includes text that sits inside a <br> — the first version of the reply test passed on the unfixed code for exactly that reason. One thing found and left alone: compose-link-popover's "leaves ordinary typing alone" fails on this machine with and without this change, and with the compose files as they were before templates. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bf3e1e8336 |
Serve templates to JMAP clients
A new capability, urn:plmail:params:jmap:templates, and five methods.
Template/get|set and TemplateFolder/get|set read and write what
Settings → Templates does, through the same library and the same
sanitiser: a template written on a phone is inserted into the web
compose window too. They are per user and take no accountId argument;
accountId is a property saying which mail account an object is filed
under. State is a hash of the list, as Appearance's is, because the
change log is keyed by mail account and these are not.
Template/render is the one a client inserts through. The stored body
still has its {{tokens}} in it, and turning them into values needs
the user's clock, the signature rules and the token grammar — three
things that would each be a second implementation in an app. The
client names the account it writes from and, if it has one, the
recipient, and gets a subject, an HTML and a text body, and the list
of recipient variables nobody could answer.
Filling the recipient on the server is a second implementation of a
rule the web runs in the browser, and both ends now say so. The web
keeps doing it client-side because it must not put a request inside a
keystroke; a native app already asks the server for every template
and has the recipient as data. TemplateRecipientFillerTest holds the
cases the two have to agree on.
TemplateLibrary::deleteFolder now removes the subfolders through
the entity manager as well as the
cascade, so a request destroying
several folders is not handed a
managed object for a deleted row.
The Session lists the variables and date formats an editor may
offer. No app uses any of this yet.
Refs #26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
09600802db |
Let a message be written once and inserted as a template
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.
A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.
The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.
TemplateTokens the notation, read the same way in PHP and in
assets/compose/template_variables.js
TemplateRenderer what each variable becomes, and who fills what
TemplateLibrary the tree, and the only writer of where a
template or folder sits
One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.
Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.
Refs #26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
4656f8874e |
Add a spam button that can also filter the sender or their domain
A button beside Delete — on a list row, in an open conversation and above a selection. On one conversation it opens a menu: move it to Spam, or move it and create a filter for the sender, or for the sender's whole domain. The domain filter asks once more first, and for a mail provider's domain it sits behind "More options". The filter is an ordinary rule under Settings → Filters, a second press on the same sender makes no second one, and Undo takes back the move and a filter the same click created. The filters match on two new conditions, fromAddress and fromDomain, which compare the whole address or the whole domain. `from` is a substring over address and display name, so a filter for a@x.de built on it also caught ba@x.de. Both are in the filter editor and in JMAP Email/query. Refs #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2dfc9b3b9b |
Give the admin panel a page for how long things take
The line under the hold switch in Admin → AI said "typically 6 s, 95%
within 13 s": enough to see that one message took twice as long as the
rest, and nothing about why. System says whether plMail is working;
nothing said how long anything took.
Admin → Performance, directly below System:
How late mail arrives per account, provider-received to stored.
Seconds is push working; minutes is the
quarter-hourly poll, which "works" and so
showed up nowhere. Imports and plMail's own
sent copies are not arrivals.
What each worker has the queue numbers by the process that owns
waiting them, a worker's queues in the order it
looks at them. Live; the transport keeps no
history.
New mail waiting for one row per held message, its wait split
the assistant three ways: queued, the model call, and how
much of the call was the model loading.
Model host moved from Admin → AI, unchanged.
To split a wait, the three parts have to be recorded with the message:
ai_queue_ms, ai_call_ms, ai_load_ms. ai_call_metric has the same
numbers and no idea which message they were for, and its timestamps are
the database's clock where the message's are PHP's, so a join by time
is not one to rely on. AiAssistant::ask() returns the whole result for
that; chat() is ask()?->content.
The model host's card and the search index backfill were one fragment
polled by one endpoint. They are about different things — a measurement
and a job with Start and Pause on it — so the fragment renders either
half, each page names the one it wants, and a caller that names neither
gets both, as before.
BackgroundProcesses::consumers() is the worker-to-queue map, read by
all() to start the processes and by this page to label them. They were
two facts that had to agree.
Rows name accounts. Nothing about any mail's content is shown.
Not looked at in a browser; the E2E card-inset spec covers the section
and has been given its frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
6ef9c07986 |
Stop a background request deciding where signing in takes you
Three things a test install of main turned up. None is from the ingest
change; all three were there before it.
The two-factor form could hang on "Checking…". The code had been
accepted — a reload showed the inbox. While the code is outstanding the
form's own layout still fetches things, the two-factor bundle turns each
one away, and its handler remembers every GET it turns away as the
destination, the last one winning. LoginFormAuthenticator has filtered
that since the avatar report; the bundle's handler never did. When the
remembered URL was /mercure/auth, which answers 204, the browser stayed
on the form. It showed on an install whose hub was down, because a
failing connection is what makes the page keep asking for that cookie.
DocumentNavigation the test both places now share, and a Turbo
Frame is refused first: it asks for text/html
and is not a page
TwoFactorRequired the bundle's handler with that one condition
A fresh install had no live updates until the container next restarted.
The public address is saved by the install form, the hub's browser
address is derived from it, and the web server is one kernel in worker
mode that booted before either existed. Every page was told to connect
to https://localhost, which its own Content-Security-Policy refuses.
The install now ends by restarting the web process, on the page Admin →
Restart uses. That page waited a fixed six seconds; it asks /healthz
until something answers.
A provider still rate-limiting after five retries was a CRITICAL with
two stack traces and a parked job, whatever the job. For
SyncAccountMessage that is noise — the next sync resumes from a cursor
that did not move. For the message batches it hid a real loss: they are
queued after the cursor has moved, nothing lists those messages again,
and the parked job was the only copy. GiveUpQuietlyOnThrottling drops
the first with a warning and puts the second back for a quarter of an
hour with a fresh set of retries, ten times, before it may fail.
Not verified: the restart at the end of install runs only where the web
process is PID 1, so it needs the built image. The hub failing on the
test box was a root-owned volume left by an older install, not a bug.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
464df70473 |
Move a conversation to a label, and take it out of the list it was in
Issue #25. Filing mail the way Gmail does took two actions here: the label button adds a label and leaves the mail where it is, and a drop on a folder needs a pointer and a sidebar in reach. "Move to" is one click, beside the label button, in the list toolbar and in the reading pane: the target goes on and the label of the list being looked at comes off. The client names the view; the server decides what that removes. A body that lists labels to strip is not an instruction the route has. Inbox target on, archived (Archive on, Inbox off) — or into the folder, where the target is one a label that label swapped for the target Trash, Spam target on, out of the bin, not into the inbox anywhere else target on, Inbox off where it was on to Inbox restore(), then the view's label off to Trash, Spam the existing trash and spam paths, not a label swap MoveToService composes ThreadStatusUpdater's operations rather than writing labels itself, inside one transaction, because each of those carries provider behaviour that took a bug report to get right. Only the messages that carry the leaving label lose it: a sent reply in an inbox thread gains the target and is not archived. The issue asks for "tag(s)", plural. This is Gmail's single target; several labels are still what the label button is for. Undo came with it, for archive and labels as the issue asks, and for the drag onto a folder since the mechanism was there. It remembers each message's labels before the action, in the session, and puts exactly those back — not the inverse of the action, which is wrong for a mixed selection. A whole-view action runs in a worker and has none. Three things fell out: - The toolbar's label menu posted once per ticked row. With a toast and an Undo on the answer that was five of each, so it posts the selection once now, to /status/bulk/label, which learned `attach`. - Inbox, Spam and Trash are asked for by role. Their labels are made lazily, so a picker that carried ids had no Trash row for anybody who had never deleted anything. - Font Awesome keeps its folder-with-arrow in Pro, so the button is an inline SVG. Not verified against a live Gmail or Exchange account: the tests pin local label state and the IMAP jobs that are queued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8a7d56862c |
Read the time when the clock connects, so Back does not show an old one
The clock only wrote the time when the minute turned. A page the browser's Back button restores carries the reading it was made with, and showed it until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e6868482c1 |
feat(calendar): answering an invitation shows the meeting in the calendar
Yes or Maybe redrew the card and put a toast in the corner, and the calendar pane beside it went on showing the week without the meeting until a reload: the response replaced the card and nothing told the pane. calendar--invite makes the calendar part of the answer, in three layers that each work without the next: - Preview. Hovering or focusing Yes or Maybe outlines the slot the meeting would take, dashed, in its calendar's colour, at every motion level. The slot is worked out in the browser from the card's UTC instants on the grid's own clock (data-grid-zone), full width; the real block takes its lane when it arrives. - Flight. At Full motion the pressed answer arcs to that slot and turns into the meeting on the way, or shrinks into a dot on the calendar button when the pane is closed. It reads --motion-travel, a new MotionLevel token: 580ms at Full, zero at Minimal and None and under prefers-reduced-motion. DESIGN.md names it the second exception to the 260ms ceiling, beside new mail. - Redraw. Once the server has answered, the pane is fetched and morphed in place with Idiomorph, since a pane embedded at page load has no src to reload, and the real block is lit where the placeholder stood. A screen reader hears where it went. Nothing waits on the iTIP reply the server sends first: the button takes its answer at once and the flight lands on a placeholder the real block replaces. A failed request takes all of it back. A meeting answered Maybe is striped in its calendar's colour with a dashed edge, in every view, and says "Maybe" in its accessible name. Nothing drew a tentative meeting differently before. Only the reader's own answer stripes it, not an event's STATUS. app:test:seed-invite hangs an unanswered invitation on seed-mail's "E2E Read Me". calendar-invite.spec.ts answers it at Full (Yes flies, no reload) and under reduced motion (Maybe outlines a striped slot, nothing flies, the block is striped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |