Commit Graph
758 Commits
Author SHA1 Message Date
LINUXexpert.org bc6a605629 Update email format for reporting security issues 2026-08-25 14:31:45 -07:00
LINUXexpert.org 798f10e4aa Modify email address format in CONTRIBUTING.md
Updated email format for reporting security issues.
2026-08-25 14:31:03 -07:00
LINUXexpert.org e81214956b Update contact email format for reporting vulnerabilities 2026-08-25 14:29:28 -07:00
LINUXexpert.org 401814cf3b Add Contributor Covenant Code of Conduct
Added Contributor Covenant Code of Conduct to promote a respectful and inclusive community.
2026-08-25 14:27:34 -07:00
LINUXexpert.org 0c86208455 Revise SECURITY.md for clarity on support and reporting
Updated the security policy to clarify supported versions and reporting procedures for vulnerabilities.
2026-08-25 14:26:43 -07:00
LINUXexpert.org 3c8238d6fd Create CONTRIBUTING.md 2026-08-25 14:25:11 -07:00
LINUXexpert.org 558b1bcce2 Merge pull request #51 from LINUXexpert-org/readme-badges-trim
Trim the badges
2026-08-25 13:54:09 -07:00
jcoffey-dev 55fb3535d0 Trim the badges to three, and credit LINUXexpert.org
Down to the licence, the Stalwart generations this has been run
against, and a LINUXexpert.org badge linking to the site. The CI, JMAP,
Node, TypeScript and no-IMAP badges are gone.

The Stalwart and LINUXexpert.org links carry target="_blank". Worth
knowing that GitHub itself will ignore it: its README sanitiser strips
target and rewrites rel to nofollow, which was checked against the
markdown API rather than assumed. The attribute still does its job
anywhere else the README is rendered, so it stays.
2026-08-25 13:52:13 -07:00
LINUXexpert.org 7b274c7c7a Merge pull request #50 from LINUXexpert-org/readme-badges
Put badges under the logo
2026-08-25 13:48:24 -07:00
LINUXexpert.org d448a72d6c Merge pull request #49 from LINUXexpert-org/agpl-source-offer
Make the AGPL's source offer point at the source being run
2026-08-25 13:47:41 -07:00
jcoffey-dev a2f0852437 Put badges under the logo
Seven, each saying something true about this project rather than
decorating the page: CI, the licence, which Stalwart generations it has
actually been run against, the JMAP RFCs it implements, the Node floor
from engines, that both tsconfigs are strict, and the line the README
already uses to describe itself -- no IMAP, no SMTP, no database.

Every claim was checked against the repository: the CI badge is the
workflow's own, the licence matches LICENSE and package.json, the Node
version comes from engines, and strict is set in both tsconfigs. All
seven were fetched and rendered before committing, so none of them is a
broken image.

The standalone licence badge lower down goes, being now the second one
on the page.

The Stalwart badge is the one to remember: it is static, so it needs
changing when the live instance moves, exactly as the prose above it
does.
2026-08-25 13:46:32 -07:00
jcoffey-dev 7b05322577 Make the AGPL's source offer point at the source being run
Three things a licence audit turned up. None of them is a conflict --
every one of the 182 installed packages is permissive, and the relicence
was within the copyright holder's gift -- but all three are ways the
AGPL fails to stick.

The offer was hard-coded to this repository. Section 13 asks whoever
runs a modified version to offer *that* version's source, so every
deployment with a patch in it was pointing at the wrong tree, and would
have gone on doing so unless its operator noticed and edited the About
page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in
page and About read it.

The offer was also only visible after signing in. Whoever is looking at
the sign-in form is interacting with the program over a network too, so
the footer carries it now.

And the two workspace packages declared no licence at all. Private, so
npm never minded, but anything reading the tree saw a blank where the
rest of the project says AGPL-3.0-or-later.

Checked both ways round: with SOURCE_URL set to a fork, the sign-in page
and About both point at the fork; with it unset, both fall back to this
repository.
2026-08-25 13:42:11 -07:00
LINUXexpert.org 259b625c3e Merge pull request #44 from LINUXexpert-org/push-dot-and-menu-close
Close the colour menu on a pick, and make the push dot readable
2026-08-25 13:18:20 -07:00
jcoffey-dev 732fdac78b Close the colour menu on a pick, and make the push dot readable
Two cosmetics.

Picking a folder colour left the menu open, which every other action in
it does not. It closes now, the way the calendar's colour menu already
did.

The live-updates indicator was an 8px flat speck in --fg-faint, near
invisible in either theme, and it had two states where the code has
three. The push client only ever said connected or not, which cannot
tell "retrying with a backoff" from "stopped": it now reports
connecting, connected or disconnected, and the retry path says
connecting rather than going dark. pushConnected stays for the callers
that only want the boolean.

The dot is 12px and raised -- a white highlight over a solid colour with
a soft halo, so one bead reads on light and dark alike without a
per-theme variant. Green connected, amber reconnecting with a slow
pulse, red disconnected. The pulse respects prefers-reduced-motion, and
the indicator is labelled for a screen reader rather than hidden from
it, since it carries real information.
2026-08-25 13:15:56 -07:00
LINUXexpert.org 5499971c54 Merge pull request #42 from LINUXexpert-org/folder-colours-filled
Fill a coloured folder in, rather than outlining it
2026-08-25 13:07:23 -07:00
jcoffey-dev fe09961383 Fill a coloured folder in, rather than outlining it
A tinted outline barely registered against the sidebar. The icon is now
filled with the colour, which is what makes it findable at a glance in a
list of a dozen folders.

Two details it needed. The fill has to come from CSS, because lucide
writes fill="none" as a presentation attribute on the svg and a rule
beats one. And the strokes are drawn in --bg rather than the colour: a
solid fill in one colour swallowed the detail inside icons that have any
-- Archive lost the lid and handle of its box and became an orange blob.
Knocked out against the background they read again, in either theme,
since --bg follows the theme rather than being pinned to one.

Checked by pixels and by eye in both themes: the coloured area of the
icon went from an outline to 55% of its box, and Archive, Newsletters
and Work are all still recognisably themselves.
2026-08-25 13:04:45 -07:00
LINUXexpert.org 037843a79d Merge pull request #41 from LINUXexpert-org/folder-colours
Give a folder a colour from its right-click menu
2026-08-25 12:57:37 -07:00
jcoffey-dev bbd6980cff Give a folder a colour from its right-click menu
Right-click a folder and pick one of the twelve colours the calendar
already uses, or clear it again. The colour tints the folder's icon; the
label keeps the sidebar's own contrast, which a dozen arbitrary colours
would not reliably give it.

Kept by mailbox id rather than by name, so a folder renamed or dragged
somewhere else keeps its colour. Stored in settings, which live in this
browser -- JMAP has nowhere on a Mailbox to put a colour, and every other
colour in the app, labels and event categories included, already works
this way. Worth knowing it does not follow you to another device.

The cascade needed care: .nav-item svg sets the colour on the icon
itself, so a colour inherited from a wrapper does nothing. Checking
getComputedStyle on the wrapper said the icon was purple while the pixels
stayed grey; the rule now targets the svg, and the check now reads the
pixels.
2026-08-25 12:54:33 -07:00
LINUXexpert.org b78d452d29 Merge pull request #40 from LINUXexpert-org/folder-drag-to-move
Move a folder by dragging it
2026-08-25 12:39:09 -07:00
jcoffey-dev bc4bad8466 Move a folder by dragging it
Reparenting a folder meant the Folders settings page, or nothing at all.
The tree already accepted messages dropped onto a folder, so folders now
travel the same way: drag one onto another to nest it, or onto the
Folders heading to bring it back to the top level.

The heading says "Drop here for the top level" while a folder is in
flight, because an unlabelled strip of heading is not a discoverable
target. The row being dragged fades, the row under the pointer is
outlined, and only rows that would accept the drop light up.

Four drops are refused: a folder onto itself, into its own subtree,
onto the parent it already has, and any folder the server gave a role,
which is not draggable in the first place. The subtree case is the one
that matters -- it would orphan the branch -- and it checks the whole
subtree rather than the immediate children.

Whether a drop is legal has to be known during dragover, when
dataTransfer.getData is blocked, so the tree remembers what is being
dragged rather than asking the drag.

The move goes through updateMailbox, so the filter rules pointing at the
folder follow it, and the target folder is expanded afterwards so the
folder can be seen where it landed.
2026-08-25 12:36:09 -07:00
LINUXexpert.org 418d3fafce Merge pull request #39 from LINUXexpert-org/sieve-detach-only-fileinto
Take the filing action, not the whole rule
2026-08-25 12:25:41 -07:00
jcoffey-dev 7f382565e8 Take the filing action, not the whole rule
Deleting a folder removed every rule that filed into it, along with
whatever else those rules did. A rule that filed into Work, marked read
and stopped processing lost the marking and the stopping too, and
deleting a folder says nothing about whether those were still wanted.

Only the fileinto action goes now. A rule left with nothing to do is
still removed, because it has nothing to do; a rule filing into two
folders keeps the one that still exists. The toast says which happened.

Verified against the running app with two rules aimed at the same
folder, one filing only and one filing and marking read: the first was
removed, the second kept its markread, and the script stored on the
server agrees.
2026-08-25 12:23:29 -07:00
LINUXexpert.org 1f14b818ef Merge pull request #38 from LINUXexpert-org/relicense-agpl3
Relicense to the AGPL, version 3 or later
2026-08-25 12:18:40 -07:00
LINUXexpert.org c2871304f1 Merge pull request #37 from LINUXexpert-org/sieve-follows-folders
Keep filter rules pointing at the folder they were aimed at
2026-08-25 12:16:22 -07:00
jcoffey-dev 8cbbc9cc6c Relicense to the AGPL, version 3 or later
ihasmail is webmail: it is nearly always run as a network service rather
than handed to anyone as a binary, which is the case the plain GPL does
not reach. The AGPL's section 13 does — anyone running a modified
ihasmail for other people has to offer them its source.

LICENSE is the full AGPL-3.0 text from gnu.org. The SPDX identifier
changes from GPL-3.0-or-later to AGPL-3.0-or-later in package.json, the
lockfile's own entry for it, and the About screen. Old commits and tags
are left exactly as they were; this is the license from here on.
2026-08-25 12:13:41 -07:00
jcoffey-dev 9da1ef3ead Keep filter rules pointing at the folder they were aimed at
A rule files mail into a folder by path, because that is what Sieve
needs. Rename the folder and the path becomes a lie: the rule keeps
matching and stops filing, and nothing anywhere says so. Delete the
folder and the rule is aimed at nothing at all.

Renaming or moving a folder now rewrites the rules that file into it,
and deleting one takes its rules with it. Both are reported in a toast,
because rules live on the server and are otherwise invisible from the
folder list.

The reconciliation runs off one hook. Before a mailbox is changed the
folder and everything beneath it are noted with the paths they have then
-- renaming a parent rewrites the path of every child, and rules naming
those children are just as stale. Afterwards, whatever still exists is
retargeted and whatever has gone takes its rules with it.

Rules record the folder twice, as a mailboxId and as the path. The id is
the reliable half and is preferred; the path is the fallback for rules
written before the id was recorded, or by hand in the Scripts tab, and a
rule matched that way has its id filled in on the way past. Only the
script the rule editor manages is touched; a hand-written one is left
alone.

Awaited rather than fired and forgotten, so a folder operation is not
reported complete while the rules still disagree with it.
2026-08-25 12:12:30 -07:00
LINUXexpert.org bb71a2d063 Merge pull request #36 from LINUXexpert-org/screenshot-script-guards
Make the light screenshot actually light
2026-08-25 10:41:49 -07:00
jcoffey-dev 89d2d0128e Make the light screenshot actually light
The light inbox screenshot in the README was not light, and had not been
since it was first taken -- the pair showed the dark theme twice.

The app was never at fault: update() calls applyTheme() synchronously
and the CSS flips --bg to #f6f8fa as it should. The capture was.
Swapping the theme under setDeviceMetricsOverride produces a mixed
frame, the panes that re-rendered in the new theme and the rest of the
chrome still in the old one, while the DOM and computed styles insist
the whole page is light. Clicking the app's own toggle, setting the
attribute, pinning it against applyTheme with a MutationObserver,
installing that pin before the document loads, nudging the viewport and
forcing a full reflow all left the frame mixed.

Chrome launched at --window-size, with the emulation layer never
touched, renders it correctly. That is docs/screenshots-light.mjs, and
inbox-light.jpg is now genuinely light.

assertTheme() stays: without it the script wrote a dark screenshot under
a light caption and reported success, which is how this survived
unnoticed. The header says which shots are taken elsewhere and why.
2026-08-25 10:39:44 -07:00
LINUXexpert.org 608bac62be Merge pull request #35 from LINUXexpert-org/readme-screenshots-refresh
Take the screenshots again, on today's build
2026-08-25 10:19:25 -07:00
jcoffey-dev 35ce2a8d3d Take the screenshots again, on today's build
The ones in the README were captured on 23 August, before the 2.0 QA
work: no drag handles on the filter rules, none of the calendar or
Sieve fixes, and the read-receipt line in the composer missing.

Same sizes as before, 1420x703 and 500x703 for the phone, so the table
lays out unchanged. The filters shot now carries four rules that say
something -- list-id, sender, subject, a wildcard match, each filing
somewhere different -- instead of four blank ones, and the calendar is
in the month view its caption has always claimed. A contact is open in
the contacts shot rather than an empty "select a contact".

The script that took them is committed alongside, so the next person
does not have to work out how to drive the mock: headless Chrome over
CDP, which is also how the viewport comes out at exactly the size the
old images used.
2026-08-25 10:16:14 -07:00
LINUXexpert.org d6c88e809a Merge pull request #34 from LINUXexpert-org/scheduled-send-verified-live
Record that a hold really does hold, on the live server
2026-08-25 09:30:21 -07:00
jcoffey-dev a195589e51 Record that a hold really does hold, on the live server
Scheduled send had only ever been exercised against the mock, and the
one thing that could not be taken on trust was whether the MTA honours
the hold at all: with futureRelease off it takes the HOLDUNTIL, drops
the hold and sends at once, saying nothing.

With the setting turned on at 30d, a submission ten minutes out came
back pending, sendAt equal to the time asked for, queued at the MTA.

The capability stays worthless as evidence — it advertised
maxDelayedSend: 2592000 and FUTURERELEASE throughout, including while
the setting was off. Noted, because it is the obvious thing to check and
it lies.

Still mock-only, and now said so precisely: the Scheduled folder
reconciling on the way in, and a hold expiring into a delivery.
2026-08-25 09:22:58 -07:00
LINUXexpert.org 8551d5417d Merge pull request #33 from LINUXexpert-org/calendar-vocabulary-into-main
Bring the stranded calendar work into main
2026-08-25 09:08:18 -07:00
jcoffey-dev aeba426203 Merge remote-tracking branch 'origin/calendar-recurring-warning' into calendar-vocabulary-into-main 2026-08-25 09:05:48 -07:00
LINUXexpert.org 04c79f6c5d Merge pull request #32 from LINUXexpert-org/sieve-rule-drag-reorder
Let a filter rule be dragged into place
2026-08-25 08:53:39 -07:00
jcoffey-dev 696b3713ed Let a filter rule be dragged into place
Twenty-five rules and two buttons that move one place at a time meant a
rule pushed to the wrong end cost ten clicks to bring back. It can now
be dragged.

A grip on the left of each card arms the drag, so the switch, the name
and the buttons still take a plain click, and the up and down buttons
stay for the keyboard. The card being dragged fades; the one under the
pointer draws a line on the edge the rule would land on, top half or
bottom.

The guard against dropping a rule onto itself reads a ref rather than
state: dragstart and the first dragover can arrive in the same frame,
and a stale read there drew a drop line on the card being dragged. Found
by driving the real thing in a browser, and covered by a test that fires
the two events back to back.
2026-08-25 08:51:28 -07:00
LINUXexpert.org 5a21763605 Merge pull request #31 from LINUXexpert-org/calendar-stalwart-vocabulary
Say it in the words Stalwart 0.16 answers to
2026-08-25 08:42:25 -07:00
jcoffey-dev b0525d10d4 Record that RSVP and the edit path hold up too
Adding a participant by patch had failed earlier, which left a question
over RSVP, since that patches participants/{key}/participationStatus.
It works, comment and all, and so does adding guests to an event that
had none and clearing them again with null.

The patch has to name the base event: a synthetic id is refused with
"Updating synthetic ids is not yet supported", which is exactly why rsvp
resolves baseEventId first. Worth writing down before someone simplifies
that line away.
2026-08-25 08:39:40 -07:00
jcoffey-dev 4558804752 Record that the invitation reached a real guest and came back
The store side was proven earlier; the sending side had only been
reasoned about. An invitation went to an external Gmail address from the
live 0.16.19: it arrived as an invite card, the decline came back, and
Stalwart applied it to the event — needs-action to declined, sequence 1.
Cancelling notified the guest as well.
2026-08-25 08:31:47 -07:00
jcoffey-dev c1ef19849e Say it in the words Stalwart 0.16 answers to
Guests added to an event vanished on save and no invitation was ever
sent. Not a guard in the editor, and nothing the server complained
about: ihasmail addresses a participant the way RFC 8984 does, with
sendTo and email, and Stalwart 0.16 keeps that address under
calendarAddress. Handed the RFC's spelling it stores the event, drops
the entire participant map, and reports success. Six shapes were tried
against a live 0.16.19, down to sendTo and roles alone; all six were
dropped, and patching a participant onto an existing event fails
outright with "Patch operation failed".

The same disagreement runs through two more properties. The organizer is
organizerCalendarAddress, not replyTo. A recurrence is a single
recurrenceRule, not a recurrenceRules array — and that one Stalwart
refuses honestly, with invalidProperties, so no recurring event could be
created at all and existing ones showed no repeat.

So writes now use Stalwart's names and reads accept either, since a
mailbox may hold events written by other clients. The mock now refuses
what the real server refuses and drops what it drops: advertising the
RFC spelling is exactly how this reached a live server unnoticed, the
same way the capability-placement bug did.

Verified against 0.16.19: participants, organizer and rule all survive a
create, an update and a re-read, with the roles kept as sent.

Fixes #26
Fixes #30
2026-08-25 08:26:34 -07:00
jcoffey-dev 458eb118b4 Ask the recurrenceId, which is the part that survives expansion
Rules alone were still wrong, in the other direction. A live 0.16.19 was
asked to expand a real weekly series: the occurrences come back carrying
no rule at all — only the master has one — and Stalwart spells that
master's rule "recurrenceRule", singular, not the RFC 8984 array ihasmail
looks for. So a genuine occurrence would have read as a one-off, and the
delete dialog would have offered to delete "this event" while deleting
the series.

What an occurrence does carry is a recurrenceId, which a one-off never
has. Master by its rule under either name, occurrence by its
recurrenceId. The tests carry the shapes the live server returned.
2026-08-25 08:18:38 -07:00
jcoffey-dev 330cecfb04 Ask only the recurrence rules, the live server settles it
A probe against the live 0.16.19 says a one-off event comes back from an
expanded query as id "eaaaaai" with baseEventId "i" — an instance id of
its own, and a base that is a different event. The clause that treated a
differing base as an occurrence of a series would therefore have gone on
calling every event recurring, which was the bug.

So recurrence rules alone decide it. What that gives up is an expanded
instance that arrives without its rules attached; whether Stalwart does
that is still to be checked against a real series.
2026-08-25 08:12:06 -07:00
LINUXexpert.org b4fd3d3ab4 Merge pull request #29 from LINUXexpert-org/sieve-custom-header-operator
Give a hand-typed header its own box
2026-08-25 07:58:37 -07:00
jcoffey-dev 0c334a113a Give a hand-typed header its own box
Picking "Other header…" in the filter dialog took the comparator away.
The condition row has three columns — field, comparator, value — and the
box for the header name was rendered into the comparator's, so the
comparator disappeared along with any way to change it. Whatever it had
been when you switched, contains, was what the rule got: matching a
header exactly, or on a regex, could not be expressed at all.

The header name now has a column of its own and the comparator keeps
its, on a row that widens to hold both.

Fixes #23
2026-08-25 07:56:01 -07:00
LINUXexpert.org 3a93de451c Merge pull request #28 from LINUXexpert-org/calendar-recurring-warning
Stop calling every event a series
2026-08-25 07:53:00 -07:00
LINUXexpert.org f5af5913cf Merge pull request #27 from LINUXexpert-org/sieve-rule-order-on-edit
Keep an edited filter rule where it was
2026-08-25 07:52:32 -07:00
jcoffey-dev 8b22ea9aab Stop calling every event a series
A one-time event opened for editing said "this is a recurring event —
changes apply to the whole series", and deleting one offered to delete
all occurrences of an event that has exactly one.

Three places asked whether an event had a baseEventId and took that for
recurrence. It isn't: the calendar loads its range with expandRecurrences,
and Stalwart puts a baseEventId on everything it returns that way, a
one-off pointing at itself included. The mock never sets the field at
all, which is why this only showed up against a real server.

They now share isRecurring(), which asks about recurrence rules, and
treats a base that is some other event as an occurrence of a series too
— so an expanded instance that travels without its rules is still
described honestly on the way to being deleted.

Fixes #25
2026-08-25 07:01:23 -07:00
jcoffey-dev fbcdff68da Keep an edited filter rule where it was
Renaming or editing a Sieve rule moved it to the bottom of the list, and
in Sieve the order is the order the rules run in, so mail started being
filed by a different rule than before. Putting it back took a click per
place moved.

Two things did it. saveAndApply always appended the rule it was given —
right for a rule created from a message, wrong for one being edited. And
the "Also apply to existing messages" tick defaulted to on wherever it
was offered, so every edit in Settings went down that path, including a
plain rename.

The rule now keeps its seat: a shared upsertRule replaces by id in place
and only appends what is genuinely new. The tick defaults to on only in
"Filter messages like this…", where applying it is the point, and the
toast no longer calls an edited rule "created".

Fixes #24
2026-08-25 06:55:47 -07:00
LINUXexpert.org 0b922e175f Merge pull request #22 from LINUXexpert-org/registry-path-verified
Record that the registry path has now met a live 0.16.19
2026-08-24 23:06:14 -07:00
LINUXexpert.org 77d9804583 Merge branch 'main' into registry-path-verified 2026-08-24 23:04:33 -07:00