Commit Graph
58 Commits
Author SHA1 Message Date
jcoffey-dev 6431ec87f5 Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing
system a customer invited, a colleague's export, a booking confirmation
forwarded on -- and until now the only events ihasmail could take were the
ones attached to a message it had received.

The calendar's own menu now offers "Import iCAL file…", which files
everything in the file into that calendar. No global button: the issue is
right that this is not a frequent enough thing to earn one.

The parsing is the server's, through the same CalendarEvent/parse an
emailed invitation already goes through. An .ics is not a format worth
reimplementing in a browser, and Stalwart's reader handles what a
hand-rolled one would not.

Every event goes out in a single CalendarEvent/set. The round trips are
the smaller half of the reason: createEvent invalidates on the way out and
invalidating refetches every cached range, so a year of events imported
one at a time would refetch the calendar a few hundred times.

Nothing is mailed to anyone named in the file. Importing is filing
something you already have, and scheduling messages would be a surprise to
its participants.

The mock's parser read the whole file with one regex and returned one
event, which is all an invitation ever needed. It now reads per VEVENT, so
a multi-event file can be tested against it, and it invents an organiser
and an attendee only for events that carry a METHOD -- a plain export is
not addressed to anyone.

Closes #173
2026-09-01 08:38:06 -07:00
jcoffey-dev b862f61cde Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender
and everyone it was addressed to already in the guest list, so a thread
becomes a meeting without retyping the room.

Two things are deliberately left out. The reader's own addresses, since
they are the organiser and an organiser among their own guests is an
invitation to your own appointment. And a blind copy, on a message the
reader sent: a guest list is visible to every guest, so promoting a Bcc
to a guest would tell the room about a copy the sender chose to hide.
That is not something a menu item may do quietly.

*Send invitation emails to guests* now starts off when the guests were
inherited rather than typed, and on everywhere else -- which is every
event whose guests somebody chose one at a time. The reason is the case
the issue opened with: a reminder made out of a bill carries the biller
and everyone else on the mail. Left on, the primary button reads Send
invites and the first press mails all of them an invitation to what was
meant as a note to self. The switch sits right there under the list and
says what it does, so inviting them is one deliberate click. Un-sending
is not.
2026-08-31 23:07:18 -07:00
jcoffey-dev 7ba749148f Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar
entry, the way a bill or a task becomes a reminder. Nothing clever, and
deliberately so -- the subject becomes the title, the body becomes the
description, and the reader supplies the one thing the message cannot.

A due date is exactly that thing. "Due on the 14th" in an invoice is not
a date a parser could be trusted with, and a wrong guess quietly
scheduled is worse than no guess at all, so the editor opens on the next
half hour for an hour and the reader fixes it. Forward rather than now,
because a start time that has already passed by the time they press
Create is one more thing to correct.

The body is capped at 5000 characters. A newsletter is a message too,
and its whole body would be stored on the event, synced to every device,
and shown in a three-row textarea; what is worth keeping -- the amount,
the account, the address -- is near the top. The cut is marked, so a
truncated bill is not read as the whole of it.

One message only. The list menu acts on the selection everywhere else,
but there is no sensible event to make out of five mails, and the mobile
entry appears only when exactly one row is held.

The editor lives inside CalendarView and the reader is in the mail view
when they ask, so the draft waits in the calendar store until that view
mounts and takes it -- once, or it would reopen on every later visit.
It seeds a form rather than an event: the dialog still says New event
and still has to be pressed.

Called *Create event…* rather than "appointment", which is the word the
issue used: it opens the New event dialog, and each catalogue already
has its own settled noun for that -- Termin, événement, 日程.

Reachable three ways, since a phone has no right-click: the row context
menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely
where the account has no calendar.
2026-08-31 22:52:59 -07:00
jcoffey-dev ded2f4dc1b Ten languages, not nine: correct the count everywhere
The picker offers English plus nine translations. I wrote it up as nine
in total with eight unread, which is off by one in the direction that
undercounts the work and, worse, misstates how many catalogues are
waiting for a speaker to read them.

Nine of the ten are machine-made and unread — all of them, not all but
one — so the sentence that matters reads more sharply than the wrong
version did, not less.
2026-08-31 15:36:04 -07:00
jcoffey-dev c26ca90e01 Document the nine languages, and what Beta means on them
The translations shipped today and the docs still said "no languages but
English". They also need to say the harder thing, which is that eight of
the nine have never been read by anybody who speaks them.

- README gains the language list, with the Beta caveat in the same line
  rather than a footnote.
- FEATURES.md gains an Interface language section: the list, why it is a
  separate setting from the date locale, and the two design properties
  that follow — a missing entry renders English, and plurals are asked of
  Intl.PluralRules rather than assumed, which is why Russian carries
  three forms and Japanese one.
- ROADMAP.md no longer lists translations as "not yet". What replaces it
  is the half that is genuinely not done: a translation anybody has
  checked. RTL is split out as its own entry, because holding Arabic,
  Hebrew and Persian back is a layout decision and not a queue position.
- KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the
  one thing on that page that cannot be closed by testing. And the
  coverage number that read 100% while two hundred strings rendered
  English in every language — recorded as a general lesson rather than an
  i18n one, since a coverage number measures what it can see and the rest
  is exactly what nobody is checking.
2026-08-31 15:18:01 -07:00
jcoffey-dev 562cee82ce Renew the push subscription, so it does not lapse in a week
Background notifications were built, verified against a live server, and then
went quiet a few days later on every device that had them. A JMAP push
subscription expires -- seven days is the ceiling -- and re-registering before
it lapses is the client's job. Nothing did: enableWebPush() was reachable only
from the switch in Settings, so the subscription was registered once, expired,
and stayed expired. Nobody reports that as a bug. They report that push does
not really work.

It is renewed on every app start now, which is the only place it can be: the
registration is a JMAP call and the service worker has no session cookie to
make one with. So the guarantee is that push keeps working as long as ihasmail
is opened now and again, and a two-day renewal window against a seven-day
ceiling means once a week is enough. Registering is the same call as turning it
on -- deviceClientId makes a repeat replace rather than accumulate -- so there
is no second path to get wrong.

Two more things in the same area, both of which produce the same silence:

- webPushActive() asked whether the *account* had any subscription, so the
  moment one device had one, every other device showed the switch already on.
  A phone that had never successfully registered, or whose registration had
  since expired, read as on and delivered nothing. It matches on the device now.
- Turning push on reused an existing browser subscription and gave up if there
  was none. A browser drops or rotates one on its own, and there is no tab open
  to hear the pushsubscriptionchange when it does, so that state was permanent.
  Renewal re-subscribes rather than bailing.

Whether this browser has push on is now remembered locally, which is what
renewal keys off. It is per browser rather than per account on purpose: a
subscription is an endpoint and a device, and a phone having push says nothing
about the desktop. It is not kept across sign-out, matching sign-out already
destroying the subscription itself.

The mock is the reason this was invisible in development: it handed back
expires: null, so a client that never renewed worked perfectly against it
forever. It expires a subscription in seven days now, which is what makes
"does this client renew?" a question the mock can answer.

Checked against the mock: a create returns an expiry seven days out that
survives PushSubscription/get and parses, renewing the same deviceClientId
replaces rather than accumulates, and a device with no registration of its own
finds nothing where the old code saw two subscriptions and said yes. What the
live Stalwart sets for expires is not confirmed -- if it sets none, renewal
correctly does nothing and the other two fixes still stand.
2026-08-31 08:03:43 -07:00
jcoffey-dev 42dfdc5a44 Write down what the mail list does under a finger
FEATURES.md and the touch gestures were written at the same time on separate
branches, so the inventory of everything ihasmail does landed knowing about
none of them: the mobile layout was still one line about a tab bar, and the
Appearance row of the settings table did not mention the two settings that
had just been added to it.

The gestures get a section of their own under Layout rather than a bullet,
because the interesting part is not the list of five -- it is why they are
gated on `(pointer: coarse)` rather than on width, why a direction with no
meaning in this folder refuses to move rather than moving and doing nothing,
and why the axis lock gives a diagonal drag to the scroller. The same
reasoning is in the code; this is where somebody reads it without opening
lib/touch.ts.

Also here: the `dvh` and safe-area notes alongside the mobile layout, a long
press added to the list's multi-select bullet, and a short note in the
settings-sync section on why the swipe actions follow the account despite
looking like a per-device setting.
2026-08-31 07:03:29 -07:00
jcoffey-dev f736bf0c34 Write down everything ihasmail does
The feature list lived in three places that each answered a different question: the site sells it, the docs teach the parts that surprise people, and the README summarises both in a paragraph. Nothing said, in one place and at full detail, what is actually built -- so evaluating ihasmail meant reading the source, and a feature that quietly stopped working had nowhere to be contradicted.

FEATURES.md is that inventory, written from the code rather than the copy: the capability matrix and what each missing one removes, the search operators as the parser actually reads them, the Sieve tests and actions, every settings section and which of them follow the account, the shortcut set, the security posture, and the environment. Where a behaviour is odd it says why, because the reason is usually the stateless constraint.

It also records what the docs had not caught up with: single-occurrence edit and delete are built, and the id renumbering that made them hard is described where someone changing that code will find it.
2026-08-30 23:02:14 -07:00