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 d009ff72ad)

* linked-issue: read the description of a pull request into a milestone branch (#254)

GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* The release pull request is the suite run behind a tag (#252)

Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.

The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.

Closes #251

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)

The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).

The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.

calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.

And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.

Closes #253

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Close an issue when its pull request is merged into a milestone branch (#260)

GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview the reply, not the quote, and send replies with their quote visible (#258)

The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.

The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.

Closes #257

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Move a thread's unread count when JMAP marks a message read or unread (#267)

EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.

Closes #266

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a message that is already in the destination folder where it is (#270)

Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.

Closes #269

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hide a conversation from the lists only when all of it is in the bin (#272)

A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.

Closes #268

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Let a long message scroll in the expanded compose window (#273)

expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.

Closes #262

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Write each changelog entry as a file of its own, collected at release (#275)

Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Say which step failed when notifications can't be turned on (#278)

Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.

Closes #277

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)

A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.

Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.

Closes #157
Closes #236

Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Install APCu and Xdebug with PIE instead of from PECL (#286)

PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep contact photos where the web container can serve them (#287)

Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Match a contact's email addresses in any case (#288)

Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open a contact's reference email in the mail pane (#294)

A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open mail beside the full Contacts page without a reload (#297)

The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.

The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.

The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview image and video attachments on click, like PDFs (#295) (#298)

Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.

Closes #295

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Erase a message's parts with it, and delete mail one way (#300)

MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.

The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.

Closes #299

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)

New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep spam off the radar and out of search; add a Not spam button (#308)

Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.

Closes #304

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)

Closes #309
Closes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the pictures a review request hid, dropped or never asked for (#274)

* Show the pictures a review request hid, dropped or never asked for

A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.

- The logo is linked as http://. The sanitizer keeps http sources and
  leaves them to render time, the fetcher only connects over TLS, and
  nothing in between upgraded the URL, so every http image became the
  placeholder. RemoteContentBlocker now signs the https:// form. The
  fetcher's own rules also say in the log which one refused a target;
  until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
  took the photo, the name and the star links with it. The form is
  unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
  .d {display:block !important}. CssToInlineStyles keeps an inline
  property whatever the rule says, so the card stayed hidden next to its
  Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
  plain inline style, and matches selectors against forms as the divs
  the reader will see, so `form .a` rules take the sender's fallback
  for clients without forms.

  app:backfill safe-html-hidden-cards

re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.

Closes #271

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mark the German mail page as translated from the current English one

Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit the image proxy entry and keep it as a changelog fragment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>

* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)

On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.

Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)

CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.

Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer an address book another connection already mirrors locked, not as new (#319)

Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.

Closes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)

Closes #203
Closes #205

- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Gmail sync: look up only the listed ids, not every stored one (#321)

Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Store whether a conversation is in the bin, kept by triggers (#322)

Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Account sync: wait out a provider that can't be reached, quietly (#333)

When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.

WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.

Closes #332

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 11:18:21 +02:00
1 parent 41248d0f23
commit 13303b247e
456 files changed
+46760 -1355

No files matched your search

+1 -1
View File
@@ -21,5 +21,5 @@ come without one if this description says what the bug was.
- [ ] PHPStan and the suites pass (`npm run test:unit:docker`, `npm run test:e2e:docker`)
- [ ] New or changed copy is in all four catalogues: `messages.en`, `.de`, `.fr`, `.en_PI`
- [ ] Handbook updated if behaviour changed, with the German page and its `translated-from` hash
- [ ] An entry under `## Unreleased` in CHANGELOG.md for anything a user would notice
- [ ] A changelog entry in `changelog.d/<issue>-<words>.md` for anything a user would notice (not in CHANGELOG.md itself; see `changelog.d/README.md`)
- [ ] A setting that changes how plMail behaves for everybody is off by default, or says why not
+19 -6
View File
@@ -12,11 +12,14 @@ name: E2E
# v0.2.16 — counted with `npx playwright test --list`, which is worth doing
# before reasoning about shard counts from a number in a comment.)
#
# The trade, stated so it is a decision rather than a surprise: `main` is no
# longer verified commit by commit. A regression pushed to main is found when a
# release is tagged, which may be several commits later, and the tag is then the
# thing that fails. Run it by hand with `workflow_dispatch` before pushing
# anything you are unsure of — or locally, which is faster than either:
# Pushes to main are not run, and that costs nothing since main became
# protected (2026-10-15): every commit there comes from a pull request whose
# jobs below are required checks. That includes a release, whose `Release vX.Y.Z`
# pull request is the suite run behind its tag (see CONTRIBUTING.md, Releasing).
# The protection does not require a pull request to be up to date with main, so
# two that pass apart can still fail together. Run it by hand with
# `workflow_dispatch` on any branch you are unsure of — or locally, which is
# faster than either:
#
# npm run test:unit:docker && npm run test:e2e:docker
#
@@ -56,8 +59,18 @@ on:
# Kept, and cheap: a pull request is somebody proposing a change, which is
# exactly when a second opinion from the suite is worth having, and there
# are few of them.
#
# `wip/**` as well: a feature too big for one pull request is built on a
# `wip/` branch and reviewed in phases, each phase a pull request into that
# branch (issue #28 is the first). Those merges are where the phases meet,
# so they get the same second opinion a pull request into main does. A
# push to a wip branch still does not — the pull request is the check.
#
# `milestone/**` too: a release's work is collected on a milestone branch
# (`milestone/v0.4.0`) before it reaches main, and a pull request into one
# is the same proposal a pull request into main is.
pull_request:
branches: [main]
branches: [main, 'wip/**', 'milestone/**']
workflow_dispatch:
# Six runners per push adds up, so a superseded pull request stops paying for
+85
View File
@@ -3,10 +3,95 @@
Notable changes per release. plMail runs its migrations automatically on boot,
so anything that changes the schema irreversibly is called out explicitly.
Changes not yet released wait in [`changelog.d/`](changelog.d/), one file each, and are collected
here by the release.
The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, `milestone-vX.Y.Z` the work collected for that
release before it reaches `main`, and `sha-…` pins one commit.
## Unreleased
**A conversation with one message in the bin stays in your other lists, and so does new mail in it.**
Deleting one message of a conversation took the whole conversation out of the Inbox, its tab, its
labels, All Mail, Unread and search, because the conversation then carried the Trash label. A reply
that arrived in it afterwards, by its references or by its subject, was unread mail the phone app
showed and the web did not. A conversation now leaves those lists only once every message of it is
in the bin. One that you deleted whole still leaves its labels, as before. A partly deleted
conversation is listed in Trash too, and opening it still shows the deleted messages with the rest.
No schema change. [#268](https://github.com/karatektus/pl_mail/issues/268).
**A long message scrolls in the expanded compose window.** Expanded, the editor stayed 170px tall
and cut a longer message off mid-line, with nothing to scroll. It now fills the window and grows
with the text, and the window scrolls. A short message still fills the window, so clicking under
the text still puts the cursor there. The plain-text field fills the expanded window as well.
[#262](https://github.com/karatektus/pl_mail/issues/262).
**The month view says what is on a day.** An entry in a month cell was its time on one line and its
title cut to a single line under it, which at a normal width left "Lea…" and "Ihre…", and for an
invitation a lone "…". It is now the title first, on up to two lines, and under it the time it runs
and where. In a narrower day the end time and the place go so the title gets a third line, long
words are hyphenated, and an invitation you have not answered has a dashed bar. On a phone, where a
day is too narrow for words and its entries are bars, the grid makes room for the chosen day listed
in full underneath: today at first, then whichever day you tap. The list has a **+** that adds an
event on that day, and the toolbar's **New event** does too. On a touch screen with room for words,
the first tap on a day opens it and the next opens an entry. No schema change.
[#253](https://github.com/karatektus/pl_mail/issues/253).
**The calendar's toolbar and the bar at the top fit a phone.** On a 320–375px screen the toolbar
ran off its card and the top bar's icons slid over the logo. The toolbar now switches to icons and a
short date ("October", "Oct 20–26") when it is narrow, and the top bar tightens and moves **Sync
now**, **What's new** and **Send feedback** into the user menu, below a divider of their own.
[#253](https://github.com/karatektus/pl_mail/issues/253).
**Dialogs are the whole screen on a phone**, as the compose window is, with less padding, so the
event editor's times, day buttons and repeat menu fit. [#253](https://github.com/karatektus/pl_mail/issues/253).
**The red now line moves, and the calendar turns over at midnight.** The current-time line in the
day and 7-day views was drawn when the page loaded and stayed there, and the month's circle on
today stayed on yesterday until something reloaded the page. The line now moves every minute, and
when the date changes the view redraws itself. [#253](https://github.com/karatektus/pl_mail/issues/253).
**A new event on a day that has gone stays on that day.** Pressing **+** on a past day opened the
editor on today, because a past date was pulled to the next hour. It now keeps its date, at the hour
it would have had; today and future days are unchanged.
[#253](https://github.com/karatektus/pl_mail/issues/253).
**A reply sent from plMail shows its quote to the person who gets it.** The composer folds the
quoted mail under a reply out of the way while you write, and sent it still folded: the "show
quoted text" button was taken out, but the quote kept the `display: none` that hid it. So a reply
sent without opening the quote reached Gmail, Outlook and Apple Mail users with the history
invisible, and in plMail the "···" opened onto nothing. Replies go out unfolded now, and mail
already received that way opens properly in plMail. Copies already delivered to other mail
programs stay as they went out. No schema change. [#257](https://github.com/karatektus/pl_mail/issues/257).
**The preview line in the list shows what the newest mail says.** A short reply ("Passt!")
previewed as the mail it quoted, often your own, because the preview skipped short lines to find
the first long one and landed in the quote. The quoted history and a `-- ` signature are cut off
first now, in HTML and plain-text mail alike. Only bulk mail (promotions, updates, social) still
skips its "View in browser" lines; a person's mail starts at its first line, greeting included.
The Android and iOS apps get the same preview. No schema change.
[#257](https://github.com/karatektus/pl_mail/issues/257).
**A message marked read or unread in the phone app is shown that way on the web too.** Marking a
message read or unread over JMAP changed the message but not its conversation's unread count, which
is what the web list draws in bold from, so a message marked unread on the phone stayed read in the
browser and one marked read there stayed bold. It now goes the same way as the web's own read
button, and so does an automatic read receipt where the mailbox sends one. Marking a single message
of a longer conversation read or unread on the web also set the conversation's count from that one
message alone; it is now counted from all of them. A conversation whose count is already wrong is
put right the next time one of its messages is marked. No schema change.
[#266](https://github.com/karatektus/pl_mail/issues/266), by @phdelodder.
**Trashing a conversation with one message already in the bin no longer fails.** On an IMAP account,
the message that was already in the Trash was sent to the server as a move from Trash into Trash,
which the server refused with "NO Not permitted in EXAMINE state". The rest of the conversation
moved, but the refusal was logged as an error and recorded on the account as a change the server
would not take, and the binned message lost its address until the next sync found it again. A
message that is already where a trash, archive or restore sends it now stays put and keeps its
address; its labels change as before. No schema change.
[#269](https://github.com/karatektus/pl_mail/issues/269), by @phdelodder.
## v0.3.8 — 2026-10-21
**The image runs PHP 8.4.27, which fixes CVE-2026-109618 and CVE-2026-109619.** Every earlier
+4
View File
@@ -1241,6 +1241,10 @@ Bold the symptom in the user's words, then explain the cause and what changed:
Each release states its migration and deployment impact up front —
`No schema change, no deployment change.` — because migrations run automatically on boot.
A pull request writes its entry as a file of its own (`changelog.d/266-jmap-read-state.md`), and
the release collects them into the changelog with a script. Pull requests that each add a paragraph
at the top of one file conflict with each other in turn, and each resolution re-runs CI.
### 11.4 Commit messages
**Subject: an imperative sentence describing the change's effect, no prefix, no scope, no ticket.**
+46 -18
View File
@@ -24,7 +24,10 @@ in the meantime. An issue costs a paragraph; a rejected pull request costs the w
plMail wants it, where it lives, and whether it is a setting. A setting that changes how plMail
behaves for everybody starts off. **A bug fix may come straight as a pull request** if its
description says what the bug was; an issue is still welcome. A large feature — a new provider, a
new section of the app — gets a short design in its issue before any code.
new section of the app — gets a short design in its issue before any code. **One issue per
feature, not per change:** what comes up while a feature is being built on its branch or a
milestone branch — a follow-up, a small addition asked for along the way — goes into the same pull
request without an issue of its own, and the pull request says what else it brings.
**Milestones are releases.** `v0.3.6` is the next patch release and `v0.4.0` the next minor one;
an issue with no milestone is in the backlog. A pull request belongs to the milestone of the issue
@@ -220,11 +223,13 @@ Changing the shard count means changing two numbers in the same `matrix` block,
`shardTotal`. They are kept together for that reason: a fifth index without a matching total asks
Playwright for shard 5 of 4, which it refuses.
The consequence is worth being explicit about: **`main` is not verified commit by commit.** A
regression pushed there is found when a version is tagged, which may be several commits later, and
the tag is then what fails. So run the suites locally before pushing anything you are unsure of —
`npm run test:unit:docker && npm run test:e2e:docker` — which is faster than waiting for a runner
anyway. `workflow_dispatch` on the Actions tab is the other way to get a full run without tagging.
Every commit on `main` is still verified, because nothing reaches `main` except through a pull
request, and branch protection requires the jobs above to be green on it before it merges. What
is not required is that the pull request is up to date with `main`, so its checks ran against the
`main` it was opened on, or last updated against. Two pull requests that pass apart can still break
together. `workflow_dispatch` on the Actions tab runs the whole workflow on any branch, and the
suites run locally with `npm run test:unit:docker && npm run test:e2e:docker`, which is faster than
waiting for a runner when you are unsure of a change before pushing it.
A tag starts the E2E workflow and the image build **in parallel**, so a failing suite does not stop
the image being published. Tag from a tree you have already run the suites against — see
@@ -318,21 +323,31 @@ unpublish a catalogue version. A bad release is repaired by tagging the next one
That makes the tag the only gate there is, and two things have to be true before it is pushed:
- **The suites are green on the exact commit being tagged.** Either locally —
`npm run test:unit:docker && npm run test:e2e:docker` — or by starting the E2E workflow on `main`
with `workflow_dispatch` and waiting for it. The run the tag itself starts does not count: the
image is published beside it, not after it.
- **The suites are green on the exact commit being tagged.** The `Release vX.Y.Z` pull request
described below is that run: it cannot merge until the E2E workflow is green on it, and the
commit it makes on `main` is the one that is tagged. There is no need to start the workflow by
hand before it. If anything else merged into `main` after its checks ran, press **Update branch**
and let them run again, because the protection does not ask for that by itself. The run the tag
starts does not count: the image is published beside it, not after it.
- **That commit has been installed and used on a real installation.** A push to `main` publishes
`main` and `sha-<commit>` without touching `latest` or the catalogue, which is what those tags are
for: pull one onto the production install, let the migrations run against real data, and read
mail with it. The suites start from an empty database every time; an upgrade never does.
Then, and in this order: rename `## Unreleased` in `CHANGELOG.md` to `## vX.Y.Z — date` in a pull
request titled `Release vX.Y.Z` — `main` is protected, so the release commit goes through one like
everything else — merge it, tag the commit it made on `main` and push the tag. The release commit
changes the changelog and nothing else, so what was tested is what is tagged — except for the
highlight pictures that release pushes past their ten releases, which it deletes (see
[Release notes](#release-notes)). Then close the
Then, and in this order: write the release's section in a pull request titled `Release vX.Y.Z`
— `main` is protected, so the release commit goes through one like everything else — merge it, tag
the commit it made on `main` and push the tag. The section is written by
```bash
bin/changelog-release.sh vX.Y.Z
```
which puts the entries waiting in `changelog.d/` (highlights first) and whatever is still under
`## Unreleased` under a new `## vX.Y.Z — date` heading, dated today unless a second argument says
otherwise, and deletes the fragments. It refuses a version that already has a section, and a
release with nothing in it. The release commit changes the changelog and `changelog.d/` and nothing
else, so what was tested is what is tagged — except for the highlight pictures that release pushes
past their ten releases, which it deletes (see [Release notes](#release-notes)). Then close the
milestone of that name, moving anything still open in it to the next one (see
[How work gets in](#how-work-gets-in)).
@@ -376,7 +391,19 @@ Never point it at a stack holding real mail.
`CHANGELOG.md` is not only for people reading GitHub: every installation reads it from its own image
and shows it in **What's new** after an update (see
[What everyone sees after an update](docs/install/upgrading.md#what-everyone-sees-after-an-update)).
So an entry is written for the person using plMail, and three conventions decide how it is shown:
So an entry is written for the person using plMail.
**A pull request adds its entry as a file of its own in `changelog.d/`**, named after the issue
(`266-jmap-read-state.md`), not as a paragraph in `CHANGELOG.md`. Every pull request used to add
its paragraph under `## Unreleased`, at the same place in the same file, so the second of any two
conflicted with the first as soon as that one merged — and fixing the conflict is a push that
re-runs the whole suite, once per pull request in a batch. The release pull request collects the
files (see [Releasing](#releasing)). [`changelog.d/README.md`](changelog.d/README.md) has the
details; the one rule that differs from writing in `CHANGELOG.md` is that a fragment has no
headings, since the files are put one after the other. `### Before you upgrade` is still written
under `## Unreleased` in `CHANGELOG.md`, and so are entries from pull requests that predate this.
Three conventions decide how an entry is shown:
- **An entry is a paragraph that opens with a bold sentence.** The bold part is its headline in the
dialog; the rest, until the next bold paragraph, is what opens under it.
@@ -927,12 +954,13 @@ php bin/mirror-wiki.php --check
| `app:mail:test-connection` | Probe an account's IMAP/SMTP settings |
| `app:contacts:harvest [account-id]` | Harvest contact addresses from synced messages |
| `app:calendar:sync [calendar-id] [--stale]` | Dispatch a two-way sync for connected calendars; `--stale` is the sweep the scheduler runs |
| `app:contacts:sync [address-book-id] [--stale]` | Dispatch a two-way sync for mirrored CardDAV address books; `--stale` is the sweep the scheduler runs |
| `app:calendar:push [calendar-id] [--force] [--stop]` | Register and renew Google/Microsoft calendar push channels, so changes arrive instead of being polled for |
| `app:calendar:alerts [--dry-run]` | Deliver the event reminders that have come due, and prune the records of ones long past. Runs every minute; `--dry-run` lists what is due without sending or recording anything |
| `app:mail:wake-snoozed` | Return snoozed conversations whose time has come. Runs every minute |
| `app:mail:release-held` | Show mail that has been held for the assistant longer than **Wait at most** allows. Runs every minute and finds nothing on an ordinary day — the live worker releases held mail itself. It is the backstop for that worker being down, and it warns when it has to act |
| `app:calendar:materialise [--dry-run]` | Redraw the occurrences of recurring events whose horizon no longer reaches far enough. Runs nightly; without it a long-untouched series eventually runs out of dates |
| `app:backfill [task]` | Run a one-off backfill over stored data; with no argument it lists the tasks and asks. The tasks that re-read mail (`events`, `insights`) cover the last 24 hours unless `--since` says otherwise (`7d`, `2026-09-01`, `all`). `events` re-runs calendar extraction, `proposals` re-reads mail for dates written in prose, `safe-html-table-links` restores links that were wrapped around a table and came out empty, `safe-html-conditional-comments` removes Outlook's `<![endif]-->` from messages that show it as text, `repeated-headers` takes out the header block some IMAP servers repeated at the top of a message body |
| `app:backfill [task]` | Run a one-off backfill over stored data; with no argument it lists the tasks and asks. The tasks that re-read mail (`events`, `insights`) cover the last 24 hours unless `--since` says otherwise (`7d`, `2026-09-01`, `all`). `events` re-runs calendar extraction, `proposals` re-reads mail for dates written in prose, `safe-html-table-links` restores links that were wrapped around a table and came out empty, `safe-html-conditional-comments` removes Outlook's `<![endif]-->` from messages that show it as text, `repeated-headers` takes out the header block some IMAP servers repeated at the top of a message body, `gmailify-placement` puts IMAP mail a Gmail account also collects back where its IMAP folder has it |
| `app:imap:idle <mailbox-id>` | Hold an IMAP IDLE connection for a single mailbox |
| `app:imap:supervise` | Spawn and watch one `app:imap:idle` process per IDLE-enabled mailbox |
| `app:imap:test [--account=ID]` | Test an IMAP connection and folder listing |
+21 -5
View File
@@ -20,6 +20,14 @@ FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# A static Go binary, so an Alpine build runs on this Debian image as it is.
FROM docker.io/dunglas/mercure:v1 AS mercure_upstream
# PIE, the PHP Installer for Extensions, for the two extensions that are not
# part of PHP (APCu, and Xdebug in the dev image). install-php-extensions
# fetches those from PECL, which is deprecated in PIE's favour and was down
# for most of an evening on 2026-10-21, failing every build. PIE takes them
# from the extensions' own releases. The binary image is only the PHAR; it is
# mounted for the one RUN that needs it, so it is not in the final image.
FROM ghcr.io/php/pie:1.5.2-bin AS pie_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target
@@ -72,7 +80,6 @@ RUN set -eux; \
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \
zip \
@@ -85,6 +92,15 @@ RUN set -eux; \
gd \
;
# APCu backs Symfony's system cache (Doctrine's query cache, the serializer
# and validator metadata), in front of the PHP-file cache. After the line
# above, because PIE unpacks the release with the zip extension installed
# there. The compilers it builds with are in the FrankenPHP image already.
RUN --mount=type=bind,from=pie_upstream,source=/pie,target=/usr/local/bin/pie \
set -eux; \
pie install --no-cache apcu/apcu:^5.1; \
php -r 'exit(extension_loaded("apcu") ? 0 : 1);'
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1
@@ -144,10 +160,10 @@ ENV FRANKENPHP_WORKER_CONFIG=watch
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
install-php-extensions \
xdebug \
;
RUN --mount=type=bind,from=pie_upstream,source=/pie,target=/usr/local/bin/pie \
set -eux; \
pie install --no-cache xdebug/xdebug; \
php -r 'exit(extension_loaded("xdebug") ? 0 : 1);'
COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/conf.d/
+1
View File
@@ -5,6 +5,7 @@ import './password_manager_ignore.js';
import './nav_origin.js';
import './motion.js';
import './confirm.js';
import './panes.js';
import './client_errors.js';
import './request_errors.js';
import { watchForMailChanges } from './mail_writes.js';
+141
View File
@@ -0,0 +1,141 @@
/**
* What the contact editor and quick add both need to know about a contact's
* fields in the browser: how a name splits into its parts, which label an
* address or number probably has, and whether two spellings are the same
* number.
*
* splitName() mirrors ContactTextParser::splitName() on the server. Two
* copies, on purpose: the editor re-splits on every keystroke in the name
* field, and a round trip per keystroke for five words is the wrong price.
* The server's copy is the one a pasted text and the AI's answer go
* through; this one only fills the part fields as somebody types, and what
* is saved is whatever those fields then hold. ContactTextParserTest pins
* the server's rules; the e2e spec pins these to the same names.
*/
const PREFIXES = ["dr.", "dr", "prof.", "prof", "mr.", "mr", "mrs.", "mrs", "ms.", "ms", "herr", "frau", "dipl.-ing.", "ing.", "mag.", "m.", "mme", "mme."]
const SUFFIXES = ["jr.", "jr", "sr.", "sr", "phd", "ph.d.", "md", "m.d.", "ii", "iii", "iv", "mba", "msc", "m.sc.", "b.sc."]
const PARTICLES = ["van", "von", "vom", "zu", "zum", "de", "der", "den", "del", "della", "di", "da", "du", "la", "le", "ten", "ter", "af", "al", "bin", "ibn"]
const PERSONAL_DOMAINS = [
"gmail.com", "googlemail.com", "gmx.de", "gmx.net", "gmx.at", "gmx.ch", "web.de", "t-online.de",
"outlook.com", "outlook.de", "hotmail.com", "hotmail.de", "live.com", "icloud.com", "me.com", "mac.com",
"yahoo.com", "yahoo.de", "posteo.de", "posteo.net", "mailbox.org", "proton.me", "protonmail.com",
"freenet.de", "arcor.de", "aol.com", "orange.fr", "free.fr", "laposte.net",
]
export const NAME_PARTS = ["namePrefix", "givenName", "middleName", "familyName", "nameSuffix"]
/** @returns {Record<string, string>} NAME_PARTS → value, empty ones left out */
export function splitName(name) {
const words = (name || "").trim().split(/\s+/).filter(Boolean)
const parts = {}
const prefix = []
while (words.length > 1 && isTitle(words[0], prefix.length > 0)) prefix.push(words.shift())
if (prefix.length) parts.namePrefix = prefix.join(" ")
const suffix = []
while (words.length > 1 && isDegree(words[words.length - 1].replace(/,$/, ""), words[words.length - 2].endsWith(","))) {
suffix.unshift(words.pop().replace(/,$/, ""))
}
if (words.length) words[words.length - 1] = words[words.length - 1].replace(/,$/, "")
if (words.length === 1) {
parts.givenName = words[0]
} else if (words.length > 1) {
let family = words.length - 1
for (let i = 1; i < words.length - 1; i++) {
if (PARTICLES.includes(words[i])) { family = i; break }
}
parts.givenName = words[0]
if (family > 1) parts.middleName = words.slice(1, family).join(" ")
parts.familyName = words.slice(family).join(" ")
}
if (suffix.length) parts.nameSuffix = suffix.join(" ")
return parts
}
/** A known title, an abbreviation of two or more letters, or a lower-case word after a title. */
function isTitle(word, afterTitle) {
if (PREFIXES.includes(word.toLowerCase())) return true
if (word.endsWith(".") && (word.match(/\p{L}/gu) || []).length >= 2) return true
return afterTitle && /^\p{Ll}[\p{Ll}.]*$/u.test(word) && !PARTICLES.includes(word)
}
/** A known suffix, a dotted or short mixed-case degree, or an all-caps group after a comma. */
function isDegree(word, afterComma) {
if (SUFFIXES.includes(word.toLowerCase())) return true
if (/^(\p{L}{1,3}\.){2,}$/u.test(word)) return true
if ([...word].length <= 5 && /^\p{Lu}(\p{Lu}+\p{Ll}+|\p{Ll}+\p{Lu})$/u.test(word)) return true
return afterComma && /^\p{Lu}{2,}$/u.test(word)
}
export function joinName(parts) {
return NAME_PARTS.map((k) => (parts[k] || "").trim()).filter(Boolean).join(" ")
}
/** home for a personal mailbox provider, work for any other domain, other before there is one. */
export function emailLabel(email) {
const at = (email || "").lastIndexOf("@")
if (at < 0) return "other"
const domain = email.slice(at + 1).trim().toLowerCase()
if (!domain.includes(".")) return "other"
return PERSONAL_DOMAINS.includes(domain) ? "home" : "work"
}
/**
* Whether a number is surely a mobile, for the label a new row starts with.
* Only the countries whose mobile prefixes are unambiguous — Germany, Austria,
* Switzerland, the UK, France, the Netherlands; anywhere else the row keeps the
* label it has, and the server's libphonenumber reading is the one a pasted
* number gets.
*/
export function looksMobile(number) {
const d = (number || "").replace(/[^\d+]/g, "").replace(/^00/, "+")
return /^(\+49|0)1[5-7]/.test(d)
|| /^(\+43|0)6[5-9]/.test(d)
|| /^(\+41|0)7[5-9]/.test(d)
|| /^(\+44|0)7[1-9]/.test(d)
|| /^(\+33|0)[67]/.test(d)
|| /^(\+31|0)6/.test(d)
}
/** The same number in two spellings: the last nine digits agree. */
export function sameNumber(a, b) {
const da = (a || "").replace(/\D+/g, "")
const db = (b || "").replace(/\D+/g, "")
if (da.length < 6 || db.length < 6) return da === db
return da.slice(-9) === db.slice(-9)
}
export function sameText(a, b) {
const norm = (v) => (v || "").trim().replace(/\s+/g, " ").toLowerCase()
return norm(a) === norm(b)
}
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
/**
* POST a form body with a per-action CSRF token in the header; the parsed
* JSON, or an Error carrying `status`.
*/
export async function postForm(url, token, fields, signal) {
const body = new FormData()
for (const [k, v] of Object.entries(fields)) body.append(k, v)
const response = await fetch(url, {
method: "POST",
body,
signal,
headers: { "X-CSRF-Token": token, Accept: "application/json" },
credentials: "same-origin",
})
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`)
error.status = response.status
throw error
}
return response.json()
}
@@ -0,0 +1,109 @@
import { Controller } from "@hotwired/stimulus";
/**
* What a tap on a month day does, which depends on how wide the month is (#253).
*
* **On a narrow month it chooses the day the list under the grid shows, and
* that is all it does.** Below 39rem the days are bars and the month view lists
* one day in full beneath them (see _view_month). Any tap or click on a day
* selects it, entry bars and the date included: a bar is too small to aim at,
* and opening one blind would be a guess. The day does not open over its
* neighbours either; the list is where it is read, and where its entries open
* and a new one is added.
*
* **On a wider month, and only on a screen with no pointer, it opens the day.**
* With a mouse a day opens under the pointer: its card widens over the days
* around it and shows every entry in full (see _month_shell, `day-open:`). A
* phone has no hover, so there the first tap anywhere on a day opens it, by
* putting `data-open` on it, and does nothing else: not the entry under the
* finger, not the date's link to the day view. Those act on the second tap,
* once the card is open and the reader can see what they are tapping. A tap
* outside the open day, or Escape, closes it.
*
* `(hover: none)` rather than `pointerType`, for the same reason Tailwind's
* own `hover:` asks it: what matters is whether this screen CAN hover, and a
* laptop with a touch screen that can is better served by its pointer.
*
* In the capture phase, so the tap is swallowed before the entry's own
* handlers or Turbo's link handling see it. Which arrangement is showing is
* read off the list itself, `hidden` until the container query shows it, so
* the breakpoint is written once, in the template.
*/
export default class extends Controller {
static targets = ["list"];
static values = { selected: String };
connect() {
this.touch = window.matchMedia("(hover: none)");
this.mark(this.selectedValue);
}
tap(event) {
const day = event.target.closest("[data-calendar-grid] [data-day]");
if (null === day) {
return;
}
if (this.narrow()) {
event.preventDefault();
event.stopPropagation();
this.select(day.dataset.day);
return;
}
if (false === this.touch.matches || day.hasAttribute("data-open")) {
return;
}
event.preventDefault();
event.stopPropagation();
this.close();
day.setAttribute("data-open", "");
}
select(dayKey) {
this.selectedValue = dayKey;
this.mark(dayKey);
// The toolbar's + adds to the chosen day too. Two + buttons a few
// rows apart, one starting on the day the reader picked and one on
// today, was a trap: it is the toolbar's that is reached first.
document.querySelectorAll("[data-calendar-new-event]").forEach((button) => {
button.setAttribute(
"data-ui--modal-src-value",
`${button.dataset.calendarNewEvent}?start=${encodeURIComponent(dayKey)}`,
);
});
this.listTargets.forEach((list) => {
list.hidden = list.dataset.listDay !== dayKey;
});
}
outside(event) {
if (this.element.contains(event.target)) {
return;
}
this.close();
}
close() {
this.element.querySelectorAll("[data-day][data-open]").forEach((day) => day.removeAttribute("data-open"));
}
narrow() {
const list = this.listTargets[0]?.parentElement;
return undefined !== list && null !== list.offsetParent;
}
mark(dayKey) {
this.element.querySelectorAll("[data-calendar-grid] [data-day]").forEach((day) => {
day.toggleAttribute("data-selected", day.dataset.day === dayKey);
});
}
}
@@ -0,0 +1,112 @@
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
/**
* Keeps "now" true on a calendar that stays open (#253).
*
* The red line in the day and week grids, and the circle on today's date in the
* month, were drawn at the minute the page was built and never moved. A
* calendar left open beside the mail, which is the docked pane's whole job, had
* the line an hour behind by lunch and the circle on yesterday by breakfast.
*
* **Within the day, the line moves.** Once a minute, on the minute, each `line`
* target's `top` is set to the minute of the day as a percentage, the same
* spelling the server wrote it in. Nothing else on the page changes, so this is
* one style write a minute, not a re-render.
*
* **When the date changes, the view is drawn again.** A new day moves the line
* to another column, the tint to another day, and the circle to another date,
* and only the server knows how each view draws them; working that out here
* would be a second copy of three templates. So the view reloads: its Turbo
* Frame when it is in one (the pane), the page otherwise. Only a view that
* shows the old day or the new one reloads; a week three weeks ahead has nothing
* that moves at midnight.
*
* Both are read on the VIEW's clock, `zone`, never the browser's: the grid is
* drawn in the calendar's zone, and a reader whose laptop is in another one
* would otherwise see the line at their own hour on a grid that is not on it.
*
* A tab in the background gets no timers worth trusting, so coming back to it
* checks at once rather than at the next minute.
*/
export default class extends Controller {
static targets = ["line"];
static values = { zone: String, today: String };
connect() {
this.format = new Intl.DateTimeFormat("en-CA", {
timeZone: this.zoneValue || undefined,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
this.onVisible = () => {
if ("visible" === document.visibilityState) {
this.tick();
}
};
document.addEventListener("visibilitychange", this.onVisible);
this.tick();
this.schedule();
}
disconnect() {
clearTimeout(this.timer);
document.removeEventListener("visibilitychange", this.onVisible);
}
// On the minute rather than every 60s from whenever the page loaded, so the
// line steps with the clock in the corner of the screen and not up to a
// minute after it.
schedule() {
const wait = 60_000 - (Date.now() % 60_000) + 50;
this.timer = setTimeout(() => {
this.tick();
this.schedule();
}, wait);
}
tick() {
const parts = Object.fromEntries(this.format.formatToParts(new Date()).map((part) => [part.type, part.value]));
const dayKey = `${parts.year}-${parts.month}-${parts.day}`;
if (dayKey !== this.todayValue) {
this.dayChanged(dayKey);
return;
}
const minute = Number(parts.hour) * 60 + Number(parts.minute);
this.lineTargets.forEach((line) => {
line.style.top = `${((minute * 100) / 1440).toFixed(4)}%`;
});
}
dayChanged(dayKey) {
const shows = (key) => null !== this.element.querySelector(`[data-day="${key}"]`);
if (this.reloading || (false === shows(this.todayValue) && false === shows(dayKey))) {
this.todayValue = dayKey;
return;
}
this.reloading = true;
const frame = this.element.closest("turbo-frame[src]");
if (null !== frame) {
frame.reload();
return;
}
Turbo.visit(window.location.href, { action: "replace" });
}
}
@@ -199,6 +199,11 @@ export default class extends Controller {
// event.
this._boundNameAddressFields = this._nameAddressFields.bind(this);
this.element.addEventListener('autocomplete:connect', this._boundNameAddressFields);
// Before the widget is built, so its first chips are drawn this way
// too. This controller is an ancestor of the fields, so Stimulus has
// connected it before the bundle's controller dispatches this.
this._boundRenderAddressFields = this._renderAddressFields.bind(this);
this.element.addEventListener('autocomplete:pre-connect', this._boundRenderAddressFields);
requestAnimationFrame(this._boundNameAddressFields);
// A draft that arrives with text and no HTML is already a plain-text
@@ -308,6 +313,60 @@ export default class extends Controller {
this._markInvalid(this.hasErrorsTarget && false === this.errorsTarget.classList.contains('hidden'));
}
/**
* How a recipient is drawn: a suggestion as avatar, name and address, a
* chip as the name alone, with the address on hover.
*
* These were tom_select_options in ContactAutocompleteField, as strings of
* JavaScript. The bundle hands options to Tom Select as they are, so the
* strings were never run, and the bundle's own renderers drew every chip
* as "Name <address>". Once address-book contacts were suggested, nearly
* every chip had a name, and at the chip's maximum width its × was clipped.
*/
_renderAddressFields(event) {
const options = event.detail?.options;
if (undefined === options || false === this._addressRows().some((row) => row.contains(event.target))) {
return;
}
const parts = (data) => {
// Plain text, escaped when drawn — never tag-stripped, which would
// take the <address> with it.
const raw = String(data.text ?? data.value ?? '');
const match = raw.match(/^(.*?)\s*<([^<>]+)>$/);
return null !== match && '' !== match[1].trim()
? { name: match[1].trim(), email: match[2], full: raw }
: { name: '', email: raw, full: raw };
};
options.render = {
...options.render,
option: (data, escape) => {
const { name, email } = parts(data);
const initials = '' !== name
? name.split(/\s+/).map((word) => word[0] ?? '').slice(0, 2).join('')
: (email[0] ?? '?');
// A child of the option, not the option: Tom Select puts its
// `.option` class on whatever root it is given, and that
// rule's display: block would undo the row's flex.
return '<div><div class="ts-option-row">'
+ `<span class="ts-option-avatar">${escape(initials.toUpperCase())}</span>`
+ '<span class="ts-option-text">'
+ ('' !== name ? `<span class="ts-option-name">${escape(name)}</span>` : '')
+ `<span class="ts-option-email">${escape(email)}</span>`
+ '</span></div></div>';
},
item: (data, escape) => {
const { name, email, full } = parts(data);
return `<div title="${escape(full)}"><span class="ts-item-label">${escape('' !== name ? name : email)}</span></div>`;
},
};
}
/** The id of the shared refusal line, minted once so it can be pointed at. */
_errorsId() {
if (false === this.hasErrorsTarget) {
@@ -581,6 +640,7 @@ export default class extends Controller {
this.element.removeEventListener('keydown', this._boundHandleTab);
this.element.removeEventListener('keydown', this._boundHandleEnter);
this.element.removeEventListener('autocomplete:connect', this._boundNameAddressFields);
this.element.removeEventListener('autocomplete:pre-connect', this._boundRenderAddressFields);
if (this.hasBodyTarget && undefined !== this._boundBodyPaste) {
this.bodyTarget.removeEventListener('paste', this._boundBodyPaste);
@@ -1406,10 +1466,29 @@ export default class extends Controller {
flex-direction: column;
`;
// The body fills the window, and grows past it with a long
// message so the scroller around it scrolls. The wrapper becomes
// the flex column that `flex-grow` needs.
//
// This used to be `closest('div').style.flex = '1'` plus
// `height: 0` on the editor. closest() starts at the element
// itself, and the editor is a <div>, so the wrapper never became a
// flex container; `height: 0` lost to `min-h-[170px]`, and the
// editor's [contain:paint] clipped everything past 170px instead
// of letting it scroll (#262). `1 0 auto`, not `1`: the editor may
// grow to fill but never shrink below its content.
//
// The plain-text field gets the same fill. A textarea scrolls
// itself, so it is sized by the window and not by its text.
if (this.hasBodyTarget) {
this.bodyTarget.closest('div').style.flex = '1';
this.bodyTarget.style.flex = '1';
this.bodyTarget.style.height = '0';
const wrap = this.bodyTarget.parentElement;
wrap.style.display = 'flex';
wrap.style.flexDirection = 'column';
this.bodyTarget.style.flex = '1 0 auto';
}
if (this.hasPlainBodyTarget) {
this.plainBodyTarget.style.flex = '1 1 auto';
}
document.body.style.overflow = 'hidden';
@@ -1430,9 +1509,14 @@ export default class extends Controller {
return;
}
this.bodyTarget.closest('div').style.flex = '';
this.bodyTarget.style.flex = '';
this.bodyTarget.style.height = '';
const wrap = this.bodyTarget.parentElement;
wrap.style.display = '';
wrap.style.flexDirection = '';
this.bodyTarget.style.flex = '';
if (this.hasPlainBodyTarget) {
this.plainBodyTarget.style.flex = '';
}
}
_syncExpandIcon(expanded) {
@@ -1092,11 +1092,19 @@ export default class extends Controller {
* draft: the debug toolbar used to end up quoted inside the body, and the
* "show quoted text" toggle is a UI affordance, not part of the mail.
* Scripts go too — nothing executable belongs in a mail we're sending.
*
* The fold around the quote goes with its toggle. Removing only the button
* left the quote's `display: none` behind, and every reply sent with the
* quote still folded — which is how the composer opens — reached its
* recipient with the history invisible, in every client (#257). The quote
* is unhidden and its wrapper unwrapped; a draft reopened from this is
* folded again by compose_controller, as it always was.
* MailBodySanitizer::unfoldQuotes() does the same on the server.
*/
_cleanHtml() {
const html = this.editorTarget.innerHTML;
if (!/sf-toolbar|<script|sfwdt|data-quote-toggle/i.test(html)) {
if (!/sf-toolbar|<script|sfwdt|data-quote-toggle|data-quote-wrapped/i.test(html)) {
return html;
}
@@ -1106,6 +1114,16 @@ export default class extends Controller {
'script, .sf-toolbar, [id^="sfwdt"], [data-frankenphp-hot-reload-preserve], [data-quote-toggle]',
).forEach((node) => node.remove());
clone.querySelectorAll('[data-quote-wrapped]').forEach((wrapper) => {
Array.from(wrapper.children).forEach((child) => {
child.style.removeProperty('display');
if ('' === child.getAttribute('style')) {
child.removeAttribute('style');
}
});
wrapper.replaceWith(...wrapper.childNodes);
});
return clone.innerHTML;
}
}
@@ -51,6 +51,14 @@ export default class extends Controller {
);
}
/**
* Close whatever card stands in the dock's slot — the error card below, or
* one the server sent instead of a window (compose/_no_account.html.twig).
*/
dismiss() {
this.element.querySelector('turbo-frame#compose_dock')?.replaceChildren();
}
_onError() {
this._render(this._t('offline', 'The compose window could not be opened — you appear to be offline.'));
}
@@ -0,0 +1,691 @@
import { Controller } from "@hotwired/stimulus"
import { EMAIL_RE, emailLabel, joinName, looksMobile, NAME_PARTS, postForm, sameNumber, sameText, splitName } from "../../contacts/fields.js"
/**
* contacts--editor: the contact form (templates/contacts/form.html.twig).
*
* The form is complete without this — every input the save reads is in the
* markup with its name. What this adds:
*
* - the name's parts follow the name as it is typed, until somebody edits a
* part by hand (then they stay, and a note says when the two disagree);
* - email and phone end in a dashed row, and typing into it makes a real
* row, with a type guessed from the address or number (marked, until the
* person picks one);
* - one preferred row per kind (the star), sections that show when asked
* for, and "Add another" for the lists;
* - a save bar that appears once something changed and says how much, and
* refuses to save an email address that is not one;
* - "Paste details" and "Improve with AI", which fill one review list: each
* detail found is compared with the form — new, already there, or
* different — and only what is ticked is applied, into the form, where it
* stays highlighted until saved. Nothing here saves.
*
* Every text it shows comes from the template (the `strings` value), so the
* page speaks the person's language without a second catalogue in JS.
*/
export default class extends Controller {
static values = {
parseUrl: String,
parseToken: String,
aiUrl: String,
aiToken: String,
new: Boolean,
strings: Object,
}
static targets = [
"name", "initials", "partsSummary", "partsToggle", "partsStale", "partsPanel", "part",
"panel", "panelTitle", "pasteText", "panelNote", "suggestions", "suggestionTemplate", "panelFoot",
"suggestSummary", "applyButton", "appliedNote",
"saveBar", "dirtyText", "saveButton", "tags", "tagChips",
]
connect() {
this.snapshot = this.#serialize()
// Parts follow the name only while they still say the same thing:
// a saved contact whose parts were set by hand keeps them.
const parts = this.#parts()
this.autoParts = Object.values(parts).every((v) => v === "") || joinName(parts) === this.nameTarget.value.trim()
this.suggestionsList = []
this.#renderParts()
this.#renderTags()
this.#refresh()
}
disconnect() {
this.abort?.abort()
clearTimeout(this.pasteTimer)
}
// ── Anything changed ────────────────────────────────────────────────
changed(event) {
const target = event.target
const row = target.closest?.("[data-row]")
// A guessed type follows the value until somebody picks one.
if (row && row.dataset.auto && target.matches("[data-row-value]")) {
this.#guessLabel(row)
}
if (row && target.matches("select") && target.name.endsWith("[label]")) {
delete row.dataset.auto
row.querySelector("[data-auto-mark]")?.setAttribute("hidden", "")
}
if (this.hasTagsTarget && target === this.tagsTarget) {
this.#renderTags()
}
this.#refresh()
}
// ── The name ────────────────────────────────────────────────────────
nameTyped() {
if (this.autoParts) {
const parts = splitName(this.nameTarget.value)
for (const input of this.partTargets) input.value = parts[input.dataset.part] || ""
}
if (this.hasInitialsTarget) {
this.initialsTarget.textContent = (this.nameTarget.value.trim().charAt(0) || "?").toUpperCase()
}
this.#renderParts()
}
partTyped() {
// The name follows the parts when it said what they said a moment
// ago; a name that was something else ("Mum") is left alone.
const before = this.lastJoined ?? ""
if (this.nameTarget.value.trim() === before) {
this.nameTarget.value = joinName(this.#parts())
}
this.autoParts = false
this.#renderParts()
}
toggleParts() {
const open = this.partsPanelTarget.hidden
this.partsPanelTarget.hidden = !open
this.partsToggleTarget.setAttribute("aria-expanded", String(open))
this.partsToggleTarget.textContent = open ? this.stringsValue.hideParts : this.stringsValue.editParts
}
resplit() {
this.autoParts = true
this.nameTyped()
this.#refresh()
}
// ── Rows ────────────────────────────────────────────────────────────
ghostTyped(event) {
const ghost = event.target
const value = ghost.value
if (value.trim() === "") return
const row = this.#addRow(ghost.dataset.ghost)
const input = row.querySelector("[data-row-value]")
input.value = value
row.dataset.auto = "1"
this.#guessLabel(row)
ghost.value = ""
input.focus()
input.setSelectionRange?.(value.length, value.length)
this.#refresh()
}
addRow({ params: { list } }) {
const row = this.#addRow(list)
row.querySelector("input, select")?.focus()
this.#refresh()
}
removeRow(event) {
const row = event.currentTarget.closest("[data-row]")
const list = row?.parentElement
const wasPrimary = !!row?.querySelector("[data-row-primary]:not([disabled])")
row?.remove()
if (wasPrimary && list) {
const first = list.querySelector("[data-row]")
if (first) this.#setPrimary(first)
}
// A list section with no rows left goes back to being a chip — the
// section has no remove of its own, its rows' are enough.
const section = list?.closest("[data-section]")
if (section && !section.querySelector("[data-row]") && !section.querySelector("[data-birthday]")) {
section.hidden = true
this.element.querySelector(`[data-chip="${section.dataset.section}"]`)?.removeAttribute("hidden")
}
this.#refresh()
}
makePrimary(event) {
this.#setPrimary(event.currentTarget.closest("[data-row]"))
this.#refresh()
}
// ── Sections ────────────────────────────────────────────────────────
addSection({ params: { section } }) {
const el = this.#showSection(section)
const list = el?.querySelector("[data-list-rows]")
if (list && !list.querySelector("[data-row]")) this.#addRow(list.dataset.listRows)
el?.querySelector("input:not([type=hidden]), select, textarea")?.focus()
this.#refresh()
}
discard() {
const frame = this.element.closest("turbo-frame")
if (frame?.src) frame.reload()
else window.location.reload()
}
// ── Paste details and Improve with AI ───────────────────────────────
openPaste() {
this.mode = "paste"
this.#openPanel(this.stringsValue.pasteTitle)
this.pasteTextTarget.hidden = false
this.pasteTextTarget.focus()
if (this.pasteTextTarget.value.trim() !== "") this.pasted()
}
pasted() {
clearTimeout(this.pasteTimer)
this.pasteTimer = setTimeout(() => this.#readPaste(), 350)
}
async improve(event) {
const button = event?.currentTarget
this.mode = "ai"
this.#openPanel(this.stringsValue.aiTitle)
this.pasteTextTarget.hidden = true
this.#note(this.stringsValue.aiRunning)
this.#setSuggestions([])
button?.setAttribute("disabled", "")
this.abort?.abort()
this.abort = new AbortController()
try {
const answer = await postForm(this.aiUrlValue, this.aiTokenValue, {}, this.abort.signal)
const list = []
if (answer.name) list.push({ kind: "name", label: "", value: answer.name.value, reason: answer.name.reason || "", ai: true })
for (const f of answer.fields || []) list.push(this.#fromField(f, true))
const when = answer.signature?.receivedAt ? new Date(answer.signature.receivedAt).toLocaleDateString() : null
this.#note(when ? this.stringsValue.aiSent.replace("%date%", when) : this.stringsValue.aiSentNoMail)
this.#setSuggestions(list)
if (list.length === 0) this.#note(this.stringsValue.aiNothing)
} catch (error) {
if (error.name === "AbortError") return
if (error.status === 409) {
this.element.querySelector("[data-contacts-ai-button]")?.remove()
}
this.#note(this.stringsValue.aiFailed)
} finally {
button?.removeAttribute("disabled")
}
}
closePanel() {
this.abort?.abort()
this.panelTarget.hidden = true
}
apply() {
let applied = 0
for (const s of this.suggestionsList) {
if (!s.checked || s.status === "same") continue
this.#applyOne(s)
applied++
}
this.closePanel()
if (applied > 0) {
this.appliedNoteTarget.textContent = this.stringsValue.applied.replace("%count%", applied)
this.appliedNoteTarget.hidden = false
}
this.#renderParts()
this.#refresh()
}
// ── Internals: rows ─────────────────────────────────────────────────
#addRow(list) {
const container = this.element.querySelector(`[data-list-rows="${list}"]`)
const template = this.element.querySelector(`template[data-list-template="${list}"]`)
if (!container || !template) return null
const index = parseInt(container.dataset.nextIndex || "0", 10)
container.dataset.nextIndex = String(index + 1)
const holder = document.createElement("div")
holder.innerHTML = template.innerHTML.replaceAll("__INDEX__", String(index))
const row = holder.firstElementChild
const ghost = container.querySelector("[data-ghost]")
container.insertBefore(row, ghost)
// The first email or number is the preferred one.
if (row.querySelector("[data-row-primary]") && container.querySelectorAll("[data-row]").length === 1) {
this.#setPrimary(row)
}
const section = container.closest("[data-section]")
if (section?.hidden) this.#showSection(section.dataset.section)
return row
}
#setPrimary(row) {
const list = row.parentElement
for (const other of list.querySelectorAll("[data-row]")) {
const on = other === row
const input = other.querySelector("[data-row-primary]")
const star = other.querySelector("[data-star]")
if (!input || !star) continue
input.disabled = !on
star.setAttribute("aria-pressed", String(on))
star.classList.toggle("text-accent", on)
const icon = star.querySelector("i")
icon?.classList.toggle("fa-solid", on)
icon?.classList.toggle("fa-regular", !on)
}
}
#guessLabel(row) {
const value = row.querySelector("[data-row-value]")?.value || ""
const select = row.querySelector("select[name$='[label]']")
if (!select) return
const list = row.closest("[data-list-rows]")?.dataset.listRows
let label = select.value
if (list === "emails") label = emailLabel(value)
if (list === "phones" && looksMobile(value)) label = "mobile"
if ([...select.options].some((o) => o.value === label)) select.value = label
row.querySelector("[data-auto-mark]")?.removeAttribute("hidden")
}
#showSection(key) {
const el = this.element.querySelector(`[data-section="${key}"]`)
if (el) el.hidden = false
this.element.querySelector(`[data-chip="${key}"]`)?.setAttribute("hidden", "")
return el
}
// ── Internals: name parts, tags, save bar ───────────────────────────
#parts() {
const parts = {}
for (const input of this.partTargets) parts[input.dataset.part] = input.value.trim()
return parts
}
#renderParts() {
const parts = this.#parts()
this.lastJoined = joinName(parts)
this.partsSummaryTarget.replaceChildren(...NAME_PARTS.filter((k) => parts[k]).map((k) => {
const chip = document.createElement("span")
chip.className = "inline-flex items-baseline gap-1 px-2 py-0.5 rounded-full bg-sunken"
const label = document.createElement("span")
label.className = "text-ink-faint"
label.textContent = this.stringsValue.parts?.[k] ?? k
const value = document.createElement("span")
value.className = "text-ink font-medium"
value.textContent = parts[k]
chip.append(label, value)
return chip
}))
this.partsStaleTarget.hidden = this.autoParts || this.lastJoined === "" || this.lastJoined === this.nameTarget.value.trim()
}
#renderTags() {
if (!this.hasTagsTarget) return
const tags = this.tagsTarget.value.split(",").map((t) => t.trim()).filter(Boolean)
this.tagChipsTarget.replaceChildren(...tags.map((t) => {
const chip = document.createElement("span")
chip.className = "px-2 py-0.5 rounded-full bg-accent-soft text-accent text-xs font-medium"
chip.textContent = t
return chip
}))
}
#refresh() {
let invalid = 0
for (const row of this.element.querySelectorAll('[data-list-rows="emails"] [data-row]')) {
const input = row.querySelector("[data-row-value]")
const bad = input.value.trim() !== "" && !EMAIL_RE.test(input.value.trim())
input.classList.toggle("border-danger", bad)
input.setAttribute("aria-invalid", String(bad))
row.querySelector("[data-row-error]").hidden = !bad
if (bad) invalid++
}
const count = this.#changedCount()
const show = this.newValue || count > 0
this.saveBarTarget.hidden = !show
this.saveButtonTarget.disabled = invalid > 0
if (invalid > 0) {
this.dirtyTextTarget.textContent = this.stringsValue.fixEmails
} else if (count > 0) {
this.dirtyTextTarget.textContent = (count === 1 ? this.stringsValue.dirtyOne : this.stringsValue.dirtyMany).replace("%count%", count)
}
}
/**
* The form as the units a person thinks in: one entry per row of a list
* (`emails[3]` — its value, type and star together) and one per single
* field, each the row's values joined. Rows keep their index for the
* life of the page (removing one renumbers nothing), so a row present in
* both and different is one change, and a row in only one is one more.
*/
#serialize() {
const out = {}
const data = new FormData(this.element)
for (const [key, value] of data.entries()) {
if (key === "_token" || typeof value !== "string") continue
const unit = key.match(/^[^[]+\[\d+\]/)?.[0] ?? key.replace(/\[.*$/, "")
out[unit] = out[unit] === undefined ? value.trim() : `${out[unit]}\u001f${value.trim()}`
}
// An empty single field and a missing one are the same thing.
for (const [unit, value] of Object.entries(out)) if (value.replace(/\u001f/g, "") === "") delete out[unit]
return out
}
#changedCount() {
const now = this.#serialize()
const units = new Set([...Object.keys(now), ...Object.keys(this.snapshot)])
let count = 0
for (const unit of units) if (now[unit] !== this.snapshot[unit]) count++
return count
}
// ── Internals: the review list ──────────────────────────────────────
#openPanel(title) {
this.panelTarget.hidden = false
this.panelTitleTarget.textContent = title
this.appliedNoteTarget.hidden = true
this.#note("")
this.#setSuggestions([])
}
#note(text) {
this.panelNoteTarget.textContent = text
this.panelNoteTarget.hidden = text === ""
}
async #readPaste() {
const text = this.pasteTextTarget.value
if (text.trim() === "") {
this.#note("")
this.#setSuggestions([])
return
}
this.#note(this.stringsValue.reading)
this.abort?.abort()
this.abort = new AbortController()
try {
const draft = await postForm(this.parseUrlValue, this.parseTokenValue, { text }, this.abort.signal)
const list = []
if (draft.name) list.push({ kind: "name", label: "", value: draft.name, ai: false })
for (const f of draft.fields || []) list.push(this.#fromField(f, false))
this.#setSuggestions(list)
this.#note(list.length ? "" : this.stringsValue.nothingFound)
} catch (error) {
if (error.name !== "AbortError") this.#note(this.stringsValue.nothingFound)
}
}
#fromField(f, ai) {
return {
kind: f.type,
label: f.label || "",
value: f.value,
address: f.address || null,
reason: f.reason,
ai: ai && f.reason !== undefined,
}
}
#setSuggestions(list) {
this.suggestionsList = list.map((s) => this.#compare(s))
this.#renderSuggestions()
}
/**
* Where a found detail stands against the form: `same` (nothing to do),
* `new` (adds a row), `fill` (an empty field), `update` (the same email
* or number with another type or spelling), `conflict` (a different
* value where there can only be one). New and filled start ticked; a
* change to something already there starts unticked unless the AI was
* asked for exactly that.
*/
#compare(s) {
const out = { ...s, status: "new", current: "", target: null }
if (s.kind === "name") {
const current = this.nameTarget.value.trim()
const words = current.toLowerCase().split(/\s+/)
if (current === "") out.status = "fill"
else if (sameText(current, s.value) || s.value.toLowerCase().split(/\s+/).every((w) => words.includes(w))) out.status = "same"
else { out.status = "conflict"; out.current = current }
} else if (s.kind === "email" || s.kind === "phone") {
const list = s.kind === "email" ? "emails" : "phones"
for (const row of this.element.querySelectorAll(`[data-list-rows="${list}"] [data-row]`)) {
const value = row.querySelector("[data-row-value]").value
const match = s.kind === "email" ? sameText(value, s.value) : sameNumber(value, s.value)
if (!match) continue
const label = row.querySelector("select[name$='[label]']").value
out.target = row
out.current = value
out.status = label === s.label && value.trim() === s.value.trim() ? "same" : "update"
out.currentLabel = label
break
}
} else if (s.kind === "address") {
for (const row of this.element.querySelectorAll('[data-list-rows="addresses"] [data-row]')) {
const street = row.querySelector('[data-address-part="street"]').value
const postcode = row.querySelector('[data-address-part="postcode"]').value
if (sameText(street, s.address?.street ?? s.value) && sameText(postcode, s.address?.postcode ?? postcode)) { out.status = "same"; break }
}
} else if (s.kind === "url") {
const bare = (v) => (v || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "")
for (const row of this.element.querySelectorAll('[data-list-rows="urls"] [data-row]')) {
if (bare(row.querySelector("[data-row-value]").value) === bare(s.value)) { out.status = "same"; break }
}
} else if (s.kind === "birthday") {
const current = this.#birthday()
if (current === "") out.status = "fill"
else if (current === s.value) out.status = "same"
else { out.status = "conflict"; out.current = current }
} else {
const input = this.element.querySelector(`[data-field="${s.kind}"]`)
const current = input?.value.trim() ?? ""
if (current === "") out.status = "fill"
else if (sameText(current, s.value)) out.status = "same"
else { out.status = "conflict"; out.current = current }
}
out.checked = s.checked ?? (out.status === "new" || out.status === "fill" || (out.status === "update" && s.ai))
return out
}
#renderSuggestions() {
const strings = this.stringsValue
const rows = this.suggestionsList.map((s, i) => {
const node = this.suggestionTemplateTarget.content.firstElementChild.cloneNode(true)
const check = node.querySelector("[data-check]")
const locked = s.status === "same"
check.checked = locked || s.checked
check.disabled = locked
check.setAttribute("aria-label", `${this.#kindText(s)}: ${s.value}`)
check.addEventListener("change", () => { this.suggestionsList[i].checked = check.checked; this.#renderFoot() })
if (locked) node.classList.add("opacity-60")
const kinds = this.#kindOptions(s)
if (kinds && !locked) {
const select = node.querySelector("[data-kind-select]")
select.hidden = false
node.querySelector("[data-kind]").hidden = true
for (const [value, text] of kinds) select.add(new Option(text, value, false, value === this.#kindKey(s)))
select.setAttribute("aria-label", strings.kinds[s.kind] ?? s.kind)
select.addEventListener("change", () => {
const [kind, label] = select.value.split(":")
this.suggestionsList[i] = this.#compare({ ...s, kind, label: label || "", checked: undefined })
this.#renderSuggestions()
})
} else {
node.querySelector("[data-kind]").textContent = this.#kindText(s)
}
node.querySelector("[data-value]").textContent = s.value
node.querySelector("[data-ai]").hidden = !s.ai
const noteText = node.querySelector("[data-note-text]")
const status = this.#statusText(s)
noteText.textContent = s.reason ? (status ? `${s.reason} · ${status}` : s.reason) : status
node.querySelector("[data-note]").classList.add(s.status === "conflict" ? "text-warning" : "text-ink-muted")
return node
})
this.suggestionsTarget.replaceChildren(...rows)
this.#renderFoot()
}
#renderFoot() {
const strings = this.stringsValue
const list = this.suggestionsList
this.panelFootTarget.hidden = list.length === 0
const n = (status) => list.filter((s) => status.includes(s.status)).length
const parts = []
if (n(["new", "fill", "update"])) parts.push(strings.summaryNew.replace("%count%", n(["new", "fill", "update"])))
if (n(["conflict"])) parts.push(strings.summaryConflict.replace("%count%", n(["conflict"])))
if (n(["same"])) parts.push(strings.summarySame.replace("%count%", n(["same"])))
this.suggestSummaryTarget.textContent = parts.join(" · ")
const picked = list.filter((s) => s.checked && s.status !== "same").length
this.applyButtonTarget.disabled = picked === 0
this.applyButtonTarget.textContent = picked === 0 ? strings.applyNone
: (picked === 1 ? strings.applyOne : strings.applyMany.replace("%count%", picked))
}
#statusText(s) {
const strings = this.stringsValue
switch (s.status) {
case "same": return strings.same
case "fill": return strings.fills
case "conflict": return strings.replaces.replace("%value%", s.current)
case "update":
return s.currentLabel && s.currentLabel !== s.label
? strings.relabels.replace("%from%", strings.labels[s.currentLabel] ?? s.currentLabel).replace("%to%", strings.labels[s.label] ?? s.label)
: strings.replaces.replace("%value%", s.current)
default: return strings.adds
}
}
#kindText(s) {
const kind = this.stringsValue.kinds[s.kind] ?? s.kind
return s.label && this.stringsValue.labels[s.label] ? `${kind} · ${this.stringsValue.labels[s.label]}` : kind
}
#kindKey(s) {
return `${s.kind}:${s.label || ""}`
}
/**
* What a found detail can be changed into, without retyping it: another
* type of email or number, another kind of address or website, or — for
* a line of text — another of the one-line fields. A number does not
* become a company; the families stay apart.
*/
#kindOptions(s) {
const k = this.stringsValue.kinds
const l = this.stringsValue.labels
const labelled = (kind, labels) => labels.map((label) => [`${kind}:${label}`, `${k[kind]} · ${l[label]}`])
switch (s.kind) {
case "email": return labelled("email", ["home", "work", "other"])
case "phone": return labelled("phone", ["mobile", "home", "work", "other"])
case "address": return labelled("address", ["home", "work", "other"])
case "url": return labelled("url", ["home", "work", "blog", "profile", "other"])
case "jobTitle": case "company": case "department": case "nickname": case "notes":
return ["jobTitle", "company", "department", "nickname", "notes"].map((kind) => [`${kind}:`, k[kind]])
default: return null
}
}
#birthday() {
const day = this.element.querySelector('[name="birthday[day]"]')?.value
const month = this.element.querySelector('[name="birthday[month]"]')?.value
const year = this.element.querySelector('[name="birthday[year]"]')?.value
if (!day || !month) return ""
const md = `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return year ? `${year}-${md}` : `--${md}`
}
// ── Internals: applying ─────────────────────────────────────────────
#applyOne(s) {
const mark = (el) => el?.classList.add("!bg-accent-soft", "!border-accent")
const setLabel = (row, label) => {
const select = row.querySelector("select[name$='[label]']")
if (select && [...select.options].some((o) => o.value === label)) select.value = label
}
switch (s.kind) {
case "name": {
this.nameTarget.value = s.value
this.autoParts = true
this.nameTyped()
mark(this.nameTarget)
return
}
case "email":
case "phone": {
const list = s.kind === "email" ? "emails" : "phones"
const row = s.status === "update" ? s.target : this.#addRow(list)
const input = row.querySelector("[data-row-value]")
input.value = s.value
setLabel(row, s.label)
mark(input)
return
}
case "address": {
const row = this.#addRow("addresses")
const parts = s.address || { street: s.value }
for (const [key, value] of Object.entries(parts)) {
const input = row.querySelector(`[data-address-part="${key}"]`)
if (input) { input.value = value; mark(input) }
}
setLabel(row, s.label)
return
}
case "url": {
const row = this.#addRow("urls")
const input = row.querySelector("[data-row-value]")
input.value = /^https?:\/\//i.test(s.value) ? s.value : `https://${s.value}`
setLabel(row, s.label)
mark(input)
return
}
case "birthday": {
this.#showSection("dates")
const m = s.value.match(/^(?:(\d{4})|-)-(\d{2})-(\d{2})$/)
if (!m) return
const set = (name, value) => {
const el = this.element.querySelector(`[name="${name}"]`)
if (el) { el.value = value; mark(el) }
}
set("birthday[day]", String(parseInt(m[3], 10)))
set("birthday[month]", String(parseInt(m[2], 10)))
set("birthday[year]", m[1] || "")
return
}
default: {
const input = this.element.querySelector(`[data-field="${s.kind}"]`)
if (!input) return
const section = input.closest("[data-section]")
if (section) this.#showSection(section.dataset.section)
const more = input.closest("details")
if (more) more.open = true
input.value = s.value
mark(input)
}
}
}
}
@@ -0,0 +1,57 @@
import { Controller } from "@hotwired/stimulus"
/**
* contacts--form: the address book's two upload forms — a contact's photo,
* which submits itself once a file is chosen, and the import page's file
* picker. The contact form itself is contacts--editor.
*
* No inline handlers anywhere: the app's CSP refuses them.
*/
export default class extends Controller {
static targets = ["fileInput", "uploadBtn", "status"]
/** Auto-submit the photo upload form when a file is chosen. */
submitForm(event) {
if (event.target.files?.length) {
event.target.form.requestSubmit()
}
}
/**
* Import page: a file was picked — show its name and submit. The form
* also has a real submit button, so the upload works even if this
* controller never loads; this is the convenience path, not the only one.
*/
fileChosen(event) {
const file = event.target.files?.[0]
if (this.hasStatusTarget && file) {
this.statusTarget.textContent = file.name
}
if (file) {
event.target.form.requestSubmit()
}
}
/**
* Import page: "Upload & preview" with no file yet. The file input is
* required but visually hidden, so the browser cannot show its validation
* message and the click would silently do nothing — open the picker.
*/
uploadClicked(event) {
if (this.hasFileInputTarget && !this.fileInputTarget.files?.length) {
event.preventDefault()
this.fileInputTarget.click()
}
}
/** Import page: the upload is on its way — say so instead of silence. */
uploading() {
if (this.hasUploadBtnTarget) {
this.uploadBtnTarget.disabled = true
this.uploadBtnTarget.classList.add("opacity-60", "pointer-events-none")
}
if (this.hasStatusTarget) {
this.statusTarget.textContent = this.statusTarget.dataset.uploading || "Uploading…"
}
}
}
@@ -0,0 +1,463 @@
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
import { registerPane } from "../../panes.js";
/*
* The address book docked beside the mail: whether it is open, and whether it
* can be open beside the calendar.
*
* On the shell, beside the calendar's ui--split, because both of the things it
* talks to are there: the topbar's contacts button, and the calendar boundary
* it may have to ask for room. The dock's own resizing is a second ui--split
* on the dock — see _contacts_pane.html.twig.
*
* ── One pane or two ───────────────────────────────────────────────────────
*
* From WIDE up (a desktop monitor) the row holds the mail, the calendar and
* the address book side by side, and each opens and closes on its own. Below
* it — a notebook — it holds one of them beside the mail, so opening one puts
* the other away: the address book asks the calendar to `yield`, and a
* calendar position chosen while the address book is open closes it. Below lg
* there is no dock at all, and the button is the link to /contacts it always
* was.
*
* The rule is enforced in app.css as well, for the first paint and for a
* window made narrower with both open: the address book is simply not drawn
* while the calendar is beside the mail there. This controller keeps what is
* remembered in step with what the user did.
*
* ── At full size ──────────────────────────────────────────────────────────
*
* The full contacts page is this same shell with `data-contacts-full` on it
* (contacts/_page_shell.html.twig): the mail column put away and the dock
* given the row. Leaving that is `reveal`, asked for by mail--mail-pane when a
* message is opened from a contact, and by a list loaded into the mail column
* from the sidebar: the mail comes in from the left and the dock draws back to
* its width, as a view transition, with the contact as it was. From then on it
* is the mailbox with the pane open, and remembered so.
*
* ── Three positions ──────────────────────────────────────────────────────
*
* mail closed
* split docked beside the mail
* contacts full width, the mail behind it: the full page's layout
* (`data-contacts-full`) on the mailbox, so reveal() brings the
* mail back exactly as it does from /contacts
*
* The topbar button steps through them as the calendar's does (ui--split),
* and `skip` (User::$contactsSwitchSkip) leaves one of the two open ones out.
* The dock's handle reaches the two ends too: dragged past either limit it
* reports `snap`, and snapped() makes that full width or closed.
*
* ── Asked from elsewhere ──────────────────────────────────────────────────
*
* Registered in assets/panes.js as "contacts", so anything can open it on a
* contact — "Add to contacts" does, with an open_pane stream — without
* knowing this controller: show(), below.
*/
export default class extends Controller {
static targets = ["dock", "toggle"];
static values = {
open: Boolean,
// The stored position — see "Three positions". `open` is whether it
// is docked at all, what most of this file asks.
mode: { type: String, default: "mail" },
skip: { type: String, default: "none" },
// The /contacts page itself, as opposed to the mailbox in `contacts`
// position: there "less" than full width is a visit to the mail.
page: Boolean,
src: String,
stateUrl: String,
token: String,
inboxUrl: String,
};
/** The positions, in the order the button steps through them. */
static MODES = ["mail", "split", "contacts"];
/** Where the row starts to hold both panes. Matches app.css. */
static WIDE = "(min-width: 1920px)";
connect() {
// The topbar's first-paint accent, from lg up — see the button in
// _topbar.html.twig. _render draws the real state from here on.
this.toggleTargets.forEach((toggle) => toggle.classList.remove("lg:bg-accent-soft", "lg:text-accent"));
this._onReveal = (event) => {
event.detail.done = this.reveal();
};
document.addEventListener("contacts-pane:reveal", this._onReveal);
// A list loaded into the mail column — the sidebar's mail links and
// search target its frame (see ui--split) — is somebody going to the
// mail. The syncs refresh it by fetch, not as a frame load, so this
// hears only a navigation.
this._onFrameLoad = (event) => {
if ("inbox-list-frame" === event.target.id) {
this.reveal();
}
};
document.addEventListener("turbo:frame-load", this._onFrameLoad);
this._unregister = registerPane("contacts", {
available: () => this.hasDockTarget && false === this._isNarrow(),
isOpen: () => this._isFull() || true === this.openValue,
open: ({ src } = {}) => this.show(src),
close: () => this._set(false),
});
// Full width remembered from a bigger screen, on one with no dock: the
// mail, as the calendar's position is demoted there. Not written back
// — a phone is not somebody closing the pane.
if (this._isFull() && false === this.pageValue && this._isNarrow()) {
this.element.removeAttribute("data-contacts-full");
this._render(false);
return;
}
// At full size the pane is open whatever is remembered, and has the
// calendar beside it rather than taking turns — there is no mail on
// screen to share the row with.
if (this._isFull()) {
this._render(true);
return;
}
// Remembered open from a wider screen, beside a calendar this one
// shows: the calendar keeps the room, as app.css already drew it. Not
// written back — a smaller window is not somebody closing the pane.
if (true === this.openValue && false === this._isWide() && true === this._calendarShowing()) {
this._render(false);
return;
}
this._render(this.openValue);
}
disconnect() {
document.removeEventListener("contacts-pane:reveal", this._onReveal);
document.removeEventListener("turbo:frame-load", this._onFrameLoad);
this._unregister?.();
}
/**
* On screen, showing src if given: the pane opened on it when it was
* closed, the pane's frame sent there when it was open. Opening is _set(),
* as the topbar button's is, so the calendar is asked for room and the
* choice remembered the same way.
*
* The frame is pointed before the pane is opened, so _render() finds a src
* and does not start loading the list first. Asked for the page it already
* shows, it reloads: the contact was just changed, which is why it is
* being shown.
*
* @param {string} [src]
*/
show(src) {
const frame = this.hasDockTarget ? this.dockTarget.querySelector("turbo-frame#contacts-pane-frame") : null;
if (frame && src) {
if (new URL(src, location.href).href === frame.src) {
frame.reload();
} else {
frame.src = src;
}
}
if (false === this._isFull() && true !== this.openValue) {
this._set(true);
}
}
/**
* The topbar button. Below lg it is the link to /contacts it looks like,
* and so is a middle click or a modified one anywhere.
*
* On the full contacts page it puts the address book away, which leaves
* the mail: the Inbox, with the pane remembered closed.
*/
toggle(event) {
if (true === this._isNarrow() || event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) {
return;
}
event.preventDefault();
const next = this._nextMode();
// The /contacts page has no mail behind it to go back to: anything
// short of full width there is the mail, as a visit. After the write,
// not beside it: the Inbox is rendered from what is remembered, and a
// visit that overtook the write drew the pane open.
if (this.pageValue && "contacts" !== next) {
this._persist(next, { thenVisit: true }).then(() => Turbo.visit(this.inboxUrlValue));
return;
}
this._setMode(next);
}
/**
* The dock's handle let go past one of its limits (ui--side-split's
* `snap`): past the widest, full width; past the narrowest, closed.
*/
snapped(event) {
this._setMode("more" === event.detail?.toward ? "contacts" : "mail");
}
/** Where the button goes next — CalendarPaneMode::next()'s rules. */
_nextMode() {
const mode = this._mode();
if ("split" === this.skipValue) {
return "mail" === mode ? "contacts" : "mail";
}
if ("contacts" === this.skipValue) {
return "mail" === mode ? "split" : "mail";
}
const modes = this.constructor.MODES;
return modes[(modes.indexOf(mode) + 1) % modes.length];
}
/** The position on screen now, which a reveal or a drag may have moved. */
_mode() {
if (this._isFull()) {
return "contacts";
}
return true === this.openValue ? "split" : "mail";
}
/**
* The pane's own ↗: full width, in place. A modified or middle click is
* left to the link, which opens the page in a new tab.
*/
expand(event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) {
return;
}
event.preventDefault();
this._setMode("contacts");
}
/** The pane's own × . */
close(event) {
event?.preventDefault();
this._set(false);
}
/** The calendar moved — see ui--split's `mode` event. */
calendarMoved(event) {
const mode = event.detail?.mode;
// Beside the full page the two do not take turns: no mail is showing.
if (this._isFull()) {
return;
}
if (true === this.openValue && "mail" !== mode && false === this._isWide()) {
this._set(false);
}
}
/** Docked or closed, for the callers that only know those two. */
_set(open) {
this._setMode(open ? "split" : "mail");
}
_setMode(mode) {
// Docking asks the calendar for room below WIDE, as opening always
// did. Full width asks only when it opens from closed: from beside the
// mail the room is already this pane's, and full width has the
// calendar beside it rather than taking turns.
const claims = "split" === mode || ("contacts" === mode && "mail" === this._mode());
if (claims && false === this._isWide()) {
this.dispatch("claim");
}
if (false === this.pageValue) {
this.element.toggleAttribute("data-contacts-full", "contacts" === mode);
}
this._render("mail" !== mode);
this._persist(mode);
window.dispatchEvent(new CustomEvent("side-pane:changed"));
}
_render(open) {
this.openValue = open;
// Below lg there is no dock, so nothing to show as open.
const docked = open && false === this._isNarrow();
// Which position the button shows, and what pressing it does next —
// ui--split's icons and label for the calendar, the same way: every
// icon and name is in the template, translated, and only picked here.
// Below lg there is no dock, so the button is the plain link it looks
// like, in its closed position.
const mode = docked ? this._mode() : "mail";
const next = docked || false === this._isNarrow() ? this._nextMode() : "split";
this.toggleTargets.forEach((toggle) => {
toggle.classList.toggle("bg-accent-soft", docked);
toggle.classList.toggle("text-accent", docked);
toggle.classList.toggle("text-ink-muted", !docked);
toggle.setAttribute("aria-pressed", docked ? "true" : "false");
// The first paint's classes (`hidden lg:flex` and the like, see
// _topbar.html.twig) go entirely, the plain `hidden` with them:
// left on, it hid the icon of the position the pane was restored
// in, and the button was an empty square. The attribute says which
// one shows from here on, as ui--split's FIRST_PAINT_ICON does.
toggle.querySelectorAll("[data-contacts-mode-icon]").forEach((icon) => {
icon.className = "flex";
icon.toggleAttribute("hidden", icon.dataset.contactsModeIcon !== mode);
});
const label = toggle.querySelector(`[data-contacts-mode-name="${next}"]`)?.textContent;
if (undefined !== label) {
toggle.setAttribute("aria-label", label);
toggle.setAttribute("title", label);
}
});
if (false === this.hasDockTarget) {
return;
}
// The boundary keeps its hands off the width anywhere but beside the
// mail — see ui--split's _reclamp().
this.dockTarget.toggleAttribute("data-split-held", "split" !== this._mode());
// lg:flex, never flex: below lg there is no dock, open or not.
this.dockTarget.classList.add("hidden");
this.dockTarget.classList.toggle("lg:flex", open);
this.dockTarget.classList.toggle("lg:w-auto", open);
// Lazy until first shown, as the calendar's frame is; a pane rendered
// open with the page already has its content and needs no src.
const frame = this.dockTarget.querySelector("turbo-frame#contacts-pane-frame");
if (true === open && frame && !frame.getAttribute("src") && null === frame.querySelector("[data-contacts-page]")) {
frame.setAttribute("src", this.srcValue);
}
}
/**
* From the full page to the mailbox with the pane open: the mail column
* comes in from the left, the dock draws back to its own width, and the
* contact in it is the same DOM throughout — nothing reloads, nothing
* scrolls. A view transition, where the browser has them and motion is
* welcome; the same change without one otherwise.
*
* Below lg the mail does not sit beside the pane, so there it replaces it,
* as the mail does everything below lg, and nothing is remembered: a phone
* is not somebody docking the address book.
*
* @returns {Promise<void>} once the change is in the DOM
*/
reveal() {
if (false === this._isFull()) {
return Promise.resolve();
}
const change = () => {
this.element.removeAttribute("data-contacts-full");
// The mailbox from here on, so less than full width is the pane
// drawing back, not a visit to the Inbox (see toggle()).
this.pageValue = false;
// The frame's navigations were the page's while it had the row;
// the address is the mail's from here on.
this.dockTarget.querySelector("turbo-frame#contacts-pane-frame")?.removeAttribute("data-turbo-action");
if (this._isNarrow()) {
this._render(true);
} else {
this._set(true);
}
};
if (
"function" !== typeof document.startViewTransition
|| this._isNarrow()
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
change();
return Promise.resolve();
}
// Names for the two panes, for this transition only — see app.css.
this.element.setAttribute("data-contacts-revealing", "");
const transition = document.startViewTransition(change);
transition.finished.finally(() => this.element.removeAttribute("data-contacts-revealing"));
return transition.updateCallbackDone;
}
_isFull() {
return this.element.hasAttribute("data-contacts-full");
}
_calendarShowing() {
return "mail" !== (this.element.dataset.calendarMode ?? "mail");
}
_isWide() {
return window.matchMedia(this.constructor.WIDE).matches;
}
_isNarrow() {
return window.matchMedia("(max-width: 1023px)").matches;
}
/**
* Best-effort, as ui--split's is: a lost write costs the memory, not the
* layout. Settles either way, for a caller that has to come after it.
*/
_persist(mode, { thenVisit = false } = {}) {
this.modeValue = mode;
if (!this.hasStateUrlValue) {
return Promise.resolve();
}
// Through the dock's boundary when there is one, so the mode and the
// width are one writer's (ui--side-split#remember). Not before a
// visit: that has to wait for its write, and the queue does not say
// when it is done.
const boundary = false === thenVisit && this.hasDockTarget
? this.application.getControllerForElementAndIdentifier(this.dockTarget, "ui--side-split")
: null;
if (null !== boundary) {
boundary.remember({ mode });
return Promise.resolve();
}
const body = new FormData();
body.append("_token", this.tokenValue);
body.append("mode", mode);
return fetch(this.stateUrlValue, {
method: "POST",
body,
headers: { "X-Requested-With": "fetch" },
keepalive: true,
}).catch(() => {});
}
}
@@ -0,0 +1,449 @@
import { Controller } from "@hotwired/stimulus"
import { joinName, NAME_PARTS, postForm, splitName } from "../../contacts/fields.js"
/**
* contacts--quick-add: the new-contact page (templates/contacts/new.html.twig).
*
* The box is read on every pause in typing (ContactAssistController::parse()),
* and what came back is drawn as rows the person can untick, re-type or
* correct — the type is a select, because "the rules filed my mobile as a
* work number" should be one click, not a delete and a retype. Lines the
* rules could not place are asked about. "Improve with AI" swaps in the
* model's reading, with what it changed marked; "Undo AI changes" swaps the
* rules' reading back.
*
* What posts is a set of hidden inputs written from the kept rows with the
* full editor's names (writeInputs()), so create() and the editor read this
* page exactly as they read the editor. Several rows can feed one field —
* two lines placed as notes become one note — which is why the inputs are
* written in one place rather than one per row.
*/
export default class extends Controller {
static values = {
parseUrl: String,
parseToken: String,
aiUrl: String,
aiToken: String,
strings: Object,
}
static targets = [
"text", "result", "sourceBadge", "sourceDetail", "initials", "name", "partsSummary", "partsToggle",
"partsPanel", "part", "rows", "rowTemplate", "unsureBox", "unsureTitle", "unsureList", "skipped",
"aiBar", "aiAsk", "aiButton", "aiButtonText", "existing", "summary", "create", "inputs",
]
connect() {
this.rules = null
this.ai = null
this.usingAi = false
this.models = []
this.unsure = []
this.nameEdited = false
this.partsEdited = false
this.#refresh()
}
disconnect() {
this.abort?.abort()
clearTimeout(this.timer)
}
typed() {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.#read(), 300)
}
nameTyped() {
this.nameEdited = true
if (!this.partsEdited) this.#setParts(splitName(this.nameTarget.value))
this.#refresh()
}
partTyped() {
this.partsEdited = true
this.nameTarget.value = joinName(this.#parts())
this.nameEdited = true
this.#refresh()
}
toggleParts() {
const open = this.partsPanelTarget.hidden
this.partsPanelTarget.hidden = !open
this.partsToggleTarget.setAttribute("aria-expanded", String(open))
this.partsToggleTarget.textContent = open ? this.stringsValue.done : this.stringsValue.fix
}
async improve() {
if (this.usingAi) {
this.usingAi = false
this.#show(this.rules)
return
}
const text = this.textTarget.value
if (text.trim() === "") return
this.aiButtonTarget.disabled = true
this.aiButtonTextTarget.textContent = this.stringsValue.aiRunning
this.abort?.abort()
this.abort = new AbortController()
try {
this.ai = await postForm(this.aiUrlValue, this.aiTokenValue, { text }, this.abort.signal)
this.usingAi = true
this.#show(this.ai)
} catch (error) {
if (error.name === "AbortError") return
if (error.status === 409) this.aiBarTarget.remove()
else this.aiAskTarget.textContent = this.stringsValue.aiFailed
} finally {
if (this.hasAiButtonTarget) this.aiButtonTarget.disabled = false
this.#refreshAiBar()
}
}
// ── Reading ─────────────────────────────────────────────────────────
async #read() {
const text = this.textTarget.value
this.usingAi = false
this.ai = null
if (text.trim() === "") {
this.rules = null
this.nameEdited = false
this.partsEdited = false
this.#show(null)
return
}
this.sourceDetailTarget.textContent = this.stringsValue.reading
this.abort?.abort()
this.abort = new AbortController()
try {
this.rules = await postForm(this.parseUrlValue, this.parseTokenValue, { text }, this.abort.signal)
this.#show(this.rules)
} catch (error) {
if (error.name !== "AbortError") this.sourceDetailTarget.textContent = ""
}
}
#show(draft) {
this.models = (draft?.fields || []).map((f) => ({
type: f.type,
label: f.label || "",
value: f.value,
address: f.address || null,
family: this.#family(f.type),
original: `${f.type}:${f.label || ""}`,
keep: true,
reason: f.reason,
ai: this.usingAi && f.reason !== undefined,
}))
this.unsure = [...(draft?.unsure || [])]
this.draft = draft
if (draft && !this.nameEdited) this.nameTarget.value = draft.name || ""
if (draft && !this.partsEdited) this.#setParts(draft.parts && Object.keys(draft.parts).length ? draft.parts : splitName(this.nameTarget.value))
this.resultTarget.hidden = !draft
this.#renderRows()
this.#renderUnsure()
this.#renderSource()
this.#renderExisting(draft?.existing || [])
const skipped = draft?.skipped || []
this.skippedTarget.hidden = skipped.length === 0
this.skippedTarget.textContent = skipped.length ? this.stringsValue.skipped.replace("%lines%", skipped.map((l) => `“${l}”`).join(", ")) : ""
this.#refresh()
}
// ── Rows ────────────────────────────────────────────────────────────
#renderRows() {
const nodes = this.models.map((model, i) => {
const node = this.rowTemplateTarget.content.firstElementChild.cloneNode(true)
const keep = node.querySelector("[data-keep]")
const kind = node.querySelector("[data-kind]")
const value = node.querySelector("[data-value]")
keep.checked = model.keep
keep.setAttribute("aria-label", `${this.stringsValue.keep}: ${model.value}`)
keep.addEventListener("change", () => { model.keep = keep.checked; this.#dim(node, model); this.#refresh() })
for (const [key, text] of this.#kindOptions(model.family)) {
kind.add(new Option(text, key, false, key === `${model.type}:${model.label}`))
}
if (![...kind.options].some((o) => o.selected)) {
kind.add(new Option(this.#kindText(model.type, model.label), `${model.type}:${model.label}`, true, true), 0)
}
const markKind = () => {
const changed = kind.value !== model.original
kind.classList.toggle("text-accent", changed)
kind.classList.toggle("text-ink", !changed)
}
markKind()
kind.addEventListener("change", () => {
const [type, label] = kind.value.split(":")
model.type = type
model.label = label || ""
if (type !== "address") model.address = null
markKind()
this.#refresh()
})
value.value = model.value
value.setAttribute("aria-label", this.#kindText(model.type, model.label))
value.addEventListener("input", () => { model.value = value.value; model.address = null; this.#refresh() })
const note = node.querySelector("[data-note]")
const noteText = model.you ? this.stringsValue.youPlaced : (model.reason || "")
if (model.ai || noteText) {
note.hidden = false
node.querySelector("[data-ai]").hidden = !model.ai
node.querySelector("[data-note-text]").textContent = noteText
}
this.#dim(node, model)
return node
})
this.rowsTarget.replaceChildren(...nodes)
}
#dim(node, model) {
node.classList.toggle("opacity-50", !model.keep)
const value = node.querySelector("[data-value]")
value.classList.toggle("line-through", !model.keep)
value.disabled = !model.keep
node.querySelector("[data-kind]").disabled = !model.keep
}
#renderUnsure() {
const lines = this.unsure
this.unsureBoxTarget.hidden = lines.length === 0
this.unsureTitleTarget.textContent = lines.length > 1 ? this.stringsValue.unsureMany : this.stringsValue.unsureOne
this.unsureListTarget.replaceChildren(...lines.map((line) => {
const row = document.createElement("div")
row.className = "flex items-center gap-2 flex-wrap"
const text = document.createElement("span")
text.className = "flex-1 basis-56 min-w-0 text-sm font-mono text-ink break-words"
text.textContent = line
const select = document.createElement("select")
select.className = "h-8 rounded-lg border border-field bg-field px-2 text-sm text-ink shrink-0"
select.setAttribute("aria-label", `${this.stringsValue.useAs}: ${line}`)
select.add(new Option(this.stringsValue.useAs, ""))
for (const type of ["company", "jobTitle", "department", "notes"]) select.add(new Option(this.stringsValue.kinds[type], type))
select.add(new Option(this.stringsValue.ignore, "ignore"))
select.addEventListener("change", () => {
if (!select.value) return
if (select.value !== "ignore") {
this.models.push({ type: select.value, label: "", value: line, address: null, family: "text", original: `${select.value}:`, keep: true, you: true })
}
this.unsure = this.unsure.filter((l) => l !== line)
this.#renderRows()
this.#renderUnsure()
this.#renderSource()
this.#refresh()
})
row.append(text, select)
return row
}))
}
#renderSource() {
const s = this.stringsValue
const draft = this.draft
if (!draft) return
let badge = s.sourceRules
let detail = this.unsure.length ? s.detailUnsure.replace("%count%", this.unsure.length) : s.detailRules
if (draft.source === "vcard") { badge = s.sourceVcard; detail = s.detailVcard.replace("%count%", this.models.length) }
if (this.usingAi) {
badge = s.sourceAi
detail = this.models.some((m) => m.ai) ? s.detailAi : s.detailAiSame
}
this.sourceBadgeTarget.textContent = badge
this.sourceDetailTarget.textContent = detail
this.#refreshAiBar()
}
#renderExisting(existing) {
const box = this.existingTarget
box.hidden = existing.length === 0
box.replaceChildren()
if (!existing.length) return
const first = existing[0]
const text = document.createElement("span")
text.className = "flex-1 basis-60"
text.textContent = this.stringsValue.existing.replace("%name%", first.name).replace("%email%", first.email)
const link = document.createElement("a")
link.href = first.url
link.className = "text-accent font-medium hover:underline"
link.textContent = this.stringsValue.openExisting
box.append(text, link)
}
#refreshAiBar() {
if (!this.hasAiBarTarget) return
const s = this.stringsValue
this.aiBarTarget.hidden = !this.draft || this.draft.source === "vcard"
this.aiAskTarget.textContent = this.usingAi ? s.aiUndoAsk : (this.unsure.length ? s.aiAsk : s.aiAskComplete)
this.aiButtonTextTarget.textContent = this.usingAi ? s.aiUndo : s.aiButton
}
// ── Name parts ──────────────────────────────────────────────────────
#parts() {
const parts = {}
for (const input of this.partTargets) parts[input.dataset.part] = input.value.trim()
return parts
}
#setParts(parts) {
for (const input of this.partTargets) input.value = parts[input.dataset.part] || ""
}
#renderParts() {
const parts = this.#parts()
this.partsSummaryTarget.replaceChildren(...NAME_PARTS.filter((k) => parts[k]).map((k) => {
const chip = document.createElement("span")
chip.className = "inline-flex items-baseline gap-1 px-2 py-0.5 rounded-full bg-surface border border-line"
const label = document.createElement("span")
label.className = "text-ink-faint"
label.textContent = this.stringsValue.parts[k] ?? k
const value = document.createElement("span")
value.className = "text-ink font-medium"
value.textContent = parts[k]
chip.append(label, value)
return chip
}))
this.partsToggleTarget.hidden = Object.values(parts).every((v) => !v) && this.partsPanelTarget.hidden
}
// ── What posts ──────────────────────────────────────────────────────
#refresh() {
this.#renderParts()
const name = this.nameTarget.value.trim()
this.initialsTarget.textContent = (name.charAt(0) || "?").toUpperCase()
this.createTarget.disabled = name === ""
const kept = this.models.filter((m) => m.keep && m.value.trim() !== "")
const s = this.stringsValue
this.summaryTarget.textContent = name === ""
? (this.draft ? s.noName : s.summaryNone)
: (kept.length === 0 ? s.summaryName.replace("%name%", name)
: (kept.length === 1 ? s.summaryOne : s.summaryMany).replace("%name%", name).replace("%count%", kept.length))
this.#writeInputs(kept)
}
#writeInputs(kept) {
const inputs = []
const add = (name, value) => {
const input = document.createElement("input")
input.type = "hidden"
input.name = name
input.value = value
inputs.push(input)
}
const count = {}
const next = (list) => (count[list] = (count[list] ?? -1) + 1)
const scalars = {}
for (const m of kept) {
const value = m.value.trim()
switch (m.type) {
case "email":
case "phone": {
const list = m.type === "email" ? "emails" : "phones"
const i = next(list)
add(`${list}[${i}][value]`, value)
add(`${list}[${i}][label]`, m.label || "other")
if (i === 0) add(`${list}[${i}][primary]`, "1")
break
}
case "address": {
const i = next("addresses")
const parts = m.address || this.#splitAddress(value)
for (const key of ["street", "postcode", "city", "region", "country"]) add(`addresses[${i}][${key}]`, parts[key] || "")
add(`addresses[${i}][label]`, m.label || "work")
break
}
case "url": {
const i = next("urls")
add(`urls[${i}][url]`, /^https?:\/\//i.test(value) ? value : `https://${value}`)
add(`urls[${i}][label]`, m.label || "work")
break
}
case "birthday": {
const d = this.#date(value)
if (d && !scalars.birthday) {
scalars.birthday = true
add("birthday[day]", d.day)
add("birthday[month]", d.month)
add("birthday[year]", d.year)
}
break
}
case "notes":
scalars.notes = scalars.notes ? `${scalars.notes}\n${value}` : value
break
default:
if (!(m.type in scalars)) scalars[m.type] = value
}
}
for (const [key, value] of Object.entries(scalars)) {
if (key !== "birthday") add(key, value)
}
this.inputsTarget.replaceChildren(...inputs)
}
/** "Street 12, 12345 City, Country" back into parts, after somebody edited the line. */
#splitAddress(line) {
const m = line.match(/^(.*?),\s*(?:([A-Z]{1,2})[-\s])?(\d{4,5})\s+([^,]+)(?:,\s*(.+))?$/)
if (m) return { street: m[1], postcode: m[3], city: m[4], country: m[5] || "" }
return { street: line }
}
/** A date in the spellings people paste: 1990-05-21, --05-21, 21.05.1990, 21.5. */
#date(value) {
let m = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/)
if (m) return { year: m[1], month: String(+m[2]), day: String(+m[3]) }
m = value.match(/^--(\d{2})-?(\d{2})$/)
if (m) return { year: "", month: String(+m[1]), day: String(+m[2]) }
m = value.match(/^(\d{1,2})\.\s?(\d{1,2})\.(?:\s?(\d{4}))?$/)
if (m) return { year: m[3] || "", month: String(+m[2]), day: String(+m[1]) }
return null
}
// ── Types ───────────────────────────────────────────────────────────
#family(type) {
return type === "email" || type === "phone" ? type : "text"
}
#kindText(type, label) {
const kind = this.stringsValue.kinds[type] ?? type
return label && this.stringsValue.labels[label] ? `${kind} · ${this.stringsValue.labels[label]}` : kind
}
/**
* What a row can be turned into. An email stays an email and a number a
* number — only their type changes; a line of text can become any of the
* text fields, an address or a website.
*/
#kindOptions(family) {
const opt = (type, label = "") => [`${type}:${label}`, this.#kindText(type, label)]
switch (family) {
case "email": return ["work", "home", "other"].map((l) => opt("email", l))
case "phone": return ["mobile", "work", "home", "other"].map((l) => opt("phone", l))
default: return [
opt("jobTitle"), opt("company"), opt("department"), opt("nickname"), opt("notes"),
opt("address", "work"), opt("address", "home"), opt("url", "work"), opt("url", "home"), opt("birthday"),
]
}
}
}
@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
/**
* contacts--save-to: the line under "Save to" says where the chosen place
* sends the contact. Each choice — a radio in the segmented row, or an
* option in the dropdown past four books — carries its sentence in
* `data-hint`; this swaps it in. See contacts/_save_to.html.twig.
*/
export default class extends Controller {
static targets = ["hint"]
picked(event) {
const el = event.target
const hint = el.tagName === "SELECT" ? el.selectedOptions[0]?.dataset.hint : el.dataset.hint
this.hintTarget.textContent = hint ?? ""
}
}
@@ -0,0 +1,290 @@
import { Controller } from "@hotwired/stimulus";
import { askConfirm } from "../../confirm.js";
import { motionIsOff } from "../../motion.js";
import { CLICK_WINDOW_MS, registerSurface, unregisterSurface } from "../../touch_gesture.js";
/**
* The long-press action sheet for one mail row.
*
* A copy of the list's one <template> (rendered in _mailbox.html.twig, outside every
* list region), made by mail--row-hold when a hold opens it and appended to <body>. The
* copy is taken once, at connect: the row's entries are read from the row's data-sheet
* attribute at that moment, and the URLs the sheet posts are those captured then. A
* refresh that morphs the list afterwards cannot change what the open sheet does.
*
* What it shows is the server's decision (SheetAffordance, via SwipeActionResolver::sheet()):
* an entry the row's data-sheet does not carry is hidden, and a key the template does not
* know is ignored. Its job is the sheet: fill the entries, make the page behind it inert,
* close on Escape, on the backdrop, after an action, or when the row leaves the list, and
* post through the row's own write path (mail--message-row#perform), never a copy of it.
*
* Its pickers (move, labels, snooze) are the existing menus, each behind an invisible
* trigger, opened from their entry (the mail--list-toolbar#openFlyout precedent). Each
* picker is given this row's target, carried labels and label ids at open time, and those
* are Stimulus values, so the menus read them live.
*
* Registers "sheet" as an open surface (touch_gesture.js, invariant 9): while it is
* connected, a pull and a swipe are refused, and both work again once it closes.
*
* Closing: Escape, the backdrop, an action, a picker's pick (after it runs), and a row that
* leaves the list (a refresh removed it, or it is animating out). Closing removes the
* sheet, which lifts inert and returns focus to the row's checkbox.
*
* Reads the row (by its DOM id), its data-sheet, data-starred, data-label-ids,
* data-unread, and the message-row id
* Writes through mail--message-row#perform (status routes), mail--action-sheet's own
* select (the row checkbox, with a real change event)
* Routes none of its own: every URL is the captured one from the row's data-sheet
*
* Reduced motion: the slide takes zero time under motionIsOff().
*/
/** The name this sheet registers as an open surface under, see touch_gesture.js. */
const SURFACE = "sheet";
/** The page behind the sheet, made inert while it is open: the sidebar drawer's shell. */
const SHELL = '[data-controller~="ui--sidebar-drawer"]';
export default class extends Controller {
static targets = ["panel", "backdrop"];
static values = {
rowDomId: String,
};
connect() {
// Set before anything can return early, so disconnect() (which runs for the early return too)
// never reads a field connect() did not reach.
this._shell = null;
this._liftsShell = false;
this._row = document.getElementById(this.rowDomIdValue);
// A row that is no longer there (a refresh took it between the hold and this) has no sheet.
if (this._row === null) {
this.element.remove();
return;
}
const { type, placement, entries } = JSON.parse(this._row.getAttribute("data-sheet") ?? "{}");
this._entries = entries ?? {};
this._type = type ?? "thread";
this._placement = placement ?? "";
this._rowId = this._row.getAttribute("data-mail--message-row-id-value") ?? "";
this._labelIds = this._row.getAttribute("data-label-ids") ?? "";
this._returnTo = this._row.querySelector("[data-thread-select]");
this.#fill();
// The page behind is inert, and this is what keeps Tab in the sheet. The message list
// lives under the sidebar drawer's shell, so making that inert covers the whole page.
this._shell = document.getElementById("message-list")?.closest(SHELL) ?? null;
// Only an inert this sheet set is lifted on close: a shell that was already inert belongs to someone else.
if (this._shell !== null && this._shell.inert === false) {
this._shell.inert = true;
this._liftsShell = true;
}
this._onKeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.close();
}
};
document.addEventListener("keydown", this._onKeydown);
// The move picker's entries close the sheet, and so does a modal opened from it (Create label),
// which lives outside the sheet and would otherwise open under it. Captured, so the listener
// sees the click before the picker's own action (which stops propagation), and the close waits
// a tick so that action has run.
this._onPick = (event) => {
if (event.target.closest?.('[role="menuitem"], [data-controller~="ui--modal"]')) {
setTimeout(() => this.close(), 0);
}
};
this.element.addEventListener("click", this._onPick, { capture: true });
// A row a refresh removes, or one that is animating out of the list, takes the sheet with it.
this._observer = new MutationObserver(() => this.#checkRow());
this._observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-leaving"] });
// The finger that opened the sheet is still down, and when it lifts it can leave a click
// behind (a tap, to the browser), which lands on the backdrop that now covers the row. That
// click must not close the sheet it opened. Only the first touchend is the opening finger's.
this._ignoreClickUntil = 0;
this._onOpeningTouchEnd = () => {
this._ignoreClickUntil = performance.now() + CLICK_WINDOW_MS;
document.removeEventListener("touchend", this._onOpeningTouchEnd, { capture: true });
};
document.addEventListener("touchend", this._onOpeningTouchEnd, { capture: true, passive: true });
registerSurface(SURFACE, () => this.element.isConnected);
this.element.style.setProperty("--sheet-duration", motionIsOff() ? "0ms" : "200ms");
this.panelTarget.querySelector("button:not([hidden])")?.focus();
}
disconnect() {
this._observer?.disconnect();
this.element.removeEventListener("click", this._onPick, { capture: true });
document.removeEventListener("touchend", this._onOpeningTouchEnd, { capture: true });
document.removeEventListener("keydown", this._onKeydown);
unregisterSurface(SURFACE);
if (this._liftsShell) {
this._shell.inert = false;
}
if (this._returnTo?.isConnected) {
this._returnTo.focus();
}
}
close() {
this.element.remove();
}
backdropClick(event) {
if (event.target !== event.currentTarget) {
return;
}
// Within the window after the opening finger lifted, a click on the backdrop is that finger's.
if (performance.now() < this._ignoreClickUntil) {
return;
}
this.close();
}
/** An entry that posts a status route: close the sheet, confirm if it must, then post the captured URL. */
async run(event) {
const { sheetUrl: url = "", sheetBody: body = "{}", sheetConfirm: confirm = "" } = event.currentTarget.dataset;
this.close();
if (confirm !== "" && false === await askConfirm(confirm)) {
return;
}
this.#post(url, JSON.parse(body));
}
/** The snooze menu's option: the wake time arrives as a param, set by mail--snooze-menu. */
snooze(event) {
const { until = null } = event.params;
this.close();
this.#post(this._snoozeUrl, { until });
}
/** The row's checkbox, ticked with a real change event so the toolbar's count hears it. */
select() {
const box = this._row.querySelector("[data-thread-select]");
if (box !== null && false === box.checked) {
box.checked = true;
box.dispatchEvent(new Event("change", { bubbles: true }));
}
this.close();
}
/**
* Opens a picker by pressing its invisible trigger. The entry's click stops here, so the
* picker's own outside-click listeners, which it adds on open, never see the click that
* opened them. The trigger's click is a new event, which the picker's toggle expects.
*/
openPicker(event) {
event.stopPropagation();
const name = event.currentTarget.dataset.sheetPicker;
this.element.querySelector(`[data-sheet-picker-scope="${name}"] button`)?.click();
}
// ── Private ───────────────────────────────────────────────────────────
/**
* Show the entries the row offers, with the URL and body each posts to, and point the
* pickers at this row. A picker's own values are read live, so setting them before it
* is used is enough.
*/
#fill() {
const readState = this._entries.read_unread?.b?.read === true ? "mark_read" : "mark_unread";
const starState = this._row.getAttribute("data-starred") === "true" ? "unstar" : "star";
const states = { read_unread: readState, star: starState };
for (const entry of this.element.querySelectorAll("[data-sheet-entry]")) {
const key = entry.dataset.sheetEntry;
const offered = this._entries[key];
const container = entry.closest("[data-sheet-row]") ?? entry;
container.hidden = offered === undefined;
if (offered === undefined) {
continue;
}
entry.dataset.sheetUrl = offered.u ?? "";
entry.dataset.sheetBody = JSON.stringify(offered.b ?? {});
for (const variant of entry.querySelectorAll("[data-sheet-when]")) {
variant.hidden = variant.dataset.sheetWhen !== states[key];
}
}
// Each picker's first button is its invisible trigger. It is pressed, never found: it has
// the same name as the entry that opens it, so it is taken out of the accessibility tree.
for (const scope of this.element.querySelectorAll("[data-sheet-picker-scope]")) {
const trigger = scope.querySelector("button");
trigger?.setAttribute("aria-hidden", "true");
trigger?.setAttribute("tabindex", "-1");
}
this._snoozeUrl = this._entries.snooze?.u ?? "";
const move = this.element.querySelector('[data-controller~="mail--move-menu"]');
move?.setAttribute("data-mail--move-menu-target-id-value", this._rowId);
move?.setAttribute("data-mail--move-menu-carried-value", JSON.stringify(this._labelIds === "" ? [] : this._labelIds.split(",")));
move?.setAttribute("data-mail--move-menu-placement-value", this._placement);
const labels = this.element.querySelector('[data-controller~="mail--label-menu"]');
labels?.setAttribute("data-mail--label-menu-target-type-value", this._type);
labels?.setAttribute("data-mail--label-menu-target-id-value", this._rowId);
labels?.setAttribute("data-mail--label-menu-label-ids-value", this._labelIds);
}
/**
* Close when the row has left the list, or is leaving it. A stream that redraws the row (a label
* tick, a star) replaces its node under the same DOM id, so the row is looked up again by that id,
* and the sheet follows the new node: the sheet closes only when the id is gone, or the row leaves.
*/
#checkRow() {
const current = document.getElementById(this.rowDomIdValue);
if (current === null || current.hasAttribute("data-leaving")) {
this.close();
return;
}
if (current !== this._row) {
this._row = current;
this._returnTo = current.querySelector("[data-thread-select]");
}
}
/** The row's own write path: the same fetch, stream and announcement as its strip. */
#post(url, body) {
this.application.getControllerForElementAndIdentifier(this._row, "mail--message-row")?.perform(url, body);
}
}
@@ -26,6 +26,10 @@ export default class extends Controller {
static values = {
targetType: { type: String, default: "thread" },
targetId: Number,
// The ids of the labels the target carries, comma-separated, for a menu whose target is not
// rendered by the server with its labels: the action sheet's copy, which is opened for one row
// after another. Read only in single-target mode, and only when present.
labelIds: String,
};
connect() {
@@ -142,6 +146,20 @@ export default class extends Controller {
*/
_syncFromSelection() {
if (this.hasTargetIdValue) {
// A single target the server did not tick (see labelIds): the ticks are the ids it was given.
if (this.hasLabelIdsValue) {
const carried = this.labelIdsValue === "" ? [] : this.labelIdsValue.split(",");
for (const button of this.panelTarget.querySelectorAll("[data-label-id]")) {
const attached = carried.includes(button.dataset.labelId);
button.dataset.attached = attached ? "true" : "false";
button.querySelector("[data-mail--label-menu-target='check']")
?.classList.toggle("invisible", false === attached);
}
}
return;
}
@@ -114,6 +114,11 @@ const MORPHED_REGION = "rows";
const CLIENT_OWNED = new Set([
"data-entered", "data-enter", "data-enter-scope", "data-leaving",
"data-selected", "aria-current",
// The row swipe's marker and side. See SWIPED: a marked row is skipped whole, and these
// two say which row it is and which way it is open, so they are not the server's to reset.
"data-swipe-owned", "data-swipe-side",
// The long-press marker on a row and its overlay, held while a finger is on them. See mail--row-hold.
"data-pressed",
]);
/**
@@ -134,6 +139,23 @@ const CLIENT_OWNED = new Set([
*/
const MENU = '[data-ui--dropdown-target="menu"]';
/**
* A row the client is holding: under a finger, or open for its swipe actions.
*
* Skipped whole, children included, like an open menu. The server's copy of the row
* knows nothing about the drag, and its inline transform, its open panel and its
* place in the gesture are all the client's. A refresh that resets any of them
* closes a row a person is holding, or snaps it back under their finger. The marker
* is removed when the row is released (row_swipe_controller.js #reset), and the
* next refresh then brings the row up to date as usual.
*
* A refresh that REMOVES a marked row is not skipped: the morph takes the row out.
* The gesture then gets no further events for that touch, because the touch targets
* the detached node and its touchend never reaches the list. The stale sequence is
* abandoned at the next one-finger touchstart (row_swipe_controller.js #touchStart).
*/
const SWIPED = "li[data-swipe-owned]";
/**
* One row of the list, as opposed to any of the hundreds of nodes inside one.
*
@@ -237,6 +259,16 @@ export default class extends Controller {
};
document.addEventListener("turbo:before-frame-render", this._onListRendering);
// A message link from outside this pane — the contacts pane's
// "Reference emails" — asks for it here, because a Stimulus action
// only reaches controllers around the element clicked. Cancelling the
// event tells the link this pane took it (see mail--open-in-pane).
this._onOpenRequest = (event) => {
event.preventDefault();
this._openUrl(event.detail.url);
};
document.addEventListener("mail-pane:open", this._onOpenRequest);
// A refresh that came due while the tab was in the background is held
// rather than dropped, and taken the moment it is looked at again.
this._refreshPending = false;
@@ -292,6 +324,7 @@ export default class extends Controller {
this._selectionObserver?.disconnect();
document.removeEventListener("turbo:frame-load", this._onListSwap);
document.removeEventListener("turbo:before-frame-render", this._onListRendering);
document.removeEventListener("mail-pane:open", this._onOpenRequest);
document.removeEventListener("visibilitychange", this._onVisibility);
// A trailing refresh outlives this controller otherwise: Turbo replaces
@@ -356,17 +389,51 @@ export default class extends Controller {
async open(event) {
event.preventDefault();
const link = event.currentTarget;
const url = link.href;
await this._openUrl(event.currentTarget.href);
}
// Remember where to go back to, if we don't already know.
if (!this._listUrl) {
async _openUrl(url) {
const fromContacts = this._contactsFull();
// Remember where to go back to, if we don't already know. Not the full
// contacts page's own address: closing a message opened from it shows
// the Inbox, in the mail column the message brought in.
if (fromContacts) {
this._listUrl = this.inboxUrlValue;
} else if (!this._listUrl) {
this._listUrl = window.location.href;
}
await this._loadMessage(url);
history.pushState({ mailPaneOpen: true }, "", url);
// Loaded while the column was still put away, so it arrives filled.
if (fromContacts) {
await this._revealFromContacts();
this._fillBesideList();
}
}
/**
* Whether this is the full contacts page, with the mail column put away
* (contacts/_page_shell.html.twig).
*/
_contactsFull() {
return null !== document.querySelector("[data-contacts-full]");
}
/**
* Asks contacts--pane to bring the mail column in beside the address book,
* and waits until it is in the DOM — the transition itself may still be
* running. The shell owns that state, so it is asked rather than told;
* nobody answering is a page without one, where there is nothing to wait for.
*/
async _revealFromContacts() {
const request = new CustomEvent("contacts-pane:reveal", { detail: { done: null } });
document.dispatchEvent(request);
await request.detail.done;
}
close(event) {
@@ -395,6 +462,13 @@ export default class extends Controller {
async _handlePopState(event) {
const state = event.state;
// Back to the full contacts page a message was opened from. That entry
// is Turbo's, and Turbo restores the page; a list fetched here for its
// address would be a contacts page, not a list.
if (false === window.location.pathname.startsWith("/mail")) {
return;
}
if (state && state.mailPaneOpen) {
await this._loadMessage(window.location.href);
} else {
@@ -474,7 +548,8 @@ export default class extends Controller {
* widened after the page loaded is the same situation arriving late.
*/
_fillBesideList() {
if (false === this._besideList() || false === this._listNeedsRendering() || "" === this.inboxUrlValue) {
// Nothing is beside anything while the contacts have the row.
if (this._contactsFull() || false === this._besideList() || false === this._listNeedsRendering() || "" === this.inboxUrlValue) {
return;
}
@@ -766,6 +841,13 @@ export default class extends Controller {
return;
}
// The full contacts page: no list on screen, and its address would
// answer with a contacts page. The list is fetched when the mail
// column comes in.
if (this._contactsFull()) {
return;
}
// Nobody is looking. Remembered, not dropped: the visibilitychange
// handler takes it the moment the tab is looked at again.
if (document.hidden && !immediate) {
@@ -1016,9 +1098,14 @@ export default class extends Controller {
/**
* Whether this node and everything inside it may be brought up to date.
*
* Only one thing says no, and see MENU for why: a dropdown that is open.
* Two things say no: a dropdown that is open (see MENU), and a row the client
* is holding under a finger or open (see SWIPED). Both are skipped whole.
*/
_mayMorph(node) {
if (true === node.matches?.(SWIPED)) {
return false;
}
if (true !== node.matches?.(MENU)) {
return true;
}
@@ -0,0 +1,27 @@
import { Controller } from "@hotwired/stimulus";
/**
* The picture-and-video viewer's one piece of behaviour: saying so when the
* browser cannot show the file (#295).
*
* The chip offers a preview from the declared type, so the bytes get no say
* until they arrive. An octet-stream part named .jpg may turn out not to be a
* picture (serve() then sends a download, which an <img> cannot draw), and a
* .mov may be in a codec this browser does not play. Either way the element
* would sit there as a broken-image glyph or a dead player; this puts a
* sentence in its place, and the toolbar's download link is still there.
*
* `error` does not bubble, which is why the action is on the media element
* itself rather than on the frame.
*/
export default class extends Controller {
static targets = ["media", "status"];
failed() {
if (this.hasMediaTarget) {
this.mediaTarget.remove();
}
this.statusTarget.hidden = false;
}
}
@@ -130,6 +130,19 @@ export default class extends Controller {
await this.#post(readUrl, { read });
}
/**
* Post to a status route on this row, for a caller that is not a click on
* one of this row's own buttons: the row swipe, which commits on a drag as
* well as on a tap. The same fetch, stream and announcement as every action
* above, so there is one write path for a row, not a copy of it.
*
* @param {string} url the status route, as the server rendered it
* @param {object} body the JSON body the route reads; empty for most routes
*/
async perform(url, body = {}) {
await this.#post(url, body);
}
// ── Private ───────────────────────────────────────────────────────────
async #post(url, body = {}) {
@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
/**
* mail--open-in-pane: a message link outside the mail pane that opens the
* message IN it — the contacts page's "Reference emails".
*
* The list's rows use mail--mail-pane#open, which a link elsewhere cannot: a
* Stimulus action only reaches controllers around the clicked element, and the
* contacts pane sits beside the mail pane, not inside it. Left to Turbo, the
* click was a visit inside the contacts frame, which found no frame of that
* name in the mailbox page it got back and blanked the mail pane.
*
* So the click is offered to the mail pane as a `mail-pane:open` event; the
* pane cancels it to say it opened the message, beside the contacts as they
* were — on the full contacts page too, which is the mailbox with its mail
* column put away (contacts/_page_shell.html.twig). A page with no mail pane
* at all gets a visit to the message instead.
*
* Not `data-turbo-frame="_top"` on the links for that last case: ui--split
* reads a `_top` mail link as a full visit to come, and leaves a calendar
* position behind for it (its handoff). The visit then never came, and the
* next page opened on the calendar. A middle- or modified click is left to the
* browser.
*/
export default class extends Controller {
open(event) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const link = event.target.closest("a[href]");
if (null === link) {
return;
}
event.preventDefault();
const request = new CustomEvent("mail-pane:open", { cancelable: true, detail: { url: link.href } });
if (true === document.dispatchEvent(request)) {
Turbo.visit(link.href);
}
}
}
@@ -0,0 +1,364 @@
import { Controller } from "@hotwired/stimulus";
import {
CLICK_WINDOW_MS,
HOLD_MS,
Intent,
claimSequence,
createSequence,
releaseSequence,
} from "../../touch_gesture.js";
/**
* Long-press a mail row on a phone to open its action sheet.
*
* A finger that rests on a row for HOLD_MS, moving no more than HOLD_SLOP_PX, claims
* the sequence as "hold" and opens the sheet for that row (see mail--action-sheet). The
* decision is the arbiter's (touch_gesture.js): this controller only supplies the
* displacement and the time, and asks again when its own timer fires, because the
* arbiter never sees time pass by itself.
*
* Mounted on #message-list, beside the row swipe and the pull, so one set of listeners
* serves every row, including rows a refresh has just drawn. Touch only, below md.
*
* ┌──────────────────┬──────────────────────────────────────────────────────────┐
* │ Actions │ opens the sheet (mail--action-sheet) for the held row │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Reads │ li[data-sheet] on the row (presence = the row has a │
* │ │ sheet), the row's id │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Writes │ draggable on thread rows: "false" on a phone (see │
* │ │ TOUCH_ONLY_LIST), "true" elsewhere │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Claims │ "hold", via touch_gesture.js. A claimed sequence, or an │
* │ │ open surface, refuses it. Once a hold is decided a later │
* │ │ drag never becomes a swipe │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Routes │ none │
* └──────────────────┴──────────────────────────────────────────────────────────┘
*
* Clock: performance.now() for the origin, the samples and the timer, all on one
* clock. event.timeStamp is not used, because the timer and the events must agree on
* what "450ms" is, and a test clock moves only the former.
*
* Constants, each with its derivation:
* HOLD_MS = 450 and HOLD_SLOP_PX = 8 live in touch_gesture.js, with the arbiter.
* CLICK_WINDOW_MS = 300 (touch_gesture.js): the click a finger lifting from a hold may
* leave behind follows its touchend within the double-tap delay. A click inside that
* window after a hold is swallowed; one after it is not.
* HAPTIC_MS = 15: the length of the vibration when the sheet opens. navigator.vibrate
* exists on Android Chrome and not on iOS, where the call is simply absent.
*
* What it does NOT do: it does not decide what a row offers (the sheet's own data
* does), and it does not close the sheet (that is the sheet's).
*/
/** Below md: 48rem, the same query the swipe and the pull use. */
const MOBILE = "(width < 48rem)";
/**
* The phone's pointers: a touch screen below md. Rows are not native drags here.
*
* Why it is in this controller, and not in CSS or in the server's markup: a row is
* draggable because the server says so (`draggable="true"`, for a thread that is not a
* draft), and nothing CSS can do switches the attribute off; `-webkit-user-drag` only
* stops the image, and a long press on iOS may lift the row as a drag, which races the
* 450ms hold. HTML5 drag is documented as mouse-only in dnd_controller.js, so nothing a
* phone can do is lost. The server cannot know the pointer, so the list does it.
*/
const TOUCH_ONLY_LIST = "(pointer: coarse) and (width < 48rem)";
/** The attribute the server puts on a thread row that can be dragged. See _thread_row.html.twig. */
const DRAG_SOURCE = "[data-dnd-thread]";
/** A row, as opposed to any node inside one. */
const ROW = 'li[data-controller~="mail--message-row"]';
/** Vibration when the sheet opens, in milliseconds. */
const HAPTIC_MS = 15;
/**
* The attribute the pressed row and its overlay carry while a finger is on them. The
* iOS callout and selection hooks in app.css key on it. CLIENT_OWNED in mail_pane, so a
* refresh mid-press does not strip it.
*/
const PRESSED = "data-pressed";
export default class extends Controller {
connect() {
// The touch sequence in progress, or null: { row, sequence, origin, dx, dy, timer, held }.
this._active = null;
// Set when a hold opened the sheet, so the click the lifting finger may leave is swallowed.
this._consumeClickUntil = 0;
this.element.addEventListener("touchstart", this.#onStart, { passive: true });
this.element.addEventListener("touchmove", this.#onMove, { passive: true });
this.element.addEventListener("touchend", this.#onEnd, { passive: true });
this.element.addEventListener("touchcancel", this.#onCancel, { passive: true });
// Capture, so the overlay link's own click is seen, and stopped, before it acts.
this.element.addEventListener("click", this.#onClick, { capture: true });
// A long press is also a context menu on Android. While a finger is on a row, that menu is not wanted.
this.element.addEventListener("contextmenu", this.#onContextMenu);
// Rows are not native drags on a phone (see #syncDraggable). A refresh morphs the rows
// back to the server's draggable="true", so this watches the attribute as well as the list.
this._touchOnly = window.matchMedia(TOUCH_ONLY_LIST);
this._touchOnly.addEventListener("change", this.#onTouchOnlyChange);
this._draggable = new MutationObserver(() => this.#syncDraggable());
this._draggable.observe(this.element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["draggable"],
});
this.#syncDraggable();
}
disconnect() {
this.#abandon();
this.element.removeEventListener("touchstart", this.#onStart);
this.element.removeEventListener("touchmove", this.#onMove);
this.element.removeEventListener("touchend", this.#onEnd);
this.element.removeEventListener("touchcancel", this.#onCancel);
this.element.removeEventListener("click", this.#onClick, { capture: true });
this.element.removeEventListener("contextmenu", this.#onContextMenu);
this._touchOnly.removeEventListener("change", this.#onTouchOnlyChange);
this._draggable.disconnect();
}
// ── Private ───────────────────────────────────────────────────────────
#onStart = (event) => this.#touchStart(event);
#onMove = (event) => this.#touchMove(event);
#onEnd = () => this.#touchEnd();
#onCancel = () => this.#abandon();
#onClick = (event) => this.#click(event);
#onContextMenu = (event) => this.#contextMenu(event);
#onTouchOnlyChange = () => this.#syncDraggable();
/**
* Each thread row's draggable matches the pointer: "false" on a phone, "true" elsewhere.
* Written only where it differs, so the observer this triggers does not loop.
*/
#syncDraggable() {
const want = this._touchOnly.matches ? "false" : "true";
for (const row of this.element.querySelectorAll(DRAG_SOURCE)) {
if (row.getAttribute("draggable") !== want) {
row.setAttribute("draggable", want);
}
}
}
#touchStart(event) {
// A second finger is not a hold, and ends whatever a first one was.
if (event.touches.length !== 1) {
this.#abandon();
return;
}
this._consumeClickUntil = 0;
// A one-finger touchstart begins a new sequence, so whatever this controller still holds is
// stale: its touchend or touchcancel may never reach us (the row it was on was removed). Its
// timer goes, its pressed marks go, and its claim is released, before anything new is armed.
this.#abandon();
if (window.matchMedia(MOBILE).matches !== true) {
return;
}
const row = event.target.closest?.(ROW) ?? null;
if (row === null || this.element.contains(row) === false || row.hasAttribute("data-sheet") === false) {
return;
}
const touch = event.touches[0];
const origin = { x: touch.clientX, y: touch.clientY, t: performance.now() };
this._active = {
row,
sequence: createSequence(),
origin,
dx: 0,
dy: 0,
timer: null,
held: false,
};
row.setAttribute(PRESSED, "");
this.#overlayOf(row)?.setAttribute(PRESSED, "");
// The arbiter is asked again when the time has come: nothing else calls it while the finger is still.
this._active.timer = setTimeout(() => this.#decide(), HOLD_MS);
}
#touchMove(event) {
const active = this._active;
if (active === null || active.held || event.touches.length !== 1) {
return;
}
const touch = event.touches[0];
active.dx = touch.clientX - active.origin.x;
active.dy = touch.clientY - active.origin.y;
this.#decide();
}
#touchEnd() {
const active = this._active;
if (active === null) {
return;
}
this.#release(active);
if (active.held) {
this._consumeClickUntil = performance.now() + CLICK_WINDOW_MS;
releaseSequence("hold");
}
this._active = null;
}
/**
* Ask the arbiter what the sequence is now, and act on a hold. Called on every move
* and by the timer. Once held, nothing here changes again until touchend.
*/
#decide() {
const active = this._active;
if (active === null || active.held) {
return;
}
// A row a refresh removed under the finger is no hold: nothing is claimed and nothing opens.
if (active.row.isConnected === false) {
this.#release(active);
this._active = null;
return;
}
const elapsed = performance.now() - active.origin.t;
const intent = active.sequence.move(active.dx, active.dy, elapsed);
if (intent !== Intent.HOLD) {
// A decided swipe or scroll is the other gestures' business. This sequence is done with.
if (intent !== Intent.UNDECIDED) {
this.#release(active);
this._active = null;
return;
}
// Still undecided: the timer can fire a little early (performance.now() and the timer do not
// agree to the millisecond), and nothing else would ask again. Ask again for what is left.
this.#arm(active, HOLD_MS - elapsed);
return;
}
// A claim that is refused (a swipe or pull holds the sequence, or a surface is open) means no sheet.
if (claimSequence("hold") === false) {
this.#release(active);
this._active = null;
return;
}
active.held = true;
this.#release(active, { keepPressed: true });
this.#open(active.row);
}
/**
* Ask #decide again after `ms` (replacing any timer already armed). Nothing is armed when the time
* is up already: a sequence that is still undecided then is one the arbiter will not hold.
*/
#arm(active, ms) {
clearTimeout(active.timer);
active.timer = ms > 0 ? setTimeout(() => this.#decide(), ms) : null;
}
/** Clear the timer and, unless the hold is kept, the pressed marks. */
#release(active, { keepPressed = false } = {}) {
clearTimeout(active.timer);
active.timer = null;
if (keepPressed) {
return;
}
active.row.removeAttribute(PRESSED);
this.#overlayOf(active.row)?.removeAttribute(PRESSED);
}
/** A touch that is lost (the system took it, or a second finger came): nothing is left held. */
#abandon() {
const active = this._active;
this._active = null;
if (active === null) {
return;
}
this.#release(active);
if (active.held) {
releaseSequence("hold");
}
}
/** The overlay link that covers the row, which iOS would call out on. */
#overlayOf(row) {
return row.querySelector(":scope > a[tabindex='-1']");
}
/**
* Opens the sheet for the row. The sheet is a copy of the list's one <template>, made
* now, so a refresh that lands after this point cannot change what it shows or posts.
*/
#open(row) {
const template = this.element.querySelector("template[data-action-sheet-template]");
if (template === null) {
return;
}
const sheet = document.importNode(template.content.firstElementChild, true);
sheet.setAttribute("data-mail--action-sheet-row-dom-id-value", row.id);
document.body.append(sheet);
navigator.vibrate?.(HAPTIC_MS);
}
/** While a finger is on a row, the browser's own long-press menu is refused. */
#contextMenu(event) {
if (this._active !== null) {
event.preventDefault();
}
}
/** The click a finger lifted from a hold leaves behind, swallowed so the reading pane does not open. */
#click(event) {
if (performance.now() > this._consumeClickUntil) {
return;
}
this._consumeClickUntil = 0;
event.preventDefault();
event.stopPropagation();
}
}
@@ -0,0 +1,557 @@
import { Controller } from "@hotwired/stimulus";
import { motionIsOff } from "../../motion.js";
import {
DIRECTION_LOCK_PX,
Intent,
claimSequence,
createSequence,
registerSurface,
releaseSequence,
unregisterSurface,
} from "../../touch_gesture.js";
/**
* Swipe a mail row on a phone: right for its read action, left for archive.
*
* The row's directions and what they do are decided on the server (SwipeActionResolver):
* a direction with no panel on a row is inert there, and this controller reads
* that presence and nothing else. Its job is the gesture: lock the direction,
* follow the finger, decide on release, and post the panel's own URL through
* the row's mail--message-row, which is the one write path a row has.
*
* Mounted on #message-list, outside the list frame, so one set of listeners
* serves every row, including rows a refresh has just drawn. A row that is
* under a finger or open is marked (data-swipe-owned), and mail--mail-pane's
* morph leaves a marked row alone until it is released.
*
* Touch only, below md (invariant 1). Under a mouse nothing here runs.
*
* ┌──────────────────┬──────────────────────────────────────────────────────────┐
* │ Actions │ commit: the revealed button (click), and a fling or a │
* │ │ full drag, which commit without a tap │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Reads │ [data-swipe-panel="left|right"] (presence = that │
* │ │ direction is live on the row), [data-swipe-content], │
* │ │ the button's data-swipe-url and data-swipe-body │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Writes via │ mail--message-row#perform, the row's own write path │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Registers │ the "row" surface (touch_gesture.js): an open row │
* │ │ refuses a pull, and a touch on it is its own business │
* ├──────────────────┼──────────────────────────────────────────────────────────┤
* │ Routes │ none of its own │
* └──────────────────┴──────────────────────────────────────────────────────────┘
*
* Constants, each a fraction or a px figure, with its derivation:
* PARTIAL_FRACTION = 0.35 of the row's width: release past it opens the row on
* its action.
* FULL_FRACTION = 0.70: release past it commits without a tap.
* FLING_PX_PER_MS = 0.5: a release at or above this speed, in the swipe's
* direction, commits, provided the finger has gone past PARTIAL.
* VELOCITY_WINDOW_MS = 100: the speed is measured over the last 100ms of
* samples, back from the release. A finger that stopped more than that before
* lifting releases at speed 0, so a drop reads as a drop.
* REST_PX = 96: how far an open row rests, which is the width of its button.
* In px, not rem, so the rest and the button cannot drift apart under a font
* preference.
*
* Thresholds are on finger displacement from touchstart, never on the drawn
* offset, so easing and the rest position do not move them. An open row is
* different in one respect: it is closed by a drag back past PARTIAL the other
* way, and a drag that only nudges it further open keeps it open.
*
* Touches that start outside an open row close it, and are consumed: the sequence
* claims "dismiss", which holds it until the finger lifts, so no pull and no swipe
* can take it. A tap on the open row itself closes it too, and the click that
* follows is consumed, so the row beneath does not open.
*
* Reduced motion: the drawn offset jumps rather than easing, under motionIsOff().
*/
/** Release past this fraction of the row's width opens the row. */
const PARTIAL_FRACTION = 0.35;
/** Release past this fraction of the row's width commits without a tap. */
const FULL_FRACTION = 0.7;
/** Speed, in px/ms, at which a release is a fling. */
const FLING_PX_PER_MS = 0.5;
/** The window, in ms, the release speed is measured over. */
const VELOCITY_WINDOW_MS = 100;
/** How far an open row rests: the width of its revealed button. */
const REST_PX = 96;
/** The name this controller registers as a surface, see touch_gesture.js. */
const SURFACE = "row";
/** The sequence that only closes an open row. Its claim is "dismiss". */
const DISMISS = "dismiss";
/** A row, as opposed to any node inside one. */
const ROW = 'li[data-controller~="mail--message-row"]';
/** The part of the row a swipe moves. */
const CONTENT = "[data-swipe-content]";
/** The panel a direction draws, present only where the direction is live. */
const PANEL = "[data-swipe-panel]";
/** The revealed button, which commits its own panel. */
const COMMIT = "[data-swipe-commit]";
/** Below md: 48rem, the same query the pull gesture uses. */
const MOBILE = "(width < 48rem)";
/** Which way each side's displacement runs, as a sign on finger movement. */
const SIGN = Object.freeze({ left: -1, right: 1 });
/**
* Clamp a drawn offset to the side it is on, and to the row's width. A drag on
* the left side never draws the row to the right, and the other way round.
*/
function clampOffset(offset, side, width) {
return side === "left" ? Math.max(-width, Math.min(0, offset)) : Math.min(width, Math.max(0, offset));
}
/**
* The finger's speed at release, in px/ms, measured over the last
* VELOCITY_WINDOW_MS of samples. Positive is the direction of the finger's last
* move, so the sign is what the fling test reads. Zero when nothing moved in the
* window, which is what a drop looks like.
*/
function releaseVelocity(samples, endAt) {
const recent = samples.filter((sample) => sample.t >= endAt - VELOCITY_WINDOW_MS);
if (recent.length === 0 || samples.length < 2) {
return 0;
}
const last = samples[samples.length - 1];
const first = recent.length >= 2 ? recent[0] : samples[samples.length - 2];
return last.t === first.t ? 0 : (last.x - first.x) / (last.t - first.t);
}
/**
* What a release on a drag does. The thresholds are displacement from touchstart,
* and an open row is closed by a drag back past partial, not by a nudge further open.
*
* @returns {"commit"|"open"|"close"}
*/
function outcomeOf({ along, speed, width, onOpenRow }) {
if (along >= FULL_FRACTION * width) {
return "commit";
}
if (speed >= FLING_PX_PER_MS && along >= PARTIAL_FRACTION * width) {
return "commit";
}
if (onOpenRow) {
return along <= -PARTIAL_FRACTION * width ? "close" : "open";
}
return along >= PARTIAL_FRACTION * width ? "open" : "close";
}
export default class extends Controller {
connect() {
// { row, side } for the open row, or null. The surface predicate reads this, so a
// row that has left the document no longer counts as open.
this._open = null;
// The touch sequence in progress, or null. See #touchStart.
this._active = null;
// Set by a touch that closed a row, so the click that follows it is not a click on
// what is beneath. Cleared by the next touch, or by the click it consumes.
this._consumeClick = false;
this.element.addEventListener("touchstart", this.#onStart, { passive: true });
this.element.addEventListener("touchmove", this.#onMove, { passive: true });
this.element.addEventListener("touchend", this.#onEnd, { passive: true });
this.element.addEventListener("touchcancel", this.#onCancel, { passive: true });
// Capture, so a click on a row's overlay link is seen before the link acts on it.
this.element.addEventListener("click", this.#onClick, { capture: true });
// Past the phone width the gesture is off, so an open row must not stay open there: a
// rotation to a desktop width closes it, as sidebar_drawer_controller does at its own breakpoint.
this._mobile = window.matchMedia(MOBILE);
this._onBreakpoint = () => {
if (this._mobile.matches === false) {
this.#close();
}
};
this._mobile.addEventListener("change", this._onBreakpoint);
registerSurface(SURFACE, () => this._open !== null && this._open.row.isConnected);
}
disconnect() {
this.#abandon();
this.#close();
this._mobile.removeEventListener("change", this._onBreakpoint);
this.element.removeEventListener("touchstart", this.#onStart);
this.element.removeEventListener("touchmove", this.#onMove);
this.element.removeEventListener("touchend", this.#onEnd);
this.element.removeEventListener("touchcancel", this.#onCancel);
this.element.removeEventListener("click", this.#onClick, { capture: true });
unregisterSurface(SURFACE);
}
// ── Private ───────────────────────────────────────────────────────────
#onStart = (event) => this.#touchStart(event);
#onMove = (event) => this.#touchMove(event);
#onEnd = (event) => this.#touchEnd(event);
#onCancel = () => this.#abandon();
#onClick = (event) => this.#click(event);
#touchStart(event) {
this._consumeClick = false;
// A second finger ends whatever this sequence was, and is not a swipe itself.
if (event.touches.length !== 1) {
this.#abandon();
return;
}
// A one-finger touchstart begins a new sequence, so whatever this controller still
// holds is stale: its touchend or touchcancel may never reach us, because the element
// the finger was on was removed mid-drag. Abandon it, and release only its own claim.
this.#abandon();
if (window.matchMedia(MOBILE).matches !== true) {
return;
}
const row = event.target.closest?.(ROW) ?? null;
const open = this.#openRow()?.row ?? null;
// A touch outside the open row closes it, and the sequence goes no further.
const touch = event.touches[0];
const origin = { x: touch.clientX, y: touch.clientY, t: event.timeStamp };
if (open !== null && open !== row) {
this.#close();
this._active = { row: null, origin, maxReach: 0, claimed: claimSequence(DISMISS, { except: SURFACE }) };
return;
}
if (row === null || this.element.contains(row) === false || row.querySelector(PANEL) === null) {
return;
}
this._active = {
row,
sequence: createSequence(),
origin,
// The revealed button the touch began on, if any: a tap on it commits that panel.
button: event.target.closest?.(COMMIT) ?? null,
onOpenRow: open === row,
maxReach: 0,
dragging: false,
ignored: false,
claimed: false,
side: null,
width: 0,
startOffset: 0,
last: 0,
samples: [],
};
}
#touchMove(event) {
const active = this._active;
if (active === null || event.touches.length !== 1) {
return;
}
const touch = event.touches[0];
const dx = touch.clientX - active.origin.x;
const dy = touch.clientY - active.origin.y;
active.maxReach = Math.max(active.maxReach, Math.abs(dx), Math.abs(dy));
// A dismissal has nothing to drag: it only has to be consumed.
if (active.row === null || active.ignored) {
return;
}
if (active.dragging === false) {
const intent = active.sequence.move(dx, dy, event.timeStamp - active.origin.t);
if (intent === Intent.UNDECIDED) {
return;
}
// Only a horizontal decision is a swipe. A vertical one is a scroll or a pull, and
// must not claim the sequence here: the pull is refused by the claim, not by luck.
// An open row moves on its own side, whichever way the finger went: a drag back is
// how it closes. A closed row locks to the side the finger went.
const horizontal = intent === Intent.LEFT || intent === Intent.RIGHT;
const lock = !horizontal ? null : active.onOpenRow && this._open !== null ? this._open.side : sideOf(intent);
if (lock === null || active.row.querySelector(`[data-swipe-panel="${lock}"]`) === null || claimSequence("swipe", { except: SURFACE }) === false) {
active.ignored = true;
return;
}
active.dragging = true;
active.claimed = true;
active.side = lock;
active.width = active.row.getBoundingClientRect().width;
active.startOffset = active.onOpenRow ? SIGN[lock] * REST_PX : 0;
}
active.last = dx;
active.samples.push({ x: dx, t: event.timeStamp });
this.#render(active.row, active.side, clampOffset(active.startOffset + dx, active.side, active.width), true);
}
#touchEnd(event) {
const active = this._active;
this._active = null;
if (active === null) {
return;
}
if (active.row === null) {
releaseSequence(DISMISS);
// A tap outside the row leaves a click behind it; a drag does not.
this._consumeClick = active.maxReach < DIRECTION_LOCK_PX;
return;
}
if (active.claimed === false) {
// A tap on the open row closes it, and the click after it is not the row's to take.
// A tap on the revealed button is that button's commit, by touch, exactly as its click
// is: the click the browser then synthesises is consumed, so the button posts once.
if (active.onOpenRow && active.maxReach < DIRECTION_LOCK_PX) {
this.#close();
if (active.button !== null) {
this.#post(active.row, active.button.dataset.swipeUrl, JSON.parse(active.button.dataset.swipeBody || "{}"));
}
this._consumeClick = true;
}
return;
}
releaseSequence("swipe");
// The row left the document, and this touchend arrived anyway: the touch did not stay on the
// removed node, so nothing is committed and nothing is shown. A removed row whose touch ended
// elsewhere never reaches this point; #touchStart abandons that stale sequence instead.
if (active.row.isConnected === false) {
this._open = this._open?.row === active.row ? null : this._open;
return;
}
const sign = SIGN[active.side];
const outcome = outcomeOf({
along: sign * active.last,
speed: sign * releaseVelocity(active.samples, event.timeStamp),
width: active.width,
onOpenRow: active.onOpenRow,
});
if (outcome === "commit") {
this.#close();
this.#reset(active.row);
this.#commitPanel(active.row, active.side);
return;
}
if (outcome === "open") {
this.#close();
this.#open(active.row, active.side);
return;
}
this.#close();
this.#reset(active.row);
}
/**
* Drop whatever sequence is in progress. A drag that was following the finger
* settles where it was, so an open row stays open and a closed one returns home.
*/
#abandon() {
const active = this._active;
this._active = null;
if (active === null) {
return;
}
if (active.row === null) {
releaseSequence(DISMISS);
return;
}
if (active.claimed) {
releaseSequence("swipe");
}
if (active.row.isConnected === false || active.claimed === false) {
return;
}
if (this._open !== null && this._open.row === active.row) {
this.#open(active.row, this._open.side);
} else {
this.#reset(active.row);
}
}
/**
* The revealed button's click, commits its own URL and body on this row. Handled
* here rather than by a Stimulus action on the button, so the markup carries no
* action attribute for every row. The close comes first, so the row is back at
* rest before the stream redraws it.
*
* Otherwise, a click the last touch asked to be consumed is stopped here.
*/
#click(event) {
// The click a consumed touch leaves behind, whatever it landed on. A touch that committed
// the button has already posted, so its click must not post again.
if (this._consumeClick) {
this._consumeClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
const commit = event.target.closest?.(COMMIT);
if (commit) {
const row = commit.closest(ROW);
event.stopPropagation();
if (row !== null) {
this.#close();
this.#post(row, commit.dataset.swipeUrl, JSON.parse(commit.dataset.swipeBody || "{}"));
}
}
}
/** Rest the row open on its side, and remember it. */
#open(row, side) {
this._open = { row, side };
this.#render(row, side, SIGN[side] * REST_PX, false);
}
/** Close the open row, if there is one. */
#close() {
const open = this.#openRow();
this._open = null;
if (open !== null) {
this.#reset(open.row);
}
}
/**
* The open row, or null. A row that a refresh has removed from the document is
* not open any more, and is forgotten here, so it cannot make the next touch on
* the list look like a touch outside an open row.
*/
#openRow() {
if (this._open !== null && this._open.row.isConnected === false) {
this._open = null;
}
return this._open;
}
/** Post the panel's own URL and body through the row's write path. */
#commitPanel(row, side) {
const button = row.querySelector(`[data-swipe-panel="${side}"] ${COMMIT}`);
if (button === null) {
return;
}
this.#post(row, button.dataset.swipeUrl, JSON.parse(button.dataset.swipeBody || "{}"));
}
#post(row, url, body) {
this.application.getControllerForElementAndIdentifier(row, "mail--message-row")?.perform(url, body);
}
/**
* Draw the row at an offset, on a side. The owned marker is set first, so a
* refresh that lands now is held off this row (see mail--mail-pane's _mayMorph).
*/
#render(row, side, offset, dragging) {
row.dataset.swipeOwned = "";
row.dataset.swipeSide = side;
row.style.setProperty("--swipe-reveal", `${Math.abs(offset)}px`);
const content = row.querySelector(CONTENT);
if (content === null) {
return;
}
content.style.transform = offset === 0 ? "" : `translateX(${offset}px)`;
// No easing while the finger is on the row, and none at all under reduced motion.
content.style.transition = dragging || motionIsOff() ? "none" : "";
}
/** Take the row back to rest, and release the marker that kept a refresh off it. */
#reset(row) {
delete row.dataset.swipeOwned;
delete row.dataset.swipeSide;
row.style.removeProperty("--swipe-reveal");
const content = row.querySelector(CONTENT);
if (content !== null) {
content.style.transform = "";
// The stylesheet eases every transform on the content (180ms), so a reset under reduced
// motion must say "none" outright, or the close and the snap back would still animate.
content.style.transition = motionIsOff() ? "none" : "";
}
}
}
/** The side a horizontal intent locks to, or null for a vertical or a hold. */
function sideOf(intent) {
if (intent === Intent.LEFT) {
return "left";
}
if (intent === Intent.RIGHT) {
return "right";
}
return null;
}
+10 -1
View File
@@ -357,6 +357,9 @@ export default class extends Controller {
token: `${operator}:${this.#quote(contact.email)}`,
label: contact.displayName || contact.email,
hint: contact.email,
// Address-book hits carry the contact's photo when it has one;
// harvested correspondents send photoUrl: null.
photoUrl: contact.photoUrl ?? null,
}));
} else if (VALUES[operator]) {
items = VALUES[operator]
@@ -743,6 +746,12 @@ export default class extends Controller {
#suggestionMarkup(item, index) {
const icon = item.kind === "contact" ? "fa-user" : "fa-terminal";
const label = item.label ?? item.token;
// Address-book contacts show their photo when they have one; everyone
// else keeps the icon. The URL is same-origin and owner-gated.
const avatar = item.photoUrl
? `<img src="${this._escape(item.photoUrl)}" alt="" aria-hidden="true"
class="w-3.5 h-3.5 rounded-full object-cover flex-shrink-0">`
: `<i class="fa-solid ${icon} text-ink-faint w-3.5 flex-shrink-0" aria-hidden="true"></i>`;
return `
<li role="presentation">
@@ -755,7 +764,7 @@ export default class extends Controller {
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-ink-soft
hover:bg-hover transition-colors text-left"
>
<i class="fa-solid ${icon} text-ink-faint w-3.5 flex-shrink-0" aria-hidden="true"></i>
${avatar}
<span class="font-mono text-xs text-ink truncate">${this._escape(label)}</span>
${item.hint ? `<span class="ml-auto text-xs text-ink-faint truncate">${this._escape(item.hint)}</span>` : ""}
</button>
@@ -46,12 +46,14 @@ export default class extends Controller {
// ("mail--message-row"), which has no faithful camelCase form and
// so cannot be round-tripped through the dataset API.
//
// Both are set unconditionally. Only one has a controller listening
// All are set unconditionally. Only one has a controller listening
// on any given surface, and the unused attribute is inert — cheaper
// than teaching this controller which surface it is on.
// than teaching this controller which surface it is on. The last one
// is the long-press sheet's, which posts its own captured URL.
element.setAttribute("data-mail--message-row-until-param", at.toISOString());
element.setAttribute("data-mail--list-toolbar-until-param", at.toISOString());
element.setAttribute("data-mail--message-actions-until-param", at.toISOString());
element.setAttribute("data-mail--action-sheet-until-param", at.toISOString());
const when = element.querySelector("[data-snooze-when]");
@@ -64,10 +64,18 @@ export default class extends Controller {
return;
}
// Which step failed is the whole diagnosis. Registering the worker,
// subscribing and the server's answer break for different reasons
// (a proxy, the push service, CSRF or configuration), and one shared
// "something went wrong" left a report saying nothing about which.
let step = "worker";
try {
const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
await navigator.serviceWorker.ready;
step = "subscribe";
// An existing subscription may be bound to a rotated VAPID key, in
// which case subscribe() throws; dropping it first is cheaper than
// parsing the error.
@@ -84,6 +92,8 @@ export default class extends Controller {
const json = subscription.toJSON();
step = "server";
const response = await fetch(this.subscribeUrlValue, {
method: "POST",
credentials: "same-origin",
@@ -99,7 +109,7 @@ export default class extends Controller {
});
if (false === response.ok) {
this._setState("error");
this._fail(step, `HTTP ${response.status}`);
return;
}
@@ -108,7 +118,7 @@ export default class extends Controller {
this._setState("pending");
this._pollStatus(6);
} catch (e) {
this._setState("error");
this._fail(step, e);
}
}
@@ -205,13 +215,32 @@ export default class extends Controller {
}, 1000);
}
_setState(state) {
/**
* The error state, worded for the step that failed and naming the cause.
*
* The cause is the DOMException's name (AbortError, InvalidAccessError,
* SecurityError…) or the HTTP status: short, the same in every language,
* and exactly what a bug report needs. The full exception goes to the
* console for anyone who can open it.
*/
_fail(step, cause) {
const detail = cause instanceof Error ? cause.name : String(cause);
if (cause instanceof Error) {
console.error(`plMail: enabling notifications failed at the ${step} step`, cause);
}
this._setState("error", this._label(`error_${step}`).replace("%error%", detail));
}
/** `text` overrides the state's own label, for an error that names its cause. */
_setState(state, text = null) {
if (true === this.hasStateTarget) {
this.stateTarget.dataset.pushState = state;
// The data attribute alone is invisible — the label has to be
// written, or the pane sits on its server-rendered "Checking…"
// forever no matter what the real state is.
this.stateTarget.textContent = this._label(state);
this.stateTarget.textContent = text ?? this._label(state);
}
if (true === this.hasToggleTarget) {
@@ -49,6 +49,31 @@ export default class extends Controller {
form.submit();
}
/**
* submit(), once typing pauses: for a search that answers as it is
* typed. On `input`, not `change`, which a text field only fires when it
* loses focus. One timer per controller, so each key restarts it and a word
* typed quickly is one request, not one per letter.
*/
submitSoon(event) {
clearTimeout(this._soon);
this._soon = setTimeout(() => this.submit(event), 250);
}
/**
* Drops a submitSoon() still waiting. For a container holding the search
* and other forms (the add-to-contacts dialog): a pick submitted while a
* search is pending must not be followed by that search, whose answer
* would land in a dialog already closing.
*/
cancel() {
clearTimeout(this._soon);
}
disconnect() {
clearTimeout(this._soon);
}
/**
* Select the whole value, for a read-only field somebody is about to copy.
*
@@ -0,0 +1,43 @@
import { Controller } from "@hotwired/stimulus";
import { openPane } from "../../panes.js";
/**
* A link that opens in a side pane when there is one, and is a plain link
* when there is not.
*
* <a href="/contacts/new/full"
* data-controller="ui--pane-link"
* data-ui--pane-link-pane-value="contacts"
* data-action="click->ui--pane-link#open">More details</a>
*
* The pane is asked through assets/panes.js, so it opens if it was closed
* and goes to the link if it was open, as every other opener's does. Below lg,
* or on a page with no such pane, openPane() says no and the click is left
* alone: the link goes where it says, as a page.
*
* A modifier or middle click is always the link — somebody asking for a new
* tab gets one.
*
* Dispatches `ui--pane-link:opened` when the pane took it, for whatever the
* link sits in to react to: "More details" in the add-to-contacts dialog
* closes the dialog on it (`ui--pane-link:opened->ui--modal#close`). Not a
* close in this controller, which has no business knowing about dialogs, and
* not a second click action, which would close the dialog — and cancel the
* navigation with it — on the fallback too.
*/
export default class extends Controller {
static values = { pane: String };
open(event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
return;
}
if (false === openPane(this.paneValue, { src: this.element.getAttribute("href") ?? undefined })) {
return;
}
event.preventDefault();
this.dispatch("opened");
}
}
@@ -0,0 +1,12 @@
/*
* ui--split, under a second name, for a docked pane that is not the calendar.
*
* The same class: the drag, the clamp, the arrow keys, the double-click reset
* and the coalesced persist are exactly ui--split's, with its modes switched
* off (modes-value="false"). Registered again rather than nested as a second
* `ui--split` because that name means "the calendar's boundary" to more than
* this file: the stylesheet's calendar positions, and every spec that finds
* the shell or its handle by it. A second element answering to it made all of
* those ambiguous. See _contacts_pane.html.twig.
*/
export { default } from "./split_controller.js";
+92 -2
View File
@@ -49,6 +49,24 @@ import { Controller } from "@hotwired/stimulus";
* `variable-value` says which custom property the boundary moves, so the two
* cannot write over each other. That is the only thing in here that ever knew
* a pane by name.
*
* ── A second docked pane ──────────────────────────────────────────────────
*
* The address book docks beside the mail too, with a modeless boundary of its
* own on its wrapper: this class again, registered as ui--side-split (see
* side_split_controller.js and _contacts_pane.html.twig). The mail pane is
* outside that wrapper, so `main-selector-value` finds it instead of a target.
*
* Its positions are contacts--pane's, not this boundary's, so it is modeless
* with `snap-value="true"`: the band and the threshold as the calendar has
* them, and a release past either end reported as `snap` for contacts--pane to
* turn into full width or closed — the calendar's drag, without this class
* learning a second set of positions.
*
* Whether the two can be docked at once is contacts--pane's to decide. This
* side only reports: every position the user moves the calendar to is
* dispatched as `ui--split:mode`, and `yield` puts the calendar back on the
* mail when the address book needs the room.
*/
export default class extends Controller {
static targets = ["wrapper", "pane", "handle", "main"];
@@ -59,6 +77,12 @@ export default class extends Controller {
// Whether this boundary has the three positions at all. Without them
// the handle is a handle and nothing else.
modes: { type: Boolean, default: true },
// The rubber band and its threshold for a boundary WITHOUT positions
// of its own: a release past either end dispatches `ui--split:snap`
// (`ui--side-split:snap` for the address book) with `toward` "more"
// or "less", and whoever owns the positions decides what that is.
// See "A second docked pane" above.
snap: { type: Boolean, default: false },
// Which position the switch skips: "none" (the three-way cycle),
// "split" or "calendar". User::$calendarSwitchSkip. See _nextMode().
skip: { type: String, default: "none" },
@@ -72,6 +96,9 @@ export default class extends Controller {
// The pane's own minimum still stops it going further.
mainMin: { type: Number, default: 340 },
mode: { type: String, default: "mail" },
// The mail pane, for a boundary that cannot have it as a target. See
// "A second docked pane" above.
mainSelector: { type: String, default: "" },
step: { type: Number, default: 24 },
default: { type: Number, default: 380 },
};
@@ -100,6 +127,9 @@ export default class extends Controller {
this._onResize = () => this._reclamp();
window.addEventListener("resize", this._onResize);
// A docked pane opening or closing changes what the mail side has, as
// a window resize does, and is answered the same way.
window.addEventListener("side-pane:changed", this._onResize);
// A plain boundary is done here: there is no position to restore, and
// nothing on the page for a mail click to demote.
@@ -247,6 +277,7 @@ export default class extends Controller {
disconnect() {
window.removeEventListener("resize", this._onResize);
window.removeEventListener("side-pane:changed", this._onResize);
if (this._onMailClick) {
document.removeEventListener("click", this._onMailClick, true);
@@ -404,6 +435,16 @@ export default class extends Controller {
return;
}
// The same for a boundary whose positions are somebody else's: while
// they have it anywhere but beside the mail (`data-split-held`, set by
// contacts--pane) the pane is not at its own width — full width it is
// the row's — and clamping that would put the row's width into the
// variable, so the next "beside the mail" came back as wide as the
// limit instead of as it was left.
if (this.element.hasAttribute("data-split-held")) {
return;
}
// A pane that is not laid out has no width to re-clamp, and clamping
// its zero up to the minimum would throw away the stored width for the
// rest of the session. The appearance preview is `display: none` below
@@ -446,6 +487,17 @@ export default class extends Controller {
this._setMode(this._nextMode());
}
/**
* Another docked pane needs the room: back to the mail, if the calendar
* is showing at all. Dispatched by contacts--pane, which has already
* decided the row cannot hold both.
*/
yield() {
if (true === this.modesValue && "mail" !== this.modeValue) {
this._setMode("mail");
}
}
_nextMode() {
// With a position skipped, one press from the mail is the one that is
// left, and one press from anywhere else is the mail. The skipped
@@ -477,6 +529,10 @@ export default class extends Controller {
_setMode(mode) {
this._render(mode);
// Chosen, not restored — connect() calls _render directly — so the
// other docked pane hears only what the user did. See contacts--pane.
this.dispatch("mode", { detail: { mode } });
// Entering split honours the stored width, and the stored width can
// be one a BIGGER window chose — up to the maximum. On a window just
// above lg that leaves the mail a sliver beside a near-fullscreen
@@ -672,6 +728,26 @@ export default class extends Controller {
// No band, no threshold — just keep where it was let go, and write it.
// The grip's flare is cleared here as well: it says something about the
// pointer, and the pointer has gone.
if (false === this.modesValue && true === this.snapValue) {
const strain = this._strain ?? 0;
this._strain = 0;
this.handleTarget?.classList.remove("is-straining");
if (Math.abs(strain) >= this.constructor.RUBBER_THRESHOLD) {
// The width the drag started from, not where it was let go:
// past the limit that is the limit, and coming back beside the
// mail should give back the pane as it was before the snap.
this._apply(this._startWidth);
this.dispatch("snap", { detail: { toward: strain > 0 ? "more" : "less" } });
return;
}
this._animateTo(this._currentWidth());
return;
}
if (false === this.modesValue) {
this._strain = 0;
this.handleTarget?.classList.remove("is-straining");
@@ -698,6 +774,17 @@ export default class extends Controller {
this._animateTo(this._currentWidth());
}
/**
* Fields written with the next width, through the same coalesced queue —
* for whoever owns this boundary's positions (contacts--pane, its mode).
* One writer per endpoint, as the calendar has: two separate requests to
* the same settings bag race, and the one that lands last writes the bag
* back without the other's change, which lost a width just set.
*/
remember(fields) {
this._persist(fields);
}
_stopListening() {
window.removeEventListener("pointermove", this._onMove);
window.removeEventListener("pointerup", this._onUp);
@@ -756,7 +843,7 @@ export default class extends Controller {
if (true === this._dragging) {
this._strain = width - bounded;
if (true === this.modesValue) {
if (true === this.modesValue || true === this.snapValue) {
drawn = bounded + this._rubber(this._strain);
}
@@ -787,7 +874,10 @@ export default class extends Controller {
_combinedWidth() {
const pane = this.hasPaneTarget ? this.paneTarget.getBoundingClientRect().width : 0;
const main = this.hasMainTarget ? this.mainTarget.getBoundingClientRect().width : 0;
const mainElement = this.hasMainTarget
? this.mainTarget
: ("" !== this.mainSelectorValue ? document.querySelector(this.mainSelectorValue) : null);
const main = null !== mainElement ? mainElement.getBoundingClientRect().width : 0;
// No main pane to measure (the calendar page itself) — fall back to the
// row, which is the same thing minus a sidebar.
+99
View File
@@ -0,0 +1,99 @@
/**
* The side panes beside the mail, by name: one place to open one, close one,
* or show something in one, for whoever needs to.
*
* Each pane already knows how to open itself — whether there is room, what
* it has to ask the calendar for, what to remember (see contacts--pane). What
* was missing is a way for anything else to ask. Without one, every feature
* that wanted "and show it in the address book" would reach into that
* controller, or dispatch an event of its own that only it and the pane knew
* about, and the next such feature would do it again a little differently.
*
* So a pane registers itself here by name, and callers use the name:
*
* import { openPane } from "../panes.js";
* openPane("contacts", { src: "/contacts/12" });
*
* and a server response can ask for the same thing without any script of its
* own, as a turbo-stream:
*
* <turbo-stream action="open_pane" pane="contacts" src="/contacts/12"></turbo-stream>
*
* which is what "Add to contacts" answers with: the contact just made or added
* to, in the pane.
*
* ── What a pane registers ────────────────────────────────────────────────
*
* available() whether it can be shown at all here — no dock below
* lg, for instance. A pane that is not available is
* neither opened nor pointed anywhere, and openPane()
* says so by returning false.
* isOpen() whether it is on screen.
* open({ src }) on screen, showing src when one is given. Open already
* is fine: then it only navigates.
* close()
*
* Only contacts--pane registers today. The calendar's pane is ui--split's and
* has positions rather than open/closed; it can register here the day
* something needs to ask for it.
*/
import * as Turbo from "@hotwired/turbo";
/** @type {Map<string, {available: () => boolean, isOpen: () => boolean, open: (options: {src?: string}) => void, close: () => void}>} */
const panes = new Map();
/**
* Called from the pane's connect(). Returns the unregister, for its
* disconnect(): a Turbo visit replaces the shell and with it the controller,
* and a registry still holding the old one would open a pane that is gone.
* Only the same registration is removed, so a new shell that connected
* before the old one disconnected keeps its place.
*/
export function registerPane(name, pane) {
panes.set(name, pane);
return () => {
if (panes.get(name) === pane) {
panes.delete(name);
}
};
}
/**
* Opens the pane, showing src if given. False when there is no such pane on
* this page or it cannot be shown here, so a caller can fall back.
*
* @param {string} name
* @param {{src?: string}} [options]
* @returns {boolean}
*/
export function openPane(name, options = {}) {
const pane = panes.get(name);
if (undefined === pane || false === pane.available()) {
return false;
}
pane.open(options);
return true;
}
/** @param {string} name */
export function closePane(name) {
panes.get(name)?.close();
}
/** @param {string} name */
export function isPaneOpen(name) {
return true === panes.get(name)?.isOpen();
}
// The stream actions. Neither needs a target: the pane is found by name.
Turbo.StreamActions.open_pane = function () {
openPane(this.getAttribute("pane") ?? "", { src: this.getAttribute("src") ?? undefined });
};
Turbo.StreamActions.close_pane = function () {
closePane(this.getAttribute("pane") ?? "");
};
+274 -6
View File
@@ -11,6 +11,25 @@
inside it, the way it styles its own hover. */
@custom-variant lit (&:where(.is-lit, .is-lit > *));
/* A month day opened: under the pointer, or tapped on a screen that has no
pointer to hover with (#253). `group-hover/cell:` was the whole of it, and
Tailwind gates `hover:` on `(hover: hover)`, so on a phone a day never opened
and its narrowest days were bars nobody could read. calendar/month_tap_controller.js
puts `data-open` on the day a finger tapped; everything a hover opened, this
opens, from the same classes. The cell itself as well as what is inside it,
for the `z-40` that lifts it over its neighbours. */
@custom-variant day-open {
@media (hover: hover) {
&:where(.group\/cell:hover, .group\/cell:hover *) {
@slot;
}
}
&:where(.group\/cell[data-open], .group\/cell[data-open] *) {
@slot;
}
}
/* ═══ Palette channels + runtime knobs ══════════════════════════════════════
Unlayered on purpose: these are variable definitions, not utilities, and
unlayered declarations outrank Tailwind's cascade layers.
@@ -2656,16 +2675,34 @@
told to grow, which is what `flex: 1 1 0%` says and what `width: auto`
un-says about the stored pixel count. The handle goes too: there is no
boundary left to drag when one side has all of it. */
[data-calendar-mode="calendar"] [data-ui--split-target="wrapper"],
[data-calendar-mode="calendar"] [data-ui--split-target="pane"] {
[data-calendar-mode="calendar"] [data-side-pane="calendar"],
[data-calendar-mode="calendar"] [data-side-pane="calendar"] [data-ui--split-target="pane"] {
flex: 1 1 0%;
width: auto;
}
[data-calendar-mode="calendar"] [data-ui--split-target="handle"] {
[data-calendar-mode="calendar"] [data-side-pane="calendar"] [data-ui--split-target="handle"] {
display: none;
}
/* ═══ The address book beside the calendar ═══════════════════════════════════
Both are docked panes (_side_pane.html.twig), and from 1920px up the row
holds them side by side. Below that it holds one beside the mail, so the
address book is not drawn while the calendar is — contacts--pane keeps what
is remembered in step, but the layout must not wait for it: a first paint
with both remembered open, or a window made narrower with both showing,
would otherwise squeeze the mail under three panes. And a calendar that has
the whole row has it at every width. The numbers match contacts--pane. */
[data-calendar-mode="calendar"] [data-side-pane="contacts"] {
display: none;
}
@media (max-width: 1919px) {
[data-calendar-mode="split"] [data-side-pane="contacts"] {
display: none;
}
}
/* Split below lg is Calendar. The row cannot hold a sidebar, a usable message
list and a usable calendar at once under 1024px, so rather than squeezing
both under their minimums the calendar takes over — the same behaviour the
@@ -2677,6 +2714,82 @@
}
}
/* ═══ The address book at full size ═════════════════════════════════════════
The full contacts page is the mailbox with `data-contacts-full` on the shell
(contacts/_page_shell.html.twig): the mail column put away and the address
book's dock given what is left of the row, at every width. A calendar the
user keeps open stays beside it — there is no mail here to take turns with.
After the rules above, which they have to beat: a calendar that covers the
mail, or splits it on a notebook, hides the dock, and its .main-pane rule
catches the dock's pane as well. Same specificity, later in the file.
The dock's own way out to the full page, its × and its handle go while it
has the row — each of them is about a boundary that is not there. */
[data-contacts-full] [data-mail-column] {
display: none;
}
[data-contacts-full] [data-side-pane="contacts"],
[data-contacts-full] [data-side-pane="contacts"] .main-pane {
display: flex;
flex: 1 1 0%;
width: auto;
}
[data-contacts-full] [data-side-pane="contacts"] [data-ui--side-split-target="handle"],
[data-contacts-full] [data-contacts-pane-actions] {
display: none;
}
/* Leaving it — a message opened from the contact — is a view transition (see
contacts--pane#reveal): the mail column comes in from the left and the
address book draws back to its docked width, the same DOM throughout. The
names are only given for the length of it, so no other change of the page
is animated by accident. The two pictures of the address book are pinned to
its right edge and not stretched: the contact narrows, it does not squash. */
[data-contacts-revealing] [data-mail-column] {
view-transition-name: contacts-reveal-mail;
}
[data-contacts-revealing] [data-side-pane="contacts"] {
view-transition-name: contacts-reveal-dock;
}
[data-contacts-revealing] [data-side-pane="calendar"] {
view-transition-name: contacts-reveal-calendar;
}
::view-transition-group(contacts-reveal-mail),
::view-transition-group(contacts-reveal-dock),
::view-transition-group(contacts-reveal-calendar) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-group(contacts-reveal-dock) {
overflow: clip;
}
::view-transition-old(contacts-reveal-dock),
::view-transition-new(contacts-reveal-dock) {
height: 100%;
object-fit: none;
object-position: right top;
animation-duration: 280ms;
}
::view-transition-new(contacts-reveal-mail) {
animation: contacts-reveal-mail 280ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes contacts-reveal-mail {
from {
opacity: 0;
transform: translateX(-2rem);
}
}
/* The switch itself needs no rule here, deliberately. Which icon is drawn is
the `hidden` ATTRIBUTE on the other two, and the accented state is two
utility classes — both written by the server and toggled by ui--split. A
@@ -2692,13 +2805,15 @@
switches position; short of it, this transition is what puts the pane back.
Only ever on for the length of that settle: a transition left on would make
every drag lag a frame behind the pointer. */
[data-ui--split-target="pane"].is-settling {
[data-ui--split-target="pane"].is-settling,
[data-ui--side-split-target="pane"].is-settling {
transition: width 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* And the grip says the same thing, so the affordance is visible on a handle
that is only two pixels wide. */
[data-ui--split-target="handle"].is-straining .split-grip {
[data-ui--split-target="handle"].is-straining .split-grip,
[data-ui--side-split-target="handle"].is-straining .split-grip {
background-color: rgb(var(--rgb-accent));
transform: scaleY(1.6);
}
@@ -2709,11 +2824,13 @@
@media (prefers-reduced-motion: reduce) {
[data-ui--split-target="pane"].is-settling,
[data-ui--side-split-target="pane"].is-settling,
.split-grip {
transition: none;
}
[data-ui--split-target="handle"].is-straining .split-grip {
[data-ui--split-target="handle"].is-straining .split-grip,
[data-ui--side-split-target="handle"].is-straining .split-grip {
transform: none;
}
}
@@ -3769,6 +3886,21 @@ html {
}
}
/* An invitation not answered yet, in a month cell (see _event_chip): its bar is
dashed. A mask rather than a dashed border, because the bar is a background
(a gradient, for a meeting on several calendars) and a mask cuts whichever
it is. Across the bar below 5.5rem, where the bar is the whole chip and
lies on its side. */
[data-awaiting-bar] {
mask-image: repeating-linear-gradient(to bottom, black 0 3px, transparent 3px 5px);
}
@container (max-width: 5.5rem) {
[data-awaiting-bar] {
mask-image: repeating-linear-gradient(to right, black 0 6px, transparent 6px 9px);
}
}
/* The slot an answered invitation is about to take, drawn by calendar--invite
before the server has redrawn the grid.
@@ -5707,3 +5839,139 @@ html[data-dnd-active] [data-dnd-ghost] {
.mail-pull-indicator[data-state="release"] {
color: var(--accent-text);
}
/* ── Row swipe ─────────────────────────────────────────────────────────────
The action revealed on each side of a row, on a phone. row_swipe_controller.js
moves the row's content (data-swipe-content) with an inline transform, sets
data-swipe-side on the row while it is dragged or open, and sizes the panel
with --swipe-reveal. The panels themselves come from the server: one per
direction that does something on that row, so a direction with no panel is
inert and draws nothing.
Touch only, below md: a mouse never reaches these rules, and the row claims
no horizontal pan (pan-y), so the browser leaves a sideways drag to the
gesture. Colours are tokens.
Unlayered, like the rules above. */
@media (pointer: coarse) and (width < 48rem) {
li[data-controller~="mail--message-row"] {
touch-action: pan-y;
}
}
[data-swipe-panel] {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
width: var(--swipe-reveal, 0px);
overflow: hidden;
display: flex;
visibility: hidden;
pointer-events: none;
}
[data-swipe-panel="right"] {
left: 0;
}
[data-swipe-panel="left"] {
right: 0;
justify-content: flex-end;
}
li[data-swipe-side="right"] > [data-swipe-panel="right"],
li[data-swipe-side="left"] > [data-swipe-panel="left"] {
visibility: visible;
pointer-events: auto;
}
/* The button is a fixed 96px, the same figure the controller rests an open row at. */
[data-swipe-commit] {
flex: 0 0 96px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
color: rgb(var(--rgb-accent-ink));
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
[data-swipe-panel="right"] [data-swipe-commit] {
background: rgb(var(--rgb-success));
}
[data-swipe-panel="left"] [data-swipe-commit] {
background: rgb(var(--rgb-info));
}
/* Eased back into place by a release, and held still under a finger. */
[data-swipe-content] {
transition: transform 180ms ease-out;
}
/* Above the overlay link and the panels only while the row is in motion or open.
At rest the link is on top, so a tap on the row still opens the message. */
li[data-swipe-side] > [data-swipe-content] {
position: relative;
z-index: 2;
}
/* ── Action sheet ──────────────────────────────────────────────────────────
The long-press sheet on a phone (mail--action-sheet, opened by mail--row-hold).
A copy of the list's template, appended to <body>, so it sits above the page and
outside the list's own stacking. Its slide takes --sheet-duration, which the
controller sets to 0ms under reduced motion.
Pressed rows (data-pressed, while a finger is on them) switch off iOS's touch
callout and text selection, on the row and on its overlay link, which is the
element iOS calls out on. Pressed is CLIENT_OWNED in mail_pane, so a refresh
does not strip it mid-press.
The pickers (move, labels, snooze) are the list's own menus, each behind its
own trigger. The trigger is invisible and never clicked by a finger; the sheet
presses it. Only the picker's panel takes pointer events, so the entry under it
stays tappable. Unlayered, like the rules above. */
/* Both spellings: iOS Safari reads only -webkit-user-select, and the stylesheet build adds no
prefix to a hand-written rule (only to Tailwind's own utilities). Unprefixed alone, the
press on a word still starts a selection on an iPhone. */
[data-pressed],
[data-pressed] > a {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.action-sheet-panel {
animation: action-sheet-rise var(--sheet-duration, 200ms) ease-out both;
/* The base is 1rem, what the panel had as pb-4. The inset clears the home indicator on an iPhone:
the sheet is appended to <body>, outside the padding the app gives its own body. */
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
}
@keyframes action-sheet-rise {
from {
transform: translateY(100%);
}
}
[data-sheet-picker-scope] {
pointer-events: none;
}
[data-sheet-picker-scope] > * > button:first-child {
opacity: 0;
pointer-events: none;
}
[data-sheet-picker-scope] [role="menu"],
[data-sheet-picker-scope] [data-mail--label-menu-target="panel"],
[data-sheet-picker-scope] [data-ui--dropdown-target="menu"] {
pointer-events: auto;
}
+26
View File
@@ -291,6 +291,16 @@
max-width: none;
}
/* The chip's text gives way, not its ×. Overflow on the chip itself cut
* from the right, and the × is the rightmost thing in it. The label is
* compose_controller's (see _renderAddressFields); a chip without one keeps
* the old behaviour. */
.ts-control .item .ts-item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* × remove button — per chip (remove_button plugin) */
.ts-control .item .remove {
display: inline-flex;
@@ -549,6 +559,10 @@
}
/* ── No results ──────────────────────────────────────────────────────── */
/* .no-results is the autocomplete bundle's own row, and the one actually
* drawn: it replaces the renderers a field passes (see compose_controller's
* _renderAddressFields). */
.ts-dropdown .no-results,
.ts-no-results {
padding: 10px 14px;
font-size: 0.8125rem;
@@ -556,6 +570,18 @@
text-align: center;
}
/* The bundle's paging rows. A suggestion list is eight rows at most, so
* "No more results" under every one of them said nothing. */
.ts-dropdown .no-more-results {
display: none;
}
.ts-dropdown .loading-more-results {
padding: 7px 14px;
font-size: 0.8125rem;
color: rgb(var(--rgb-ink-faint));
}
/* ── Thin scrollbar ──────────────────────────────────────────────────── */
.ts-dropdown-content::-webkit-scrollbar {
width: 4px;
+3 -2
View File
@@ -11,6 +11,7 @@ export type IntentValue = "undecided" | "hold" | "vertical" | "left" | "right";
export const DIRECTION_LOCK_PX: number;
export const HOLD_MS: number;
export const HOLD_SLOP_PX: number;
export const CLICK_WINDOW_MS: number;
export const Intent: {
readonly UNDECIDED: "undecided";
@@ -27,6 +28,6 @@ export interface Sequence {
export function createSequence(): Sequence;
export function registerSurface(name: string, isOpen: () => boolean): void;
export function unregisterSurface(name: string): void;
export function openSurface(): string | null;
export function claimSequence(kind: string): boolean;
export function openSurface(except?: string | null): string | null;
export function claimSequence(kind: string, options?: { except?: string | null }): boolean;
export function releaseSequence(kind: string): boolean;
+32 -9
View File
@@ -22,7 +22,10 @@
* 3. The open surface. A surface (the sidebar drawer, and later an open row or
* the action sheet) registers a predicate that reads its own state, so
* nothing here mirrors a DOM flag and goes stale. While any surface reports
* open, claimSequence() refuses: no gesture starts over an open surface.
* open, claimSequence() refuses: no gesture starts over an open surface,
* except the one a caller names in `except`, which is how a touch on an open
* row reaches that row. A touch outside it claims "dismiss" instead, which
* closes the row and holds the sequence, so no pull can take it afterwards.
*
* What it does NOT do: read the DOM, time anything, or see a touch event. The
* caller passes in displacement and elapsed milliseconds, which keeps every rule
@@ -47,6 +50,14 @@ export const HOLD_MS = 450;
*/
export const HOLD_SLOP_PX = 8;
/**
* How long, in milliseconds, after a hold's finger lifts a click is still that finger's.
* A finger released from a hold can leave a click behind it, which lands on whatever is
* under it: the backdrop of the sheet the hold opened, most often. The browser's own
* double-tap delay is 300ms, and a click follows its touchend within it.
*/
export const CLICK_WINDOW_MS = 300;
/** What a sequence has been decided to be, or that it is still undecided. */
export const Intent = Object.freeze({
UNDECIDED: "undecided",
@@ -121,10 +132,20 @@ export function unregisterSurface(name) {
surfaces.delete(name);
}
/** The name of the first registered surface that is open, or null. */
export function openSurface() {
/**
* The name of the first registered surface that is open, or null.
*
* `except` names one surface to leave out of the answer. It exists for the open
* row: a touch on that row is the row's own business (its button, or a swipe
* back), so the row must not refuse the claim that lets it happen. Only the
* surface named is exempt; every other open surface still answers.
*
* @param {string|null} [except]
* @returns {string|null}
*/
export function openSurface(except = null) {
for (const [name, isOpen] of surfaces) {
if (isOpen()) {
if (name !== except && isOpen()) {
return name;
}
}
@@ -134,14 +155,16 @@ export function openSurface() {
/**
* Claim the current sequence for `kind`. Refused while another consumer holds it
* and while any surface is open. The refusal is silent: the caller does nothing,
* and the touch stays with whatever the browser would do with it.
* and while any surface is open, other than the one named in `except`. The
* refusal is silent: the caller does nothing, and the touch stays with whatever
* the browser would do with it.
*
* @param {string} kind "pull", later "swipe" or "hold"
* @param {string} kind "pull", "swipe", "dismiss" (a touch that only closes an open row), or "hold"
* @param {{ except?: string|null }} [options] a surface the claim may ignore
* @returns {boolean} true when the caller now owns the sequence
*/
export function claimSequence(kind) {
if (claimedBy !== null || openSurface() !== null) {
export function claimSequence(kind, { except = null } = {}) {
if (claimedBy !== null || openSurface(except) !== null) {
return false;
}
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
# The `## vX.Y.Z — date` section of a release, written into CHANGELOG.md from
# changelog.d/: bin/changelog-release.sh v0.3.8 [2026-10-22]
#
# WHY THIS EXISTS
# ───────────────
# Every pull request used to add its entry to `## Unreleased` in CHANGELOG.md,
# at the same place in the same file, so the second of any two pull requests
# conflicted with the first once that one merged. Fixing the conflict is a
# push, a push re-runs the whole E2E suite, and a batch of four bug fixes for
# one milestone waited on four suite runs in a row to settle nothing but the
# order of paragraphs. Now each pull request adds a file of its own under
# changelog.d/, no two touch the same file, and this script puts them together
# once, in the `Release vX.Y.Z` pull request (CONTRIBUTING.md → Releasing).
#
# Why a script at release time and not a reader that merges the files at run
# time: CHANGELOG.md stays the one file everything reads — the What's new
# dialog (ChangelogReader), bin/release-notes.sh and the GitHub page — and
# none of them has to learn about a second place. Entries are only shown once
# they are released, and by then they are in the file.
#
# What goes into the section, in this order:
#
# 1. fragments with a <!-- highlight --> marker, by file name;
# 2. the other fragments, by file name;
# 3. whatever is still written under `## Unreleased` in CHANGELOG.md, last.
#
# The third is kept on purpose. Pull requests opened before changelog.d/
# existed write there, and `### Before you upgrade` is a section of its own
# that a fragment cannot be (see changelog.d/README.md): a heading in the
# middle would claim every fragment after it. Last, so it claims nothing.
#
# Fragments are deleted once they are in. Exits non-zero, changing nothing,
# when the version already has a section or there is nothing to release.
set -eu
tag="${1:?usage: bin/changelog-release.sh vX.Y.Z [YYYY-MM-DD]}"
date="${2:-$(date +%Y-%m-%d)}"
root="${CHANGELOG_ROOT:-.}"
changelog="$root/CHANGELOG.md"
fragments="$root/changelog.d"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Not a release tag: $tag (expected vX.Y.Z)" >&2; exit 1 ;;
esac
if grep -q "^## $tag\( \|\$\)" "$changelog"; then
echo "$changelog already has a \"## $tag\" section" >&2
exit 1
fi
# Every fragment but the README, highlights first, each group by file name.
# `sort` on the name alone: they are prefixed with the issue number, and the
# order inside a release is the order they were asked for, near enough.
files=$(find "$fragments" -maxdepth 1 -type f -name '*.md' ! -name 'README.md' 2>/dev/null | sort)
highlighted=""
plain=""
for file in $files; do
if grep -q '<!--[[:space:]]*highlight' "$file"; then
highlighted="$highlighted $file"
else
plain="$plain $file"
fi
done
# One fragment's text without its leading and trailing blank lines, then one
# blank line: the paragraphs of the section are separated by exactly one.
emit() {
sed -e '/./,$!d' "$1" | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
echo
}
# The `## Unreleased` section's text, if there is one, trimmed the same way.
unreleased=$(awk '
/^## / { inside = ($0 ~ /^## Unreleased[[:space:]]*$/); next }
inside { print }
' "$changelog" | sed -e '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
section=$(
for file in $highlighted $plain; do
emit "$file"
done
if [ -n "$unreleased" ]; then
printf '%s\n\n' "$unreleased"
fi
)
if [ -z "$section" ]; then
echo "Nothing to release: no fragments in $fragments and no ## Unreleased text" >&2
exit 1
fi
# The new file: everything before the first `## ` heading, the release, then
# every older section with `## Unreleased` left out — its text is in the
# release now.
out="$changelog.tmp.$$"
{
awk '/^## /{ exit } { print }' "$changelog"
printf '## %s — %s\n\n%s\n\n' "$tag" "$date" "$section"
awk '
/^## / { seen = 1; skip = ($0 ~ /^## Unreleased[[:space:]]*$/) }
seen && !skip { print }
' "$changelog"
} > "$out"
mv "$out" "$changelog"
for file in $highlighted $plain; do
rm "$file"
done
echo "Wrote ## $tag — $date with $(echo $highlighted $plain | wc -w | tr -d ' ') fragment(s)"
+9
View File
@@ -0,0 +1,9 @@
**On a phone, archiving a mail or marking it read meant opening it first.** The row's buttons show
on hover, and a phone has no hover, so they never appeared in the list. A row now swipes: drag it to
the right to mark it read or unread, and to the left to archive it, past a third of its width to
reveal the action or past two thirds to do it straight away. It is for a finger on a phone-width
screen only; a mouse and wider windows behave as before. Read / unread and archive are offered only
on ordinary mail, never on a draft or in the archive, the bin or spam, as the row's own buttons do;
star is offered on every row. Trashing is not a swipe. Each direction can be changed under Settings
→ Mail → Swipe actions (next entry). No schema change, no deployment change.
[#147](https://github.com/karatektus/pl_mail/issues/147), by @Flouss.
+7
View File
@@ -0,0 +1,7 @@
**On a phone, a swipe to the right or to the left always did the same thing.** The two directions
were read or unread and archive, with no way to change either. Settings → Mail → Swipe actions now
sets each direction to read or unread, archive, star, or nothing, and the defaults are the same as
before. Trash is not one of the choices: a swipe commits without a tap, and a trashed row has no
Undo. A value stored for trash reads as the direction's default. No schema change: the two choices
live in the settings bag, and the config backup carries them.
[#148](https://github.com/karatektus/pl_mail/issues/148), by @Flouss.
+12
View File
@@ -0,0 +1,12 @@
**On a phone, a mail row's actions were only reachable through buttons that appear on hover, and a
phone has no hover.** Archive, delete, snooze, move and label were in the row's strip, which a phone
never shows. Holding a row for about half a second now opens an action sheet with every action the
row offers, the same ones the strip and the swipe offer, in the same places. Move to (on a
conversation, not a single message), Label as and Snooze open their pickers over the sheet, and
Select ticks the row for a bulk action. A row is no longer a native drag on a phone, so iOS does not
lift it during the hold. Trash in the sheet has no Undo, as the strip's trash button has none, and
delete forever is offered only with the delete-now buttons switched on under Settings → Mail →
Disposal. The sheet is only for a finger on a phone-width screen; a mouse and wider windows behave
as before. Android Chrome gives a short vibration when the sheet opens; iOS has no vibration for web
pages. No schema change, no deployment change.
[#149](https://github.com/karatektus/pl_mail/issues/149), by @Flouss.
+31
View File
@@ -0,0 +1,31 @@
<!-- highlight -->
**Contacts: an address book, with your Google, Outlook, Nextcloud or CardDAV contacts in it.** A new
**Contacts** page (in the sidebar) lists, searches and tags your contacts, shows each one's details
and the mail you exchanged, and creates, edits and deletes them: names in parts and how they are
spoken, organisation and office, postal addresses, birthdays and other dates, related people,
websites, chat addresses, custom fields, notes and a photo. The button beside the calendar's docks the whole address book beside the mail, resizable like
the calendar, and on a wide monitor the two sit side by side. Duplicates are found and merged, with
90 days to undo, and contacts import from and export to vCard and CSV. A message's sender shows
whether they are in your contacts, with one click to add them. Address books on a server come in
too: a Google or Microsoft mail account brings its contacts on the same sign-in as its mail and
calendars (an account connected before this asks once, in Settings → Account health, to sign in
again and allow them), a Nextcloud connected under Settings → Integrations is listed under
**Settings → Contacts** with no second connection to make, and Radicale, Baïkal, Fastmail or iCloud
connect there over CardDAV. Their contacts sit beside plMail's own, a filter shows one book at a
time, and every change made here — the photo included — is sent back to the server and from there
to your phone. Anything plMail has no field for stays on the card. Changes made elsewhere arrive within fifteen minutes
(`app:contacts:sync --stale`). Contacts are suggested when you address a message, and over JMAP
(`Contact/get`, `Contact/query`, `Contact/set`) for the apps. See
[Contacts](docs/features/contacts.md) and [CardDAV](docs/providers/carddav.md).
[#157](https://github.com/karatektus/pl_mail/issues/157), by @jo2904 and @DanversKara.
**Migration:** adds the `address_book` and `contacts_*` tables; nothing existing changes. For
contacts on Google accounts, enable the **People API** in the Cloud project and add the
`.../auth/contacts` scope to the consent screen; for Microsoft accounts, add the
`Contacts.ReadWrite` delegated permission to the app registration — see
[Google](docs/providers/google.md) and [Microsoft](docs/providers/microsoft.md). Without the People
API, mail and calendars work as before, and a Google address book says the API needs enabling.
**A Microsoft 365 organisation where only administrators approve apps refuses every sign-in to
plMail after this upgrade, mail included, until an administrator approves the new permission:**
Microsoft Entra admin centre → Enterprise applications → plMail → Permissions → Grant admin
consent. Do it before upgrading. Personal Microsoft accounts are not affected.
+11
View File
@@ -0,0 +1,11 @@
**Mail trashed or archived in Gmail no longer leaves the Inbox of the account Gmail collected it
for.** When a Gmail account collects mail for another connected account (Gmailify), plMail merges
the Gmail copy into that account's own IMAP copy, and the merge applied Gmail's `TRASH`, `SPAM`,
`INBOX`, `SENT` and `DRAFT` too. Mail still in the IMAP server's Inbox showed as trashed or
archived and dropped out of the Inbox list. The IMAP folder now decides where such a message sits;
Gmail's other labels still merge. Mail that Gmail deleted for good, by emptying its bin, was also
removed from the other account here, although its IMAP server still had it, and the IMAP sync
never fetched it again. That copy now only loses its link to Gmail. Mail already moved this way is
put back with `app:backfill gmailify-placement`; mail already removed is not brought back. No
schema change. See [Accounts](docs/features/accounts.md#gmail-collecting-mail-for-another-account-gmailify).
[#203](https://github.com/karatektus/pl_mail/issues/203), by @phdelodder.
+11
View File
@@ -0,0 +1,11 @@
**A Gmail account can stop filing mail under your other accounts.** A Gmail account that collects
mail for another connected address files that mail under the other account (Gmailify), and
nothing in Settings could turn this off. After a domain moved from Gmail to an IMAP provider of
its own, Gmail went on matching old and late copies to it. Settings → Aliases now has a switch
under each Gmail account, on by default. Turned off, the Gmail account keeps only mail addressed
to itself, and the other account's own IMAP copies lose their link to Gmail, in a background job
on the maintenance queue. Mail Gmail filed there that the IMAP server never had keeps it. While
Gmail is still collecting, mail whose To or Cc names the other address is left to that account even
though Gmail stamps its own address on it; mail Bcc'd there names nobody and is kept on both, so the
switch is best turned off once Gmail has stopped collecting. No schema change: the choice lives in the account's settings, as it did before there was a switch.
[#205](https://github.com/karatektus/pl_mail/issues/205).
@@ -0,0 +1,12 @@
**Admin → Integrations lists the calendar services, so a CalDAV server pinned during setup can be
changed later.** The setup wizard could pin one CalDAV server address for everybody, and once setup
was finished nothing showed that address again. CalDAV and calendar feeds now have their own group
under the file services, with the same row, setup notes and **Configure** form, where the address
can be changed or cleared. A change applies the next time calendars are found on a connection;
calendars already mirrored keep the address they were found at. Both services are offered to every
user as before, so their rows say so and the form has no on/off switch. Once people have connected,
the CalDAV form says how many connections a change reaches. A calendar feed has no server to pin, so
its row offers nothing to configure unless an older address is stored, which it shows so it can be
cleared. Nextcloud stays with the file services, where its calendars' address is already set. The
server address field now accepts only an http or https address. No schema change.
[#212](https://github.com/karatektus/pl_mail/issues/212), by @phdelodder.
+7
View File
@@ -0,0 +1,7 @@
**A Gmail sync no longer reads every Gmail id you have stored.** To tell new mail from mail it
already had, each Gmail sync loaded the id of every message stored for you, and every one it had
declined. It did this after every history window, including the many that bring nothing, and on
every page of a listing. On a large mailbox that is thousands of rows every few minutes, and more as
the mailbox grows. It now asks only about the ids Gmail has just listed, and nothing when there are
none. No schema change. [#223](https://github.com/karatektus/pl_mail/issues/223), reported by
@phdelodder.
+10
View File
@@ -0,0 +1,10 @@
**Lists, unread badges and search answer faster on a large mailbox.** Every list and badge leaves
out conversations that are entirely in the bin (#268), and each of them worked that out from the
messages on every query: about 170 ms each on a mailbox of 67k conversations, and the sidebar
asks about ten times. Whether a conversation is in the bin is now stored on it and kept by the
database. On the same mailbox the Inbox count dropped to 35 ms, the unified Inbox page to 52 ms,
and most other lists and badges to between 1 and 20 ms. The results are the same on all 41 lists,
badges and searches compared. [#223](https://github.com/karatektus/pl_mail/issues/223).
**Migration:** adds `message_thread.binned` with the triggers that keep it, and fills it for the
conversations already stored. That took under a second on 67k conversations.
+11
View File
@@ -0,0 +1,11 @@
**Nextcloud calendars come with the Nextcloud connection.** A Nextcloud connected for files under
Settings → Integrations is now listed in Settings → Calendars, and **Find calendars on…** lists its
calendars with the same app password. There is no second address or password to type. The address
is the connection's own, or the one an administrator pinned in Admin → Integrations; a pinned
address on a private network now works for calendars too, where before it was refused as a private
address. Disconnecting Nextcloud removes its calendars as well, and the confirmation says how many;
events that came from mail are moved to the default calendar first, as when a CalDAV server is
disconnected. A calendar already mirrored through a separate CalDAV connection to the same
Nextcloud shows as *Already mirrored through …* and cannot be ticked a second time. Existing CalDAV
connections are left as they are. No schema change.
[#236](https://github.com/karatektus/pl_mail/issues/236), by @jo2904.
+11
View File
@@ -0,0 +1,11 @@
**Pictures in mail show up again where they were missing.** A BAUHAUS review request showed
neither its logo nor its product photos, even with images allowed, for three reasons. Pictures
linked as `http://` were never asked for, because plMail only fetches over an encrypted
connection; they are now fetched as `https://`. Everything inside a form was removed along with the
form, here the whole first product with its photo, name and stars; the form's content now stays and
only its text fields go. And a card a mail hides by default and switches on with its stylesheet
stayed hidden; plMail now follows the stylesheet as a browser does. Mail already in the mailbox is
repaired on request: `php bin/console app:backfill safe-html-hidden-cards` writes the shown copy of
every message with a form or such a stylesheet again from the sender's original. The `http://` fix
needs nothing. When plMail refuses a picture's address, the log now says why. No schema change.
[#271](https://github.com/karatektus/pl_mail/issues/271), by @pmdevelopment.
+7
View File
@@ -0,0 +1,7 @@
**When notifications can't be turned on, Settings now says why.** Every way that turning on
notifications can fail used to end in the same "Something went wrong. Try again." Now the pane says
which step failed: the browser's background worker, the browser's push service, or the server. It
also gives the browser's error name or the server's HTTP status, and writes the full error to the
browser console. For Brave, where push needs "Use Google services for push messaging" switched on,
the push-service message says so. No schema change.
[#277](https://github.com/karatektus/pl_mail/issues/277).
+8
View File
@@ -0,0 +1,8 @@
**Pictures and videos open in a preview when you click them, like PDFs.** Clicking an image
attachment (JPEG, PNG, GIF, WebP, AVIF, BMP, ICO) or a video (MP4, WebM, Ogg, QuickTime) now opens
it in the same dialog as the PDF reader instead of downloading it. Videos play with the browser's
own controls and can be seeked without loading the whole file. Every attachment that opens a preview,
PDFs included, has a download button on the right side of its chip, so downloading is still one click.
If the browser can't show the file after all, the dialog says so and offers the download. Other
attachments download on click as before. No schema change.
[#295](https://github.com/karatektus/pl_mail/issues/295).
+9
View File
@@ -0,0 +1,9 @@
**A sync no longer fails after mail with an attachment was deleted on the server.** When Gmail,
Outlook or an IMAP server reported a message with an attachment as deleted, the sync that removed it
failed on its next database write: "A new entity was found through the relationship
'MessagePart#message'". The retry went through, but the failed run did not record the sync, and on
IMAP every folder after the one that tripped was skipped until the next run. Deleting mail now goes
the same way whether the server, a discarded draft or "Delete forever" asked for it. As a result,
a conversation that loses its last message is removed instead of left behind empty, and one deleted
for good drops the labels only its deleted messages carried. No schema change.
[#299](https://github.com/karatektus/pl_mail/issues/299).
+10
View File
@@ -0,0 +1,10 @@
**Spam stays in Spam: off the radar and out of search.** Cards read out of mail in Spam showed in the
**Happening Soon** panel and the strip above the mail list, beside the real ones, though a fake
delivery notice is exactly what spam imitates. They are now left out, and a spam conversation no
longer shows its cards above the message; mail in the bin keeps its cards, since deleting a shipping
confirmation does not stop the parcel. Search, and the results as you type, likewise leave out Spam
unless you search with `in:spam`, as they already did for the bin. No schema change.
[#304](https://github.com/karatektus/pl_mail/issues/304).
**A Not spam button.** An open conversation in Spam has a **Not spam** button in its toolbar, which
moves it back to the inbox in one click instead of through **Move to**. No schema change.
+20
View File
@@ -0,0 +1,20 @@
<!-- highlight -->
**A simpler contact editor, and new contacts from whatever you paste.** **New contact** is now one
box: type or paste a name, an email signature, a `Name <address>` line, an address or a vCard, and
the details it finds appear below it, each one easy to untick, retype or give another type with one
click (a work number that is a home number). Lines it cannot place are asked about instead of
guessed. The editor shows the name as one field whose parts fill themselves, email and phone rows
that appear as you type, and only the kinds of detail a contact has, with the rest a click away; a
bar at the bottom says what changed. **Paste details** in the editor compares pasted text with the
contact before anything changes: new details ticked, ones that differ left for you to decide.
<!-- highlight -->
**Improve with AI, for contacts.** With the new **Contact help** switch on (Admin → AI, off by
default), quick add can give pasted text a second reading by the AI, with what it changed marked
and undoable, and the editor can suggest updates to a contact from the signature of the newest mail
they sent you, each with its reason. Only the signature block of that mail is sent, never its body,
and nothing is saved until you tick it and save. Everyone can switch it off for themselves under
Settings → Assistant.
**Migration:** one migration adds the switch's two columns (`ai_settings.contact_help_enabled`,
`"user".ai_contact_help_off`), both off by default.
@@ -0,0 +1 @@
- "Add to contacts" on a mail now asks whether the address belongs to somebody new or to a contact you already have. You can search for that contact as you type, or name a new one and open the full editor for more details. The contact then opens in the contacts pane beside the mail. The button is on every address in a message's details too, not only the sender.
@@ -0,0 +1 @@
- The contacts button in the top bar now steps through three positions, like the calendar button: the address book beside the mail, full width with the mail behind it, and closed. Dragging the pane's edge past either end snaps it to full width or closes it. Settings → Contacts → Behaviour can leave one of the open positions out. On a wide screen the sidebar no longer has a Contacts row; on a phone it stays in the menu.
@@ -0,0 +1,8 @@
**A sync that can't reach Gmail or Outlook no longer fills the log with stack traces.** When the
worker could not connect to the provider at all, every retry of the account sync was logged as a
warning with three nested stack traces. An outage that lasted ended at CRITICAL, with the job parked
on the failed queue. Now the sync waits and tries again on the same schedule as before, with one
info line per attempt. If the provider is still unreachable after about eight minutes, the sync is
dropped with a single warning. Nothing is lost: the next sync resumes from the same point. Fetching
particular messages still retries and fails as before, because nothing would list those again.
[#332](https://github.com/karatektus/pl_mail/issues/332)
+38
View File
@@ -0,0 +1,38 @@
# Changelog entries waiting for a release
A pull request that changes something a user would notice adds its CHANGELOG entry here, as a file
of its own, instead of editing `CHANGELOG.md`. The `Release vX.Y.Z` pull request collects them
into the release's section with `bin/changelog-release.sh` and deletes them (see
[CONTRIBUTING.md → Releasing](../CONTRIBUTING.md#releasing)).
Why: every pull request used to add its paragraph at the same place in `CHANGELOG.md`, so every
second pull request of a batch conflicted with the first, and each fix of that conflict re-ran the
whole suite. Files of their own never conflict.
## The file
- **Named after the issue, then a few words:** `266-jmap-read-state.md`. The number keeps the
entries of a release in roughly the order they were asked for; the words keep two pull requests
for one issue apart.
- **The entry, written as it would be in `CHANGELOG.md`:** a paragraph that opens with a bold
sentence, wrapped at a hundred columns. More than one entry in a file is fine. A highlight is
marked by `<!-- highlight -->` or `<!-- highlight screenshot=name -->` on the line above it, and a
`**Migration:**` paragraph is for administrators, both as described in
[CONTRIBUTING.md → Release notes](../CONTRIBUTING.md#release-notes).
- **No headings.** The files are put one after the other, and a `### ` heading in one would claim
every entry after it — under `### Before you upgrade`, hiding them from everybody but
administrators. A release that needs a `### Before you upgrade` section writes it under
`## Unreleased` in `CHANGELOG.md`, which the script puts after the fragments.
```markdown
**A message marked read or unread in the phone app is shown that way on the web too.** Marking a
message read or unread over JMAP changed the message but not its conversation's unread count …
No schema change. [#266](https://github.com/karatektus/pl_mail/issues/266), by @phdelodder.
```
`ChangelogReaderTest` reads these files too: it fails on a heading in one, on an entry it cannot
read, and on a screenshot a highlight names that is not in `public/whats-new/`.
Entries here are not shown in **What's new** until they are released: the dialog reads tagged
releases only (see
[What everyone sees after an update](../docs/install/upgrading.md#what-everyone-sees-after-an-update)).
+4
View File
@@ -0,0 +1,4 @@
**Pressing Compose before adding a mailbox says so.** With no mail account yet, the compose window
answered "The compose window could not be opened. Please try again.", which no retry could fix. It
now shows "No mail account yet" with a button to add one, as it was meant to, and the button opens
Settings → Mail accounts.
+1
View File
@@ -13,6 +13,7 @@
"doctrine/orm": "^3.6",
"dragonmantank/cron-expression": "^3.6",
"endroid/qr-code": "^6.1",
"giggsey/libphonenumber-for-php-lite": "^9",
"knplabs/knp-paginator-bundle": "*",
"league/oauth2-client": "^2.9",
"league/oauth2-google": "^5.0",
Generated
+79 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "847007779beadb1a2270eb9f1596b574",
"content-hash": "47baa00bc2379d78853f7c146383957f",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -1702,6 +1702,84 @@
},
"time": "2026-06-11T17:54:14+00:00"
},
{
"name": "giggsey/libphonenumber-for-php-lite",
"version": "9.0.41",
"source": {
"type": "git",
"url": "https://github.com/giggsey/libphonenumber-for-php-lite.git",
"reference": "6ffb41a259c950d6f4df5596e32917f631052910"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/6ffb41a259c950d6f4df5596e32917f631052910",
"reference": "6ffb41a259c950d6f4df5596e32917f631052910",
"shasum": ""
},
"require": {
"php": "^8.1",
"symfony/polyfill-mbstring": "^1.17"
},
"conflict": {
"giggsey/libphonenumber-for-php": "*"
},
"require-dev": {
"ext-dom": "*",
"friendsofphp/php-cs-fixer": "^3.71",
"infection/infection": "^0.29|^0.31.0",
"nette/php-generator": "^4.1",
"php-coveralls/php-coveralls": "^2.7",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.7",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpstan/phpstan-phpunit": "^2.0.4",
"phpstan/phpstan-strict-rules": "^2.0.3",
"phpunit/phpunit": "^10.5.45",
"symfony/console": "^6.4",
"symfony/filesystem": "^6.4",
"symfony/process": "^6.4"
},
"suggest": {
"giggsey/libphonenumber-for-php": "Use libphonenumber-for-php for geocoding, carriers, timezones and matching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "9.x-dev"
}
},
"autoload": {
"psr-4": {
"libphonenumber\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Joshua Gigg",
"email": "giggsey@gmail.com",
"homepage": "https://giggsey.com/"
}
],
"description": "A lite version of giggsey/libphonenumber-for-php, which is a PHP Port of Google's libphonenumber",
"homepage": "https://github.com/giggsey/libphonenumber-for-php-lite",
"keywords": [
"geocoding",
"geolocation",
"libphonenumber",
"mobile",
"phonenumber",
"validation"
],
"support": {
"issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues",
"source": "https://github.com/giggsey/libphonenumber-for-php-lite"
},
"time": "2026-10-08T08:51:54+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.15.5",
+28
View File
@@ -267,6 +267,10 @@ framework:
# kind is dropped with a warning, the other is put back to
# wait, and neither is a CRITICAL any more.
- App\Infrastructure\Messaging\Middleware\GiveUpQuietlyOnThrottling
# An account sync that could not reach the provider at
# all is put back to wait, quietly, and dropped with one
# warning if the outage lasts. See the class.
- App\Infrastructure\Messaging\Middleware\WaitOutConnectionFailures
# A clock on every message a worker handles, which closes
# the IMAP connections it opened if it runs out — so one
# server sending a trickle cannot hold a queue. See
@@ -321,6 +325,10 @@ framework:
# anybody's foreground. It must not sit on ingest, where a person
# changing a setting would put arriving mail behind their mailbox.
App\Infrastructure\Messaging\Message\ResortMailboxMessage: maintenance
# maintenance for the same reason: a settings switch (Gmailify off)
# followed by a scan of rows the database already has. Nobody waits
# on it, and arriving mail must not queue behind it.
App\Infrastructure\Messaging\Message\SeverGmailifyLinksMessage: maintenance
# The dispatcher is trivial; what it queues is ClassifyMailMessage,
# which is already routed to its own transport below. Here so the
# settings request answers at once rather than waiting on a query
@@ -422,6 +430,26 @@ framework:
# every failure worth retrying is a third-party rate limit that
# takes minutes to clear.
App\Infrastructure\Messaging\Message\SyncCalendarMessage: ingest
# Same queue and the same reasoning as SyncCalendarMessage: the sweep
# and a book's first sync are reads of the server, minutes long on a
# big book, and nobody waits on them.
App\Infrastructure\Messaging\Message\SyncAddressBookMessage: ingest
# Beside it: finding a Google or Microsoft account's address books
# once its grant carries contacts, which then queues their first
# sync. Routed so it never runs inside the OAuth callback.
App\Infrastructure\Messaging\Message\SubscribeAccountContactsMessage: ingest
# export, unlike the calendar: an edit on the contacts page leaving
# plMail is what that queue is for, and a push is a handful of PUTs,
# not a read of the whole book — which is why it is a message of its
# own rather than SyncAddressBookMessage. On ingest it waited behind
# every mailbox sync. A push that runs out of export's short retries
# loses nothing: the contact is still pending, and the next sweep
# sends it before it reads.
App\Infrastructure\Messaging\Message\PushAddressBookMessage: export
# Beside it, for its reason. This one has no row left to stay
# pending on (the contact is gone), so once its retries are spent it
# waits in `failed` like any other outbound write.
App\Infrastructure\Messaging\Message\DeleteRemoteContactMessage: export
# maintenance rather than ingest, although it is dispatched beside
# the SyncCalendarMessage above and for the same calendar. Two
# reasons, and the first is the point of the message existing at
+9
View File
@@ -320,6 +320,10 @@ services:
arguments:
$drivers: !tagged_iterator app.calendar_sync_driver
App\Service\AddressBook\AddressBookDriverRegistry:
arguments:
$drivers: !tagged_iterator app.address_book_driver
_instanceof:
App\Domain\Interface\AccountSyncerInterface:
tags: ['app.account_syncer']
@@ -335,6 +339,11 @@ services:
# CalendarSource that may carry a mail Account instead.
App\Domain\Interface\CalendarSyncDriverInterface:
tags: ['app.calendar_sync_driver']
# Address books, keyed on an AddressBookSource for the calendar
# drivers' reason. The CardDAV driver carries this tag and the
# integration one: it also proves a connection's credentials.
App\Domain\Interface\AddressBookDriverInterface:
tags: ['app.address_book_driver']
# And a calendar push manager is neither of those. It is keyed on a
# Calendar where PushSubscriptionManagerInterface is keyed on an
# Account, so the two must not share a tag: the mail managers cannot
+7 -5
View File
@@ -711,7 +711,9 @@ your client wants one, **ask for it rather than engineering around it** (see [§
here as missing while `SearchSnippetGetMethod` was in the tree.
- **Contacts are autocomplete only.** `Contact/autocomplete`, under
`urn:plmail:params:jmap:contacts`, returns ranked recipient suggestions; there is no
`Contact/get`, `/query` or `/set` — the address book is harvested from mail headers, not writable.
`Contact/get`, `/query` or `/set`. Suggestions come from addresses harvested from mail headers and
from the user's CardDAV address books (`inAddressBook: true`); those books are edited in the web
app and synced with their server, not through JMAP.
Calendars are served, under `urn:plmail:params:jmap:calendars`; there is no
`Calendar/set` and no `/changes` on either type, because calendars cannot calculate changes — see
[JMAP](internals/jmap.md).
@@ -1448,10 +1450,10 @@ Ordered roughly by how much users will miss them.
**Writing**
- Compose, reply, reply-all, forward. Rich text.
- Contact autocomplete (harvested from synced mail server-side; ask `Contact/autocomplete` under
`urn:plmail:params:jmap:contacts` — the server ranks over the whole address book, so it beats a
local cache. The OS address book remains a sensible *supplement* for people the user has never
mailed).
- Contact autocomplete (harvested from synced mail server-side, plus any CardDAV address book the
user mirrors; ask `Contact/autocomplete` under `urn:plmail:params:jmap:contacts` — the server ranks
over the whole address book, so it beats a local cache. The OS address book remains a sensible
*supplement* for people the user has never mailed and has not added to a mirrored book).
- Send from any account **and any sendable alias** — always show the From picker.
- Draft autosave.
- Undo send (client-side for JMAP; see above).
+2
View File
@@ -35,6 +35,7 @@ mechanism behind it.
| [Reminders](features/calendar-alerts.md) | Setting alerts, how they are delivered, what a fresh install needs before they arrive |
| [Connected calendars](features/calendar-sync.md) | Subscribing to Google, Microsoft and CalDAV calendars, two-way sync, ICS import, export and feed subscriptions, duplicate meetings |
| [Sharing and booking](features/calendar-sharing.md) | Share links and what each reveals, appointment pages, how a booking arrives |
| [Contacts](features/contacts.md) | The address book: your own contacts and synced ones side by side, editing, duplicates and merging, vCard and CSV import and export |
| [Files and integrations](features/integrations.md) | Attaching from and saving to Drive, Photos, OneDrive, Dropbox, Nextcloud, Immich and Paperless-ngx |
| [Security](features/security.md) | Changing your password, two-factor authentication, recovery codes, remembered devices, app passwords, sessions |
| [Other clients](features/clients.md) | Connecting a JMAP client, per-app passwords, the PWA and browser notifications |
@@ -65,6 +66,7 @@ Each page names the exact console, the exact checkboxes and the exact redirect U
| [Microsoft](providers/microsoft.md) | Azure app registration, redirect URIs, delegated permissions, tenant choice, Graph subscriptions |
| [IMAP and SMTP](providers/imap-smtp.md) | Plain mailboxes, IDLE, and the settings servers disagree about |
| [CalDAV](providers/caldav.md) | Discovery, app passwords, and the servers that have been tested |
| [CardDAV](providers/carddav.md) | Address books from Nextcloud and other servers: suggestions while writing, editing, and two-way sync |
| [ICS feeds](providers/ics-feeds.md) | Subscribing to a published calendar by URL, and why some addresses are refused |
| [Single sign-on](providers/oidc.md) | Signing in to plMail through Authentik, Keycloak or another OpenID Connect provider: setup, account linking, and auto-creation |
+11 -7
View File
@@ -1,4 +1,4 @@
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:1f3b51a6f659a64adbc57fb6125f4885ce518f82 -->
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:c51b2fe57829cba3fad6d6c2d3e4c8287e300e05 -->
# Einen Client für plMail bauen
Alles, was eine Entwicklerin (oder ein Agent) braucht, um einen *neuen* plMail-Client zu schreiben
@@ -779,8 +779,10 @@ konstruieren** (siehe [§0](#0-lies-das-zuerst-der-server-wird-aktiv-weiterentwi
fehlend, während `SearchSnippetGetMethod` bereits im Baum lag.
- **Kontakte sind nur Autovervollständigung.** `Contact/autocomplete`, unter
`urn:plmail:params:jmap:contacts`, liefert gereihte Empfängervorschläge; es gibt kein
`Contact/get`, `/query` oder `/set` — das Adressbuch wird aus Mail-Headern geerntet und ist nicht
beschreibbar. Kalender werden bedient, unter `urn:plmail:params:jmap:calendars`; es
`Contact/get`, `/query` oder `/set`. Die Vorschläge kommen aus Adressen, die aus Mail-Headern
geerntet werden, und aus den CardDAV-Adressbüchern des Nutzers (`inAddressBook: true`); diese
Bücher werden in der Web-App bearbeitet und mit ihrem Server synchronisiert, nicht über JMAP.
Kalender werden bedient, unter `urn:plmail:params:jmap:calendars`; es
gibt kein `Calendar/set` und kein `/changes` auf beiden Typen, weil Kalender keine Änderungen
berechnen können — siehe [JMAP](internals/jmap.md).
@@ -1592,10 +1594,12 @@ Grob danach geordnet, wie sehr Nutzerinnen sie vermissen werden.
**Schreiben**
- Verfassen, antworten, allen antworten, weiterleiten. Formatierter Text.
- Autovervollständigung von Kontakten (serverseitig aus synchronisierter Mail geerntet; frag
`Contact/autocomplete` unter `urn:plmail:params:jmap:contacts` — der Server reiht über das ganze
Adressbuch und schlägt damit jeden lokalen Cache. Das Adressbuch des Betriebssystems bleibt eine
sinnvolle *Ergänzung* für Menschen, denen der Nutzer noch nie geschrieben hat).
- Autovervollständigung von Kontakten (serverseitig aus synchronisierter Mail geerntet, dazu jedes
CardDAV-Adressbuch, das der Nutzer spiegelt; frag `Contact/autocomplete` unter
`urn:plmail:params:jmap:contacts` — der Server reiht über das ganze Adressbuch und schlägt damit
jeden lokalen Cache. Das Adressbuch des Betriebssystems bleibt eine sinnvolle *Ergänzung* für
Menschen, denen der Nutzer noch nie geschrieben und die er in kein gespiegeltes Buch eingetragen
hat).
- Senden aus jedem Konto **und jedem sendefähigen Alias** — zeig immer die Von-Auswahl.
- Automatisches Speichern von Entwürfen.
- Senden rückgängig machen (bei JMAP clientseitig; siehe oben).
+3 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: README.md sha1:0672ad1ba0164a9c746efbc168be740b1de378b1 -->
<!-- translated-from: README.md sha1:2b4822814b48a42b23e2129e94c51d2eac8d6548 -->
# plMail-Dokumentation
@@ -37,6 +37,7 @@ wo der Mechanismus dahinter steht.
| [Erinnerungen](features/calendar-alerts.md) | Erinnerungen setzen, wie sie zugestellt werden, was eine frische Installation braucht, damit sie ankommen |
| [Verbundene Kalender](features/calendar-sync.md) | Google-, Microsoft- und CalDAV-Kalender abonnieren, Abgleich in beide Richtungen, ICS-Import, -Export und Feed-Abonnements, doppelte Termine |
| [Teilen und Buchen](features/calendar-sharing.md) | Freigabelinks und was jeder preisgibt, Buchungsseiten, wie eine Buchung ankommt |
| [Kontakte](features/contacts.md) | Das Adressbuch: eigene und synchronisierte Kontakte nebeneinander, Bearbeiten, Duplikate und Zusammenführen, vCard- und CSV-Import und -Export |
| [Dateien und Integrationen](features/integrations.md) | Anhängen aus und Speichern in Drive, Photos, OneDrive, Dropbox, Nextcloud, Immich und Paperless-ngx |
| [Sicherheit](features/security.md) | Dein Passwort ändern, Zwei-Faktor-Authentifizierung, Wiederherstellungscodes, gemerkte Geräte, App-Passwörter, Sitzungen |
| [Andere Clients](features/clients.md) | Einen JMAP-Client verbinden, Passwörter pro App, die PWA und Browser-Benachrichtigungen |
@@ -67,6 +68,7 @@ Jede Seite nennt die genaue Konsole, die genauen Häkchen und die genauen Redire
| [Microsoft](providers/microsoft.md) | App-Registrierung in Azure, Redirect-URIs, delegierte Berechtigungen, Wahl des Tenants, Graph-Abonnements |
| [IMAP und SMTP](providers/imap-smtp.md) | Gewöhnliche Postfächer, IDLE, und die Einstellungen, über die sich die Server uneinig sind |
| [CalDAV](providers/caldav.md) | Discovery, App-Passwörter, und die Server, die getestet wurden |
| [CardDAV](providers/carddav.md) | Adressbücher von Nextcloud und anderen Servern: Vorschläge beim Schreiben, Bearbeiten, und Synchronisation in beide Richtungen |
| [ICS-Feeds](providers/ics-feeds.md) | Einen veröffentlichten Kalender per URL abonnieren, und warum manche Adressen abgelehnt werden |
| [Single Sign-on](providers/oidc.md) | Anmeldung bei plMail über Authentik, Keycloak oder einen anderen OpenID-Connect-Anbieter: Einrichtung, Verknüpfung von Konten und automatisches Anlegen |
+55 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/accounts.md sha1:e3b70a077702b079166c724d88537caef5fba35f -->
<!-- translated-from: features/accounts.md sha1:a008e379503ce8b4292e95d3580ed2550552e214 -->
# Konten und Aliase
@@ -282,6 +282,60 @@ Einen Vorbehalt nennt plMail auf der Seite selbst: **Outlook versendet unabhäng
Wahl hier immer über den primären Alias des Kontos.** Das ist Microsofts Verhalten, geändert
wird es in deinen Microsoft-Kontoeinstellungen und nicht hier.
### Gmail holt Mail für ein anderes Konto ab (Gmailify)
Gmail kann die Mail einer anderen Adresse in deinen Gmail-Posteingang holen. Ist diese andere
Adresse auch hier verbunden, kommt jede Nachricht zweimal bei plMail an: einmal über Gmail und
einmal über die eigene IMAP-Synchronisierung des anderen Kontos. plMail legt solche Mail unter dem
anderen Konto ab und führt die beiden Kopien zu einer zusammen, sodass eine Nachricht nur einmal
in der Liste steht. Die zusammengeführte Nachricht gehört weiter dem anderen Konto: Ihr
IMAP-Ordner entscheidet, ob sie im Posteingang, im Papierkorb oder in einem anderen Ordner liegt,
egal was Gmail mit seiner eigenen Kopie gemacht hat. Labels, die du ihr in Gmail gibst, erscheinen
hier, und wenn du sie hier archivierst, markierst oder labelst, geht das auch an Gmail.
Unter jedem Gmail-Konto auf dieser Seite schaltet **Mail für meine anderen Konten unter diesen
ablegen** das an oder aus. Es ist an, solange du es nicht ausschaltest. Schalte es aus, sobald
Gmail diese Mail nicht mehr abholt, typischerweise nachdem eine Domain von Gmail zu einem eigenen
IMAP-Anbieter umgezogen ist. Sonst werden alte und verspätete Kopien aus Gmail weiter dem anderen
Konto zugeordnet. Wenn es aus ist:
- Das Gmail-Konto behält nur Mail, die an es selbst gerichtet ist. Mail, die es für andere
Adressen hat, wird nicht noch einmal gespeichert.
- Die eigenen Kopien des anderen Kontos folgen Gmail nicht mehr. Sie verlieren die Labels, die
Gmail ihnen gegeben hat, und was du an ihnen änderst, geht nicht mehr an Gmail. Die Mail selbst
bleibt, wo ihr IMAP-Ordner sie hat. Das läuft im Hintergrund, bei einem Postfach, das jahrelang
Mail abgeholt hat, also unter Umständen noch ein paar Minuten nach dem Ausschalten.
- Mail, die Gmail unter dem anderen Konto abgelegt hat und die der IMAP-Server des anderen Kontos
nie hatte, bleibt, wie sie ist, und folgt weiter Gmail. Gmail ist der einzige Ort, an dem es
diese Mail gibt.
**Am besten ausschalten, sobald Gmail nicht mehr abholt.** Solange Gmail die Mail der anderen
Adresse noch abholt, unterscheidet plMail diese Mail an ihren Empfängern: Eine Nachricht, deren An
oder Cc die andere Adresse nennt und nicht die Gmail-Adresse, bleibt dem anderen Konto überlassen,
auch wenn Gmail seine eigene Adresse als Zustelladresse daraufstempelt. Eine Nachricht, die per Bcc
an die andere Adresse ging, nennt niemanden, sieht deshalb wie eigene Mail des Gmail-Kontos aus und
liegt in beiden Konten. Mailinglisten und Newsletter kommen oft so an. Beende das Abholen in Gmail
(Einstellungen → Konten → E-Mails von anderen Konten abrufen), dann hören auch diese Doppelten auf.
Schaltest du es wieder an, ändert sich an Gespeichertem nichts. Mail, die danach ankommt, wird
wieder zugeordnet, und eine ältere Nachricht ebenso, sobald Gmail eine Änderung an ihr meldet.
**Mail, die Gmail vor v0.3.9 verschoben hat.** Frühere Versionen haben die Kopie des anderen
Kontos dorthin verschoben, wohin Gmail seine eigene verschoben hat. Mail, die in Gmail gelöscht
oder archiviert wurde, stand in plMail im Papierkorb oder im Archiv, obwohl der IMAP-Server sie
noch im Posteingang hatte. Um diese Mail dorthin zurückzulegen, wo ihr IMAP-Ordner sie hat, führt
ein Administrator aus:
```bash
php bin/console app:backfill gmailify-placement
```
Führe es aus, wenn die Worker ruhig sind. Eine Verschiebung, die du in plMail machst, ändert erst
die Labels und einen Moment später den IMAP-Ordner, und eine Nachricht, die genau dazwischen
erwischt wird, ginge kurz in ihren alten Ordner zurück. Mail, die frühere Versionen entfernt
haben, weil Gmail sie aus seinem Papierkorb geleert hatte, holt das nicht zurück. Die
IMAP-Synchronisierung holt nur Mail, die neuer ist als das, was sie schon hat.
### Was jede Adresse standardmäßig tut
Zwei Einstellungen hängen an der Aliasliste statt am Konto, denn sie sind Eigenschaften der
+33 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/admin.md sha1:745cb9593e98a39e4729dffb19f7be3e1808df78 -->
<!-- translated-from: features/admin.md sha1:32548a111582d707b9c8a1ef8439eeaebb02c69c -->
# Administration
@@ -333,8 +333,8 @@ den erledigten Stapel ab; der Knopf erscheint nur, wenn etwas darin liegt, und n
## Integrationen
Zwei Dinge wohnen hier: welche Dateidienste diese Installation anbietet, und die
OAuth-Anwendungen, über die Menschen sich bei ihrer Mail anmelden.
Drei Dinge wohnen hier: welche Dateidienste diese Installation anbietet, die Serveradresse für
ihre Kalenderdienste, und die OAuth-Anwendungen, über die Menschen sich bei ihrer Mail anmelden.
**Mail-Anmeldung** hält Client-ID und Client-Secret von Google und Microsoft, den
Microsoft-Tenant und für Gmail das Pub/Sub-Topic und das Push-Verifizierungstoken. Lässt du
@@ -357,6 +357,26 @@ hat:
- **Serveradresse**, bei den selbst gehosteten. Lass sie leer, und jede Person trägt ihre eigene
ein; setze sie, und alle sind an diesen Server gebunden.
**Kalender** listet CalDAV und Kalender-Feeds unter den Dateidiensten auf, mit derselben Zeile und
demselben Formular hinter **Konfigurieren**. Beide stehen allen zur Verfügung, gleich was hier
eingestellt ist, darum steht in der Zeile **Für alle verfügbar**, und das Formular hat keinen
Schalter. Nextcloud steht dort nicht ein zweites Mal: Es wird einmal bei den Dateidiensten
eingerichtet, und seine Kalender nutzen diese Adresse.
- **CalDAV** hat die eine Einstellung, die der Einrichtungsassistent womöglich schon geschrieben
hat: **Serveradresse**. Setze sie, und jede CalDAV-Verbindung findet ihre Kalender auf diesem
Server; leere sie, und es gilt wieder die eigene Adresse jeder Person. Eine Änderung greift, wenn
auf einer Verbindung das nächste Mal Kalender gesucht werden. Bereits gespiegelte Kalender
behalten die Adresse, unter der sie gefunden wurden, und wer eine Adresse in einem privaten Netz
leert, beendet auch ihre Ausnahme von der Regel für private Netze — diese Kalender
synchronisieren dann nicht mehr, bis der Host in `INTEGRATIONS_ALLOWED_HOSTS` steht. Sobald sich
jemand verbunden hat, sagt das Formular, wie viele Verbindungen eine Änderung erreicht. Siehe
[CalDAV](../providers/caldav.md).
- **Calendar feed**, der Kalender-Feed, hat keinen Server zum Festlegen, denn jeder Feed ist seine
eigene Adresse; seine Zeile hat darum nichts einzurichten. Nur eine Adresse, die der
Einrichtungsassistent früher gespeichert hat, wird angezeigt, mit **Konfigurieren**, um sie zu
leeren: Eine solche Adresse lässt nur Feeds auf ihrem Host an der Regel für private Netze vorbei.
**Zugangsdaten von … übernehmen** kopiert für Google wie für Microsoft eine Client-ID und ein
Secret serverseitig hinüber, ohne das Secret je anzuzeigen. Ein Google-Cloud-Projekt deckt auch
Drive und Photos ab; eine Entra-Registrierung deckt auch OneDrive ab. Die Zugangsdaten zu
@@ -638,7 +658,7 @@ Speicher und der Klick hat nichts gebracht. Praktisch nach einem Neustart — un
was ein kalter Ladevorgang auf deiner Hardware wirklich kostet, bevor du entscheidest, wie lange du
das Modell hältst.
### Die vier Funktionen sind einzeln schaltbar
### Die fünf Funktionen sind einzeln schaltbar
Sie kosten sehr Unterschiedliches, und es hat sehr unterschiedliche Folgen, wenn das Modell danebenliegt.
@@ -648,6 +668,7 @@ Sie kosten sehr Unterschiedliches, und es hat sehr unterschiedliche Folgen, wenn
| **Mail in Tabs einsortieren** | Setzt eine Kategorie, wenn keine Regel gegriffen hat | Bei jeder eingehenden Nachricht |
| **Beim Schreiben helfen** | Bietet Entwürfe und Umformulierungen im Editor an | Nur wenn du fragst |
| **Konversationen zusammenfassen** | Schreibt einen kurzen Abriss eines langen Gesprächs | Nur wenn jemand darum bittet |
| **Kontakthilfe** | Liest einen Kontakt aus eingefügtem Text und schlägt Änderungen an einem Kontakt anhand der Signatur seiner letzten Mail vor | Nur wenn jemand auf **Mit KI verbessern** klickt |
Beim Einsortieren solltest du am ehesten vorsichtig sein: Es läuft unbeaufsichtigt. Es kann immer nur
den Stichentscheid geben — wenn ein Header, ein Gmail-Label oder die Tatsache, dass du der Person
@@ -674,7 +695,7 @@ Zwei Dinge sorgen dafür, dass das Einsortieren ankommender Mail nicht im Weg st
Die Schreibhilfe ersetzt nie, was du geschrieben hast. Sie hängt an, dein Original bleibt stehen.
Zusammenfassungen sind die teuerste der vier. Sie nutzen dasselbe Schreibmodell, aber ein ganzes
Zusammenfassungen sind die teuerste der fünf. Sie nutzen dasselbe Schreibmodell, aber ein ganzes
Gespräch ist eine deutlich größere Eingabe als ein Entwurf: Auf einem 30B-Modell mit einer GPU
rechne mit rund einer Minute für die erste Zusammenfassung nach einer ruhigen Phase und etwa der
Hälfte, solange das Modell warm ist. Die GPU ist die ganze Zeit belegt, auf einer geteilten Maschine
@@ -682,6 +703,13 @@ merken das also alle. Von allein wird nie etwas zusammengefasst — keine eingeh
nächtlicher Durchlauf — und eine geschriebene Zusammenfassung wird aufbewahrt und wieder angezeigt,
das zweite Öffnen derselben Konversation kostet also nichts.
Die Kontakthilfe schickt Text über andere Menschen: was jemand in einen neuen Kontakt eingefügt hat,
oder einen gespeicherten Kontakt zusammen mit dem Signaturblock der neuesten Mail, die dieser Kontakt
geschickt hat — der Teil unter `-- ` oder unter der Grußformel („Viele Grüße“), nie der Text der Mail.
Eine Mail mit keinem von beiden schickt nichts aus dieser Mail. Nichts, was sie vorschlägt, wird
gespeichert, bevor die Person es anhakt und den Kontakt speichert. Ohne sie funktioniert das Einfügen
trotzdem: plMail liest den Text nach Regeln, nur der Knopf, der das Modell fragt, fällt weg.
### Suchen nach Bedeutung braucht einmal einen Durchlauf
Die semantische Suche findet nur Mail, die indexiert wurde, und das Einschalten allein indexiert
+14 -4
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/calendar-sync.md sha1:2035fa53b2757d4eef5706a37a0d6fab6f70ef92 -->
<!-- translated-from: features/calendar-sync.md sha1:86f9d9e067468c420433595984c981709680f476 -->
# Verbundene Kalender
@@ -23,9 +23,19 @@ Zustimmung erfragt wie den Postfachzugriff. Es gibt keine zweite Anmeldung, kein
registrieren und nichts Zweites einzurichten — das Konto zeigt eine Schaltfläche **Kalender auf …
suchen**, und das ist alles.
**Verbindungen, die es nur für Kalender gibt.** Ein **CalDAV-Server** — Nextcloud, Radicale, Baïkal,
Fastmail, iCloud, eine Kiste im Schrank — oder eine **Kalenderadresse**, also jede veröffentlichte
`.ics`.
**Verbindungen, die es nur für Kalender gibt.** Ein **CalDAV-Server** — Radicale, Baïkal, Fastmail,
iCloud, eine Kiste im Schrank — oder eine **Kalenderadresse**, also jede veröffentlichte `.ics`.
**Deine Nextcloud-Verbindung.** Hast du Nextcloud für Dateien unter **Einstellungen →
Integrationen** verbunden, steht sie auch hier, mit einer eigenen Schaltfläche **Kalender auf …
suchen**. Dasselbe App-Passwort erreicht deine Nextcloud-Kalender, es gibt also keine Adresse und
kein Passwort ein zweites Mal einzugeben. Statt einer Schaltfläche zum Trennen steht dort *Unter
Integrationen eingerichtet*: Die Verbindung hält auch deine Dateien, also wird sie dort getrennt,
und die Rückfrage dort sagt, wie viele Kalender mitgehen.
Ein Kalender, der schon über eine andere Verbindung gespiegelt wird — etwa über eine CalDAV-Verbindung,
die du von Hand zur selben Nextcloud angelegt hast —, erscheint als *Wird bereits über … gespiegelt*
und lässt sich kein zweites Mal anhaken, damit seine Termine nicht doppelt synchronisiert werden.
Sagt der Abschnitt *Noch kein Konto und keine Verbindung hier führt Kalender.*, hast du IMAP-Konten
und sonst nichts; verbinde einen CalDAV-Server oder abonniere eine Adresse.
+36 -9
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/calendar.md sha1:122f0e14aa2280cfe38b171539644284c35d7c38 -->
<!-- translated-from: features/calendar.md sha1:d781321a4c7533e5b2770a4250b64e69f3cfee39 -->
# Kalender
@@ -76,13 +76,25 @@ Zurück und Weiter springen in der Monatsansicht um einen Monat, in der 7-Tage-A
und überall sonst um einen Tag — die Agenda eingeschlossen, denn sie ist eine fortlaufende Liste und
keine Seite.
**Auf einem schmalen Bildschirm rückt die Werkzeugleiste zusammen, statt zu scrollen.** Unter etwa
590 Pixeln zeigt der Ansichtsumschalter Symbole, und das Datum nimmt eine kurze Form an: der Monat
ohne Jahr, solange es dieses Jahr ist (**Oktober**), eine Woche als **20.–26. Okt.**, ein Tag als
**20. Okt.** Auf den schmalsten Telefonen wird **Heute** zum heutigen Datum in einem kleinen Kästchen.
Die volle Seite öffnet mit der Ansicht **7 Tage**. Die angedockte Fläche öffnet mit **Agenda** — sie
ist 380 Pixel einer geteilten Zeile, und ein Monatsraster in dieser Breite besteht überwiegend aus
leeren Zellen. Die Fläche bietet trotzdem alle vier Ansichten an, als Symbole statt als Wörter, denn
die Fassung, die den Umschalter ganz weggelassen hatte, ließ die Fläche nichts als ihre Agenda
zeigen.
In der Monatsansicht zeichnet eine Zelle so viele Einträge ihres Tages, wie hineinpassen, und blendet
**In der Monatsansicht steht bei einem Eintrag der Titel zuerst**, auf bis zu zwei Zeilen, und
darunter, wann er ist und wo: **Quartalsplanung Q4**, dann **10:00–11:00 · Raum Elbe**. In einem
schmaleren Tag fallen das Ende und der Ort weg, und der Titel bekommt eine dritte Zeile in kleinerer
Schrift; lange Wörter werden in der Sprache getrennt, auf die plMail eingestellt ist. Eine Einladung,
die du noch nicht beantwortet hast, hat einen gestrichelten Balken, damit die offenen Einladungen
der Woche auf einen Blick zu sehen sind.
Eine Zelle zeichnet so viele Einträge ihres Tages, wie hineinpassen, und blendet
den letzten dort aus, wo der Platz endet — statt sie zu zählen. Früher stand dort **N weitere**, und
diese Zahl konnte nicht stimmen: Die Höhe einer Zelle ist die des Fensters geteilt durch sechs, und
ein Eintrag ist ein oder zwei Zeilen hoch, je nachdem, ob er eine Uhrzeit hat. Die Zahl war immer
@@ -92,13 +104,18 @@ diesen Tag, und dort stehen die übrigen.
**Bleibt der Zeiger auf einem Tag stehen, klappt dieser ganze Tag heraus.** Die Zelle wird zu einer
kleinen Fläche, breit genug für die vollen Titel und lang genug für jeden Eintrag. Dabei verrutscht
nichts: Sie wächst nach rechts und nach unten, genau von dort, wo die Liste ohnehin stand, und der
Eintrag, den du angesehen hast, liegt weiterhin unter dem Zeiger.
Eintrag, den du angesehen hast, liegt weiterhin unter dem Zeiger. Auf einem Touchscreen, der keinen
Zeiger hat, der stehen bleiben könnte, **öffnet der erste Tipp auf einen Tag diesen Tag** und tut
sonst nichts; die Einträge darin öffnen sich beim nächsten Tipp.
**Wo eine Zelle zu schmal für Wörter ist, verzichtet sie darauf.** Sieben Spalten eines Telefons
sind je etwa vierzig Pixel breit, und in dieser Breite wird weder ein Titel noch eine Uhrzeit fertig.
Darunter wird ein Eintrag deshalb als Balken in der Farbe seines Kalenders gezeichnet — wie viele,
und aus welchen Kalendern, auf einen Blick. Ein Tipp auf das Datum öffnet den Tag, dort stehen sie
ausgeschrieben.
**Wo eine Zelle zu schmal für Wörter ist, verzichtet sie darauf, und der Tag steht stattdessen
unter dem Monat.** Sieben Spalten eines Telefons sind je etwa fünfzig Pixel breit, und in dieser
Breite wird weder ein Titel noch eine Uhrzeit fertig. Dort wird ein Eintrag deshalb als Balken in der
Farbe seines Kalenders gezeichnet — wie viele, und aus welchen Kalendern, auf einen Blick —, und das
Raster wird kleiner, um darunter Platz für die Liste eines Tages zu machen, ausgeschrieben, wie die
Agenda schreibt. Sie beginnt mit heute; **ein Tipp auf einen beliebigen Tag, die Balken
eingeschlossen, listet stattdessen diesen Tag**. Die Liste hat ihr eigenes **+**, das einen neuen
Termin an diesem Tag öffnet, und **Neuer Termin** in der Werkzeugleiste legt ihn ebenfalls dort an.
Die Agenda fasst ihre Einträge unter einer Überschrift je Tag zusammen, die oben stehen bleibt,
solange die Termine dieses Tages auf dem Schirm sind. Beginn und Ende — oder **Ganztägig** — stehen
@@ -141,6 +158,11 @@ Quadratzentimeter groß und hat keinen Platz zu sagen, wo im Tag etwas liegt; un
Agenda besteht darin, die leere Zeit zu überspringen — also genau das, was eine Rasterachse zeichnen
würde.
**Die rote Linie der aktuellen Zeit läuft mit der Uhr mit**, einmal pro Minute, und wenn sich das
Datum ändert, zeichnet sich die Ansicht neu — die Linie, die Tönung auf heute und der Kreis um das
heutige Datum im Monat wandern zum neuen Tag —, sodass ein Kalender, der neben der Mail offen bleibt,
stimmt.
**Das Raster wird auf deiner Uhr gezeichnet.** Die Stunden an der Seite, die Stelle, an der die
Linie der aktuellen Zeit sitzt, und die Zeit, die für einen neuen Termin vorgeschlagen wird, wenn du
auf leeren Raum doppelklickst, folgen alle der Zeitzone aus **Einstellungen → Allgemein**. Das ist
@@ -179,7 +201,12 @@ Agenda, und die Tagesansicht ist in der Werkzeugleiste einen Tipper entfernt und
**Neuer Termin** in der Werkzeugleiste, das **+** an einer Tagesüberschrift und ein Klick auf einen
vorhandenen Termin öffnen alle denselben Editor. Er ist ein Dialog über der Seite, auf der du gerade
warst — der Kalenderseite oder einer Mail-Ansicht mit dem angedockten Kalender daneben — und
Speichern bringt dich dorthin zurück, statt dich in den Kalender zu ziehen.
Speichern bringt dich dorthin zurück, statt dich in den Kalender zu ziehen. Auf einem Telefon füllt er
den ganzen Bildschirm, wie das Fenster zum Verfassen.
Ein **+** an einem Tag öffnet den Editor an diesem Tag zur nächsten vollen Stunde. Ein Tag, der schon
vorbei ist, behält sein Datum: Ein Termin, den du nachträglich einträgst, landet an dem Tag, an dem er
war, nicht an heute.
Die Felder sind **Titel**, **Beginn**, **Ende**, **Ganztägig**, **Kalender**, **Wiederholung**,
**Ort**, **Beschreibung** und **Erinnerungen**, dazu **Ändern**, sobald sich der Termin wiederholt.
+219
View File
@@ -0,0 +1,219 @@
<!-- translated-from: features/contacts.md sha1:713dc966d588067fd0d5ad0abafcfad4a6e76e2e -->
# Kontakte
plMail führt ein Adressbuch: die Menschen, denen du schreibst, mit ihren Adressen, Telefonnummern,
Postanschriften, Geburtstagen, einem Foto und Notizen. Es ist eine Liste, egal wo ein Kontakt liegt. Manche
gehören nur plMail und gibt es nur hier. Andere kommen aus einem Adressbuch auf einem Server, und
Änderungen gehen in beide Richtungen:
- ein **Google**- oder **Microsoft**-Mailkonto bringt seine Kontakte mit, auf derselben Anmeldung
wie seine Mail und Kalender — nichts zu verbinden (siehe [Google](../providers/google.md) und
[Microsoft](../providers/microsoft.md));
- deine [Nextcloud](../providers/carddav.md#einen-server-verbinden) oder ein anderer CardDAV-Server
wird unter **Einstellungen → Kontakte** verbunden.
Ein Konto, das verbunden wurde, bevor plMail Kontakte synchronisiert hat, braucht eine weitere
Anmeldung, um sie zu erlauben: **Einstellungen → Kontakte** und **Einstellungen → Zustand der Konten** bieten sie
beide an.
Du öffnest es mit dem Adressbuch-Symbol neben dem Kalender-Knopf. Wie der Kalender-Knopf schaltet
es durch drei Stellungen: das Adressbuch neben der Mail, dann in voller Breite mit deiner Mail
dahinter, dann wieder zu. **Einstellungen → Kontakte → Verhalten** macht daraus zwei Stellungen
(**Nur volle Breite** oder **Nur neben der Mail**), wie beim Kalender. Auf dem Handy öffnet
**Kontakte** im Menü die ganze Seite.
Das angedockte Adressbuch ist das ganze Adressbuch: Du suchst, öffnest, bearbeitest, legst an und
führst Kontakte dort zusammen, während deine Mail bleibt, wo sie war. Zieh an seinem Rand, um es
breiter oder schmaler zu machen (oder nimm die Pfeiltasten am Rand; ein Doppelklick stellt es
zurück). Ziehst du es über seine größte Breite hinaus und lässt los, geht es auf volle Breite; über
seine kleinste hinaus, schließt es sich. plMail merkt sich Breite und Stellung. Die Pfeile in seiner
Kopfzeile schalten auf volle Breite, und das × schließt es. Öffnest du bei voller Breite eine
Nachricht aus einem Kontakt, kommt die Mail daneben zurück.
Auf einem breiten Monitor (ab 1920 Pixeln Breite) können Adressbuch und Kalender nebeneinander
angedockt sein. Auf einem kleineren Bildschirm, etwa dem eines Laptops, ist neben der Mail Platz für
eines von beiden, also räumt das eine das andere weg. Unter 1024 Pixeln öffnet das Symbol die ganze
Seite.
## Die Liste
Jeder Kontakt als Karte: Foto oder Initialen, Name, Position und Firma und die wichtigste
E-Mail-Adresse. Die Suche findet einen Kontakt über jeden seiner Namen, seine Firma oder jede seiner
Adressen. Schlagwörter (du vergibst sie an einem Kontakt) stehen als Chips über der Liste und
grenzen sie auf ein Schlagwort ein.
Sobald du ein synchronisiertes Adressbuch hast, wählt eine zweite Reihe Chips, wo du suchst: **Alle
Bücher**, **Nicht synchronisiert** (die Kontakte, die nur in plMail liegen) oder ein Adressbuch. Ein
synchronisierter Kontakt trägt eine kleine Wolke, oder ein Schloss, wenn sein Adressbuch
schreibgeschützt ist. Eine Uhr heißt, dass eine hier gemachte Änderung darauf wartet, an den Server
zu gehen.
## Ein Kontakt
Die Seite eines Kontakts zeigt seine Angaben, seine Notizen, verwandte Kontakte und
**Referenz-E-Mails**: jede nicht gelöschte Nachricht an ihn oder von ihm, in jedem Ordner. Ein
Klick öffnet sie in der Mail neben dem Kontakt, der bleibt, wie er ist; auf der ganzen Seite schiebt
sich die Mail von links herein.
**Neuer Kontakt** öffnet die **Schnellerfassung**: ein Feld, in das du irgendetwas über die Person
tippst oder einfügst — einen Namen, eine E-Mail-Signatur, eine Zeile wie
`Mia Hoffmann <mia@example.net>`, eine Adresse oder eine ganze vCard. Es wird beim Tippen gelesen,
und was gefunden wird, erscheint darunter als Zeilen: Jede kannst du abwählen, neu tippen oder mit
einem Klick einem anderen Typ zuordnen (eine Arbeitsnummer, die eine private ist, eine Zeile, die als
Position erkannt wurde und eigentlich die Firma ist). Eine Zeile, die nicht zugeordnet werden konnte,
wird erfragt statt geraten, eine Grußformel über einer Signatur wird weggelassen, und eine Adresse,
die schon bei einem deiner Kontakte steht, wird angezeigt. Eine vCard wird Feld für Feld gelesen,
nichts wird geraten. **Alle Felder** öffnet den vollständigen Editor mit allem, was gefunden wurde.
Der **Editor** zeigt den Namen als ein Feld; seine Teile (Anrede/Titel, Vor-, zweiter Vor- und
Nachname, Namenszusatz) füllen sich beim Tippen und öffnen sich unter **Teile bearbeiten**. Bearbeitest
du einen Teil von Hand, bleibt er so, und die Seite sagt, wenn Name und Teile nicht mehr zusammenpassen.
E-Mail und Telefon enden in einer gestrichelten Zeile — tipp hinein, und sie wird zu einer Zeile,
als privat oder geschäftlich nach der Adresse oder als Handy nach der Nummer eingeordnet, bis du
selbst einen Typ wählst. Der Stern markiert die bevorzugte. Jede andere Art von Angabe erscheint nur,
wenn der Kontakt eine hat; die Chips unter **Hinzufügen** holen den Rest. Sobald sich etwas geändert
hat, sagt eine Leiste unten, wie viel, und bietet **Speichern** und **Verwerfen** an.
**Details einfügen** im Editor liest eingefügten Text genauso und vergleicht ihn mit dem Kontakt,
bevor sich etwas ändert: Was schon da ist, ist ausgegraut, was neu ist, ist angehakt, und was vom
Gespeicherten abweicht („Ersetzt …“ oder ein anderer Typ für dieselbe Nummer), hakst du selbst an.
**Übernehmen** trägt das Angehakte ins Formular ein und markiert es; gespeichert wird erst, wenn du
speicherst.
Hat deine Administration die **Kontakthilfe** eingeschaltet (Admin → KI), haben beide Seiten
**Mit KI verbessern**. In der Schnellerfassung liest es den Text ein zweites Mal — die Firma, die die
Regeln nicht von einem Slogan unterscheiden konnten, eine Faxnummer, die als `Fax -99` dasteht — und
markiert, was es geändert hat; **KI-Änderungen rückgängig** geht zurück. Im Editor schickt es den
Kontakt und die Signatur der neuesten Mail, die der Kontakt dir geschickt hat, und zeigt seine
Vorschläge, jeden mit Begründung, in derselben Liste wie eingefügte Details. Für dich selbst schaltest
du es unter Einstellungen → Assistent ab.
Ein Kontakt hat:
- seinen Namen in Teilen (Anrede/Titel, Vor-, zweiter Vor- und Nachname, Namenszusatz), einen
Spitznamen und wie die Namen gesprochen werden (**Phonetische Schreibweise**, für Namen, die ein
Handy anders sortieren oder vorlesen soll);
- Firma, Abteilung, Position und Büro;
- E-Mail-Adressen, Telefonnummern und Postanschriften, jede mit Bezeichnung und je eine als primär
markiert;
- einen Geburtstag und weitere Daten (etwa einen Jahrestag) — das Jahr darf fehlen;
- **Personen**: die Namen von Ehepartner, Kindern, Vorgesetzten, Assistenz und so weiter;
- Websites, Chat-Adressen (SIP, XMPP, Skype, Teams, Matrix und andere) und **eigene Felder** mit
selbst gewählten Namen;
- Notizen, Schlagwörter und ein Foto.
Eine Bezeichnung, die vom Server kommt und keine von plMails eigenen ist (eine auf dem Handy
getippte „Patentante“), wird so gezeigt, wie sie ist, und bleibt beim Speichern erhalten.
Hast du ein synchronisiertes Adressbuch, wählst du unter **Speichern in**, wohin ein neuer Kontakt
kommt: ein Knopf pro Adressbuch, wenn du bis zu vier hast, ein Menü, wenn es mehr sind. **plMail**
behält ihn nur in plMail. Sonst geht er an den Server dieses Adressbuchs und von dort auf jedes
Gerät, das es synchronisiert; die Zeile unter den Knöpfen sagt, wohin. Ein schreibgeschütztes
Adressbuch wird nie angeboten.
Bearbeiten geht bei beiden Arten gleich. Bei einem synchronisierten Kontakt wird die Änderung hier
sofort gespeichert und gleich danach an den Server geschickt, und von dort auf dein Handy. Was
plMail nicht als Feld hat, etwa die Eigenschaften, mit denen ein Handy einen Kontakt an eine App
bindet, bleibt auf der Kopie des Servers unberührt. Google und Outlook haben für manche Felder
keinen Platz; siehe **Fallstricke**. Wie
Änderungen und Konflikte behandelt werden, steht unter
[CardDAV](../providers/carddav.md#synchronisation).
Einen Kontakt aus einem **schreibgeschützten** Adressbuch (einem geteilten Buch, einem
Firmenverzeichnis) kannst du lesen und anschreiben, aber hier nicht ändern oder löschen. Seine Seite
sagt, woher er kommt.
**Fotos** sind JPG, PNG oder WebP bis 5 MB und werden mit höchstens 512 Pixeln an der längeren
Seite gespeichert. Sie gehen in beide Richtungen: Ein Foto auf dem Server wird hier angezeigt, und
ein Foto, das du hier hochlädst oder entfernst, geht an den Server. Im Editor lädt die Kamera am
Foto eines hoch, und **Foto entfernen** unten entfernt es.
## Löschen
**Löschen** legt einen Kontakt in den Papierkorb. Einen synchronisierten Kontakt zu löschen löscht
auch seine Karte auf dem Server, und der Kontakt bleibt im Papierkorb als einer, der nur in plMail
liegt.
## Duplikate
**Duplikate** listet Paare, die nach derselben Person aussehen, mit der Sicherheit des Treffers und
dem Grund (dieselbe E-Mail-Adresse, dieselbe Telefonnummer, ein ähnlicher Name). Wähle den, der
bleiben soll, und **Zusammenführen**. Adressen, Nummern und Schlagwörter des anderen wandern zum
behaltenen Kontakt, und seine Notizen werden unter die des behaltenen gehängt. Eine Zusammenführung
lässt sich 90 Tage lang rückgängig machen.
Synchronisierte Kontakte werden zusammengeführt wie alle anderen. Die Karte des behaltenen wird
aktualisiert, und die des zusammengeführten wird auf seinem Server gelöscht, sodass ein Rückgängig
den zusammengeführten Kontakt als einen zurückbringt, der nur in plMail liegt. Kontakte aus einem
schreibgeschützten Adressbuch lassen sich nicht zusammenführen.
## Import und Export
**Import** nimmt eine `.vcf`-Datei (vCard, wie jedes Handy und jedes Adressbuch sie exportiert) oder
eine `.csv`-Datei. Du siehst zuerst eine Vorschau. Importierte Kontakte liegen nur in plMail, und
danach landest du auf der Duplikate-Seite, um zusammenzuführen, was du schon hattest.
**Export** lädt alle Kontakte als eine `.vcf`-Datei herunter, mit Fotos. Ein synchronisierter
Kontakt wird als Karte seines Servers exportiert, mit deinen Änderungen darin, sodass die Datei
behält, wofür plMail kein Feld hat. Der CSV-Export ist eine Übersicht für eine Tabelle — eine
E-Mail-Adresse, eine Nummer und eine Website pro Kontakt — und lässt sich wieder importieren; um
Kontakte vollständig umzuziehen, nimm vCard.
## Beim Lesen und Schreiben von E-Mails
Beim Absender einer Nachricht und bei jeder Adresse in ihren Details (An, Cc, Antwort an) siehst du,
ob sie schon in deinen Kontakten steht, mit einem Knopf, um sie hinzuzufügen, wenn nicht. Der Knopf
fragt, wer das ist:
- **Neuer Kontakt**: Prüf den Namen aus der E-Mail und speichere. *Weitere Angaben* öffnet den
vollen Editor, Name und Adresse sind schon eingetragen.
- **Zu einem bestehenden Kontakt hinzufügen**: Such beim Tippen in deinen Kontakten und wähl einen
aus. Die Adresse kommt zu diesem Kontakt dazu, so bleibt jemand, der von einer zweiten Adresse
schreibt, eine Person. Die Suche beginnt mit dem Namen des Absenders. Kontakte in
schreibgeschützten Adressbüchern werden nicht angeboten.
Nach dem Speichern öffnet sich der Kontakt im angedockten Adressbuch neben der Mail. War es zu,
geht es auf, war es offen, springt es zu diesem Kontakt. Auch *Weitere Angaben* öffnet den Editor
dort. Auf dem Handy wird nichts angedockt: *Weitere Angaben* öffnet den Editor als Seite, und nach
dem Speichern wird der Knopf zu einem Link auf den Kontakt.
Adressierst du eine Nachricht, werden dir deine Kontakte
vorgeschlagen, zusammen mit den Adressen, die plMail aus deinen E-Mails kennt. Die Reihenfolge steht
unter [Vorschläge beim Schreiben](../providers/carddav.md#vorschläge-beim-schreiben).
## Auf dem Handy
Die plMail-Apps lesen und bearbeiten dieselben Kontakte über JMAP (`Contact/get`, `Contact/query`,
`Contact/set`). Jeder Kontakt sagt, in welchem Adressbuch er liegt und ob er sich ändern lässt. Eine
App kann einen Kontakt aus einem schreibgeschützten Buch nicht ändern, und ihre Änderungen an einem
synchronisierten Kontakt gehen an den Server weiter wie Änderungen im Web.
## Fallstricke
**Der Papierkorb hat noch keine Seite.** Ein gelöschter Kontakt wird aufbewahrt, aber im Web holt
ihn nichts zurück. Führe Duplikate lieber zusammen, statt sie zu löschen: Eine Zusammenführung lässt
sich rückgängig machen.
**Ein Import legt Kontakte an, die nur in plMail liegen.** Willst du eine `.vcf`-Datei in ein
synchronisiertes Adressbuch bringen, importiere sie auf dem Server (in der Nextcloud unter
Kontakte), und die Kontakte kommen mit der nächsten Synchronisierung hierher.
**Ein Kontakt kann nicht zwischen Adressbüchern wandern**, auch nicht aus plMail in ein
synchronisiertes. Lege ihn am anderen Ort an und lösche den alten.
**Google und Outlook fassen nicht alles.** Google behält jedes Feld oben. Outlook hat feste Plätze:
drei E-Mail-Adressen, zwei geschäftliche und zwei private Nummern und eine Mobilnummer, je eine
Anschrift jeder Art, je einen Ehepartner, Vorgesetzten und eine Assistenz und beliebig viele Kinder,
eine Website, drei Chat-Adressen und einen Geburtstag. Weitere Daten, einen phonetischen zweiten
Vornamen und eigene Felder kennt es nicht. Was nicht passt, wird nicht gesendet und bleibt nur in
plMail, bis sich der Kontakt das nächste Mal in Outlook ändert (siehe
[Microsoft](../providers/microsoft.md#kontakte-hängen-ebenfalls-an-derselben-anmeldung)).
**Outlook behält ein hier entferntes Foto.** Microsofts Schnittstelle kann das Foto eines
Outlook-Kontakts ersetzen, aber nicht entfernen; ein in plMail entferntes Foto kommt also zurück,
sobald sich der Kontakt in Outlook ändert. Entferne es in Outlook.
**Eigene Felder und das Büro gehören auf einem CardDAV-Server plMail.** vCard hat für beides keine
Eigenschaft, also stehen sie unter plMails eigenen Namen auf der Karte (`X-PLMAIL-FIELD`,
`X-PLMAIL-OFFICE`). Sie überstehen den Hin- und Rückweg, aber ein Handy zeigt sie nicht.
+77 -4
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/mail.md sha1:3ce077e532ee6d78f8b007edb8ab2e563af0413d -->
<!-- translated-from: features/mail.md sha1:df37980670901d8064e9a02c3c0316ebd5549b93 -->
# Mail
@@ -333,6 +333,12 @@ Was ein Datum trägt, reiht sich im Kalender-Panel **Demnächst** ein, unter **A
eine Konversation, in der etwas gefunden wurde, zeigt ihre Karten außerdem in einer Leiste
**In dieser Konversation gefunden** über den Nachrichten.
Mail im Spam ergibt keine Karten, auch nicht Mail, die du später dorthin verschiebst: Ihre Karten
verschwinden aus dem Panel und aus der Leiste über der Mailliste, und eine Spam-Konversation zeigt
keine über ihren Nachrichten — eine gefälschte Versandbenachrichtigung ist genau das, was Spam
nachahmt. **Kein Spam** (oder das Verschieben in den Posteingang) holt sie zurück. Der Papierkorb ist
etwas anderes: Wer eine Versandbestätigung löscht, hält das Paket nicht auf, also bleiben ihre Karten.
Auch ein Shop, der nie einen Zusteller nennt, ergibt ein Paket. Amazon nennt eine Bestellnummer und
einen Link in den eigenen Tracker und nirgends eine Sendungsnummer — genau das trägt dann die Karte.
Und ein Liefertag, der in Worten statt in Ziffern steht ("Arriving today", "Ankunft Montag"), wird
@@ -443,8 +449,10 @@ die SPF-, DKIM- und DMARC-Urteile daneben, sofern der Anbieter einen
HTML-Texte werden direkt eingebettet dargestellt und nicht in einem iframe. Vorher läuft der
Text genau einmal, beim Import, durch einen Sanitizer: `cid:`-Verweise werden auf plMails
eigene Anhang-Route umgeschrieben, `<style>`-Blöcke werden auf die Elemente heruntergezogen,
für die sie galten, und Skripte, Formulare, iframes und Klassen fallen weg. Links werden
gezwungen, außerhalb der App zu öffnen.
für die sie galten, und Skripte, iframes und Klassen fallen weg. Ein Formular wird aufgelöst statt
entfernt: Sein Inhalt bleibt, seine Textfelder und Auswahllisten fallen weg, und die Mail sieht so
aus, wie ihr Absender sie für ein Mailprogramm ohne Formulare gedacht hat. Links werden gezwungen,
außerhalb der App zu öffnen.
### Ein langes Gespräch zusammenfassen
@@ -604,6 +612,9 @@ deren Text nur `help.wirhub.de` steht.
`junk` (oder `spam`), `archive` (oder `archived`) und `snoozed`. Werte in Anführungszeichen
bleiben zusammen, `from:"Ada Lovelace"` funktioniert also.
Die Suche lässt Spam und Papierkorb aus, solange du sie nicht mit `in:spam` oder `in:trash`
verlangst, so wie die Listen auch. Die Vorschläge beim Tippen lassen beide aus.
Vorschläge erscheinen beim Tippen — und **echte Treffer** ebenfalls: die zehn neuesten passenden
Konversationen, unter den Operator-Vorschlägen, etwa ab dem dritten Zeichen. Sie sind eine Vorschau
und nicht die Antwort: Es laufen nur die Durchgänge, die schnell genug für jeden Tastendruck sind.
@@ -715,6 +726,37 @@ Knopf oben auf dem Bildschirm dreht sich, solange er läuft, und der Hinweis dre
Berührung ist der Knopf der Ersatz: ein benannter Knopf für Tastatur und Bildschirmleser, und die
Meldung zur Synchronisierung sagt, wann ein Lauf beginnt und endet.
Auf dem Telefon lässt sich eine Zeile auch wischen. Zieh sie nach rechts, und geschieht, was die
Richtung rechts eingestellt hat; standardmäßig wird sie als gelesen markiert, oder als ungelesen,
wenn sie schon gelesen ist. Zieh sie nach links, und standardmäßig wird sie archiviert. Was jede Richtung
tut, stellst du unter **Einstellungen → E-Mail → Wischgesten** ein (siehe unten). Über ein Drittel der Zeilenbreite wartet die Aktion hinter der Zeile, und Drücken führt
sie aus. Über zwei Drittel, oder ein schneller Wisch über das erste Drittel, geschieht sie beim
Loslassen. Ein Tippen irgendwo anders schließt eine offene Zeile, und dieses Tippen tut sonst nichts.
Gelesen / ungelesen und Archivieren gibt es nur bei normalen Mails: nie bei einem Entwurf und nie im
Archiv, im Papierkorb oder bei Spam. Stern gibt es bei jeder Zeile, auch bei Entwürfen. Ohne Berührung erledigen das
Kontrollkästchen der Zeile (ihr Avatar) und die Leiste dieselbe Arbeit, **Archivieren** und **Mehr**
für eine Auswahl eingeschlossen.
Jede Richtung hat ihre eigene Einstellung unter **Einstellungen → E-Mail → Wischgesten**, je eine
Auswahl für rechts und für links. Zur Wahl stehen Gelesen / ungelesen, Archivieren, Stern und Nichts.
Beide Richtungen dürfen auf dasselbe gesetzt sein. Löschen gehört nicht dazu (siehe *Fallstricke*). Ein Stern ist auf der Zeile ein Schalter: Ein zweiter Wisch nimmt ihn wieder weg, wie bei Gelesen /
ungelesen; Archivieren bietet sein **Rückgängig** an.
Auf dem Telefon öffnet auch ein längeres Drücken einer Zeile ihre Aktionen. Halte eine Zeile etwa eine
halbe Sekunde still, und von unten erscheint ein Aktionsblatt mit allem, was die Zeile anbietet:
auswählen, als gelesen oder ungelesen markieren, archivieren, markieren, verschieben (nur eine
Unterhaltung, nicht eine einzelne Nachricht), mit Labels versehen, auf später legen, als Spam markieren, löschen und, im Papierkorb oder im Spam, in den
Posteingang zurückholen und, wenn du unter **Einstellungen → E-Mail → Entsorgen → Knöpfe zum sofortigen
Löschen** eingeschaltet hast, endgültig löschen. Die Einträge sind die, die auch die eigenen Knöpfe der
Zeile anbieten, an denselben Stellen, und jeder tut, was sein Knopf tut. **Verschieben**, **Labels** und
**Später** öffnen ihre Auswahl über dem Blatt, wie in der Leiste. Das Blatt schließt sich bei einem
Tippen auf die abgedunkelte Seite, mit Escape oder nach einer Aktion; die Seite dahinter reagiert,
solange es offen ist, nicht. Eine Aktualisierung im Hintergrund lässt es in Ruhe, und eine Aktualisierung,
die die Zeile aus der Liste nimmt, schließt es. Wo der Browser vibrieren kann (Android Chrome), gibt es
beim Öffnen einen kurzen Impuls; iOS kennt für Webseiten keine Vibration, dort gibt es keinen. Bei
reduzierter Bewegung erscheint das Blatt ohne Gleiten. Ohne Berührung erledigen die Knöpfe der Zeile und
die Leiste dieselbe Arbeit.
### Tastenkürzel
Die einzelnen Tasten, die Gmail benutzt, funktionieren auch hier. Mit **?** bekommst du überall die
@@ -778,6 +820,9 @@ holt die Konversation zurück und nimmt einen Filter wieder weg, den derselbe Kl
an: Eine Auswahl hat nicht den einen Absender. Für Mail, die schon im Spam oder im Papierkorb
liegt, wird der Knopf nicht gezeigt.
Eine geöffnete Konversation im Spam hat stattdessen einen Knopf **Kein Spam** in ihrer
Werkzeugleiste. Er verschiebt sie zurück in den Posteingang, wie **Verschieben nach → Posteingang**.
Mail in den Spam zu verschieben sagt plMail nichts über die nächste Nachricht. plMail hat keinen
eigenen Spamfilter, der lernt; ob dein Anbieter das Verschieben als Signal wertet, entscheidet
der Anbieter. Ein Filter ist der Weg, es hier dauerhaft zu machen.
@@ -882,6 +927,10 @@ nur für diese Mail und **Bilder von … immer anzeigen**, das sich den Absender
Unter der Mail eines vertrauten Absenders steht das dann, mit **Nicht mehr anzeigen**, um es
zurückzunehmen. Mail, die du selbst gesendet hast, zeigt ihre Bilder ohne Nachfrage.
Angezeigte Bilder kommen immer über plMail, und nur über eine verschlüsselte Verbindung: Ein Bild,
das die Mail mit `http://` verlinkt, wird mit `https://` geholt. Der seltene Server, der so nicht
antwortet, hinterlässt ein leeres Feld.
**Einstellungen → Mail → Bilder**, **Externe Bilder immer anzeigen**, schaltet die Nachfrage für
alle Absender ab. Es ist aus, bis du es einschaltest. Absender können dann sehen, dass du ihre Mail
geöffnet hast, aber nicht deine Adresse: Die Bilder holt plMail und reicht sie an dich weiter, nie
@@ -1372,6 +1421,30 @@ Code noch geht — das kann nur die Mail, aus der er stammt.
**Ziehen zum Aktualisieren gibt es nur mit dem Finger, und nur ganz oben in der Liste.** Maus,
Trackpad und jedes Fenster ab der Tablet-Breite ziehen nie. Ein Ziehen beginnt nur, wenn die Liste
ganz oben steht, und nicht bei offener Seitenleiste; von weiter unten scrollt dieselbe Bewegung nur.
ganz oben steht, und nicht bei offener Seitenleiste, offener Zeile oder offenem Aktionsblatt; von weiter
unten scrollt dieselbe Bewegung nur.
Reduzierte Bewegung nimmt dem Hinweis das Gleiten und sonst nichts, der Auslösepunkt bleibt gleich.
**Ein Wisch ist eine Fingergeste auf dem Telefon, und seine Richtungen sind echte Richtungen.** Eine
Maus wischt nie, in keiner Breite, und ab der Tablet-Breite wischt nichts. Rechts heißt zum rechten
Bildschirmrand, unabhängig von der Sprache, denn eine Sprache von rechts nach links wird nicht
mitgeliefert. Wer eine ergänzt, muss entscheiden, wie sich die beiden Seiten spiegeln.
**Eine Aktualisierung lässt eine Zeile, die du wischst oder offen hast, wo sie ist.** Eine solche Zeile
behält ihren Zustand, während eine Synchronisierung oder eine Aktualisierung die Liste neu zeichnet.
Die Änderung an dieser Zeile kommt, wenn du sie schließt. Eine Zeile, die nur gedrückt wird, oder die
ein langes Drücken in das Aktionsblatt geöffnet hat, wird wie gewohnt neu gezeichnet. Eine
Aktualisierung, die die Zeile entfernt, nimmt sie von der Liste und schließt das Blatt mit ihr.
**Löschen ist kein Wisch.** Ein voller Zug hat kein Tippen davor, und eine Zeile im Papierkorb hat
kein eigenes Rückgängig. Das Löschen bleibt daher bei den Knöpfen der Zeile und im Papierkorb, wo
zuerst getippt werden muss. Auch unter **Einstellungen → E-Mail → Wischgesten** ist Löschen keine
Wahl: Ein dafür gespeicherter Wert gilt als Standard der Richtung, sodass auch ein wiederhergestelltes
Backup es nicht zurückbringt.
**Ein längeres Drücken öffnet das Blatt, und Zeilen werden auf dem Telefon nicht gezogen.** Unterhalb des
Tablet-Breakpunkts ist eine Unterhaltung kein natives Ziehen: Ziehen per HTML5 hat mit dem Finger nie
funktioniert, und das Abschalten verhindert, dass iOS eine Zeile während der halben Sekunde anhebt. Mit der
Maus lässt sich eine Zeile wie bisher ziehen. Das Blatt bietet Löschen an einer Zeile im Posteingang an, wie
es der eigene Knopf der Zeile tut, und hat kein Rückgängig, so wie dieser Knopf.
+2 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/troubleshooting.md sha1:ad52dfe87905d09cdd1f0545fabb06f413d402fa -->
<!-- translated-from: install/troubleshooting.md sha1:81f5c6d505546c5bab2723acf94c75233a8d970e -->
# Fehlersuche
Was `/healthz` bedeutet, wie du eine hängende von einer leeren Warteschlange unterscheidest, wo die
@@ -111,6 +111,7 @@ von ihnen ausgelöst.**
| jede Minute | `app:calendar:alerts` |
| `*/15` | `app:mail:sync` |
| `7-59/15` | `app:calendar:sync --stale` |
| `11-59/15` | `app:contacts:sync --stale` |
| stündlich um :20 | `app:calendar:push` |
| täglich 03:50 | `app:calendar:materialise` |
| täglich 04:00 | `app:push:renew --repair` |
+2 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/architecture.md sha1:776308c1fc128a2474116af3c889c58aa5c5e49a -->
<!-- translated-from: internals/architecture.md sha1:23028b00c52d254c70297c87bd36f5d6f1cffad5 -->
# Architektur
Die Schichten, was wo liegt, und die Regeln, die das so halten. Diese Seite beschreibt die
@@ -230,6 +230,7 @@ war, in dem das Projekt vorher steckte: Logs und verwaiste Blobs wuchsen ohne Gr
|---|---|---|
| `*/15 * * * *` | `app:mail:sync` | Weder Gmail-Push noch Graph-Abonnements garantieren Zustellung, und IDLE-Verbindungen brechen ab; Polling ist die Rückfallebene |
| `7-59/15 * * * *` | `app:calendar:sync --stale` | Der Mechanismus für CalDAV und ICS-Feeds, die Rückfallebene für Google und Graph. Gegen die Viertelstunde versetzt, damit er sich nicht auf den Mail-Durchlauf stapelt — beide teilen sich einen Worker |
| `11-59/15 * * * *` | `app:contacts:sync --stale` | CardDAV-Adressbücher haben überhaupt keinen Push, also kommen Änderungen vom Handy nur so an. Gegen beide Durchläufe oben versetzt, aus demselben Grund des geteilten Workers |
| `20 * * * *` | `app:calendar:push` | Stündlich, nicht weil so schnell etwas abliefe, sondern weil die Registrierung aus *Deployment*-Gründen scheitert, die mit dem Klick, der den Kalender verbunden hat, nichts zu tun haben |
| `* * * * *` | `app:mail:wake-snoozed` | Eine Minute ist die Einheit, in der Menschen eine Weckzeit wählen |
| `* * * * *` | `app:calendar:alerts` | Dasselbe Argument, und zusätzlich ist das Intervall die Schranke dafür, wie spät eine Erinnerung kommen kann |
+26 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/mail-ingest.md sha1:b6f9af84349aeff829bc8e6e82f7108d258f7ab6 -->
<!-- translated-from: internals/mail-ingest.md sha1:a58cbc774876056b0dd1036a91fed0d4d107f5a5 -->
# Mail-Ingest
Vom Anbieter in die Datenbank: wie aus Bytes eine Zeile `Message` wird, was mit ihr geschieht,
@@ -522,6 +522,31 @@ tatsächlich verloren. Das ist ein bewusster Handel: Eine Konversation, die in d
zurückkommt, in dem du sie verlassen hast, ist eine, an der du bereits gelernt hast
vorbeizuscrollen.
### „Im Papierkorb“ ist gespeichert, und die Datenbank pflegt es
Eine Konversation ist im Papierkorb, wenn sie Trash trägt und keine ihrer Nachrichten außerhalb
von Trash liegt (#268). Jede Liste, jedes Badge und die Suche lassen eine solche Konversation
weg. Sie lesen dafür `message_thread.binned`, statt die Regel aus den Nachrichten abzuleiten; bei
einem Postfach mit 67k Konversationen kostete das Ableiten etwa 170 ms pro Abfrage.
Die Spalte pflegen Trigger, nicht PHP (`Version20261022160000`). Die Schreibvorgänge, die die
Antwort ändern, sind verstreut: Labels über das ORM, rohes SQL, das Konversationen neu aufbaut
oder ein Label entfernt, `ON DELETE CASCADE`, eine Nachricht, die in eine andere Konversation
wandert, eine Antwort, die in einer Konversation im Papierkorb ankommt. Ein Flag, das PHP setzt,
wäre nur auf den Wegen richtig, an die jemand gedacht hat.
- `message_label` und `thread_label` feuern einmal pro Anweisung und berechnen nur die
Konversationen neu, deren Trash-Zeilen sich geändert haben. Ablegen, Lesen und Labeln kosten
nichts.
- `message` feuert pro Zeile: beim Einfügen nur, wenn die Konversation im Papierkorb ist (eine
neue Nachricht kann sie dort nur herausholen), beim Löschen und bei geändertem `thread_id` für
die Konversation, die die Nachricht verlassen hat.
- Jede Neuberechnung leitet die Antwort von Grund auf ab (`plmail_refresh_thread_binned()`),
deshalb spielt die Reihenfolge der Anweisungen in einem Flush keine Rolle.
Die Entity bildet die Spalte nur lesend ab. Im Speicher kann sie einer noch nicht geflushten
Änderung hinterherhinken, deshalb sollte PHP nicht darauf verzweigen; die Abfragen lesen sie.
## Das Änderungsprotokoll, das JMAP liest
Jede Mail-Änderung schreibt eine Zeile nach `jmap_change_log` (`App\Jmap\State\ChangeLog`). Der
+41 -4
View File
@@ -1,11 +1,11 @@
<!-- translated-from: providers/caldav.md sha1:ccfc56f41acd4b8b77775fff1716870993c07d8e -->
<!-- translated-from: providers/caldav.md sha1:a835199379cc0465712e3e763bf86d73bdc7a274 -->
# CalDAV
CalDAV ist das Standardprotokoll für Kalender auf einem Server, und plMail spricht es mit jedem
Server, der RFC 4791 umsetzt: Nextcloud, Radicale, Baïkal, Fastmail, iCloud, eine Synology-Kiste im
Schrank. Es gibt nirgends etwas zu registrieren und keinen Administrationsschritt — eine Person
verbindet einen Server aus den eigenen Einstellungen heraus, mit einer Adresse, einem Benutzernamen
Schrank. Es gibt nirgends etwas zu registrieren, und eine Administratorin muss nichts einschalten — eine
Person verbindet einen Server aus den eigenen Einstellungen heraus, mit einer Adresse, einem Benutzernamen
und einem Passwort.
**Gmail- und Outlook-Kalender kommen nicht auf diesem Weg herein.** Sie kommen mit dem Mail-Konto,
@@ -18,6 +18,42 @@ Was nach dem Verbinden eines Servers passiert — anhaken, welche Kalender gespi
Zwei-Wege-Synchronisation, schreibgeschützte Kalender —, steht in
[Verbundene Kalender](../features/calendar-sync.md).
## Nextcloud: keine zweite Verbindung
Eine Nextcloud, die unter **Einstellungen → Integrationen** für Dateien verbunden ist, bringt auch
ihre Kalender mit. Nextcloud stellt CalDAV unter demselben `/remote.php/dav` mit demselben
App-Passwort bereit, also steht diese Verbindung unter **Einstellungen → Kalender**, und ihre
Kalender werden von dort aus gefunden. Nichts auf dieser Seite ist dafür nötig.
- **Die Adresse** ist die der Nextcloud-Verbindung. Eine Adresse, die eine Administratorin oder ein
Administrator unter **Admin → Integrationen** festgelegt hat, gilt also auch für Kalender,
samt ihrer Ausnahme von der Regel für private Netze weiter unten.
- **Die Suche** beginnt bei `/remote.php/dav` unter dieser Adresse, auch bei Installationen in einem
Unterordner, und weicht erst auf `.well-known` aus, wenn dort nichts antwortet.
- **Trennen** der Nextcloud unter Einstellungen → Integrationen entfernt auch ihre Kalender. Wie bei
jeder Kalenderverbindung kommen Termine, die aus deinen E-Mails statt vom Server stammen, vorher in
deinen Standardkalender.
- **Eine eigene CalDAV-Verbindung**, die du früher zur selben Nextcloud angelegt hast, funktioniert
weiter. Ihre Kalender erscheinen in der Liste der Nextcloud-Verbindung als *Wird bereits über …
gespiegelt*, derselbe Kalender wird also nie doppelt gespiegelt.
Die allgemeine CalDAV-Verbindung unten bleibt für jeden anderen Server.
## Ein Server für alle
Unter **Admin → Integrationen**, im Abschnitt **Kalender**, lässt sich ein CalDAV-Server für alle
festlegen, und der Einrichtungsassistent stellt dieselbe Frage. Ist eine Adresse festgelegt, findet
jede CalDAV-Verbindung ihre Kalender auf diesem Server statt unter der Adresse, die ihre Person
eingetragen hat, und dieser Host ist von der Regel für private Netze
[unten](#adressen-die-plmail-ablehnt) ausgenommen.
Geändert oder geleert wird die Adresse an derselben Stelle, und die Änderung greift, wenn auf einer
Verbindung das nächste Mal Kalender gesucht werden. Bereits gespiegelte Kalender behalten die
Adresse, unter der sie gefunden wurden; nach einem Serverumzug also die Kalender neu suchen. Wer eine
festgelegte Adresse in einem privaten Netz leert, nimmt ihr auch die Ausnahme: Kalender von diesem
Host synchronisieren dann nicht mehr und melden *„Server address must not point at a private
network“*, bis der Host in `INTEGRATIONS_ALLOWED_HOSTS` steht.
## Einen Server verbinden
**Einstellungen → Kalender → CalDAV-Server verbinden.** Vier Felder, in der Reihenfolge, in der die
@@ -108,7 +144,8 @@ Serveradresse:
es landete überall dort im Log, wo die URL landet, und würde stillschweigend die Zugangsdaten der
Verbindung überschreiben.
- Alles, was auf Loopback, Link-local oder einen privaten Bereich auflöst, wird abgelehnt, sofern der
Host nicht in `INTEGRATIONS_ALLOWED_HOSTS` steht.
Host nicht in `INTEGRATIONS_ALLOWED_HOSTS` steht oder der Host einer Serveradresse ist, die unter
**Admin → Integrationen** festgelegt wurde.
Die letzte Regel ist die, der Selbsthostende begegnen. Ein Nextcloud in deinem LAN unter
`192.168.1.10` oder `nextcloud.lan` wird abgelehnt, bis du es erlaubst, und ein Nextcloud ohne
+150
View File
@@ -0,0 +1,150 @@
<!-- translated-from: providers/carddav.md sha1:bc3ce68500077b393156115361373684803ffdbc -->
# CardDAV
CardDAV ist das Standardprotokoll für Adressbücher auf einem Server, und plMail spricht es mit jedem
Server, der RFC 6352 umsetzt: Nextcloud, Radicale, Baïkal, Fastmail, iCloud. Meist ist es derselbe
Server wie bei [CalDAV](caldav.md), mit demselben App-Passwort. Du verbindest ihn in deinen eigenen
Einstellungen; ein Schritt für den Administrator ist nicht nötig.
Die Kontakte eines gespiegelten Adressbuchs kommen zu deinen [Kontakten](../features/contacts.md),
neben denen, die nur in plMail liegen. Dort listest, durchsuchst, bearbeitest, führst zusammen und
löschst du sie wie jeden anderen, und jede Änderung dort wird an den Server zurückgeschrieben.
Außerdem werden sie dir vorgeschlagen, wenn du eine Nachricht adressierst.
## Einen Server verbinden
**Einstellungen → Kontakte → CardDAV-Server verbinden.** Dasselbe Formular wie bei den Kalendern,
mit denselben vier Feldern in derselben Reihenfolge — Serveradresse, Name, Benutzername,
App-Passwort — und derselben bewusst ausgeschalteten Option, das gespeicherte Passwort eines
E-Mail-Kontos wiederzuverwenden. Was jedes Feld erwartet, steht unter
[Einen Server verbinden](caldav.md#einen-server-verbinden) auf der CalDAV-Seite; alles dort gilt
auch hier.
**Nextcloud braucht keine CardDAV-Verbindung.** Eine Nextcloud, die unter **Einstellungen →
Integrationen** für Dateien verbunden ist, steht auch unter Einstellungen → Kontakte, mit einer
eigenen Schaltfläche **Adressbücher wählen**, mit demselben App-Passwort. Statt einer
Schaltfläche zum Trennen steht dort *Unter Integrationen eingerichtet*, weil die Verbindung auch
deine Dateien und Kalender hält; trennst du sie dort, gehen ihre Adressbücher mit, und die
Rückfrage sagt, wie viele. Ihre Adressbücher werden unter `/remote.php/dav` gesucht, wie
[ihre Kalender](caldav.md#nextcloud-keine-zweite-verbindung). Eine CardDAV-Verbindung, die du von
Hand auf eine Nextcloud gerichtet hast, funktioniert weiter, und deren Kontakte-App zeigt unter
**Kontakte → Einstellungen** eine Adresse pro Buch, falls du nur auf ein einzelnes Buch zeigen
willst. Ein Adressbuch, das schon über eine der beiden gespiegelt wird, steht in der Liste der
anderen als *Wird bereits über … gespiegelt* und lässt sich dort nicht ankreuzen, damit seine
Kontakte nicht doppelt erscheinen.
Speichern verbindet sofort und listet dann die Adressbücher, die der Server anbietet, mit
Kästchen zum Anhaken. Angehakte Bücher werden gespiegelt; ihre erste Synchronisierung wird sofort
eingereiht, sodass die Kontakte innerhalb einer Minute erscheinen und nicht erst beim nächsten
Durchlauf.
**Für jeden anderen Server ist es eine eigene Verbindung, getrennt von CalDAV**, auch auf
demselben Server. Manche Server bieten nur eines von beiden, und manche Menschen haben Kontakte und
Kalender an verschiedenen Orten. Ein Radicale oder Baïkal für beides zu verbinden heißt, das
App-Passwort zweimal einzugeben.
## Die Suche
Genau die CalDAV-Suche mit den Namen der Adressbücher: zuerst die eingefügte Adresse, dann
`/.well-known/carddav`, dann das `addressbook-home-set` des Principals, dann wird das Home
aufgelistet. Eine eingefügte Kalender-URL wird nicht für ein Adressbuch gehalten, und die Kalender
desselben Servers tauchen nie in der Liste der Adressbücher auf. Siehe
[Die Suche, und der `.well-known`-Tanz](caldav.md#die-suche-und-der-well-known-tanz).
Es gelten dieselben [abgelehnten Adressen](caldav.md#adressen-die-plmail-ablehnt): Ein Server in
deinem LAN braucht seinen Host in `INTEGRATIONS_ALLOWED_HOSTS`, und reines `http://` braucht
`INTEGRATIONS_ALLOW_HTTP`.
## Synchronisation
**Nur Polling.** CardDAV hat keinen Push, also liest `app:contacts:sync --stale` gespiegelte Bücher
alle fünfzehn Minuten (um :11, :26, :41 und :56, versetzt gegen die Mail- und Kalender-Durchläufe,
die sich den Worker teilen). Eine Änderung vom Handy erscheint hier innerhalb einer Viertelstunde;
unter **Einstellungen → Kontakte** gibt es pro Buch einen Knopf zum sofortigen Synchronisieren.
Änderungen werden gelesen wie bei Kalendern: `sync-collection` (RFC 6578), wo der Server es
anbietet, sonst `getctag` plus eine vollständige Liste, wenn sich das ctag bewegt. Nur eine
vollständige Liste löscht lokale Karten, die sie nicht erwähnt; ein Delta tut das nie.
**Was du hier änderst, wird sofort gespeichert und danach gesendet.** Der Kontakt wird als
ausstehend markiert (die Kontaktliste zeigt eine kleine Uhr an ihm), und ein paar Sekunden später
schickt ihn der Worker über die Warteschlange `export`, zusammen mit der Mail, die plMail verlässt.
Beim Senden wird der Server nicht gelesen: Änderungen von anderswo kommen mit der nächsten
Synchronisierung. Ist der Server nicht erreichbar, bleibt die Änderung ausstehend, und diese
Synchronisierung sendet sie zuerst. Jede Synchronisierung sendet ausstehende Änderungen, *bevor* sie
den Server liest, damit die ältere Kopie des Servers eine Änderung nicht auf dem Weg hinaus
überschreibt.
**Jeder Schreibzugriff ist bedingt.** Ein neuer Kontakt wird mit `If-None-Match: *` unter einem aus
seiner UID abgeleiteten Namen per `PUT` angelegt; eine Änderung oder Löschung trägt `If-Match` mit
dem zuletzt gesehenen ETag. Hat sich die Karte zwischendurch auf dem Server geändert, gewinnt die
Version des Servers: Das Adressbuch wird neu gelesen, und die hier gemachte Änderung wird verworfen,
mit einer Warnung im Log, die den Kontakt nennt. Zwei Versionen einer vCard Feld für Feld
zusammenzuführen ist eine eigene Funktion, und wer sie falsch macht, beschädigt ein Adressbuch.
Eine Karte, die der Server rundweg ablehnt (`403`, meist aus einem Adressbuch, das nicht gesagt hat,
dass es schreibgeschützt ist), wird genauso behandelt: Die Kopie des Servers bleibt, die abgelehnte
Änderung landet im Log, und eine einzelne Karte kann den Rest des Adressbuchs nicht am
Synchronisieren hindern. Jeder andere Fehler — der Server nicht erreichbar, sein Speicher voll oder
das Passwort abgelehnt — lässt die Änderung ausstehend, und sie wird bei einer späteren
Synchronisierung erneut gesendet.
**Was plMail nicht anzeigt, rührt es nicht an.** Ein Kontakt hat hier jedes Feld, das unter
[Kontakte](../features/contacts.md#ein-kontakt) steht, und eine Karte kann mehr tragen, etwa
Eigenschaften, mit denen ein Handy sie an eine App bindet. All das bleibt erhalten. plMail
behält die vCard, die der Server geschickt hat, und wendet eine Änderung auf sie an, wobei nur die
geänderten Eigenschaften angefasst werden. Es baut die Karte nicht aus seinen eigenen Feldern neu.
Eine Adresse oder Nummer, die auf der Karte bleibt, behält ihren Typ (Arbeit, privat), ihre
Gruppierung und die Schreibweise des Servers.
Die Felder werden in jeder Schreibweise gelesen, die gängige Programme schreiben — Apples gruppierte
`X-ABDATE`, `X-ABRELATEDNAMES` und `X-ABLabel`, vCard 4s `ANNIVERSARY` und `RELATED`, `IMPP` und die
ältere `X-JABBER`-Familie, Apples 1604 für einen Geburtstag ohne Jahr —, und eine hier geänderte
Liste wird so geschrieben, wie die meisten sie lesen. Das Foto geht in beide Richtungen: Ein hier
hochgeladenes oder entferntes ersetzt das der Karte, und eines, das hier nicht geändert wurde, bleibt
genau so auf der Karte.
**Einen Kontakt zu löschen löscht seine Karte.** Ein Kontakt, den du hier in den Papierkorb legst,
wird bei der nächsten Synchronisierung auf dem Server gelöscht und bleibt im Papierkorb als einer,
der nur in plMail liegt. Führst du Duplikate zusammen, wird die Karte des behaltenen Kontakts
aktualisiert, und die Karten der zusammengeführten werden gelöscht.
Schreibgeschützte Bücher, etwa ein geteiltes Buch oder das globale Adressbuch eines Servers, werden
gespiegelt und vorgeschlagen, lassen sich hier aber nicht bearbeiten, löschen oder zusammenführen.
Ob ein Buch Schreibzugriffe annimmt, wird beim Server erfragt (`current-user-privilege-set`), nicht
angenommen.
## Vorschläge beim Schreiben
Der Editor und JMAP `Contact/autocomplete` lesen eine gereihte Liste, in dieser Reihenfolge:
1. wie oft die Adresse in deinen E-Mails vorkam;
2. dann, ob sie in deinen Kontakten steht (nur in plMail oder in einem synchronisierten Buch);
3. dann, wie kürzlich sie gesehen wurde, dann der Name.
Eine Adresse, der du oft schreibst, kommt also weiterhin zuerst, und ein Kontakt, dem du nie
geschrieben hast, kommt vor einer Adresse, die du einmal in einen Entwurf getippt hast. Eine
Adresse, die an beiden Stellen steht, wird einmal vorgeschlagen, unter dem Namen aus deinen
Kontakten. Tippst du einen Teil eines Namens, bekommst du jede Adresse dieses Kontakts angeboten;
tippst du einen Teil einer Adresse, nur diese Adresse.
## Fallstricke
**Kalender- und Kontakte-Verbindung sind getrennt.** Eine unter Kalender verbundene Nextcloud
bringt ihre Kontakte nicht mit; verbinde sie unter Kontakte noch einmal.
**Eine hier gemachte Änderung kann an eine anderswo gemachte verloren gehen.** Wurde derselbe
Kontakt zwischen zwei Synchronisierungen auf einem Handy geändert, gewinnt die Version des Handys,
und die Änderung hier wird verworfen (und geloggt). Das Fenster ist die Zeit zwischen dem Speichern
und dem nächsten Lauf des Workers, meist Sekunden.
**Ein Adressbuch abzuwählen oder den Server zu trennen verwirft noch nicht gesendete Änderungen.**
Die Kopien hier verschwinden, mit jedem Foto, das du hier zu ihnen hochgeladen hast; der Server
behält seine eigenen. Zeigt ein Kontakt die Uhr, warte, bis sie verschwindet.
**Ein Server im LAN wird standardmäßig abgelehnt**, wie bei CalDAV. Trag den Host in
`INTEGRATIONS_ALLOWED_HOSTS` ein.
**Einen Kontakt zwischen Adressbüchern zu verschieben wird nicht angeboten.** Auf dem Server ist
das eine Löschung in einer Sammlung und eine Neuanlage in einer anderen; mach es in der Oberfläche
des Servers selbst.
+30 -3
View File
@@ -1,4 +1,4 @@
<!-- translated-from: providers/google.md sha1:ab22097bf3000ba96411f571d10ade5dd0362ae3 -->
<!-- translated-from: providers/google.md sha1:b48c1f67e02a6658bb083369e5cd0a089c282cb2 -->
# Google
@@ -33,6 +33,11 @@ Alles unter **Einstellungen**, was dadurch möglich wird, beschreiben
3. Aktiviere im selben Projekt die **Google Calendar API**, wenn du Kalender willst. Das ist der
Schritt, den der Admin-Bildschirm meint, wenn er sagt, auf der plMail-Seite sei nichts
einzurichten — der Kalenderzugriff wird vollständig in dieser Konsole erworben.
4. Aktiviere außerdem die **Google People API**, wenn du Kontakte willst. Es ist derselbe Schritt für
das Adressbuch: Ohne ihn funktionieren Mail und Kalender, und das Google-Adressbuch jedes
Benutzers unter **Einstellungen → Kontakte** sagt, dass die Administration die People API
aktivieren muss. Sobald sie aktiviert ist, synchronisieren die Kontakte von selbst; niemand muss
sich neu anmelden.
Notier dir gleich deine **project ID** (Projekt-ID). Sie ist kleingeschrieben und kann von dem
Anzeigenamen abweichen, den du eingetippt hast; das Pub/Sub-Topic weiter unten wird mit der ID
@@ -86,12 +91,13 @@ am Zustimmungsbildschirm scheitert.
## Die Scopes, und wofür jeder da ist
plMail fragt bei der Zustimmung diese vier ab, für jedes Gmail-Konto:
plMail fragt bei der Zustimmung diese fünf ab, für jedes Gmail-Konto:
| Scope | Wofür er da ist |
|---|---|
| `https://mail.google.com/` | Das Postfach selbst: Nachrichten lesen, senden, Labels vergeben und entfernen, verschieben und löschen. Das ist voller Postfachzugriff, und genau das ist ein Mail-Client. |
| `https://www.googleapis.com/auth/calendar` | Kalender auf demselben Konto, lesend **und** schreibend. |
| `https://www.googleapis.com/auth/contacts` | Kontakte auf demselben Konto, lesend **und** schreibend, über die People API. |
| `openid` | Identifiziert das Konto, zu dem das Token gehört. |
| `email` | Liefert die Adresse, nach der plMail das Konto benennt. |
@@ -123,6 +129,27 @@ abgelehnt wird, und sagt es in den Kalendereinstellungen — *„Auf Googles Zus
der Kalenderzugriff abwählen, während E-Mail erlaubt wird. Verbinde das Konto neu und lass das
Kalenderhäkchen gesetzt."*
### Kontakte hängen ebenfalls an derselben Anmeldung
Der Kontakte-Scope reist auf derselben Freigabe, aus denselben Gründen. Ein mit plMail verbundenes
Google-Konto bringt seine Kontakte mit: Sein Adressbuch erscheint unter **Kontakte** neben plMails
eigenem, Googles Kontaktgruppen werden zu Schlagwörtern, und eine Änderung in plMail wird
zurückgeschrieben — Namen, phonetische Namen, Organisation, Geburtstag und weitere Daten,
Beziehungen, Websites, Chat- und SIP-Adressen, eigene Felder und das Foto, dazu Anschriften und
Nummern — und von Google auf die Handys, die damit synchronisieren. Gesendet werden nur die Felder,
die plMail zeigt, also bleibt, was Google darüber hinaus hat (Berufe, Interessen, das Kürzel einer
Organisation), wie es ist. Siehe [Kontakte](../features/contacts.md).
Ein Konto, das verbunden wurde, **bevor** plMail nach Kontakten gefragt hat, hält eine Freigabe ohne
den Scope. Daran ist nichts kaputt: Mail und Kalender laufen weiter, und **Einstellungen →
Zustand der Konten** zeigt einen Hinweis, *… erneut verbinden, um seine Kontakte zu synchronisieren*, dessen
Knopf dich noch einmal anmeldet und die Berechtigung ergänzt. Das Adressbuch erscheint, sobald das
erledigt ist.
Google stuft den Kontakte-Scope als **sensibel** ein. Das ist eine leichtere Klasse als die
*eingeschränkte* des Mail-Scopes, mit der eine Installation ohnehin leben muss (siehe unten), und
ändert an der Überprüfung nichts.
### Was die Überprüfung „eingeschränkter Scope“ hier bedeutet
`https://mail.google.com/` gewährt Zugriff auf das gesamte Postfach, und Google behandelt Scopes
@@ -296,7 +323,7 @@ kommen* auftaucht, ohne Kalender daran. Verbinde es neu und lass das Häkchen ge
**Die Calendar API zu aktivieren ist nicht dasselbe wie den Kalender-Scope hinzuzufügen**, und das
eine ohne das andere scheitert auf zwei verschiedene Arten: der Scope ohne die API ist ein 403 bei
jedem Kalenderaufruf, und die API ohne den Scope ist ein Token, dem der Kalenderzugriff nie erteilt
wurde.
wurde. Dasselbe gilt für die People API und den Kontakte-Scope.
**Die Domainbestätigung gilt pro Cloud-Projekt, nicht pro Google-Konto.** Die Domain in der Search
Console zu verifizieren ist nur die erste Hälfte; sie muss zusätzlich unter **Domain verification**
+46 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: providers/microsoft.md sha1:593c30a706038d59877536d7f264562631d8d6fc -->
<!-- translated-from: providers/microsoft.md sha1:3e529992d3beed2d19d98f73d09643bf23bb54af -->
# Microsoft
@@ -66,6 +66,7 @@ stimmt dem Satz als Ganzem zu; anders als bei Google gibt es keine Teilerteilung
| `Mail.Send` | Senden. In Graphs Modell vom Obigen getrennt, muss also getrennt hinzugefügt werden. |
| `MailboxSettings.ReadWrite` | Master-Kategorien, unter `/me/outlook/masterCategories`. Diese sind **nicht** von `Mail.ReadWrite` abgedeckt: Sie liegen unter der Outlook-Ressource für Benutzereinstellungen und liefern ohne diese Berechtigung `ErrorAccessDenied`. ReadWrite statt Read, weil plMail Kategoriedefinitionen anlegt, wenn ein Label gespiegelt wird. |
| `Calendars.ReadWrite` | Kalender auf demselben Konto, lesend und schreibend. |
| `Contacts.ReadWrite` | Outlook-Kontakte auf demselben Konto, lesend und schreibend: Jeder Kontaktordner ist ein Adressbuch. |
Zwei davon lohnen ein genaueres Verständnis, bevor du dich entscheidest, eine wegzulassen.
@@ -93,6 +94,32 @@ Erscheinen für eine Person keine Kalender, sagt plMail unter **Einstellungen
möglichen Ursachen es ist: Die Berechtigung kann in der App-Registrierung fehlen, oder das Konto hat
schlicht keine Kalender.
### Kontakte hängen ebenfalls an derselben Anmeldung
`Contacts.ReadWrite` reist auf derselben Freigabe. Ein mit plMail verbundenes Microsoft-Konto bringt
seine Outlook-Kontakte mit: Jeder Kontaktordner erscheint als Adressbuch unter **Kontakte**,
Outlooks Kategorien werden zu Schlagwörtern, und eine Änderung in plMail wird zurückgeschrieben.
Siehe [Kontakte](../features/contacts.md).
Outlooks Kontakt hat feste Plätze, wo plMail Listen hat: drei E-Mail-Adressen, zwei geschäftliche und
zwei private Nummern und eine Mobilnummer, je eine private, geschäftliche und sonstige Anschrift,
einen Ehepartner (oder Partner), einen Vorgesetzten, eine Assistenz und die Namen der Kinder, eine
Website (die geschäftliche, wenn es eine gibt), drei Chat-Adressen und einen Geburtstag. plMail
füllt diese Plätze, die primären zuerst. Ein Eintrag darüber hinaus — eine vierte E-Mail-Adresse,
ein zweiter Partner — wird nicht gesendet und bleibt nur in plMail, bis sich der Kontakt das nächste
Mal in Outlook ändert; dann kommt Outlooks Fassung zurück. Für weitere Daten (einen Jahrestag), einen
phonetischen zweiten Vornamen und eigene Felder hat Outlook gar keinen Platz; sie bleiben in plMail.
Seine Chat-Adressen tragen keinen Dienst: Eine SIP-Adresse kommt als SIP zurück, jede andere als
„Andere“.
Fotos gehen in beide Richtungen, mit einer Ausnahme: Microsoft Graph kann das Foto eines
Outlook-Kontakts ersetzen, aber nicht entfernen, also bleibt ein in plMail entferntes Foto in
Outlook.
Ein Konto, das verbunden wurde, bevor plMail nach Kontakten gefragt hat, hält ein Token ohne die
Berechtigung. Mail und Kalender laufen weiter, und **Einstellungen → Zustand der Konten** zeigt einen Hinweis,
dessen Knopf dich noch einmal anmeldet und sie ergänzt.
## Der Tenant-Wert
`MICROSOFT_OAUTH_TENANT` — oder das Feld **Tenant** unter **Admin → Integrationen → Mail-Anmeldung** —
@@ -193,10 +220,24 @@ der Value ist brauchbar; er wird einmal angezeigt und lässt sich danach nicht m
ersetzen.
**Eine Berechtigung hinzuzufügen wertet bestehende Tokens nicht auf.** Wer sich verbunden hat, bevor
du `Calendars.ReadWrite` oder `MailboxSettings.ReadWrite` ergänzt hast, hält ein Token, das sie nicht
trägt, und daran ändert kein Abwarten etwas. Das Konto muss getrennt und neu verbunden werden, damit
eine frische Zustimmung ein neues Token ausstellt — `app:graph:diagnose` sagt das ausdrücklich, wenn
es die Lücke findet.
du `Calendars.ReadWrite`, `Contacts.ReadWrite` oder `MailboxSettings.ReadWrite` ergänzt hast, hält
ein Token, das sie nicht trägt, und daran ändert kein Abwarten etwas. Das Konto muss sich neu
anmelden, damit eine frische Zustimmung ein neues Token ausstellt — der Knopf zum erneuten Verbinden
unter **Einstellungen → Zustand der Konten** tut das, ohne das Konto zu entfernen, und `app:graph:diagnose` sagt
es ausdrücklich, wenn es die Lücke findet.
**Eine Organisation, die Apps für ihre Benutzer freigibt, lehnt nach einem Update mit einer neuen
Berechtigung jede Anmeldung ab.** In einem Tenant, in dem Benutzer Apps nicht selbst zustimmen
dürfen, hat ein Administrator plMail einmal freigegeben, für die Berechtigungen, die es damals
verlangt hat. Eine plMail-Version, die eine weitere verlangt (`Contacts.ReadWrite`, seit es die
Kontaktsynchronisierung gibt), wird bei der Anmeldung abgelehnt, auch für Mail, mit `AADSTS65001`
oder `AADSTS90094`, bis der Administrator erneut zustimmt: **Microsoft Entra Admin Center →
Unternehmensanwendungen → plMail → Berechtigungen → Administratorzustimmung erteilen**. Tu das vor
dem Update oder bevor sich jemand neu verbindet. Die Fehlermeldung bei der Anmeldung sagt dem
Benutzer, dass er seinen Administrator fragen soll, **Admin → Integrationen → Microsoft-Mail-Anmeldung**
trägt dieselbe Warnung, und ebenso das Angebot zum erneuten Verbinden unter
**Einstellungen → Kontakte**. Private Microsoft-Konten und Tenants, in denen Benutzer Apps selbst
freigeben, sind nicht betroffen.
**Eine funktionierende Identität bei durchweg scheiternden Mail-Endpunkten ist kein Problem der
Zugangsdaten.** Meistens heißt es, dass für das Microsoft-Konto überhaupt kein Outlook-Postfach
+49
View File
@@ -249,6 +249,55 @@ One caveat plMail states on the page itself: **Outlook sends as the account's pr
regardless of what you choose here.** That is Microsoft's behaviour, changed in your Microsoft
account settings, not here.
### Gmail collecting mail for another account (Gmailify)
Gmail can fetch the mail of another address into your Gmail inbox. When that other address is
connected here as well, every message reaches plMail twice: once through Gmail and once through the
other account's own IMAP sync. plMail files such mail under the other account and merges the two
copies into one, so a message is listed once. The merged message is still the other account's: its
IMAP folder decides whether it is in the Inbox, the bin or another folder, whatever Gmail did with
its own copy. Labels you give it in Gmail show here, and archiving, starring or labelling it here
is also sent to Gmail.
Under each Gmail account on this page, **File mail for my other accounts under them** turns this
on or off. It is on unless you turn it off. Turn it off once Gmail no longer collects that mail,
typically after a domain has moved from Gmail to an IMAP provider of its own. Otherwise Gmail's old
and late copies keep being matched to the other account. With it off:
- The Gmail account keeps only mail addressed to itself. Mail it has for other addresses is not
stored again.
- The other account's own copies stop following Gmail. They lose the labels Gmail put on them,
and a change you make to them is no longer sent to Gmail. The mail itself stays where its IMAP
folder has it. This runs in the background, so on a mailbox that has collected mail for years it
can take a few minutes after the switch is turned off.
- Mail that Gmail filed under the other account, and that the other account's IMAP server never
had, stays as it is and keeps following Gmail. Gmail is the only place that mail exists.
**Best turned off once Gmail has stopped collecting.** While Gmail still collects the other
address's mail, plMail tells that mail apart by its recipients: a message whose To or Cc names the
other address, and not the Gmail address, is left to the other account, even though Gmail stamps
its own address on it as the place it was delivered. A message Bcc'd to the other address names
nobody, so it looks like the Gmail account's own mail and is kept on both accounts. Mailing lists
and newsletters often arrive that way. Stop the collecting in Gmail (Settings → Accounts → Check
mail from other accounts) and those duplicates stop too.
Turning it back on changes nothing that is already stored. Mail that arrives afterwards is matched
again, and so is an older message once Gmail reports a change to it.
**Mail moved by Gmail before v0.3.9.** Earlier versions moved the other account's copy wherever
Gmail moved its own. Mail trashed or archived in Gmail showed in plMail's bin or archive, although
the IMAP server still had it in its Inbox. To put that mail back where its IMAP folder has it, an
administrator runs:
```bash
php bin/console app:backfill gmailify-placement
```
Run it while the workers are quiet. A move you make in plMail updates the labels first and the IMAP
folder a moment later, and a message caught in between would briefly go back to its old folder.
Mail that those earlier versions removed because Gmail had emptied it from its bin is not brought
back by this. The IMAP sync only fetches mail newer than what it already has.
### What each address does by default
Two settings hang off the alias list rather than off the account, because they are properties of the
+29 -4
View File
@@ -302,8 +302,8 @@ appears only when there is something in it, and names how many.
## Integrations
Two things live here: which file services this installation offers, and the OAuth applications users
sign into their mail with.
Three things live here: which file services this installation offers, the server address for its
calendar services, and the OAuth applications users sign into their mail with.
**Mail sign-in** holds the Google and Microsoft client id and secret, the Microsoft tenant, and for
Gmail the Pub/Sub topic and push verification token. Leaving any of them empty falls back to the
@@ -324,6 +324,23 @@ appear.
- **Server address**, for the self-hosted ones. Leave it empty and each user enters their own; set
it and every user is pinned to that server.
**Calendars** lists CalDAV and calendar feeds below the file services, with the same row and the same
**Configure** form. Both are offered to every user whatever is set here, so the row says **Offered
to everyone** and the form has no switch. Nextcloud is not repeated there: it is configured once
among the file services, and its calendars use that address.
- **CalDAV** has the one setting the setup wizard may already have written: **Server address**. Set
it and every CalDAV connection finds its calendars on that server; clear it and each user's own
address counts again. A change applies the next time calendars are found on a connection.
Calendars already mirrored keep the address they were found at, and clearing an address in a
private range also ends its exemption from the private-network rule, so those calendars stop
syncing until the host is in `INTEGRATIONS_ALLOWED_HOSTS`. Once people have connected, the form
says how many connections a change reaches. See [CalDAV](../providers/caldav.md).
- **Calendar feed** has no server to pin, because every feed is its own address, so its row has
nothing to configure. Only an address the setup wizard stored before is shown, with a
**Configure** button to clear it: such an address only lets feeds on its host past the
private-network rule.
**Reuse Gmail credentials** and its Microsoft equivalent copy a client id and secret across
server-side, without ever showing the secret. One Google Cloud project also covers Drive and Photos;
one Entra registration also covers OneDrive. Copying the credential does **not** grant the extra
@@ -581,7 +598,7 @@ on the host itself applies. plMail cannot read that variable, so an empty box is
nothing. Useful after a restart, and useful for finding out what a cold load actually costs on your
hardware before you decide how long to hold it.
### The four features are separate switches
### The five features are separate switches
They have very different costs, and very different consequences when the model is wrong.
@@ -591,6 +608,7 @@ They have very different costs, and very different consequences when the model i
| **Sort mail into tabs** | Fills in a category when no rule recognised the message | On every message that arrives |
| **Help me write** | Offers drafts and rewrites in the composer | Only when you ask |
| **Thread summaries** | Writes a short account of a long conversation | Only when somebody asks for one |
| **Contact help** | Reads a contact out of pasted text, and suggests updates to a contact from the signature of their latest mail | Only when somebody presses **Improve with AI** |
Sorting is the one to be careful with: it runs unattended. It can only ever act as a tie-breaker —
if a header, a Gmail label or the fact that you have written to somebody already decided the
@@ -614,7 +632,7 @@ Two things keep sorting from getting in the way of mail arriving:
Writing help never replaces what you wrote. It appends, so the original is always still there.
Summaries are the most expensive of the four. They use the same writing model, but a whole
Summaries are the most expensive of the five. They use the same writing model, but a whole
conversation is a much larger prompt than a draft: on a 30B model and one GPU, expect around a
minute for the first summary after a quiet spell and around half that once the model is warm. The
GPU is held for the whole of it, so on a shared machine a summary is felt by everybody. Nothing is
@@ -622,6 +640,13 @@ ever summarised on its own — no arriving message, no nightly pass — and a su
written is kept and shown again, so opening the same conversation twice costs nothing the second
time.
Contact help sends text about somebody else: what a person pasted into a new contact, or a saved
contact together with the signature block of the newest mail that contact sent — the part under
`-- ` or under the closing ("Best regards"), never the body of the mail. A mail with neither sends
nothing from that mail. Nothing it suggests is saved until the person ticks it and saves the
contact. Without it, pasting still works: plMail reads the text by rules, and only the button that
asks the model goes away.
### Searching by meaning needs one pass first
Semantic search can only find mail it has indexed, and switching the feature on indexes nothing on
+13 -2
View File
@@ -20,8 +20,19 @@ also see your calendars there, because plMail asks for calendar access on the sa
mailbox access. There is no second sign-in, no second application to register, and no second thing
to configure — the account shows a **Find calendars on…** button and that is the whole of it.
**Connections made for calendars alone.** A **CalDAV server** — Nextcloud, Radicale, Baïkal,
Fastmail, iCloud, a box in a cupboard — or a **calendar address**, which is any published `.ics`.
**Connections made for calendars alone.** A **CalDAV server** — Radicale, Baïkal, Fastmail,
iCloud, a box in a cupboard — or a **calendar address**, which is any published `.ics`.
**Your Nextcloud connection.** If you connected Nextcloud for files under **Settings →
Integrations**, it is listed here too, with its own **Find calendars on…** button. The same app
password reaches your Nextcloud calendars, so there is no address or password to type a second
time. It is marked *Set up in Integrations* instead of offering a disconnect button: the
connection holds your files as well, so it is disconnected there, and that confirmation says how
many calendars go with it.
A calendar already mirrored through another connection — a CalDAV connection you made by hand to
the same Nextcloud, say — is shown as *Already mirrored through …* and cannot be ticked a second
time, so its events are not synced twice.
If the section says *No account or connection here carries calendars yet*, you have IMAP accounts and
nothing else; connect a CalDAV server or subscribe to an address.
+29 -7
View File
@@ -70,12 +70,23 @@ looking at, the view switcher, and a **New event** button. Previous and Next ste
month view, by seven days in the 7-day view, and by a day everywhere else — agenda included, because it
is a rolling list rather than a page.
**On a narrow screen the toolbar tightens rather than scrolling.** Below about 590 pixels the view
switcher shows icons, and the date takes a short form: the month without its year while it is this
year's (**October**), a week as **Oct 20–26**, a day as **Oct 20**. On the narrowest phones **Today**
becomes today's date in a small box.
The full page opens on **7 days**. The docked pane opens on **Agenda** — it is 380 pixels of a shared
row, and a month grid in that width is a lot of empty cells. The pane still offers all four views,
as icons rather than words, because the version that dropped the switcher entirely left the pane
able to show nothing but its agenda.
In month view a cell draws as many of its day's entries as fit and fades the last one where it runs
**In month view an entry is its title first**, on up to two lines, and under it the time it runs
and where: **Quartalsplanung Q4**, then **10:00–11:00 · Raum Elbe**. In a narrower day the end time
and the place go, and the title gets a third line in a smaller size; long words are hyphenated in
the language plMail is set to. An invitation you have not answered yet has a dashed bar, so the
week's open invitations can be seen at a glance.
A cell draws as many of its day's entries as fit and fades the last one where it runs
out, rather than counting them. It used to say **N more**, and the count could not be right: a cell's
height is the window's divided by six and an entry is one line or two depending on whether it has a
time, so the number was always a guess about a box nobody had measured. **The date is a link** — it
@@ -84,12 +95,16 @@ opens that day, which is where the rest of them are.
**Resting the pointer on a day floats that whole day out.** The cell becomes a small panel, wide
enough for the full titles and long enough for every entry. Nothing in it moves as it opens — it
grows right and down from exactly where the list already was — so the entry you were looking at is
still under the pointer.
still under the pointer. On a touch screen, which has no pointer to rest, **the first tap on a day
opens it** and does nothing else; the entries in it open on the next tap.
**Where a cell is too narrow for words it stops using them.** Seven columns of a phone are about
forty pixels each, which is not a width a title or even a time can finish in, so below that an entry
is drawn as a bar in its calendar's colour — how many, and which calendars, at a glance. Tapping the
date opens the day, where they are written out.
**Where a cell is too narrow for words it stops using them, and the day is listed under the
month instead.** Seven columns of a phone are about fifty pixels each, which is not a width a title
or even a time can finish in, so there an entry is drawn as a bar in its calendar's colour — how
many, and which calendars, at a glance — and the grid shrinks to make room for a list of one day
underneath, written out the way the agenda writes it. It starts on today; **tapping any day, bars
included, lists that day instead**. The list has its own **+**, which opens a new event on that day,
and the toolbar's **New event** adds to that day too.
The agenda groups its entries under a heading per day, which stays at the top of the list while that
day's events are on screen. The start and end time — or **All day** — sit in a lane of their own down
@@ -127,6 +142,10 @@ Month and Agenda have no time axis in either shape. A month cell is a couple of
and has no room to say where in the day anything is; an agenda's whole value is that it skips the
empty time, which is exactly the axis a grid would draw.
**The red current-time line moves with the clock**, once a minute, and when the date changes the
view draws itself again — the line, the tint on today and the circle on today's date in the month
all move to the new day — so a calendar left open beside the mail stays true.
**The grid is drawn on your clock.** The hours down the side, where the red current-time line sits,
and the time a new event is proposed at when you double-click empty space all follow the timezone in
**Settings → General**. That is the same zone every other timestamp in the app is read against.
@@ -162,7 +181,10 @@ toolbar and is a single full-width column.
**New event** in the toolbar, the **+** on a day heading, or clicking an existing event, all open the
same editor. It is a modal over whatever page you were on — the calendar page, or a mail view with
the calendar docked beside it — and saving puts you back there rather than dragging you to the
calendar.
calendar. On a phone it is the whole screen, as the compose window is.
A **+** on a day opens the editor on that day at the next full hour. A day that has already gone keeps
its date: a meeting written down after it happened lands on the day it happened, not on today.
The fields are **Title**, **Starts**, **Ends**, **All day**, **Calendars**, **Repeat**, **Location**,
**Description** and **Reminders**, with **Apply to** appearing when the event repeats. An event
+192
View File
@@ -0,0 +1,192 @@
# Contacts
plMail keeps an address book: the people you write to, with their addresses, phone numbers, postal
addresses, birthdays, a photo and notes. It is one list, wherever a contact lives. Some are plMail's own and
exist only here. Others come from an address book on a server, and changes go both ways:
- a **Google** or **Microsoft** mail account brings its contacts with it, on the same sign-in as its
mail and calendars — nothing to connect (see [Google](../providers/google.md) and
[Microsoft](../providers/microsoft.md));
- your [Nextcloud](../providers/carddav.md#connecting-a-server), or any other CardDAV server, is
connected under **Settings → Contacts**.
An account connected before plMail synced contacts needs one more sign-in to allow them: **Settings
→ Contacts** and **Settings → Account health** both offer it.
Open it with the address-book icon beside the calendar button. Like the calendar button it is a
three-way switch: the address book beside the mail, then full width with your mail behind it, then
closed again. **Settings → Contacts → Behaviour** can turn it into a two-way one (**Full width
only** or **Beside mail only**), as for the calendar. On a phone, **Contacts** in the menu opens the
full page.
The docked address book is the whole of it: search, open, edit, add and merge contacts there while
your mail stays where it was. Drag its edge to make it wider or narrower (or use the arrow keys on
the edge; a double-click puts it back). Drag it past its widest and let go, and it goes full width;
past its narrowest, and it closes. plMail remembers the width and the position. The arrows in its
header make it full width, and the × closes it. Opening a message from a contact at full width
brings the mail back beside it.
On a wide monitor (1920 pixels across or more) the address book and the calendar can be docked side
by side. On a smaller screen, such as a laptop's, there is room for one of them beside the mail, so
opening one puts the other away. Below 1024 pixels the icon opens the full page.
## The list
Every contact as a card: photo or initials, name, job and company, and the main email address.
Search finds a contact by any of its names, its company or any of its addresses. Tags (you set them
on a contact) appear as chips above the list and narrow it to one tag.
Once you have a synced address book, a second row of chips chooses where to look: **All books**,
**Not synced** (plMail's own contacts), or one address book. A synced contact carries a small
cloud, or a lock if its address book is read-only. A clock means an edit made here is waiting to be
sent to the server.
## A contact
The contact's page shows its details, its notes, related contacts, and **Reference emails**: every
message to or from the contact that has not been deleted, in any folder. Clicking one opens it in
the mail beside the contact, which stays as it was; on the full page the mail slides in from the
left.
**New contact** opens **quick add**: one box to type or paste anything about the person into — a
name, an email signature, a line like `Mia Hoffmann <mia@example.net>`, an address, or a whole
vCard. It is read as you type, and what it finds appears below as rows: each can be unticked,
retyped, or given another type with one click (a work number that is a home number, a line taken
for the job title that is really the company). A line that could not be placed is asked about
rather than guessed, a greeting above a signature is left out, and an address that is already on
one of your contacts is pointed out. A vCard is read field by field and nothing is guessed. **All
fields** opens the full editor with everything found already filled in.
The **editor** shows the name as one field; its parts (prefix, first, middle and last name,
suffix) fill themselves as you type and open under **Edit parts**. Once you edit a part by hand it
stays, and the page says when the name and its parts no longer match. Email and phone end in a
dashed row — type into it and it becomes a row, typed as home or work from the address, or as a
mobile from the number, until you pick a type yourself. The star marks the preferred one. Every
other kind of detail shows only when the contact has one; the chips under **Add** bring the rest.
Once something has changed, a bar at the bottom says how much and offers **Save** and **Discard**.
**Paste details** in the editor reads pasted text the same way and compares it with the contact
before anything changes: what is already there is greyed out, what is new is ticked, and what
differs from what is saved ("Replaces …", or a different type for the same number) is left for you
to tick. **Apply** puts the ticked details into the form and highlights them; nothing is saved
until you save.
When your administrator has switched on **Contact help** (Admin → AI), both pages have **Improve
with AI**. On quick add it gives the text a second reading — the company the rules could not tell
from a slogan, a fax number written as `Fax -99` — and marks what it changed, with **Undo AI
changes** to go back. In the editor it sends the contact and the signature of the newest mail the
contact sent you, and shows its suggestions, each with its reason, in the same list as pasted
details. You can switch it off for yourself under Settings → Assistant.
A contact holds:
- its name in parts (prefix, first, middle and last name, suffix), a nickname, and how the names are
spoken (**Phonetic spelling**, for names a phone should sort or read out differently);
- company, department, job title and office;
- email addresses, phone numbers and postal addresses, each labelled and one of each marked primary;
- a birthday and other dates (an anniversary, say) — the year can be left out;
- **People**: the names of a spouse, children, a manager, an assistant and so on;
- websites, chat addresses (SIP, XMPP, Skype, Teams, Matrix and others), and **custom fields** of
your own naming;
- notes, tags and a photo.
A label that came from the server and is not one of plMail's own (a "Godmother" typed on a phone)
is shown as it is and kept when you save.
If you have a synced address book you can choose where a new contact goes under **Save to**: one
button per address book when you have up to four, a menu when you have more. **plMail** keeps it in
plMail only. Otherwise it goes to that address book's server and from there to every device that
syncs it; the line under the buttons says which. A read-only address book is never offered.
Editing works the same for both kinds. For a synced contact, the edit is saved here at once and sent
to the server straight after, and from there to your phone. Anything plMail has no field for, such
as the properties a phone uses to link a contact to an app, stays on the server's copy untouched.
Google and Outlook have no place for some fields; see **Things that bite**. See [CardDAV](../providers/carddav.md#sync) for how
edits and conflicts are handled.
A contact from a **read-only** address book (a shared book, a company directory) can be read and
written to, but not changed or deleted here. Its page says where it comes from.
**Photos** are JPG, PNG or WebP up to 5 MB, and kept at up to 512 pixels on the longer side. They go
both ways: a photo on the server is shown here, and a photo uploaded or removed here is sent to the
server. In the editor, the camera on the photo uploads one and **Remove photo** at the bottom removes
it.
## Deleting
**Delete** moves a contact to the bin. Deleting a synced contact also deletes its card on the
server, and the contact stays in the bin as one of plMail's own.
## Duplicates
**Duplicates** lists pairs that look like the same person, with how sure the match is and why
(the same email address, the same phone number, a similar name). Pick the one to keep and
**Merge**. The other one's addresses, numbers and tags move to the kept contact, and its notes are
added below the kept contact's. A merge can be undone for 90 days.
Synced contacts merge like any other. The kept one's card is updated, and the merged one's card is
deleted from its server, so an undo brings the merged contact back as one of plMail's own. Contacts
from a read-only address book cannot be merged.
## Import and export
**Import** takes a `.vcf` (vCard, as every phone and address book exports) or a `.csv` file. You see
a preview first. Imported contacts are plMail's own, and you land on the duplicates page afterwards
to merge anything you already had.
**Export** downloads every contact as one `.vcf` file, photos included. A synced contact is exported
as its server's card with your edits in it, so the file keeps what plMail has no field for. The CSV
export is a summary for a spreadsheet — one email, one phone and one website per contact — and reads
back in; use vCard to move contacts whole.
## While reading and writing mail
A message's sender, and every address in its details (To, Cc, Reply-To), shows whether it is already
in your contacts, with a button to add it if not. The button asks who it is:
- **New contact**: check the name taken from the mail and save. *More details* opens the full
editor with the name and address filled in.
- **Add to an existing contact**: search your contacts as you type and pick one. The address is
added to that contact, so somebody writing from a second address stays one person. The search
starts with the sender's name. Contacts in read-only address books are not offered.
Once saved, the contact opens in the contacts pane beside the mail. If the pane was closed it opens,
and if it was open it goes to that contact. *More details* opens the editor there too. On a phone
there is no pane: *More details* opens the editor as a page, and after a save the button turns into
a link to the contact.
When you address a message, your contacts are suggested along with the addresses plMail has
learned from your mail. The order is explained under
[suggestions while writing](../providers/carddav.md#suggestions-while-writing).
## On a phone
The plMail apps read and edit the same contacts over JMAP (`Contact/get`, `Contact/query`,
`Contact/set`). Each contact says which address book it is in and whether it can be changed. An app
cannot change a contact from a read-only book, and its edits to a synced contact go on to the
server like edits made on the web.
## Things that bite
**The bin has no page yet.** A deleted contact is kept, but nothing on the web brings it back. Merge
instead of deleting when you are tidying up duplicates: a merge can be undone.
**An import makes plMail's own contacts.** To put a `.vcf` file into a synced address book, import
it on the server (in Nextcloud, under Contacts), and the contacts arrive here with the next sync.
**A contact cannot move between address books**, or from plMail's own into a synced one. Create it
in the other place and delete the old one.
**Google and Outlook cannot hold everything.** Google keeps every field above. Outlook has fixed
places: three email addresses, two business and two home numbers and a mobile, one address of each
kind, one spouse, manager and assistant and any number of children, one website, three chat
addresses and a birthday. It has no other dates, no phonetic middle name and no custom fields. What
does not fit is not sent and stays only in plMail until the contact next changes in Outlook (see
[Microsoft](../providers/microsoft.md#contacts-are-on-the-same-sign-in-too)).
**Outlook keeps a photo removed here.** Microsoft's interface can replace an Outlook contact's photo
but not remove it, so a photo removed in plMail comes back the next time the contact changes in
Outlook. Remove it in Outlook.
**Custom fields and the office are plMail's own on a CardDAV server.** vCard has no property for
either, so they are stored on the card under plMail's own names (`X-PLMAIL-FIELD`,
`X-PLMAIL-OFFICE`). They survive the round trip, but a phone does not show them.
+71 -4
View File
@@ -299,6 +299,12 @@ card. Dated ones queue
up in the calendar's **Happening Soon** panel, under **On your radar**; a conversation that yielded
something also shows its cards in a **Found in this conversation** strip above the messages.
Mail in Spam yields no cards, and neither does mail you move there later: its cards leave the panel
and the strip above the mail list, and a spam conversation shows none above its messages, because a
fake delivery notice is exactly what spam imitates. **Not spam** (or moving it to the inbox) brings
them back. The bin is different: deleting a shipping confirmation does not stop the parcel, so its
cards stay.
A shop that never names a carrier still yields a parcel. Amazon states an order number and a link
into its own tracker and no tracking number anywhere, so that is what the card carries — and a
delivery day promised in words rather than digits ("arriving today", "Arriving Monday") is resolved
@@ -397,8 +403,10 @@ provider recorded an `Authentication-Results` header. Both it and **Print** open
HTML bodies are rendered inline rather than in an iframe. Before that happens the body is put
through a sanitiser once, at ingest: `cid:` references are rewritten to plMail's own attachment
route, `<style>` blocks are flattened onto the elements they applied to, and scripts, forms,
iframes and classes are dropped. Links are forced to open away from the app.
route, `<style>` blocks are flattened onto the elements they applied to, and scripts, iframes and
classes are dropped. A form is taken apart rather than dropped: what is in it stays, its text
fields and menus go, and the mail looks the way its sender meant it to in a mail program without
forms. Links are forced to open away from the app.
### Summarising a long conversation
@@ -547,6 +555,9 @@ whose body only ever says `help.wirhub.de`.
`spam`), `archive` (or `archived`) and `snoozed`. Quoted values are kept together, so
`from:"Ada Lovelace"` works.
Search leaves out Spam and the bin unless you ask for them with `in:spam` or `in:trash`, the same
as the lists do. The suggestions as you type leave both out.
Suggestions appear as you type, and so do **actual results** — the ten most recent matching
conversations, under the operator suggestions, from about the third character. They are a preview
rather than the answer: they run only the passes that are fast enough to spend on every keystroke,
@@ -646,6 +657,34 @@ On a phone the same sync is a pull: drag the list down from its top, past the in
the indicator spins with it. Without touch, the sync button is the equivalent: it is a named button
for a keyboard or a screen reader, and the sync toast says when the run starts and when it ends.
On a phone a row swipes too. Drag it to the right and it does what the right direction is set to; by
default that marks it read, or unread if it already is. Drag it to the left and, by default, it is
archived. What each direction does is set under **Settings → Mail → Swipe actions**, below. Past a third of
the row's width the action waits behind the row, and pressing it does the thing. Past two thirds, or a
quick flick past the first third, it happens as you let go. Touching anything else closes an open row,
and that touch does nothing more. Read / unread and archive are offered only on ordinary mail: never on
a draft, and never in the archive, the bin or spam. Star is offered on every row, drafts included. Without touch, the row's checkbox (its avatar) and
the toolbar do the same work, **Archive** and **More** on a selection included.
Each direction has its own setting under **Settings → Mail → Swipe actions**, a select for right and one
for left. The choices are read / unread, archive, star, and nothing. Both directions may be set to the
same choice. Trash is not one of them (see *Things that bite*). A star is a toggle on the row, so a
second swipe takes it off again, as it does for read / unread; archive offers its **Undo**.
On a phone, pressing and holding a row also opens its actions. Hold a row still for about half a second
and an action sheet rises from the bottom of the screen with every action the row offers: select it,
mark it read or unread, archive it, star it, move it (a conversation, not a single message), label it,
snooze it, mark it as spam, delete it,
and, in the bin or in spam, move it back to the inbox and, if you have switched on **Settings → Mail →
Disposal → Delete-now buttons**, delete it for good. The entries are the ones the row's own buttons
offer, in the same places, and each does what that button does. **Move to**, **Label as** and **Snooze**
open their pickers over the sheet, as they do on the toolbar. The sheet closes on a tap on the dimmed
page, on Escape, or after an action, and the page behind it does not respond while it is open. A
background refresh leaves it alone, and a refresh that takes the row out of the list closes it. Where
the browser can vibrate (Android Chrome), opening the sheet gives a short buzz; iOS has no vibration for
web pages, so there is none there. Reduced motion makes the sheet appear without its slide. Without
touch, the row's own buttons and the toolbar do the same work.
### Keyboard shortcuts
The single keys Gmail uses work here too. Press **?** anywhere for the full list, or the
@@ -704,6 +743,9 @@ conversation back and takes away a filter the same click created.
Above a selection the button moves everything selected to Spam and offers no filter: a selection
has no one sender. The button is not shown for mail that is already in Spam or in the bin.
An open conversation in Spam has a **Not spam** button in its toolbar instead. It moves the
conversation back to the inbox, the same as **Move to → Inbox**.
Moving mail to spam tells plMail nothing about the next message. plMail has no spam filter of its
own that learns; whether your provider treats the move as a signal is up to the provider. A filter
is how you make it stick here.
@@ -799,6 +841,10 @@ says how many it held back, with two ways to show them — **Show images** for t
sender's messages say so under the body, with **Stop showing** to take it back. Mail you sent
yourself shows its pictures without asking.
Shown pictures always come through plMail, and only over an encrypted connection: a picture the
mail links as `http://` is fetched as `https://`. The rare host that cannot answer that way leaves
an empty box.
**Settings → Mail → Images**, **Always show remote images**, turns the question off for every
sender. It is off until you turn it on. Senders can then tell that you opened their mail, but not
your address: the pictures are fetched by plMail and passed on to you, never by your browser. The
@@ -1173,10 +1219,31 @@ included.
**Pull to refresh is for a finger at the top of the list, and only there.** A mouse, a trackpad and
any window at the tablet breakpoint or wider never pull. A pull starts only while the list is
scrolled to its very top, and not while the sidebar is open: from further down, the same drag just
scrolls. Reduced motion removes the indicator's slide and nothing else, so the release point is the
scrolled to its very top, and not while the sidebar, an open row or the action sheet is up: from further
down, the same drag just scrolls. Reduced motion removes the indicator's slide and nothing else, so the release point is the
same.
**A swipe is a finger's gesture on a phone, and its directions are physical.** A mouse never swipes,
at any width, and nothing swipes at the tablet breakpoint or wider. Right means towards the right
edge of the screen whatever the language, because no right-to-left locale ships. A reader who adds
one has to decide how the two sides mirror.
**A refresh leaves a row you are swiping, or have open, where it is.** Such a row keeps its state while
a sync or an update redraws the list, and the change to that row arrives when you close it. A row that
is only pressed, or that a long press has opened into the action sheet, is redrawn as usual. A refresh
that removes the row takes it off the list, and closes the sheet with it.
**Trash is not a swipe.** A full drag has no tap in front of it, and a trashed row has no undo of its
own, so deleting stays with the row's buttons and the bin, where a tap comes first. Trash is not one of
the **Settings → Mail → Swipe actions** choices either: a value stored for it reads as that direction's
default, so a restored backup cannot bring it back.
**A long press opens the sheet, and a phone's rows are not dragged.** Below the tablet breakpoint a
thread row is not a native drag: HTML5 drag never worked from a finger, and turning it off keeps iOS
from lifting a row during the half-second hold. A mouse still drags a row as before. The sheet offers
trash on a row in the inbox, where the row's own trash button does, and it has no Undo, as that button
has none.
**A signature image has to be small to travel with the mail.** A picture in a *signature* is stored
inside it and sent as an inline part, up to 256 KB each and 768 KB per signature; see
[Signature](#signature). Anything larger, an SVG, or an image that could not be fetched stays a
+1
View File
@@ -103,6 +103,7 @@ one container consuming `scheduler_default`. **Without that container, none of t
| every minute | `app:calendar:alerts` |
| `*/15` | `app:mail:sync` |
| `7-59/15` | `app:calendar:sync --stale` |
| `11-59/15` | `app:contacts:sync --stale` |
| hourly at :20 | `app:calendar:push` |
| daily 03:50 | `app:calendar:materialise` |
| daily 04:00 | `app:push:renew --repair` |
+1
View File
@@ -212,6 +212,7 @@ in before, with logs and orphaned blobs growing without bound.
|---|---|---|
| `*/15 * * * *` | `app:mail:sync` | Neither Gmail push nor Graph subscriptions guarantee delivery and IDLE connections drop; polling is the backstop |
| `7-59/15 * * * *` | `app:calendar:sync --stale` | The mechanism for CalDAV and ICS feeds, the backstop for Google and Graph. Offset off the quarter hour so it does not stack on the mail sweep — they share one worker |
| `11-59/15 * * * *` | `app:contacts:sync --stale` | CardDAV address books have no push at all, so this is the only way changes made on a phone arrive. Offset off both sweeps above for the same shared-worker reason |
| `20 * * * *` | `app:calendar:push` | Hourly, not because anything expires that fast, but because registration fails for *deployment* reasons that have nothing to do with the click that connected the calendar |
| `* * * * *` | `app:mail:wake-snoozed` | A minute is the unit people pick a wake time in |
| `* * * * *` | `app:calendar:alerts` | Same argument, plus the interval is the bound on how late a reminder can be |
+22
View File
@@ -461,6 +461,28 @@ carrying it would look like it is in two places at once to anything counting loc
deliberate trade: a thread that returns in the state you left it in is one you have already
learned to scroll past.
### "In the bin" is stored, and the database keeps it
A conversation is in the bin when it carries Trash and none of its messages is outside Trash
(#268). Every list, every badge and search leave such a conversation out. They read
`message_thread.binned` instead of working the rule out from the messages; on a mailbox of 67k
conversations, working it out took about 170 ms per query.
Triggers maintain the column, not PHP (`Version20261022160000`). The writes that change the
answer are spread out: labels through the ORM, raw SQL that rebuilds threads or unlinks a label,
`ON DELETE CASCADE`, a message moving to another thread, a reply arriving in a conversation in
the bin. A flag set from PHP would be right only on the paths someone remembered.
- `message_label` and `thread_label` fire once per statement and recompute only the threads
whose Trash rows changed. Filing, reading and labelling mail cost nothing.
- `message` fires per row: on insert only when the thread is in the bin (a new message can only
take it out), on delete and on a change of `thread_id` for the thread the message left.
- Each recompute derives the answer from scratch (`plmail_refresh_thread_binned()`), so the
order of the statements in a flush does not matter.
The entity maps the column read-only. In memory it can lag behind a change that has not been
flushed, so nothing in PHP should branch on it; the queries read it.
## The change log JMAP reads
Every mail mutation writes a row to `jmap_change_log` (`App\Jmap\State\ChangeLog`). The
+38 -3
View File
@@ -2,8 +2,8 @@
CalDAV is the standard protocol for calendars on a server, and plMail speaks it to any server that
implements RFC 4791: Nextcloud, Radicale, Baïkal, Fastmail, iCloud, a Synology box in a cupboard.
There is nothing to register with anybody and no administrator step — a user connects a server from
their own settings with an address, a username and a password.
There is nothing to register with anybody and nothing an administrator has to switch on — a user
connects a server from their own settings with an address, a username and a password.
**Gmail and Outlook calendars do not come in this way.** They arrive with the mail account, on the
grant it already holds, and need only the calendar permission ticked on the app registration — see
@@ -13,6 +13,40 @@ pointing it at Google's CalDAV endpoint is the first thing somebody tries.
What happens after a server is connected — ticking which calendars to mirror, two-way sync,
read-only calendars — is in [Connected calendars](../features/calendar-sync.md).
## Nextcloud: no second connection
A Nextcloud connected for files under **Settings → Integrations** also gives you its calendars.
Nextcloud serves CalDAV under the same `/remote.php/dav` with the same app password, so that
connection is listed in **Settings → Calendars** and its calendars are found from there. Nothing
on this page needs doing for it.
- **The address** is the one the Nextcloud connection uses, so an address an administrator pinned
in **Admin → Integrations** applies to calendars as well, including its exemption from the
private-network rule below.
- **Discovery** starts at `/remote.php/dav` under that address, subfolder installs included, and
only falls back to `.well-known` when nothing answers there.
- **Disconnecting** Nextcloud in Settings → Integrations removes its calendars too. As with any
calendar connection, events that came from your mail rather than from the server are moved to
your default calendar first.
- **A separate CalDAV connection** you made earlier to the same Nextcloud keeps working. Its
calendars show as *Already mirrored through …* on the Nextcloud connection's list, so the same
calendar is never mirrored twice.
The generic CalDAV connection below stays for every other server.
## One server for everybody
An administrator can pin one CalDAV server in **Admin → Integrations**, under **Calendars**, and the
setup wizard asks the same question. With an address pinned, every CalDAV connection finds its
calendars on that server rather than at the address its user typed, and that host is exempt from the
private-network rule [below](#addresses-plmail-will-refuse).
The address is changed or cleared in the same place, and the change applies the next time calendars
are found on a connection. Calendars already mirrored keep the address they were found at, so after
a server moves, find its calendars again. Clearing a pinned address in a private range takes its
exemption with it: calendars mirrored from that host then stop syncing with *"Server address must not
point at a private network"* until the host is listed in `INTEGRATIONS_ALLOWED_HOSTS`.
## Connecting a server
**Settings → Calendars → Connect a CalDAV server.** Four fields, in the order the questions actually
@@ -95,7 +129,8 @@ as every other user-supplied server address:
- A username or password embedded in the URL is refused outright — it would be logged wherever the
URL is, and would silently override the credentials on the connection.
- Anything resolving into loopback, link-local or a private range is refused unless the host is
listed in `INTEGRATIONS_ALLOWED_HOSTS`.
listed in `INTEGRATIONS_ALLOWED_HOSTS`, or is the host of a server address an administrator
pinned in **Admin → Integrations**.
That last rule is the one a self-hoster meets. A Nextcloud on your LAN at `192.168.1.10` or
`nextcloud.lan` is refused until you allow it, and a Nextcloud with no certificate needs the http
+130
View File
@@ -0,0 +1,130 @@
# CardDAV
CardDAV is the standard protocol for address books on a server, and plMail speaks it to any server
that implements RFC 6352: Nextcloud, Radicale, Baïkal, Fastmail, iCloud. It is usually the same
server as [CalDAV](caldav.md), on the same app password. A user connects one from their own
settings; there is no administrator step.
A mirrored address book's contacts join your [contacts](../features/contacts.md), alongside
plMail's own. They are listed, searched, edited, merged and deleted there like any other, and every
change made there is written back to the server. They are also suggested when you address a
message.
## Connecting a server
**Settings → Contacts → Connect a CardDAV server.** The same form as for calendars, with the same
four fields in the same order — server address, name, username, app password — and the same
deliberately-off option to reuse a mail account's stored password. See
[Connecting a server](caldav.md#connecting-a-server) on the CalDAV page for what each one wants;
everything there applies here.
**Nextcloud needs no CardDAV connection.** A Nextcloud connected for files under **Settings →
Integrations** is listed in Settings → Contacts too, with its own **Choose address books** button,
on the same app password. It is marked *Set up in Integrations* rather than offering a disconnect
button, because the connection also holds your files and calendars; disconnecting it there removes
its address books along with them, and the confirmation says how many. Its address books are
looked up under `/remote.php/dav`, as for [its calendars](caldav.md#nextcloud-no-second-connection).
A CardDAV connection pointed at a Nextcloud by hand keeps working, and its contacts app shows a
per-book address under **Contacts → Settings** if you want to point at one book only. An address
book already mirrored through one of the two is shown as *Already mirrored through …* on the
other's list and cannot be ticked there, so its contacts are not listed twice.
Saving connects immediately and then lists the address books the server offers, with tick boxes.
Ticked books are mirrored; their first sync is queued at once, so the contacts appear within a
minute rather than at the next sweep.
**For any other server it is a separate connection from CalDAV**, even on the same server. Some
servers carry only one of the two, and a person may keep their contacts and their calendars in
different places. Connecting a Radicale or a Baïkal for both means entering the app password twice.
## Discovery
Exactly the CalDAV discovery with the address-book names: the pasted address first, then
`/.well-known/carddav`, then the principal's `addressbook-home-set`, then the home is listed. A
pasted calendar URL is not taken for an address book, and the same server's calendars are never
offered in the address-book list. See
[Discovery, and the `.well-known` dance](caldav.md#discovery-and-the-well-known-dance).
The same [refused addresses](caldav.md#addresses-plmail-will-refuse) apply: a server on your LAN
needs its host in `INTEGRATIONS_ALLOWED_HOSTS`, and plain `http://` needs `INTEGRATIONS_ALLOW_HTTP`.
## Sync
**Polling only.** CardDAV has no push, so mirrored books are read by `app:contacts:sync --stale`
every fifteen minutes (at :11, :26, :41 and :56, offset from the mail and calendar sweeps that share
the worker). A change made on a phone appears here within a quarter of an hour; **Settings →
Contacts** has a sync-now button per book.
Changes are read the way calendars are: `sync-collection` (RFC 6578) where the server advertises it,
otherwise `getctag` plus a full listing when the ctag moves. Only a full listing deletes local cards
it did not mention; a delta never does.
**Edits made here are saved at once and sent afterwards.** The contact is marked pending (the
Contacts list shows a small clock on it), and a couple of seconds later the worker sends it on the
`export` queue, with the mail leaving plMail. Sending does not read the server: changes made
elsewhere arrive with the next sync. If the server can't be reached, the edit stays pending and
that sync sends it first. Each sync sends pending edits *before* reading the server, so the
server's older copy never overwrites an edit on its way out.
**Every write is conditional.** A new contact is `PUT` with `If-None-Match: *` under a name derived
from its UID; an edit or a delete carries `If-Match` with the etag last seen. If the card changed on
the server in the meantime, the server's version wins: the address book is read again and the edit
made here is dropped, with a warning in the log naming the contact. Merging two versions of a vCard
field by field is a feature of its own, and getting it wrong corrupts an address book.
A card the server refuses outright (`403`, usually from a book that did not say it was read-only) is
handled the same way: the server's copy is kept and the refused edit is logged, so one card cannot
stop the rest of the book from syncing. Any other failure — the server unreachable, out of space, or
rejecting the password — keeps the edit pending, and it is sent again on a later sync.
**What plMail does not show, it does not touch.** A contact here has every field listed under
[Contacts](../features/contacts.md#a-contact), and a card can carry more, such as the properties a
phone uses to link a contact to an app. All of it is kept. plMail keeps the vCard the server sent
and applies an edit to it, changing only the properties that were edited. It does not rebuild the
card from its own fields. An address or a number that is still on the card keeps its type (work,
home), its grouping and the server's own formatting.
The fields are read in every spelling the common clients write — Apple's grouped `X-ABDATE`,
`X-ABRELATEDNAMES` and `X-ABLabel`, vCard 4's `ANNIVERSARY` and `RELATED`, `IMPP` and the older
`X-JABBER` family, Apple's 1604 for a birthday without a year — and a list changed here is written
the way most of them read it. The photo goes both ways: one uploaded or removed here replaces the
card's, and one that was not changed here stays on the card exactly as it was.
**Deleting a contact deletes its card.** A contact moved to the bin here is deleted on the server at
the next sync, and stays in the bin as one of plMail's own. Merging
duplicates updates the kept contact's card and deletes the merged ones' cards.
Read-only books, such as a shared book or a server's global address list, are mirrored and
suggested, and cannot be edited, deleted or merged here. Whether a book accepts writes is asked of
the server (`current-user-privilege-set`), not assumed.
## Suggestions while writing
The composer and JMAP `Contact/autocomplete` read one ranked list, in this order:
1. how often the address has been seen in your mail;
2. then whether it is in your contacts (plMail's own or a synced book);
3. then how recently it was seen, then the name.
So an address you write to often still comes first, and a contact you have never mailed comes before
an address you once typed into a draft. An address in both places is suggested once, under the name
from your contacts. Typing part of a name offers every address of that contact; typing part of an
address offers only that address.
## Things that bite
**The calendar and contacts connections are separate.** Connecting Nextcloud under Calendars does
not bring its contacts in; connect it again under Contacts.
**An edit made here can be lost to an edit made elsewhere.** If the same contact was changed on a
phone between two syncs, the phone's version wins and the change made here is dropped (and logged).
The window is the time between saving and the worker's next run, usually seconds.
**Unticking an address book, or disconnecting the server, discards edits not yet sent.** The copies
here go, along with any photo uploaded to them here; the server keeps its own. If a contact shows
the pending clock, wait for it to clear first.
**A LAN server is refused by default**, as for CalDAV. Add the host to `INTEGRATIONS_ALLOWED_HOSTS`.
**Moving a contact between address books is not offered.** On the server it is a delete in one
collection and a create in another; do it in the server's own interface.
+27 -2
View File
@@ -31,6 +31,10 @@ Everything under **Settings** that this enables is described in
3. Enable the **Google Calendar API** on the same project if you want calendars. This is the step
the admin screen means when it says there is nothing to set up on the plMail side — calendar
access is bought entirely in this console.
4. Enable the **Google People API** too if you want contacts. It is the same kind of step for the
address book: without it, mail and calendars work, and each user's Google address book in
**Settings → Contacts** says that the administrator needs to enable the People API. Once it is
enabled, contacts sync on their own; nobody has to sign in again.
Note your **project ID** while you are here. It is lower-case and may differ from the display name
you typed; the Pub/Sub topic below is named with the ID, not the name.
@@ -79,12 +83,13 @@ it, because that combination looks configured and fails at the consent screen.
## The scopes, and what each one is for
plMail asks for these four at consent time, for every Gmail account:
plMail asks for these five at consent time, for every Gmail account:
| Scope | What it is for |
|---|---|
| `https://mail.google.com/` | The mailbox itself: reading messages, sending, applying and removing labels, moving and deleting. This is full mailbox access, and it is what a mail client is. |
| `https://www.googleapis.com/auth/calendar` | Calendars on the same account, read **and** write. |
| `https://www.googleapis.com/auth/contacts` | Contacts on the same account, read **and** write, through the People API. |
| `openid` | Identifies the account the token belongs to. |
| `email` | Supplies the address, which is what plMail names the account after. |
@@ -114,6 +119,25 @@ finds out when a calendar call is refused, and says so in the calendar settings
screen lets you untick calendar access while allowing mail. Reconnect the account and leave the
calendar box ticked."*
### Contacts are on the same sign-in too
The contacts scope rides the same grant, for the same reasons. A Google account connected to plMail
brings its contacts with it: its address book appears under **Contacts** beside plMail's own, with
Google's contact groups as tags, and an edit made in plMail is written back — names, phonetic names,
organisation, birthday and other dates, relations, websites, chat and SIP addresses, custom fields
and the photo, as well as addresses and numbers — and from Google to the phones that sync with it.
Only the fields plMail shows are sent, so what Google holds beyond them (occupations, interests, an
organisation's symbol) is left as it is. See [Contacts](../features/contacts.md).
An account connected **before** plMail asked for contacts holds a grant without the scope. Nothing
is wrong with it: mail and calendars carry on, and **Settings → Account health** shows a notice, *Connect
… again to sync its contacts*, whose button signs in once more and adds the permission. Its address
book appears as soon as that is done.
Google classes the contacts scope as **sensitive**. That is a lighter class than the mail scope's
*restricted*, which an installation already has to live with (see below), so it changes nothing
about verification.
### What "restricted scope" verification means here
`https://mail.google.com/` grants access to the whole mailbox, and Google treats scopes of that
@@ -274,7 +298,8 @@ from* with no calendars on it. Reconnect and leave the box ticked.
**Enabling the Calendar API is not the same as adding the calendar scope**, and doing one without
the other fails in two different ways: the scope without the API is a 403 on every calendar call,
and the API without the scope is a token that was never granted calendar access.
and the API without the scope is a token that was never granted calendar access. The same goes for
the People API and the contacts scope.
**Domain verification is per Cloud project, not per Google account.** Verifying the domain in Search
Console is only the first half; it also has to be added under **Domain verification** in the same
+39 -3
View File
@@ -57,6 +57,7 @@ the set as a whole; unlike Google there is no partial grant to handle.
| `Mail.Send` | Sending. Separate from the above in Graph's model, so it has to be added separately. |
| `MailboxSettings.ReadWrite` | Master categories, at `/me/outlook/masterCategories`. These are **not** covered by `Mail.ReadWrite`: they live under the Outlook user-settings resource and return `ErrorAccessDenied` without this. ReadWrite rather than Read because plMail creates category definitions when a label is mirrored. |
| `Calendars.ReadWrite` | Calendars on the same account, read and write. |
| `Contacts.ReadWrite` | Outlook contacts on the same account, read and write: each contact folder is an address book. |
Two of them are worth understanding before you decide to skip one.
@@ -83,6 +84,29 @@ If calendars do not appear for a user, plMail says which of the possible causes
**Settings → Calendars**: the permission may not be on the app registration, or the account may
genuinely have no calendars.
### Contacts are on the same sign-in too
`Contacts.ReadWrite` rides the same grant. A Microsoft account connected to plMail brings its
Outlook contacts with it: each contact folder appears as an address book under **Contacts**, with
Outlook's categories as tags, and an edit made in plMail is written back. See
[Contacts](../features/contacts.md).
Outlook's contact has fixed places where plMail has lists: three email addresses, two business and
two home numbers and one mobile, one home, one business and one other address, one spouse (or
partner), one manager, one assistant and the children's names, one website (the work one if there
is one), three chat addresses and a birthday. plMail fills those places, primary first. An entry
beyond them — a fourth email address, a second partner — is not sent, and stays only in plMail until
the contact next changes in Outlook, when Outlook's version comes back. Outlook has no place at all
for other dates (an anniversary), a phonetic middle name or custom fields; those stay in plMail. Its
chat addresses carry no service: a SIP address comes back as SIP, any other as "other".
Photos go both ways, with one exception: Microsoft Graph can replace an Outlook contact's photo but
not remove it, so a photo removed in plMail stays in Outlook.
An account connected before plMail asked for contacts holds a token without the permission. Mail
and calendars carry on, and **Settings → Account health** shows a notice whose button signs in once more
and adds it.
## The tenant value
`MICROSOFT_OAUTH_TENANT` — or the **Tenant** field in **Admin → Integrations → Mail sign-in** —
@@ -175,9 +199,21 @@ misconfiguration here.
usable; it is displayed once and cannot be retrieved afterwards, only replaced.
**Adding a permission does not upgrade tokens that already exist.** A user who connected before you
added `Calendars.ReadWrite` or `MailboxSettings.ReadWrite` holds a token that does not carry it, and
no amount of waiting changes that. The account has to be disconnected and reconnected so a fresh
consent issues a new token — `app:graph:diagnose` says so explicitly when it finds the gap.
added `Calendars.ReadWrite`, `Contacts.ReadWrite` or `MailboxSettings.ReadWrite` holds a token that
does not carry it, and no amount of waiting changes that. The account has to sign in again so a
fresh consent issues a new token — the reconnect button in **Settings → Account health** does that without
removing the account, and `app:graph:diagnose` says so explicitly when it finds the gap.
**An organisation that approves apps for its users refuses every sign-in after an upgrade that
adds a permission.** In a tenant where users may not consent to apps themselves, an administrator
approved plMail once, for the permissions it asked for then. A plMail version that asks for one
more (`Contacts.ReadWrite`, when contact sync arrived) is refused at sign-in, mail included, with
`AADSTS65001` or `AADSTS90094`, until the administrator approves again: **Microsoft Entra admin
centre → Enterprise applications → plMail → Permissions → Grant admin consent**. Do it before
upgrading, or before anyone reconnects. The sign-in error tells the user to ask their administrator,
**Admin → Integrations → Microsoft mail sign-in** carries the same warning, and so does the
reconnect offer in **Settings → Contacts**. Personal Microsoft accounts and tenants where users
approve apps themselves are not affected.
**Identity working while every mail endpoint fails is not a credentials problem.** It usually means
the Microsoft account has no Outlook mailbox provisioned at all, which happens with accounts created
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Contacts: the address book, and the address books mirrored into it — CardDAV
* through a connection, Google and Outlook through a mail account.
* See App\Entity\Contacts\Contact and App\Entity\AddressBook\AddressBook.
*
* New, empty tables; nothing existing is touched. The harvested `contact`
* table keeps feeding autocomplete as before, and the address book is read
* beside it (ContactSuggester), not copied into it.
*/
final class Version20261021123800 extends AbstractMigration
{
public function getDescription(): string
{
return 'Contacts: an address book, and CardDAV address books mirrored into it';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE address_book (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, remote_id VARCHAR(1024) NOT NULL, name VARCHAR(120) NOT NULL, is_read_only BOOLEAN DEFAULT false NOT NULL, sync_token TEXT DEFAULT NULL, last_synced_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, last_sync_error TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, integration_id INT DEFAULT NULL, account_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_address_book_usr ON address_book (usr_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_address_book_integration_remote ON address_book (integration_id, remote_id)');
$this->addSql('CREATE INDEX IDX_B6A973DA9E82DDEA ON address_book (integration_id)');
$this->addSql('CREATE INDEX idx_address_book_account ON address_book (account_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_address_book_account_remote ON address_book (account_id, remote_id)');
$this->addSql('CREATE TABLE contacts_contact (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, uid VARCHAR(255) NOT NULL, href VARCHAR(1024) DEFAULT NULL, etag VARCHAR(255) DEFAULT NULL, vcard TEXT DEFAULT NULL, remote_resource TEXT DEFAULT NULL, sync_state VARCHAR(20) DEFAULT \'clean\' NOT NULL, display_name VARCHAR(255) NOT NULL, given_name VARCHAR(255) DEFAULT NULL, family_name VARCHAR(255) DEFAULT NULL, name_prefix VARCHAR(255) DEFAULT NULL, middle_name VARCHAR(255) DEFAULT NULL, name_suffix VARCHAR(255) DEFAULT NULL, phonetic_given_name VARCHAR(255) DEFAULT NULL, phonetic_middle_name VARCHAR(255) DEFAULT NULL, phonetic_family_name VARCHAR(255) DEFAULT NULL, nickname VARCHAR(255) DEFAULT NULL, company VARCHAR(255) DEFAULT NULL, phonetic_company VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, job_title VARCHAR(255) DEFAULT NULL, office_location VARCHAR(255) DEFAULT NULL, birthday VARCHAR(10) DEFAULT NULL, dates JSON DEFAULT \'[]\' NOT NULL, relations JSON DEFAULT \'[]\' NOT NULL, urls JSON DEFAULT \'[]\' NOT NULL, ims JSON DEFAULT \'[]\' NOT NULL, custom_fields JSON DEFAULT \'[]\' NOT NULL, notes TEXT DEFAULT NULL, tags JSON NOT NULL, photo_path VARCHAR(255) DEFAULT NULL, photo_dirty BOOLEAN DEFAULT false NOT NULL, photo_version VARCHAR(2048) DEFAULT NULL, source VARCHAR(32) NOT NULL, trashed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, address_book_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_contacts_contact_owner_updated ON contacts_contact (usr_id, updated_at)');
$this->addSql('CREATE INDEX idx_contacts_contact_book_state ON contacts_contact (address_book_id, sync_state)');
$this->addSql('CREATE UNIQUE INDEX uniq_contacts_contact_book_uid ON contacts_contact (address_book_id, uid)');
$this->addSql('CREATE INDEX IDX_C6CAEA65C69D3FB ON contacts_contact (usr_id)');
$this->addSql('CREATE INDEX IDX_C6CAEA654D474419 ON contacts_contact (address_book_id)');
$this->addSql('CREATE TABLE contacts_contact_address (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(32) NOT NULL, street VARCHAR(255) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, region VARCHAR(255) DEFAULT NULL, postcode VARCHAR(255) DEFAULT NULL, country VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, contact_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_90FBF9D3E7A1254A ON contacts_contact_address (contact_id)');
$this->addSql('CREATE TABLE contacts_contact_email (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, email VARCHAR(320) NOT NULL, label VARCHAR(32) NOT NULL, is_primary BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, contact_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_contacts_email_contact_email ON contacts_contact_email (contact_id, email)');
$this->addSql('CREATE INDEX IDX_DB2535EFE7A1254A ON contacts_contact_email (contact_id)');
$this->addSql('CREATE TABLE contacts_contact_merge_record (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, merged_ids JSON NOT NULL, snapshot JSON NOT NULL, merged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, undo_until TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, kept_contact_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_contacts_merge_owner_undo ON contacts_contact_merge_record (usr_id, undo_until)');
$this->addSql('CREATE INDEX IDX_9CF7CBF7C69D3FB ON contacts_contact_merge_record (usr_id)');
$this->addSql('CREATE INDEX IDX_9CF7CBF731B2A36F ON contacts_contact_merge_record (kept_contact_id)');
$this->addSql('CREATE TABLE contacts_contact_phone (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, phone VARCHAR(64) NOT NULL, label VARCHAR(32) NOT NULL, is_primary BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, contact_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_contacts_phone_contact_phone ON contacts_contact_phone (contact_id, phone)');
$this->addSql('CREATE INDEX IDX_78F8DE46E7A1254A ON contacts_contact_phone (contact_id)');
$this->addSql('CREATE TABLE contacts_contact_relation (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, rel_type VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, owner_contact_id INT NOT NULL, related_contact_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_contacts_relation_triple ON contacts_contact_relation (owner_contact_id, related_contact_id, rel_type)');
$this->addSql('CREATE INDEX IDX_E771C207B5CBBC0F ON contacts_contact_relation (owner_contact_id)');
$this->addSql('CREATE INDEX IDX_E771C2076D6C2DFA ON contacts_contact_relation (related_contact_id)');
$this->addSql('ALTER TABLE address_book ADD CONSTRAINT FK_B6A973DAC69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE address_book ADD CONSTRAINT FK_B6A973DA9E82DDEA FOREIGN KEY (integration_id) REFERENCES integration (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE address_book ADD CONSTRAINT FK_B6A973DA9B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact ADD CONSTRAINT FK_C6CAEA65C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact ADD CONSTRAINT FK_C6CAEA654D474419 FOREIGN KEY (address_book_id) REFERENCES address_book (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_address ADD CONSTRAINT FK_90FBF9D3E7A1254A FOREIGN KEY (contact_id) REFERENCES contacts_contact (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_email ADD CONSTRAINT FK_DB2535EFE7A1254A FOREIGN KEY (contact_id) REFERENCES contacts_contact (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_merge_record ADD CONSTRAINT FK_9CF7CBF7C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_merge_record ADD CONSTRAINT FK_9CF7CBF731B2A36F FOREIGN KEY (kept_contact_id) REFERENCES contacts_contact (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_phone ADD CONSTRAINT FK_78F8DE46E7A1254A FOREIGN KEY (contact_id) REFERENCES contacts_contact (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_relation ADD CONSTRAINT FK_E771C207B5CBBC0F FOREIGN KEY (owner_contact_id) REFERENCES contacts_contact (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE contacts_contact_relation ADD CONSTRAINT FK_E771C2076D6C2DFA FOREIGN KEY (related_contact_id) REFERENCES contacts_contact (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE address_book DROP CONSTRAINT FK_B6A973DAC69D3FB');
$this->addSql('ALTER TABLE address_book DROP CONSTRAINT FK_B6A973DA9E82DDEA');
$this->addSql('ALTER TABLE address_book DROP CONSTRAINT FK_B6A973DA9B6B5FBA');
$this->addSql('ALTER TABLE contacts_contact DROP CONSTRAINT FK_C6CAEA65C69D3FB');
$this->addSql('ALTER TABLE contacts_contact DROP CONSTRAINT FK_C6CAEA654D474419');
$this->addSql('ALTER TABLE contacts_contact_address DROP CONSTRAINT FK_90FBF9D3E7A1254A');
$this->addSql('ALTER TABLE contacts_contact_email DROP CONSTRAINT FK_DB2535EFE7A1254A');
$this->addSql('ALTER TABLE contacts_contact_merge_record DROP CONSTRAINT FK_9CF7CBF7C69D3FB');
$this->addSql('ALTER TABLE contacts_contact_merge_record DROP CONSTRAINT FK_9CF7CBF731B2A36F');
$this->addSql('ALTER TABLE contacts_contact_phone DROP CONSTRAINT FK_78F8DE46E7A1254A');
$this->addSql('ALTER TABLE contacts_contact_relation DROP CONSTRAINT FK_E771C207B5CBBC0F');
$this->addSql('ALTER TABLE contacts_contact_relation DROP CONSTRAINT FK_E771C2076D6C2DFA');
$this->addSql('DROP TABLE address_book');
$this->addSql('DROP TABLE contacts_contact');
$this->addSql('DROP TABLE contacts_contact_address');
$this->addSql('DROP TABLE contacts_contact_email');
$this->addSql('DROP TABLE contacts_contact_merge_record');
$this->addSql('DROP TABLE contacts_contact_phone');
$this->addSql('DROP TABLE contacts_contact_relation');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Contact photos move from var/contacts to var/uploads/contacts (see
* ContactPhotoStore), and the photos stored before that are gone.
*
* var/contacts was in neither of the volumes the web and worker containers
* share, so a photo a sync brought in was written inside the worker and never
* seen by the web container, and every photo was lost when its container was
* recreated — which the update that brings this migration does. No file is
* left to move, so the rows that point at one are reset instead:
*
* - every contact forgets its photo path;
* - a synced contact also forgets its photo version and etag, and its
* address book its sync position, so the next sync reads the whole book
* and fetches every photo again, into the new place.
*
* Plain SQL, deliberately: through the ORM, clearing a synced contact's photo
* would be an edit, and the push would delete the photo on the server too.
* Contacts with an edit waiting to be sent are left alone; the sync does not
* overwrite them, and their photo comes back with the next change after.
*
* A photo uploaded to one of plMail's own contacts is lost and has to be
* uploaded again. The contacts feature reached only installs following the
* milestone image, on the day this was written.
*/
final class Version20261021210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Contacts: forget photos stored where the web container could not see them, and read synced books again';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
UPDATE address_book SET sync_token = NULL
WHERE id IN (SELECT DISTINCT address_book_id FROM contacts_contact
WHERE address_book_id IS NOT NULL AND photo_path IS NOT NULL AND sync_state = 'clean')
SQL);
$this->addSql(<<<'SQL'
UPDATE contacts_contact SET photo_path = NULL, photo_version = NULL, photo_dirty = false, etag = NULL
WHERE address_book_id IS NOT NULL AND photo_path IS NOT NULL AND sync_state = 'clean'
SQL);
$this->addSql('UPDATE contacts_contact SET photo_path = NULL WHERE address_book_id IS NULL AND photo_path IS NOT NULL');
}
public function down(Schema $schema): void
{
// Nothing to restore: the files were already gone.
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The fifth AI feature's two switches: contact help.
*
* The same two-columns-one-fact shape as Version20260902100000, for the same
* reasons: AiPermissions ANDs them on every read, and the user column is
* hydrated into a typed property, so both are `DEFAULT false NOT NULL` and go
* in together. False on both sides changes nothing until an administrator
* switches the feature on.
*
* Nothing for ai_call_metric: `feature` is a plain VARCHAR(32), and
* 'contact_help' fits.
*/
final class Version20261022120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the fifth AI feature\'s switches: ai_settings.contact_help_enabled and "user".ai_contact_help_off.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE ai_settings ADD contact_help_enabled BOOLEAN DEFAULT false NOT NULL');
$this->addSql('ALTER TABLE "user" ADD ai_contact_help_off BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE ai_settings DROP contact_help_enabled');
$this->addSql('ALTER TABLE "user" DROP ai_contact_help_off');
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Store whether a conversation is in the bin, instead of working it out on
* every list and badge (#223).
*
* "In the bin" is #268's rule: the thread carries Trash and none of its
* messages is outside Trash. Every list, every badge and search leave such a
* thread out, and each of them computed it from the messages: on a mailbox of
* 67k threads that was about 170 ms per query, and a sidebar runs about ten.
* Rewriting the sub-query as NOT EXISTS gave the same rows and no better time
* where it mattered, because the cost is the rule itself, not its spelling.
* So it is stored, as message_thread.unread_count is, and the filters read a
* column.
*
* ── Kept right by the database, not by PHP ───────────────────────────────────
* What changes the answer is scattered: a label going on or off a message or
* a thread through the ORM; raw SQL that rebuilds threads or unlinks a label
* from an account; ON DELETE CASCADE when a label or a message goes; a message
* moved to another thread; a reply arriving in a conversation that was in the
* bin. A flag set from PHP would be right on the paths someone remembered to
* update and silently wrong on the rest, and a wrong answer here is mail that
* vanishes from the Inbox. Triggers see every one of those writes, as the
* JMAP change log's already does (Version20260923140100).
*
* Every trigger recomputes the whole answer for the threads it touched, from
* the tables as they are, rather than adjusting it: a flush writes a message's
* labels and the thread's in several statements, and a value derived from
* scratch after each is right after the last whatever the order.
*
* ── Cost ─────────────────────────────────────────────────────────────────────
* The label tables fire once per statement, with the rows it changed, and only
* Trash rows lead to any work: filing, reading and labelling mail never reach
* the recompute. A new message costs one primary-key lookup, and recomputes
* only when it lands in a thread that is in the bin, which it takes out. A
* message leaving a thread, by deletion or a move, recomputes that thread.
*/
final class Version20261022160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store whether a conversation is in the bin, kept by triggers, so lists need not compute it.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE message_thread ADD binned BOOLEAN DEFAULT false NOT NULL');
// The rule, in one place. Called with the threads a write touched.
$this->addSql(<<<'SQL'
CREATE OR REPLACE FUNCTION plmail_refresh_thread_binned(thread_ids bigint[]) RETURNS void
LANGUAGE sql AS $$
UPDATE message_thread t
SET binned = s.binned
FROM (
SELECT c.id,
EXISTS (
SELECT 1 FROM thread_label tl
JOIN label l ON l.id = tl.label_id
WHERE tl.message_thread_id = c.id AND l.role = 'trash'
)
AND NOT EXISTS (
SELECT 1 FROM message m
WHERE m.thread_id = c.id
AND NOT EXISTS (
SELECT 1 FROM message_label ml
JOIN label l ON l.id = ml.label_id
WHERE ml.message_id = m.id AND l.role = 'trash'
)
) AS binned
FROM message_thread c
WHERE c.id = ANY (thread_ids)
) s
WHERE t.id = s.id
AND t.binned IS DISTINCT FROM s.binned
$$
SQL);
// message_label: the threads of the messages whose Trash changed.
$this->addSql(<<<'SQL'
CREATE OR REPLACE FUNCTION plmail_message_label_binned() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM plmail_refresh_thread_binned(ARRAY(
SELECT DISTINCT m.thread_id
FROM changed c
JOIN label l ON l.id = c.label_id AND l.role = 'trash'
JOIN message m ON m.id = c.message_id
WHERE m.thread_id IS NOT NULL
));
RETURN NULL;
END
$$
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER message_label_binned_insert AFTER INSERT ON message_label
REFERENCING NEW TABLE AS changed
FOR EACH STATEMENT EXECUTE FUNCTION plmail_message_label_binned()
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER message_label_binned_delete AFTER DELETE ON message_label
REFERENCING OLD TABLE AS changed
FOR EACH STATEMENT EXECUTE FUNCTION plmail_message_label_binned()
SQL);
// thread_label: the threads whose own Trash changed.
$this->addSql(<<<'SQL'
CREATE OR REPLACE FUNCTION plmail_thread_label_binned() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM plmail_refresh_thread_binned(ARRAY(
SELECT DISTINCT c.message_thread_id
FROM changed c
JOIN label l ON l.id = c.label_id AND l.role = 'trash'
));
RETURN NULL;
END
$$
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER thread_label_binned_insert AFTER INSERT ON thread_label
REFERENCING NEW TABLE AS changed
FOR EACH STATEMENT EXECUTE FUNCTION plmail_thread_label_binned()
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER thread_label_binned_delete AFTER DELETE ON thread_label
REFERENCING OLD TABLE AS changed
FOR EACH STATEMENT EXECUTE FUNCTION plmail_thread_label_binned()
SQL);
// message: arriving, leaving, or moving between threads. Per row, so
// that an UPDATE that does not move a message costs nothing at all —
// a statement trigger with transition tables would copy every updated
// row, bodies included, for every flag change.
$this->addSql(<<<'SQL'
CREATE OR REPLACE FUNCTION plmail_message_binned() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- A message arriving can only take a thread out of the bin, so
-- only a thread that is in it needs looking at.
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.thread_id IS NOT NULL
AND EXISTS (SELECT 1 FROM message_thread WHERE id = NEW.thread_id AND binned) THEN
PERFORM plmail_refresh_thread_binned(ARRAY[NEW.thread_id::bigint]);
END IF;
-- A message leaving can put the rest of its thread in the bin.
IF TG_OP IN ('DELETE', 'UPDATE') AND OLD.thread_id IS NOT NULL THEN
PERFORM plmail_refresh_thread_binned(ARRAY[OLD.thread_id::bigint]);
END IF;
RETURN NULL;
END
$$
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER message_binned_insert AFTER INSERT ON message
FOR EACH ROW WHEN (NEW.thread_id IS NOT NULL)
EXECUTE FUNCTION plmail_message_binned()
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER message_binned_move AFTER UPDATE OF thread_id ON message
FOR EACH ROW WHEN (OLD.thread_id IS DISTINCT FROM NEW.thread_id)
EXECUTE FUNCTION plmail_message_binned()
SQL);
$this->addSql(<<<'SQL'
CREATE TRIGGER message_binned_delete AFTER DELETE ON message
FOR EACH ROW WHEN (OLD.thread_id IS NOT NULL)
EXECUTE FUNCTION plmail_message_binned()
SQL);
// What is in the bin already. Only threads wearing Trash can be.
$this->addSql(<<<'SQL'
SELECT plmail_refresh_thread_binned(ARRAY(
SELECT DISTINCT tl.message_thread_id
FROM thread_label tl
JOIN label l ON l.id = tl.label_id AND l.role = 'trash'
))
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER IF EXISTS message_binned_delete ON message');
$this->addSql('DROP TRIGGER IF EXISTS message_binned_move ON message');
$this->addSql('DROP TRIGGER IF EXISTS message_binned_insert ON message');
$this->addSql('DROP FUNCTION IF EXISTS plmail_message_binned()');
$this->addSql('DROP TRIGGER IF EXISTS thread_label_binned_delete ON thread_label');
$this->addSql('DROP TRIGGER IF EXISTS thread_label_binned_insert ON thread_label');
$this->addSql('DROP FUNCTION IF EXISTS plmail_thread_label_binned()');
$this->addSql('DROP TRIGGER IF EXISTS message_label_binned_delete ON message_label');
$this->addSql('DROP TRIGGER IF EXISTS message_label_binned_insert ON message_label');
$this->addSql('DROP FUNCTION IF EXISTS plmail_message_label_binned()');
$this->addSql('DROP FUNCTION IF EXISTS plmail_refresh_thread_binned(bigint[])');
$this->addSql('ALTER TABLE message_thread DROP binned');
}
}
+4 -4
View File
@@ -106,9 +106,9 @@ export default defineConfig({
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
// integrations.spec.ts, mercure.spec.ts, ai-compose.spec.ts and
// thread-summary.spec.ts are handled separately below.
testIgnore: /(integrations|mercure|ai-compose|thread-summary)\.spec\.ts/,
// integrations.spec.ts, mercure.spec.ts, ai-compose.spec.ts,
// thread-summary.spec.ts and contacts-ai.spec.ts are handled separately below.
testIgnore: /(integrations|mercure|ai-compose|thread-summary|contacts-ai)\.spec\.ts/,
// No `storageState` here, and no `setup` project: signing in is now a
// worker-scoped fixture in tests/e2e/support/test.ts, because the path
// has to differ per worker and project config is static.
@@ -155,7 +155,7 @@ export default defineConfig({
// under the other — an inline composer with no "Help me write" button,
// failing in a file that passes alone.
workers: 1,
testMatch: /(integrations|mercure|ai-compose|thread-summary)\.spec\.ts/,
testMatch: /(integrations|mercure|ai-compose|thread-summary|contacts-ai)\.spec\.ts/,
dependencies: ["chromium"],
},
],
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Command\AddressBook;
use App\Infrastructure\Messaging\Message\SyncAddressBookMessage;
use App\Repository\AddressBook\AddressBookRepository;
use App\Service\Demo\DemoMode;
use DateTimeImmutable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* The sweep the scheduler fires for CardDAV address books, and the one a
* person runs when their contacts look stale — app:calendar:sync's twin.
*
* Dispatches rather than syncing inline, for the reason that command gives:
* a hand-run sync must fail the way the scheduled one does.
*/
#[AsCommand(
name: 'app:contacts:sync',
description: 'Dispatch a two-way sync for mirrored CardDAV address books — all of them, one, or only those due',
)]
final class ContactsSyncCommand extends Command
{
/** Slightly under the sweep's interval — see CalendarSyncCommand::STALE_AFTER. */
private const string STALE_AFTER = '-14 minutes';
public function __construct(
private readonly AddressBookRepository $books,
private readonly MessageBusInterface $bus,
private readonly DemoMode $demoMode,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('address-book-id', InputArgument::OPTIONAL, 'Sync a single address book by ID; omit for all of them');
$this->addOption('stale', null, InputOption::VALUE_NONE, 'Only address books not synced recently — what the scheduler runs');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (true === $this->demoMode->isEnabled()) {
$io->note('Demo mode is on — contacts sync is disabled.');
return Command::SUCCESS;
}
$id = $input->getArgument('address-book-id');
if (null !== $id) {
$book = $this->books->find((int) $id);
if (null === $book) {
$io->error(sprintf('Address book %s not found.', $id));
return Command::FAILURE;
}
$books = [$book];
} else {
$cutoff = true === $input->getOption('stale')
? new DateTimeImmutable(self::STALE_AFTER)
: new DateTimeImmutable('+1 year');
$books = $this->books->findDueForSync($cutoff);
}
foreach ($books as $book) {
$this->bus->dispatch(new SyncAddressBookMessage((int) $book->id));
$io->text(sprintf('→ dispatched sync for %s (#%d)', $book->name, (int) $book->id));
}
$io->success(sprintf('Dispatched %d address book sync(s).', count($books)));
return Command::SUCCESS;
}
}
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff. Show more