Compare commits

...
Author SHA1 Message Date
LINUXexpert.org 2d5bda086f Merge pull request #86 from LINUXexpert-org/docs/readme-quick-start
docs: put the Docker quick start back in the README
2026-08-26 19:18:03 -07:00
jcoffey-dev d15f64ada6 docs: put the Docker quick start back in the README
Slimming it left no way to try ihasmail without leaving GitHub. Restored in
short form -- the four commands, the 2FA app-password note, and links to the
install and configure guides for TLS and the full environment.

ROADMAP.md's 2FA entry points back at that section again, rather than at the
install docs it was redirected to when the section was gone.
2026-08-26 19:16:04 -07:00
LINUXexpert.org 906d17a48b Merge pull request #85 from LINUXexpert-org/docs/readme-slim
docs: slim the README, split out known issues and roadmap
2026-08-26 19:08:16 -07:00
jcoffey-dev 9618a0278c docs: slim the README, split out known issues and roadmap
The README had grown to 330 lines and was carrying three audiences at once:
installing, using, and working on ihasmail. docs.ihasmail.org covers the first
two now, and ihasmail.org covers the feature tour, so the README links there
instead of restating them.

- Known issues / pending QA → KNOWN-ISSUES.md, verbatim
- Roadmap / not yet → ROADMAP.md, verbatim (its "see Quick start" pointer now
  aims at the install docs, since that section is gone)
- Dropped the env-var table (docs.ihasmail.org/configure/), the shortcut list
  (/shortcuts/), the Docker quick start (/install/) and the long feature list
  (ihasmail.org/#features), leaving a nav table at the top and a six-line
  summary of what's in it
- Kept and tightened what is only true of this tree: architecture, dev
  commands, the mock, version numbers, deploying
- Version examples refreshed from 2.16.57 to the current 2.16.84
2026-08-26 19:02:04 -07:00
LINUXexpert.org 486ab2f0d0 Merge pull request #84 from LINUXexpert-org/remove-2fa-login-entry
Remove the two-factor entry point from the login form
2026-08-26 16:44:07 -07:00
jcoffey-dev 1527ebffc8 Remove the two-factor entry point from the login form
The field never worked here: Stalwart takes a TOTP code only through an
OAuth flow, so a client posting a username and password had nothing to
send it to. Offering the button advertised a feature the login path
cannot honour, so it comes out until the flow works end to end.

The login store still takes a totp argument and the server still accepts
one; the form now passes an empty string, which the server reads as no
code given. A failed sign-in no longer reveals the field, and the
invalid-credentials message drops its mention of a verification code.
2026-08-26 16:41:51 -07:00
LINUXexpert.org 4dd46b156c Merge pull request #83 from LINUXexpert-org/remove-2fa-setup
Stop offering to turn two-factor authentication on
2026-08-26 16:23:27 -07:00
jcoffey-dev c6db19de19 Stop offering to turn two-factor authentication on
Settings > Security & sessions still had the full enrolment flow --
QR code, secret, "Set up" -- for something that cannot be signed in
with. Turning 2FA on there took a working account and made webmail
unreachable from any device not already signed in, because ihasmail
has nowhere to send a TOTP code: Stalwart accepts one through an
OAuth flow alone and offers no password grant (#75). The one mercy
was that enabling reseals the current session onto a fresh app
password, so the browser doing it stayed in -- and the next sign-in
elsewhere did not.

So the enrolment path is gone until sign-in with a code works.

Turning 2FA *off* stays. It is a plain registry write, it was
verified live on 0.16.19, and anyone already enrolled -- here or in
Stalwart's own settings -- needs a way back. That control is now the
whole section, and it appears only for an account that has 2FA on;
everyone else no longer sees the heading at all.

The password form keeps its authenticator-code field on the same
condition, since Stalwart demands a code on every credential write
once 2FA is on.

Nothing changes on the server: /api/account/2fa/begin and /enable
are untouched and still tested, ready for the OAuth work that makes
them usable. The sign-in page's code field is also untouched -- it
already explains itself and points at app passwords.

README no longer advertises enrolment by QR code, and the roadmap
entry says which direction the setting still moves.
2026-08-26 16:21:27 -07:00
LINUXexpert.org 96ec790d58 Merge pull request #82 from LINUXexpert-org/hide-identities
Hide identities from the compose picker
2026-08-26 15:44:55 -07:00
jcoffey-dev 133036a6c5 Hide identities from the compose picker
An account using a unique address per service, on a server with an alias
domain, ends up with every local part twice over and a From picker
nobody can use -- while only ever sending from a handful (#73).

Identities can now be hidden from that picker, from Identities &
signatures. Hiding is presentation only: the identity still exists,
still receives, and stays listed and editable, the way an unsubscribed
folder is still a folder. That framing is mbunkus's own, and it is the
right one -- this is a UI preference, not a change to the account.

Three things it refuses to do, because a sender picker with nothing
usable in it is worse than a cluttered one:

  - it will not hide the identity a draft is already using, which would
    leave the select with no matching option and move the From line
    under the writer
  - it will not hide the default, which is what a new draft starts on;
    the button is disabled there and says why
  - if every identity is somehow hidden -- reachable only through
    settings sync, since the UI will not do it -- they are all offered
    again

The setting syncs, so the picker looks the same on every device, which
follows from DEVICE_KEYS being a list of exceptions rather than a list
of what travels.

Verified against the mock with four identities and one hidden: the
picker offers the other three, the hidden address is gone from
composing, the default's hide button is disabled, and the row says the
identity still receives.
2026-08-26 15:36:35 -07:00
LINUXexpert.org 72c409f0f5 Merge pull request #81 from LINUXexpert-org/fix-thread-collapse
Stop the reading view rearranging itself as you read
2026-08-26 15:33:59 -07:00
jcoffey-dev d4b39c06d6 Stop the reading view rearranging itself as you read
Opening a conversation with several unread messages showed them all
expanded, each with its unread bar. The moment the auto-mark-read timer
fired, every one of them collapsed except the last, and the bars
vanished -- so the messages you had just been given were taken away
again, and the only record of which ones they were went with them (#69).

Both came from the same place: expansion and the bar were derived from
`$seen`, live. Marking read on the server changed what the view thought
it was looking at.

Marking read is not the problem. Opening a thread is the signal that you
are reading it, and mbunkus was explicit that turning the setting off is
not the answer he wants. What was wrong was letting a change *this view
caused* alter its own shape underneath the reader.

The thread now remembers which messages were unread when it was opened,
and uses that for expansion and for the bar. The set only grows while a
thread is open -- a message arriving unread joins it -- and is discarded
on the way to another thread. The server still gets marked read on the
timer, exactly as before, and the message list still updates.

It is accumulated during render rather than in an effect. It is derived
purely from the messages already in hand and adding an id twice does
nothing, while an effect would repaint a frame later -- which is the
flicker this exists to remove.

Verified against the mock with markReadDelay at 0, the harshest setting,
where the timer fires immediately: six seconds after opening a
three-message thread, the server reports all three seen while the view
still shows all three expanded with their bars. Before, two of the three
would have collapsed in the first instant.
2026-08-26 15:28:51 -07:00
LINUXexpert.org 6e58c22807 Merge pull request #80 from LINUXexpert-org/fix-keyboard-delete
Move focus off a row that has been deleted
2026-08-26 15:18:08 -07:00
LINUXexpert.org 6e59f59d18 Merge pull request #79 from LINUXexpert-org/fix-from-select-dark
Theme the dropdown a native select paints for itself
2026-08-26 15:17:17 -07:00
LINUXexpert.org c647744470 Merge pull request #78 from LINUXexpert-org/fix-sieve-overwrite
Never overwrite filters we could not read
2026-08-26 15:17:13 -07:00
jcoffey-dev 2e33b4c467 Move focus off a row that has been deleted
Two complaints in #71, one cause. Deleting from the keyboard left
`focusId` pointing at a row that was no longer in the list, and nothing
moved it.

The confirmation appearing "every other message": `targetIds()` falls
back to the focused id, so the second `#` re-targeted the message the
first one had just deleted. The optimistic update had already moved that
message into Deleted Items, so it read as a permanent delete -- and a
permanent delete always confirms, whatever "Confirm before deleting" is
set to. The dialog was correct about the message it was asked about; it
was asked about the wrong one.

`k` jumping to the top: `moveFocus` reads `ids.indexOf(focusId)`, which
was -1 for the departed row, and -1 is treated as "before the first
row". Adding -1 to that clamps to 0.

Both explain why the mouse was fine: clicking sets focus to a row that
exists.

Focus now moves to whatever slid into the deleted row's place, honouring
"After archiving or deleting" -- the row below by default, the one above
when set to newer -- and clears when the folder empties. `moveFocus`
also no longer reads a missing row as index 0; it falls back to where
the list thinks it is.

Verified in the browser against the mock, since arithmetic tests cannot
prove the wiring: with confirmation off, two deletes in a row both go
through silently, focus stepping e4 to e5 to e6 as rows close up; then
`k` moves up exactly one instead of to the top of the list.
2026-08-26 15:15:48 -07:00
jcoffey-dev 087c856a46 Theme the dropdown a native select paints for itself
The From picker's drop-down rendered a light background under light
text, unreadable in any dark theme (#70).

A native <select>'s popup is painted by the browser from the element's
own colours, not the page's. `.from-select` is deliberately transparent
so it sits flush in the composer's From line, which left the popup with
no background of its own: the browser drew a light one while the text
kept the app's light foreground.

Fixed by styling `option` rather than the control, so the popup gets a
background without the closed select gaining a box. Verified: the select
stays transparent, the options are now --bg-elev on --fg, which is
12.5:1 where it was light on light.

Scoped to every select rather than this one. Nothing in the app styled
options anywhere, so this was not one broken dropdown but the first one
anybody happened to open in the dark -- and the next transparent select
would have arrived with the same bug.
2026-08-26 15:09:30 -07:00
jcoffey-dev 9c37af7b07 Never overwrite filters we could not read
Adding a filter from a message reported success while the script on the
server never held more than two rules (#76). Rules were being destroyed,
and the confirmation was a lie.

Three links, each defensible alone:

  1. load() recorded a *failed* blob fetch as `contents[id] = ""`.
  2. sieveToRules("") returns [] -- "this script has no rules", which is
     indistinguishable from "we could not read this script".
  3. Saving rewrites the whole script from that baseline, so every rule
     already in it was deleted. The write itself succeeded, which is why
     the UI said so.

No fetch failure was even required: rules() did `contents[id] ?? ""`, so
a script whose content had not loaded yet read as empty too. And
saveScript cached the content it had just written and then called
load(), which replaced the whole map -- discarding it if the refetch
came back short.

The fix is to keep "unknown" and "empty" apart at every step:

  - a failed fetch leaves the key absent rather than storing ""
  - load() merges rather than replacing, so a reload cannot throw away
    what saveScript just wrote
  - rules() returns null for content it does not have, which every
    caller already treats as "do not touch this script"
  - saveRules refuses outright when the baseline is unknown. Refusing is
    recoverable; overwriting is not.

rules() now also reports whether the script was read, because "written
by hand" and "could not be read" want different advice -- one is
permanent, the other is a reload away, and telling someone the wrong one
sends them hunting for a problem they do not have.

Ruled out on the way: the rule codec round-trips fine, eight rules in
and eight out. sieveToRules reads the `# rule:` JSON comments rather
than parsing Sieve, so the generated script's shape was never the issue.
2026-08-26 15:03:53 -07:00
LINUXexpert.org 450a38f7cd Merge pull request #77 from LINUXexpert-org/totp-honest-failure
Stop pretending the two-factor field can work
2026-08-26 14:57:10 -07:00
jcoffey-dev d98c425a9a Stop pretending the two-factor field can work
Signing in with a two-factor code failed with a bare 401 and "Invalid
credentials", which sent the user off to check a password that was
perfectly good (#75). It cannot work, and the app already knew.

Stalwart accepts a TOTP code only through an OAuth flow -- its own web
interface is an OAuth client, which is why signing in *there* succeeds --
and it offers only the authorization-code and device flows. There is no
password grant, so a client holding a username and password has nowhere
to exchange them plus a code for a token. The concatenated
`password$code` form this README claimed was accepted is not a route the
server has, and appears never to have been. What was verified live on
0.16.19 was enabling and disabling 2FA, never signing in with a code.

The contradiction was already in the codebase: turning 2FA *on* mints an
app password and reseals the session onto it, precisely because a plain
password stops working from that moment. The sign-in page was the one
place still assuming otherwise.

Three changes, no new capability:

  - A 401 on a sign-in that carried a code now says what is happening
    and where to go instead, and says the password is probably fine.
    A sign-in without a code is untouched, so an ordinary typo still
    reads as an ordinary typo.

  - The field stays, and is honest about itself. Removing it would leave
    someone with 2FA finding nothing at all, which is worse than finding
    a field that explains the situation and points at app passwords.

  - The README's claim is corrected rather than quietly dropped, and
    real 2FA support is written into the roadmap as what it is: an OAuth
    implementation, handing sign-in to Stalwart and holding a refresh
    token instead of a sealed password.
2026-08-26 14:46:08 -07:00
LINUXexpert.org b37c422e7d Merge pull request #74 from LINUXexpert-org/push-limits-doc
Say what "even when ihasmail is closed" actually means
2026-08-26 14:15:44 -07:00
jcoffey-dev 7726665a48 Say what "even when ihasmail is closed" actually means
Web Push works, confirmed end to end against the live 0.16.19: with
Chrome open and every ihasmail tab closed, a notification arrives
immediately and names the sender and subject.

But "closed" means ihasmail, not the browser, and the switch did not say
so. Web Push is delivered over a connection the browser holds, so
something of it has to be running.

Observed on 2026-08-26, with Chrome fully quit and "Continue running
background apps" off: nothing arrived until Chrome was started again, at
which point the queued notification was delivered. Turning that setting
on keeps a process alive and restores immediate delivery.

Worth knowing that the queue is not indefinite -- a Web Push message
carries a TTL, and one that expires before the browser comes back is
dropped rather than delivered late. Being an installed PWA does not
change any of this on a desktop; it changes the window, not who holds
the connection. On Android it would, since the push service can wake the
browser from cold.

None of this is ihasmail's to fix. It is what Web Push is, and the only
thing worth doing about it is not implying otherwise -- which the
notification switch was quietly doing.
2026-08-26 14:13:19 -07:00
LINUXexpert.org 99e98f82f3 Merge pull request #72 from LINUXexpert-org/fix-emailpush-filter
Send a filter the server can read
2026-08-26 14:03:52 -07:00
jcoffey-dev 2263aa494f Send a filter the server can read
Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:

    filter: { inMailbox: null, notKeyword: "$seen" }

`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.

The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.

Two reasons this got out, both worth fixing rather than just the bug:

  - The tests checked the properties list and its ordering, and never
    looked at the filter. There is now one that walks every condition
    and fails on a null or undefined value, for both the known-inbox and
    unknown-inbox cases.

  - The mock accepted it happily, so nothing local disagreed with the
    code. It now refuses a filter condition with a null value and
    answers "Invalid filter.", which is what the live server said.
    Reproduced: the old payload is rejected, the new one accepted.
2026-08-26 14:01:56 -07:00
LINUXexpert.org d7e9e94794 Merge pull request #68 from LINUXexpert-org/web-push
Notifications that arrive when ihasmail is closed
2026-08-26 13:54:18 -07:00
jcoffey-dev 96bc7b53d7 Notifications that arrive when ihasmail is closed
ihasmail's notifications came from EventSource, which lives exactly as
long as a tab does -- so "desktop notifications" has always quietly
meant "while you are looking". That switch is now labelled as much, and
a second one does the thing people assumed the first one did.

Stalwart 0.16 signs Web Push with VAPID (RFC 9749) and can put the
message itself in the payload (draft-ietf-jmap-emailpush). The server
pushes straight to the browser's own push service: ihasmail's server is
not in the delivery path, there is no relay to run, and nothing beyond
the browser vendor's endpoint that Web Push requires of everyone.

Checked against the live 0.16.19 before any of this was written, because
an advertised capability is not a configured one:

  - the session publishes a real applicationServerKey, so no key
    generation or server configuration is needed
  - PushSubscription/get answers an ordinary user rather than refusing
  - emailpush is advertised, and its draft defines a filter, an ordered
    properties list and an urgency -- so the payload can carry sender and
    subject, and the server drops properties from the end when it will
    not fit rather than failing the notification

Three things this gets right that are easy to get wrong:

  - The verification handshake. A JMAP subscription delivers nothing
    until the client echoes back a code the server pushed, and the
    service worker cannot answer it -- no credentials in that context.
    It forwards the code to a tab, or leaves it in the cache when no tab
    was open to forward it to.

  - Key encoding. The W3C Push API produces unpadded base64url and
    Stalwart 0.16 was fixed to accept exactly that, so nothing here pads
    on the way out. The VAPID key needs padding on the way *in* for
    atob; getting that backwards fails at subscribe() with an opaque
    error, so it lives in one named function with tests.

  - Sign-out. A subscription belongs to the account, not the session.
    Without tearing it down, a shared machine keeps notifying for a
    mailbox nobody is signed into -- which is somebody else's mail.

The mock models the JMAP half, including refusing padded keys and
non-https endpoints, and creating subscriptions *unverified*. Delivery
cannot be mocked -- it runs through the browser vendor's real push
service -- but a mock that marked a subscription verified on creation
would let a client ship without the handshake, and the symptom in
production is "registered, and silent".

Not verified end to end: an actual notification arriving. That needs a
real browser, a real push service and real delivery, so it is live
testing or nothing.
2026-08-26 13:51:09 -07:00
LINUXexpert.org e2f17f6f49 Merge pull request #66 from LINUXexpert-org/deploy-image-retention
Keep the last few images, and record delete-all-spam as verified live
2026-08-26 12:17:48 -07:00
jcoffey-dev e4915dd496 Keep the last few images, and record delete-all-spam as verified live
Every deploy tags an image with its version, which is what makes a
rollback a `docker run` rather than a rebuild -- and what has quietly
put 7.7 GB of them on the host, 4.3 GB of it reclaimable. `docker image
prune` will not help: they are tagged, which is the whole point of them.

The script that creates them now removes them, keeping the newest
IHASMAIL_KEEP_VERSIONS (three by default, 0 to keep everything).

Two things it is careful about, both learned from what could go wrong
rather than from something that did:

  - it runs only after the new container reports healthy, so a rollback
    target is never dropped while the thing meant to replace it is still
    unproven.

  - it excludes the image the container is actually running, by asking
    docker what that is rather than assuming it sorts newest. After a
    rollback it does not: the running image is an old one, and naive
    keep-the-newest-N would delete the image in use. Docker would refuse,
    but being refused is not the same as not having tried.

Exercised against a stubbed docker on PATH, so the pipeline and xargs
are the real ones: keeps 3 and removes the oldest, keeps 1 and removes
three, keeps everything at 0, never lists :current, and spares the
running image in a rollback where it is the fourth-newest.

Also records delete-all-spam as confirmed live on 0.16.19 today: Junk
Mail emptied and Deleted Items stayed empty afterwards, which is the
half a mock cannot prove and the half the feature exists for.
2026-08-26 12:15:51 -07:00
LINUXexpert.org e45c43100f Merge pull request #65 from LINUXexpert-org/junk-delete-all
Delete all spam, and call folders what the server calls them
2026-08-26 12:04:18 -07:00
jcoffey-dev 18e493bcd1 Delete all spam, and call folders what the server calls them
Junk Mail can now be emptied in one action, the way every other mail
client offers it: a banner across the top of the folder, and an item in
both the folder's right-click menu and the list's own menu.

The messages are destroyed rather than moved to Deleted Items. Routing
spam through the bin on its way out leaves you with the same problem in
a different folder, and "delete all spam" means gone everywhere else. So
the dialog says it before you commit, and there is no undo.

emptyMailbox already did the hard part -- walking a folder a page at a
time so it survives maxObjectsInSet, which a Deleted Items of 5192 once
did not. All that changed is which folders it will accept. The guard
stays in the store rather than living only in the menus, so a fourth
caller cannot empty the Inbox by asking nicely.

The three entry points share one helper, because three dialogs warning
about a permanent deletion in three slightly different ways is how one
of them ends up not warning at all. A folder with nothing in it offers
the item greyed out rather than hiding it, so it is where you expect it
to be next time.

Folder naming is fixed in the same commit because it changed the same
file, and because testing this is what surfaced it. Two problems, one
cause:

  - The mock called its folders "Trash" and "Sent". Stalwart's defaults
    follow the Exchange convention -- "Deleted Items", "Sent Items" --
    so anything built from a folder's name read differently against the
    mock than against a real server, and every screenshot in the README
    showed a folder list no user has.

  - Worse, and in shipping code: the undo toast took a hardcoded label
    in preference to the folder's actual name, so deleting a message
    announced "moved to Trash" on a server whose folder is called
    "Deleted Items", and reporting spam said "moved to Spam" where it is
    "Junk Mail". The one message whose job is saying where mail went was
    naming somewhere that does not exist. It now prefers the mailbox's
    own name and keeps the hardcoded word only as a fallback.

Verified against the mock: the banner appears only in Junk and only with
something to delete, the dialog counts and pluralises, the messages are
destroyed and Deleted Items stays empty afterwards, the banner
disappears once the folder is, the item greys out when empty, Archive is
offered neither, and Trash still says "Empty Deleted Items".
2026-08-26 12:00:42 -07:00
LINUXexpert.org 34eedfa9af Merge pull request #64 from LINUXexpert-org/theme-ihasmail
Add the ihasmail theme, and make it the default
2026-08-26 11:39:56 -07:00
jcoffey-dev 95e56303f7 Pin the theme settings as account-level, not per-browser
Both `theme` and `lastDarkTheme` sync, so a theme chosen on one machine
-- and the toggle's way back to it -- are the same everywhere. That is
already true, by the rule that DEVICE_KEYS is a list of exceptions and
anything else syncs by default, but nothing said so.

The existing test cannot say it: it derives what should sync from
DEVICE_KEYS, so moving one of these into that list would move the
expectation with it and still pass. These name the two keys outright.
2026-08-26 11:37:47 -07:00
jcoffey-dev 9689ac8aae Let the toggle come back to the theme you were on
The top-bar toggle went to light from anything dark, and back to plain
"dark" -- which quietly moved an ihasmail user onto a theme they had
never chosen, two clicks and no way to tell what had happened. It did
the same to "match system", which the toggle could not restore at all;
the comment above it conceded as much and sent people to Settings.

There is more than one way to be dark now, so the way back is
remembered: lastDarkTheme holds whichever non-light theme was last
chosen, and the toggle returns to that.

The remembering lives in update(), the single path every way of setting
a theme goes through -- the toggle, Appearance, an imported settings
file -- so a fourth way to choose one cannot forget to record it. Light
never overwrites it, since light is the side being toggled away from.

The button's label follows: "Switch to the ihasmail theme", "Switch to
your system theme", rather than claiming everything dark is "dark mode".

Confirmed in a browser, not only in tests: from a fresh profile the
round trip ihasmail -> light -> ihasmail returns to ihasmail, and
system -> light -> system returns to system, with the label naming the
destination each time.
2026-08-26 11:34:45 -07:00
jcoffey-dev 8487f561f6 Add the ihasmail theme, and make it the default
A dark theme carrying ihasmail.org's palette: a teal-navy ground rather
than the blue-slate of the plain dark theme, with the orange the logo's
cat is drawn in doing the work of the star and the warning colour. The
values are the site's own, read from its stylesheet rather than picked
by eye.

It is a theme rather than an accent because it changes backgrounds,
borders and text as well as the highlight -- an accent could not.

It rides on data-theme="dark" and adds data-palette="ihasmail" on top,
so the eleven dark-only rules further down the stylesheet keep applying
without being duplicated for a second dark theme. Specificity then does
something deliberate: the palette block is 0,2,0 and the accent variants
are 0,3,0, so a chosen accent still wins over it -- and because the
default accent ("teal") has no rule of its own, ihasmail.org's accent is
what shows until someone picks another. Verified both ways in a browser.

It is now what a new account starts on, so the app looks like itself
before anyone has chosen anything. Only a default: a stored theme always
wins, which leaves everyone already using ihasmail where they are, since
the setting is saved whether or not they deliberately picked it.

While here, the theme-color meta tag was fixed. There were two, both
carrying media attributes, and applyTheme looks for
:not([media]) -- so it matched neither and the browser chrome had never
followed the chosen theme at all, only what the OS preferred. One tag
now, updated from JS, starting at the default theme's background so the
first paint is right too.

Contrast measured rather than assumed, against the theme's own
background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link
9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better.
2026-08-26 11:28:47 -07:00
LINUXexpert.org 16351abdbf Merge pull request #63 from LINUXexpert-org/login-ihasmail-org
Rework the sign-in footer
2026-08-26 10:50:07 -07:00
LINUXexpert.org d90270b204 Merge pull request #62 from LINUXexpert-org/readme-live-verified
Nothing left pending in QA
2026-08-26 10:49:15 -07:00
jcoffey-dev 48805c8072 Split the sign-in footer over two lines
The build and where to find it were running together on one line. The
name and version now stand on their own, with the two links beneath:

    ihasmail v2.16.61
    ihasmail.org · AGPL-3.0 source

"by" is gone with the restructuring. It was doing attribution work that
the line no longer needs -- ihasmail.org is a LINUXexpert.org project
site, so pointing at it says the same thing without the preposition, and
the README badge is where the credit properly lives.

One <p> with a break rather than two paragraphs: .foot carries a 20px
margin-top, which a second one would repeat as a gap under the button.
2026-08-26 10:48:14 -07:00
jcoffey-dev ecc6c42bf1 Point the sign-in footer at ihasmail.org
The link went to linuxexpert.org, which is the organisation rather than
the project. Someone reading a sign-in page and following it wants the
software's own site, not its publisher's.

Checked that https://ihasmail.org answers 200 before putting it in front
of everyone who reaches the instance -- a dead link on a sign-in page is
worse than none.

The README badge still credits LINUXexpert.org, which is the right place
for attribution and was not part of this.
2026-08-26 10:43:11 -07:00
jcoffey-dev d0fbbbc44e Nothing left pending in QA
The four things this section still had outstanding were all exercised
against the live 0.16.19 on 2026-08-26:

  - read receipts, end to end -- assembled, uploaded, imported, submitted,
    landed in Sent, and $mdnsent set so a second look does not offer to
    send another. Only the mock had seen this before.

  - the rest of the scheduled-send journey: a hold expiring and being
    delivered, and the Scheduled folder reconciling on the way in -- a
    released message to Sent, a cancelled one back to Drafts. The hold
    itself was already confirmed on 2026-08-25.

  - Files rename, move and delete on the 0.16 path. What had been
    confirmed on 0.15.5 was the older code path, and that no longer
    exists, so this closes the entry rather than adding to it.

  - the settings file on the deployed instance, rather than only on a
    build that had not shipped yet.

The section keeps saying what was checked and when, and now says why:
it has been wrong before, when the 0.16 registry path was recorded as
verified live while a capability looked for in the wrong place meant it
had never run at all. What is left here is no longer a list of unknowns
but of things worth knowing -- where Stalwart departs from a spec, where
a setting has to be on for a feature to work, and what ihasmail
deliberately does not do.
2026-08-26 10:38:34 -07:00
LINUXexpert.org fd0fe43ef7 Merge pull request #61 from LINUXexpert-org/deploy-self-rewrite
Stop the deploy script rewriting itself mid-run
2026-08-26 10:29:56 -07:00
jcoffey-dev 82108ebd97 Stop the deploy script rewriting itself mid-run
Moving the deploy script into the repo put it inside the checkout it
resets, and bash does not read a script all at once -- it reads as it
goes, by byte offset. `git reset --hard` replacing the file underneath a
running shell makes it stop wherever it had reached.

Silently, and with exit status 0. A three-line demonstration:

    echo "line A"
    cat > "$0" <<'NEW'
    echo "REWRITTEN"
    NEW
    echo "line B"

prints "line A" and nothing else, and exits 0. In a deploy that means
building the image, then stopping before the container is replaced, and
reporting success -- so the old container keeps serving while everything
says the new one shipped.

It only bites when a deploy carries a change to the deploy script
itself, which is rare enough to be baffling when it happens and exactly
the sort of quiet failure this project keeps paying for.

The script now re-execs from a copy outside the tree before touching
git, so the file being run cannot change while it runs, and removes the
copy on exit. Running it from outside the checkout -- as the host did
before this moved into the repo -- skips all of that.

The trap is an `if` rather than `[ -n ... ] && trap`, which would leave
the not-re-exec'd path resting on errexit ignoring a failed left operand
of &&. It does ignore it, but a deploy script is a poor place to depend
on knowing that.

Verified: with the guard, a script that overwrites itself mid-run
completes every later line and cleans up its copy; without it, the lines
after the rewrite never run.
2026-08-26 10:26:58 -07:00
LINUXexpert.org ea83631609 Merge pull request #60 from LINUXexpert-org/deploy-example
Add a deploy script, with no host in it
2026-08-26 10:23:02 -07:00
LINUXexpert.org 0613b5bf29 Merge pull request #59 from LINUXexpert-org/version-numbers
Give builds a version number
2026-08-26 10:22:28 -07:00
jcoffey-dev 2d72e870c4 Add a deploy script, with no host in it
The live deploy script has never been under version control, which makes
it the one part of the pipeline that can silently fall out of step with
the repo -- as it just did: it builds without --build-arg
IHASMAIL_VERSION, so every deployment would report 2.16.0 no matter what
was actually built.

This is that script with the host taken out of it. Every path, name,
port and volume is a variable with a default that describes the shape of
a deployment rather than any particular one, so what is published is the
logic and none of the topology. It follows Caddyfile.example and
nginx.example.conf, which are here for the same reason.

Nothing sensitive was in the original either -- it never reads the
environment file, only hands the path to docker run --env-file -- but
the absolute paths named a user and a directory layout, and there is no
reason for those to be public to buy version control over the guards.

Two things it does that the original did not:

  - passes the version to the build, which is the whole reason this came
    up. A Docker tag may not contain "+", which a version for a commit
    that arrived outside a pull request does (2.16.57+g1fa6578), so the
    tag turns it into "-" while the build is told the real form. About
    and /api/health still report it correctly.

  - tags each build with its own version as well as :current, so rolling
    back is running the previous tag rather than rebuilding it. The
    failure path lists what is there to go back to.

Exercised against a throwaway clone, container, volume and image:
the hold guard refuses a held commit that production does not already
carry and lets one through that it does; the confirmation guard refuses
to run over a pipe without --yes; a dry run stops before building; and a
full run built, replaced the container and reported healthy at
2.16.57+g0549a09 -- from an image tagged 2.16.57-g0549a09, which is the
sanitising working.
2026-08-26 10:18:44 -07:00
jcoffey-dev 4618f7656e Show the version on the sign-in page too
It was only on the About page, which is behind a sign-in -- so the one
place a version is most often wanted, when something is wrong and nobody
can get in, was the one place it could not be read.

It sits next to the AGPL source link deliberately. Section 13's offer is
for the source of *this* build, and naming the build is what turns that
into something a person can act on rather than a link to whatever main
happens to be. A bug report can name the build without signing in, too.

Worth being deliberate about: this is pre-authentication, so anyone who
can reach the instance can read it. That is a real disclosure -- it tells
an unauthenticated visitor exactly which build to look up. It is being
accepted rather than overlooked: ihasmail is AGPL with its source already
linked from that same line, so the version narrows nothing that reading
the source would not, and the sign-in page already names the software.
2026-08-26 10:16:59 -07:00
jcoffey-dev bf70ba9df0 Give builds a version number
ihasmail called itself "2.0" on the About page and "2.0.0" from
/api/health, both hardcoded, in four places that had drifted from each
other and from anything meaningful. A build now says what it is:

  ihasmail v2.16.57
             |  |  |
             |  |  the pull request the commit came from
             |  the Stalwart generation this build targets -- 0.16
             ihasmail's own major

The first two are the version in the root package.json, so there is a
single place to bump them, and 16 becomes 17 when ihasmail moves to
Stalwart 0.17. Dropping 0.15 is what makes that middle number honest:
while two generations were supported it could not have been either.

The pull request number comes from git at build time and is never
written back into the tree. It cannot be: it does not exist until the
pull request has merged, so a committed version would always describe a
merge that had not happened yet, and every open branch would collide on
the same line. A commit that did not come through a pull request carries
the last number plus its own short SHA -- 2.16.57+g1fa6578 -- which says
it is past that pull request rather than quietly claiming to be it.

.dockerignore excludes .git on purpose, so an image build cannot work
any of this out. It takes --build-arg IHASMAIL_VERSION instead, which
the build stage bakes into the bundle and the runtime stage keeps as an
environment variable for the server. Left out, it falls back to the base
version from package.json rather than failing -- so a version with no PR
number on it means whoever built the image did not pass one.

scripts/ is copied into the runtime image because the server resolves
its version through it. There is no git in there to ask, which is the
fallback's whole purpose.

Verified: 2.16.57 in the bundle and from /api/health on a dev checkout;
the same after a real docker build --build-arg, from inside the
container; and 2.16.0 rather than a crash when the arg is left off.

Note for deploying: ihasmail-deploy.sh on the host builds without the
argument and will produce 2.16.0 until it passes
--build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)".
2026-08-26 10:00:29 -07:00
LINUXexpert.org 2a741f6407 Merge pull request #57 from LINUXexpert-org/drop-stalwart-0.15
Drop Stalwart 0.15 support
2026-08-26 09:52:52 -07:00
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00
LINUXexpert.org f41e3d2631 Merge pull request #58 from LINUXexpert-org/ci-workflow-dispatch
Let CI be started by hand
2026-08-26 09:47:28 -07:00
LINUXexpert.org d739ad625d Merge pull request #56 from LINUXexpert-org/readme-settings-sync-verified
Record the settings file as verified live
2026-08-26 09:46:44 -07:00
jcoffey-dev 8e02300000 Let CI be started by hand
CI triggers on a push to main and on pull requests, and on nothing else.
That left no way to put a check on 7a1e5ee -- the commit production is
running -- after GitHub's Actions outage on 2026-08-26 orphaned every run
created during it.

Those runs are not merely slow. GitHub accepted them, allocated zero
jobs, and left them in a state its own API contradicts itself about:

  gh run rerun   ->  "cannot be rerun; This workflow is already running"
  gh run cancel  ->  "Cannot cancel a workflow run that is completed"
  gh api         ->  status=queued, conclusion=null, jobs=0

Neither recoverable nor clearable, and with no manual trigger the only
remaining option would have been an empty commit pushed to main to move
the ref -- which is both a junk commit and against how changes land here.

workflow_dispatch also covers the ordinary case of wanting a check on a
commit that predates a CI change.
2026-08-26 09:45:33 -07:00
jcoffey-dev 326c122231 Record the settings file as verified live
Settings moved into the account's JMAP Files in #55, which shipped to the
live 0.16.19 today. The README carried the feature but not what had
actually been checked against a real server, which is the distinction
the Known issues section exists to keep.

Confirmed live on 0.16.19 (2026-08-26): settings set in Chrome came back
on a fresh login in Firefox and in an incognito session. Both start with
an empty localStorage, so each of them read the account's file rather
than anything cached locally -- which is the whole claim.

That incidentally exercised part of the 0.16 Files path the section had
flagged as still wanting a look on its own terms: finding and creating
the folder, creating a node with nodeType, uploading and downloading its
blob, and pointing an existing node at a new one. Rename, move, delete
and the Files view itself are still unchecked there, and the entry now
says so rather than letting the tick spread further than the evidence.
2026-08-26 08:51:22 -07:00
LINUXexpert.org 26a017f1c4 Merge pull request #55 from LINUXexpert-org/settings-server-side
Keep settings with the account, not the browser
2026-08-26 08:24:03 -07:00
jcoffey-dev 0a9218f622 Keep settings with the account, not the browser
Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.

localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.

Not everything should follow the account. A list-pane width picked on a
27" monitor is wrong on a laptop, and the notification toggles track a
permission the browser grants per-device, so claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.

Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.

The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.

Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
2026-08-26 08:19:57 -07:00
LINUXexpert.org f9f442072b Merge pull request #53 from LINUXexpert-org/screenshots-refresh
Retake the screenshots on the new logo
2026-08-25 15:53:04 -07:00
jcoffey-dev 5a87a101c3 Retake the screenshots on the new logo, and refresh the README header
Every screenshot still showed the old mark in the topbar -- squashed,
with an illegible ".com" smudge under it. All seven are retaken against
the mock server at the original framing (1420x703, mobile 500x703).

The four Sieve rules in filters.jpg are not seeded by the mock, so they
are rebuilt through the rule builder to match the previous shot:
Newsletters, From the boss, Receipts, Build failures.

Date format is pinned to the locale default so the list reads "Aug 24"
as it did before, rather than the "24.08." the stored preference had
drifted to.

README: the header logo drops 180 -> 150 wide, since the mark alone is
portrait where the old artwork was landscape, and the note about the
prototype now says the logo has lost its wordmark too.
2026-08-25 15:47:50 -07:00
LINUXexpert.org 046f8b7e58 Merge pull request #52 from LINUXexpert-org/logo-drop-com-wordmark
Drop the .com wordmark from the logo
2026-08-25 15:34:58 -07:00
jcoffey-dev c81e4f1aa9 Drop the .com wordmark from the logo
The logo baked "ihasmail.com" into the artwork, which is the wrong
identity for a project that is not the hosted instance -- and at the
34px the topbar renders it at, the wordmark was an illegible smudge
under a squashed cat.

logo.png, icon-512.png, icon-192.png and apple-touch-icon.png are now
the mark alone. favicon-64.png, icon-maskable.png and favicon.ico
already were, and are untouched.

Cropping needed a threshold: both source files carry a band of
near-invisible pixels (alpha 1-10) roughly 40px wide down the left
side, so a plain getbbox() crop leaves the mark sitting off-centre.
The bounding box is taken at alpha > 8 instead.

The login page had no name of its own -- it relied on the wordmark --
so it gets one as text, styled like the topbar's. Its screenshot is
retaken; the rest still show the old mark in the topbar.
2026-08-25 15:32:17 -07:00
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
jcoffey-dev ccf9fa34f0 Record that the registry path has now met a live 0.16.19
Self-service credentials work against the real server: password changes, 2FA
and app passwords, over the registry rather than the REST endpoint 0.16
removed. That also settles the generation lookup, which About and Files read
through the same helper.

Files is the one thing this does not settle. The earlier live run recorded
against 0.16.19 exercised the pre-0.16 path -- correct behaviour for what
ihasmail then believed the server to be, but not the path it takes now. Said
so, rather than letting an old confirmation stand for a different code path.
2026-08-24 23:02:58 -07:00
LINUXexpert.org 5393524dc7 Merge pull request #21 from LINUXexpert-org/read-receipts
Send the read receipt the sender asked for
2026-08-24 23:01:03 -07:00
jcoffey-dev 46abc1c083 Drop S/MIME and OpenPGP from the roadmap
Not worth the cost right now. Removing it rather than leaving it sitting
there unstarted, so the list says what is actually intended.
2026-08-24 22:59:15 -07:00
jcoffey-dev 3310149fcc Send the read receipt the sender asked for
JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does
not implement it, so ihasmail assembles the RFC 8098 multipart/report itself
and sends it the long way round: raw MIME uploaded as a blob, imported,
submitted. That is also why the receipt lands in Sent, which is where it
honestly belongs.

The plumbing is the easy half. A receipt tells whoever asked that the address
is live and when the message was read, to an address the sender chose, so the
refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two
servers answer each other forever), nothing carrying Precedence bulk/list/junk
or a List-Id, nothing already acknowledged, nothing that never arrived. A
receipt aimed anywhere other than the sender is offered, but says so first.
There is no "always send" setting, only ask or never.

Sending is recorded with RFC 3503's $mdnsent keyword on the original rather
than remembered locally, so a second look -- or another client entirely --
knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so
nothing rests on 8BITMIME surviving every hop.

Verified against the mock end to end: the blob uploads, the receipt imports
and submits, and the original reads back marked. Not yet exercised against
the live server.
2026-08-24 22:53:06 -07:00
jcoffey-dev be75a02181 Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet
in the same two files. The mock keeps `urn:stalwart:jmap` out of the
session-level capabilities and hands it out per-account, as a real server
does, while the submission capability it grew for scheduled send lives
per-account beside it; the client keeps both accessors, one asking whether a
capability is advertised anywhere and one reading the object itself.
2026-08-24 22:24:13 -07:00
jcoffey-dev 14125a0799 Look for Stalwart's capability where Stalwart advertises it
Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.

So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.

Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.

The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.

Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
2026-08-24 22:21:26 -07:00
jcoffey-dev e720623895 Hold a message in the server's queue until the time you asked for
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.

Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.

A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.

The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.

Verified end to end against the mock; not yet against the live server.
2026-08-24 22:07:29 -07:00
jcoffey-dev 03b5a6c388 Say which Stalwart the live instance runs, and credit the tool that moved it
The README described the deployment as 0.15.5 in four places. It has run
0.16.19 since 2026-08-25, which matters here because ihasmail supports both
generations of Stalwart and they are less alike than the version numbers
suggest: 0.16 replaced the REST management API with JMAP registry objects,
changed the shape of FileNode, split its rights up, and moved configuration
into the store.

Both backends have now met a real server of their own generation. The
registry paths for credentials and Files had only ever met the mock, and are
now exercised against the live 0.16.19: app passwords created and revoked,
password changed, 2FA switched on and off with the browser session surviving
the swap to an app password, and Files through folder creation, upload,
rename, move and delete. That closes the gap, and it was worth closing - the
0.16 half was resting on a mock we wrote ourselves, which is the arrangement
that let four bugs through on the 0.15 side.

Each known-issues entry now says which generation it was proven against
rather than implying one level of assurance across both. The account locale
is confirmed working on 0.16; on 0.15 neither method it can use was
reachable, so it had always fallen back to the browser.

Also records what did the upgrade. stalwart-migrator is a companion project,
and the 0.15 to 0.16 move is genuinely treacherous by hand - the store is
migrated in place with no way back, and Stalwart's own converter drops
settings without reporting them - so a reader running 0.15.x has a real
reason to want the link.
2026-08-24 17:38:39 -07:00
LINUXexpert.org 10331e14d9 Merge pull request #20 from LINUXexpert-org/empty-trash-batching
Empty a folder in batches the server will accept
2026-08-24 12:57:24 -07:00
jcoffey-dev 7095968282 Empty a folder in batches the server will accept
Emptying Deleted Items back-referenced one Email/query straight into one
Email/set, so every id in the folder arrived in a single call. Stalwart
refuses the whole call over maxObjectsInSet with requestTooLarge, which
left a folder of 5192 messages impossible to empty at all.

Walk the folder a page at a time instead: the filter re-runs each pass,
so the next page is whatever is still there. A pass that destroys nothing
stops the loop and reports the server's error rather than spinning.

The same defect sat in two neighbours. Delete-forever on a large
selection sent every id in one Email/set, and mark-all-read fed up to
5000 ids into an Email/get that only echoed them back - past
maxObjectsInGet, and for no gain, since the query already returned them.
Both now page through the same ceiling, read from the session rather than
hardcoded.

The mock advertised maxObjectsInSet but never enforced it, so none of
this could fail in a test. It now rejects oversized get and set calls the
way Stalwart does.

While here, restrict emptying to Deleted Items. It was offered on Junk
too, where a permanent one-shot clear is harder to justify; Junk is now
select-all plus Delete, which takes the batched path.
2026-08-24 12:55:23 -07:00
LINUXexpert.org 0cb7404330 Merge pull request #19 from LINUXexpert-org/image-proxy-pinning
Connect the image proxy to the address it checked
2026-08-24 11:52:45 -07:00
jcoffey-dev 9d06dce473 Connect the image proxy to the address it checked
The proxy resolved a hostname, refused it if any answer pointed somewhere
private, and then handed the *hostname* to fetch — which resolved it again when
the socket opened. An attacker who controls the zone answers with a public
address the first time and 127.0.0.1 the second, and the check has been walked
straight past. It is the standard way an SSRF guard gets bypassed.

Resolve once and connect to that address: a `lookup` that returns what we
already approved, on `node:http`/`node:https` rather than fetch, since fetch
gives no say in how the socket is opened. Every redirect hop is re-checked and
re-pinned. TLS is unaffected — the certificate is still validated against the
hostname, which `servername` and the Host header carry.

Pooling had to go with it: sockets are keyed by host and port, not by the
address we pinned, so a connection opened earlier would be reused and the pin
never consulted. Found by a test, not by reading it back.

Also refuse two ranges the old check let through: IPv6 multicast, and the NAT64
prefix, which is a route into IPv4 space.
2026-08-24 11:27:14 -07:00
LINUXexpert.org e21944a031 Merge pull request #18 from LINUXexpert-org/audit-hardening
Harden four things the security audit turned up
2026-08-24 11:12:43 -07:00
jcoffey-dev c5f2e2c7f2 Harden four things the audit turned up
**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each
hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a
client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real
address>". Reading the leftmost entry, as we did, handed the caller a
rate-limit key they could change per request: unlimited password guessing
against a deployment that looks correctly configured. Read from the right
instead, skip hops that are themselves trusted proxies, and believe the header
only when the peer is one (loopback and the private ranges by default,
TRUSTED_PROXIES to be explicit).

**The upload cap was a suggestion.** It read content-length, which a chunked
request simply omits. Count the bytes through a stream, as the image proxy
already does.

**App password secrets were drawn with a modulo.** 256 is not a multiple of 33,
so the first 25 characters of the alphabet came up on 8 byte values and the
last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of
the alphabet rather than single characters, because a 7/8 skew is invisible
per character against the noise — and it does fail when the bias is put back.

**Upstream headers were relayed wholesale.** Anything the mail server set —
cookies, auth challenges, CORS grants — landed on our origin, where it means
something else. Allowlist what is actually wanted.
2026-08-24 11:10:15 -07:00
LINUXexpert.org 984ebc185d Merge pull request #17 from LINUXexpert-org/message-css-containment
Stop a message painting over the whole application
2026-08-24 11:07:54 -07:00
jcoffey-dev 6242d4dee1 Stop a message painting over the whole application
A shadow root scopes selectors, not layout. `position:fixed` in mail CSS is
still positioned against the viewport, and the containment meant to stop that
sat inside the shadow root as `.ihm-email-root { contain: content }` — in the
same tree as the message's own <style>, which is inserted after it and simply
overrides it. Any sender could cover the entire window with markup of their
choosing, inside our own origin: a ready-made place to ask for a password.

Verified in a browser: the message rendered at exactly the viewport size with
the maximum z-index.

Moving the containment onto the shadow host does not fix it — mail CSS reaches
the host through `:host`, and an `!important` there beats an `!important` from
the app's own stylesheet, because importance reverses tree order in the
cascade. An ancestor of the host is the one thing mail CSS has no selector
for, so the control goes on .message-body. `layout` rather than `paint`: it
makes the element a containing block for fixed descendants without clipping
tall messages.

The sanitizer now also turns fixed and sticky positioning static and defangs
`:host`, as a second line that does not depend on one CSS declaration.

Checked end to end against the real stylesheet afterwards: the same message
renders 1678x112 instead of 1720x1279.
2026-08-24 11:00:10 -07:00
LINUXexpert.org f29a504ead Merge pull request #16 from LINUXexpert-org/legacy-mock
Teach the mock to impersonate Stalwart 0.15
2026-08-24 10:27:04 -07:00
jcoffey-dev 0845c93c4c Teach the mock to impersonate Stalwart 0.15
MOCK_STALWART=0.15 (or npm run mock:legacy) switches the mock to the
generation before the registry. It is not a cut-down mock: it reproduces the
specific ways that generation differs, and every one of them is a thing the
server does not report as an error.

  - urn:stalwart:jmap is not a capability it knows, and naming one it cannot
    parse fails the whole request rather than the one call
  - x: methods do not exist; credentials live at POST /api/account/auth
  - FileNode/query masks out containers, so it returns files and never folders
  - FileNode has no nodeType, and rights are only mayRead/mayWrite/mayShare

Both modes now also enforce the 2047-byte signature cap, and `using` is
validated in both — the gap that let the Identity capability bug in #12 ship.

This gives the legacy credential adapter its first automated coverage: it was
the least-tested code here, checked only by hand against the live server. The
new tests also pin the mock's own fidelity, so it cannot quietly drift back to
being 0.16-shaped in the places that matter.
2026-08-24 10:23:39 -07:00
LINUXexpert.org 88885f316a Merge pull request #15 from LINUXexpert-org/signature-byte-limit
Make signatures and Files work on Stalwart before 0.16
2026-08-24 10:13:30 -07:00
jcoffey-dev dab4808baa Record the live Files and signature verification
Both entries had been sitting as pending QA. Everything they were waiting on
has now run against the live 0.15.5 server: oversized, non-ASCII and
inline-image signatures (with a test message reaching Gmail intact), and folder
creation, listing, upload, rename, move and delete in Files.

The Files entry now describes what actually differs before 0.16 — the query
that cannot see folders, the missing nodeType, the coarser rights — since all
three were found the hard way and none of them surfaces as an error.
2026-08-24 10:11:08 -07:00
jcoffey-dev 8d90bca6b4 List Files through get, because query cannot see a folder before 0.16
Creating a folder on the live 0.15.5 server did nothing visible, with no error
and nothing after a reload. The folder was real all along: FileNode/query masks
its results with document_ids(false) — resources that are *not* containers — so
it returns files and never folders, and says nothing about the omission.

FileNode/get carries no such mask, so on those servers the whole tree comes
from a single get with ids:null instead. That also stops ensureFolder making a
fresh "ihasmail" folder on every signature save, having never been able to find
the one already there.
2026-08-24 10:05:08 -07:00
jcoffey-dev 5befef7ed7 Translate the older FileNode rights, so Rename and Delete work again
Deleting a file did nothing on the live 0.15.5 server, with no error: the menu
items are gated on myRights.mayDelete and myRights.mayRename, and 0.16 was the
release that split rights up. Before it a node carried mayRead, mayWrite and
mayShare, with the one mayWrite covering everything the newer release names
separately — so both items sat permanently disabled.

Widen mayWrite into the four rights the newer shape names, alongside the
nodeType normalisation, and the UI can keep reading the 0.16 vocabulary.
2026-08-24 09:56:53 -07:00
jcoffey-dev 49ea07eeaa Stop sending nodeType to servers that have no such property
Uploading a file or creating a folder failed on the live 0.15.5 server with
`invalidProperties (nodeType)`. The property arrived in Stalwart 0.16; before
that a FileNode has no nodeType at all, and the create is refused outright.

Older servers tell a file from a directory a different way: the node carries
file properties or it does not. Setting blobId, size or type — even to null —
makes it a file, so a directory there is exactly parentId plus name.

0.16 is also the first release to advertise urn:stalwart:jmap and no earlier
one knows that capability, so its presence stands in for "has the newer
FileNode shape". Creates, and the property lists we ask for, are shaped from
that.

The read side needed it too: a server that never reports nodeType would have
had every folder drawn with a file icon, sorted among the files and opening as
a download. Nodes are normalised as they arrive, so everything downstream can
still just read nodeType.
2026-08-24 09:42:25 -07:00
jcoffey-dev 0057fce558 Measure signatures in bytes, so the oversize fallback actually saves
Stalwart accepts a signature of `value.len() < 2048`, and that is Rust's len():
2047 bytes of UTF-8. Every check here counted JavaScript `.length` instead,
which is UTF-16 units and agrees only for ASCII — an accent is one unit and two
bytes, CJK three, an emoji two units and four.

That alone would let a non-Latin signature we judged to fit come back rejected.
But the fallback that is supposed to rescue an oversize signature was broken
outright, for everyone: it truncated to `budget - 1` characters and appended an
ellipsis, one character but three bytes, so the result was always 2047
characters and 2049 bytes. Every marker signature Stalwart was ever offered was
two bytes too long, ASCII included. That is why this flow has been sitting in
the README as implemented but unconfirmed — the first person to exceed 2 KB
would have hit it.

Cutting the source text and rendering afterwards, rather than slicing the
rendered string, also means a cut can no longer land inside an HTML entity, and
stepping through code points means it cannot split a surrogate pair.

The old tests used ASCII only, which is how this survived; the new ones weigh
the encoded form.
2026-08-24 09:32:39 -07:00
LINUXexpert.org 682b5d77ee Merge pull request #14 from LINUXexpert-org/self-service-credentials
Self-service credentials, plus the account-locale fix, server info and a theme toggle
2026-08-24 09:13:55 -07:00
jcoffey-dev f5d26a1f61 Finish the README pass: stale locale method, and the two new bits of UI
The Dates & times entry still named x:Account/get as where the default locale
comes from, which this branch changed. It also never mentioned the top-bar
light/dark toggle or what About now reports about the server.
2026-08-24 09:12:19 -07:00
jcoffey-dev 25dfe714cb Record what the live 0.15.5 run actually verified
The REST credential path is no longer untested: password change, app passwords
and enabling and disabling 2FA were all exercised against the live server on a
real mailbox. The registry path is still mock-only.

Also correct the locale line. Both methods it can use are 0.16 ones, so on an
older server neither is reachable and the browser locale still wins — the fix
helps 0.16+ users, and the entry should not imply otherwise.
2026-08-24 09:08:43 -07:00
jcoffey-dev 0fdcbbc778 Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00
jcoffey-dev c8fe822586 Treat an unreadable backend probe as the older server, not an error
Stalwart before 0.16 does not know urn:stalwart:jmap, and rejects the whole
request rather than the one call when `using` names a capability it cannot
parse. The probe is only sent when the session advertises that capability, so
this should not arise — but if it ever does, throwing turns a server we can
still manage credentials on into a Security page that only shows an error.
Fall through to the endpoint those servers do have.
2026-08-24 08:41:15 -07:00
jcoffey-dev c145858bbe Read the locale where users can actually read it, and say which Stalwart answered
The account locale came from `x:Account/get`, which needs `sysAccountGet` — a
permission the built-in `user` role is not given, so the setting silently fell
back to the browser locale for exactly the people most likely to have set it.
Stalwart 0.16 carries the same field on `x:AccountSettings`, whose
`sysAccountSettingsGet` *is* part of that role. Both are now asked for in one
request and whichever answers wins, so admins and older servers keep working.

That pair of replies also says which generation we are talking to: only 0.16+
can parse the method name at all. About now reports that, plus the edition
from /api/account where the server offers it. It does not report a version
number because Stalwart does not publish one to clients — it hardcodes a
public "1.0.0" and keeps the real version to its SMTP internals — so the
screen says what was actually detected rather than inventing precision.

Also adds a light/dark toggle to the top bar, left of the settings button. The
stored setting is three-way, so the button acts on the theme actually on
screen: whichever one you see, a click gives you the other. Choosing "match
system" again stays in Settings › Appearance, where a three-way choice belongs.
2026-08-24 08:35:22 -07:00
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.

Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.

Two problems are worth calling out, because both would bite a user hard:

Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.

Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.

Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.

The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
2026-08-24 08:18:47 -07:00
LINUXexpert.org cea7545481 Merge pull request #13 from LINUXexpert-org/identity-submission-capability
Ask for the submission capability when using identities
2026-08-24 06:37:11 -07:00
jcoffey-dev a3fd236c36 Ask for the submission capability when using identities
Identity is defined by RFC 8621 under urn:ietf:params:jmap:submission, not
under mail. ihasmail asked for mail alone, so Stalwart 0.16 rejected both
Identity/get and Identity/set with unknownMethod: no identities were ever
listed, none could be created, and sending then failed with "No sending
identity available". Older Stalwart builds accepted the calls anyway, which
is why this went unnoticed.

Also filter `using` down to the capabilities the session actually advertises.
A server must reject the entire request with unknownCapability when `using`
names something it does not implement, so one over-eager urn would take down
every call sharing the batch — including, on a server predating the submission
capability, the mailbox and message loads batched alongside an identity fetch.

Fixes #12
2026-08-24 06:31:37 -07:00
LINUXexpert.org cfe5595882 Fix version number in known issues section
Updated known issues section with corrected version number and additional details.
2026-08-23 21:37:47 -07:00
LINUXexpert.org f63b28d54b Merge pull request #11 from LINUXexpert-org/contact-from-message
Add contacts by right-clicking anyone named in a message
2026-08-23 15:19:19 -07:00
jcoffey-dev d143e711d4 Add contacts by right-clicking anyone named in a message
Right-clicking a sender, or any address in the message details, opens a menu
offering to add that person to the address book - plus edit them when they are
already known, write to them, or copy the address.

"Add to contacts" opens the contact editor prefilled rather than saving
silently, so the address book gets a real card that the user can complete,
not a bare email address. contactFromAddress splits the display name into
JSContact name components: "Ada Lovelace" into given and surname, "Lovelace,
Ada" unpicked, a single word as the given name, and a name that is really
just an address left off entirely.

Addresses in the details block were joined into one string, so they are now
rendered per address to be individually targetable.

ContactEditor previously ignored a prefilled name on an unsaved card - it read
name components only when the card had an id - so it now reads them either
way.
2026-08-23 14:46:38 -07:00
LINUXexpert.org 40f0ad5fdb Merge pull request #10 from LINUXexpert-org/fix-subject-focus
Stop the composer stealing focus while the subject is typed
2026-08-23 14:17:24 -07:00
jcoffey-dev 8faf9002c2 Stop the composer stealing focus while the subject is typed
The body editor was told to focus itself with

    autoFocus={d.to.length > 0 && Boolean(d.subject)}

and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.

autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.

initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.
2026-08-23 14:15:28 -07:00
LINUXexpert.org b4d89e94dc Merge pull request #9 from LINUXexpert-org/set-error-detail
Fix sending: never send null for an empty header property
2026-08-23 14:02:32 -07:00
jcoffey-dev 5575d540b8 Fix sending: never send null for an empty header property
Every message ihasmail sent set cc, bcc and replyTo to null when unused, and
inReplyTo/references likewise on a new message. Stalwart parses those
properties with try_into_address_list, which returns None for null, and the
create is rejected outright:

    if let Some(addresses) = value.try_into_address_list() { ... }
    else { response.invalid_property_create(id, header); continue 'create; }

So every send failed with "Invalid property or value.", new messages and
replies alike, regardless of attachments or signature. The mock server
accepts anything, which is why this only showed up against a real server.

Empty header properties are now omitted. On a create there is no previous
value to clear, so null was never needed - only the properties actually being
set belong in the object.

buildEmailObject is exported so the shape can be tested directly, with a
regression test that no property is ever null.
2026-08-23 13:59:33 -07:00
LINUXexpert.org ef1b52030b Merge pull request #8 from LINUXexpert-org/remove-live-hostname
Keep the live mail host out of the repo
2026-08-23 13:57:10 -07:00
jcoffey-dev f09566b56e Keep the live mail host out of the repo
Replace the hard-coded mail.inbuxa.com with generic placeholders: the config
default and .env.example use mail.example.com, the README stops naming the QA
host, and docker-compose now requires STALWART_URL to be set rather than
defaulting to somebody's real server.

Nothing deployed depends on the old default - the running container passes
STALWART_URL explicitly.
2026-08-23 13:55:17 -07:00
jcoffey-dev e05880eefc Say which property a JMAP SetError rejected
"Send failed: Invalid property or value." is Stalwart's description for
invalidProperties, and on its own it says nothing about what to fix. The
SetError also carries a `properties` array naming the offending fields, which
every call site was discarding.

setErrorMessage appends them, and the 35 places that surfaced a SetError -
send, save draft, mailboxes, calendars, contacts, sieve, files, signature
images, sharing - now go through it.
2026-08-23 13:51:02 -07:00
LINUXexpert.org 55af4eab8f Merge pull request #7 from LINUXexpert-org/message-theme-option
Let messages follow the app theme, at the user's choice
2026-08-23 13:36:38 -07:00
jcoffey-dev c3cecf9916 Let messages follow the app theme, at the user's choice
Messages render on a white card in every theme. That is deliberate for mail
that styles itself, but #4 points out the case it gets wrong: a message with
no styling of its own has nothing worth preserving, and flashing white at
someone reading in the dark is a real cost.

Appearance gains a switch under the theme cards, off by default so the
current behaviour is unchanged. With it on, HTML mail that declares no
colours follows the app theme; mail that sets a background or text colour
still gets the light card it was designed for, because half-darkening someone
else's design is worse than leaving it alone. Plain-text mail already
followed the theme and is untouched by the switch.

The themed palette is expressed in the app's own custom properties, which
cross the shadow boundary, so switching theme repaints open messages without
re-rendering them, and the accent-coloured link stays consistent. The host
element takes color-scheme: inherit so form controls and scrollbars inside a
message match too.

htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or
background declarations in style attributes and <style> blocks, while
ignoring near-misses like border-color and ?color= in a URL.

Closes #4
2026-08-23 13:34:24 -07:00
LINUXexpert.org a89fc2b26e Merge pull request #6 from LINUXexpert-org/default-mail-handler
Offer ihasmail as the browser's mailto: handler
2026-08-23 13:29:21 -07:00
LINUXexpert.org 462721ce7f Merge pull request #5 from LINUXexpert-org/custom-date-time-pickers
Custom date and time pickers that follow the configured format
2026-08-23 13:27:02 -07:00
jcoffey-dev 2c23ea980b Offer ihasmail as the browser's mailto: handler
Settings > General gains a "Default mail app" section that calls
registerProtocolHandler so mail links anywhere in the browser open ihasmail.
The browser owns the decision and there is no API to read it back, so the UI
says what it can: it records that we asked, offers "Ask again", shows a
Remove button where unregisterProtocolHandler exists, and points at the
browser's own settings. Unsupported browsers (Safari) and insecure contexts
get an explanation instead of a dead button.

The manifest now declares protocol_handlers for mailto, which is the route by
which an *installed* app can be offered by the operating system itself; the
UI says so and links the two ideas rather than promising a system-wide
default the page cannot grant.

Mailto parsing is now one function (parseMailto in lib/address.ts) instead of
three hand-rolled copies in AppShell and MessageView. It follows RFC 6068:
recipients from the path, the to= header or both, case-insensitive headers,
"+" as space, and tolerant of malformed escapes. That fixes Cc and Bcc being
silently dropped, and draftFromMailto escapes the body so a mailto: URL from
an untrusted page reaches the composer as text rather than markup.
2026-08-23 13:21:50 -07:00
jcoffey-dev 2518605126 Custom date and time pickers that follow the configured format
Browsers render <input type="date"> and datetime-local in their own locale and
ignore the page's, so #1 left a German user on an English browser reading
22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget.
#3 makes the case that people use the picker rather than typing, which is
where the AM/PM mistakes happen.

New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine
native controls — event editor (all-day and timed start/end, recurrence
until), out-of-office, contact birthday, advanced search. They take and emit
the same ISO strings the native inputs did, so call sites barely changed.

Each is a text box in the configured order plus a popover: a month grid
(week start from settings, locale weekday and month names, today and the
selection marked) and, for date-times, a list of times in the configured
clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End
across the week, Enter picks, Escape closes, ArrowDown opens; the focused day
holds DOM focus so screen readers follow, and the dialog has an accessible
name (Popover gained an ariaLabel prop).

Text entry is lenient — the configured order with any separator, unseparated
digits (221125), day and month alone, non-Latin digits, and bare ISO always;
times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur
rather than clearing the field, and impossible dates like 31 February are
rejected instead of rolling into March.

Editable boxes stay Gregorian and Latin-digit even where display does not
(fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a
Buddhist-era year in a text box cannot round-trip against a Gregorian grid.
Noted in the README.

The out-of-office format echo added in #2 is gone — the fields now show the
right format themselves.

Closes #3
2026-08-23 13:12:17 -07:00
LINUXexpert.org c8fab0dd81 Merge pull request #2 from LINUXexpert-org/locale-date-time-formats
Configurable date and time formats, defaulting to the Stalwart locale
2026-08-23 12:43:36 -07:00
jcoffey-dev d82ff15921 Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by
three settings (Settings > General > Locale):

- Language & region: automatic, or any of the 618 locales CLDR has data for,
  each named in its own language and script (web/src/lib/locales.ts, generated
  by probing Intl over the subtag space).
- Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025,
  or ISO 8601 2025-11-22.
- Time format: automatic (locale), 24-hour, or 12-hour.

Automatic takes the locale Stalwart has for the account, read best-effort at
login via x:Account/get (urn:stalwart:jmap) and passed to the client in the
session; servers without the capability, or that deny sysAccountGet to a
regular user, fall back to the browser locale. POSIX forms are normalised
(de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS,
uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are
dropped and a script the locale already implies is not appended.

Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under
ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so
one line never mixes digit systems.

Rewired: message list and headers, quoted reply headers, calendar (titles,
weekday and hour gutters, mini calendar, agenda, popovers, invite cards,
free/busy), contacts, files, sessions. No raw toLocale*String date calls are
left in web/src.

Native <input type="datetime-local"> pickers always follow the browser locale
and cannot be restyled by a page, so the out-of-office fields echo the entered
instant in the chosen format underneath.

Also: month-grid day labels no longer wrap when they hold a date, and the mock
server serves x:Account/get (MOCK_LOCALE, default en_US).

Closes #1
2026-08-23 12:32:11 -07:00
LINUXexpert.org 00a57eabec Update README with new server version 2026-08-23 02:12:22 -07:00
jcoffey-dev de1b33e2aa Folder pane: align icons, mark-read incl. subfolders
- Move the expand chevron into a gutter left of the folder icon so folders
  with and without subfolders line up on their icon; labels share the column.
- Add "Mark all as read, incl. subfolders" to the folder context menu, with
  the affected count and a per-folder fallback for servers without filter
  operators.
- Re-measure the virtualised message list when row height changes.
- Mock: seed unread mail in a subfolder.
2026-08-23 01:34:15 -07:00
jcoffey-dev d079e2ad88 Update copyright holder to LINUXexpert.org 2026-08-23 01:25:18 -07:00
jcoffey-dev c99375bea6 Use verbatim GPL-3.0 text in LICENSE
The LICENSE file only carried the short "how to apply" notice, not the
license itself, so it wasn't a valid GPLv3 distribution and license
detection tools couldn't identify it. Replace it with the canonical
674-line GNU GPL v3 text (sha256 8ceb4b9e...), and move the project
copyright line plus the "version 3 or any later version" grant into the
README, which now matches package.json's GPL-3.0-or-later.
2026-08-23 01:15:29 -07:00
jcoffey-dev 645b8b510f ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00
235 changed files with 32014 additions and 1085 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
**/node_modules
**/dist
.git
.env
server/data
+47
View File
@@ -0,0 +1,47 @@
# ---- ihasmail server configuration ----
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
# the JMAP session at <STALWART_URL>/.well-known/jmap.
STALWART_URL=https://mail.example.com
# Random secret used to derive encryption keys for persisted sessions.
# Generate with: openssl rand -base64 48
APP_SECRET=change-me
# Listen address
HOST=0.0.0.0
PORT=8080
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
TRUST_PROXY=1
# Peers whose X-Forwarded-* headers are believed. Unset means loopback and the
# private ranges, which covers a reverse proxy on the same host or Docker
# network. A request from anywhere else is attributed to its socket address,
# whatever the headers claim -- otherwise anyone could pick their own key for
# the login rate limiter.
# TRUSTED_PROXIES=10.0.0.0/8,192.168.1.5
SECURE_COOKIES=auto
# Session lifetime (idle timeout) in seconds. "Remember me" extends to SESSION_REMEMBER_TTL.
SESSION_TTL=43200
SESSION_REMEMBER_TTL=2592000
# Where to persist sessions so restarts don't log everyone out (optional).
SESSION_FILE=./data/sessions.json
# Upstream timeouts / limits
UPSTREAM_TIMEOUT=30000
MAX_UPLOAD_BYTES=52428800
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
IMAGE_PROXY=1
# Branding
APP_NAME=ihasmail
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
# Lets CI be run by hand against any ref, including a specific commit.
# Without this there is no way to re-run a check that never started: a run
# GitHub queues and then orphans -- as it did to every run created during the
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
# nor cancelled ("already completed"), and the workflow has no other trigger
# to reach for. Useful too for putting a check on a commit that predates a CI
# change, without pushing an empty commit to move it.
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Docker build
run: docker build -t ihasmail:ci .
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
.DS_Store
server/data/
.vite/
coverage/
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+84
View File
@@ -0,0 +1,84 @@
# Contributing to ihasmail
Thanks for your interest in contributing to **ihasmail** — a Gmail-style, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
## Code of Conduct
By participating in this project, you agree to treat other contributors with respect. Be constructive, be patient with newcomers, and keep discussion focused on the project. Harassment or abusive behavior toward other contributors will not be tolerated.
## Before You Start
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first.
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
## How to Contribute
### Reporting Bugs
Before opening a new issue, please search [existing issues](https://github.com/LINUXexpert-org/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
- A clear, descriptive title
- Steps to reproduce the issue
- Expected behavior vs. actual behavior
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.)
- Relevant logs, console errors, or screenshots
- Whether the issue is reproducible against a fresh Stalwart instance
### Suggesting Features
Open an issue describing:
- The problem you're trying to solve (not just the solution)
- How it fits with ihasmail's JMAP-only, Gmail-style design philosophy
- Any relevant JMAP RFC references (RFC 8620, RFC 8621) if the feature touches protocol behavior
For larger changes, please open an issue to discuss the approach **before** submitting a pull request — this saves everyone time if the direction needs adjusting.
### Submitting Pull Requests
1. **Fork** the repository and create your branch from `main`.
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
4. **Write clear commit messages** describing what changed and why.
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle.
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
7. **Open the pull request** against `main`, filling out the PR template with:
- A summary of the change
- Related issue number(s), if any
- Screenshots/GIFs for UI changes
- Any manual testing you performed
### Code Style
- Match the existing formatting and naming conventions used elsewhere in the codebase.
- Keep functions small and single-purpose where practical.
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
### Development Setup
1. Clone your fork:
```bash
git clone https://github.com/YOUR-USERNAME/ihasmail.git
cd ihasmail
```
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox).
3. Follow the setup instructions in the repository's `README.md` for installing dependencies and running the app locally.
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
## Review Process
- A maintainer will review your PR and may request changes.
- Please respond to review feedback in a timely manner; PRs with no activity for an extended period may be closed and can be reopened once updated.
- Once approved, a maintainer will merge the PR.
## Reporting Security Issues
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
## Questions?
If you're unsure whether something is a good fit, open an issue and ask — discussion is welcome before you invest time in a PR.
Thanks again for helping improve ihasmail!
+9
View File
@@ -0,0 +1,9 @@
# Example reverse proxy (Caddy) in front of ihasmail.
# TLS is automatic. ihasmail sets Secure cookies and HSTS when X-Forwarded-Proto is https.
mail.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080 {
# Keep SSE (push) connections open
flush_interval -1
}
}
+41 -14
View File
@@ -1,16 +1,43 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# ---- build stage ----
FROM node:22-alpine AS build
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
# context on purpose, and git is not installed either. `node scripts/version.mjs`
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
# Left empty, the build falls back to the base version from package.json.
ARG IHASMAIL_VERSION=""
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
COPY pyproject.toml README.md /app/
RUN pip install --no-cache-dir -e .
COPY app /app/app
COPY .env.example /app/.env.example
ENV PORT=8000
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
# ---- runtime stage ----
FROM node:22-alpine AS runtime
# Re-declared: an ARG does not cross stages.
ARG IHASMAIL_VERSION=""
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080 \
STATIC_DIR=/app/web/dist \
SESSION_FILE=/data/sessions.json \
IHASMAIL_VERSION=$IHASMAIL_VERSION
WORKDIR /app
COPY package.json ./
COPY server/package.json server/
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
# set it never looks further; without it, it falls back to package.json rather
# than failing, since there is no git in here to ask.
COPY scripts/ ./scripts/
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
RUN mkdir -p /data && chown -R node:node /data /app
USER node
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
CMD ["node", "server/dist/index.js"]
+35
View File
@@ -0,0 +1,35 @@
# Known issues and pending QA
What was checked, against which server, and when. For a failure you are hitting
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
The live instance runs **0.16.19**, and as of **2026-08-26 there is nothing
left pending**: every entry below has been exercised against it. What remains
here is not a list of unknowns but of things worth knowing — where Stalwart
departs from a spec, where a setting has to be turned on for a feature to work,
and what ihasmail deliberately does not do.
Entries keep saying what was checked and when, because this section has been
wrong before: the 0.16 registry path was once recorded as verified live when a
capability looked for in the wrong place meant it had never run at all.
Some entries record what a live **0.15.5** proved before that server was
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
rather than about 0.15 — a byte cap that still applies, a flow that still
works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/LINUXexpert-org/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/LINUXexpert-org/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/LINUXexpert-org/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action``declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
+657 -12
View File
@@ -1,16 +1,661 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2025 John Coffey
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
Preamble
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-13
View File
@@ -1,13 +0,0 @@
.PHONY: run dev test build
run:
uvicorn app.main:app --host 0.0.0.0 --port 8000
dev:
uvicorn app.main:app --reload
test:
pytest
build:
docker compose build
+168 -41
View File
@@ -1,60 +1,187 @@
<p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
</p>
<p align="center">
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.19" src="https://img.shields.io/badge/Stalwart-0.16.19-6366f1?style=flat-square"></a>
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
<a href="https://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
</p>
# ihasmail
![ihasmail logo](app/static/img/logo.png)
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy.
Mail, calendars, contacts, files and filters in a responsive single-page app
that works equally well on a desktop monitor and a phone. It talks only JMAP
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
database.
A production-leaning, **FastAPI** + **HTMX/Jinja** webmail for [Stalwart Mail Server](https://stalw.art/), using **JMAP** for mail, contacts, and calendar, plus simple **WebDAV/CalDAV** helpers. Authenticates with the user's Stalwart mailbox (like Roundcube). Designed to run behind a reverse proxy.
| | |
| --- | --- |
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
## Features
- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided)
- Inbox listing, read messages (plain text), compose & send via JMAP (`Email`, `EmailSubmission`)
- Contacts/Directory via JMAP `Contact`
- Calendar view via JMAP `CalendarEvent`
- WebDAV browser (read-only sample) and CalDAV endpoints (external DAV clients)
- CSRF on POST, signed session cookie, proxy-friendly
- Dockerfile + docker-compose for easy deploy
This file is for people working *on* ihasmail. Everything about running it
lives in the docs.
> HTML rendering and attachment streaming are stubbed—extend using the JMAP `downloadUrl` and sanitize HTML before display.
## Screenshots
## Quick Start (Docker)
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
| | |
| --- | --- |
| **Inbox & conversation (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Composer** ![Composer](docs/screenshots/compose.jpg) | **Calendar** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** ![Filters](docs/screenshots/filters.jpg) |
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
## What's in it
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
## Requires Stalwart 0.16 or newer
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
and moved configuration into the store; supporting both generations meant a
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
reached production. With one supported generation a wrong guess is a loud error
on the first call.
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
## Quick start (Docker)
```bash
# 1) Configure environment
cp .env.example .env
# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET
# 2) Build & run
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
docker compose up --build -d
# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
```
## Environment Variables
- `APP_SECRET` random string for signing cookies (required)
- `JMAP_BASE` e.g., `https://mail.example.com/jmap`
- `CALDAV_BASE` e.g., `https://mail.example.com/caldav/`
- `WEBDAV_BASE` e.g., `https://mail.example.com/webdav/`
- `COOKIE_NAME` cookie name (default: `stalwart_webmail`)
- `TRUST_PROXY` `1` to honor `X-Forwarded-*` (default: `1`)
- `UPSTREAM_TIMEOUT` seconds for upstream HTTP (default: `15`)
Users sign in with their Stalwart mailbox credentials. **An account with
two-factor authentication needs an app password**, created in Stalwart's own
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
password grant, so no client holding a username and password can exchange them
plus a code for a token.
Full instructions, TLS, and every environment variable:
[Installing](https://docs.ihasmail.org/install/) ·
[Configuring](https://docs.ihasmail.org/configure/).
## Architecture
```
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
React SPA • session cookie ⇄ Basic auth
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
```
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
missing.
## Development
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
## Dev
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reload
pytest
npm install
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
npm run typecheck # tsc for both packages
npm test # vitest (web) + node:test (server)
npm run build # web/dist + server/dist
npm start # serve the production build
```
## Security & Hardening
- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken`
- Set explicit `accountId` from the JMAP session `primaryAccounts`
- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get`
- Sanitize HTML bodies (e.g., `bleach`) before rendering
- Add Sieve UI via `urn:ietf:params:jmap:sieve`
- Consider rate limiting and security headers in the reverse proxy
- Serve static assets via proxy/CDN
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
build. Running it for real is covered in
[Installing](https://docs.ihasmail.org/install/) and
[Configuring](https://docs.ihasmail.org/configure/).
### The mock
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised
**per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
the sign-in refusal can be tested.
### Version numbers
`ihasmail v2.16.84``2` is ihasmail's own major, `16` the Stalwart generation
this build targets, `84` the pull request the commit came from. The first two
live in the root `package.json`; the third comes from git at build time, since
it does not exist until the PR has merged. A commit that did not arrive through
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
```bash
node scripts/version.mjs # the version for the current checkout
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
```
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
out for itself — pass it in. Left out, the build falls back to the base version
from `package.json`, so a version with no PR number means whoever built the
image did not pass one.
### Deploying
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
introduced and asks, rebuilds with the right version baked in, replaces the
container, waits for healthy, then prunes all but the newest
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
```bash
./deploy.sh # origin/main, asks before shipping new commits
./deploy.sh --dry-run # run the guards and stop
./deploy.sh v2.16.84 --yes # a named ref, no prompt (there is no tty over ssh)
```
`--yes` does not override a hold; clearing one means deleting its line.
## Contributing
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
## License
GPL-3.0-or-later
Copyright (C) 2026 LINUXexpert.org — AGPL-3.0-or-later. See
[LICENSE](LICENSE).
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
nearly always run as a network service rather than handed to anyone as a binary,
and the AGPL's section 13 closes that gap.
That offer has to point at *your* source, not this one. If you run a modified
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
Settings About both show it. See
[Rebranding](https://docs.ihasmail.org/rebranding/).
+11
View File
@@ -0,0 +1,11 @@
# Roadmap / not yet
Things ihasmail does not do, and why. Anything with an issue number is tracked
in [the issue tracker](https://github.com/LINUXexpert-org/ihasmail/issues); the
rest is here because the answer is "no", not "not yet".
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
+53
View File
@@ -0,0 +1,53 @@
# Security Policy
## Supported Versions
ihasmail is under active development. Security fixes are applied to the latest release on the `main` branch. Older tags/releases are not guaranteed to receive backported fixes.
| Version | Supported |
| ------------- | ------------------ |
| `main` (latest) | :white_check_mark: |
| Older releases | :x: |
## Reporting a Vulnerability
**Please do not open a public GitHub issue for security vulnerabilities.** Public issues are visible to everyone, including potential attackers, before a fix is available.
Instead, report security issues privately by emailing:
**johnellisATlinuxDOTcom**
Please include as much of the following as you can:
- A description of the vulnerability and its potential impact
- Steps to reproduce, or a proof-of-concept
- The version/commit of ihasmail affected
- The version of Stalwart Mail Server you were testing against, if relevant
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency
### What to Expect
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code.
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
### Scope
In scope:
- Authentication and session handling in ihasmail
- Cross-site scripting (XSS), CSRF, or injection issues in the webmail UI
- Improper handling of JMAP responses that could lead to data leakage between accounts
- Dependency vulnerabilities that are actually exploitable in ihasmail's usage
Out of scope (please report upstream instead):
- Vulnerabilities in Stalwart Mail Server itself — report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server)
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance
## Disclosure Policy
We follow coordinated disclosure: please give us a reasonable window to investigate and release a fix before any public disclosure. In turn, we'll keep you updated on progress and won't leave you waiting indefinitely.
Thank you for helping keep ihasmail and its users safe.
View File
-9
View File
@@ -1,9 +0,0 @@
import os, secrets
APP_SECRET = os.getenv("APP_SECRET") or secrets.token_urlsafe(32)
COOKIE_NAME = os.getenv("COOKIE_NAME", "stalwart_webmail")
JMAP_BASE = os.getenv("JMAP_BASE", "https://mail.example.com/jmap")
CALDAV_BASE = os.getenv("CALDAV_BASE", "https://mail.example.com/caldav/")
WEBDAV_BASE = os.getenv("WEBDAV_BASE", "https://mail.example.com/webdav/")
TRUST_PROXY = os.getenv("TRUST_PROXY", "1") == "1"
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "15"))
-41
View File
@@ -1,41 +0,0 @@
from typing import List, Dict, Any, Tuple, Optional
import httpx
from urllib.parse import urljoin
from . import config
DAV_PROPFIND = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>"""
async def propfind(ac: httpx.AsyncClient, base: str, path: Optional[str], auth: Tuple[str,str]) -> List[Dict[str, Any]]:
href = urljoin(base, path or "/")
r = await ac.request("PROPFIND", href, content=DAV_PROPFIND, headers={"Depth": "1"}, auth=auth)
if r.status_code not in (207, 200):
raise RuntimeError(f"WebDAV error {r.status_code}")
import xml.etree.ElementTree as ET
tree = ET.fromstring(r.text)
ns = {"d":"DAV:"}
items: List[Dict[str, Any]] = []
for resp in tree.findall("d:response", ns):
href_el = resp.find("d:href", ns)
prop = resp.find("d:propstat/d:prop", ns)
if href_el is None or prop is None:
continue
name = prop.find("d:displayname", ns)
cl = prop.find("d:getcontentlength", ns)
rtype = prop.find("d:resourcetype", ns)
is_collection = rtype is not None and rtype.find("d:collection", ns) is not None
items.append({
"href": href_el.text,
"name": (name.text if name is not None and name.text else href_el.text.rstrip("/").split("/")[-1] or "/"),
"type": "directory" if is_collection else "file",
"size": int(cl.text) if (cl is not None and cl.text and cl.text.isdigit()) else None
})
if items:
items = items[1:]
return items
-31
View File
@@ -1,31 +0,0 @@
from typing import Any, Dict, List, Tuple
import httpx
from . import config
def client() -> httpx.AsyncClient:
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
return httpx.AsyncClient(timeout=config.UPSTREAM_TIMEOUT, limits=limits, trust_env=True)
async def get_session(ac: httpx.AsyncClient, base: str, username: str, password: str) -> Dict[str, Any]:
r = await ac.get(base, auth=(username, password))
if r.status_code == 401:
raise PermissionError("Invalid credentials")
r.raise_for_status()
return r.json()
async def call(ac: httpx.AsyncClient, api_url: str, auth: Tuple[str,str] | None, method_calls: List[list]) -> Dict[str, Any]:
payload = {
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:contacts",
"urn:ietf:params:jmap:calendars"
],
"methodCalls": method_calls
}
kwargs: Dict[str, Any] = {"json": payload}
if auth:
kwargs["auth"] = auth
r = await ac.post(api_url, **kwargs)
r.raise_for_status()
return r.json()
-34
View File
@@ -1,34 +0,0 @@
import bleach
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
from . import config
from .routes import auth, mail, contacts, calendar, webdav, sieve
app = FastAPI(title="Stalwart Webmail (Python)")
if config.TRUST_PROXY:
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
app.add_middleware(SessionMiddleware, secret_key=config.APP_SECRET, session_cookie=config.COOKIE_NAME, same_site="lax", https_only=True)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/", include_in_schema=False)
async def root(request: Request):
from fastapi.responses import RedirectResponse
return RedirectResponse("/mail" if request.session.get("user") else "/login")
# Routers
app.include_router(auth.router)
app.include_router(mail.router)
app.include_router(contacts.router)
app.include_router(calendar.router)
app.include_router(webdav.router)
app.include_router(sieve.router)
@app.get("/healthz", include_in_schema=False)
async def healthz():
return {"ok": True}
View File
-45
View File
@@ -1,45 +0,0 @@
from fastapi import APIRouter, Request, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import PlainTextResponse
from .. import config, jmap
from fastapi.templating import Jinja2Templates
from jinja2 import FileSystemLoader, Environment, select_autoescape
import pathlib, base64, os
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def make_csrf(session: dict) -> str:
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
session["csrf"] = token
return token
def check_csrf(session: dict, token: str):
if not token or token != session.get("csrf"):
raise HTTPException(status_code=400, detail="CSRF token invalid")
@router.get("/login", response_class=HTMLResponse)
async def login_form(request: Request):
csrf = make_csrf(request.session)
return templates.TemplateResponse("login.html", {"request": request, "csrf": csrf, "jmap_base": config.JMAP_BASE})
@router.post("/login")
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...), jmap_base: str = Form(...), csrf: str = Form(...)):
check_csrf(request.session, csrf)
async with jmap.client() as ac:
try:
session = await jmap.get_session(ac, jmap_base, username, password)
except PermissionError:
raise HTTPException(status_code=401, detail="Invalid credentials")
api_url = session.get("apiUrl") or jmap_base
download_url = session.get("downloadUrl") or ""
primary = session.get("primaryAccounts") or {}
request.session["user"] = {"username": username, "jmap_base": jmap_base, "api_url": api_url, "auth": (username, password), "download_url": download_url, "primary": primary, "session": session}
return RedirectResponse("/mail", status_code=303)
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
-39
View File
@@ -1,39 +0,0 @@
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib, datetime
from .. import jmap
from ..utils import fmt_when
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/calendar", response_class=HTMLResponse)
async def calendar(request: Request, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
account_id = None
now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
until = now + datetime.timedelta(days=30)
res = await jmap.call(ac, api, tuple(user["auth"]), [
["CalendarEvent/query", {"accountId": account_id, "limit": 200, "sort":[{"property":"start","isAscending": True}]}, "q1"],
["CalendarEvent/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"CalendarEvent/query","path":"ids"}, "properties":["id","title","start","end","location"]}, "g1"]
])
events = []
for name, data, _ in res.get("methodResponses", []):
if name == "CalendarEvent/get":
for e in data.get("list", []):
try:
s = datetime.datetime.fromisoformat((e.get("start") or "").replace("Z","+00:00"))
if s < now - datetime.timedelta(days=1) or s > until:
continue
except Exception:
pass
events.append({"title": e.get("title") or "(no title)", "start": fmt_when(e.get("start")), "end": fmt_when(e.get("end")), "loc": e.get("location")})
return templates.TemplateResponse("calendar.html", {"request": request, "events": events, "user": user})
-35
View File
@@ -1,35 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import jmap
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/contacts", response_class=HTMLResponse)
async def contacts(request: Request, q: Optional[str] = None, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
account_id = None
filter_cond = {"text": q} if q else {}
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Contact/query", {"accountId": account_id, "filter": filter_cond, "limit": 100}, "c1"],
["Contact/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Contact/query","path":"ids"}, "properties":["id","firstName","lastName","emails","company"]}, "c2"]
])
contacts = []
for name, data, _ in res.get("methodResponses", []):
if name == "Contact/get":
for c in data.get("list", []):
emails = [e.get("email","") for e in (c.get("emails") or [])]
contacts.append({"name": f"{c.get('firstName','')} {c.get('lastName','')}".strip() or (emails[0] if emails else ""),
"email": ", ".join(emails),
"org": c.get("company")})
return templates.TemplateResponse("contacts.html", {"request": request, "contacts": contacts, "q": q, "user": user})
-316
View File
@@ -1,316 +0,0 @@
import json
import io
import bleach
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import jmap
from ..utils import human_size, fmt_when
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
def make_csrf(session: dict) -> str:
import os, base64
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
session["csrf"] = token
return token
def check_csrf(session: dict, token: str):
if not token or token != session.get("csrf"):
raise HTTPException(status_code=400, detail="CSRF token invalid")
@router.get("/mail", response_class=HTMLResponse)
async def inbox(request: Request, q: Optional[str] = None, mailbox: Optional[str] = None, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
boxes, inbox_id = await get_mailboxes(ac, api, tuple(user["auth"]), account_id)
box_id = mailbox or inbox_id
filt = {"text": q} if q else ({"inMailbox": box_id} if box_id else {})
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/query", {"accountId": account_id, "filter": filt, "sort": [{"property":"receivedAt","isAscending": False}], "limit": 50}, "c1"],
["Email/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Email/query","path":"ids"}, "properties": ["id","subject","from","size","receivedAt"]}, "c2"]
])
emails = []
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
for e in data.get("list", []):
from_str = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])])
emails.append({"id": e["id"], "subject": e.get("subject") or "(no subject)", "from": from_str, "when": fmt_when(e.get("receivedAt")), "size": human_size(e.get("size"))})
return templates.TemplateResponse("mail.html", {"request": request, "messages": emails, "q": q, "user": user, "mailboxes": boxes, "selected": box_id})
@router.get("/mail/{email_id}", response_class=HTMLResponse)
async def read_message(request: Request, email_id: str, user=Depends(require_user)):
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["id","subject","from","to","receivedAt","size","keywords","preview","bodyStructure","htmlBody","textBody"]}, "c1"]
])
msg = {"id": email_id, "subject":"", "from":"", "to":[], "when":"", "textBody":"", "htmlBody":"", "attachments":[]}
bstruct = None
cid_map = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
e = lst[0]
msg["subject"] = e.get("subject") or msg["subject"]
msg["from"] = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) or msg["from"]
msg["to"] = [a.get("email","") for a in (e.get("to") or [])] or msg["to"]
msg["when"] = fmt_when(e.get("receivedAt")) or msg["when"]
if "textBody" in e:
msg["textBody"] = e.get("textBody") or msg["textBody"]
if "htmlBody" in e:
raw_html = e.get("htmlBody")
if raw_html:
msg["htmlBody"] = bleach.clean(raw_html, tags=bleach.sanitizer.ALLOWED_TAGS.union({"p","span","div","br","hr","pre","code","blockquote","ul","ol","li","table","thead","tbody","tr","th","td","img","a","b","i","strong","em"}), attributes={"a":["href","title"],"img":["src","alt","title","width","height"]}, strip=True)
bstruct = bstruct or e.get("bodyStructure")
def walk_cid(bs):
if not isinstance(bs, dict): return
cid = bs.get("cid")
if cid and bs.get("blobId"):
cid_map[cid.strip("<>")] = {"blobId": bs["blobId"], "name": bs.get("name") or "inline"}
for p in bs.get("subParts", []) or []:
walk_cid(p)
if bstruct:
walk_cid(bstruct)
def walk_bs(bs, out):
if not isinstance(bs, dict): return
if bs.get("disposition") == "attachment":
out.append({"name": bs.get("name") or "attachment", "type": bs.get("type") or "application/octet-stream", "size": bs.get("size"), "blobId": bs.get("blobId")})
for p in bs.get("subParts", []) or []:
walk_bs(p, out)
att = []
walk_bs(bstruct, att)
msg["attachments"] = att
# Inline CID images via internal route
if msg.get("htmlBody") and cid_map:
import re as _re
def _repl(m):
cid = m.group(1)
return f'src="/mail/{email_id}/cid/{cid}"'
msg["htmlBody"] = _re.sub(r'src=\"cid:([^\"]+)\"', _repl, msg["htmlBody"]) # cid_rewrite
return templates.TemplateResponse("message.html", {"request": request, "msg": msg, "user": user})
@router.get("/compose", response_class=HTMLResponse)
async def compose_form(request: Request, user=Depends(require_user)):
csrf = make_csrf(request.session)
return templates.TemplateResponse("compose.html", {"request": request, "csrf": csrf, "user": user})
@router.post("/compose")
async def compose_send(request: Request, to: str = Form(...), subject: str = Form(""), body: str = Form(""), csrf: str = Form(...), action: str = Form("send"), files: list[UploadFile] = File(default=[]), user=Depends(require_user)):
check_csrf(request.session, csrf)
async with jmap.client() as ac:
api = user["api_url"]
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
# Upload attachments if any
upload_url = user.get("upload_url")
blobs = []
form = await request.form()
for k, v in form.multi_items():
if k == 'preblob':
try:
b = json.loads(v)
if b.get('blobId'): blobs.append(b)
except Exception:
pass
if files:
for f in files:
data = await f.read()
if upload_url:
url = upload_url.replace("{accountId}", account_id or "")
ru = await ac.post(url, content=data, headers={"Content-Type": f.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
ru.raise_for_status()
up = ru.json()
blobs.append({"blobId": up.get("blobId"), "type": f.content_type or "application/octet-stream", "name": f.filename, "size": len(data)})
email_creation_id = "k1"
submission_creation_id = "k2"
create_email = {
"accountId": account_id,
"create": {
email_creation_id: {
"mailboxIds": {},
"from": [{"email": user["username"]}],
"to": [{"email": x.strip()} for x in to.split(",") if x.strip()],
"subject": subject,
"textBody": body,
"attachments": [{"blobId": b["blobId"], "type": b["type"], "name": b["name"]} for b in blobs]
}
}
}
# Move to Drafts if requested, else submit and move to Sent
special = await get_special_mailboxes(ac, api, tuple(user["auth"]), account_id)
sent_id = special.get("sent")
drafts_id = special.get("drafts")
calls = []
calls.append(["Email/set", create_email, "s1"])
if action == "draft":
if drafts_id:
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {drafts_id: True}}}}, "sdraft"])
else:
calls.append(["EmailSubmission/set", {"accountId": account_id, "create": {submission_creation_id: {"emailId": {"resultOf":"s1","name":"Email/set","path": f"created/{email_creation_id}/id"}}}}, "s2"])
if sent_id:
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {sent_id: True}}}}, "ssent"])
await jmap.call(ac, api, tuple(user["auth"]), calls)
return RedirectResponse("/mail", status_code=303)
async def get_mailboxes(ac, api, auth, account_id):
res = await jmap.call(ac, api, auth, [
["Mailbox/query", {"accountId": account_id, "sort":[{"property":"sortOrder","isAscending": True},{"property":"name","isAscending": True}], "limit": 200}, "q1"],
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","name","role","totalEmails","unreadEmails"]}, "g1"]
])
boxes = []
inbox_id = None
for name, data, _ in res.get("methodResponses", []):
if name == "Mailbox/get":
for b in data.get("list", []):
boxes.append({"id": b["id"], "name": b.get("name",""), "role": b.get("role"), "total": b.get("totalEmails",0), "unread": b.get("unreadEmails",0)})
if b.get("role") == "inbox":
inbox_id = b["id"]
return boxes, inbox_id or (boxes[0]["id"] if boxes else None)
@router.get("/mail/{email_id}/attach/{index}")
async def download_attachment(request: Request, email_id: str, index: int, user=Depends(require_user)):
atts = request.query_params.get("atts")
# Re-fetch message to resolve bodyStructure (simple approach; could cache)
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
])
bstruct = None
cid_map = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
bstruct = lst[0].get("bodyStructure")
parts = []
def walk(bs, out):
if not isinstance(bs, dict): return
if bs.get("disposition") == "attachment":
out.append(bs)
for p in bs.get("subParts", []) or []:
walk(p, out)
walk(bstruct, parts)
if index < 0 or index >= len(parts):
raise HTTPException(status_code=404, detail="Attachment not found")
p = parts[index]
blob = p.get("blobId")
name = p.get("name") or "attachment"
ctype = p.get("type") or "application/octet-stream"
# Build download URL from session template
tmpl = user.get("download_url") or ""
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
url = tmpl
if "{accountId}" in url:
url = url.replace("{accountId}", account_id or "")
if "{blobId}" in url:
url = url.replace("{blobId}", blob or "")
if "{name}" in url:
from urllib.parse import quote
url = url.replace("{name}", quote(name))
# Fallback naive pattern if template missing
if not url or "{" in url:
from urllib.parse import urljoin, quote
base = user.get("jmap_base")
url = urljoin(base, f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
async with jmap.client() as ac:
r = await ac.get(url, auth=tuple(user["auth"]))
r.raise_for_status()
return StreamingResponse(io.BytesIO(r.content), media_type=ctype, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def get_special_mailboxes(ac, api, auth, account_id):
res = await jmap.call(ac, api, auth, [
["Mailbox/query", {"accountId": account_id, "limit": 200}, "q1"],
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","role","name"]}, "g1"]
])
sent_id = drafts_id = inbox_id = None
boxes = {}
for name, data, _ in res.get("methodResponses", []):
if name == "Mailbox/get":
for b in data.get("list", []):
boxes[b["id"]] = b
role = b.get("role")
if role == "sent": sent_id = b["id"]
if role == "drafts": drafts_id = b["id"]
if role == "inbox": inbox_id = b["id"]
return {"sent": sent_id, "drafts": drafts_id, "inbox": inbox_id, "all": boxes}
@router.get("/mail/{email_id}/cid/{cid}")
async def fetch_cid(request: Request, email_id: str, cid: str, user=Depends(require_user)):
# Walk bodyStructure to find matching cid, then download via downloadUrl
async with jmap.client() as ac:
api = user["api_url"]
res = await jmap.call(ac, api, tuple(user["auth"]), [
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
])
bstruct = None
for name, data, _ in res.get("methodResponses", []):
if name == "Email/get":
lst = data.get("list", [])
if lst:
bstruct = lst[0].get("bodyStructure")
target = None
def walk(bs):
nonlocal target
if not isinstance(bs, dict) or target is not None: return
if bs.get("cid") and bs.get("cid").strip("<>") == cid:
target = bs
return
for p in bs.get("subParts", []) or []:
walk(p)
walk(bstruct)
if not target:
raise HTTPException(status_code=404, detail="Inline part not found")
blob = target.get("blobId")
ctype = target.get("type") or "application/octet-stream"
name = target.get("name") or "inline"
tmpl = user.get("download_url") or ""
primary = user.get("primary", {})
account_id = primary.get("urn:ietf:params:jmap:mail")
from urllib.parse import quote, urljoin
if tmpl and "{accountId}" in tmpl and "{blobId}" in tmpl:
url = tmpl.replace("{accountId}", account_id or "").replace("{blobId}", blob or "")
if "{name}" in url:
url = url.replace("{name}", quote(name))
else:
url = urljoin(user.get("jmap_base"), f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
async with jmap.client() as ac:
r = await ac.get(url, auth=tuple(user["auth"]))
r.raise_for_status()
return StreamingResponse(io.BytesIO(r.content), media_type=ctype)
@router.post("/upload")
async def upload_file(request: Request, file: UploadFile = File(...), user=Depends(require_user)):
async with jmap.client() as ac:
primary = user.get("primary", {})
account_id = user.get("active_account") or primary.get("urn:ietf:params:jmap:mail")
upload_url = user.get("upload_url")
if not upload_url or not account_id:
raise HTTPException(status_code=400, detail="Upload not available")
url = upload_url.replace("{accountId}", account_id)
data = await file.read()
r = await ac.post(url, content=data, headers={"Content-Type": file.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
r.raise_for_status()
up = r.json()
return JSONResponse({"blobId": up.get("blobId"), "type": file.content_type or "application/octet-stream", "name": file.filename, "size": len(data)})
-21
View File
@@ -1,21 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import pathlib
from .. import dav, jmap, config
router = APIRouter()
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
def require_user(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401)
return user
@router.get("/webdav", response_class=HTMLResponse)
async def webdav_browse(request: Request, path: Optional[str]=None, user=Depends(require_user)):
async with jmap.client() as ac:
items = await dav.propfind(ac, config.WEBDAV_BASE, path, tuple(user["auth"]))
return templates.TemplateResponse("webdav.html", {"request": request, "items": items, "base": config.WEBDAV_BASE, "user": user})
-30
View File
@@ -1,30 +0,0 @@
:root { color-scheme: light dark; --header-bg: #f6f7f9; --header-fg: #111; --card-bg: #fff; }
@media (prefers-color-scheme: dark) { :root { --header-bg: #0f172a; --header-fg: #e5e7eb; --card-bg: #0b1222; } }
body { margin:0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header, footer { padding: 10px 14px; border-bottom: 1px solid #4443; background: var(--header-bg); color: var(--header-fg); }
main { padding: 14px; max-width: 1100px; margin: 0 auto; }
nav a { margin-right: 12px; }
.btn { display:inline-block; padding:6px 10px; border:1px solid #6665; border-radius:8px; text-decoration:none; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: 8px; border-bottom: 1px solid #6662; text-align: left; vertical-align: top; }
.muted { color: #888; }
input, textarea, select { padding:6px 8px; width:100%; box-sizing: border-box; }
form .row { display:grid; grid-template-columns: 160px 1fr; gap: 8px; align-items: center; margin-bottom:10px; }
.msg { cursor:pointer; }
.pill { display:inline-block; font-size:12px; padding:2px 6px; border:1px solid #6663; border-radius:999px; margin-right:6px;}
.nowrap { white-space: nowrap; }
.right { text-align:right; }
.toolbar { display:flex; gap:8px; align-items:center; margin:8px 0; }
.panel { border:1px solid #6663;padding:10px;border-radius:8px;margin:10px 0;white-space:pre-wrap }
#dropzone{padding:16px;border:2px dashed #6665;border-radius:8px;text-align:center;margin:10px 0}
.brand { display:flex; align-items:center; gap:10px; }
.brand .logo { height:28px; vertical-align:middle; }
.brand-link { text-decoration:none; color:inherit; }
header nav { margin-top:6px; }
.badge { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; background:#6662; margin-left:6px; }
.card{background:var(--card-bg); border:1px solid #6663; border-radius:12px; padding:18px; box-shadow:0 2px 6px #0001;}
.center{display:grid; place-items:center; min-height:60vh;}
.logo-lg{height:64px;}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

-37
View File
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title or "ihasmail" }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://unpkg.com;">
<link rel="icon" href="/static/img/logo.png">
<link rel="preconnect" href="https://unpkg.com">
<script defer src="https://unpkg.com/[email protected]"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<div class="brand">
<a href="/" class="brand-link"><img src="/static/img/logo.png" alt="ihasmail" class="logo"> <strong>ihasmail</strong></a>
</div>
<nav>
{% if user %}
<span class="muted">Signed in as {{ user.get("username") }}</span>
<a class="btn" href="/mail">Inbox</a>
<a class="btn" href="/compose">Compose</a>
<a class="btn" href="/calendar">Calendar</a>
<a class="btn" href="/contacts">Contacts</a>
<a class="btn" href="/webdav">WebDAV</a>
<a class="btn" href="/logout">Logout</a>
{% else %}
<a class="btn" href="/login">Login</a>
{% endif %}
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="muted">ihasmail • JMAP • Sieve • DAV • FastAPI • reverse-proxy ready</footer>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Calendar (JMAP & CalDAV)</h1>
<p class="muted">Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.</p>
<table>
<tr><th>When</th><th>Summary</th><th>Where</th></tr>
{% for e in events %}
<tr>
<td class="nowrap">{{ e.start }} {{ e.end }}</td>
<td>{{ e.title }}</td>
<td>{{ e.loc or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Compose</h1>
<form method="post" action="/compose">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row"><label>To</label><input name="to" required></div>
<div class="row"><label>Subject</label><input name="subject"></div>
<div class="row"><label>Body</label><textarea name="body" rows="14"></textarea></div>
<button class="btn">Send</button>
</form>
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Contacts (Directory via JMAP)</h1>
<div class="toolbar">
<form>
<input name="q" value="{{ q or '' }}" placeholder="Search name/email…">
</form>
</div>
<table>
<tr><th>Name</th><th>Email</th><th>Org</th></tr>
{% for c in contacts %}
<tr>
<td>{{ c.name }}</td>
<td>{{ c.email }}</td>
<td>{{ c.org or "" }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="center"><div class="card" style="min-width:320px; max-width:420px;">
<div style="text-align:center;margin-bottom:8px"><img class="logo-lg" src="/static/img/logo.png" alt="ihasmail"></div>
<h2 style="text-align:center;margin-top:0">Sign in</h2>
<form method="post" action="/login">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row">
<label>Username</label>
<input name="username" autocomplete="username" required>
</div>
<div class="row">
<label>Password</label>
<input type="password" name="password" autocomplete="current-password" required>
</div>
<div class="row">
<label>JMAP Base</label>
<input name="jmap_base" value="{{ jmap_base }}">
</div>
<button class="btn" type="submit">Sign in</button>
</form>
</div></div>
<p class="muted">Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.</p>
{% endblock %}
-26
View File
@@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Inbox</h1>
<div class="toolbar">
<form method="get" action="/mail">
<select name="mailbox" onchange="this.form.submit()">
{% for b in mailboxes %}
<option value="{{ b.id }}" {% if b.id == selected %}selected{% endif %}>{{ b.name }}{% if b.unread %} ({{ b.unread }}){% endif %}</option>
{% endfor %}
</select>
<input name="q" placeholder="Search (from, subject, text…)" value="{{ q or '' }}">
</form>
<a class="btn" href="/compose">Compose</a>
</div>
<table>
<tr><th class="nowrap">When</th><th>From</th><th>Subject</th><th class="right">Size</th></tr>
{% for m in messages %}
<tr class="msg" onclick="location.href='/mail/{{ m.id }}'">
<td class="nowrap">{{ m.when }}</td>
<td>{{ m.from }}</td>
<td>{{ m.subject }}</td>
<td class="right">{{ m.size }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-25
View File
@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ msg.subject or "(no subject)" }}</h1>
<p><span class="pill">From</span> {{ msg.from }} <span class="pill">To</span> {{ msg.to|join(", ") }}</p>
<p class="muted">{{ msg.when }}</p>
{% if msg.htmlBody %}
<div class="panel">{{ (msg.htmlBody | safe) }}</div>
{% elif msg.textBody %}
<div class="panel">{{ msg.textBody }}</div>
{% else %}
<div class="panel muted">(no body)</div>
{% endif %}
<div class="toolbar">
<a class="btn" href="/compose?reply={{ msg.id }}">Reply</a>
<a class="btn" href="/compose?forward={{ msg.id }}">Forward</a>
</div>
{% if msg.attachments %}
<h3>Attachments</h3>
<ul>
{% for a in msg.attachments %}
<li>{{ a.name }} ({{ a.type }}, {{ a.size }} bytes)</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
-15
View File
@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>WebDAV</h1>
<p class="muted">Browsing {{ base }}</p>
<table>
<tr><th>Name</th><th>Type</th><th class="right">Size</th></tr>
{% for i in items %}
<tr>
<td>{{ i.name }}</td>
<td>{{ i.type }}</td>
<td class="right">{% if i.size is not none %}{{ i.size }}{% endif %}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-19
View File
@@ -1,19 +0,0 @@
import datetime
def human_size(n: int | None) -> str:
if n is None: return ""
units = ["B","KB","MB","GB","TB","PB"]
i = 0
x = float(n)
while x >= 1024 and i < len(units)-1:
x /= 1024.0
i += 1
return f"{x:.0f} {units[i]}"
def fmt_when(iso: str | None) -> str:
if not iso: return ""
try:
dt = datetime.datetime.fromisoformat(iso.replace("Z","+00:00")).astimezone()
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return iso or ""
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v2
name: ihasmail
description: ihasmail — JMAP webmail for Stalwart (FastAPI)
type: application
version: 0.1.0
appVersion: "0.2.0"
-7
View File
@@ -1,7 +0,0 @@
Thanks for installing ihasmail!
Get the service URL by running these commands:
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ihasmail.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo http://$SERVICE_IP:{{ .Values.service.port }}/
If using Ingress and DNS, browse to the configured host (e.g., https://ihasmail.example.com).
-20
View File
@@ -1,20 +0,0 @@
{{- define "ihasmail.name" -}}
{{- .Chart.Name -}}
{{- end -}}
{{- define "ihasmail.fullname" -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "ihasmail.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "ihasmail.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
-60
View File
@@ -1,60 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "ihasmail.fullname" . }}
labels:
{{- include "ihasmail.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "ihasmail.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "ihasmail.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "ihasmail.fullname" . }}-secret
key: APP_SECRET
- name: JMAP_BASE
value: {{ .Values.env.JMAP_BASE | quote }}
- name: CALDAV_BASE
value: {{ .Values.env.CALDAV_BASE | quote }}
- name: WEBDAV_BASE
value: {{ .Values.env.WEBDAV_BASE | quote }}
- name: COOKIE_NAME
value: {{ .Values.env.COOKIE_NAME | quote }}
- name: TRUST_PROXY
value: {{ .Values.env.TRUST_PROXY | quote }}
- name: UPSTREAM_TIMEOUT
value: {{ .Values.env.UPSTREAM_TIMEOUT | quote }}
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 20
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "ihasmail.fullname" . }}-secret
type: Opaque
stringData:
APP_SECRET: {{ .Values.env.APP_SECRET | quote }}
-30
View File
@@ -1,30 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "ihasmail.fullname" . }}
{{- if .Values.ingress.className }}
annotations:
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
{{- end }}
spec:
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "ihasmail.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "ihasmail.fullname" . }}
labels:
{{- include "ihasmail.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8000
protocol: TCP
name: http
selector:
{{- include "ihasmail.selectorLabels" . | nindent 4 }}
-32
View File
@@ -1,32 +0,0 @@
image:
repository: ghcr.io/your-org/ihasmail
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8000
ingress:
enabled: false
className: ""
hosts:
- host: ihasmail.example.com
paths:
- path: /
pathType: Prefix
tls: []
env:
APP_SECRET: "CHANGE_ME"
JMAP_BASE: "https://mail.example.com/jmap"
CALDAV_BASE: "https://mail.example.com/caldav/"
WEBDAV_BASE: "https://mail.example.com/webdav/"
COOKIE_NAME: "ihasmail"
TRUST_PROXY: "1"
UPSTREAM_TIMEOUT: "15"
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
+215
View File
@@ -0,0 +1,215 @@
#!/bin/bash
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
#
# Copy it, or run it as-is and set the variables below in the environment.
# Nothing here is specific to any one host: the defaults describe the shape of
# a deployment rather than anyone's particular one.
#
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
#
# Three guards stand between a careless run and production:
#
# .deploy-hold commits that must not reach prod yet, one per line. If the
# target contains one that is not already deployed, the deploy
# is refused outright -- `--yes` does not override it. Clearing
# a hold means deleting its line, which is a deliberate edit.
#
# confirmation anything introducing new commits is listed first and has to
# be confirmed. Over SSH, where there is no terminal to answer
# on, that means passing --yes: a bare `deploy.sh` cannot ship
# whatever main happens to have picked up since the last
# release.
#
# --dry-run runs both guards, says what it would deploy, and stops before
# building or touching the container.
#
# The container is replaced rather than restarted, because the image is rebuilt
# from the new checkout. Data lives in a named volume and survives that; the
# environment file is never read here, only handed to Docker.
set -euo pipefail
# --- what to deploy, and where ----------------------------------------------
# The checkout to deploy from. It must be a git clone: the version number is
# read from its history (see scripts/version.mjs).
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
# Environment file passed to the container. Keep it outside the repo's tracked
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
# Commits held back from production, one per line; blank or missing is fine.
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
# Container name, and where to publish it. The default binds to loopback only,
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
NAME="${IHASMAIL_NAME:-ihasmail}"
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
# Named volume for /data (sessions).
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
# Image repository. Each build is tagged with its version as well, so an
# earlier one can be run again without rebuilding it.
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
# How long to wait for the new container to report healthy, in seconds.
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
# How many past versions to keep as images, for rolling back to. Each is around
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
# couple of releases -- and `docker image prune` will not touch them, because
# they are tagged. 0 keeps every version.
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
# --- run from a copy, if this script lives in the checkout it resets ---------
# `git reset --hard` below rewrites the working tree, and this script may be
# part of it. Bash does not read a script all at once -- it reads as it goes,
# by byte offset -- so a file replaced underneath it makes the shell stop
# wherever it had reached. Silently, and with exit status 0: a deploy that
# stopped halfway would report success. Re-exec from a copy outside the tree so
# the file being run cannot change while it runs.
SELF="$(readlink -f "$0")"
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
cat "$SELF" > "$COPY"
chmod +x "$COPY"
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
fi
# The copy has served its purpose once we exit; the shell has finished reading
# it by then.
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
trap 'rm -f "$SELF"' EXIT
fi
REF=""
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*)
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
REF="$arg" ;;
esac
done
REF="${REF:-origin/main}"
cd "$APP"
git fetch --quiet origin
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
echo "!! no such commit: $REF" >&2
exit 2
fi
CURRENT=$(git rev-parse --verify HEAD)
# --- guard 1: commits held back from production -----------------------------
if [ -f "$HOLD" ]; then
blocked=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="$(printf '%s' "$line" | tr -d '[:space:]')"
[ -z "$line" ] && continue
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
continue
fi
# Only a problem if the target carries it and production does not already.
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
fi
done < "$HOLD"
if [ -n "$blocked" ]; then
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
printf '%s' "$blocked" >&2
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
exit 1
fi
fi
# --- guard 2: say what is being introduced, and get a yes --------------------
NEW=$(git log --oneline "$CURRENT..$TARGET")
if [ -n "$NEW" ]; then
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
echo "==> introduces:"
printf '%s\n' "$NEW" | sed 's/^/ /'
if [ "$ASSUME_YES" -ne 1 ]; then
if [ -t 0 ]; then
read -r -p "deploy these to production? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "aborted."; exit 1 ;;
esac
else
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
exit 1
fi
fi
else
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
exit 0
fi
git reset --hard --quiet "$TARGET"
# The version is worked out here, from the checkout, because the image build
# cannot: .dockerignore keeps .git out of the build context. Without this the
# build falls back to the base version in package.json and every deployment
# reports the same number -- see "Version numbers" in the README.
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
#
# Only ever runs after the new container reports healthy, so a rollback target
# is never removed while the thing replacing it is still unproven. The image in
# use is excluded outright rather than relied on to sort newest -- docker
# refuses to remove an image a container is using, but being refused is not the
# same as not having tried.
prune_old_images() {
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
local in_use stale
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
# Newest first, tags only, skipping the moving ":current" pointer.
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
| grep -v ":current" \
| sort -k2 -r \
| cut -f1 \
| grep -vxF "$in_use" \
| tail -n +"$((KEEP_VERSIONS + 1))")"
[ -n "$stale" ] || return 0
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
}
VERSION="$(node scripts/version.mjs)"
# A Docker tag may not contain "+", which a version for a commit that did not
# come through a pull request does: 2.16.57+g1fa6578. The image is tagged with
# the "+" turned into "-"; what the build is *told* it is keeps the real form,
# so About and /api/health still report it correctly.
TAG="${VERSION//+/-}"
echo "==> building $(git log --oneline -1) as v$VERSION"
docker build \
--build-arg IHASMAIL_VERSION="$VERSION" \
-t "$IMAGE_REPO:$TAG" \
-t "$IMAGE_REPO:current" \
.
echo "==> restarting container"
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d --name "$NAME" --restart unless-stopped \
-p "$BIND:8080" --env-file "$ENVF" -v "$VOLUME:/data" "$IMAGE_REPO:$TAG" >/dev/null
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if health=$(curl -sf "http://$BIND/api/health"); then
echo "==> healthy: $health"
prune_old_images
exit 0
fi
sleep 1
done
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
docker logs "$NAME" 2>&1 | tail -20 >&2
echo "!! the previous image is still tagged, if you need it back:" >&2
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
exit 1
+13 -6
View File
@@ -1,11 +1,18 @@
services:
ihasmail:
build: .
image: ihasmail:latest
env_file: .env
image: ihasmail:2
restart: unless-stopped
networks: [edge]
ports:
- "127.0.0.1:8080:8000"
networks:
edge: {}
- "8080:8080"
environment:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
volumes:
- ihasmail-data:/data
volumes:
ihasmail-data:
+75
View File
@@ -0,0 +1,75 @@
/**
* The light inbox shot, with no Emulation.setDeviceMetricsOverride at all --
* the window is simply launched at the size we want. The emulation layer is the
* prime suspect for the mixed-theme frames every other approach produced.
*/
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2] ?? ".";
const PORT = 9334;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--window-size=1420,790", "--force-device-scale-factor=1",
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
], { stdio: "ignore" });
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
const version = await json("/json/version");
let id = 1; const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { resolve, reject } = pending.get(x.id); pending.delete(x.id); x.error ? reject(new Error(JSON.stringify(x.error))) : resolve(x.result); } };
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const i = id++; pending.set(i, { resolve, reject }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable"); await cmd("Runtime.enable");
const evaluate = async (expression) => {
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
return r.result.value;
};
const waitFor = async (expr, what, ms = 20000) => {
const end = Date.now() + ms;
while (Date.now() < end) { if (await evaluate(`!!(${expr})`)) return; await sleep(200); }
throw new Error(`timed out waiting for ${what}`);
};
try {
await cmd("Page.navigate", { url: "http://localhost:5173/" });
await sleep(1500);
console.log("viewport:", await evaluate(`window.innerWidth + 'x' + window.innerHeight`));
await evaluate(`
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (t, r=document) => [...r.querySelectorAll('button')].find(b => b.textContent.trim() === t);
`);
await evaluate(`(() => {
const i = [...document.querySelectorAll('input')];
window.__set(i.find(x => x.type === 'text' || x.type === 'email'), '[email protected]');
window.__set(document.querySelector('input[type=password]'), 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await sleep(1500);
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1500);
// The app's own control, the way a user switches theme.
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /light mode/i.test(x.getAttribute('aria-label') || x.title || ''));
if (b) b.click(); else document.documentElement.dataset.theme = 'light';
})()`);
await sleep(2000);
const bg = await evaluate(`getComputedStyle(document.body).backgroundColor`);
const topbar = await evaluate(`getComputedStyle(document.querySelector('.topbar')).backgroundColor`);
console.log("body:", bg, "topbar:", topbar);
if (parseInt(bg.match(/\d+/)[0], 10) < 200) throw new Error("page is not rendering light");
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/inbox-light.jpg`, Buffer.from(data, "base64"));
console.log("wrote inbox-light.jpg");
} finally { ws.close(); chrome.kill(); }
+272
View File
@@ -0,0 +1,272 @@
/**
* Regenerates most of the README screenshots from the mock server.
*
* Drives headless Chrome over CDP, so the viewport is exactly the size the
* images already use rather than whatever a window happens to be.
*
* npm run dev:mock # in another terminal
* node docs/screenshots.mjs docs/screenshots
* node docs/screenshots-light.mjs docs/screenshots
*
* Restart the mock before a run. The filters shot creates rules, so a second
* run against the same mock shows them twice.
*
* Two shots are deliberately not taken here:
*
* - **mobile**, because at the tail of this sequence the app would not render
* the message list at 500px within the wait. A short run of its own is
* reliable, and it is a screenshot, not a mystery worth solving.
*
* - **inbox-light**, because of setDeviceMetricsOverride. Swapping the theme
* under the emulation layer captures a *mixed* frame: the panes that
* re-rendered come out light while the rest of the chrome stays dark, with
* the DOM and computed styles insisting the whole page is light. The app is
* not at fault -- update() calls applyTheme() synchronously and the CSS does
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
* CSS-variable change touches while metrics are overridden. Launching Chrome
* at --window-size and never calling setDeviceMetricsOverride renders it
* correctly, which is what docs/screenshots-light.mjs does.
*
* assertTheme() stays either way: without it this script wrote a dark
* screenshot under a light caption and reported success, and that is how the
* README came to show the same theme twice for months.
*/
import { spawn } from "node:child_process";
import { writeFile, mkdir } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2];
if (!OUT) { console.error("usage: node shots.mjs <out-dir>"); process.exit(2); }
await mkdir(OUT, { recursive: true });
const PORT = 9333;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
], { stdio: "ignore" });
const json = async (path) => {
for (let i = 0; i < 60; i++) {
try { return await (await fetch(`http://127.0.0.1:${PORT}${path}`)).json(); }
catch { await sleep(250); }
}
throw new Error("Chrome did not come up");
};
const version = await json("/json/version");
let nextId = 1;
const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
};
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
});
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable");
await cmd("Runtime.enable");
let current = { width: 1420, height: 703, mobile: false };
const metrics = (width, height, mobile = false) => {
current = { width, height, mobile };
return cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });
};
/**
* Forces the whole page to repaint.
*
* Headless only repaints the layers that changed, and a theme swap changes CSS
* variables rather than any single element — so the capture came back with the
* message pane in the new theme and the rest of the app in the old one. Nudging
* the viewport by a pixel and back invalidates everything.
*/
const repaint = async () => {
// Detaching and reattaching the body invalidates every layer; nudging the
// viewport did not, and the capture kept coming back with mixed themes.
await evaluate(`(() => { const b = document.body; b.style.display = 'none'; void b.offsetHeight; b.style.display = ''; })()`);
await sleep(500);
};
const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); };
const evaluate = async (expression) => {
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
return r.result.value;
};
/** Polls a predicate inside the page until it is true, or gives up loudly. */
const waitFor = async (jsExpr, what, ms = 15000) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(`!!(${jsExpr})`)) return;
await sleep(200);
}
throw new Error(`timed out waiting for ${what}`);
};
/**
* Pins the theme, because setting it once is not enough.
*
* The app re-runs applyTheme() from its own setting whenever the settings store
* stirs, and that overwrote a plain attribute set during the settle before the
* capture — twice, silently, producing a "light" screenshot of the dark theme.
* A MutationObserver puts it back faster than anything can take it away.
*
* The check is the rendered background colour: the attribute is what lied.
*/
const themeTest = (want) => want === "light"
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
: "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) < 60";
const setTheme = async (want) => {
await evaluate(`(() => {
const html = document.documentElement;
const want = ${JSON.stringify(want)};
if (window.__themePin) window.__themePin.disconnect();
window.__themePin = new MutationObserver(() => { if (html.dataset.theme !== want) html.dataset.theme = want; });
window.__themePin.observe(html, { attributes: true, attributeFilter: ['data-theme'] });
html.dataset.theme = want;
})()`);
await waitFor(themeTest(want), `the ${want} theme to actually render`);
await repaint();
};
/** Refuses to write the file unless the page still looks the way it should. */
const assertTheme = async (want) => {
if (!(await evaluate(themeTest(want)))) throw new Error(`page is not rendering the ${want} theme at capture time`);
};
const shot = async (name) => {
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64"));
console.log(" wrote", name);
};
// Helpers injected into the page: React-controlled inputs need the native setter.
const HELPERS = `
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (txt, root=document) => [...root.querySelectorAll('button')].find(b => b.textContent.trim() === txt);
window.__click = (sel) => { const el = document.querySelector(sel); if (el) el.click(); return !!el; };
window.__sel = (el, v) => { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); };
`;
try {
console.log("chrome:", version.Browser);
// --- login (taller, as the existing shot is) ---
await metrics(1420, 759);
await go("http://localhost:5173/");
await evaluate(HELPERS);
await sleep(600);
await shot("login.jpg");
// --- sign in (a fresh profile prefills nothing, so both fields) ---
await evaluate(`(() => {
const inputs = [...document.querySelectorAll('input')];
const user = inputs.find(i => i.type === 'text' || i.type === 'email');
const pw = document.querySelector('input[type=password]');
window.__set(user, '[email protected]');
window.__set(pw, 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelector('.msg-row') || document.querySelector('.nav-item')", "the app after sign-in");
await sleep(1500);
// --- inbox, dark, with a conversation open ---
await metrics(1420, 703);
await go("http://localhost:5173/mail");
await evaluate(HELPERS);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1800);
await shot("inbox-dark.jpg");
// --- the reply composer, still on the dark theme ---
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /^reply$/i.test(x.getAttribute('aria-label')||'') || /^reply$/i.test(x.textContent.trim()));
if (b) b.click();
})()`);
await sleep(1800);
await shot("compose.jpg");
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
await sleep(800);
// (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
// --- calendar ---
await go("http://localhost:5173/calendar");
await waitFor("document.querySelector('.cal-grid, .calendar, [class*=cal]')", "the calendar");
await evaluate(HELPERS);
// The README caption promises the month view.
await evaluate(`(() => { const b = window.__btn('Month'); if (b) b.click(); })()`);
await sleep(1800);
await shot("calendar.jpg");
// --- contacts ---
await go("http://localhost:5173/contacts");
await waitFor("document.querySelector('[class*=contact]')", "the contact list");
// Open someone, so the detail pane is not an empty "Select a contact".
await evaluate(`(() => {
const hit = [...document.querySelectorAll('div, li, button, a')]
.filter(e => (e.textContent || '').trim().startsWith('Ada Lovelace'))
.sort((a, b) => a.textContent.length - b.textContent.length)[0];
if (hit) (hit.closest('li, button, a, [class*=row], [class*=item]') || hit).click();
})()`);
await waitFor("!/Select a contact/.test(document.body.innerText)", "the contact detail pane", 8000);
await sleep(1800);
await shot("contacts.jpg");
// --- filters, with rules that actually say something ---
await go("http://localhost:5173/settings/filters");
await evaluate(HELPERS);
await waitFor("[...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'New rule')", "the filters editor");
await evaluate(`(async () => {
const wait = (ms=350) => new Promise(r => setTimeout(r, ms));
const rules = [
{ name: 'Newsletters', field: 'list-id', op: 'exists', value: '', folder: 'Newsletters' },
{ name: 'From the boss', field: 'from', op: 'contains', value: '[email protected]', folder: 'Work' },
{ name: 'Receipts', field: 'subject', op: 'contains', value: 'invoice', folder: 'Archive' },
{ name: 'Build failures',field: 'subject', op: 'matches', value: '*FAILED*', folder: 'Work' },
];
for (const r of rules) {
window.__btn('New rule').click(); await wait();
const d = document.querySelector('.dialog');
window.__set(d.querySelector('input.input'), r.name); await wait(120);
const row = d.querySelector('.rule-row');
const sels = row.querySelectorAll('select');
window.__sel(sels[0], r.field); await wait(120);
const sels2 = d.querySelector('.rule-row').querySelectorAll('select');
if (sels2[1]) { window.__sel(sels2[1], r.op); await wait(120); }
const val = [...d.querySelector('.rule-row').querySelectorAll('input.input')].pop();
if (val && r.value) { window.__set(val, r.value); await wait(120); }
const arow = d.querySelector('.rule-row.actions');
const asels = arow.querySelectorAll('select');
if (asels[1]) { window.__sel(asels[1], r.folder); await wait(120); }
window.__btn('Done', d).click(); await wait();
}
const save = window.__btn('Save filters'); if (save && !save.disabled) save.click();
await wait(1500);
// Clear the "Filters saved" toast so it does not sit over a rule.
document.querySelectorAll('.toast, [class*=toast]').forEach(t => t.remove());
})()`);
await sleep(1200);
await shot("filters.jpg");
// (mobile is captured separately by shots-mobile.mjs)
console.log("done");
} finally {
ws.close();
chrome.kill();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+19
View File
@@ -0,0 +1,19 @@
# Example nginx location block for ihasmail behind TLS termination.
server {
listen 443 ssl http2;
server_name mail.example.com;
# ssl_certificate ...; ssl_certificate_key ...;
client_max_body_size 60m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Server-Sent Events (push notifications)
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
+3852
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ihasmail",
"version": "2.16.0",
"private": true,
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
"license": "AGPL-3.0-or-later",
"type": "module",
"workspaces": [
"server",
"web"
],
"engines": {
"node": ">=20.10"
},
"scripts": {
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
"build": "npm run build -w web && npm run build -w server",
"start": "node server/dist/index.js",
"typecheck": "npm run typecheck -w web && npm run typecheck -w server",
"test": "npm run test -w web && npm run test -w server",
"lint": "npm run typecheck",
"mock": "npm run mock -w server",
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
},
"devDependencies": {
"concurrently": "^9.1.2",
"typescript": "^5.7.3"
}
}
-30
View File
@@ -1,30 +0,0 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ihasmail"
version = "0.2.0"
description = "ihasmail — JMAP webmail for Stalwart (FastAPI, HTMX/Jinja)"
authors = [{name = "John Coffey", email = "[email protected]"}]
readme = "README.md"
requires-python = ">=3.10"
license = {text = "GPL-3.0-or-later"}
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"jinja2>=3.1",
"bleach>=6.1",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"anyio>=4.4",
"httpx>=0.27",
]
[tool.pytest.ini_options]
addopts = "-q"
+4
View File
@@ -0,0 +1,4 @@
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
export function baseVersion(): string;
export function versionFromGit(): string | null;
export function resolveVersion(): string;
+83
View File
@@ -0,0 +1,83 @@
/**
* Work out this build's version: `2.16.57`.
*
* 2 ihasmail's own major
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
* 57 the pull request the checked-out commit came from
*
* The first two are the `version` in the root package.json, so there is one
* place to bump them; the third is read from git, because it does not exist
* until the pull request has actually merged. Nothing writes a version back
* into the tree: a committed one would always be describing a merge that had
* not happened yet, and every branch would collide on the same line.
*
* A commit that did not arrive through a pull request has no number of its
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
* — which is honest about being past that PR rather than silently claiming to
* be it.
*
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
* whoever builds is responsible for computing it — see ihasmail-deploy.sh.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
export function baseVersion() {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const [major, minor] = String(pkg.version).split(".");
return `${major}.${minor}`;
}
function git(...args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
/**
* The version for the commit checked out here, or null when there is no git to
* ask — an unpacked tarball, or the Docker build context.
*/
export function versionFromGit() {
let head;
try {
head = git("rev-parse", "--short", "HEAD");
} catch {
return null;
}
const base = baseVersion();
try {
// Walk back over first parents: a merge commit's subject names its PR, and
// anything after the newest one is work that has not been through one.
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
for (const [sha, subject = ""] of commits) {
const pr = PR_SUBJECT.exec(subject)?.[1];
if (!pr) continue;
// The PR's own merge commit is the version; anything above it is past it.
const exact = sha.startsWith(git("rev-parse", "HEAD"));
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
}
} catch {
/* a shallow clone, or no history to read */
}
return `${base}.0+g${head}`;
}
/** Whatever the environment was told, else git, else just the base. */
export function resolveVersion() {
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
if (fromEnv) return fromEnv;
return versionFromGit() ?? `${baseVersion()}.0`;
}
// `node scripts/version.mjs` prints it, for shell scripts and CI.
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.stdout.write(resolveVersion() + "\n");
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@ihasmail/server",
"version": "2.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch --clear-screen=false src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
"mock": "tsx src/mock/index.ts",
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.4"
},
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}
}
+206
View File
@@ -0,0 +1,206 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* End-to-end self-service credential flows against the mock, which enforces
* the same rules a real 0.16 server does: the current password is checked,
* password policy is applied, and once 2FA is on every request wants a fresh
* TOTP code — except one authenticating with an app password.
*/
const PORT = 18797;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-account-flows";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
const app = createApp();
let cookie = "";
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
const res = await app.request(path, {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
});
const setCookie = res.headers.get("set-cookie");
if (setCookie) cookie = setCookie.split(";")[0]!;
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
before(async () => {
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 200, "login should succeed against the mock");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
/**
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock
* everyone out rather than merely misroute credentials.
*/
test("the session is accepted on a server that advertises the registry per-account", async () => {
const res = await call("/api/auth/session");
assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
});
test("the registry reports an account with nothing set up yet", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
assert.equal(res.body.otpEnabled, false);
assert.deepEqual(res.body.appPasswords, []);
});
test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
assert.equal(created.status, 200);
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
assert.ok(created.body.id);
const list = await call("/api/account/security");
assert.equal(list.body.appPasswords.length, 1);
assert.equal(list.body.appPasswords[0].description, "Thunderbird");
assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again");
const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id });
assert.equal(revoked.status, 200);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " " });
assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields");
});
test("the wrong current password is refused with the server's reason", async () => {
const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
assert.equal(res.status, 403);
assert.match(res.body.message, /Current secret is incorrect/);
});
test("the server's password policy is surfaced verbatim", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "short" });
assert.equal(res.status, 400);
assert.match(res.body.message, /at least 8 characters/);
});
test("a password unchanged from the old one is rejected before we ask upstream", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" });
assert.equal(res.status, 400);
assert.equal(res.body.error, "unchanged");
});
test("changing the password keeps this session working", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
assert.equal(res.status, 200);
// The stored credential was re-sealed, so the next proxied call still passes
// upstream authentication with the new password.
assert.equal((await call("/api/auth/session")).status, 200);
assert.equal((await call("/api/account/security")).status, 200);
});
test("enabling 2FA rejects a code the new secret did not produce", async () => {
const begin = await post("/api/account/2fa/begin", {});
assert.equal(begin.status, 200);
assert.match(begin.body.url, /^otpauth:\/\/totp\//);
const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
assert.equal(res.status, 400);
assert.equal(res.body.code, undefined);
assert.match(res.body.message, /doesn't match/);
assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored");
});
test("enabling 2FA switches the session onto an app password so it survives", async () => {
const begin = await post("/api/account/2fa/begin", {});
const params = parseOtpauthUrl(begin.body.url);
assert.ok(params);
const res = await post("/api/account/2fa/enable", {
url: begin.body.url,
code: totpCode(params),
current: "a-brand-new-password",
});
assert.equal(res.status, 200);
assert.equal(res.body.sessionKept, true);
const state = await call("/api/account/security");
assert.equal(state.status, 200, "the session still authenticates upstream");
assert.equal(state.body.otpEnabled, true);
assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser");
assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser");
});
test("with 2FA on, a password change needs the current code too", async () => {
const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" });
assert.equal(withoutCode.status, 403);
assert.match(withoutCode.body.message, /OTP code is required/);
});
test("2FA is switched off with the password and a current code", async () => {
const state = await call("/api/account/security");
assert.equal(state.body.otpEnabled, true);
// The enrolment secret is known only to the client, so disabling uses a code
// from the authenticator - here, the one the mock stored.
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
assert.ok(params);
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
assert.equal(res.status, 200);
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
});
test("credential endpoints reject unauthenticated callers", async () => {
const saved = cookie;
cookie = "";
assert.equal((await call("/api/account/security")).status, 401);
assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401);
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved;
});
/**
* A sign-in carrying a two-factor code that the server rejects is almost never
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
* no password grant, so the concatenated form ihasmail sends cannot work — and
* saying "invalid credentials" sends the user to check a password that is fine.
*
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
* worked, which is Stalwart's documented route and gave no hint of itself.
*/
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
assert.match(res.body.message, /app password/i, "points at the route that does work");
assert.match(res.body.message, /probably fine/i, "does not blame the password");
});
test("a rejected sign-in without a code is still a plain credential failure", async () => {
// The explanation must not leak onto ordinary typos.
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "invalid_credentials");
});
+221
View File
@@ -0,0 +1,221 @@
import { config } from "./config.js";
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
/**
* Self-service credential management, over Stalwart's JMAP registry:
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
* and `x:AppPassword`.
*
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
* supports. Sign-in refuses anything older, so by the time any of this runs
* the registry is known to be there.
*/
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton";
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
const MASKED = "[********]";
export interface AppPasswordRow {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
}
export interface SecurityState {
otpEnabled: boolean;
appPasswords: AppPasswordRow[];
}
/** An error with a message meant for the person using the app. */
export class AccountError extends Error {
constructor(
message: string,
public readonly status = 400,
public readonly code = "account_error",
) {
super(message);
this.name = "AccountError";
}
}
interface Ctx {
authorization: string;
session: UpstreamSession;
username: string;
}
/* ------------------------------------------------------------------ */
/* Transport */
/* ------------------------------------------------------------------ */
function accountId(ctx: Ctx): string {
return (
ctx.session.primaryAccounts?.[STALWART_CAP] ??
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(ctx.session.accounts ?? {})[0] ??
""
);
}
type Invocation = [string, Record<string, unknown>, string];
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
method: "POST",
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
}
/**
* Pull the single result out of a /set, turning JMAP's several failure shapes
* into one error carrying whatever the server was willing to explain.
*/
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
const [name, args] = res.methodResponses?.[0] ?? [];
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
if (name === "error") {
const err = args as { type?: string; description?: string };
if (err.type === "unknownMethod") {
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
}
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
}
const body = args as Record<string, Record<string, unknown> | undefined>;
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
const failures = body[notKind];
const failure = failures && Object.values(failures)[0];
if (failure) {
const err = failure as { type?: string; description?: string; properties?: string[] };
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
}
const ok = body[kind];
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
}
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
if (err.description) return err.description;
if (err.type === "forbidden") return "The mail server refused the change.";
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
if (err.type === "invalidProperties") {
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
}
return `The mail server refused the change (${err.type ?? "error"}).`;
}
/* ------------------------------------------------------------------ */
/* Operations */
/* ------------------------------------------------------------------ */
export async function getState(ctx: Ctx): Promise<SecurityState> {
const id = accountId(ctx);
const res = await jmap(ctx, [
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
["x:AppPassword/get", { accountId: id, ids: null }, "a"],
]);
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
const apps = listOf(res, "a");
return {
// The URL itself is masked; its presence is what tells us 2FA is on.
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
appPasswords: apps.map((a) => ({
id: String(a.id ?? ""),
description: String(a.description ?? "App password"),
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
})),
};
}
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
const call = res.methodResponses?.find((r) => r[2] === callId);
if (!call || call[0] === "error") return [];
const list = (call[1] as { list?: unknown }).list;
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
}
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
return listOf(res, callId)[0] ?? null;
}
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
setResult(res, "updated");
}
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
const description = opts.description.trim() || "App password";
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
const created = setResult(res, "created");
const secret = created && typeof created.secret === "string" ? created.secret : "";
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
return { id: String(created?.id ?? description), secret };
}
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
setResult(res, "destroyed");
}
/**
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
* Nothing is stored until the user proves they can produce a code from it.
*/
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
const secret = generateSecret();
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
}
/**
* Prove the user can produce a code from the secret they just scanned.
*
* Stalwart validates the credentials already on the account and never looks at
* the new secret, so without this an authenticator that was mistyped or out of
* step would lock the user out of their mailbox at the next sign-in.
*/
export function assertEnrolmentCode(url: string, code: string): void {
const params = parseOtpauthUrl(url);
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
if (!verifyTotp(params, code)) {
throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code");
}
}
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
assertEnrolmentCode(opts.url, opts.code);
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
"s",
],
]);
setResult(res, "updated");
}
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{
accountId: accountId(ctx),
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
},
"s",
],
]);
setResult(res, "updated");
}
export { MASKED };
+112
View File
@@ -0,0 +1,112 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
/**
* The account locale used to be read only from `x:Account/get`, which needs
* the `sysAccountGet` permission — one the built-in `user` role is not given.
* Ordinary users therefore silently fell back to the browser locale. Stalwart
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
* so both are asked for and whichever answers wins. Both are 0.16 methods:
* this is a permissions fallback, not a version one.
*/
type Responses = [string, Record<string, unknown>, string][];
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
test("prefers the locale a regular user is allowed to read", () => {
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
assert.equal(info.locale, "de-DE");
});
test("falls back to x:Account when the settings object is forbidden", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
assert.equal(info.locale, "sr-Latn-RS");
});
test("an account with no locale set yields none, rather than a guess", () => {
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
assert.equal(info.locale, null);
});
test("neither answering leaves the locale unknown", () => {
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
});
test("locales that carry no language are dropped, not passed through", () => {
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
});
test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
// fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null });
});
test("no capabilities at all is treated the same way", async () => {
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
assert.equal(info.locale, null);
});
/**
* Where Stalwart actually advertises `urn:stalwart:jmap`.
*
* Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is
* handed out per-account instead, so it lands in `primaryAccounts` and in each
* account's `accountCapabilities`. Looking only at the session level called
* every real 0.16 server too old, which sent self-service credentials to a
* REST endpoint 0.16 had removed and made the About page report the wrong
* thing.
*
* This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server.
*/
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
assert.equal(
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
true,
);
});
test("a 0.16 server is recognised from an account's capabilities", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
test("the session level still counts, for a server that ever advertises it there", () => {
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
});
test("a server that advertises it nowhere is one we do not support", () => {
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
assert.equal(hasStalwartRegistry(undefined), false);
});
test("a shared account carrying the capability is enough to recognise the server", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
+37
View File
@@ -0,0 +1,37 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
test("CSRF guard rejects API POSTs without the custom header", async () => {
const app = createApp();
const res = await app.request("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
assert.equal(res.status, 403);
});
test("unauthenticated JMAP calls are rejected", async () => {
const app = createApp();
const res = await app.request("/api/jmap", { method: "POST", headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
assert.equal(res.status, 401);
});
test("cross-site fetches are rejected", async () => {
const app = createApp();
const res = await app.request("/api/health", { headers: { "sec-fetch-site": "cross-site" } });
assert.equal(res.status, 403);
});
test("health and security headers", async () => {
const app = createApp();
const res = await app.request("/api/health");
assert.equal(res.status, 200);
assert.equal(res.headers.get("x-content-type-options"), "nosniff");
assert.equal(res.headers.get("x-frame-options"), "DENY");
});
test("image proxy refuses private targets", async () => {
const app = createApp();
// no session -> 401 first; so exercise the handler directly via a logged-in-less path is not possible; check the URL validation ordering instead
const res = await app.request("/api/image?url=http://127.0.0.1/x");
assert.equal(res.status, 401);
});
+682
View File
@@ -0,0 +1,682 @@
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js";
import { SessionStore, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js";
import { resolveClientIp } from "./clientip.js";
import {
type AccountInfo,
UpstreamError,
absoluteUpstream,
expandTemplate,
fetchUpstreamSession,
hasStalwartRegistry,
forgetUpstreamSession,
getAccountInfo,
getUpstreamSession,
localizeSession,
} from "./upstream.js";
import {
AccountError,
assertEnrolmentCode,
beginOtpEnrolment,
changePassword,
createAppPassword,
disableOtp,
enableOtp,
getState,
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { staticHandler } from "./static.js";
type Env = { Variables: { session: LiveSession } };
export const sessions = new SessionStore(config.sessionFile);
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
/**
* Credential changes verify the current password upstream, and Stalwart's
* fail2ban counts those failures against the *caller's* IP — which for a proxy
* is shared by every user. Keep our own lid on it so one person guessing
* cannot get the whole deployment banned.
*/
const accountLimiter = new RateLimiter(10, 15 * 60_000);
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-encoding",
"content-length",
]);
export function clientIp(c: Context): string {
let peer = "unknown";
try {
peer = getConnInfo(c).remote.address ?? "unknown";
} catch {
/* no socket information available */
}
return resolveClientIp(peer, { forwardedFor: c.req.header("x-forwarded-for"), realIp: c.req.header("x-real-ip") }, config);
}
function isSecureRequest(c: Context): boolean {
if (config.secureCookies === "1" || config.secureCookies === "true") return true;
if (config.secureCookies === "0" || config.secureCookies === "false") return false;
if (config.trustProxy) {
const proto = c.req.header("x-forwarded-proto");
if (proto) return proto.split(",")[0]!.trim() === "https";
}
return new URL(c.req.url).protocol === "https:";
}
/** Security headers for every response. */
const securityHeaders: MiddlewareHandler = async (c, next) => {
await next();
const h = c.res.headers;
h.set("X-Content-Type-Options", "nosniff");
h.set("X-Frame-Options", "DENY");
h.set("Referrer-Policy", "no-referrer");
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
h.set("Cross-Origin-Opener-Policy", "same-origin");
if (!h.has("Cache-Control")) h.set("Cache-Control", "no-store");
if (isSecureRequest(c)) h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
};
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
const csrfGuard: MiddlewareHandler = async (c, next) => {
const site = c.req.header("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") {
return c.json({ error: "cross_site_request" }, 403);
}
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
if (c.req.header("x-requested-with") !== "ihasmail") {
return c.json({ error: "missing_csrf_header" }, 403);
}
}
await next();
};
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
const cookie = getCookie(c, config.cookieName);
const session = sessions.resolve(cookie);
if (!session) {
return c.json({ error: "unauthenticated" }, 401);
}
c.set("session", session);
await next();
};
function setSessionCookie(c: Context, value: string, remember: boolean) {
setCookie(c, config.cookieName, value, {
httpOnly: true,
sameSite: "Lax",
secure: isSecureRequest(c),
path: "/",
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
});
}
function upstreamFailure(c: Context, err: unknown) {
if (err instanceof UpstreamError) {
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
}
const name = (err as Error)?.name ?? "";
if (name === "TimeoutError" || name === "AbortError") {
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
}
console.error("[ihasmail] upstream failure:", err);
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
}
export function createApp(): Hono<Env> {
const app = new Hono<Env>();
app.use("*", securityHeaders);
const api = new Hono<Env>();
api.use("*", csrfGuard);
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
api.get("/config", (c) =>
c.json({
appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes,
}),
);
// ---------- Auth ----------
api.post("/auth/login", async (c) => {
const ip = clientIp(c);
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
try {
body = await c.req.json();
} catch {
return c.json({ error: "bad_request" }, 400);
}
const username = (body.username ?? "").trim();
const password = body.password ?? "";
const totp = (body.totp ?? "").trim();
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
const limitKey = `${ip}|${username.toLowerCase()}`;
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
}
// Stalwart accepts TOTP codes appended to the password as "password$123456".
const effectivePassword = totp ? `${password}$${totp}` : password;
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
try {
const upstream = await fetchUpstreamSession(authorization);
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
// clearly, rather than signing someone in and letting Files, the account
// locale and self-service credentials each fail in their own way with
// nothing to connect them. The credentials were good, so say so.
if (!hasStalwartRegistry(upstream)) {
return c.json(
{
error: "unsupported_server",
message:
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
},
501,
);
}
loginLimiter.reset(limitKey);
const { cookie, session } = sessions.create({
username,
password: effectivePassword,
remember: Boolean(body.remember),
userAgent: c.req.header("user-agent") ?? "",
ip,
});
setSessionCookie(c, cookie, session.remember);
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
// A rejected sign-in that carried a two-factor code is worth explaining
// rather than calling "invalid credentials", because the credentials are
// very likely fine.
//
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
// interface is an OAuth client, which is why signing in there works. It
// offers no password grant, so a client holding a username and password
// cannot exchange them plus a code for a token, and the concatenated
// `password$code` form ihasmail sent is not a route the server has. Its
// documented answer for clients like this one is an app password, which
// bypasses TOTP entirely.
//
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
// app password and moves the session onto it, precisely because a plain
// password stops working from that moment. The sign-in page was the one
// place still pretending otherwise.
if (totp && err instanceof UpstreamError && err.status === 401) {
return c.json(
{
error: "totp_unsupported",
message:
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
},
401,
);
}
return upstreamFailure(c, err);
}
});
api.get("/auth/session", requireSession, async (c) => {
const session = c.get("session");
try {
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) {
if (err instanceof UpstreamError && err.status === 401) {
sessions.destroy(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
}
return upstreamFailure(c, err);
}
});
api.post("/auth/logout", async (c) => {
const cookie = getCookie(c, config.cookieName);
const session = sessions.resolve(cookie);
if (session) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
}
deleteCookie(c, config.cookieName, { path: "/" });
return c.json({ ok: true });
});
api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
});
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
const session = c.get("session");
const n = sessions.destroyAllForUser(session.username, session.id);
return c.json({ revoked: n });
});
// ---------- Self-service credentials ----------
/**
* Password, app passwords and 2FA. These live on the server rather than in
* the browser because changing a credential means re-sealing the session
* cookie that holds it, and because the browser only ever sees /api/jmap.
*/
const accountCtx = async (c: Context<Env>) => {
const session = c.get("session");
const upstream = await getUpstreamSession(session.id, session.authorization);
return { authorization: session.authorization, session: upstream, username: session.username };
};
const accountFailure = (c: Context, err: unknown) => {
if (err instanceof AccountError) {
return c.json({ error: err.code, message: err.message }, err.status as 400);
}
return upstreamFailure(c, err);
};
/** Guard the endpoints that check a password against brute-forcing. */
const guarded = (c: Context<Env>): Response | null => {
const key = `account|${c.get("session").username.toLowerCase()}`;
if (accountLimiter.check(key)) return null;
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
};
api.get("/account/security", requireSession, async (c) => {
const session = c.get("session");
try {
return c.json(await getState(await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/password", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400);
const current = body.current ?? "";
const next = body.next ?? "";
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
if (next === current) {
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
}
try {
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
} catch (err) {
return accountFailure(c, err);
}
// The old password is now dead: re-seal this session with the new one and
// drop the others, whose sealed copies would fail on their next call.
const otpCode = body.otpCode?.trim();
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.username, session.id);
return c.json({ ok: true, revokedSessions: revoked });
});
api.get("/account/app-passwords", requireSession, async (c) => {
const session = c.get("session");
try {
const state = await getState(await accountCtx(c));
return c.json({ appPasswords: state.appPasswords });
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/app-passwords", requireSession, async (c) => {
const session = c.get("session");
const body = await readJson<{ description?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400);
const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
try {
return c.json(await createAppPassword(await accountCtx(c), { description }));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
const session = c.get("session");
const body = await readJson<{ id?: string }>(c);
if (!body?.id) return c.json({ error: "bad_request" }, 400);
try {
await revokeAppPassword(await accountCtx(c), body.id);
return c.json({ ok: true });
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/2fa/begin", requireSession, async (c) => {
try {
// Nothing is stored yet; the client hands the URL back to confirm.
return c.json(beginOtpEnrolment(await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/2fa/enable", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
const ctx = await accountCtx(c);
const code = body.code.trim();
/*
* Every proxied call re-authenticates with the stored password, and once
* 2FA is on the server wants a fresh TOTP code alongside it — which we
* cannot produce between requests. An app password authenticates without
* one, so the session moves onto a dedicated app password rather than
* being signed out the moment 2FA is switched on.
*
* Order matters: mint it while the current credential still works, since
* the moment 2FA is enabled this session can no longer authenticate at all.
*/
try {
assertEnrolmentCode(body.url, code);
} catch (err) {
return accountFailure(c, err);
}
let app: { id: string; secret: string } | null = null;
try {
app = await createAppPassword(ctx, { description: appPasswordName(c) });
} catch (err) {
// Out of app-password quota, say. 2FA is still worth having; the user
// just has to sign in again afterwards.
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
}
try {
await enableOtp(ctx, { url: body.url, code, current: body.current });
} catch (err) {
if (app) {
// Don't leave a credential behind for a change that never happened.
await revokeAppPassword(ctx, app.id).catch(() => {});
}
return accountFailure(c, err);
}
let sessionKept = false;
if (app) {
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
if (sessionKept) forgetUpstreamSession(session.id);
}
// Other sessions still hold the bare password and will be refused.
const revoked = sessions.destroyAllForUser(session.username, session.id);
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
});
api.post("/account/2fa/disable", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ current?: string; code?: string }>(c);
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
try {
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
} catch (err) {
return accountFailure(c, err);
}
// This session may be running on the app password minted when 2FA went on;
// the plain password works again now, so put it back.
sessions.reseal(getCookie(c, config.cookieName), body.current);
forgetUpstreamSession(session.id);
return c.json({ ok: true });
});
// ---------- JMAP API proxy ----------
api.post("/jmap", requireSession, async (c) => {
const session = c.get("session");
const ct = c.req.header("content-type") ?? "";
if (!ct.toLowerCase().startsWith("application/json")) {
return c.json({ error: "unsupported_media_type" }, 415);
}
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
method: "POST",
headers: {
authorization: session.authorization,
"content-type": "application/json",
accept: "application/json",
},
body: c.req.raw.body,
duplex: "half",
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401) {
sessions.destroy(session.id);
forgetUpstreamSession(session.id);
deleteCookie(c, config.cookieName, { path: "/" });
return c.json({ error: "unauthenticated" }, 401);
}
return passthrough(res);
} catch (err) {
return upstreamFailure(c, err);
}
});
// ---------- Blob upload ----------
api.post("/upload/:accountId", requireSession, async (c) => {
const session = c.get("session");
const accountId = c.req.param("accountId");
const len = Number(c.req.header("content-length") ?? "0");
if (len > config.maxUploadBytes) return c.json({ error: "too_large" }, 413);
// content-length is absent on a chunked request, so the header alone is a
// suggestion; count the bytes as they go past.
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
const res = await fetch(url, {
method: "POST",
headers: {
authorization: session.authorization,
"content-type": c.req.header("content-type") ?? "application/octet-stream",
accept: "application/json",
},
body,
duplex: "half",
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
return passthrough(res);
} catch (err) {
return upstreamFailure(c, err);
}
});
// ---------- Blob download ----------
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
const session = c.get("session");
const { accountId, blobId, name } = c.req.param();
const accept = c.req.query("accept") ?? "application/octet-stream";
const inline = c.req.query("inline") === "1";
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
const res = await fetch(url, {
headers: { authorization: session.authorization },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
});
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type);
const cl = res.headers.get("content-length");
if (cl) headers.set("Content-Length", cl);
const safeInline = inline && isInlineSafe(type);
headers.set(
"Content-Disposition",
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
);
headers.set("X-Content-Type-Options", "nosniff");
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
if (!(safeInline && type === "application/pdf")) {
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
}
headers.set("Cache-Control", "private, max-age=3600");
return new Response(res.body, { status: 200, headers });
} catch (err) {
return upstreamFailure(c, err);
}
});
// ---------- Push (Server-Sent Events) ----------
api.get("/events", requireSession, async (c) => {
const session = c.get("session");
const types = c.req.query("types") ?? "*";
const closeafter = c.req.query("closeafter") ?? "no";
const ping = c.req.query("ping") ?? "30";
try {
const upstream = await getUpstreamSession(session.id, session.authorization);
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
const controller = new AbortController();
c.req.raw.signal.addEventListener("abort", () => controller.abort());
const res = await fetch(url, {
headers: { authorization: session.authorization, accept: "text/event-stream" },
signal: controller.signal,
});
if (!res.ok || !res.body) return c.json({ error: "upstream_error" }, 502);
const headers = new Headers({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
return new Response(res.body, { status: 200, headers });
} catch (err) {
return upstreamFailure(c, err);
}
});
// ---------- Remote image privacy proxy ----------
api.get("/image", requireSession, imageProxyHandler);
api.notFound((c) => c.json({ error: "not_found" }, 404));
api.onError((err, c) => {
console.error("[ihasmail] api error:", err);
return c.json({ error: "internal_error" }, 500);
});
app.route("/api", api);
// ---------- Static SPA ----------
app.get("*", staticHandler(config.staticDir));
return app;
}
/** Fail a stream that runs past `max` bytes, whatever its headers claimed. */
function byteCap(max: number): TransformStream<Uint8Array, Uint8Array> {
let total = 0;
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > max) controller.error(new Error("upload too large"));
else controller.enqueue(chunk);
},
});
}
async function readJson<T>(c: Context): Promise<T | null> {
try {
return (await c.req.json()) as T;
} catch {
return null;
}
}
/** Name the app password after the browser it will live in. */
function appPasswordName(c: Context): string {
const ua = c.req.header("user-agent") ?? "";
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
return `${config.appName} (${browser})`;
}
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
return {
ihasmail: {
appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes,
sessionId: session.id,
loginName: session.username,
remember: session.remember,
/** Locale configured for the account in Stalwart's directory, if readable. */
userLocale: info.locale,
/** What the upstream server would tell us about itself. */
server: { edition: info.edition },
},
};
}
/**
* Headers worth relaying from the mail server. An allowlist rather than a
* denylist: everything else it might set — cookies, auth challenges, CORS
* grants — would be landing on *our* origin, where it means something else.
*/
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
function passthrough(res: Response): Response {
const headers = new Headers();
res.headers.forEach((v, k) => {
if (PASSTHROUGH_HEADERS.has(k.toLowerCase())) headers.set(k, v);
});
if (!headers.has("content-type")) headers.set("content-type", "application/json");
headers.set("Cache-Control", "no-store");
return new Response(res.body, { status: res.status, headers });
}
function sanitizeContentType(ct: string): string {
const lower = ct.split(";")[0]!.trim().toLowerCase();
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
if (
lower === "text/html" ||
lower === "application/xhtml+xml" ||
lower === "image/svg+xml" ||
lower.includes("javascript") ||
lower === "text/xml" ||
lower === "application/xml"
) {
return "application/octet-stream";
}
if (lower.startsWith("text/")) return `${lower}; charset=utf-8`;
return lower || "application/octet-stream";
}
function isInlineSafe(type: string): boolean {
const t = type.split(";")[0]!.trim();
return (
(t.startsWith("image/") && t !== "image/svg+xml") ||
t.startsWith("video/") ||
t.startsWith("audio/") ||
t === "application/pdf" ||
t === "text/plain" ||
t === "text/calendar" ||
t === "text/vcard"
);
}
+94
View File
@@ -0,0 +1,94 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { inRange, isTrustedProxy, resolveClientIp } from "./clientip.js";
/**
* The rate limiter keys on whatever this returns, so anything a client can
* choose is a way to sidestep it. nginx's `$proxy_add_x_forwarded_for`
* *appends*, so a client sending `X-Forwarded-For: 1.2.3.4` reaches us as
* "1.2.3.4, <their real address>" — reading the leftmost entry hands them a
* key they can change per request.
*/
const cfg = { trustProxy: true, trustedProxies: [] as string[] };
const direct = { trustProxy: false, trustedProxies: [] as string[] };
test("CIDR matching covers both families and single addresses", () => {
assert.equal(inRange("10.1.2.3", "10.0.0.0/8"), true);
assert.equal(inRange("11.1.2.3", "10.0.0.0/8"), false);
assert.equal(inRange("172.16.5.4", "172.16.0.0/12"), true);
assert.equal(inRange("172.32.5.4", "172.16.0.0/12"), false);
assert.equal(inRange("127.0.0.1", "127.0.0.1"), true, "a bare address is a /32");
assert.equal(inRange("::1", "::1/128"), true);
assert.equal(inRange("fd00::5", "fc00::/7"), true);
assert.equal(inRange("2001:db8::1", "fc00::/7"), false);
assert.equal(inRange("10.1.2.3", "not-a-range"), false);
assert.equal(inRange("10.1.2.3", "::1/128"), false, "families do not cross");
});
test("loopback and private peers are trusted by default", () => {
for (const p of ["127.0.0.1", "::1", "10.0.0.5", "172.17.0.1", "192.168.1.9", "fd00::2"]) {
assert.equal(isTrustedProxy(p, cfg), true, p);
}
for (const p of ["8.8.8.8", "2001:db8::1"]) {
assert.equal(isTrustedProxy(p, cfg), false, p);
}
});
test("the real client is taken from the right, not the left", () => {
// What nginx produces when the client sent a forged header of their own.
const ip = resolveClientIp("172.17.0.1", { forwardedFor: "1.2.3.4, 203.0.113.9" }, cfg);
assert.equal(ip, "203.0.113.9", "the entry our own proxy observed");
});
test("a forged chain cannot move the rate-limit key", () => {
const forged = ["9.9.9.9", "8.8.8.8, 7.7.7.7", "203.0.113.1, 203.0.113.2, 203.0.113.3"];
const seen = forged.map((f) => resolveClientIp("127.0.0.1", { forwardedFor: `${f}, 198.51.100.7` }, cfg));
assert.deepEqual(seen, ["198.51.100.7", "198.51.100.7", "198.51.100.7"], "always the same real client");
});
test("hops we run ourselves are skipped over", () => {
// client → our edge proxy → our app proxy → us
const ip = resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7, 10.0.0.2, 10.0.0.3" }, cfg);
assert.equal(ip, "198.51.100.7");
});
test("a peer we do not run is believed only about itself", () => {
const ip = resolveClientIp("8.8.8.8", { forwardedFor: "1.2.3.4" }, cfg);
assert.equal(ip, "8.8.8.8", "an untrusted peer cannot name its own client");
});
test("forwarding headers are ignored entirely when the proxy is not trusted", () => {
assert.equal(resolveClientIp("203.0.113.5", { forwardedFor: "1.2.3.4", realIp: "5.6.7.8" }, direct), "203.0.113.5");
});
test("X-Real-IP is a fallback, never an override", () => {
assert.equal(resolveClientIp("127.0.0.1", { realIp: "198.51.100.7" }, cfg), "198.51.100.7");
assert.equal(
resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7", realIp: "1.2.3.4" }, cfg),
"198.51.100.7",
"the chain wins where there is one",
);
});
test("junk in the chain is discarded rather than used as a key", () => {
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip, 198.51.100.7" }, cfg), "198.51.100.7");
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip" }, cfg), "127.0.0.1", "falls back to the peer");
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
});
test("bracketed and IPv4-mapped forms are normalised", () => {
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
});
test("an explicit trusted list replaces the defaults", () => {
const only = { trustProxy: true, trustedProxies: ["203.0.113.0/24"] };
assert.equal(resolveClientIp("203.0.113.9", { forwardedFor: "198.51.100.7" }, only), "198.51.100.7");
// Loopback is no longer trusted once a list is given.
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7" }, only), "127.0.0.1");
});
test("a chain of nothing but our own proxies still yields an address", () => {
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "10.0.0.2, 10.0.0.3" }, cfg), "10.0.0.2");
});
+99
View File
@@ -0,0 +1,99 @@
import { isIP } from "node:net";
/**
* Work out who is really talking to us, for rate limiting and session records.
*
* `X-Forwarded-For` is a list that each hop appends to, so the entry nearest
* the right is the one our own proxy observed and the entries to its left were
* supplied by whoever came before — including the client. nginx's
* `$proxy_add_x_forwarded_for` appends, so a client sending
* `X-Forwarded-For: 1.2.3.4` arrives as `1.2.3.4, <their real address>`:
* reading the leftmost entry hands an attacker a rate-limit key they can
* change at will. Read from the right instead, skipping hops we run ourselves,
* and only believe the header at all when the peer is a proxy we trust.
*/
/** Peers whose forwarding headers are believed when none are configured. */
const DEFAULT_TRUSTED = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
export interface TrustConfig {
trustProxy: boolean;
/** CIDRs or bare addresses; empty means DEFAULT_TRUSTED. */
trustedProxies: string[];
}
function toBits(addr: string): { value: bigint; width: number } | null {
const v = isIP(addr);
if (v === 4) {
const parts = addr.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return { value: parts.reduce((acc, n) => (acc << 8n) | BigInt(n), 0n), width: 32 };
}
if (v === 6) {
// Expand "::" and any embedded IPv4 tail into eight 16-bit groups.
let text = addr;
const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(text);
if (tail) {
const b = tail[1]!.split(".").map(Number);
text = `${text.slice(0, tail.index)}:${((b[0]! << 8) | b[1]!).toString(16)}:${((b[2]! << 8) | b[3]!).toString(16)}`;
}
const [head, rest] = text.split("::");
const left = head ? head.split(":").filter(Boolean) : [];
const right = rest !== undefined ? (rest ? rest.split(":").filter(Boolean) : []) : null;
const groups = right === null ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right];
if (groups.length !== 8) return null;
let value = 0n;
for (const g of groups) {
const n = parseInt(g, 16);
if (!Number.isInteger(n) || n < 0 || n > 0xffff) return null;
value = (value << 16n) | BigInt(n);
}
return { value, width: 128 };
}
return null;
}
/** Is `addr` inside `range`, which may be a CIDR or a single address? */
export function inRange(addr: string, range: string): boolean {
const [net, bitsText] = range.trim().split("/");
const a = toBits(addr);
const n = toBits(net ?? "");
if (!a || !n || a.width !== n.width) return false;
const bits = bitsText === undefined ? n.width : Number(bitsText);
if (!Number.isInteger(bits) || bits < 0 || bits > n.width) return false;
if (bits === 0) return true;
const shift = BigInt(n.width - bits);
return a.value >> shift === n.value >> shift;
}
export function isTrustedProxy(addr: string, cfg: TrustConfig): boolean {
const ranges = cfg.trustedProxies.length ? cfg.trustedProxies : DEFAULT_TRUSTED;
return ranges.some((r) => inRange(addr, r));
}
export interface ForwardHeaders {
forwardedFor?: string;
realIp?: string;
}
/**
* The client address to attribute a request to. `peer` is the socket address,
* which is the only part nobody downstream can forge.
*/
export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: TrustConfig): string {
if (!cfg.trustProxy || !peer || peer === "unknown") return peer || "unknown";
// A peer we do not run is not allowed to tell us who its client is.
if (!isTrustedProxy(peer, cfg)) return peer;
const chain = (headers.forwardedFor ?? "")
.split(",")
.map((s) => s.trim().replace(/^\[|\]$/g, "").replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, ""))
.filter((s) => isIP(s) !== 0);
// Rightmost first: the last hop we trust is ours, anything left of the first
// untrusted entry was written by someone we have no reason to believe.
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxy(chain[i]!, cfg)) return chain[i]!;
}
if (chain.length) return chain[0]!;
const real = headers.realIp?.trim();
return real && isIP(real) !== 0 ? real : peer;
}
+104
View File
@@ -0,0 +1,104 @@
import { resolveVersion } from "../../scripts/version.mjs";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
function loadDotEnv() {
const candidates = [resolve(process.cwd(), ".env"), fileURLToPath(new URL("../../.env", import.meta.url)), fileURLToPath(new URL("../.env", import.meta.url))];
for (const file of candidates) {
if (!existsSync(file)) continue;
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
if (!m || line.trim().startsWith("#")) continue;
let v = m[2]!;
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (process.env[m[1]!] === undefined) process.env[m[1]!] = v;
}
break;
}
}
loadDotEnv();
function env(name: string, fallback?: string): string {
const v = process.env[name];
if (v === undefined || v === "") {
if (fallback === undefined) throw new Error(`Missing required environment variable ${name}`);
return fallback;
}
return v;
}
function bool(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v === undefined || v === "") return fallback;
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
}
function int(name: string, fallback: number): number {
const v = process.env[name];
if (v === undefined || v === "") return fallback;
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n)) throw new Error(`Invalid integer for ${name}: ${v}`);
return n;
}
const isProd = process.env.NODE_ENV === "production";
let appSecret = process.env.APP_SECRET ?? "";
if (!appSecret || appSecret === "change-me") {
if (isProd) {
throw new Error("APP_SECRET must be set to a strong random value in production");
}
appSecret = randomBytes(32).toString("base64");
console.warn(
"[ihasmail] APP_SECRET not set - using an ephemeral secret (persisted sessions will not survive restarts)",
);
}
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
export const config = {
isProd,
appName: env("APP_NAME", "ihasmail"),
/**
* What this build calls itself: `2.16.57`. Set by the image build from
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
* the build context and nothing in there could work it out. A dev checkout
* has git, so it falls back to asking; see `scripts/version.mjs`.
*/
version: resolveVersion(),
/**
* Where this instance's source can be had, shown to everyone who reaches it.
*
* The AGPL asks whoever *runs* a modified version to offer that version's
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080),
stalwartUrl,
appSecret,
trustProxy: bool("TRUST_PROXY", true),
/**
* Peers whose X-Forwarded-* headers are believed. Empty falls back to
* loopback and the private ranges, which covers the usual reverse proxy on
* the same host or Docker network. A peer outside this is attributed by its
* socket address whatever it claims.
*/
trustedProxies: (process.env.TRUSTED_PROXIES ?? "").split(",").map((s) => s.trim()).filter(Boolean),
/** "auto" = Secure when the request arrived over https; "1"/"0" to force. */
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
sessionFile: process.env.SESSION_FILE ?? "",
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
imageProxy: bool("IMAGE_PROXY", true),
cookieName: env("COOKIE_NAME", "ihm_session"),
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
};
export type Config = typeof config;
Binary file not shown.
+120
View File
@@ -0,0 +1,120 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
import { AddressInfo } from "node:net";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-image-proxy";
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
const { createApp } = await import("./app.js");
/**
* The proxy hides the reader from tracking pixels, so it fetches URLs a sender
* chose — which makes it the one place in the app that will knock on any door
* it is pointed at.
*/
test("addresses we must never reach are recognised", () => {
for (const a of [
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", // cloud metadata, the classic SSRF target
"100.64.0.1", "0.0.0.0", "224.0.0.1",
"::1", "::", "fe80::1", "fd00::1", "fc00::1",
"ff02::1", // multicast
"::ffff:127.0.0.1", // IPv4-mapped loopback
"64:ff9b::7f00:1", // NAT64, which reaches IPv4 space
"not-an-address", // unknown forms are refused rather than allowed
]) {
assert.equal(isPrivateAddress(a), true, a);
}
for (const a of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "172.32.0.1", "2001:db8::1"]) {
assert.equal(isPrivateAddress(a), false, a);
}
});
/**
* The interesting half. Checking a name and then handing the *name* to a
* fetching library leaves a gap: it resolves again when the socket opens, and
* whoever controls the zone can answer differently the second time — the first
* answer passes the check, the second points at localhost.
*
* Two servers on the same port at different addresses settle it without
* depending on how this machine resolves anything: `localhost` reaches one of
* them, and the pin has to reach the other.
*/
const PORT = 18811;
const RESOLVED = "::1"; // what "localhost" gets you
const PINNED = "127.0.0.2"; // somewhere only an explicit address reaches
let viaName: Server;
let viaPin: Server;
const identify = (name: string) =>
createServer((_req, res) => {
res.writeHead(200, { "content-type": "image/png" });
res.end(name);
});
before(async () => {
viaName = identify("reached-by-name");
viaPin = identify("reached-by-pin");
await new Promise<void>((r, j) => viaName.listen(PORT, RESOLVED, r).on("error", j));
await new Promise<void>((r, j) => viaPin.listen(PORT, PINNED, r).on("error", j));
});
after(() => {
viaName?.close();
viaPin?.close();
});
const read = async (res: IncomingMessage) => {
res.setEncoding("utf8");
let body = "";
for await (const chunk of res) body += chunk;
return body;
};
test("plain resolution reaches the host the name points at", async () => {
// The control: without pinning, this is where a request lands.
const res = await new Promise<IncomingMessage>((resolve, reject) => {
const req = httpRequest(`http://localhost:${PORT}/who`, resolve);
req.on("error", reject);
req.end();
});
assert.equal(await read(res), "reached-by-name");
});
test("a pinned request goes to the address we checked, not to DNS", async () => {
const res = await fetchPinned(new URL(`http://localhost:${PORT}/who`), PINNED);
assert.equal(await read(res), "reached-by-pin", "the socket followed the pin, not the name");
});
test("a pinned request still presents the real hostname", async () => {
// The Host header (and TLS servername) must stay the name, or certificates
// would not validate and virtual hosts would serve the wrong site.
const seen = identify("");
let host = "";
seen.on("request", (req) => (host = String(req.headers.host)));
await new Promise<void>((r) => seen.listen(0, "127.0.0.3", r));
const p = (seen.address() as AddressInfo).port;
const res = await fetchPinned(new URL(`http://example.test:${p}/who`), "127.0.0.3");
await read(res);
seen.close();
assert.equal(host, `example.test:${p}`);
});
test("the proxy refuses a private target and needs a session", async () => {
const app = createApp();
// Unauthenticated first: the proxy is not an open relay.
const anon = await app.request("/api/image?url=http://127.0.0.1/x.png");
assert.equal(anon.status, 401);
});
test("the proxy rejects unusable URLs before resolving anything", async () => {
const app = createApp();
for (const u of ["file:///etc/passwd", "gopher://x/1", "http://user:[email protected]/x.png"]) {
const res = await app.request(`/api/image?url=${encodeURIComponent(u)}`);
// Still behind the session check, but the point is it never reaches the network.
assert.equal(res.status, 401);
}
});
+190
View File
@@ -0,0 +1,190 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { request as httpsRequest } from "node:https";
import { Readable } from "node:stream";
import type { Context } from "hono";
import { config } from "./config.js";
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
const UA = "Mozilla/5.0 (compatible; ihasmail-image-proxy)";
export function isPrivateAddress(addr: string): boolean {
const v = isIP(addr);
if (v === 4) {
const [a, b] = addr.split(".").map(Number) as [number, number];
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a >= 224) return true;
return false;
}
if (v === 6) {
const lower = addr.toLowerCase();
if (lower === "::1" || lower === "::") return true;
if (lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd")) return true;
if (lower.startsWith("ff")) return true; // multicast
if (lower.startsWith("::ffff:")) return isPrivateAddress(lower.slice(7));
if (lower.startsWith("64:ff9b:")) return true; // NAT64, reaches IPv4 space
return false;
}
return true;
}
export class BlockedTarget extends Error {}
/**
* Settle on one address for `hostname` and refuse it if it is somewhere we
* should not be reaching.
*/
async function resolveAllowed(hostname: string): Promise<string> {
const host = hostname.replace(/^\[|\]$/g, "");
if (isIP(host)) {
if (isPrivateAddress(host)) throw new BlockedTarget(host);
return host;
}
const addrs = await lookup(host, { all: true });
if (!addrs.length) throw new BlockedTarget(host);
// Every answer has to be acceptable: one bad record is enough to mean the
// name is not something we should be fetching at all.
for (const a of addrs) if (isPrivateAddress(a.address)) throw new BlockedTarget(a.address);
return addrs[0]!.address;
}
/**
* Fetch, connecting to `addr` rather than whatever DNS says at the moment the
* socket opens.
*
* Checking a name and then handing the name to a fetching library leaves a gap:
* the library resolves again, and an attacker who controls the zone can answer
* differently the second time — the first answer passes the check, the second
* points at localhost. Pinning the address closes the gap. TLS is unaffected:
* the certificate is still validated against the hostname, which is what
* `servername` and the Host header carry.
*/
export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promise<IncomingMessage> {
const family = isIP(addr) === 6 ? 6 : 4;
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const req = send(
url,
{
/*
* Called instead of a real resolution, so the socket goes exactly where
* we decided it should. Node asks for every address at once when it is
* picking a family itself (autoSelectFamily), and for a single one
* otherwise; answer in whichever shape was asked for.
*/
lookup: (_hostname: string, opts: { all?: boolean }, cb: (err: Error | null, address: string | { address: string; family: number }[], family?: number) => void) =>
opts?.all ? cb(null, [{ address: addr, family }]) : cb(null, addr, family),
servername: isIP(url.hostname) ? undefined : url.hostname,
// A pooled socket is keyed by host and port, not by the address we
// pinned, so a connection opened earlier would be reused and the pin
// never consulted. Take a fresh socket every time.
agent: false,
headers: { accept: "image/avif,image/webp,image/*,*/*;q=0.8", "user-agent": UA, host: url.host },
signal,
},
resolve,
);
req.on("error", reject);
req.end();
});
}
/**
* Gmail-style remote content proxy: hides the reader's IP address and
* user-agent from tracking pixels, and blocks SSRF to internal networks.
*/
export async function imageProxyHandler(c: Context) {
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
const raw = c.req.query("url") ?? "";
let url: URL;
try {
url = new URL(raw);
} catch {
return c.json({ error: "bad_url" }, 400);
}
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
let res: IncomingMessage;
try {
let addr: string;
try {
addr = await resolveAllowed(url.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
}
res = await fetchPinned(url, addr, controller.signal);
// Follow a limited number of redirects, re-checking and re-pinning each hop.
let hops = 0;
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
const loc = res.headers.location;
if (!loc) break;
res.resume(); // discard the redirect body
const next = new URL(loc, url);
if (next.protocol !== "http:" && next.protocol !== "https:") {
clearTimeout(timer);
return c.json({ error: "bad_redirect" }, 400);
}
try {
addr = await resolveAllowed(next.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
}
url = next;
res = await fetchPinned(url, addr, controller.signal);
hops++;
}
} catch {
clearTimeout(timer);
return c.json({ error: "fetch_failed" }, 502);
}
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
clearTimeout(timer);
res.resume();
return c.json({ error: "fetch_failed" }, 502);
}
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!type.startsWith("image/") || type === "image/svg+xml") {
clearTimeout(timer);
res.resume();
return c.json({ error: "not_image" }, 415);
}
const len = Number(res.headers["content-length"] ?? "0");
if (len > MAX_IMAGE_BYTES) {
clearTimeout(timer);
res.resume();
return c.json({ error: "too_large" }, 413);
}
// Enforce the size limit while streaming.
let total = 0;
const limiter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller2) {
total += chunk.byteLength;
if (total > MAX_IMAGE_BYTES) controller2.error(new Error("too large"));
else controller2.enqueue(chunk);
},
});
res.on("close", () => clearTimeout(timer));
const headers = new Headers({
"Content-Type": type,
"Cache-Control": "private, max-age=86400",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cross-Origin-Resource-Policy": "same-origin",
});
if (len) headers.set("Content-Length", String(len));
const body = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>;
return new Response(body.pipeThrough(limiter), { status: 200, headers });
}
+27
View File
@@ -0,0 +1,27 @@
import { serve } from "@hono/node-server";
import { config } from "./config.js";
import { createApp, sessions } from "./app.js";
async function main() {
await sessions.init();
const app = createApp();
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`);
console.log(`[ihasmail] static dir: ${config.staticDir}`);
});
const shutdown = async (signal: string) => {
console.log(`[ihasmail] ${signal} received, shutting down`);
server.close();
await sessions.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
main().catch((err) => {
console.error("[ihasmail] fatal:", err);
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
* and it matters that it is enforced *there*: the alternative is signing
* someone in and letting Files, the account locale and self-service
* credentials each fail in their own way, with nothing to connect the three or
* to say what the real problem is.
*
* The refusal also has to keep two things apart that look the same from the
* outside. Bad credentials are a 401 the user can fix by typing again; an
* unsupported server is not, and telling someone their password is wrong when
* it is not would send them round in circles.
*/
const PORT = 18799;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const app = createApp();
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
}
before(() => {
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("a server without the registry is refused, with good credentials", async () => {
const res = await login({ username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 501);
assert.equal(res.body.error, "unsupported_server");
});
test("the message says the credentials were fine, and names the way out", async () => {
const { body } = await login({ username: "[email protected]", password: "demo-password" });
// Someone hitting this has typed a correct password. Saying so is the
// difference between "upgrade your server" and "try your password again".
assert.match(body.message, /credentials are fine/i);
assert.match(body.message, /0\.16/);
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
});
test("no session is minted for a server we cannot talk to", async () => {
// A cookie here would leave a signed-in session against a server every
// other request is going to fail on.
const res = await login({ username: "[email protected]", password: "demo-password" });
assert.equal(res.setCookie, null);
});
test("bad credentials on such a server are still a 401, not the server error", async () => {
// The upstream session request fails first, and that answer is the honest
// one: we never got far enough to learn what the server supports.
const res = await login({ username: "[email protected]", password: "wrong-password" });
assert.equal(res.status, 401);
assert.notEqual(res.body.error, "unsupported_server");
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
const NOW = Date.parse("2026-08-24T12:00:00Z");
const envelope = (parameters: Record<string, string> | null) => ({
mailFrom: { email: "[email protected]", ...(parameters ? { parameters } : {}) },
rcptTo: [{ email: "[email protected]" }],
});
describe("FUTURERELEASE parameters", () => {
it("reads HOLDUNTIL as an RFC 3339 date-time", () => {
const at = holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z" }), NOW);
assert.equal(at, Date.parse("2026-11-20T05:00:00Z"));
});
it("reads HOLDFOR as a count of seconds from now", () => {
assert.equal(holdUntilOf(envelope({ HOLDFOR: "3600" }), NOW), NOW + 3_600_000);
});
it("matches the parameter name whatever its case, as an SMTP parser does", () => {
assert.equal(holdUntilOf(envelope({ holduntil: "2026-11-20T05:00:00Z" }), NOW), Date.parse("2026-11-20T05:00:00Z"));
});
it("means send now when neither parameter is present", () => {
assert.equal(holdUntilOf(envelope(null), NOW), null);
assert.equal(holdUntilOf(envelope({}), NOW), null);
assert.equal(holdUntilOf(undefined, NOW), null);
});
it("refuses both parameters at once, as Stalwart does with a 501", () => {
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z", HOLDFOR: "600" }), NOW)));
});
it("refuses values that will not parse", () => {
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "next tuesday" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "soon" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "0" }), NOW)));
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "-60" }), NOW)));
});
it("accepts a Unix timestamp only as the date it is not", () => {
// 0.16.16 briefly wanted seconds-since-epoch here; 0.16.17 restored RFC
// 3339. A bare number must not be mistaken for a valid hold.
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "1795000000" }), NOW)));
});
});
describe("undoStatus", () => {
const sub = (sendAt: string, undoStatus: string | null = null) => ({ sendAt, undoStatus });
it("is pending while the release time is still ahead", () => {
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z"), NOW), "pending");
});
it("is final once the release time has passed", () => {
assert.equal(undoStatusOf(sub("2026-08-24T11:59:59Z"), NOW), "final");
assert.equal(undoStatusOf(sub("2026-08-24T12:00:00Z"), NOW), "final");
});
it("stays canceled regardless of the clock", () => {
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z", "canceled"), NOW), "canceled");
assert.equal(undoStatusOf(sub("2026-01-01T00:00:00Z", "canceled"), NOW), "canceled");
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* FUTURERELEASE (RFC 4865) as Stalwart applies it to a JMAP envelope.
*
* A client asks for a delayed send by putting `HOLDUNTIL` (a date-time) or
* `HOLDFOR` (seconds) in the `mailFrom` parameters; Stalwart hands those to its
* RFC 5321 parameter parser and derives `sendAt` from the result. `sendAt` is
* never something the client sets. Kept apart from the mock server itself so
* the rules can be tested without binding a port.
*/
export type Obj = Record<string, unknown>;
/** Neither parameter given. */
export const NO_HOLD = null;
/** The parameters are contradictory or unparseable; the create must fail. */
export const BAD_HOLD = NaN;
function lookup(params: Obj, name: string): string | undefined {
const key = Object.keys(params).find((k) => k.toUpperCase() === name);
return key === undefined ? undefined : String(params[key]);
}
/**
* The instant an envelope asks to be released: null for "send it now", NaN for
* parameters the server would refuse.
*/
export function holdUntilOf(envelope: Obj | undefined, now: number): number | null {
const params = ((envelope?.mailFrom as Obj | undefined)?.parameters ?? {}) as Obj;
const until = lookup(params, "HOLDUNTIL");
const forSecs = lookup(params, "HOLDFOR");
// "501 5.5.4 Only one of HOLDFOR or HOLDUNTIL may be specified."
if (until !== undefined && forSecs !== undefined) return BAD_HOLD;
if (until !== undefined) {
const t = Date.parse(until);
return Number.isNaN(t) ? BAD_HOLD : t;
}
if (forSecs !== undefined) {
const secs = Number(forSecs);
return Number.isFinite(secs) && secs > 0 ? now + secs * 1000 : BAD_HOLD;
}
return NO_HOLD;
}
/**
* Pending while the message is still in the queue, which is what Stalwart
* reports: `undoStatus` is read off the spool, not stored on the submission.
*/
export function undoStatusOf(sub: Obj, now: number): "pending" | "final" | "canceled" {
if (sub.undoStatus === "canceled") return "canceled";
return Date.parse(String(sub.sendAt)) > now ? "pending" : "final";
}
+861
View File
@@ -0,0 +1,861 @@
/**
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
* uses. For local development and demos only: `npm run mock` then point the
* server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788);
/**
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
* against a server ihasmail does not support. This is only that: the rest of
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it.
*/
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
/**
* Stalwart advertises FUTURERELEASE in the session but only honours it when
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
* off, in which case the hold is dropped without a word and the message goes
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
*/
const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1";
const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
const PASS = process.env.MOCK_PASS ?? "demo";
/**
* Credential state, mutable so the self-service flows can be exercised against
* the mock the way they run against a real 0.16 server: the password changes,
* 2FA starts demanding a code on every request, and app passwords keep working
* without one.
*/
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
const MASKED = "[********]";
type Obj = Record<string, unknown>;
const state = { n: 1 };
const nextState = () => String(state.n++);
/* ---------- data ---------- */
/*
* The names are Stalwart's own defaults, which follow the Exchange convention:
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
* short forms, so anything built from a folder's name read differently here
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
* against a real server -- and every screenshot in the README showed a folder
* list no user has. The role is what the client branches on; the name is only
* ever displayed, which is exactly why it has to look right.
*/
/** Push subscriptions, as a fresh account has none. */
const pushSubscriptions: Obj[] = [];
const mailboxes: Obj[] = [
mb("inbox", "Inbox", "inbox"),
mb("drafts", "Drafts", "drafts"),
mb("sent", "Sent Items", "sent"),
mb("junk", "Junk Mail", "junk"),
mb("trash", "Deleted Items", "trash"),
mb("archive", "Archive", "archive"),
mb("work", "Work", null),
mb("work-inv", "Invoices", null, "work"),
mb("news", "Newsletters", null),
];
function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj {
return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } };
}
const blobs = new Map<string, { type: string; data: Buffer }>();
function putBlob(data: Buffer | string, type: string): string {
const id = `b${randomUUID().slice(0, 8)}`;
blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) });
return id;
}
const people = [
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
];
const subjects = [
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
"Reminder: dentist appointment", "Flight confirmation BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
];
const emails: Obj[] = [];
let counter = 1;
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) {
const id = `e${counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
const textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(html, "text/html");
const attachments: Obj[] = [];
if (o.attach) {
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
}
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
const e: Obj = {
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true },
keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) },
size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received,
messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null,
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
attachments,
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
};
emails.push(e);
return e;
}
// Seed
for (let i = 0; i < 45; i++) {
const p = people[i % people.length]!;
const subj = subjects[i % subjects.length]!;
const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 });
if (i % 4 === 0) {
// thread replies
addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true });
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
}
}
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
// Invitation email
{
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
const e = addEmail({ from: ["Ada Lovelace", "[email protected]"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true });
const b = putBlob(ics, "text/calendar");
(e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }];
(e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null });
e.hasAttachment = true;
}
const identities: Obj[] = [
{ id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
{ id: "i2", name: "Demo (alias)", email: "[email protected]", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true },
];
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
const sieveScripts: Obj[] = [];
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
const events: Obj[] = [];
{
const now = new Date();
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
}
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }];
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
const [given, surname] = p[0]!.split(" ");
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined };
});
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
const fileNodes: Obj[] = [
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" },
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() },
];
function fr() {
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
}
function recount() {
for (const m of mailboxes) {
const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]);
m.totalEmails = inBox.length;
m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length;
const threads = new Set(inBox.map((e) => e.threadId));
m.totalThreads = threads.size;
m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size;
}
}
recount();
/* ---------- helpers ---------- */
function pick(o: Obj, props?: string[] | null): Obj {
if (!props) return o;
const out: Obj = { id: o.id };
for (const p of props) if (p in o) out[p] = o[p];
else if (p.startsWith("header:")) out[p] = null;
return out;
}
function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
const out: Obj = {};
for (const [k, v] of Object.entries(args)) {
if (k.startsWith("#")) {
const r = v as { resultOf: string; name: string; path: string };
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
} else out[k] = resolveCreationIds(v, creations, k);
}
return out;
}
/**
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
* go, pointing at something created earlier in the same request. Sending a
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
* without this the mock quietly declines to create any submission at all.
*
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
* the method that receives them resolves them itself.
*/
function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
if (key === "onSuccessUpdateEmail") return value;
if (typeof value === "string") {
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
}
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
if (value && typeof value === "object") {
const out: Obj = {};
for (const [k, v] of Object.entries(value as Obj)) {
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
out[nk] = resolveCreationIds(v, creations, k);
}
return out;
}
return value;
}
function jsonPointer(obj: unknown, path: string): unknown {
const parts = path.split("/").filter(Boolean);
let cur: unknown = obj;
for (let i = 0; i < parts.length; i++) {
const p = parts[i]!;
if (p === "*") {
const rest = parts.slice(i + 1).join("/");
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
return arr;
}
cur = (cur as Obj)?.[p];
}
return cur;
}
function matchFilter(e: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.operator) {
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
}
const kw = e.keywords as Obj;
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
if (f.notKeyword && kw[f.notKeyword as string]) return false;
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
if (f.after && String(e.receivedAt) < String(f.after)) return false;
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
return true;
}
function applyPatch(obj: Obj, patch: Obj) {
for (const [k, v] of Object.entries(patch)) {
if (k.includes("/")) {
const [root, ...rest] = k.split("/");
const key = rest.join("/");
const target = (obj[root!] as Obj) ?? {};
if (v === null) delete target[key];
else target[key] = v;
obj[root!] = target;
} else obj[k] = v;
}
}
/* ---------- method handlers ---------- */
type Handler = (args: Obj) => Obj | [string, Obj][];
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
class MethodError extends Error {
constructor(
public readonly type: string,
description?: string,
) {
super(description ?? type);
}
}
const MAX_OBJECTS = 500;
/**
* Stalwart refuses a whole method call that carries more objects than it will
* process at once - it does not quietly handle the first 500. Enforce the same
* ceiling the session advertises, so an unbatched client fails here too.
*/
function enforceLimits(name: string, args: Obj): void {
const tooLarge = () => {
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
};
if (name.endsWith("/get")) {
const ids = args.ids as unknown[] | null | undefined;
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
}
if (name.endsWith("/set")) {
const n =
Object.keys((args.create as Obj) ?? {}).length +
Object.keys((args.update as Obj) ?? {}).length +
((args.destroy as unknown[] | undefined)?.length ?? 0);
if (n > MAX_OBJECTS) tooLarge();
}
}
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
function genericGet(list: Obj[]) {
return (a: Obj) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
}
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
}
function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
return (a: Obj) => {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `${prefix}${randomUUID().slice(0, 6)}`;
const o = { ...(obj as Obj), id };
try {
onCreate?.(o);
} catch (err) {
if (!(err instanceof SetError)) throw err;
notCreated[cid] = err.toJSON();
continue;
}
list.push(o);
created[cid] = { id };
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const o = list.find((x) => x.id === id);
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
}
for (const id of (a.destroy as string[]) ?? []) {
const i = list.findIndex((x) => x.id === id);
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
}
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
};
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
* envelope's FUTURERELEASE parameter rather than set by the client, and
* `undoStatus` reports whether the message is still in the queue.
*/
const submissions: Obj[] = [];
function submissionView(sub: Obj): Obj {
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
}
function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
return true;
}
const handlers: Record<string, Handler> = {
// 0.16 exposes the account locale here, under a permission ordinary users
// actually have (unlike x:Account below, which needs sysAccountGet).
"x:AccountSettings/get": (a) => {
const ids = (a.ids as string[] | null) ?? ["singleton"];
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
},
// Stalwart's directory extension - the client reads the account locale from here.
"x:Account/get": (a) => {
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
},
"Mailbox/get": genericGet(mailboxes),
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
"Email/query": (a) => {
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
if (a.collapseThreads) {
const seen = new Set<string>();
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
}
const pos = Number(a.position ?? 0);
const limit = Number(a.limit ?? 50);
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
},
"Email/get": (a) => genericGet(emails)(a),
"Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
"Email/set": (a) => {
const r = genericSet(emails, "e", (o) => {
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); };
const parts: Obj[] = [];
walk(o.bodyStructure as Obj, parts);
o.textBody = parts.filter((p) => p.type === "text/plain");
o.htmlBody = parts.filter((p) => p.type === "text/html");
o.attachments = [];
const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); };
collect(o.bodyStructure as Obj);
o.hasAttachment = (o.attachments as Obj[]).length > 0;
o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`;
o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
o.size = 2000;
o.preview = (bv.text?.value ?? "").slice(0, 100);
o.messageId = [`${o.id}@mock`];
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
})(a);
recount();
return r;
},
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
// Stalwart 0.16 registry objects backing self-service credentials.
"x:AccountPassword/get": () => ({
accountId: ACCOUNT,
state: String(state.n),
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
notFound: [],
}),
"x:AccountPassword/set": (a) => {
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
if (!patch) return setResp({ updated: {} });
const current = patch.currentSecret as string | undefined;
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
if (!current) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
}
if (current !== account.password) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
if (account.otpUrl && !code) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
}
if (account.otpUrl && !checkOtp(code!)) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
const secret = patch.secret as string | undefined;
if (secret !== undefined && secret !== MASKED) {
if (secret.length < 8) {
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
}
account.password = secret;
}
if ("otpAuth/otpUrl" in patch) {
const url = patch["otpAuth/otpUrl"] as string | null;
if (url !== MASKED) account.otpUrl = url;
}
state.n++;
return setResp({ updated: { singleton: null } });
},
/*
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
* runs through the browser vendor's real push service, so nothing local will
* ever make a notification appear.
*
* What is worth reproducing is the handshake, because it is the part that
* fails quietly: a subscription is created unverified and stays silent until
* the client echoes back a code the server pushed. A mock that marked one
* verified on creation would let a client ship without ever implementing
* that, and the symptom in production is "registered, and no notifications".
*/
"PushSubscription/get": (a) => {
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
// `keys` is write-only in JMAP: the server never hands it back.
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
},
"PushSubscription/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o = obj as Obj;
const keys = (o.keys ?? {}) as Obj;
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
// API produces; padding it would be the client inventing a shape.
for (const k of ["p256dh", "auth"]) {
const v = String(keys[k] ?? "");
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
if (v.includes("=") || v.includes("+") || v.includes("/")) {
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
break;
}
}
if (notCreated[cid]) continue;
if (!String(o.url ?? "").startsWith("https://")) {
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
continue;
}
// A filter condition with a null value is not a filter -- the real server
// answers "Invalid filter" and refuses the whole subscription. ihasmail
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
// all here, and the mock accepted it happily. It does not any more.
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
return Object.values(f).some((v) => v === null || v === undefined);
});
if (badFilter) {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
// One per device: re-subscribing replaces rather than accumulates.
const deviceId = String(o.deviceClientId ?? "");
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
if (clash >= 0) pushSubscriptions.splice(clash, 1);
const id = `ps${randomUUID().slice(0, 6)}`;
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
created[cid] = { id, expires: null };
state.n++;
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const s = pushSubscriptions.find((x) => x.id === id);
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
const code = (patch as Obj).verificationCode;
if (code !== undefined) {
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
s.verified = true;
}
updated[id] = null;
state.n++;
}
for (const id of (a.destroy as string[]) ?? []) {
const i = pushSubscriptions.findIndex((x) => x.id === id);
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
}
return setResp({ created, notCreated, updated, notUpdated, destroyed });
},
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => {
const created: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `ap${randomUUID().slice(0, 6)}`;
// Real app passwords carry their credential id, so the server can spot
// one by its shape alone. Mirror that.
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
account.appPasswords.push(row);
created[cid] = { id, secret, createdAt: row.createdAt };
}
for (const id of (a.destroy as string[]) ?? []) {
const i = account.appPasswords.findIndex((x) => x.id === id);
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
}
state.n++;
return setResp({ created, destroyed });
},
"Identity/get": genericGet(identities),
"Identity/set": (a) => {
// Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of
// UTF-8, not characters. Anything longer is refused by name.
for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) {
for (const [key, obj] of Object.entries(entries)) {
const over = ["htmlSignature", "textSignature"].find((prop) => {
const v = (obj as Obj)[prop];
return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047;
});
if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } });
}
}
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
},
"EmailSubmission/get": (a) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
},
"EmailSubmission/query": (a) => {
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
const pos = Number(a.position ?? 0);
const limit = Number(a.limit ?? 50);
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
},
"EmailSubmission/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
const sub = raw as Obj;
const emailId = sub.emailId as string;
const e = emails.find((x) => x.id === emailId);
if (!e) {
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
continue;
}
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
if (Number.isNaN(hold)) {
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
continue;
}
// Stalwart rejects MAIL FROM outright past its own limit.
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
continue;
}
// With the MTA extension off, the hold is dropped in silence.
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
const rec: Obj = {
id: `s${randomUUID().slice(0, 6)}`,
identityId: sub.identityId ?? null,
emailId,
threadId: e.threadId ?? null,
envelope: sub.envelope ?? null,
sendAt: new Date(sendAt).toISOString(),
undoStatus: null,
deliveryStatus: null,
};
submissions.push(rec);
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
if (patch) applyPatch(e, patch);
}
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
const patch = raw as Obj;
const sub = submissions.find((x) => x.id === id);
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
if (patch.undoStatus !== "canceled") {
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
continue;
}
const status = undoStatusOf(sub, Date.now());
if (status !== "pending") {
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
continue;
}
sub.undoStatus = "canceled";
updated[id] = null;
}
recount();
return setResp({
created,
updated,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
});
},
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacation], notFound: [] }),
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacation = { ...vacation, ...p }; return setResp({ updated: { singleton: null } }); },
"Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }),
"SieveScript/get": genericGet(sieveScripts),
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": genericGet(calendars),
"Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })),
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
"CalendarEvent/get": genericGet(events),
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": genericSet(events, "ev", (o) => {
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
return Object.assign(o, { uid: o.uid ?? randomUUID() });
}),
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals),
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
"AddressBook/get": genericGet(addressBooks),
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
"ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }),
"ContactCard/get": genericGet(cards),
"ContactCard/set": genericSet(cards, "cc"),
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {};
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
},
"FileNode/get": genericGet(fileNodes),
"FileNode/set": (a) => {
return genericSet(fileNodes, "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
// Without nodeType, a node is a directory precisely when it carries no
// file properties. Keep it internally so query and get stay consistent.
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
})(a);
},
};
/* ---------- http ---------- */
function unauthorized(res: ServerResponse) {
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
}
function checkOtp(code: string | undefined): boolean {
if (!account.otpUrl) return true;
const params = parseOtpauthUrl(account.otpUrl);
return Boolean(code && params && verifyTotp(params, code));
}
function checkAuth(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return false;
const raw = Buffer.from(h.slice(6), "base64").toString();
const sep = raw.indexOf(":");
if (sep < 0) return false;
const u = raw.slice(0, sep);
const p = raw.slice(sep + 1);
if (u !== USER) return false;
// App passwords are recognised by shape and skip the second factor, which is
// exactly what lets a webmail session survive 2FA being switched on.
if (account.appPasswords.some((a) => a.secret === p)) return true;
if (!account.otpUrl) return p === account.password;
const at = p.lastIndexOf("$");
if (at < 0) return false;
return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1));
}
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); });
}
const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
"urn:ietf:params:jmap:emailpush": {},
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`,
eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`,
state: String(state.n),
});
const sseClients = new Set<ServerResponse>();
function broadcast(types: string[]) {
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
for (const c of sseClients) c.write(payload);
}
/** Exported so tests can drive the mock in-process and shut it down. */
export const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
if (!checkAuth(req)) return unauthorized(res);
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify(session()));
}
// The account info endpoint; the only place a server reports its edition.
if (url.pathname === "/api/account" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
}
if (url.pathname === "/jmap/" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
// A capability the server cannot parse fails the whole request, not the one
// call that wanted it - which is why an over-eager `using` is so damaging.
// Stalwart decides this by parsing the urn, not by looking it up in the
// session, so a capability it hands out per-account is still usable here:
// `urn:stalwart:jmap` never appears in the session-level capabilities and
// the registry calls that name it work all the same.
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u));
if (unknown) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:unknownCapability", status: 400, detail: `Unknown capability: ${JSON.stringify(unknown)}` }));
}
const responses: [string, Obj, string][] = [];
const touched = new Set<string>();
const creations: Record<string, string> = {};
for (const [name, rawArgs, id] of body.methodCalls) {
const h = handlers[name];
// The registry, and every x: method with it, arrived in 0.16.
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
try {
const args = resolveRefs(rawArgs, responses, creations);
enforceLimits(name, args);
const r = h(args);
responses.push([name, r as Obj, id]);
for (const [cid, obj] of Object.entries(((r as Obj).created as Obj) ?? {})) {
const newId = (obj as Obj)?.id;
if (typeof newId === "string") creations[cid] = newId;
}
if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!);
} catch (err) {
if (err instanceof MethodError) responses.push(["error", { type: err.type, description: err.message }, id]);
else responses.push(["error", { type: "serverFail", description: String(err) }, id]);
}
}
if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); }
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" }));
}
if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") {
const data = await readBody(req);
const type = req.headers["content-type"] ?? "application/octet-stream";
const blobId = putBlob(data, type);
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ accountId: ACCOUNT, blobId, type, size: data.length }));
}
if (url.pathname.startsWith("/jmap/download/")) {
const [, , , , blobId] = url.pathname.split("/");
const b = blobs.get(blobId ?? "");
if (!b) { res.writeHead(404); return res.end(); }
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
return res.end(b.data);
}
if (url.pathname.startsWith("/jmap/eventsource")) {
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
res.write(`event: ping\ndata: {}\n\n`);
sseClients.add(res);
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000);
req.on("close", () => { clearInterval(t); sseClients.delete(res); });
// Simulate a new message every 90s
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
});
// Periodically inject a new inbox email to demo push
setInterval(() => {
const p = people[Math.floor(Math.random() * people.length)]!;
addEmail({ from: [p[0]!, p[1]!], subject: `Live update ${new Date().toLocaleTimeString()}`, daysAgo: 0, mailbox: "inbox", unread: true, html: true });
recount();
nextState();
broadcast(["Email", "Mailbox", "Thread"]);
}, 120_000).unref();
+45
View File
@@ -0,0 +1,45 @@
/** Simple sliding-window rate limiter keyed by arbitrary string (ip, ip+user). */
export class RateLimiter {
private hits = new Map<string, number[]>();
constructor(
private readonly max: number,
private readonly windowMs: number,
) {
const t = setInterval(() => this.prune(), windowMs);
t.unref();
}
/** Returns true if the action is allowed, false if the caller should back off. */
check(key: string): boolean {
const now = Date.now();
const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
if (arr.length >= this.max) {
this.hits.set(key, arr);
return false;
}
arr.push(now);
this.hits.set(key, arr);
return true;
}
reset(key: string): void {
this.hits.delete(key);
}
retryAfterSeconds(key: string): number {
const arr = this.hits.get(key);
if (!arr || !arr.length) return 0;
const oldest = arr[0]!;
return Math.max(1, Math.ceil((this.windowMs - (Date.now() - oldest)) / 1000));
}
private prune(): void {
const now = Date.now();
for (const [k, arr] of this.hits) {
const kept = arr.filter((t) => now - t < this.windowMs);
if (kept.length) this.hits.set(k, kept);
else this.hits.delete(k);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { SessionStore } from "./sessions.js";
import { normalizeLocale } from "./upstream.js";
import { deriveKey, open, seal, sha256 } from "./crypto.js";
import { RateLimiter } from "./ratelimit.js";
import { randomBytes } from "node:crypto";
test("seal/open round-trips and rejects wrong key", () => {
const salt = randomBytes(16);
const k1 = deriveKey("cookie-secret", "app-secret", salt);
const k2 = deriveKey("other", "app-secret", salt);
const ct = seal("hello", k1);
assert.equal(open(ct, k1), "hello");
assert.equal(open(ct, k2), null);
assert.equal(sha256("a"), sha256("a"));
});
test("session store creates, resolves, and refuses tampered cookies", () => {
const store = new SessionStore("");
const { cookie, session } = store.create({ username: "[email protected]", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" });
assert.equal(session.username, "[email protected]");
const live = store.resolve(cookie);
assert.ok(live);
assert.equal(live!.authorization, `Basic ${Buffer.from("[email protected]:p4ss").toString("base64")}`);
assert.equal(store.resolve(cookie + "x"), null);
assert.equal(store.resolve("nope"), null);
assert.equal(store.listForUser("[email protected]").length, 1);
store.destroy(live!.id);
assert.equal(store.resolve(cookie), null);
});
test("persisted session data does not contain the password", () => {
const store = new SessionStore("");
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
const json = JSON.stringify(store.listForUser("u"));
assert.ok(!json.includes("super-secret-pw"));
});
test("rate limiter blocks after max hits in window", () => {
const rl = new RateLimiter(3, 60_000);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), true);
assert.equal(rl.check("k"), false);
assert.ok(rl.retryAfterSeconds("k") > 0);
rl.reset("k");
assert.equal(rl.check("k"), true);
});
test("normalizes Stalwart account locales to BCP-47 tags", () => {
assert.equal(normalizeLocale("de_DE"), "de-DE");
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
assert.equal(normalizeLocale("en"), "en");
assert.equal(normalizeLocale("POSIX"), null);
assert.equal(normalizeLocale("C"), null);
assert.equal(normalizeLocale(""), null);
assert.equal(normalizeLocale(undefined), null);
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
assert.equal(normalizeLocale("../etc/passwd"), null);
});
+237
View File
@@ -0,0 +1,237 @@
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
import { dirname } from "node:path";
import { randomBytes } from "node:crypto";
import { config } from "./config.js";
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
export interface StoredSession {
id: string;
/** sha256 of the cookie secret; used to validate presented cookies. */
secretHash: string;
/** base64 random salt for key derivation */
salt: string;
/** sealed JSON {username, password} */
sealedCredentials: string;
username: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
remember: boolean;
userAgent: string;
ip: string;
}
export interface LiveSession {
id: string;
username: string;
/** Basic Authorization header value for upstream calls. */
authorization: string;
remember: boolean;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
userAgent: string;
ip: string;
}
const COOKIE_SEP = ".";
export class SessionStore {
private sessions = new Map<string, StoredSession>();
private dirty = false;
private saveTimer: NodeJS.Timeout | null = null;
private sweepTimer: NodeJS.Timeout | null = null;
constructor(private readonly file: string) {}
async init(): Promise<void> {
if (this.file) {
try {
const raw = await readFile(this.file, "utf8");
const arr = JSON.parse(raw) as StoredSession[];
const now = Date.now();
for (const s of arr) if (s.expiresAt > now) this.sessions.set(s.id, s);
console.log(`[ihasmail] restored ${this.sessions.size} session(s)`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn("[ihasmail] could not read session file:", (err as Error).message);
}
}
}
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
this.sweepTimer.unref();
}
async close(): Promise<void> {
if (this.sweepTimer) clearInterval(this.sweepTimer);
if (this.saveTimer) clearTimeout(this.saveTimer);
await this.flush();
}
private sweep(): void {
const now = Date.now();
let removed = 0;
for (const [id, s] of this.sessions) {
if (s.expiresAt <= now) {
this.sessions.delete(id);
removed++;
}
}
if (removed) this.scheduleSave();
}
private scheduleSave(): void {
this.dirty = true;
if (!this.file || this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
void this.flush();
}, 1000);
this.saveTimer.unref();
}
private async flush(): Promise<void> {
if (!this.file || !this.dirty) return;
this.dirty = false;
try {
await mkdir(dirname(this.file), { recursive: true });
const tmp = `${this.file}.tmp`;
await writeFile(tmp, JSON.stringify([...this.sessions.values()]), { mode: 0o600 });
await rename(tmp, this.file);
} catch (err) {
console.warn("[ihasmail] could not persist sessions:", (err as Error).message);
}
}
/** Create a session; returns the cookie value to hand to the client. */
create(params: {
username: string;
password: string;
remember: boolean;
userAgent: string;
ip: string;
}): { cookie: string; session: LiveSession } {
const id = randomToken(18);
const secret = randomToken(32);
const salt = randomBytes(16);
const key = deriveKey(secret, config.appSecret, salt);
const now = Date.now();
const ttl = (params.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
const stored: StoredSession = {
id,
secretHash: sha256(secret),
salt: salt.toString("base64"),
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
username: params.username,
createdAt: now,
lastSeenAt: now,
expiresAt: now + ttl,
remember: params.remember,
userAgent: params.userAgent.slice(0, 200),
ip: params.ip,
};
this.sessions.set(id, stored);
this.scheduleSave();
const cookie = `${id}${COOKIE_SEP}${secret}`;
return { cookie, session: this.toLive(stored, params.username, params.password) };
}
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
resolve(cookie: string | undefined): LiveSession | null {
if (!cookie) return null;
const idx = cookie.indexOf(COOKIE_SEP);
if (idx <= 0) return null;
const id = cookie.slice(0, idx);
const secret = cookie.slice(idx + 1);
const stored = this.sessions.get(id);
if (!stored) return null;
const now = Date.now();
if (stored.expiresAt <= now) {
this.sessions.delete(id);
this.scheduleSave();
return null;
}
if (!safeEqual(stored.secretHash, sha256(secret))) return null;
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
const json = open(stored.sealedCredentials, key);
if (!json) return null;
let creds: { u: string; p: string };
try {
creds = JSON.parse(json) as { u: string; p: string };
} catch {
return null;
}
// Sliding expiry: bump every few minutes, not on every request.
if (now - stored.lastSeenAt > 60_000) {
stored.lastSeenAt = now;
const ttl = (stored.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
stored.expiresAt = now + ttl;
this.scheduleSave();
}
return this.toLive(stored, creds.u, creds.p);
}
/**
* Re-seal this session's stored credentials.
*
* The upstream password is what every proxied call authenticates with, so a
* password change (or swapping in an app password when 2FA is switched on)
* would otherwise leave the session holding a credential the server no
* longer accepts. Needs the cookie: the sealing key is derived from the
* secret half of it, which the server never keeps.
*/
reseal(cookie: string | undefined, password: string): boolean {
if (!cookie) return false;
const idx = cookie.indexOf(COOKIE_SEP);
if (idx <= 0) return false;
const id = cookie.slice(0, idx);
const secret = cookie.slice(idx + 1);
const stored = this.sessions.get(id);
if (!stored) return false;
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
this.scheduleSave();
return true;
}
destroy(id: string): void {
if (this.sessions.delete(id)) this.scheduleSave();
}
destroyAllForUser(username: string, exceptId?: string): number {
let n = 0;
for (const [id, s] of this.sessions) {
if (s.username === username && id !== exceptId) {
this.sessions.delete(id);
n++;
}
}
if (n) this.scheduleSave();
return n;
}
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
const out = [];
for (const s of this.sessions.values()) {
if (s.username !== username) continue;
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
out.push(rest);
}
return out;
}
private toLive(s: StoredSession, username: string, password: string): LiveSession {
return {
id: s.id,
username,
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
remember: s.remember,
createdAt: s.createdAt,
lastSeenAt: s.lastSeenAt,
expiresAt: s.expiresAt,
userAgent: s.userAgent,
ip: s.ip,
};
}
}
+101
View File
@@ -0,0 +1,101 @@
import { createReadStream } from "node:fs";
import { stat, readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
import { Readable } from "node:stream";
import type { Context, Handler } from "hono";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
};
/**
* Content Security Policy for the app shell. Inline styles are required because
* sanitized HTML email carries style attributes; everything else is strict.
*/
export const APP_CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"media-src 'self' blob:",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"worker-src 'self'",
"manifest-src 'self'",
].join("; ");
export function staticHandler(root: string): Handler {
const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null;
async function serveIndex(c: Context) {
try {
const p = join(absRoot, "index.html");
const st = await stat(p);
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
}
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
return c.body(indexCache.body);
} catch {
c.header("Content-Type", "text/plain; charset=utf-8");
return c.body("ihasmail: web build not found. Run `npm run build` first.", 503);
}
}
return async (c) => {
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
const filePath = join(absRoot, rel);
if (!filePath.startsWith(absRoot + sep)) return serveIndex(c);
try {
const st = await stat(filePath);
if (!st.isFile()) return serveIndex(c);
const ext = extname(filePath).toLowerCase();
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
c.header("Content-Length", String(st.size));
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
c.header("Cache-Control", "public, max-age=31536000, immutable");
} else if (ext === ".html") {
c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP);
} else {
c.header("Cache-Control", "public, max-age=3600");
}
if (c.req.method === "HEAD") return c.body(null);
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
return c.body(stream);
} catch {
// SPA fallback for client-side routes (no file extension) only.
if (!extname(rel)) return serveIndex(c);
return c.text("Not Found", 404);
}
};
}
+85
View File
@@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
/** RFC 6238 Appendix B seeds. */
const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii"));
const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii"));
test("base32 matches the RFC 4648 alphabet and round-trips", () => {
assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY");
assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI");
assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii"));
// Users paste secrets with spaces, lowercase and padding.
assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii"));
assert.equal(base32Decode("not base32!"), null);
});
test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "94287082"],
[1111111109, "07081804"],
[1111111111, "14050471"],
[1234567890, "89005924"],
[2000000000, "69279037"],
[20000000000, "65353130"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => {
const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "46119246"],
[1111111109, "68084774"],
[1234567890, "91819424"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp rejects wrong, malformed and mis-sized codes", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
const at = { window: 0, now: 59_000 };
assert.equal(verifyTotp(params, "94287083", at), false);
assert.equal(verifyTotp(params, "9428708", at), false, "too short");
assert.equal(verifyTotp(params, "942870822", at), false, "too long");
assert.equal(verifyTotp(params, "abcdefgh", at), false);
assert.equal(verifyTotp(params, "", at), false);
assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret");
});
test("the skew window covers a step either side and no further", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
// 94287082 is the code for the step containing t=59.
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late");
});
test("otpauth URLs round-trip through the parser", () => {
const secret = generateSecret();
const url = otpauthUrl({ secret, account: "[email protected]", issuer: "ihasmail" });
assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/);
const parsed = parseOtpauthUrl(url);
assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 });
});
test("generated secrets are 160-bit and distinct", () => {
const a = generateSecret();
const b = generateSecret();
assert.equal(base32Decode(a)?.length, 20);
assert.notEqual(a, b);
});
test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => {
assert.equal(parseOtpauthUrl("https://example.org"), null);
assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based");
assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null);
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null);
});
+145
View File
@@ -0,0 +1,145 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
*
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
* *not* check the new secret when 2FA is switched on: it verifies the
* credentials that are already on the account. A user whose authenticator was
* mistyped or whose clock has drifted would be locked out of their mailbox at
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
* server to store anything.
*/
export interface TotpParams {
secret: string;
algorithm: "SHA1" | "SHA256" | "SHA512";
digits: number;
period: number;
}
const DEFAULTS: Omit<TotpParams, "secret"> = { algorithm: "SHA1", digits: 6, period: 30 };
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = "";
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
return out;
}
/** Decode base32, tolerating lowercase, padding and the spaces users paste. */
export function base32Decode(input: string): Buffer | null {
const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
if (!clean || /[^A-Z2-7]/.test(clean)) return null;
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
value = (value << 5) | BASE32.indexOf(ch);
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
/** A fresh 160-bit secret — the size RFC 4226 recommends for HMAC-SHA1. */
export function generateSecret(): string {
return base32Encode(randomBytes(20));
}
/**
* Build the otpauth:// URL that authenticator apps scan and Stalwart stores.
* The label is "issuer:account" with the issuer repeated as a parameter, which
* is what totp-rs (Stalwart's parser) and every common app expect.
*/
export function otpauthUrl(opts: { secret: string; account: string; issuer: string }): string {
const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`;
const params = new URLSearchParams({
secret: opts.secret,
issuer: opts.issuer,
algorithm: DEFAULTS.algorithm,
digits: String(DEFAULTS.digits),
period: String(DEFAULTS.period),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
export function parseOtpauthUrl(url: string): TotpParams | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "otpauth:" || parsed.host.toLowerCase() !== "totp") return null;
const secret = parsed.searchParams.get("secret");
if (!secret || !base32Decode(secret)) return null;
const algorithm = (parsed.searchParams.get("algorithm") ?? DEFAULTS.algorithm).toUpperCase();
if (algorithm !== "SHA1" && algorithm !== "SHA256" && algorithm !== "SHA512") return null;
const digits = Number(parsed.searchParams.get("digits") ?? DEFAULTS.digits);
const period = Number(parsed.searchParams.get("period") ?? DEFAULTS.period);
if (!Number.isInteger(digits) || digits < 6 || digits > 10) return null;
if (!Number.isInteger(period) || period < 5 || period > 300) return null;
return { secret, algorithm, digits, period };
}
/** The HOTP code for one counter value. */
function hotp(key: Buffer, counter: number, algorithm: string, digits: number): string {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = createHmac(algorithm.toLowerCase(), key).update(buf).digest();
const offset = digest[digest.length - 1]! & 0x0f;
const binary = digest.readUInt32BE(offset) & 0x7fffffff;
return (binary % 10 ** digits).toString().padStart(digits, "0");
}
/** The code an authenticator app would show at `now`. */
export function totpCode(params: TotpParams, now = Date.now()): string {
const key = base32Decode(params.secret);
if (!key || !key.length) throw new Error("unusable TOTP secret");
return hotp(key, Math.floor(now / 1000 / params.period), params.algorithm, params.digits);
}
/**
* Check a user-supplied code, allowing `window` steps of clock skew either way
* (one step = 30s by default, so the default tolerates ±30s).
*/
export function verifyTotp(params: TotpParams, code: string, opts: { window?: number; now?: number } = {}): boolean {
const digits = params.digits;
const cleaned = code.replace(/\s/g, "");
if (cleaned.length !== digits || !/^\d+$/.test(cleaned)) return false;
const key = base32Decode(params.secret);
if (!key || !key.length) return false;
const window = opts.window ?? 1;
const counter = Math.floor((opts.now ?? Date.now()) / 1000 / params.period);
let ok = false;
// Check every candidate rather than returning early, so the time taken does
// not reveal which step matched.
for (let i = -window; i <= window; i++) {
const step = counter + i;
if (step < 0) continue; // only reachable for times within a step of the epoch
const expected = hotp(key, step, params.algorithm, digits);
if (safeEqual(expected, cleaned)) ok = true;
}
return ok;
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
+271
View File
@@ -0,0 +1,271 @@
import { config } from "./config.js";
export interface UpstreamSession {
capabilities: Record<string, unknown>;
accounts: Record<string, unknown>;
primaryAccounts: Record<string, string>;
username: string;
apiUrl: string;
downloadUrl: string;
uploadUrl: string;
eventSourceUrl: string;
state: string;
}
export class UpstreamError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
}
}
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
const SESSION_CACHE_MS = 5 * 60_000;
export function wellKnownUrl(): string {
return `${config.stalwartUrl}/.well-known/jmap`;
}
/**
* Fetch the JMAP session resource from Stalwart using the given Authorization
* header. Throws UpstreamError(401) on bad credentials.
*/
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
const res = await fetch(wellKnownUrl(), {
headers: { authorization, accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) {
throw new UpstreamError("Invalid credentials", 401);
}
if (!res.ok) {
throw new UpstreamError(`Upstream session request failed (${res.status})`, 502);
}
const session = (await res.json()) as UpstreamSession;
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
return session;
}
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
const cached = sessionCache.get(sessionId);
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
const session = await fetchUpstreamSession(authorization);
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
return session;
}
export function forgetUpstreamSession(sessionId: string): void {
sessionCache.delete(sessionId);
infoCache.delete(sessionId);
}
/* ------------------------------------------------------------------ */
/* Account locale */
/* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape.
*
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
* in each account's `accountCapabilities`. Checking only the session level
* therefore reported every real 0.16 server as older than 0.16 — which routed
* self-service credentials to a REST endpoint 0.16 had removed, and told the
* About page the wrong thing. The session level is still checked last, in case
* a later release advertises it there as well.
*
* This is now what sign-in tests to decide whether a server is supported at
* all, so the same mistake would lock every user out of a working server
* rather than merely misroute them.
*/
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
if (!session) return false;
if (session.primaryAccounts && STALWART_CAP in session.primaryAccounts) return true;
for (const account of Object.values(session.accounts ?? {})) {
const caps = (account as { accountCapabilities?: Record<string, unknown> } | null)?.accountCapabilities;
if (caps && STALWART_CAP in caps) return true;
}
return Boolean(session.capabilities && STALWART_CAP in session.capabilities);
}
export interface AccountInfo {
/** BCP-47 tag configured for the account, or null if unreadable. */
locale: string | null;
/** "oss" | "community" | "enterprise", where the server reports it. */
edition: string | null;
}
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000;
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
/**
* glibc modifiers that name a script rather than a dialect or a currency:
* "sr_RS@latin" is Latin Serbian (sr-Latn-RS), not sr-RS. Anything not listed
* here (@valencia, @saaho, @euro …) carries no script and is dropped.
*/
const SCRIPT_MODIFIERS: Record<string, string> = {
latin: "Latn",
latn: "Latn",
cyrillic: "Cyrl",
cyrl: "Cyrl",
devanagari: "Deva",
iqtelif: "Latn",
};
/**
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
* anything that does not look like a language tag.
*/
export function normalizeLocale(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const [head, modifier] = raw.trim().split("@");
const base = head!.split(".")[0]!.replace(/_/g, "-");
if (!base || base === "C" || base.toUpperCase() === "POSIX") return null;
if (!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$/.test(base)) return null;
const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined;
try {
const [canonical] = Intl.getCanonicalLocales(base);
if (!canonical) return null;
if (!script) return canonical;
const loc = new Intl.Locale(canonical);
// Adding the script only helps when it differs from the one the locale
// already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU).
const implied = loc.script ?? loc.maximize().script;
return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString();
} catch {
return null;
}
}
/**
* Best-effort lookup of what the server can tell us about this account.
*
* The locale used to come from `x:Account/get`, which needs the `sysAccountGet`
* permission — a tenant/admin one that ordinary users are not granted, so the
* setting silently fell back to the browser locale for exactly the people most
* likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`,
* whose `sysAccountSettingsGet` permission *is* part of the built-in user role.
* Ask for both in one request and take whichever the server allows, which also
* tells us which generation we are talking to.
*/
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
// Sign-in refuses a server without the registry, so this should not happen —
// but a session we cannot read capabilities from is not one to ask.
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
const accountId =
session.primaryAccounts?.[STALWART_CAP] ??
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(session.accounts ?? {})[0];
if (!accountId) return EMPTY_INFO;
const res = await fetch(absoluteUpstream(session.apiUrl), {
method: "POST",
headers: { authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
using: [JMAP_CORE, STALWART_CAP],
methodCalls: [
["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"],
["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"],
],
}),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
// A locale request that fails — a permission we lack, a hiccup upstream —
// costs us the locale and nothing else.
if (!res.ok) return EMPTY_INFO;
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
return interpretAccountInfo(body.methodResponses ?? []);
}
/**
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
* permission the built-in user role has, and fall back to `x:Account` for the
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
* methods; this is a permissions fallback, not a version one.
*/
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
const settings = responses.find((r) => r[2] === "s");
const account = responses.find((r) => r[2] === "a");
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
}
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
if (!call || call[0] === "error") return null;
const list = call[1]?.list;
if (!Array.isArray(list) || !list.length) return null;
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
}
/**
* Which edition the server is running. Stalwart deliberately does not publish
* its version number to clients, but 0.16 does report its edition here.
*/
async function fetchEdition(authorization: string): Promise<string | null> {
try {
const res = await fetch(`${config.stalwartUrl}/api/account`, {
headers: { authorization, accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (!res.ok) return null;
const body = (await res.json()) as { edition?: unknown };
return typeof body.edition === "string" ? body.edition : null;
} catch {
return null;
}
}
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
const cached = infoCache.get(sessionId);
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
let info = EMPTY_INFO;
try {
info = await fetchAccountInfo(authorization, session);
info = { ...info, edition: await fetchEdition(authorization) };
} catch {
/* all of this is a nicety - never fail the session over it */
}
infoCache.set(sessionId, { info, fetchedAt: Date.now() });
return info;
}
/**
* Rewrite the upstream session so the browser talks to our same-origin proxy
* endpoints instead of Stalwart directly (no CORS, no credentials in browser).
*/
export function localizeSession(s: UpstreamSession, extras: Record<string, unknown>): Record<string, unknown> {
const caps = { ...s.capabilities };
// We proxy push as Server-Sent Events; hide the upstream websocket endpoint.
delete caps["urn:ietf:params:jmap:websocket"];
return {
...s,
capabilities: caps,
apiUrl: "/api/jmap",
downloadUrl: "/api/blob/{accountId}/{blobId}/{name}?accept={type}",
uploadUrl: "/api/upload/{accountId}",
eventSourceUrl: "/api/events?types={types}&closeafter={closeafter}&ping={ping}",
...extras,
};
}
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
export function absoluteUpstream(url: string): string {
try {
return new URL(url, config.stalwartUrl).toString();
} catch {
return url;
}
}
export function expandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_m, k: string) => encodeURIComponent(vars[k] ?? ""));
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
-7
View File
@@ -1,7 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
def test_root_redirect():
client = TestClient(app)
r = client.get("/", allow_redirects=False)
assert r.status_code in (302, 303)
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<!--
One tag, no media query: applyTheme() keeps it in step with the chosen
theme, which a media query cannot do — it only knows what the OS prefers,
not what the user picked here. There used to be two, both with media
attributes, which meant the selector in applyTheme (:not([media])) matched
neither and the colour never moved off whatever the OS implied.
The initial value is the default theme's background, so the browser chrome
is right from the first paint rather than only once JS has run.
-->
<meta name="theme-color" content="#0d2430" />
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>ihasmail</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@ihasmail/web",
"version": "2.16.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.2",
"dompurify": "^3.2.4",
"lucide-react": "^0.477.0",
"qrcode-generator": "^2.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"wouter": "^3.6.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.2.0",
"vitest": "^3.0.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+50
View File
@@ -0,0 +1,50 @@
{
"name": "ihasmail",
"short_name": "ihasmail",
"description": "Fast, friendly JMAP webmail for Stalwart",
"start_url": "/mail",
"scope": "/",
"protocol_handlers": [
{
"protocol": "mailto",
"url": "/mail?mailto=%s"
}
],
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#0f766e",
"icons": [
{
"src": "/img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/img/icon-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Compose",
"url": "/mail?compose=new",
"description": "Write a new message"
},
{
"name": "Calendar",
"url": "/calendar"
},
{
"name": "Contacts",
"url": "/contacts"
}
]
}
+135
View File
@@ -0,0 +1,135 @@
/* ihasmail service worker.
Two jobs: app-shell caching for installability and fast loads (API requests
are never cached), and Web Push, which is the only part of ihasmail that runs
when no tab is open. */
const VERSION = "ihasmail-v2";
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
// Hashed build assets: cache-first.
if (url.pathname.startsWith("/assets/")) {
event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone();
caches.open(VERSION).then((c) => c.put(req, copy));
return res;
}))
);
return;
}
// Navigations & everything else: network-first, fall back to cached shell.
if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match("/")));
return;
}
event.respondWith(fetch(req).catch(() => caches.match(req)));
});
/* ------------------------------------------------------------------ */
/* Web Push */
/* ------------------------------------------------------------------ */
/*
* Stalwart signs with VAPID and pushes straight to the browser's push service;
* nothing here talks to ihasmail's server. The payload is an EmailPush object
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
* notification without a round-trip — which matters, because when this fires
* there may be no session to make one with.
*
* A JMAP subscription also delivers a PushVerification first, and stays silent
* until the client echoes its code back. That cannot be done from here (no
* credentials), so it is stashed for a tab to collect and confirm.
*/
const VERIFY_KEY = "ihasmail-push-verification";
function textOf(email) {
const from = email?.from?.[0];
const who = from?.name || from?.email || "New message";
const what = email?.subject || "(no subject)";
return { title: who, body: what, preview: email?.preview || "" };
}
self.addEventListener("push", (event) => {
let data = null;
try {
data = event.data ? event.data.json() : null;
} catch {
/* not JSON: fall through to the generic notification below */
}
// The verification handshake. No credentials here, so hand it to a tab —
// an open one now, or the next one to start.
if (data && data["@type"] === "PushVerification") {
event.waitUntil((async () => {
const payload = { id: data.pushSubscriptionId, code: data.verificationCode };
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
if (clients.length) {
for (const c of clients) c.postMessage({ type: "push-verification", ...payload });
} else {
const cache = await caches.open(VERSION);
await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload)));
}
})());
return;
}
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => {
if (!emails.length) {
// A StateChange, or a payload too large to carry the message. Say
// something true rather than inventing a sender.
await self.registration.showNotification("New mail", {
icon: "/img/icon-192.png", badge: "/img/favicon-64.png", tag: "ihasmail-mail", data: { url: "/mail" },
});
return;
}
// One notification per message, collapsing repeats of the same message by
// tag so a re-push does not stack.
for (const email of emails.slice(0, 5)) {
const { title, body, preview } = textOf(email);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
icon: "/img/icon-192.png",
badge: "/img/favicon-64.png",
tag: `ihasmail-${email.id || body}`,
data: { url: email.id ? `/mail/inbox/${email.id}` : "/mail" },
});
}
})());
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/mail";
event.waitUntil((async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
// Reuse a tab if one is open rather than piling up windows.
for (const c of clients) {
if (new URL(c.url).origin === self.location.origin) {
await c.focus();
if ("navigate" in c) await c.navigate(url).catch(() => {});
return;
}
}
await self.clients.openWindow(url);
})());
});
+166
View File
@@ -0,0 +1,166 @@
import { lazy, Suspense, useEffect } from "react";
import { Route, Switch, Redirect, useLocation } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { scheduleSupported, useScheduled } from "@/store/scheduled";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { useFiles } from "@/store/files";
import { useSieve } from "@/store/sieve";
import { push } from "@/jmap/push";
import { client } from "@/jmap/client";
import { ToastHost } from "@/ui/toast";
import { ConfirmHost } from "@/ui/dialog";
import { Spinner } from "@/ui/misc";
import { LoginPage } from "@/views/Login";
import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification } from "@/lib/webpushEnable";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
export function App() {
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
if (status === "loading") {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
</div>
);
}
return (
<>
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
<ToastHost />
<ConfirmHost />
</>
);
}
function AuthedApp() {
const accountId = useSession((s) => s.accountId);
const [location] = useLocation();
// Settings that live with the account rather than the browser. The cached
// ones have already painted, so this only has to correct them (issue #54).
useEffect(() => {
if (!accountId) return;
let cancelled = false;
void (async () => {
const remote = await loadRemoteSettings();
if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote);
// Pushes were held back until now so they could not race the load.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})();
return () => {
cancelled = true;
};
}, [accountId]);
// Initial data + push wiring
useEffect(() => {
if (!accountId) return;
const mail = useMail.getState();
void mail.loadMailboxes();
void mail.loadIdentities();
void mail.loadQuota();
// So a held message shows its banner wherever it is opened from, not just
// after a visit to the Scheduled folder.
if (scheduleSupported()) void useScheduled.getState().load();
void useContacts.getState().init();
void useCalendar.getState().init();
void useFiles.getState().init();
void useSieve.getState().init();
push.start();
// A push subscription stays silent until its verification code is echoed
// back, and the code may have arrived while no tab was open.
listenForVerification();
const pending = new Map<string, Set<string>>();
let timer: number | null = null;
const unsub = push.subscribe((acct, type) => {
const set = pending.get(acct) ?? new Set<string>();
set.add(type);
pending.set(acct, set);
if (timer) return;
timer = window.setTimeout(() => {
timer = null;
for (const [a, types] of pending) {
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
if (a === useSieve.getState().accountId) useSieve.getState().applyChanges(types);
}
pending.clear();
}, 400);
});
const unsubState = client.onSessionState(() => void useSession.getState().refresh());
// Poll fallback when push is disconnected (every 2 minutes)
const poll = window.setInterval(() => {
if (!push.connected && document.visibilityState === "visible") {
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
}
}, 120_000);
return () => {
unsub();
unsubState();
window.clearInterval(poll);
push.stop();
};
}, [accountId]);
// Unread badge in title/favicon
const inboxUnread = useMail((s) => {
const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
});
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
useEffect(() => {
void import("@/lib/notify").then((m) => {
m.setBaseTitle(appName);
setUnreadBadge(inboxUnread);
});
}, [inboxUnread, appName]);
// Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications);
useEffect(() => {
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
}, [notif]);
return (
<AppShell>
<Suspense fallback={<Spinner size="lg" />}>
<Switch>
<Route path="/mail/:mailboxId?/:threadId?">{(p) => <MailView mailboxId={p.mailboxId} threadId={p.threadId} />}</Route>
<Route path="/search/:threadId?">{(p) => <MailView search threadId={p.threadId} />}</Route>
<Route path="/contacts/:id?">{(p) => <ContactsView id={p.id} />}</Route>
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
<Route path="/login">
<Redirect to="/mail" />
</Route>
<Route>{location === "/" ? <Redirect to="/mail" /> : <Redirect to="/mail" />}</Route>
</Switch>
</Suspense>
<ComposerDock />
</AppShell>
);
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/**
* The build's version string, substituted by Vite at build time — there is no
* git to ask from inside a browser, or inside the Docker build. See
* `scripts/version.mjs`.
*/
declare const __IHASMAIL_VERSION__: string;

Some files were not shown because too many files have changed in this diff Show More