Files
13303b247e v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches

A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 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>
2026-10-23 11:18:21 +02:00

366 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Calendar
plMail keeps a calendar beside the mail rather than in a separate application, because most of
what ends up on a calendar arrives as mail in the first place. This page covers the calendar
itself: the four views, the two shapes it takes on screen, and everything the event editor can
do. Invitations and events read out of messages are in
[Invitations and events from mail](calendar-invitations.md); reminders are in
[Reminders](calendar-alerts.md); mirroring somebody else's calendar is in
[Connected calendars](calendar-sync.md).
![The calendar](../screenshots/calendar.png)
## The two shapes
The calendar is one feature rendered two ways, and which one you get depends on where you opened
it from rather than on any setting.
**Docked beside the mail.** The calendar button in the topbar slides a pane in next to the message
list. It is a sibling of the mail pane, not an overlay, so the two split the row between them — the
handle in the middle moves the boundary and one grows exactly as much as the other shrinks. Drag
it, or focus it and use the arrow keys; double-click puts it back. The width is remembered per
user between 320 and 900 pixels, defaulting to 380, and it is written into the page server-side so
the pane is already the right size on the first paint instead of jumping once the browser catches
up.
On a desktop the pane starts open. Until you move the switch, the calendar sits beside your mail,
so the two are side by side from the first visit. Close it and plMail remembers that.
### The calendar button
By default the button is a three-way switch: mail, then the calendar beside the mail, then the
calendar full width, and round again. **Settings → Calendar → Behaviour** has one choice,
**Calendar button**, that can turn it into a two-way one:
- **All three**: the three-way switch, which is the default.
- **Full width only**: one press from the mail opens the calendar full width, the next goes back
to the mail. Clicking a mail link while the calendar is full width returns to the mail rather
than to a split.
- **Split only**: one press from the mail opens the calendar beside it, the next goes back to the
mail.
The position the button leaves out is not removed. A split or a full-width calendar you
already have stays until the first press, which closes it to the mail, and the drag handle still
reaches both ends.
Below 1024px wide the row cannot hold a sidebar, a readable message list and a readable calendar at
once, so the pane takes the whole row and the mail steps aside. It is the same pane and the same
toggle — closing it puts the mail back exactly as it was, with no navigation in between. A phone
always opens on the mail, whatever the pane was doing on your desktop; the button brings the
calendar up when you want it. Below 768px there is no pane at all and the calendar is its own page.
**Its own page.** `/calendar` replaces the mail view entirely. This is what a phone gets, and what
anyone gets who navigates there directly or follows a link.
## The four views
Day, 7 days, Month and Agenda. A view is part of the URL — `/calendar/week/2026-08-05` — rather than
something the browser remembers, which means every view of every date is bookmarkable, the browser's
back button does the obvious thing, and the range behind it stays one indexed query.
| View | What it covers |
|---|---|
| **Day** | The one day. |
| **7 days** | The seven days from today — or from the day you moved to. Today comes first, because the days of this week that are already over are the least useful thing to spend columns on. The address still says `week`. |
| **Month** | Six weeks, starting on the Monday of the week the 1st falls in — so the days spilling in from the neighbouring months are shown, as a month grid always does. |
| **Agenda** | A rolling list of the next 30 days, skipping the empty time between entries. |
The toolbar above the grid holds **Previous**, **Today** and **Next**, the date or range you are
looking at, the view switcher, and a **New event** button. Previous and Next step by a month in
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 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
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. 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, 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
the left, so the titles line up down the list rather than starting wherever the time happened to
end.
### The time grid
Day and 7 days are drawn as a time grid: hours down the left, and every event drawn where it actually
starts and as long as it actually lasts. Overlapping events are placed in lanes, so three things
happening at once are three narrower blocks side by side, and the lane count holds steady across an
unbroken run of overlaps rather than changing block by block.
**The docked pane draws the same grid the page does.** It used to keep an older column list instead,
on the reasoning that seven positioned columns need roughly a screen's width — true of 380 pixels,
and no longer the right conclusion, because the pane's width is now yours: drag its handle as wide
as you like, and past the end of its range it becomes the calendar full-width. A pane that quietly
drew a *different* calendar was the worse answer, since widening it to see the timeline gave you the
list anyway.
**A narrow week pans rather than squeezing.** Seven columns used to divide whatever width there was,
so a phone or a narrow pane gave each of them about fifty pixels and a block showed its colour and
little else. A column has a floor now: below the width where seven of them fit, the grid slides
sideways under a hour gutter that stays put, a day at a time, and four days are legible where none
were. **Day** is still one click away in the toolbar and still a single full-width column — a choice
now rather than the only way to read a narrow week.
**A block writes its time on one line and its title under it**, with the calendar's colour as a bar
down the left, so a narrow column shows a whole title where it used to show four characters of one.
Where a title still cannot finish — the all-day band above the grid is one line tall by definition —
resting the pointer on it widens it to whatever it had to say, over its neighbours, and puts it back
when you leave.
Month and Agenda have no time axis in either shape. A month cell is a couple of square centimetres
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.
Where a calendar carries a time zone of its own it wins for the events on it, as it always has; what
changed is the fallback, which used to be the container's own idea of the time — UTC — so the
current-time line could sit an hour or two away from the labels beside it and a new event was
proposed at a slot already in the past. A calendar you subscribe to from Google or a CalDAV server
is the exception: its zone is the one the remote account holds for it, often UTC for a shared
calendar, so when such a calendar is your default the grid still follows your own zone.
Three things about the grid are worth knowing:
- **The all-day band is always there**, even on a week with nothing in it, so the grid does not jump
by a row as you page through. It is deliberately not a drop target: dragging a timed event into it
would mean "make this all-day", which is a change of kind rather than of time, and kinds are
changed in the editor.
- **It opens scrolled to the working day** rather than to midnight, which costs one scroll to reach
the night instead of one scroll to reach everything.
- **Double-clicking empty space creates an event there**, starting at the quarter hour under the
pointer. A *single* click deliberately does not, and that is the whole reason it is a double: a
single click on the background has to be told apart from the end of a drag on every release, and
getting that wrong opens a dialog every time you finish moving a meeting. Each day heading keeps
its own **+** button, which is the same thing for a keyboard — a double-click is not a gesture a
keyboard has.
A seven-day week is tight below about 640 pixels — each column ends up around fifty pixels and a
block says its colour and little else. The paths a phone actually takes into the calendar avoid it:
the pane, which is what opens from the mail, keeps the agenda, and Day view is one tap away in the
toolbar and is a single full-width column.
## Creating and editing an event
**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. 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
saved with an empty title is called *Untitled* rather than refused. An end before the start is
refused: *Those times do not work — the end has to come after the start.*
**Delete** sits beside **Save** in the same form. On an event plMail read out of a message there is
also **Not an event**, which is a different thing from deleting — see
[Invitations and events from mail](calendar-invitations.md). **Download .ics** takes the event out as
a calendar file.
### Repeating events
**Repeat** offers *Does not repeat*, *Every day*, *Every week*, *Every month* and *Every year*. More
elaborate rules — every second Tuesday, weekdays only — are not written in plMail, but they are
carried faithfully when they arrive from somewhere that can write them: an invitation, an imported
`.ics`, or a connected calendar. Saving such an event from the editor with the dropdown left alone
does not flatten it.
A repeating event is stored once, as a rule, and the individual occurrences are drawn from it. They
are written out into a bounded window around today — a year back and two years forward — with a hard
ceiling of 1000 occurrences per event, so a rule that says "every second, forever" is finite rather
than fatal. The expansion happens in the event's own time zone, so a 09:00 Berlin standup is at
09:00 Berlin in November as well as in July.
### This occurrence, or all of them
When you open an editor from an occurrence of a repeating event, the editor is opened *on that
occurrence*: the times shown are the ones that occurrence actually has, including any move it has
already had. **Apply to** then offers two answers.
**This event** changes only the occurrence you opened. Nothing happens to the series or to any other
instance — the change is filed against the series as a patch, keyed by where the rule originally put
that instance, so editing the same occurrence twice updates the patch rather than stacking a second
one beside it.
**All events** changes the series. This is the one that surprises people, so it is worth stating what
it does to the times: because the editor was showing *that occurrence's* times, the fields are read
as the change you made to that occurrence, and the same shift and the same new duration are applied
to the series. Renaming a weekly meeting from its fifth occurrence therefore renames the series and
leaves it starting where it always started; pushing that fifth occurrence half an hour later and
choosing **All events** moves every occurrence half an hour later. The alternative — reading the
fields as the series' new absolute times — would drag the whole series onto the day you happened to
be looking at.
Two limits follow from how a per-occurrence edit is stored:
- **Reminders belong to the whole event.** The editor says so where you would otherwise expect the
scope radio to apply: a save scoped to *This event* writes the times and the title for that
occurrence and leaves the reminders exactly as they were on the series.
- **One occurrence cannot be added to a calendar it is not on.** Ticking a new calendar under
*This event* is refused whole, with *One occurrence cannot be added to a calendar on its own.
Choose "All events" to put this meeting on another calendar.* There is no row for a single
occurrence to create, so honouring it would mean inventing a series out of that one occurrence's
times.
Deleting works the same way. **All events** removes the series; **This event** takes one occurrence
off it, leaving the series and every other instance untouched.
## Dragging and resizing
On the time grid, an event block can be dragged to another time or another day, and resized by its
bottom edge. Changes snap to fifteen minutes. Nothing is written while you drag — the preview is
drawn in the browser and thrown away, and the change is submitted as an ordinary form when you let
go, so the grid you end up looking at is the one the server drew. A request that fails cannot leave
a block sitting somewhere the database disagrees with.
The keyboard does the same work: focus a block, then hold **Alt** and use the arrow keys to move it,
**Alt+Shift** to change how long it lasts, **Enter** to save and **Escape** to put it back. Every
change is announced, so a screen reader hears where the block went.
A repeating event asks the same this-one-or-all question a drag would otherwise answer silently, in a
dialog, before anything is submitted. An event on a calendar that does not accept changes cannot be
dragged at all, and says so.
## One event on several calendars
The **Calendars** control in the editor is a checkbox per calendar you own, ticked wherever this
meeting already is. The list is every calendar, not only the ones the meeting is on, so it says two
different things at once:
- **Tick a calendar it is not on**, and a copy is written there when you save.
- **Untick one it is on**, and that copy is left alone — not deleted. The editor spells this out:
*"untick one to leave that copy as it is — it will then differ from the others and show up as its
own entry."*
That second one is the trap, and the reason is worth understanding. Every copy of a meeting carries
the same UID, because a UID identifies the meeting rather than the row: that is what lets an update
from the organiser, a re-imported `.ics` and a provider's own copy all find every copy of the
meeting. The calendar merges copies into a single entry only *while they agree* about the five
things you can see on it — start, end, title, all-day, and whether it has been called off. Leave one
copy behind at the old time and it no longer agrees, so it splits out and is drawn as its own entry
on its own day. That is deliberate: a merged entry that quietly picked a winner would hide a real
disagreement behind a tidier screen.
A merged entry shows the colours of every calendar it covers and says *On Work, Personal* in its
tooltip. Clicking it opens one editor, with those calendars already ticked.
A calendar that does not accept changes — a mirror of a published feed, or a shared calendar you can
only read — appears in the list, marked *This calendar does not accept changes*, and cannot be
ticked. Unticking everything is refused: *Nothing was chosen, so nothing was changed. Tick at least
one calendar.*
**Delete reads the same ticks.** The copies on ticked calendars go and the rest stay exactly where
they are, read-only ones included. A ticked calendar the meeting was never on has nothing to delete
and is simply skipped.
Dragging a merged event has no checkboxes to offer, so it means what the editor means by default:
every writable copy moves.
## Managing calendars
**Settings → Calendars** is where calendars are created, renamed, recoloured, given a time zone,
hidden from the views, and nominated as the one new events land on. A calendar's time zone is the
one it is read in, and the one an event carrying no zone of its own is shown in.
plMail creates two kinds of calendar for you: one default calendar per user, and one per mail
account, which is where events found in that account's mail are filed. Neither can be deleted —
deleting one only means it is created again, and the events would go in the meantime. Calendars you
made yourself, and mirrors of remote ones, can be deleted; deleting takes every event on the
calendar with it.
Hiding a calendar hides it everywhere, consistently: the views, the topbar's next-thing indicator,
and the *Happening soon* list all read visible calendars only. The **editor** is the exception, and
deliberately: it lists every calendar you own, hidden ones marked with a struck-through eye beside
the read-only lock, because a copy of a meeting on a hidden calendar is still a fact about the
meeting. Saving onto one is then an informed choice rather than a trap — and a save that lands
somewhere you cannot see says so and names the calendar.
## Things that bite
**Unticking a calendar in the editor does not remove the event from it.** It means "leave that copy
alone". The copy stays where it is, stops agreeing with the ones you did change, and appears as a
separate entry from then on. To take a meeting off a calendar, tick that calendar and press
**Delete** — the delete acts on exactly the ticked ones.
**An event saved to a hidden calendar appears in no view at all.** The editor lists hidden
calendars; the day, week, month and agenda views do not. So the save succeeds, the row exists, and
the meeting is nowhere to be found until the calendar is made visible again in
**Settings → Calendars**. plMail now confirms every save and names the calendar when it is one you
cannot see, but the confirmation is the only thing that will tell you.
**A copy of an event that came from mail is still an event from mail.** Putting a meeting on a
second calendar does not cut either copy off from the booking it was read out of: a later message
moving the meeting moves both. See
[Invitations and events from mail](calendar-invitations.md) for when a change you make yourself
*does* stop that.
**"All events" applies your change as a shift, not as an absolute time.** Opening the fifth
occurrence of a weekly meeting, changing the start to Thursday 14:00 and choosing **All events**
moves the entire series by that difference. If you only meant that one week, choose **This event**.
**A copy created on a second calendar starts as the plain series.** It gets no participants — pushing
an attendee list to a provider is how the provider decides to re-send the invitation to everyone on
it — and none of the per-occurrence moves the original had already accumulated. Those instances stay
where they are on the original and are drawn as their own entries until you move them on both
copies.
**The editor never opens inside the pane.** It is rendered at the top of the page instead, because
both the pane and the mail pane carry a backdrop filter, which makes them clip anything positioned
inside them. This is invisible when it works and completely broken when it does not.
**Reminders are not covered by the scope radio.** Setting one while *This event* is selected sets it
on the whole series, and there is no way to give one occurrence a reminder of its own.
**An occurrence beyond the materialised window does not exist yet.** Occurrences are written out a
year back and two years forward from when the event was last saved. A repeating event that has not
been touched in a long time can therefore run out of drawn occurrences at the far end; saving it
again rewrites them from today.
---
**Related:** [Invitations and events from mail](calendar-invitations.md) ·
[Reminders](calendar-alerts.md) · [Connected calendars](calendar-sync.md) ·
[Sharing and booking](calendar-sharing.md)
**How it works:** [The calendar model](../internals/calendar-model.md) —
the JSCalendar object behind an event, how occurrences are materialised, and how overrides are stored.