Replying sanitized the quoted body with allowRemote: true, so quoting
fetched every remote image in the message whatever the reader had
decided about it. A tracking pixel in the quote then reported the
message read, and the address live, to whoever was counting -- the thing
leaving the images blocked was meant to prevent. Edit as new and opening
a draft that quotes a message did the same.
The decision now lives in one place, remoteImagesAllowed(), asked with
the same inputs the reader's answer used: the image policy, the trusted
senders, whether the sender is a contact, and whether Show images was
pressed on that message. The last of those was component state, so it
moves to the mail store, where the composer can see it.
Blocked images already keep their address in data-ihm-remote, so nothing
is lost by not fetching: it goes back on the way out, and the sent quote
is what its sender wrote. The recipient's client decides for itself, as
it would with any other client's reply.
Before pr408 this needed a rich-text default to reach; the format offer
made it reachable from plain text, which is how it was found.
No new strings.
Switching a reply between plain text and rich text converted whatever
body the draft was showing. Going from plain text to rich, that meant
the quoted message came back as the "> " text quote run through a
converter -- the sender's formatting, images and links gone, even though
the original markup was sitting on the draft untouched.
Both forms of the quote are prepared when the reply opens, so keep them
on the draft and re-attach the right one when the format changes. Only
what the author typed above the quote is converted. Where the quote
can't be found any more -- edited by hand, or a draft that quotes
nothing -- the whole body is converted as before, which is what every
non-reply draft does.
No new strings.
A reply opened in the format the settings ask for, whatever the message
being answered was written in, and the per-draft switch was buried in
the composer's ⋮ menu. Replying in plain text to a rich text message
throws away the formatting; replying in rich text to a plain-text one
overrides what the sender chose to write in.
When the two disagree the composer now says so above the editor -- "This
message is rich text", with a Switch button and a dismiss -- and the
draft still opens in the format the settings ask for. Switching converts
that draft only and leaves the setting alone; switching from the ⋮ menu
answers the offer too. Forwards get it as well, where the formatting
being passed on is somebody else's.
What counts as rich text is hasHtmlAlternative(), which reads the body
part's own type: `htmlBody` is derived (RFC 8621 4.1.4), so a plain-text
message has one too and its presence proves nothing.
The mock said otherwise -- it returned an empty `htmlBody` for a
plain-text message, where Stalwart 0.16.21 returns the text/plain part
in both lists. Both builders now answer as the server does, so the path
this feature depends on is exercised in development rather than only
against a real mailbox.
Two new strings, translated in all nine catalogs; the buttons reuse the
menu's existing "Switch to plain text" / "Switch to rich text". The
count falling back to English stays at 16 in every language.
Fixes#407
APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".
Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.
Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.
All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.
A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
The folder tree ignored sortOrder: Inbox came first, then everything
A–Z, so Sent ended up among ordinary folders. The tree now lists Inbox,
then any order the user has chosen, then the other special folders
(Drafts, Sent, Archive, Junk, Trash), then the rest A–Z. Stalwart gives
every folder sortOrder 0 until someone orders it, so an existing
sidebar changes once, to that default.
Dropping a folder on the top or bottom quarter of a row puts it above or
below that row, with a line to show where it will land. Dropping on the
middle still nests it. Special folders can now be dragged, to be
reordered but never nested; on those, the whole row reorders by the
nearer half. The folder menu gains Move up and Move down, for the
keyboard and touch. Inbox stays first.
A reorder numbers the level 10 apart and writes only the folders whose
number changes, in one Mailbox/set. The order is saved on the server,
so it follows the account to every device and to other JMAP clients.
No new strings: Move up and Move down were already translated.
Fixes#402
Settings > General > Composing has a new switch, "Open the composer full
screen". With it on, every new composer, whether a new message, reply,
forward or reopened draft, starts maximized. Restore still shrinks it to
a window. A draft put back after an undone or failed send keeps the size
it had. It's off by default, and on a phone, where the composer already
fills the screen, it changes nothing.
One new string, translated in all nine catalogs. The count falling back
to English stays at 16 in every language.
Fixes#401
A timed event in the week view now moves sideways across the columns
as well as up and down, landing on the new day at the hour it was
dragged to. All-day chips above the grid drag between days the way
month chips do. Both drags count from the day the event was picked up
on, so a multi-day event grabbed on its last day moves by the distance
dragged, not by its length.
comboOf() let a shifted letter encode Shift in its case, so Shift+I
produced "I" and never matched the "shift+i" / "shift+u" bindings for
mark as read / unread. Shifted letters now yield "shift+<letter>";
symbols such as "#" and "!" still carry Shift in the character.
Fixes#398
Co-authored-by: Joe Esteves <[email protected]>
The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.
The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
* Start at once on a device marked as your own
On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.
A trusted device now starts from what it kept:
- the service worker answers an app route from its kept page and fetches a
fresh one behind it; the app checks the server's version at start, and a
reload for a new build puts the new page in place first, so it is not
answered with the old one. Assets of the page just replaced are kept one
build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
server has confirmed the session; the answer replaces it, and a session
that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
list properties only, so the folders and the inbox paint before any reply
and the folder query does not wait on the folder list. The "folder no
longer exists" check still waits for the server's list.
All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.
* Show nothing kept before the session is confirmed
Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.
The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
The app page names only what it loads at start. The composer, settings,
viewers and the rest were fetched when first used, and after every deploy
that first use waited on the server -- and the worker's tidy-up dropped
them again at the next deploy anyway.
The build now writes the list of all its files into the page as an inert
JSON block. The service worker keeps everything listed and, once a page
names files it does not hold, fetches them three at a time; a load cut
short is resumed at the next navigation. Language catalogs are listed
apart and left to be cached when used, and nothing is fetched ahead when
the browser is set to save data.
A pushed mail change took three round trips: Email/changes beside a
Mailbox/get, then Email/get for what changed, then the list, the open
thread and a second Mailbox/get. Each page of changes now carries its own
Email/get calls by back-reference, and the one Mailbox/get goes out with
it, so a push settles in two. New mail fetched this way is not asked for
again by the notice.
A reply's sessionState that differs from the session is announced once
rather than on every reply, and session refreshes in flight are shared.
The mock's session state now matches the sessionState on its replies, as
Stalwart's does; tying it to the data counter made every reply trigger a
session refresh in development.
On a 250 ms link, opening a conversation took two round trips: Thread/get,
then the bodies. It now takes one. A known thread sends Thread/get and the
missing bodies in the same tick; an unknown one chains Email/get off
Thread/get with a back-reference, and falls back to fetching in parts when
the thread is longer than one Email/get may carry.
Conversations also start loading before the click: when the pointer rests
on a row, as soon as a press begins, and for the row below the open one.
The open waits for that load and does not repeat it.
Going back to one of the last twelve folders shows its previous list at
once, less messages that have left it, while the query runs.
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.
Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.
Three new strings, translated in all nine catalogs.
Stalwart honors a single byte range on its download endpoint but sends
no Accept-Ranges, and Chrome's PDF viewer only reads a file in pieces
when the first response says it can. The proxy now says so itself.
Checked live on 0.16.22: ContactCard/changes reports creates, updates
and destroys exactly, which the contacts store's sync relies on, and a
range the server cannot serve gets the whole file with 200, never 416.
The mock now answers ranges the same way and sends no Accept-Ranges.
Browsers subscribed to Email changes, so every read or move on any
client arrived as a push the worker could only show as "New mail". They
now subscribe to EmailDelivery, which changes only on delivery; Stalwart
sends a delivery to a subscription with an emailPush filter as an
EmailPush alone. The payload now names id and threadId, which Stalwart
sends only when asked, so notifications carry their actions and open the
message. The worker stays quiet while a focused window is open, and the
page leaves notifications to the worker where push is on.
Every renewal registered a new subscription, on the belief that a
repeated deviceClientId replaces the old one. Stalwart keeps both and
allows fifteen per account, which filled up. A browser now extends its
subscription, clears its own duplicates, replaces them only when its
endpoint changed, and on overQuota makes room among other browsers'
subscriptions. The server names its subscriptions by installation and
removes what its previous process registered, and extends rather than
re-creates.
Checked live on 0.16.22; the mock now keeps duplicates, enforces the
limit and accepts an expiry update.
Fixes#375.
Stalwart refuses a blobId in a card's media ("blobIds in media is not
supported"), so adding or changing a photo always failed. The editor now
saves the photo as a data: URI, which Stalwart accepts and returns
unchanged, and leaves the card's other media as it was. Checked live on
0.16.22; the mock now refuses a blobId the same way.
Avatars in the mail list come from the address book's cards, and nothing
loaded those at sign-in, so a photo showed only after Contacts had been
opened. The cards now load in the background at start, the avatar uses
whatever cards are held, and a shared card's photo is fetched from the
account it belongs to.
Fixes#376.
Every message opened kept its full copy for as long as the tab was open.
The store now holds bodies for the 40 messages most recently wanted; older
ones go back to the list properties and are fetched in full again if
opened. The open conversation is never released.
Contact, settings and calendar exports go through downloadFile, which
releases the object URL once the download has started; three of them
never released it.
The web build now writes a Brotli and a gzip copy of each compressible
file, and the static handler serves the best one the browser accepts.
The bundle was gzipped again for every request and Brotli was never
offered; the main chunk is 122 KB with Brotli against 144 KB gzipped.
index.html and every static file carry an ETag, and a matching
If-None-Match gets a 304. The shell and the worker are revalidated on
every load and were downloaded whole each time.
Attachment downloads pass a plain byte Range to Stalwart and relay a 206,
so a PDF viewer or a video element can read in pieces where the server
allows it. On the reader's own device a blob is cached as immutable,
since its id names its content.
The upstream session and account-info caches drop entries past their age
on a timer; they lost an entry only on sign-out or refusal, not when a
session expired. The mock answers byte ranges.
A pushed contact change, and every edit or import made here, reloaded the
whole address book. The store now keeps the state its cards were read at
and asks ContactCard/changes what changed since, fetching only those cards,
split to maxObjectsInGet. A server that cannot say falls back to the full
load.
The calendar held every week or month the reader had visited, queried each
of them again on any event change, and walked them all on every render. It
now holds the four most recently shown; a change reloads those in place,
without emptying the view first, and a window dropped is loaded again when
it is next shown. Shared calendars' events are fetched from every account
at once, and instancesIn builds the added-shares set once.
The mock keeps a ContactCard change log, answers ContactCard/changes, and
announces a ContactCard/set, as Stalwart does.
At sign-in the files, contacts and calendar stores each asked every shared
account a question, one account after another: a request apiece before the
reader had opened any of those views.
Files now only works out at sign-in whether it is available. Which shared
accounts hold files is asked when the Files view or the file picker opens,
which the Files view already did on every visit. Shared address books and
calendars are asked for in one request, and the calendar store loads its
calendars, identities and shared calendars side by side.
The main chunk carried everything the mail view might open: the file
preview and its Markdown renderer, the composer and its editor, the contact
editor, the filter and share dialogs, and the calendar, contacts and files
sidebars. Each is now loaded when first shown. The composer is also
fetched when the browser is idle after startup, so the first Compose does
not wait on the network.
Import the notification helpers statically where they already were: the
dynamic imports beside those static ones split nothing.
The rows were memoized, but nothing they were given kept its identity: the
list built each row's thread messages afresh, passed inline handlers and the
whole selection, and the click and context-menu handlers changed with the
selection and the menu. Every visible row rendered on every store write.
Rows now select their own message and conversation from the store, take a
plain selected flag, and get handlers whose identity never changes. The
conversation summary is memoized.
Refreshes also keep the object for a message whose fetched properties did
not change, so a refresh that changed one message renders one row.
Cache a build asset only when it arrived: a 404 for a chunk asked for while
a deploy was changing over used to be kept as that chunk in that browser.
Refresh the offline copy of the app page after every successful page load,
and when it changes, drop the assets it no longer names along with any
failed response. The same tidy runs when this worker activates, which
clears what earlier workers left. Every deploy's chunks used to stay in
the browser for good.
The cache keeps its name: it also holds what the worker leaves for a tab
to collect.
Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.
Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.
On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.
Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
Refresh the list in pages of at most maxObjectsInGet. A list scrolled past
500 rows used to send all of its ids to one Email/get, which the server
refuses whole, and the refresh failed without a word.
Fetch the other messages of listed threads in their own capped requests,
and only those not already held. They used to be back-referenced from
Thread/get with no bound.
Have loadThread fetch bodies only for messages not held in full. Every push
refetched the whole open thread's bodies, and the new attachment objects
made the reading pane redo work it had already done.
Build the list query from the folder names, roles and tree rather than the
mailbox map, which every reload replaces. Each reload used to build a new
query, which query() answered with another full refresh.
Cap JSON bodies at 64 KB on every API route except JMAP and uploads, which
bound themselves. Sign-in used to read a body of any size before its rate
limits ran; the flood ceiling now also runs before the body is read.
For sessions whose JMAP requests are checked, lower the read cap from 16 MB
to 4 MB, allow four such reads per session at once, and turn requests away
with a 503 once 32 MB is held across everyone.
Count sign-in limits per /64 for IPv6, since one host holds a whole /64.
Bind the compose example to loopback, and run it read-only with no
capabilities and no-new-privileges. Keep .env.* out of git and the image
build context.
Rewrite mail CSS in place instead of cutting pieces out, so a strip can no
longer join text into a closing </style>, and escape < last. Decode escaped
letters before checking, parse url() properly and drop CSS that cannot be
parsed, and disable @import and image-set() in every spelling. The body
element's style goes through the same path.
Give <area> links the same target, rel and click handling as <a>, strip
<style> blocks from HTML quoted into the composer, and contain the editor's
layout as .message-body already is.
server/src/mock/index.ts was 1,545 lines, the largest file in the repo.
It had carried `/* ---------- data ---------- */` style markers for a
while, so the seams were already drawn; this turns six of them into
files.
mock/config.ts 51 env-derived constants, `account`, `state`
mock/data.ts 410 fixtures and the builders that make them
mock/engine.ts 442 the generic JMAP machinery -- get/set, filters,
patches, refs, limits, recurrence plumbing
mock/handlers.ts 455 the `Method/name` dispatch table
mock/events.ts 26 SSE fan-out and the Email/changes ring buffer
mock/auth.ts 11 checkOtp
mock/index.ts 195 HTTP routing, the session document, listen
TWO THINGS THAT COULD NOT JUST MOVE:
`counter` and `vacation` were module-level `let`s written from both the
fixture builders and the handlers. An ES module can export a `let` and
importers see it update, but they cannot assign to it, so both became
containers: `seq.counter` and `vacationBox.current`. Seven call sites.
`recordEmailChange`, `broadcast`, `sseClients` and `checkOtp` lived in
the HTTP section, but the handlers call them -- and index.ts imports the
handlers. Leaving them there is a cycle, so they became events.ts and
auth.ts rather than being dragged into data.ts, which is fixtures.
`account` is still exported from index.ts, because account.test.ts and
login-guard.test.ts reach for `mock.account` and `mock.server`.
Verified by running it, not only by compiling it: `npm run mock` boots
and listens, `/.well-known/jmap` returns a session, and a POST to
`/jmap/` answers Mailbox/get with the nine seeded folders and
Email/query with the seeded messages.