Compare commits

...
364 Commits
Author SHA1 Message Date
Bernd RodlerandClaude Opus 5 b6fdfe72ca chore: housekeeping — rescue orphaned doc, ignore .DS_Store, adopt vnc-v0.3.0
Commits the offline-client architecture analysis doc that was sitting
untracked in docs/ — its own header already warns this exact thing
happened once before (~/vncmail-plus is a shared checkout; an earlier
untracked copy was lost to a concurrent branch switch). Confirmed the
hazard is still live: vnc/VNC-CHANGES.md itself was found deleted from
disk mid-edit by this session, by something else touching the checkout
concurrently, and had to be restored with `git checkout --` before this
commit. Committing on sight is the only defense against that, not a
process improvement for later.

Also:
- .DS_Store added to .gitignore (was untracked in docs/)
- introduces a VNC-side feature version, separate from package.json's
  upstream-tracking version (1.7.8, must stay that way per the fork's own
  rule 4 - bumping it would turn merging upstream releases into a diffing
  exercise). Retroactively bucketed at the milestone boundaries the commit
  history already has: v0.1.0 fork bootstrap, v0.2.0 S/MIME plugin
  audit+fixes, v0.3.0 the internal-CA foundation just landed. Tagged
  vnc-v0.3.0 on this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:10:03 +02:00
Bernd RodlerandClaude Opus 5 3afa7ce012 feat(smime): CaProvider seam + server-side enrolment route (A-02, C-08 half)
Corrects an architecture call I got wrong earlier in the session. I had said
CaProvider would live in the plugin. It cannot, for two independent reasons:

  1. EJBCA's REST API authenticates with a CLIENT CERTIFICATE. A browser
     cannot present one from fetch, and must not hold one anyway - the RA
     credential is the authority to mint certificates, so putting it
     anywhere script-reachable turns any XSS into a certificate factory.
  2. Only the server can answer "does this person actually own this
     address?" A browser asserting its own identity to a CA is not
     authentication.

So: the plugin generates the keypair and CSR (private key never leaves the
device), and this layer decides which addresses the certificate may assert.
api.http.post is the bridge, and the fact that it forwards the user's JMAP
auth header is what makes the identity check possible at all.

The design decision worth calling out: the CSR is NOT trusted for identity,
and the route does not parse it to police what it asks for. It doesn't need
to. The route supplies the subject and the rfc822Name SAN itself from
addresses it verified independently; the CSR contributes only a public key
and proof of possession. A CSR hand-crafted to claim the CEO's address does
not have to be detected and rejected - the extension it asks for simply
never reaches the certificate.

That property depends entirely on EJBCA ignoring CSR-supplied subjects and
extensions, which is three checkboxes in the certificate profile. Added to
the runbook as the most important line in it, with a concrete verification
using a hostile CSR - because with those overrides ON, the enrolment route
still looks correct in review while issuing certificates for any address.

Identity comes from Stalwart via Identity/get, not from the auth cookie's
username. The cookie is encrypted and server-minted so it cannot be forged,
but it is still the wrong authority: the right answer to "may this person
have a signing certificate for this address" is held by the mail server
that already decides "may this person send from this address". Anything else
invents a second, weaker answer to a settled question.

It also handles two cases the cookie cannot:

  - an alias the account legitimately sends as, which belongs ON the
    certificate and which the cookie does not know about
  - an administrative principal with no mailbox, which must get NOTHING.
    Not hypothetical: admin@sandbox.vnc.de authenticates successfully and
    has no mail session, so trusting the cookie would have issued it a
    certificate for an address it cannot send from.

Wildcard identities (*@domain) are filtered out. Stalwart can legitimately
report one for an account allowed to send as anything in a domain, but it is
a capability, not an address - and a rfc822Name SAN of *@vnc.de is either
rejected by clients or, worse, honoured.

Other deliberate choices:

- Pins EJBCA's own chain for the mTLS connection instead of the public root
  store. EJBCA serves a self-signed cert on that listener by design, and
  rejectUnauthorized:false would be worse than either option - it would let
  anything on the cluster network impersonate the CA and harvest CSRs.
- CA error bodies are logged server-side and replaced with generic messages.
  An enrolment endpoint should not double as a way to probe CA config.
- DN component values are RFC 4514 escaped. The CN comes from a display
  name; an unescaped comma or plus would inject additional RDNs.
- getCaProvider() returns null rather than throwing when unconfigured, so
  the route 503s and nothing else is affected. Enrolment is opt-in; a
  missing CA secret must not stop anyone reading their mail.
- revoke() is documented as needing to work when enrolment is broken. It is
  the incident-response path, and a design that can only revoke through the
  same path that issues is one outage from being unable to answer a key
  compromise.

Typechecks clean. Not yet exercised against a live CA - the browser half of
C-08 (keypair + CSR generation in the plugin) and a real EJBCA to enrol
against are both still outstanding, so nothing here has issued a
certificate yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:02:21 +02:00
Bernd RodlerandClaude Opus 5 759ab7fe8c feat(ca): EJBCA Community manifests + root ceremony runbook for A-01/A-06
Manifests and a runbook for the internal CA that issues 1-year S/MIME
certificates. Per the agreed split: these are applied by hand, and the
root-key ceremony in section 3 is deliberately NOT automated - the whole
value of an offline root is that its private key never exists on a machine
that runs services or tooling.

Structural recommendation up front (section 0), because it decides whether
promoting to vncmail later is a config change or a re-rooting: name the
root for the ORGANISATION, not the environment. One root, generated once
at prod grade, with per-environment intermediates under it. Promotion is
then "issue a second intermediate from the same root" - a one-hour
ceremony - and the trust anchor already distributed to laptops, phones and
partners does not change. A throwaway "VNC Sandbox Root" instead means
redistributing a new anchor to every device and every external party who
ever verified a signature. That cost is invisible today and expensive
later.

Security shape of the deployment:

- Own namespace (vnc-ca), NOT vncmail. The webmail pod is internet-facing;
  the CA signs certificates. A compromise of the former must not be a
  compromise of the latter.
- Port 8080 (CRL + OCSP) is the ONLY thing the public ingress routes, and
  only two path prefixes. Not the admin web, not the REST API, not the
  public enrolment pages.
- Port 8443 (admin + REST, client-cert authenticated) is never exposed
  through an ingress - cluster-internal or kubectl port-forward only,
  enforced by NetworkPolicy as defence in depth.
- The RA credential the enrolment route uses gets its own EJBCA role
  limited to issue/revoke under one profile. It lives on an
  internet-facing pod, so its blast radius should be "mint an S/MIME cert"
  and not "reconfigure the CA".

Two things the runbook makes you prove rather than assume:

- The NetworkPolicy actually enforces. Applying one on a CNI that does not
  implement it succeeds silently and protects nothing, so section 6 has a
  probe that MUST time out - a 401 means the REST API is exposed
  cluster-wide.
- The CA backup restores. ejbca-db-data holds the intermediate private key
  and, with key recovery on, escrowed user decryption keys; an untested CA
  backup is a belief.

Section 7 surfaces a decision rather than making it silently. S/MIME is
unlike TLS in that losing a private key makes every message ever encrypted
to that user permanently unreadable - re-issuing does not help, the old
mail was encrypted to the old key. So key escrow is on by default here,
which is the defensible choice when mail is a business record, but it
means the CA operator can decrypt user mail. That is worth deciding
consciously and being able to explain, not discovering.

MariaDB rather than the container's embedded H2 deliberately: H2 is not
supported for data you intend to keep, and the database is the one
component that must not need re-platforming on promotion.

Image tag pinned. The env-var contract is the part most likely to have
drifted between EJBCA releases, so the runbook says to verify it against
the tag pulled rather than trusting these values, and gives the log grep
that shows the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:58:04 +02:00
Bernd RodlerandClaude Opus 5 fb40e74713 fix(smime): certificate address binding prefers the deprecated DN attribute
Finding 11, found while writing the EJBCA runbook rather than from a test -
and it is a blocker that fix 1 created.

extractEmailAddresses collected the Subject DN emailAddress attribute
(OID 1.2.840.113549.1.9.1) BEFORE the SAN rfc822Name, and every consumer
reads emailAddresses[0]. Under RFC 5280/8550 the SAN is authoritative and
the DN attribute is legacy, retained only for old clients - so the order
was exactly backwards. Compounding it, signerEmailMatch compared the From
header against position 0 only, never against the other addresses a
certificate legitimately carries.

Two ways a perfectly valid certificate failed:

  1. DN and SAN disagree in any respect - case, domain form, a stale
     value. The DN wins, From never matches.
  2. A multi-alias certificate where the message was sent From the
     SECOND rfc822Name. Only [0] is compared, so it mismatches.

Before fix 1 that was a cosmetic amber "signer != From" banner. After fix
1 it BLOCKS auto-import, so the correspondent's encryption certificate is
never stored and encryption silently never becomes available for them.
I turned a latent wart into a functional blocker in the same audit.

This was not hypothetical for much longer: EJBCA populates both fields by
default once the end-entity profile has an email field, which is exactly
what the CA runbook configures. The internal CA would have shipped
certificates this client mishandles on day one.

Fix:
- collect SAN rfc822Name first, DN emailAddress second, de-duplicated
  case-insensitively, so [0] is the authoritative address
- add certAssertsAddress(), matching against every address the
  certificate asserts rather than only the first
- file the signer certificate under the address the message actually came
  from when the certificate asserts it. That address is the key used for
  encryption lookups later, so storing a usable certificate under a
  different one of its addresses hides it from the code that needs it.

The manual-import paths (index.js:961, pkcs12.js:114) have no From header
to match against and are corrected by the reordering alone.

Verified: new verify-address-binding.mjs, 18 assertions, self-contained -
it generates its own certificates with openssl, including one whose SAN
and DN deliberately disagree, and asserts openssl really emitted both
forms before drawing any conclusion.

Confirmed the bug was real rather than assumed, by running the same suite
against the pre-fix file restored from git with the old [0]-only matching
shimmed back in: emailAddresses[0] resolves to legacy.address@old.example
and all three match assertions fail. Every REFUSAL case still passed both
before and after, so this removes false negatives without loosening the
gate - lookalike domains, substrings and empty addresses are still
refused.

51 + 28 + 18 = 97 assertions passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:57:40 +02:00
Bernd RodlerandClaude Opus 4.8 fe77e9f52b docs: file two host-app issues found during the S/MIME spike
1. A 401 from ANY login step is reported as wrong password.
auth-store.ts:61 classifies any error whose message merely contains the
substring 401 as invalid_credentials, and it is fed by a catch-all around
the entire login sequence. Reproduced with admin@sandbox.vnc.de, a
Stalwart administrative principal with no mailbox: POST /api/auth/session
returns 200 (the password IS correct), then the JMAP session fetch
returns 401 and the UI claims the password is wrong. Verified directly:
bernd.rodler gets 200 with a mail capability, admin gets 401.

Cost several minutes re-typing a password that was never wrong. An
admin-only principal, a disabled mailbox and a revoked mail permission
are all indistinguishable from a typo.

2. Page reload signs you out unless stay-signed-in is ticked, which also
silently prevents plugin activation and therefore looks like a plugin
bug. SESSION_SECRET is intact, so not a key rotation.

Neither blocks P1; both deliberately not chased during the spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:44:53 +02:00
Bernd RodlerandClaude Opus 4.8 a9af816012 docs(smime): record finding 10 (banner race) in the audit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:35:33 +02:00
Bernd RodlerandClaude Opus 4.8 90c1176f93 fix(smime): banner slot can silently miss a resolved signature
Found live, not from a test: sent a genuinely signed+encrypted message
through the real composer, opened it in Sent, and the banner showed
only "Encrypted message" - no signature row at all, despite both Sign
and Encrypt having been checked and the body decrypting correctly.

Root cause is a race, not a crypto bug. onRenderEmailBody (which fetches
the blob, decrypts, verifies the inner signature, and persists the full
status) and EmailBanner (a separate plugin UI slot) mount independently.
The banner read persisted status exactly once, in a useEffect keyed only
on email.id. If that read fired before the async decrypt+verify pipeline
finished writing, the banner fell back to a header-derived guess: it can
see from the OUTER envelope's Content-Type that a message is encrypted,
but has no way to know it is ALSO signed, since that only becomes
knowable after decryption completes.

This is more than cosmetic. The same race could just as easily hide an
INVALID signature - a tampered message or wrong signer - behind the
generic "Encrypted message" banner, purely because of timing, with no
indication anything needs attention.

Fix: track whether the initial read came from a real persisted value or
from the header-only fallback. Only in the fallback case, poll briefly
(150ms x 20 = 3s) for the real result to land - the same pattern
unlockNow already uses after a manual key unlock, generalized to the
initial mount. Once persisted state exists, stop.

Verified in the real browser: re-sent and re-opened the same signed+
encrypted Sent message after this fix, banner now shows both rows -
"Decrypted" and "Valid signature by bernd.rodler@sandbox.vnc.de -
self-signed" (amber, correctly, since the spike cert is self-signed and
fix 1's selfSigned flag is doing its job).

Two source assertions added to verify-fixes.mjs. 51 unit assertions,
28 round-trip assertions, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:35:08 +02:00
Bernd RodlerandClaude Opus 4.8 5c1f14fa8b docs(smime): record UI key-import verification
User manually imported bernd.rodler.p12 through the real Settings >
S/MIME > Import key dialog on localhost:3100 - real native file picker,
real PKCS#12 passphrase, real storage passphrase. Succeeded.

This closes the last unverified layer. Every step of the delivery path
is now proven end to end: crypto correctness, parser hardening against
hostile input, admin install, client activation under the B-04 gate,
and now UI key import.

Also fixes a stale line in the audit doc that still listed finding 5
as open after it was fixed in a4155aa3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:09:23 +02:00
Bernd RodlerandClaude Opus 4.8 a4155aa342 security(smime): fix finding 5 (parser DoS) and harden finding 4
Finding 5 — the MIME parser runs on attacker-controlled input: the inner
content recovered after decrypt/verify is whatever the sender put there.
Upstream had no depth limit on nested multiparts and no size cap anywhere.

Verified against the unpatched upstream parser with the same input:

  UPSTREAM CRASHED: RangeError - Maximum call stack size exceeded
  UPSTREAM: 65MB accepted (no size cap)

So this was a live decrypt-time DoS reachable by anyone who can send mail.

Caps added: depth 20, parts 500, bytes 64 MB — generous enough that no
legitimate message comes close (real mail nests 3-4 levels). Past a limit a
subtree degrades to a leaf rather than throwing, so one pathological branch
doesn't discard the legitimate parts above it. Oversize input is refused
outright rather than truncated: half a MIME tree parses into misleading
nonsense, and showing part of a message is worse than saying no. Both
bodyStructure walkers in smime-detect.js are capped too — those run on
server-supplied structure BEFORE any decrypt/verify gate.

Finding 4 — hardened, not eliminated, per the agreed scope. Unlocked
CryptoKeys still live in durable IndexedDB rather than memory; moving them
would mean refactoring how the plugin shares state across iframes and
risking the unlock->decrypt path just verified.

What changed instead:

- Removed the lockOnLogout opt-out from the logout/account-switch wipes. A
  non-extractable key cannot be exported but can still be USED, so a handle
  outliving the session lets anyone with the browser profile decrypt mail
  without knowing the passphrase. That is not a preference to toggle off.
- Added a best-effort wipe on pagehide and beforeunload to narrow the window
  in which a usable handle exists on disk. Best-effort by nature: an
  IndexedDB write may not complete during teardown and neither event fires
  on a crash — which is precisely why the boot wipe in activate() remains
  the load-bearing control.
- Deliberately NOT wiping on visibilitychange: tabbing away would drop the
  unlock and force a passphrase re-entry every time, which trains users into
  turning S/MIME off entirely.
- Dropped the now-dead lockOnLogout setting from the manifest. A toggle that
  silently does nothing is worse than no toggle.

Tests: 49 unit assertions + 28 round trip. The round trip now feeds genuinely
hostile MIME through the real parser (5000-level nesting, 5000 siblings,
65 MB) and still confirms a normal multipart/alternative parses correctly.
Full crypto round trip unchanged and passing, so neither fix broke S/MIME.

Findings 6, 7, 8 and 9 remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:57:18 +02:00
Bernd RodlerandClaude Opus 4.8 d047891ded test(smime): real crypto round trip against the patched plugin
Adds roundtrip.mjs, which drives the plugin's own modules directly — no
browser, no DOM — and proves the three audit fixes did not break S/MIME.
24 assertions, all passing, against the self-signed spike certificates:

  PKCS#12 import (both identities, RSA-2048, kdf=600000)
  key encrypted at rest (32-byte salt, 12-byte IV)
  unlock yields NON-EXTRACTABLE keys; wrong passphrase rejected
  sign -> verify: signature valid, signer email matches From
  encrypt -> decrypt by the intended recipient, plaintext matches
  sender can read their own Sent copy
  downgraded message produces no plaintext

Two results worth recording.

Finding 1 is confirmed against a genuine CMS structure, not just a mock:
the spike certs are self-signed, smimeVerify reports signatureValid AND
signerEmailMatch true AND selfSigned true, and the gate refuses the
auto-import. That is exactly the cert-substitution attack, blocked. The
same status with selfSigned:false passes, so the gate is not simply
refusing everything.

Finding 2 is confirmed end to end: our own encrypt path produces
AES-256-GCM, decrypt reports contentAuthenticated:true, so HTML renders
without suppression. Only legacy inbound CBC degrades to text.

The section-8 assertion is deliberately loose. Swapping the 9-byte
AES-GCM OID for the 8-byte 3DES OID also invalidates the enclosing DER
lengths, so ASN.1 validation rejects the message before the allowlist is
reached — either way no plaintext is produced, and the assertion says
which path fired rather than pretending it tested the allowlist. The
allowlist itself is asserted precisely in verify-fixes.mjs, which now
carries 36 assertions including checks that fail if a legacy CBC OID
reappears or the mail path stops using the native engine.

Browser-side spike result: the patched plugin installs through the admin
channel, resolves to the privileged tier, and activates with
"hooks=5, slots=3" and no refusals — so the B-04 gate does not block it.
Its S/MIME settings section renders and survives SPA navigation. Key
import via the UI could not be automated (native file picker), which is a
harness limit rather than a product defect; roundtrip.mjs covers that
path directly instead.

Findings 4, 5 and 6 remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:41:07 +02:00
Bernd RodlerandClaude Opus 4.8 bc5d2a57e8 security(smime): fix audit finding 2 — unauthenticated CBC on decrypt
Upstream applied no content-encryption check at all on decrypt, and ran
every decryption through the liner engine — which registers DES-CBC,
3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js for PKCS#12
password-based encryption; the CMS content path merely reused the same
engine and inherited them. A crafted message could therefore be decrypted
under a broken cipher, and unauthenticated plaintext was handed straight
to the renderer — the EFAIL precondition.

The obvious fix would have been wrong. Accepting only AEAD breaks most
real S/MIME mail: RFC 5751 makes AES-128-CBC the MUST-implement content
cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS
(RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality
catastrophe wearing a security fix's clothes.

Three layers instead:

1. Allowlist the AES family and refuse everything else, with the gate
   running before any private key is touched. CBC stays for interop;
   DES/3DES/RC2 are refused.

2. Take the mail path off the legacy engine. Normal decryption now uses
   nativeEngine(); the liner engine is reachable only when a genuine
   legacy RSAES-PKCS1-v1_5 key is in play. This removes the weak ciphers
   structurally rather than by policy — native WebCrypto handles RSA-OAEP
   key transport and AES-CBC/GCM content perfectly well.

3. Refuse to render unauthenticated plaintext as HTML. CBC output is
   malleable and HTML is EFAIL's exfiltration channel. The host does block
   remote content by default (allowExternalContent starts false), but that
   is a user/admin setting this plugin cannot observe, so we don't lean on
   it. New renderUnauthenticatedHtml setting (default false) is the
   documented opt-out. Our own encrypt path always uses AES-GCM, so mail
   we send renders fully; only legacy inbound CBC degrades to text.

Built from source with the repo's own pipeline (esbuild, 1.69 MB) and
packaged to smime-vnc.zip (0.27 MB). All four fixes verified present in
the built bundle. Build output is gitignored — never vendor a prebuilt
bundle, which was the upstream mistake.

Correcting an earlier assumption: this bundle does NOT trip the B-01
pattern scanner (zero matches on all five patterns), so the override is
not needed to install it. B-01 remains correct — it closed a real
entrypoint-only coverage gap — but it isn't load-bearing here.

verify-fixes.mjs now carries 36 assertions covering all three fixes,
including source checks that fail if a guard is removed, if a legacy CBC
OID reappears in the allowlist, or if the mail path stops using the
native engine.

Findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6
(PKCS1v1.5 oracle surface) remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:47:43 +02:00
Bernd RodlerandClaude Opus 4.8 f7e487171c security(smime): fork upstream plugin and fix two audit findings
S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine
findings, two HIGH. No backdoor and no exfiltration path anywhere in the
bundle — the problems are trust-model and input-validation gaps. Full
report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md.

Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle
whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing
src/ would not audit what that zip installs. We build from source.

Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated
on signatureValid alone, but smimeVerify runs checkChain:false, so that
only proves "signed by whoever holds this key", not that the claimed
identity is real. Self-sign a cert asserting victim@example.com, send one
signed message, and it was stored as the encryption target for that
address — the user's next Encrypt to the victim went to the attacker.
Now requires signerEmailMatch === true and !selfSigned. Both values were
already computed and displayed as untrusted in the banner; only the
import path ignored them. Tests for `true` explicitly so an undefined
match (missing From header) fails closed.

Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only
Subject and attachment filename; display names, raw addresses,
Message-ID, In-Reply-To, References and attachment Content-Type were
emitted verbatim, and formatAddress escapes only backslash and quote.
In-Reply-To/References/display names are copied from inbound mail when
replying or forwarding, so the value is attacker-supplied. Sanitising
inside formatHeader covers all 17 call sites by construction; the three
headers assembled directly get stripCrlf explicitly.

Also adds auth:observe to the manifest. The plugin registers
onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363)
— without declaring the permission, so under B-09 the session-key wipe
would silently stop running.

verify-fixes.mjs carries 19 assertions including source checks that fail
if either guard is removed or a new unsanitised interpolated header
appears. That last one immediately caught the interpolated smime-type
Content-Type header, which manual review had dismissed as static.

Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This
is not safe for real mail yet — sandbox accounts only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:00:11 +02:00
Bernd RodlerandClaude Opus 4.8 e9746fcf78 feat(plugins): admin review panel for scanner findings
The overrideWarnings escape hatch added alongside the bundle scan was
API-only: an admin uploading a crypto plugin through the web form hit a
400 with canOverride and had no way to act on it, which left S/MIME and
PGP bundles uninstallable through the UI.

Hold the rejected file client-side and show the findings — pattern per
file — with "Install anyway" and "Cancel". Proceeding re-posts the same
file with overrideWarnings, so the decision stays explicit and lands in
the audit log. The route now echoes accepted findings back on success so
the confirmation says how many were waved through rather than reporting a
bare install.

Also replaces a dead `data.warnings` read with the live `findings` field;
the route never returned `warnings` on success, so that branch never ran.

Completes B-01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:03:14 +02:00
Bernd RodlerandClaude Opus 4.8 d91db37b34 fix(plugins): scan all bundle scripts, allow audited scanner override
Two problems with the upload scanner, pulling in opposite directions.

It only scanned the entrypoint, so a bundle with `eval()` in a second
file passed outright — verified against a synthetic bundle whose
vendor/openpgp.js tripped three patterns while index.js stayed clean.

At the same time, a hard 400 on eval()/new Function()/innerHTML= makes
every crypto plugin uninstallable: minified openpgp.js and pkijs
legitimately contain all three. That blocks S/MIME and PGP entirely.

Scan every .js/.mjs in the bundle and return structured findings
({file, patterns[]}) plus canOverride, so the admin can see exactly what
tripped and where. An explicit overrideWarnings=true proceeds and writes
a plugin.install.scan_override audit entry recording which patterns in
which files were accepted — not merely that an override happened.

This route is already admin-authenticated, so the scan is defence in
depth against an accidental or compromised upload, not a trust boundary.
Treating it as the latter is what made crypto plugins uninstallable.

Also log the B-04 and B-01 divergences in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:57:28 +02:00
Bernd RodlerandClaude Opus 4.8 ae19ad888b fix(security): gate plugin hook registration on granted permissions
`info.hooks` is self-reported by the sandboxed bundle, and the loader
registered any recognised hook name without checking permissions. An
untrusted, null-origin plugin could therefore claim `onRenderEmailBody`
and replace the rendered body of any opened email without ever holding
`email:render-takeover` — the permission was enforced only by the
one-time consent dialog, i.e. it gated what the user was *asked*, not
what the host *allowed*.

Add HOOK_PERMISSIONS covering the hooks that can read message content,
alter outgoing mail, or observe key state: render takeover, the three
send-interception hooks, bulk-content hooks, attachment upload, and the
four S/MIME hooks. Hooks absent from the map stay unrestricted (UI
observation, toasts, navigation), so ordinary plugins are unaffected.

Refused hooks fail closed and log the missing permission by name — a
silently inert hook is far harder to diagnose than a refused one.

Export hasPermission() from host-api rather than reimplementing the rule
in the loader, so the hook gate and the RPC gate cannot drift apart.

Remaining ~200 hooks are tracked as B-09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:57:14 +02:00
Bernd Rodler f0e63de09b feat(theme): SRC theme v1.1.0 — MD3 components (shape scale, buttons, cards, dialogs, state layers) 2026-08-03 19:13:07 +02:00
Bernd RodlerandClaude Opus 4.8 88670d4bcf feat(theme): per-theme brand logos (VNClagoon wordmark ↔ SRC mark)
Add optional logoLightUrl/logoDarkUrl to InstalledTheme + resolveThemeLogo()
helper; set logos on vnclagoon + src themes; login page and nav-rail prefer the
active theme's logo, falling back to the global config logo. So switching theme
switches the whole brand. 0 type errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:23:42 +02:00
Bernd RodlerandClaude Opus 4.8 1bc5a6a0ce feat(theme): add SRC brand theme (Swiss red/white), keep VNClagoon default
Second builtin theme builtin-src (red #D52B1E light-first, #EF4444 dark) + SRC
mountain logo asset. VNClagoon remains the default theme. Placeholder logo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:16:54 +02:00
Bernd RodlerandClaude Opus 4.8 be7bfd1f02 feat(theme): VNClagoon login card — navy card, cyan hairline + top accent + glow
Extend the vnclagoon skin: solid navy login card, cyan hairline border, a thin
cyan accent strip on top, soft cyan glow (dark), and an ambient cyan wash behind
the card on the login page. Scoped to the login card's unique class combo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:45:09 +02:00
Bernd RodlerandClaude Opus 4.8 22e5e97da9 feat(theme): VNClagoon brand theme (navy + cyan) as default, dark-first
Add builtin-vnclagoon theme (cyan #00D4FF accent on navy #0A0E1A, DM Sans body
+ Syne headings, self-hosted OFL fonts); set as default theme policy; default
mode dark. Add VNCmail wordmark SVGs (on-dark/on-light) + wire logo/company via
k8s secret template. Placeholder wordmark — swap official styleguide SVG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:42:04 +02:00
Bernd RodlerandClaude Opus 4.8 476c76c420 docs(deploy): sharpen k8s admin runbook — inventory, pre-flight, ordered apply
Self-contained guide: exactly what to deploy (7 objects + image), 3 cluster
values to match against bulwark, copy-paste apply order, verify, update/rollback,
troubleshooting table. Plain kubectl apply (no GitOps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:13:51 +02:00
Bernd RodlerandClaude Opus 4.8 a3d551b640 feat(deploy): k8s manifests for microk8s (vncmail.sandbox.vnc.de)
Bulwark is stateful (local /app/data) — Vercel serverless (read-only fs)
crashes it. Deploy as a container with 4 persistent volumes on microk8s,
alongside bulwark.sandbox.vnc.de. Adds deploy/k8s/ (namespace, pvc, deployment,
service, ingress, secret template, runbook) + rewrites setup doc off Vercel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:09:54 +02:00
Bernd RodlerandClaude Opus 4.8 83a9c5a809 docs(setup): dev-first workflow — main=production, dev=preview
Fix contradiction: production branch is main (Vercel default), dev auto-deploys
previews, promote = ff-only merge dev→main on explicit go-live. Upstream synced
into dev, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:46:55 +02:00
Bernd RodlerandClaude Opus 4.8 fb4e8f3591 revert(mf): drop microfrontends — VNCmail+ is a standalone project
Remove withMicrofrontends wrap + @vercel/microfrontends dep. Grouping is
organizational (separate Vercel team), not a microfrontends group. App
serves at its own root again (NEXT_PUBLIC_BASE_PATH removed on Vercel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:28:12 +02:00
Bernd RodlerandClaude Opus 4.8 0189935e7b feat(mf): join VNClagoon Suite microfrontends group
Wrap next.config with withMicrofrontends; add @vercel/microfrontends.
Served at /mail under the suite shell (via NEXT_PUBLIC_BASE_PATH set on the
Vercel project). Logged in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:04:40 +02:00
Bernd RodlerandClaude Opus 4.8 eace443fdf chore(vnc): bootstrap VNCmail+ fork — vnc/ layer + Vercel runbook
Fork of bulwarkmail/webmail for deploy on Vercel as project vncmail-plus.
Adds vnc/ customization layer (branding, overrides, VNC-CHANGES log),
Vercel env template, and VNCMAIL-SETUP.md runbook. No upstream files touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:42:45 +02:00
Linus RathandGitHub e94a1429d5 Merge pull request #724 from paulhenry46/userlogout-api-hook
feat: implement existing hooks onBeforeLogout and onAfterLogout + new plugin API method
2026-08-01 23:09:39 +02:00
Linus RathandGitHub 15982c2468 Merge pull request #708 from paulhenry46/user-api-plugin
feat(plugins): add 2 new methods : getAccounts and getIdentities
2026-08-01 23:06:53 +02:00
Paulhenry Saux d15c58f8da feat: implement logout hook and add a new plugin api method to perform logout 2026-08-01 21:46:33 +02:00
Paulhenry SauxandGitHub 6698ec8456 Merge branch 'bulwarkmail:main' into user-api-plugin 2026-08-01 20:24:08 +02:00
Linus Rath e738941950 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-08-01 12:28:21 +02:00
Linus Rath 1890cade08 fix: surface underlying network error cause in JMAP passthrough failures 2026-08-01 11:47:16 +02:00
Linus RathandGitHub fc7fec44a8 Merge pull request #714 from MathyV/respect-max-calls
fix: make bulwark respect server limits
2026-07-31 05:43:26 +02:00
Mathy Vanvoorden 1652a0ec62 fix: make bulwark respect server limits
If you have a large number of tags, getTagCounts would not be able to get the
unread counts because it did not respect maxCallsInRequest, even though the
value was actually read out, it was just ignored. There are also other places
where the limits were not respected.

Batching is now generalized in a helper that also takes maxObjectsInSet, which
also was ignored, into account and is applied to all functions.

In addition, the dev mock now also advertises and enforces the limits, so these
issues can get picked up during development.

Possible closes #699
Possibly closes #399
2026-07-30 22:11:40 +02:00
Linus Rath e659fe3d38 feat: allow contact cards for organizations #701 2026-07-30 19:44:36 +02:00
Linus Rath 348e032dce fix: files show creation date instead of modification date #700 2026-07-30 19:41:02 +02:00
Linus Rath a6c15b8ad6 fix: empty folder stopped after 500 emails #711 2026-07-30 19:40:25 +02:00
Linus Rath 5a2bc6b671 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	app/api/dev-jmap/[...path]/route.ts
2026-07-30 19:17:43 +02:00
Linus Rath 59bc7fd64c fix: reply to own thread message addresses original recipients #703 2026-07-30 19:02:39 +02:00
Linus Rath aa7a814b86 test: rewrite mock email content 2026-07-30 18:44:53 +02:00
Linus RathandGitHub c5c6509867 Merge pull request #710 from MathyV/nested-tags
Nested tags
2026-07-30 18:41:22 +02:00
Linus Rath 1cdbf75270 fix: use full tag path in drag-drop toasts, fresh email in context menu markAsRead
Nested tag toasts from drag-and-drop only showed the leaf name for
non-root tags, contradicting the comment above it and making two
same-named leaves under different parents (e.g. Personal/Receipts vs
Work/Receipts) indistinguishable in the toast.

The context menu's markAsRead handler was the one action left reading
the stale contextMenu.data instead of the live-refreshed
contextMenuEmail introduced alongside it, so it could act on outdated
email state while every sibling handler was already updated.
2026-07-30 18:39:49 +02:00
Mathy Vanvoorden ea7892b497 feat: Change the dev mode defaults to include nested tags 2026-07-29 19:52:31 +02:00
Mathy Vanvoorden d52dfebad4 Fix Catalan translation warnings 2026-07-29 19:16:31 +02:00
Mathy Vanvoorden 7bb58f4f9f Add translations for new tag functionality 2026-07-29 19:14:28 +02:00
Mathy Vanvoorden f1e1ed1df7 fix: make the tint of selected rows work the same way in dark and light mode 2026-07-29 17:18:42 +02:00
Mathy Vanvoorden d9d9f91a86 feat: Make it easier to handle multiple tags
- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 108406a885 feat: improve visualization of tags
Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 013ef7d557 fix: remove unused code
This code is nowhere used, so to prevent extra work during an upcoming refactor
of tags, it is removed and some related tests are now actually made useful
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 56d11b5759 fix: remove the reset-to-defaults button from tag settings
If you carefully crafted your tags and then click this button by accident, all
your hard work is gone. A confirmation message would be the other solution but
since I have difficulty to grasp when you would need such a button, I propose
to just remove it.
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 0c1e238223 feat: allow hidden tags, either permanent or when there are no unread messages 2026-07-29 17:18:42 +02:00
Mathy Vanvoorden ca0ba818b7 feat: Add nesting of tags in a tree
- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room

Closes #687.
2026-07-29 17:18:38 +02:00
Linus RathandGitHub ce97a54aaa Merge pull request #705 from guisea/fix/forward-as-attachment-filename-privacy
fix: strip from/to names from forward-as-attachment filenames
2026-07-29 17:15:12 +02:00
Linus RathandGitHub b605b6d49d Merge pull request #707 from paulhenry46/prf-secu-fix
fix(plugins): prevent a privileged plugin to get PRF secret of anothe…
2026-07-29 17:14:52 +02:00
Linus RathandGitHub cea3ec0fe6 Merge pull request #709 from MathyV/fix-animations
fix: restore animations and replace tailwind.config.ts
2026-07-29 16:53:46 +02:00
Mathy Vanvoorden f56ca594dc fix: restore animations and replace tailwind.config.ts
- tailwind.config.ts is not actually being used so removed
- moved animate-fade-in to globals.css
- added tw-animate-css package to make animate-in work
- added a definition for animate-shake as it doesn't exist in tw-animate-css
2026-07-29 15:59:45 +02:00
Paulhenry Saux 66ff523553 feat(plugins): add 2 new methods : user.getAccounts and user.getIdentities 2026-07-29 13:14:09 +02:00
Paulhenry Saux 7969fd09eb fix(plugins): prevent a privileged plugin to get PRF secret of another privileged plugin 2026-07-29 11:30:12 +02:00
Aaron Guise 3108b2f336 fix: avoid leaving TZ="undefined" when restoring an unset timezone
process.env coerces assigned values to strings, so
process.env.TZ = originalTZ left the literal string "undefined"
behind (instead of clearing TZ) when it was unset before the test
ran. Delete the var in that case instead of assigning undefined.
2026-07-29 13:31:56 +12:00
Aaron Guise 71565e9328 test: restore TZ after pinning it in forward-as-attachment tests
Setting process.env.TZ at module scope without restoring it could leak
into other test files sharing the same Vitest worker. Match the
beforeAll/afterAll restore pattern already used in
lib/__tests__/calendar-utils.test.ts.
2026-07-29 11:47:42 +12:00
Aaron Guise edd11ac27b fix: strip from/to names from forward-as-attachment filenames
buildForwardAsAttachmentPayload named the synthetic .eml attachment
using the user's own emailDownloadTemplate, which by default embeds
sender/recipient display names. Since this attachment can go to an
external recipient (e.g. an upstream spam gateway, or anyone else),
the filename now always renders as "{date}-{subject}.eml" regardless
of the user's configured template, while still honoring their
space/case/diacritics preferences.
2026-07-29 11:41:34 +12:00
Linus RathandGitHub f81b02ead9 Merge pull request #697 from shukiv/fix-impersonation-stale-account
fix(impersonation): reconcile stale account chip after handoff
2026-07-27 17:06:15 +02:00
Linus RathandGitHub 25e542867b Merge pull request #698 from guisea/feature/forward-as-attachment
feat: add "Forward as attachment" next to Export as .eml
2026-07-27 17:04:21 +02:00
Aaron Guise 0a1114f710 fix: fall back to a real tab title for subject-less Pro forward-as-attachment
buildForwardAsAttachmentPayload intentionally returns an empty subject
for a subject-less email (matching normal Forward's composer-subject
behavior, fixed in 3bfa73f3), but this handler was reusing that same
empty string as the Pro compose tab's title. handleForward, right
above it, already computes its title with a fallback
(email.subject || t('email_composer.new_message')) before prefixing -
mirror that instead of reusing payload.subject for the title.

Caught by GitHub Copilot's automated PR review.
2026-07-27 20:38:02 +12:00
Aaron Guise 3bfa73f375 fix: leave subject blank (not "Fwd:") for a subject-less message
buildForwardAsAttachmentPayload called buildForwardSubject(email.subject,
forwardPrefix) unconditionally, and buildForwardSubject("", prefix)
returns just the bare prefix rather than "". Normal Forward doesn't do
this - EmailComposer's getInitialSubject() returns "" outright when
!replyTo?.subject, only calling buildForwardSubject when there's an
actual subject to prefix. So forwarding a subject-less message as an
attachment produced "Fwd:" as the subject, while normal Forward left
it blank.

Only call buildForwardSubject when email.subject is truthy, matching
getInitialSubject()'s behavior exactly. Add a test.

Caught by GitHub Copilot's automated PR review.
2026-07-27 17:42:33 +12:00
Aaron Guise fc50e5b569 fix: wire "Forward as attachment" into Pro's popped-out email tab
The earlier fix (f3b68194) addressed the Pro/embedded composer-hoisting
path (composing FROM the Mail tab, then getting hoisted into a Pro
tab), but missed a second, entirely separate render path: viewing an
email that's already been popped into its own Pro tab
(components/pro/pro-email-tab-body.tsx). That component renders its
own <EmailViewer> with its own self-contained handleForward - it
fetches its own `email` and opens compose tabs directly via
useProTabStore, with no dependency on page.tsx's pendingDraft/
selectedEmail plumbing at all - so it never had onForwardAsAttachment
wired in the first place. The overflow menu there just silently had
no such item, since EmailViewer only renders it when the prop is
provided.

Add handleForwardAsAttachment here, mirroring handleForward but using
the shared buildForwardAsAttachmentPayload helper, with the same
filename-options handling as the page.tsx fix (7a483b3d/a34314ce). No
stale-closure risk here (unlike the list context menu fix) - `email`
is this component's own local per-tab state, not a global selection
being mutated synchronously before the call.
2026-07-27 17:33:53 +12:00
Aaron Guise a34314cef5 fix: pass email explicitly to handleForwardAsAttachment from the list
The list context-menu wiring called selectEmail(email) then invoked
handleForwardAsAttachment() synchronously in the same tick. Since
handleForwardAsAttachment read selectedEmail from its own closure, and
Zustand's store update doesn't propagate into this render's closure
until the next render, this could forward the previously selected
message (or no-op if nothing was selected yet) instead of the row the
user actually right-clicked.

Parameterize handleForwardAsAttachment to accept an explicit `email`
(defaulting to selectedEmail), the same pattern handleDelete already
uses in this file for the same class of problem, and pass it
explicitly from the list context-menu wiring. The EmailViewer overflow
menu's wiring is unaffected - it always operates on the single
currently-open email via the default parameter.

Caught by GitHub Copilot's automated PR review.
2026-07-27 17:13:53 +12:00
Aaron Guise 0563e88eb8 fix: hide "Forward as attachment" in overflow menu when blobId missing
The overflow menu ("...") showed "Forward as attachment" whenever the
handler was provided, regardless of whether the open email has a
blobId. If it doesn't, handleForwardAsAttachment immediately no-ops
(buildForwardAsAttachmentPayload returns null), so the item was
clickable but did nothing - inconsistent with the list context menu's
version, which is already disabled in that case
(!onForwardAsAttachment || !email.blobId).

Gate both occurrences (desktop and mobile layouts) on email?.blobId
too, matching the context menu's behavior.

Caught by GitHub Copilot's automated PR review.
2026-07-27 16:52:54 +12:00
Aaron Guise 7a483b3dd4 fix: honor the user's filename template for forward-as-attachment
buildForwardAsAttachmentPayload called emailExportFilename(email) with
no options, always using the default naming template regardless of
the user's configured emailDownloadTemplate (and space/case/diacritics
transforms) - the same settings the neighboring "Export as .eml"
action already respects. That could produce inconsistent .eml
filenames between the two actions for the same message.

Accept an optional EmailFilenameOptions parameter and pass it through.
handleForwardAsAttachment now reads the same settings
email-viewer.tsx's emailFilenameOptions useMemo does, via
useSettingsStore.getState() (a one-off read inside an event handler,
matching this file's existing pattern, rather than a new reactive
subscription). Add a unit test covering a custom template.

Caught by GitHub Copilot's automated PR review.
2026-07-27 16:45:53 +12:00
Aaron Guise 351f2d4e26 feat: add "Forward as attachment" to the message list context menu
Adds the same "Forward as attachment" action from the message viewer's
overflow menu to the right-click context menu on a message row in the
list, right after "Forward". Reuses the handleForwardAsAttachment
handler and buildForwardAsAttachmentPayload helper introduced earlier
in this PR - no new logic, just threading the prop down EmailList ->
EmailContextMenu the same way onForward already is.

blobId is already present on list-row emails (EMAIL_LIST_PROPERTIES
includes it specifically for the existing drag-out-to-filesystem .eml
export feature), so this works without any additional fetch. The menu
item is disabled if it's ever missing, matching how other actions
degrade when their handler prop isn't supplied.

Reuses the email_viewer.forward_as_attachment translation key already
added (via a second scoped useTranslations("email_viewer") call,
matching this file's existing cross-namespace pattern for
email_viewer.color_tag) rather than adding a duplicate key under
context_menu - avoids touching all 24 locale files again.
2026-07-27 16:37:04 +12:00
Aaron Guise f3b6819463 fix: honor pendingDraft.replyTo in the Pro embedded composer hoist
The Pro/embedded composer-hoisting effect built its own `replyTo`
straight from `selectedEmail`, unconditionally, ignoring
`pendingDraft.replyTo`. That meant intent set by the opener - e.g.
handleForwardAsAttachment's synthetic message/rfc822 attachment -
would silently get dropped when the composer is hoisted into a Pro
tab, falling back to a normal quoted forward instead. Mirror the same
precedence the non-embedded render path already uses just below
(`pendingDraft.replyTo` wins when set).

Caught by GitHub Copilot's automated PR review.
2026-07-27 16:18:47 +12:00
Aaron Guise 3ea22161d9 feat: add "Forward as attachment" next to Export as .eml
Adds a "Forward as attachment" action to the message overflow menu
(desktop and mobile), right beside the existing "Export as .eml"
action. Opens a new forward-mode compose window with the original
message attached as a message/rfc822 file instead of quoted inline -
useful for reporting spam/phishing to an upstream gateway that expects
the raw original as an attachment (the primary motivating use case:
gateways like MxGuarddog require complete original headers, including
the full mail path, for scanning), or for preserving a message's exact
formatting/headers when forwarding.

Implementation reuses the composer's existing attachment-carry-forward
mechanism (the `attachments` useState initializer in
email-composer.tsx already carries a forwarded message's own
attachments into the new compose via `replyTo.attachments`) - this
just adds one synthetic entry representing the whole original message,
referenced by its existing blobId. No re-fetch or re-upload needed,
since JMAP blobs are account-scoped rather than per-email. The inline
quote-header step (prepareComposerQuoteHeader) is skipped, so the body
starts blank instead of quoting the original.

The core "build subject + attachment entry" logic is extracted into a
pure, unit-tested helper (lib/forward-as-attachment.ts) rather than
left inline in the already-large page component.

Adds the forward_as_attachment locale key to all 24 locales (English
text as a placeholder pending translation, following the existing
add-a-key convention) to satisfy the translations completeness test.
2026-07-27 15:31:52 +12:00
shukiv 246df49c03 fix(impersonation): reconcile stale persisted account chip after handoff
After a master-user impersonation handoff (GET /api/auth/impersonate) the server
swaps the slot-0 session cookie but the client's persisted account registry
(account-registry / auth-storage in localStorage) still lists the previous
account, so the top-left account chip keeps showing the old mailbox until a
manual sign-out. Redirect impersonation to /?impersonated=1 and add a headless
ImpersonationReconciler that drops the stale persisted account/auth state (and
server-derived caches) then reloads to a clean URL, so the app rehydrates empty
and re-derives the single account from the fresh session. Cookies untouched, so
the just-granted session survives. Runs exactly once.

Reported downstream: shukiv/jabali-panel#646.
2026-07-27 05:20:53 +03:00
Linus Rath 9c04950a94 docs: use sentence case for headings 2026-07-25 17:46:48 +02:00
Linus Rath 934967b9df docs: document remaining env vars in env templates 2026-07-25 17:46:12 +02:00
Linus Rath 755201c92a docs: fix facts and rewrite tone 2026-07-25 17:38:55 +02:00
Linus RathandGitHub 9b69d89dfd Merge pull request #681 from marc0s/feature/catalan-translation
feat: add Catalan translation
2026-07-24 21:55:42 +02:00
Linus RathandGitHub 17b69e68f3 Merge pull request #673 from dealerweb/i18n/editor-toolbar
i18n: localize the editor toolbar in all 23 locales
2026-07-24 21:55:01 +02:00
Linus RathandGitHub 83cd675ccf Merge pull request #680 from hildebrandttk/fix/cross-account-move
Fix/cross account move
2026-07-24 21:54:25 +02:00
Linus RathandGitHub ddcab88e56 Merge pull request #686 from hildebrandttk/feat/unified-mailbox-always-available
feat(settings): always show the Unified Mailbox switch in Layout sett…
2026-07-24 21:53:40 +02:00
Stefan Hildebrandt f188e29152 feat(settings): always show the Unified Mailbox switch in Layout settings
Drop the `accounts.length > 1 || hasGroupInboxes` gate that hid the
Unified Mailbox toggle for single-account users with no visible shared
folder. The admin `isSettingHidden('enableUnifiedMailbox')` policy gate
is preserved, so admins can still hide it.
2026-07-24 19:39:01 +02:00
Stefan Hildebrandt b48b6e0871 fix(email): defer source removal on cross-account move to Stalwart
The explicit Email/set destroy workaround for the duplicate-on-move bug is
removed now that the root cause is filed upstream (support.stalw.art #1150:
onSuccessDestroyOriginal destroys the copy's create-id instead of the source
id). copyEmailAcrossAccounts keeps requesting onSuccessDestroyOriginal, so the
move self-heals once Stalwart ships the fix.

Kept: the keyword-preservation fix (carry the source keywords into Email/copy)
so the moved message keeps its read state.

Tests: 08-shared-moves still asserts delivery + read-state on every
cross-account case; the source-removal checks are re-pinned test.fail, scoped
to a nested describe, until #1150 is fixed. Suite green (5 pass, 3 expected-fail).
2026-07-24 18:30:50 +02:00
Stefan Hildebrandt 6248bb9825 fix(email): preserve read state and remove source on cross-account move
Moving a message across the account boundary (own ↔ shared folder, or
between two owners' shared folders) left the original in the source
folder and showed the moved copy as unread. Same-account moves were fine.

Cause (verified against a live Stalwart):
- Email/copy drops keywords unless the create sets them, so the copy lost
  $seen and arrived unread.
- onSuccessDestroyOriginal is unreliable — the implicit destroy reports
  notFound and leaves the original behind (flaky), so the move duplicated.

Fix (copyEmailAcrossAccounts): read the source keywords and carry them
into the Email/copy create, then destroy the original with an explicit
Email/set on the source account instead of onSuccessDestroyOriginal.

Tests: 08-shared-moves now asserts the source is gone and the read state
survives on every cross-account case, and adds a cross-owner shared →
shared move (alice's folder → bob's folder). Confirmed red on the old
code (3 cross-account cases fail), green with the fix.
2026-07-24 18:30:50 +02:00
Stefan Hildebrandt 15ad783848 fix(email): make the "Move to" context menu work across accounts
Moving a message to a folder in another account (own ↔ delegated/shared) via the
"Move to" context menu was a no-op — the handlers always issued a single-account
Email/set, which can't move between JMAP accounts. Drag-and-drop already routed
these correctly; the context menu never did.

Add moveToMailboxCrossAware: it detects a cross-account destination (own and
shared mailboxes both carry accountId) and routes through the drag-and-drop
crossAccountMoveEmails pipeline, else falls back to the single-account move.

Fix the pipeline for delegated folders too: a client can't stage a blob in a
delegated account (Blob/upload → blobNotFound), so importing into a shared folder
failed. When one client reaches both accounts, use a server-side JMAP Email/copy
(+ destroy original) instead of blob copy+import; the blob path is kept only for
separate cross-server login accounts. Adds client.copyEmailAcrossAccounts.

Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full
docker integration suite green (37 passed).
2026-07-24 18:30:50 +02:00
Linus RathandGitHub fc116a8b2f Merge pull request #676 from dealerweb/fix/aborted-sse-connect-fallback
Fix: treat an aborted SSE connect as a close, not a failure
2026-07-23 23:22:27 +02:00
Linus RathandGitHub a16478ffad Merge pull request #679 from hildebrandttk/fix/draft-sender-identity
fix(email): restore the sender identity when reopening a draft
2026-07-23 23:21:54 +02:00
marc0s 144d6503cc feat: add Catalan translation 2026-07-23 19:41:21 +02:00
Stefan Hildebrandt 9c6292b4b7 fix(email): restore the sender identity when reopening a draft
Reopening a draft reset the composer's From to the default identity. The
edit-draft handler matched the draft's saved From against the active-account
identity list by email only, so two identities sharing an address (a default +
an alias differing by name) collided — the wrong one was picked, or with
cross-account namespaced ids none was.

Add findDraftIdentityId (name+email, normalized, +tag fallback) and match against
the same list the composer renders (the flat cross-account list when multi-
account is on). Wired into both the classic and Pro edit-draft paths. Unit tests
plus the un-pinned 07-drafts integration spec.
2026-07-23 19:36:33 +02:00
Linus RathandGitHub 457063a48b Merge pull request #677 from dealerweb/fix/calendar-fanout-access-denied
Fix: stop re-probing shared accounts without calendar access
2026-07-23 18:44:53 +02:00
dealerweb 21ea5009f4 i18n: localize the editor toolbar in all 23 locales
The rich-text editor was the last hardcoded-English surface in the
composer: 21 tooltip titles, the eight table-menu entries, the "Remove
color" entry and the table size picker's "Pick size" label were plain
strings while every other menu in the app is localized.

All of them now come from a new email_composer.toolbar namespace,
translated into all 23 locales using each platform's established
editor terminology (Word/Docs conventions - de "Formatierung löschen",
ar "مسح التنسيق", ja "書式をクリア", ...). The link prompt stays "URL",
which is the same term in every language.

Tooltips and self-sizing dropdowns have no width constraints, so longer
translations are safe everywhere.
2026-07-23 17:59:44 +02:00
Linus RathandGitHub 74f9335e36 Merge pull request #675 from dealerweb/fix/stale-draft-after-send
Fix: stop resurrecting deleted rows in the mailbox refresh merge
2026-07-23 16:13:43 +02:00
dealerweb 010f082c73 Fix: stop re-probing shared accounts without calendar access
The calendar fan-out probes every shared/group account on suspicion,
because Stalwart does not always advertise calendar capability on group
accounts. A shared account that grants no calendar access at all rejects
that probe - and did so again on every calendar interaction: each range
change re-queried the account and logged a red console error
("You do not have access to account X") while working fine otherwise.

Remember the rejection instead: the thrown query error now carries the
JMAP error type, an access rejection for a probed secondary account is
logged once at debug level, and both fan-out loops (events and calendar
lists) skip the account for the rest of the session. Genuine failures on
the primary account keep the error log. Two regression tests cover the
probe-once behavior and the calendar-list skip.
2026-07-23 14:55:34 +02:00
dealerweb 00dc509e60 Fix: treat an aborted SSE connect as a close, not a failure
Since the per-account push setup (#281), every account switch tears down
and re-creates push notifications for all connected clients. Aborting an
SSE connect that is still in flight lands in the fetch rejection handler,
which treated it as a network failure:

- fallbackToPolling() started an unsupervised 3s state poll on a client
  whose push had just been intentionally closed, after the cleanup that
  would have removed it had already run.
- The late rejection also nulled sseAbortController, orphaning the
  replacement connection set up right after: it could never be aborted
  again and reconnected itself in parallel once the server closed it.

Rapid switching multiplied both effects until the server's concurrency
limit stalled the app entirely.

Each connect attempt now tracks its own AbortController: an aborted
attempt returns silently instead of falling back to polling, and the
end-of-stream reconnect only fires if the stream is still the current
one. Regression tests simulate the switch churn both ways.
2026-07-23 14:34:32 +02:00
dealerweb af2b00dd35 Fix: stop resurrecting deleted rows in the mailbox refresh merge
Fixes #592.

refreshCurrentMailbox merges the refreshed first page with the already
loaded list, appending existing entries beyond a cutoff. That cutoff
was derived from the refreshed list's length - so whenever a folder
shrank, the fresh page was shorter than the stale list and the loop
re-appended the deleted rows from stale local state, despite the
comment right above promising the opposite.

The visible result is the reported bug: after sending a draft, the
Drafts view keeps showing a ghost row for the already-destroyed draft.
The send actually succeeded - resending the ghost delivers the mail
again, which we reproduced with a live JMAP trace: four successful
submissions, an empty server-side Drafts folder, a notFound ghost id,
and five delivered copies. Deriving the cutoff from the page size
fixes the shrink case while preserving the merge's intent for
arrivals and loaded deeper pages; regression tests cover all three
shapes.

Also surface post-send filing failures instead of dropping them, as
flagged in 4dc76bbb's follow-up note: a rejected onSuccessUpdateEmail
patch or old-draft destroy now logs the server's error details and
returns a filingError on SendEmailResult, and the UI shows a warning
toast (all 23 locales) so a stale draft row is never again mistaken
for a failed send. A plugin veto of the send leaves a debug trace.
2026-07-23 13:26:05 +02:00
Linus RathandGitHub e442c55931 Merge pull request #669 from paulhenry46/contact-API
feat: add contact methods in plugin API
2026-07-22 21:22:37 +02:00
Paulhenry Saux 2245ad2024 fix(plugins): use correct method name for message errors in host-api.ts 2026-07-22 20:28:32 +02:00
Linus Rath 7511d8ea78 chore: update version to 1.7.8 2026-07-22 19:43:09 +02:00
Paulhenry Saux 7bf62e4bdc feat: add contact methods in plugin API 2026-07-22 19:34:45 +02:00
Linus Rath 959d4bd6ce fix: assign uid to contact cards on creation #644 2026-07-22 19:18:59 +02:00
Linus Rath b7c8cd999e feat: collapse quoted reply text behind a "..." toggle #480 2026-07-22 19:17:23 +02:00
Linus Rath 813185e58d test: fix broken suites 2026-07-22 18:56:09 +02:00
Linus Rath 3f22a3323a i18n: add missing translation keys across 22 locales 2026-07-22 18:45:22 +02:00
Linus Rath 0e4efb5a2a fix: honor "Show time in month view" on mobile instead of forcing dots #666 2026-07-22 18:21:02 +02:00
Linus Rath b354319b82 feat: support HTML body in vacation responder 2026-07-22 17:56:05 +02:00
Linus Rath 5a8c69dac2 fix: insert mail template at caret in replies instead of prepending #539 2026-07-22 17:53:40 +02:00
Linus Rath 24056e4698 fix: eliminate full-screen flash when switching accounts 2026-07-22 17:48:39 +02:00
Linus Rath 5818e60401 fix: prevent loading flash when switching to a cached account 2026-07-22 17:42:48 +02:00
Linus Rath 7beaf991e8 fix: recognize canonicalized login usernames in account-switch guard 2026-07-22 17:37:33 +02:00
Linus Rath 5105e000f5 feat: add message-list category tabs 2026-07-22 17:22:20 +02:00
Linus RathandGitHub 66bc10fa0f Merge pull request #668 from paulhenry46/ui.rerenderFetchedEmails-hook
feat: add new plugin ui.rerenderFetchedEmails method
2026-07-22 16:21:18 +02:00
Paulhenry Saux 07c473e057 feat: add new plugin ui.rerenderFetchedEmails method 2026-07-22 13:39:21 +02:00
Linus RathandGitHub 0e47c3b039 Merge pull request #520 from maartendra/feat/login-show-totp-version
feat(login): add LOGIN_SHOW_TOTP and LOGIN_SHOW_VERSION config flags
2026-07-22 08:27:44 +02:00
Maarten DraijerandClaude Fable 5 e1a973663f Merge upstream/main to resolve conflicts
Both sides added adjacent LOGIN_* config entries (upstream:
loginShowHeading/loginShowSubtitle/logo sizing; this branch:
loginShowTotp/loginShowVersion) — resolution keeps both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
2026-07-22 02:54:55 +00:00
Linus Rath de55fb6b73 fix: honor part-type fallback when quoting replies #649 2026-07-21 23:27:40 +02:00
Linus Rath a909593dda fix: detect typing inside the QuotedHtml shadow island via composedPath #654 2026-07-21 23:26:51 +02:00
Linus Rath 4ad9267a2d chore: bump dompurify to 3.4.12 and next-intl to 4.13.3, npm audit fix for dev deps 2026-07-21 23:23:14 +02:00
xhzeemandLinus Rath 23a017d4b7 fix(rtl): set dir=ltr on identity <option> elements
The From-identity picker in the composer and the template form use a
native <select>, so the earlier <bdi> fix can't apply there - browsers
render <option> as plain text and strip any nested markup. The native
OS-rendered option list still respects the dir attribute directly
though, so setting dir="ltr" on each option fixes the same bracket-
mirroring bug for "Name <email>" entries in that native popup.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath c7250dc921 fix(rtl): isolate Latin address text ("Name <email>") from RTL bidi reordering
Unicode's bidi algorithm treats < and > as mirrored characters. When a
plain "Name <email>" string is rendered as a text node inside an
RTL-inherited container, the browser swaps and reorders those brackets
for the whole run, producing garbled output (e.g. "<Maria Lopez
<maria.lopez@company.example" instead of "Maria Lopez
<maria.lopez@company.example>").

Wrapped the affected text in native <bdi>, which auto-detects its own
paragraph direction from its content rather than inheriting the
ancestor's - so a Latin address renders LTR and a genuinely
Arabic/Hebrew/Farsi name still renders RTL, both correctly, in:
- recipient-popover.tsx (shared by the email viewer's From/To/Cc/Bcc
  detail rows and the calendar invitation banner's organizer row)
- email-composer.tsx's read-only From display
- eml-preview.tsx's From/To header lines

Left the equivalent <select><option> cases (composer identity picker,
template identity picker) and the composer's quote-header text (which
becomes actual email body content, already isolated per-paragraph by
the existing TextDirection tiptap extension) out of scope - both need
a different fix approach than <bdi>.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath 80f76abc38 fix(rtl): flip JS-positioned popovers (storage, logout, account switcher, calendar picker)
These popovers are portaled and positioned via inline styles computed
from getBoundingClientRect() rather than Tailwind classes, so the
logical start-0/end-0 fix doesn't reach them. They always anchored to
the physical right of their trigger (rect.right + 8), which in RTL
pushes them further into the edge the trigger is already flush against
instead of toward the visible content area.

Added isDocumentRTL() to i18n/direction.ts and used it to mirror the
computed position in:
- navigation-rail.tsx: storage quota popover, logout/switch-account menu
- account-switcher.tsx: both the rail and expanded-sidebar variants
- calendar-invitation-banner.tsx: the "add to calendar" picker
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath adb8686293 fix(rtl): anchor floating menus with logical start/end instead of left/right
Popovers and dropdown menus across the app (sub-address helper, calendar
toolbar/color pickers, contact/template/attachment menus, rich text editor
color and table pickers, unsubscribe confirmation, composer send menu)
were anchored with physical `left-0`/`right-0`. In RTL locales those
don't flip with the trigger, so the menu detaches from the button that
opened it. Switched to Tailwind's logical `start-0`/`end-0` (and the
matching `rounded-s-*`/`rounded-e-*` corners on hover-action overlays)
so they mirror correctly for RTL locales (ar, he, fa) while staying
identical in LTR.
2026-07-21 23:08:02 +02:00
xhzeemandLinus Rath d531ad1930 fix(i18n): register ar messages in the client IntlProvider
components/providers/intl-provider.tsx keeps its own static ALL_MESSAGES
map separate from i18n/request.ts's server-side loader. It was missed
when ar was added, so switching to Arabic flipped to RTL (direction.ts
knew about ar) but rendered English text (messages lookup fell through
to the en fallback).
2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath 953355d2a5 fix(i18n): use UAE flag instead of Saudi flag for ar locale 2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath 8155f98a28 fix(i18n): use Saudi flag instead of pan-Arab colours for ar locale 2026-07-21 23:07:40 +02:00
xhzeemandLinus Rath fda898fc96 feat(i18n): add full Arabic (ar) translation
Adds a complete Arabic locale (2759 keys, full parity with en) and wires
it into routing, RTL direction detection, message loading, the language
switcher, and flag icons alongside the existing he/fa RTL locales.
2026-07-21 23:07:40 +02:00
Shuki VakninandLinus Rath 3dceecb4c5 fix(i18n): Hebrew Drafts folder label was the board game (דמקה) 2026-07-21 23:06:58 +02:00
Shuki VakninandLinus Rath 53461d1142 feat(email-viewer): message spacing setting (auto/always/edge-to-edge) 2026-07-21 23:06:43 +02:00
Linus Rath 4d9d992f3f chore: update package-lock.json metadata 2026-07-21 23:05:21 +02:00
Linus Rath 162e420a1f fix: stop HELO spf=none from downgrading a MAIL FROM spf=pass #650 2026-07-21 23:03:34 +02:00
Kristofer PettijohnandLinus Rath e8f01871c2 fix(calendar): classify self-organized imported events as editable via organizerCalendarAddress fallback 2026-07-21 21:00:53 +02:00
Shuki VakninandLinus Rath 6dfcb07b9a feat(accounts): remove a specific account from the switcher
The switcher only offered 'sign out of active' and 'sign out of all' — no
way to drop a single non-active account (e.g. one stuck in an error state you
can't switch into to sign out). Add a hover × on non-active, non-default rows
and a removeAccount(id) auth action that tears down the client, drops it from
the registry, and clears its per-slot session/token cookies.

Stacks on the switcher redesign in #517.
2026-07-21 20:59:52 +02:00
HardAndHeavyandLinus Rath 0f3459c2e5 feat: add NEXT_PUBLIC_LOCALE_PREFIX build argument to Dockerfile 2026-07-21 20:59:33 +02:00
Stefan HildebrandtandLinus Rath d5017a211f feat(email): open external links in a new tab (safely)
External web links (http/https) in rendered email bodies open in a new browser
tab with target="_blank" rel="noopener noreferrer". mailto:, tel:, and in-page
#anchors keep their default behavior instead of spawning a blank tab.

The plaintext render path is already handled on main by #594 (ADD_URI_SAFE_ATTR
in PLAIN_TEXT_RENDERED_CONFIG), so this no longer adds its own hook there — the
plaintext linkifier only ever emits http(s) anchors, so the config's declarative
exemption is sufficient. This change covers the paths #594 did not:

- iframe HTML render: both anchor passes (the DOMPurify hook and the post-render
  DOM walk in email-viewer.tsx) set target=_blank on EVERY <a>, including
  mailto:/tel:. Now scoped to http(s) via the shared applyNewTabToAnchor()
  helper (http/https -> target+rel; mailto/tel/#/other -> strip target/rel).
- sanitizeI18nHtml: the same DOMPurify strip dropped target/rel from translated
  links (e.g. the docs link in settings.security.not_available, target="_blank"
  in 19/22 locales). Keep the author's target and harden rel="noopener
  noreferrer".

Tests: unit coverage for isHttpLinkHref / applyNewTabToAnchor / sanitizeI18nHtml
plus an integration suite over the real plaintext and HTML/iframe render
pipelines. The plaintext-hook-specific cases are dropped as redundant with #594.
2026-07-21 20:59:20 +02:00
Shuki VakninandLinus Rath f51ec50443 feat(settings): add "Refresh cached data" recovery action
When the mailbox view gets into a stale or wrong state, the only escape
was the browser's "clear site data" — which also wipes the saved account
list, forcing a re-login of every account.

Add a non-destructive "Refresh cached data" button under Settings →
Data. It clears the server-derived caches (contacts, calendars,
identities, per-account snapshots) and reloads so they re-fetch fresh,
while preserving accounts, sessions, settings, themes and user content
(templates, S/MIME). Two-click confirm to avoid an accidental reload.

English strings added across all locales (translation follow-up); unit
tests cover the cache-clear (keeps account-registry/auth/prefs) and the
reload.
2026-07-21 20:58:55 +02:00
Shuki VakninandLinus Rath f2703bcc27 fix: guard false-positive on basic-auth accounts (identity != login)
Accounts whose primary sending identity differs from their login (basic
auth registers accountId from the typed login; OAuth from the identity
email) were force-re-authed on switch because the guard derived the
connected id only from the primary-identity email. Collect every
server-confirmed identifier (JMAP Session.username + primary-identity
email) and only re-auth when the target matches none. Excludes the
constructor username so a real desync still trips. Adds
JMAPClient.getSessionUsername().
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath cda4dcbf01 fix(auth): guard account switch against slot→token desync
When switching accounts, the target client connects with the token at
the account's stored cookieSlot. If that slot→token mapping is ever
wrong — e.g. corrupted client state persisted by an older build, or any
future slot desync — the connection succeeds as a *different* account
and the UI silently shows the wrong mailbox.

Add a post-connect identity guard: derive the connected session's
accountId (primary-identity email for OAuth, else the JMAP session
username) and compare it to the account being switched to. On mismatch,
drop the poisoned slot cookies and force a clean re-auth instead of
binding the wrong session.

This is belt-and-suspenders on top of 8b164c5, which fixed the slot
allocation that caused such a desync: that prevents new corruption,
this catches any residual/leftover mapping at switch time.

Adds a unit test for the canonicalisation (email vs JMAP username), the
make-or-break detail that avoids OAuth false-positives.
2026-07-21 20:58:47 +02:00
Shuki VakninandLinus Rath f15edd336b feat(send): 'Send now' on the send-delay toast
The post-send undo toast ('scheduled to send' + Cancel send) now also offers a
'Send now' action that reschedules the delayed submission for immediate release,
so you can skip the undo window without waiting it out. Adds an optional
secondaryAction to the toast component and carries identityId on the pending
undo-send state so the reschedule can target the right identity.
2026-07-21 20:58:27 +02:00
HardAndHeavyandLinus Rath a3f9055541 ci: build and publish images with NEXT_PUBLIC_LOCALE_PREFIX=always 2026-07-21 20:58:02 +02:00
dealerwebandLinus Rath a779e101e6 Fix: label the close-dialog draft button with the generic Save
"Save Draft" made the third button of the save-or-discard dialog wrap
onto two lines in several languages (German "Entwurf speichern", French
"Enregistrer le brouillon", ...) while its siblings stay one line. The
dialog title already says the draft is what's being saved, so the
button now uses the existing generic common.save key - one short word
in every locale, no new translations needed.

email_composer.save_draft had exactly this one consumer; the dead key
is removed from all 22 locales.
2026-07-21 20:57:29 +02:00
Shuki VakninandLinus Rath c38bcc4a95 feat(email-list): add bulk Not-Spam action to selection toolbar in junk 2026-07-21 20:57:08 +02:00
Shuki VakninandLinus Rath 5716d91115 feat(folders): drag-and-drop reorder for all folders 2026-07-21 20:56:38 +02:00
Stefan HildebrandtandLinus Rath 2f791318df test(integration): select the group From address in the #569 spec
Extend 04-shared-identity's UI test to not just assert team@example.org is
offered but to actually select it as the sender and confirm it becomes the
active From identity, then hold on the composer so the selected group address is
visible in the recorded video. Adds selectComposerFrom / selectedComposerFrom
helpers.
2026-07-21 20:56:27 +02:00
Stefan HildebrandtandLinus Rath 60b9ae66ea test(integration): add IT_VIDEO option to record test videos
Make Playwright's video capture configurable via IT_VIDEO (on | off |
retain-on-failure [default] | on-first-retry) so a whole run — passing tests
included — can be recorded, e.g. for a demo or to inspect a flow. Forward the
env through the Playwright container in run-tests.sh and document it in the
README's environment-knobs table.
2026-07-21 20:56:27 +02:00
Stefan HildebrandtandLinus Rath abd493fb4c feat: strip external url()/@import from <style> blocks in sanitizer (#457)
Defence-in-depth on top of the strict iframe img-src/media-src/font-src CSP
that already blocks <style>-tag fetches at the network level. The per-node
DOM walk in blockExternalResourcesOnNode only sees element attributes, so a
tracker hidden in a kept <style> block (background url(), @font-face, @import)
never passed through it.

Adds stripExternalStyleSheetCss(), wired into blockExternalResourcesOnNode for
STYLE nodes (so it's gated on shouldBlockExternal and drives the blocked-content
banner like every other vector). Decodes CSS escapes over the whole block first
so the escaped-keyword form \75\72\6C( -> url( is caught - a literal `url(`
match would miss it. Removes remote @import in both url() and bare-string forms.
2026-07-21 20:56:08 +02:00
Paulhenry SauxandLinus Rath b4739c111f feat: add new plugin API to submit without moving to box mail and import to box 2026-07-21 20:55:52 +02:00
KazNIISA ITandLinus Rath 88b07a1713 fix(email-store): route shared-folder batch actions to the owner account
Batch actions (delete, move, archive, mark-as-read) performed while
viewing a shared/group mailbox directly from the "Shared" sidebar section
were dispatched to the user's OWN account instead of the shared owner
account. Emails in that view are undecorated (no sourceAccountId, that is
only set in unified/cross-account views) and are reached through the
active client, so they fell into the '__default__' bucket / non-unified
else-branch, which defaults the JMAP accountId to the active account.
batchArchive independently picked the archive folder from the merged
mailbox list, where the user's own archive is listed first.

Stalwart then applies Email/set to the wrong account: because the ids
belong to the shared account it returns them as `updated: null` with an
unchanged state (a silent no-op, not `notUpdated`), so the UI drops the
rows optimistically and they reappear on the next reload. It only appears
to work when the own and shared folder ids happen to collide.

Add resolveViewAccountId() — the owner accountId of the directly-viewed
shared folder (from the selected namespaced mailbox), undefined for a
normal own-account view, mirroring fetchEmails and the single-email path.
Route the four batch actions to that owner account (via the active
client); batchMoveToMailbox also resolves the destination to its bare
originalId, and batchArchive scopes the archive folder to that account.
Own-account and unified/cross-account views are unchanged.

Adds email-store-shared-folder-actions.test.ts covering all four batch
actions in the non-unified shared view plus an own-account regression.
2026-07-21 20:55:26 +02:00
Paulhenry SauxandLinus Rath 18e9cf6ee6 fix: use fixed tailwind classes for chips icon 2026-07-20 20:23:11 +02:00
Paulhenry SauxandLinus Rath 2cb5c739b4 feat(plugins) : add onRecipientChipsChange hook 2026-07-20 20:23:11 +02:00
Marc SportielloandLinus Rath 0a30b2fb3a feat(templates): add support for HTML templates 2026-07-19 10:10:31 +02:00
Marc SportielloandLinus Rath 0b62afb0f8 fix(email-composer): hide template buttons when templates are disabled 2026-07-19 10:08:09 +02:00
Linus Rath f749ee1f2a fix: preserve POST across redirects in Stalwart JMAP passthrough #627 2026-07-16 22:51:07 +02:00
Linus Rath 4a4950c3e5 Fix: keep signature when inserting a template #621 2026-07-16 20:13:07 +02:00
Linus Rath 739b72d251 Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope
Feat/unified mailbox account scope

Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.

Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
  per-account default-identity (#507) migration at different versions (main v6,
  branch v7). Merged migration is version 7 using the refactored migrateSettings
  function; the unified-mailbox rework is guarded at `version < 7` so users who
  stopped at main's interim v6 identity bump still receive it, while the #507
  identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
  pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
  unified views) while preserving main's plugin hooks (onSearchResults /
  onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
  search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
  alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
  is authoritative (matches new behavior) with main's shared-identity (#569) group
  infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
  introduced by the auto-merge.
2026-07-16 19:57:51 +02:00
Linus Rath 682e47c970 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-16 18:08:40 +02:00
Linus Rath a6d8671306 Fix: render email body on DOM parse, not iframe load #635 2026-07-16 18:08:14 +02:00
dealerwebandLinus Rath 6f278845f3 Feature: text color picker in the composer toolbar
The rich-text editor already registers the TextStyle and Color
extensions so that colored text pasted or quoted from incoming mail
survives editing - but there was no way to set a color yourself.

Adds a "Text color" toolbar button next to the strikethrough control,
wired to the already-loaded extensions: a 2x8 preset swatch grid plus a
"Remove color" entry, following the table button's dropdown pattern
(wrapper ref, outside-click close, same popover styling) and the table
size picker's swatch grid. The button's baseline icon renders in the
currently active color, so the selection is visible without any extra
indicator element.

No new dependencies and no locale changes; the toolbar titles in this
file are plain English throughout, and Clear Formatting already removes
colors via unsetAllMarks.
2026-07-16 18:01:18 +02:00
honzupandLinus Rath 334fdbfb86 feat: show unread count badge on favicon
Closes #560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
2026-07-16 18:00:41 +02:00
Stefan HildebrandtandLinus Rath 578339c400 test(integration): composer From offers shared/group identities (#569)
Provision a Stalwart group (team@example.org) with carol as a member before her
first login, and assert the composer's From selector offers the group address.
This confirms the group-membership scenario of #569 already works out of the
box: Stalwart returns the group's send-as identity on the member's own account,
so the app's normal single-account identity load surfaces it (identities.length
> 1 -> the From <select> renders with team@).

- stalwart: create the `team` Group in plan-accounts and add carol via
  User.memberGroupIds in the entrypoint (id resolved after apply, like
  DOMAIN_ID). carol, not alice/bob, so the sync specs stay unshared.
- helpers: GROUP config, openComposer/composerFromOptions, and JmapClient
  accounts + sharedAccountNames (Identity/get needs the submission capability).
- composer: add data-testid="composer-from" to the From <select> and its
  single-identity <span> fallback.

Ref: https://github.com/bulwarkmail/webmail/issues/569
2026-07-16 17:59:52 +02:00
Stefan HildebrandtandLinus Rath 8d8bc7cb13 test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.

- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
  submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
  browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
  inspecting server state, and page helpers (login, add/switch account,
  locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
  burst) and multi-account (per-account isolation + cross-account Unified
  Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
  list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
2026-07-16 17:59:52 +02:00
KazNIISA ITandLinus Rath 4dc76bbb47 fix(jmap): file post-send message with a full mailboxIds replacement
Sending mail through Bulwark could leave the delivered message stuck in Drafts
(keeping the $draft keyword) and never file a copy into Sent, with no error
shown, for accounts whose Drafts/Sent mailbox JMAP id is a purely-numeric
string (e.g. "0").

The post-send Drafts->Sent move is expressed as onSuccessUpdateEmail on
EmailSubmission/set using `mailboxIds/<id>` JSON-Pointer patches. Stalwart
up to 0.16.4 (observed on 0.15.5) rejects an Email/set PatchObject whose
pointer token is all digits -- e.g. `mailboxIds/0` -- with invalidProperties
"Invalid patch value", treating the token as a JSON-Pointer array index even
though mailboxIds is a JSON object (cf. RFC 6901 section 4; RFC 8620 section
1.2 warns servers against such interop-hostile ids). Because the move runs
only AFTER the EmailSubmission already succeeded, the message is delivered but
the filing update is silently rejected: the send code inspects only
`notCreated`, not the onSuccessUpdateEmail `notUpdated` result, so nothing
surfaces to the user.

Stalwart fixed the pointer parsing server-side in 0.16.5
(stalwartlabs/stalwart@175f34ea, jmap-tools 0.1.4 -> 0.1.5; a sibling symptom
was stalwartlabs/stalwart#2985). The client-side change is still worthwhile:
earlier Stalwart deployments remain in the wild, and a full-property
replacement both states the actual intent of the move and emits no per-id
pointer token that another server could mishandle.

Replace the per-id pointer patches at every post-send / undo-send move site
(send, scheduled send, raw-import send, reschedule, and restoreEmailToDraft)
with a full `mailboxIds` property replacement via a new mailboxIdsReplacement()
helper. This states the actual intent -- after the move the message should
belong to exactly the target mailbox -- and is immune to the pointer-token
bug. Every one of these sites moves a message that Bulwark itself placed
solely in Drafts (or, for undo, in Sent), so the replacement is
behaviour-equivalent. Note it is a replacement: a membership added to the
message by another client between creation and send is not preserved.
restoreEmailToDraft now always lands the message in Drafts only (previously,
when no Sent mailbox id was passed, it left the Sent copy in place); the demo
client is aligned with the same contract.

Add regression tests for the full-replacement shape, a numeric ("0") Drafts id,
and restoreEmailToDraft.

Follow-up (not included here): the send paths still ignore the implicit
Email/set `notUpdated` result of onSuccessUpdateEmail, so any other post-send
filing failure would remain silent.
2026-07-16 17:58:19 +02:00
Shuki VakninandLinus Rath 04444003b2 feat(composer): auto-detect paragraph text direction by default 2026-07-16 17:56:50 +02:00
Joe PolastreandLinus Rath 7da3d4ae80 fix(email): show quote bar in email replies
Without the inline style in the serialized wrapper, the email reply quote bar gets lost (becomes invisible). Pull the style out into a const, and then use it in both the editor (NodeView) and the content wrapper for the email content that is sent.
2026-07-16 17:56:38 +02:00
LoneExileandLinus Rath cb5d754113 fix(oauth): harden OIDC discovery (timeout, retry, serve-stale) 2026-07-16 17:56:19 +02:00
Jesper OrdrupandLinus Rath 511f9e5195 fix: enable thread expansion in focused list 2026-07-14 16:20:52 +02:00
Joe PolastreandLinus Rath 996fa7eea6 fix: Generate Message-ID client-side using the sender's domain
Bulwark currently sends Email/set create without a messageId property,
leaving Message-ID generation to the JMAP server. Servers typically fall
back to their OS hostname for this (Stalwart, via mail-builder's
`gethostname()`), which produces IDs like:

```
<175234...abc@ip-10-0-12-97.ec2.internal>
```

This is bad for every deployment, in three escalating ways:

1. Information disclosure: the Message-ID travels in every outgoing
   message and permanently into archives, quoting, and In-Reply-To /
   References of replies. An internal hostname (container name, private
   DNS, k8s pod name) is infrastructure detail no recipient should see.

2. Deliverability: spam filters score Message-IDs whose domain part is
   not a plausible FQDN or is unrelated to the sender (SpamAssassin
   MSGID_FROM_MTA_HEADER and friends). Internal names like
   *.ec2.internal or bare container ids read as botnet-ish.

3. Correctness of intent: RFC 5322 §3.6.4 recommends the originator
   generate the Message-ID, using a domain it controls, so the id is
   meaningful and plausibly unique under that domain's authority. The
   sender's own domain is exactly that; the mail server's transient
   runtime hostname is exactly not.

Generate the id in `sendEmail()` as `<epoch36>.<uuid>@<sender-domain>`,
taken from the From address (falling back to the login username). The
timestamp prefix keeps ids roughly sortable and adds entropy across
UUID reuse concerns; crypto.randomUUID() is available in every runtime
Bulwark supports (browsers and Node 19+). Per RFC 8621 §4.1.2.3 the
JMAP messageId property carries bare msg-ids (no angle brackets), so
none are added.

Clients that never set messageId also can't thread their own sent mail
reliably until the server echoes the message back; setting it at create
time makes the id known and stable from the start.

No behavior change for servers that honored client-provided ids all
along; servers that previously synthesized an id now simply don't need
to.
2026-07-14 16:20:31 +02:00
Stefan HildebrandtandLinus Rath 01e5cd69cf fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` store and was never written to the synced
settings, so the choice was lost on clearing site data / switching browsers and
never appeared in exported settings.

Persist it in the synced settings store, keyed **per account**
(`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing
per-account `allMailFolderIds`. Per-account keying is required because JMAP
identity ids are account-scoped and would otherwise collide across accounts /
the unified mailbox.

This supersedes the earlier username-keyed fix that had landed on main: the
username-keyed map, `loadIdentities()` fallback write, and the
`applyPreferredIdentityOrdering` store action (plus its settings-store hook)
are removed so a single account-keyed mechanism remains.

- settings-store: `preferredIdentityIds` (accountId -> identityId) in state,
  defaults, export, import (non-record guard), rehydrate coercion, v6 migration.
- auth-store: `applyPreferredIdentity(accountId?)` reorders the active account's
  identities once synced settings load, and performs the one-time migration of
  the pre-#507 browser-local default into the synced map (keyed by accountId).
  Invoked from every `loadFromServer().finally()` (login / OAuth / SSO / switch
  / restore). `loadIdentities()` now only applies the local fallback ordering.
- identity-manager-modal: the star action writes the choice by `activeAccountId`.
- identity-store: `preferredPrimaryId` kept as a local (sync-off) fallback.
- tests: per-account independence, export/import round-trip, import guard, and
  applyPreferredIdentity reorder / active-account gating / local-default
  migration.
2026-07-13 21:21:34 +02:00
Paulhenry SauxandLinus Rath 20d02214df fix: add new plugin api methods introduced by #586 to protocol plugin sandbox 2026-07-13 21:21:07 +02:00
Paulhenry SauxandLinus Rath 432ba0516b fix: add bodyValues to onRenderEmailBody hook 2026-07-13 21:21:07 +02:00
Stefan HildebrandtandLinus Rath 37152504b4 feat(composer): drag-to-reorder To/Cc/Bcc recipient chips (#593)
Recipient chips could already be dragged between the To/Cc/Bcc fields, but a
drop always appended and same-field drops were a no-op, so recipients could not
be rearranged without deleting and re-adding them.

Add positional drag-and-drop: while dragging a chip, an insertion caret shows
the gap it would land in (based on which half of the hovered chip the pointer
is over, mirrored for RTL); dropping inserts it there.

- same-field drop reorders the chip locally (via onChipsChange), using the
  source index carried in the drag payload (fromIndex) and adjusting for the
  removal shift; dropping onto its own position is a no-op;
- cross-field drop inserts at the drop position: handleMoveChip gained an
  optional toIndex (omitted = append, e.g. dropping onto a hidden Cc/Bcc
  button, preserving existing behaviour);
- per-chip onDragOver computes the target gap; the container handles the
  trailing gap (past the last chip / over the input).

No new user-facing strings (the caret is purely visual), so no locale changes.

Tests (components/email/__tests__/recipient-chip-drag.test.tsx): reorder to end
/ front, self-drop no-op, cross-field positional insert, and caret visibility.
Also add the missing findComposeIdentityId export to the reply-identity mock in
the recipient drag/paste suites so <EmailComposer> mounts in compose mode.
2026-07-13 21:19:37 +02:00
honzupandLinus Rath 9072bf8470 fix: keep sidebar tag counts in step with read/unread changes
Marking mail as read left the sidebar's tag unread counts untouched — the
folder counts cleared, but a tag went on showing "47 unread" in bold until
the page was reloaded.

tagCounts is fetched from the server (Email/query per $label keyword) rather
than derived from state, and no read/unread mutation refreshed or adjusted
it. The per-mailbox unreadEmails counters were kept current by a local delta;
tags simply had no equivalent.

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and
apply it wherever the affected emails are known locally: markAsRead,
batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a
count, so re-marking a read email as read cannot drift it, and unread is
clamped at zero. A tag's total is never touched by a read-state change.

markMailboxAsRead is the exception and refetches instead: it is a server-side
bulk operation over an entire mailbox, so it also marks emails that were never
loaded into state.emails, and a local delta would leave the counts high.
2026-07-13 21:19:08 +02:00
Paulhenry SauxandLinus Rath b1f6758f98 fix: add ui:download-file permission to consent screen. 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a679d82cc3 feat: add download file method for files generated by plugin 2026-07-13 21:18:36 +02:00
Paulhenry SauxandLinus Rath a08a9e9ed3 feat(plugins) : add new api api method : webauthn.getOrCreate 2026-07-13 21:17:45 +02:00
Paulhenry SauxandLinus Rath 622adc34de feat: add onEmailsFetched and onSearchResults hook + new JMAP method getSomeEmails 2026-07-13 18:18:57 +02:00
Stefan Hildebrandt 4a3394cf8c test(vitest): exclude integration/ and examples/ Playwright specs
vitest was collecting the dockerized integration Playwright specs (run via
`npm run test:integration`) and the untracked examples/ sample code, which
fail under the vitest runner. Exclude both so `npm test` only runs the unit
suite.
2026-07-11 21:16:19 +02:00
Stefan Hildebrandt a8598db44d i18n(unified-mailbox): sync he/sk locales with unified mailbox keys
The unified-mailbox rework added settings.appearance.unified_mailbox.
cross_account.{label,description} and sidebar.unified_mailbox, and dropped
the legacy settings.appearance.all_mail.{label,description}, in en and every
other locale except Hebrew (he) and Slovak (sk). Bring he/sk in line so the
translations-completeness test passes (no missing/extra keys vs en).
2026-07-11 21:16:11 +02:00
Stefan Hildebrandt d3addf54b4 fix(rebase): reconcile viewer blob routing and dedupe settings after main rebase
Rebasing feat/unified-mailbox onto main hit deep, divergent conflicts in the
mail-view/settings area (main added its own All-Mail + RTL refactor + a
username-keyed #507 identity impl). Post-rebase reconciliation:

- re-apply the cross-account blob routing to the message viewer (inline images,
  drag-out, TNEF, embedded messages, thumbnails, bundle download) on main's
  restructured file — every fetch goes through blobClient/blobAccountId derived
  from the message's source account;
- drop the duplicate `preferredIdentityIds` declaration that both main
  (username-keyed) and the branch (accountId-keyed) introduced — the branch's
  account-scoped map is kept, matching the resolved modal/store logic.

tsc + eslint clean; unified-mailbox unit tests pass (settings-store all-mail /
preferred-identity, unified-mailbox-cross, jmap-client-resilience, migrate-policy).
2026-07-11 21:16:02 +02:00
Stefan Hildebrandt e1e83c4a83 test(integration): make reconcile-dependent counter assertions robust
The single `forceSync` + assert pattern flaked under full-suite load: one
reconcile can miss (or a shared/cross-account counter refresh lands late) with
no retry, so the assertion polls stale DOM until timeout. The flake moved
between reconcile-dependent tests (server-side move, spam source drain, unified
/ shared counters) run to run.

- Add expectFolderCountsSynced(): nudges a reconcile (visibilitychange ->
  checkForStateChanges) before *every* poll, so a missed reconcile is retried
  for the full window. Compares only the provided unread/total fields.
- Use it for the reconcile-dependent counter checks in 02 (move/delete),
  03 (multi-account isolation + unified aggregation), 04 (All Mail), 05 (spam
  source), 06 (shared folders); drop the now-redundant standalone forceSync.
  Pure live-push assertions (incoming/burst, background-login-live) keep the
  plain helpers so they still prove push works.
- The spam->not-spam round-trip could stall the Junk badge reconcile even with
  retries under load; assert the optimistic list removal + authoritative server
  round-trip (out of Junk, back in Inbox) instead of the badge.

Validated with two back-to-back full-suite runs: 35 passed each.
2026-07-11 21:15:57 +02:00
Stefan Hildebrandt cdb31634a6 fix(attachments): route all viewer blob fetches for cross-account messages
Extend the cross-account blob routing beyond download/preview to every blob
fetch in the message viewer, so a message opened from a different account in the
unified / All-Mail view renders and exports correctly instead of 404ing against
the active account:

- inline cid: images, drag-to-desktop, attachment thumbnails, the "download all"
  zip bundle, and the S/MIME / TNEF / embedded-rfc822 blob reads now use a
  resolved blobClient (getClientForAccount(sourceClientAccountId)) and the owner
  blobAccountId (sourceAccountId), computed once from the open message's source;
- fetchBlobAsObjectUrl / fetchBlobArrayBuffer / fetchBlob calls pass the
  accountId (the client methods gained the param in the previous commit);
- non-cross-account behaviour is unchanged (blobClient === active client).

Extends 10-attachments with an inline-image case (verified to fall back to the
placeholder without the routing). The SMTP helper can now send multipart/related
inline images.
2026-07-11 21:15:49 +02:00
Stefan Hildebrandt 26c3d07d56 fix(attachments): download/view attachments on cross-account All-Mail messages
Blobs are scoped per JMAP account, but the attachment download/preview path
always used the active account's client and accountId. Opening a message from a
different account in the unified / All-Mail view and downloading (or previewing)
an attachment therefore 404'd against the active account.

Route the blob fetch to the message's source instead:
- resolveBlobSource() picks the owning login's client
  (getClientForAccount(sourceClientAccountId)) and the owner accountId
  (sourceAccountId) for delegated/shared blobs, in the unified view;
- handleDownloadAttachment + the attachment-preview handlers use it;
- downloadBlob / fetchBlobAsObjectUrl / fetchBlobArrayBuffer gain an accountId
  param (getBlobDownloadUrl/fetchBlob already had one).

Adds 10-attachments: an attachment on another account's All-Mail message
downloads with the correct bytes (verified to fail without the routing).
2026-07-11 21:15:43 +02:00
Stefan Hildebrandt c3acb537d0 test(integration): live unified/All-Mail counter coverage
Add 09-live-counters: a background-login account updates the unified counter
live (no reconcile), and a shared-folder change reconciles the All-Mail counter
on focus. Document the shared-account counter behaviour in the README.
2026-07-11 21:15:35 +02:00
Stefan Hildebrandt 060c5d00d1 test(integration): draft handling and shared-folder moves
Add draft and shared-folder-move coverage (suite now 31 tests). Findings are
asserted server-side or pinned with test.fail where the UI is incomplete.

Drafts (07):
- multiple recipients (committed and typed-but-uncommitted) persist, and the
  draft reopens via the continue-draft button;
- a server-created draft (with $draft) shows the continue-draft button;
- a changed sender identity is saved to the draft on the server;
- KNOWN BUG (test.fail): reopening a draft resets the From selector to the
  default identity instead of the one the draft was saved with.

Shared-folder moves (08):
- shared -> shared (same owner) moves work in both directions (server-verified);
- KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared
  folder) don't relocate the message — the Move-to submenu offers the target
  but clicking it is a no-op.

Hooks added: composer From select + save-status, viewer edit-draft button,
context-menu "Move to" submenu + per-target testids (testId on
ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer
drive + move-via-submenu. README documents the findings.
2026-07-11 21:15:27 +02:00
Stefan Hildebrandt e05fbb2fe9 test(integration): All Mail, message actions, and shared-folder sync
Extend the integration suite (now 22 tests) to cover:

- All Mail view (04): single-account merge of Inbox + custom folders with
  Junk excluded, and cross-account aggregation across every logged-in account.
- Message actions from the list context menu (05): mark read/unread, delete
  (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both
  the UI counters/row state and the server mailbox the message ends up in.
- Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared
  alice→carol; the shared folder renders with its counter, and read/unread/
  delete/spam performed there land correctly (server-verified).

Hooks added: data-testid on context-menu delete/spam/read-unread items
(via a testId prop on ContextMenuItem), data-shared on folder rows, and
testId/data-expanded on sidebar section headers to drive the Shared section.

Observations surfaced by the suite (asserted server-side / with a reconcile):
- mark-as-spam doesn't optimistically decrement the *source* counter the way
  delete does; a visibility reconcile settles it.
- shared *destination* counters (shared Trash/Junk) don't refresh live —
  forceSync reconciles the active account only, not shared accounts.
2026-07-11 21:15:20 +02:00
Stefan Hildebrandt b8809c2e69 test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.

- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
  submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
  browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
  inspecting server state, and page helpers (login, add/switch account,
  locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
  burst) and multi-account (per-account isolation + cross-account Unified
  Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
  list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
2026-07-11 21:15:13 +02:00
Stefan Hildebrandt bc11450f3f feat(jmap): keep unified/All-Mail counters current for shared accounts
Stalwart's JMAP EventSource only pushes StateChange for the session's *primary*
account — a background change in a shared/delegated (secondary) account is never
pushed — so the shared folder's counters, and the unified/All-Mail badge that
aggregates them, went stale until a full reload. (Other *login* accounts already
update live because each login has its own SSE.)

Extend the client's state poll to every account in the session:
- buildStatePollingRequest emits a Mailbox/Email `get` per account, with the
  accountId encoded in the callId (`mbx:<id>` / `eml:<id>`);
- checkForStateChanges / fetchCurrentStates key polling state per account and
  report a per-account `changed` map, which handleStateChange already treats as
  "some mailbox changed" and refetches the full (own + delegated) mailbox list
  from — the badge is a live projection over that list;
- a slow (20s) secondary-account poll runs alongside SSE (paused while hidden)
  so shared counters stay current between focus events.
2026-07-11 21:15:05 +02:00
Stefan Hildebrandt 034f7a4b9b fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the
browser-local `identity-storage` Zustand store and was never written to
the server-side synced settings. As a result the choice was lost when
clearing site data or switching browsers, and never appeared in the
exported settings JSON.

Persist the default identity in the synced settings store, keyed per
account (`preferredIdentityIds: Record<accountId, identityId>`), mirroring
the existing per-account `allMailFolderIds`. Per-account keying is required
because JMAP identity ids are account-scoped and would otherwise collide
across accounts / the unified mailbox.

- settings-store: add `preferredIdentityIds` to state, defaults, export
  (so it shows in exported JSON), import (with a non-record guard),
  rehydrate coercion, and a v6->v7 migration.
- auth-store: add `applyPreferredIdentity()`, invoked in every
  `loadFromServer().finally()` (login / OAuth / SSO / switch / restore) so
  the synced default reorders the active account's identities once server
  settings load (the composer defaults From to identities[0]).
- identity-manager-modal: the star action also writes the choice to the
  synced per-account map, triggering server sync + export inclusion.
- identity-store: keep `preferredPrimaryId` in local persist as a sync-off
  fallback; synced settings are the durable cross-device source of truth.
- tests: per-account independence, export/import round-trip, non-record
  import guard, and v6->v7 migration.
2026-07-11 21:14:56 +02:00
Stefan Hildebrandt 2e42693228 fix(unified-mailbox): single-source unified counters, unified id space, background push
The unified-section sidebar badges (per-role unified folders + cross-view
All mail/Unread/Starred) failed to count down when messages were deleted/moved/
read from the unified views, and failed to count up for incoming mail - while
the underlying per-account folder counters updated correctly. Root cause: the
badges were a separate counter representation, recomputed only by a fresh server
fetch, completely decoupled from the optimistically-patched mailbox lists.

Three coordinated changes:

V1 - single source of truth: derive `unifiedCounts`/`crossUnreadCount` as a pure
live projection of `mailboxes` + `accountMailboxes` (the lists every mutation
already patches and push refreshes), over the last-known unified scope. A store
subscription re-projects whenever those lists change, so optimistic deletes and
push refreshes flow into the badges with no server round trip and no
eventual-consistency snap-back.

V3 - unified id space: searchEmails/advancedSearchEmails now namespace shared/
delegated mailboxIds (`${ownerId}:${id}`) like getEmails already did. The
cross-account views browse via advancedSearchEmails, so shared emails there
previously carried bare owner ids; now every fetch path is consistent and
emailInMailbox hits the `ids[mailbox.id]` fast path (originalId branches kept as
a defensive fallback). resolveSourceFolderName matches `m.id` first (also fixes
a latent missing source-folder name for shared emails).

Background push: bind push notifications for every connected login, not just the
active one - background accounts now drive the unified counters by rebuilding the
unified scope on their state changes. handleStateChange also refreshes the
mailbox list on a Mailbox change for ANY changed account key, so delegated
shared-folder activity arriving via the active client updates counters too.

Tests: unified-badge live projection on delete; client-level namespacing for
searchEmails/advancedSearchEmails (shared vs own account).
2026-07-11 21:14:50 +02:00
Stefan Hildebrandt fdad60cf03 fix(unified-mailbox): update shared/group folder counters on delete/move/read
In the unified All mail / Unread / Starred views, deleting (or moving /
marking read) a message from a shared/group folder left that folder's
sidebar counter at its old value.

Root cause: lib/unified-mailbox.ts decorates shared emails WITHOUT
namespacing their `mailboxIds`, so they carry the owner's bare JMAP ids,
while the shared mailbox is stored with a namespaced id (`${ownerId}:${origId}`)
and `isShared: true`. `emailInMailbox` only matched the namespaced `mailbox.id`
and disabled the `originalId` fallback for shared mailboxes, so no shared
email ever matched its folder and the counter math skipped it.

Match shared mailboxes via `originalId` too, scoped to the owning account
(`sourceAccountId === mailbox.accountId`) so a bare owner id can't collide
with another account's folder. This is the single matching helper used by all
counter paths (delete/move/markRead/spam), so they're all fixed at once.

Adds a regression test covering deletion of a shared-folder email in the
unified view.
2026-07-11 21:14:44 +02:00
Stefan Hildebrandt dc72122ed8 feat(unified-mailbox): enable search in the unified views
Enable text AND advanced search in all Unified Mailbox views (the per-role
mailboxes and the folder-selected All mail / Unread / Starred cross views). The
search input was hard-disabled for every unified view; the store fan-out already
supported text search.

- page.tsx: the search text input and the advanced-filter toggle are enabled for
  all unified views (only the scheduled view stays disabled). Clear-search also
  restores a cross view (not just per-role).
- Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails
  ANDs the advanced filter (text + field conditions from buildJMAPFilter, built
  without an inMailbox clause) onto the cross-view membership. Per-role unified
  views keep using advancedSearchUnifiedEmails. Both honor the filter on the first
  page, on load-more, and on the folder-switch re-run. Fixes: an active Starred
  filter not applying after switching into a cross view, and the Unread filter in
  the Unread view returning nothing.
- Search persistence on folder switch: an active search is kept and re-run in the
  target view, preserving advanced filters. handleMailboxSelect picks
  advancedSearch when filters are set (normal, per-role unified, and cross views,
  after setting the unified state), text searchEmails when only a query is set,
  and browses otherwise. The scheduled view is the only view that resets the
  search on enter (unavailable there; setScheduledView clears searchQuery +
  searchFilters).

Account scope is intentionally left unrestricted in search (it already fanned out
across all accounts); the per-view folder selection still applies via
crossIncludedMailboxIds.
2026-07-11 21:14:39 +02:00
Stefan Hildebrandt 7c221c4a4a feat(unified-mailbox): account-bounded Unified Mailbox with opt-in cross-account
Rework the sidebar "All accounts" section into a "Unified Mailbox" that, by
default, stays within the active login account and its shared/group folders.
Merging across multiple logged-in accounts becomes an opt-in sub-option instead
of the default, and the standalone per-account "All Mail" virtual folder is
folded into the unified All mail / Unread / Starred entries (its folder selection
now narrows those lists).

Scope:
- lib/unified-mailbox.ts: UnifiedAccountClient.crossIncludedMailboxIds; the cross
  views honor the per-account folder selection (union across accounts = the sum
  of each account's selection), falling back to inbox+custom when unset.
- stores/email-store.ts: buildUnifiedAccountClients gains scopeToClientAccountId
  (the account boundary) and populates crossIncludedMailboxIds from
  allMailFolderIds; remove the standalone __all_mail__ fetch/search/load-more
  branches.
- page.tsx: scope to the active account unless cross-account is active (per-user
  opt-in AND admin gate); the per-role unified mailboxes obey the same scope.

Folding:
- Drop ALL_MAIL_MAILBOX_ID (lib/jmap/types.ts); thread-list source-folder column
  now keys on isUnifiedView only; settings folder picker moves under the unified
  group and shows once any unified entry is enabled.

Config:
- User: new unifiedCrossAccount (default false); includeGroupInUnified default
  flips to true; enableAllMailView retired; the three cross-view toggles now gate
  the unified Unread/Starred/All mail entries.
- Admin: new unifiedCrossAccountEnabled gate, default FALSE (cross-account is an
  admin opt-in; when off the per-user toggle is hidden and the scope is forced
  account-bounded at runtime). allMailViewEnabled deprecated and normalized
  forward into crossAllViewEnabled on policy load; cross-view gate labels reworded
  to "Unified Mailbox: ...".

Header: the sidebar section shows "All accounts" when cross-account is active
(opt-in AND admin gate AND >1 connected account), else "Unified Mailbox".

Migration:
- Settings persist v5 -> v6 (exported migrateSettings) - cross-active users keep
  cross-account; All-Mail-only users get the account-bounded unified All mail
  entry with folder ids preserved; includeGroupInUnified enabled for every
  migrated config; fresh installs are account-bounded.
- Admin policy: one-shot, marker-guarded migratePolicyUnifiedMailbox (run before
  configManager.load) enables unifiedCrossAccountEnabled when a cross view was
  active, so existing cross-account installs keep the behaviour despite the
  default-false gate. Skipped on read-only config dirs.

Locales: sidebar all_accounts (original label) + unified_mailbox (translated, per
locale) keys; dead standalone all_mail strings removed across all 20 locales.

Docs: FEATURES.md updated to the account-bounded model, the cross-account gate,
and the folder-narrowed aggregate entries.

Verification: tsc clean, eslint clean, full vitest suite green (incl. translations
completeness, cross-view/migration coverage, and the admin policy migration test).
2026-07-11 21:14:34 +02:00
Stefan HildebrandtandLinus Rath c1acf58c5f test(compose): add findComposeIdentityId to reply-identity mock
The recipient chip-drag and paste tests render <EmailComposer>, which since
b716f95a (feat(compose): preselect identity of the active mailbox) calls
findComposeIdentityId() from @/lib/reply-identity in compose mode. Both tests
mock that module but only returned resolveReplyFrom, so vitest threw
"No 'findComposeIdentityId' export is defined on the mock" on mount,
failing all 18 tests. Add the missing export (returns null; the composer
guards with if (composeIdentityId)).
2026-07-11 19:36:27 +02:00
honzupandLinus Rath 42798c2b7c fix: open signature links in a new tab instead of navigating the app away
Signatures render into the main document - the identity form's live preview
and the composer's signature block - rather than the sandboxed iframe used for
message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those
anchors were live and target-less: one click navigated the whole app away,
discarding the unsent draft or the unsaved signature with it.

Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image
restrictions but forces target="_blank" rel="noopener noreferrer" on every
anchor, and use it at the two render sites. The composer's SignatureBlock
NodeView stamps the target on its rendered DOM instead, because attrs.html is
what serializeEditorContent emits into the sent message - storage and the
recipient's copy stay exactly as the user wrote them.
2026-07-11 10:45:01 +02:00
honzupandLinus Rath 75d17d4e37 fix: keep target/rel on links in plain-text message bodies
Plain-text bodies render into the main document rather than the sandboxed
iframe, so an anchor without target="_blank" navigates the whole app away
instead of opening a new tab.

plainTextToSafeHtml emits target and rel correctly, but
sanitizePlainTextRenderedHtml stripped both back off: DOMPurify URI-tests
every attribute value not on its URI-safe list, and "_blank" does not match
PLAIN_TEXT_RENDERED_CONFIG's ALLOWED_URI_REGEXP. EMAIL_SANITIZE_CONFIG avoids
this only because its regex carries a catch-all alternation for non-URI values.

Mark target and rel as URI-safe so they survive the URI test, rather than
loosening href validation.
2026-07-11 10:45:01 +02:00
dealerwebandLinus Rath 38a396d150 Fix: end refresh loops on sign-out and back off failed retries
Fixes #588.

Sign-out already cleared the token-refresh timers and stopped the
keep-alive interval - the reported endless loops came from async
callbacks that were in flight at that moment. The token refresh's
failure handler re-armed its retry after logout, and a failing
keep-alive ping called reconnect() -> connect(), which restarts the
keep-alive and thereby revived the interval disconnect() had just
stopped. Only closing the tab ended it.

Two mechanisms fix that class: transiently failed token refreshes only
re-arm while the account is still signed in (checked when the failure
lands, not when the request started), and the client carries an
intentionallyDisconnected flag set by disconnect() - the ping callback,
reconnect(), the SSE reconnect scheduling and the polling fallback all
stop at it, so nothing revives after an intentional sign-out.

Failed retries also back off instead of hammering a down server every
30 seconds: the token refresh climbs 30s/1m/2m/5m (capped, reset on
success), and the keep-alive skips upcoming ticks on consecutive
failures for the same effective ladder. Recovery after an outage is
unchanged in substance - the session survives and reconnects within at
most ~5 minutes, immediately on user activity.
2026-07-10 14:13:04 +02:00
dealerwebandLinus Rath c6bd5f645a Feature: contact groups as single expandable recipient chips
Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.

The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.

Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.

RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
2026-07-09 17:46:06 +02:00
Linus Rath fa31933922 chore: update version to 1.7.7 2026-07-09 16:25:12 +02:00
Linus Rath d3f77ef9cf feat: add plugin ui.rerenderEmail API and restyle read-receipt banner 2026-07-09 16:04:33 +02:00
Linus Rath 5ccba83129 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	lib/plugin-sandbox/host-api.ts
2026-07-09 14:57:36 +02:00
752e71198c feat: add 3 new plugin hooks : onBeforeBlobUpload, onBeforeDraftAutoSave, onBeforeEditDraft (#586)
Co-authored-by: Linus Rath <minipixxelinfo@gmail.com>
2026-07-09 14:55:34 +02:00
Linus Rath 2cb74c0186 i18n: use example.com in email placeholder strings 2026-07-09 14:54:04 +02:00
Linus Rath 9ab7339320 feat: add plugin ui.prompt dialog and first-class settings-section tabs 2026-07-09 14:51:17 +02:00
Linus Rath 782974ecdb fix: load trusted senders address book on settings page so count isn't 0 2026-07-09 14:01:05 +02:00
Linus Rath c47137fb49 feat: enable trusted-senders address book sync by default when contacts are available 2026-07-09 13:59:52 +02:00
Linus Rath 38313639ed fix: use callable .get to detect Headers in pickRequestHost 2026-07-09 13:51:12 +02:00
Linus Rath e933800792 fix: store event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE 2026-07-09 13:45:04 +02:00
Linus Rath 60a1cc670c fix: apply per-domain favicon override in root metadata #585 2026-07-09 13:30:44 +02:00
dealerwebandLinus Rath c0515001f1 i18n: restore key parity after the Jalali calendar
The translation parity test fails on main again after the Jalali
calendar landed: the two newest locales, he and sk, were missed when
the twelve Jalali month names were added - they get the same Latin
transliterations every other non-Persian locale received.

fa in turn carried four keys that do not exist in en and are not
referenced anywhere in the code (the email_composer.text_direction
block and settings.templates.image_too_large) - removed, as the
suite's no-extra-keys check demands.
2026-07-08 22:43:49 +02:00
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00
dealerwebandLinus Rath 3d36492518 i18n: restore key parity after the Hebrew locale
The translation parity test fails on main since the Hebrew locale
landed. Three gaps, all from catalogs drifting past each other:

The new he locale was based on an older en catalog and was missing 17
keys (pin/unpin, recipient autocomplete, attachment-upload validation,
the date_locale block, the tint_list_rows and show_folder_total_count
settings, language.sk). They are translated to Hebrew here; date_locale
keeps the same English values every other locale currently has.

The rtl_editing setting existed only in en and he - added translated to
the other 20 locales.

language.he was missing everywhere - added as the endonym "עברית" to
all locales, matching how the other language names are written.
2026-07-08 15:42:02 +02:00
dealerwebandLinus Rath 94af4725b6 Fix: send mailto unsubscribe ourselves instead of via the OS handler
The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
2026-07-08 15:41:46 +02:00
Shuki VakninandLinus Rath 22418c17cf feat(i18n): Hebrew locale + full RTL support 2026-07-08 00:07:34 +02:00
Linus Rath 8904d724bb fix: hide Files when account lacks filenode capability #563 2026-07-07 23:59:19 +02:00
Linus Rath c7d551f185 fix: attachment reminder ignores quoted text on reply/forward #570 2026-07-07 23:54:50 +02:00
Linus Rath 6470fa86f0 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 23:44:34 +02:00
Linus Rath 8647d709ff fix: only commit recipient on Space when input is a valid email #571 2026-07-07 23:43:54 +02:00
Shuki VakninandLinus Rath b73d1b55d1 fix(email): render emails that put height:100% on a wrapper element
Some HTML emails set height:100% on a full-bleed wrapper table/div rather
than html/body. With the viewer's body{overflow:hidden}, this collapses
documentElement.scrollHeight to the iframe's 150px default, so the
scrollHeight-based auto-resize locks the iframe short and the body renders
blank below the fold. A Box.co.il verification email rendered as a
logo-only 150px strip.

- Neutralise height:100% on any element so the body grows to its content.
- Measure max(documentElement, body).scrollHeight, and re-measure on a
  fixed cadence over a short settle window so a late reflow (height:100%
  wrapper, or images that resize after onload) is caught even when no
  ResizeObserver/image event fires.
2026-07-07 23:40:18 +02:00
honzupandLinus Rath 7db6fd7e24 feat: add setting to disable tag-color row tint in message list
Message-list rows are tinted with the first tag's color, which becomes
overwhelming when a label applies to most messages (for example
per-account labels). Add a `tintListRowsByTag` setting (default true, so
current behavior is unchanged) with a toggle in Settings beside
"Colorful Sidebar Icons". When off, rows are not tinted; tag dots and
chips still show the color. Gated in both list renderers
(email-list-item and thread-list-item).
2026-07-07 23:39:05 +02:00
Linus Rath e066698938 fix: strike through cancelled events and mute their reminders #572 2026-07-07 23:26:36 +02:00
Linus Rath c3a97de62f fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500 2026-07-07 23:18:05 +02:00
Linus Rath 29283282d5 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 20:36:05 +02:00
Linus Rath db2c642d74 fix: storage quota not shown with Stalwart #577 2026-07-07 20:35:37 +02:00
dealerwebandLinus Rath 9110bc388f Fix: keep the session when the auth server is briefly unreachable
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".

Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.

Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
2026-07-06 15:52:05 +02:00
dealerwebandLinus Rath a4dc0b7b4e i18n: restore key parity for the Slovak locale
The Slovak translation was based on a slightly older en catalog, so the
translation parity test currently fails on main.

sk was missing ten keys that landed around the same time: the composer
attachment-upload validation strings, the recipient autocomplete
strings (translated to Slovak here) and the settings date_locale block
(kept as the same English values every other locale currently has).

The other twenty locales were missing language.sk in return - added as
the endonym "Slovenčina" (matching how the other entries are written),
and as "Slovacă" in ro, which translates its language names.
2026-07-06 13:36:51 +02:00
dealerwebandLinus Rath d384c3b553 Feature: pin emails to the top of the folder list
Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a
$pinned keyword on the message (plain IMAP-compatible flag, survives
other clients), and pinned mails stay at the top of the folder list
regardless of age, marked with a pin icon.

Ordering is done server-side via the hasKeyword sort comparator
(RFC 8621), applied consistently to the folder fetch, pagination and
the push-refresh so page windows stay stable. The client-side safety
sort in getEmails mirrors it, and sortThreadGroups keeps threads
containing a pinned mail on top so the client-side thread grouping
does not undo the order.

The new-mail notification in refreshCurrentMailbox now checks the
first non-pinned entry: with pinned mails on top, the newest mail is
no longer at index 0 and arrivals would never have notified.

The toggle reuses the color-tag pathway (routed keyword write for
unified views, in-place local patch), then refetches the first page
so the mail floats or sinks immediately. Search and unified views
keep their existing order.

Pin/Unpin strings are added to all 21 locales.
2026-07-06 13:36:25 +02:00
dealerwebandLinus Rath d7a64fd9d6 Fix: hide the spam action in Sent, Drafts and Scheduled
Marking your own outgoing mail as spam makes no sense, but the action
was offered in every non-junk folder: context menu, hover quick-actions,
viewer toolbar and its overflow menu, plus the "!" shortcut.

All surfaces now skip the action when the folder role is sent, drafts or
scheduled, and the shortcut is a no-op there. Scheduled messages were
already covered per-email via isScheduled; the role check additionally
covers the server-side Scheduled folder before that annotation loads.

The hover quick-actions bar gets a spamApplicable prop for this, since
it renders its buttons without knowing the folder.
2026-07-06 11:04:37 +02:00
dealerwebandLinus Rath 06ddda688d Fix: spam actions left folder counters and the open message stale
Marking mail as spam (or not spam) updated the email list but nothing
else, unlike delete/move which patch the folder counters optimistically.
Without a connected JMAP push the sidebar badges simply never moved, and
"not spam" additionally left the reading pane stuck on the message that
had just left the folder.

markAsSpam now mirrors moveToMailbox: source folder counts down, junk
counts up (honoring that trash-and-read delivers the mail to junk
already read). batchMarkAsSpam and batchUndoSpam mirror the batch move
pattern the same way.

undoSpam advances the selection to the next message like markAsSpam
already did, and refreshes the mailbox list instead of patching counts:
in the undo-toast path the email is no longer in the list, so its unread
state is unknown and an optimistic patch is not possible.
2026-07-06 11:04:20 +02:00
Shuki VakninandLinus Rath 6b74615969 fix(email-viewer): hide images that fail to load
Instead of leaving the browser's broken-image placeholder + alt text (which
reads as stray label text — e.g. a 'logo' alt — in an otherwise image-only
email), hide any image that fails to load. Sanitizer-blocked images already use
a 1x1 transparent pixel with display:none, so they're unaffected.
2026-07-05 21:29:19 +02:00
Shuki VakninandLinus Rath 0d73cb5dfb fix(csp): allow external/data fonts so email webfonts render
The page CSP set font-src 'self', which blocked fonts referenced by
rendered email CSS (brand webfonts loaded over https). Allow https:/data:
for font-src. Email bodies render inside the sandboxed iframe, which keeps
its own stricter blocking-mode font-src for privacy.
2026-07-05 21:28:15 +02:00
b1b09d54c8 feat(pwa): add Apple Touch icons for iOS home screen
- Add apple-touch-icon.png (180x180) - default iOS icon
- Add apple-touch-icon-120x120.png - iPhone retina
- Add apple-touch-icon-152x152.png - iPad retina
- Add apple-touch-icon-167x167.png - iPad Pro
- Add apple-touch-icon-180x180.png - iPhone Plus/Pro

iOS automatically detects these icons when users add the PWA to their home screen.
No code changes required - icons are discovered by convention.

Fixes: PWA icon not appearing on iOS home screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:51:06 +02:00
Linus Rath 9930d19ba4 fix: keep advanced search filters applied when switching folders #553 2026-07-04 15:21:11 +02:00
Shuki VakninandLinus Rath 902774eae1 feat(list): click sender avatar to select message/thread (Thunderbird-style)
Wrap the message-list avatar in a SelectableAvatar control: clicking the
avatar toggles the row into the current selection instead of opening it,
matching Thunderbird's correspondent-avatar selection affordance. A check
overlay appears on hover (hinting it is clickable) and stays while selected.

- email-list-item + thread single-email: toggle that message's id
- thread header: toggle the whole thread (reuses existing thread-select logic)
- focused-mail and extra-compact layouts render no avatar, so unaffected
2026-07-04 15:00:15 +02:00
1429a6fe1e feat(login): configurable logo size + hideable heading/subtitle
Login header customization for white-label deployments, all defaults
preserve current behaviour:

- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
  box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
  ~13px tall. When either is set, the fixed box is dropped and the logo
  renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
  {appName} heading and/or the subtitle when the logo already reads as the
  brand (e.g. a wordmark) and they'd be redundant.

Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:59:49 +02:00
Kristofer PettijohnandLinus Rath d1da83a5d6 fix: keep email list scrollable when bottom reading-pane is enabled with no conversation selected 2026-07-04 14:59:32 +02:00
Kristofer PettijohnandLinus Rath babacca482 fix(contacts): clear photo on server by sending media: null when removed 2026-07-04 14:59:06 +02:00
dealerwebandLinus Rath e6aa79ed94 Feature: recipient autocomplete from Sent, with on-demand server search
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.

On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.

When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.

New strings are added to all 20 locales.
2026-07-04 14:56:49 +02:00
dealerwebandLinus Rath 4d6b4b5b8e Fix: brand push notifications with the configured PWA icon
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).

Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
2026-07-04 14:56:19 +02:00
DaniilandLinus Rath d259168bd7 fix(shortcuts): also map /, ?, #, ! by physical position
Extend the layout-agnostic handling to the symbol shortcuts. On non-Latin
layouts the characters '/', '?', '#', '!' are often unreachable or on different
keys, so map them from their US-QWERTY physical codes (Slash, shifted Digit1 /
Digit3). Fixes e.g. '?' (open shortcuts help) on a Cyrillic layout.
2026-07-04 14:54:58 +02:00
DaniilandLinus Rath 97d87c44d0 fix(shortcuts): make keyboard shortcuts layout-agnostic
Shortcuts were matched against event.key, which returns a layout-dependent
character. On non-Latin layouts (Cyrillic, Greek, ...) the physical letter keys
produce non-Latin characters, so single-letter shortcuts (c, j, k, r, e, ...)
never fire and users must switch layout to use them.

Derive the letter from event.code (KeyA..KeyZ) instead, which is
layout-independent. Non-letter keys keep event.key (arrows/Enter are already
layout-independent; #, !, ?, / stay symbol-based).
2026-07-04 14:54:58 +02:00
KazNIISA ITandLinus Rath 396f5f7d60 fix(compose): wait for in-flight attachment uploads before sending
Clicking Send while attachments were still uploading silently dropped
them from the outgoing message: every place that builds the outgoing
attachment list filters on att.blobId && !att.uploading, and the Send
button never accounted for uploads still in flight. Attach a few files,
hit Send right away, and the email could go out missing some of them
with no warning.

handleSend now detects pending uploads before validating:

- Send is disabled with an explanatory tooltip
  (validation.attachments_uploading) while it waits.
- Once uploads finish cleanly, the send proceeds automatically - no
  second click needed.
- If an upload FAILS while waiting, the send is aborted with an error
  toast (validation.attachment_upload_failed) instead of silently
  shipping the email without the failed attachment - the user may not
  be looking at the composer to notice the red error chip.
- If the draft is closed or discarded while waiting, the pending send
  is cancelled cleanly.

The wait/decision logic lives in waitForPendingUploads() in
lib/email-composer-utils.ts (returns completed | cancelled | failed)
with unit tests covering all three outcomes. Outgoing-attachment
call sites read the freshest state via attachmentsRef since the
render closure captured at click time won't reflect uploads that
finished during the wait.

Both new i18n keys added to all 20 locales under
email_composer.validation.
2026-07-04 14:54:41 +02:00
dealerwebandLinus Rath 9aef8bc393 i18n: localize the 'Return to list after delete or mark unread' setting
This reading setting shipped with its English label/description as a placeholder in every locale except fa. Translate it into the remaining 18 locales (de, cs, da, es, fr, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, tr, uk, zh), reusing each locale's existing 'mark as unread' wording for consistency.
2026-07-04 14:54:26 +02:00
Peter GondaandLinus Rath e05ab48a45 feat: add Slovak translation 2026-07-04 14:54:07 +02:00
honzupandLinus Rath 14c2807f0a feat: add user-selectable regional date format 2026-07-04 14:53:37 +02:00
Patrick RotterandLinus Rath a099ab442a fix: route keyword writes to the email's own account in unified view
Tags applied to a shared/group-mailbox message did not persist. Custom
keywords (:*),  and  were written via Email/set
against the reaching client's primary account instead of the email's
owning account, so the server returned notUpdated without an error and
the change was lost on the next reload.

toggleStar already threaded an accountId through (#281); the keyword
methods did not. Add an optional accountId to updateEmailKeywords and
setKeyword and resolve it at the call sites from the email's source
account (sourceClientAccountId / sourceAccountId), matching the existing
delete/archive routing. Personal sources resolve to the account itself,
so behavior there is unchanged.
2026-07-04 14:53:15 +02:00
dealerwebandLinus Rath e9ac2de6cb Fix: notification sound preview - base-path prefix + longer default beep
The sound picker's preview always played the default beep, even for the other choices, on a subpath deployment.

playFile() used a raw '/notification/x.mp3' path, which 404s under a deployment base path (e.g. /webmail); audio.play() then rejected and fell back to the beep for every non-default choice. Prefix the file with withBasePath().

The default beep was a 150 ms tone with no envelope - easy to miss on Bluetooth outputs, whose audio path can take 100-200 ms to wake up and route. Lengthen it to ~0.45 s with a fade in/out (also removes click artifacts).
2026-07-04 14:52:26 +02:00
Shuki VakninandLinus Rath c4a7f575c5 feat(accounts): pin default account on top + drag-to-reorder switcher
The account switcher now always renders the default (starred) account first
and lets you drag the remaining accounts into any order. Default stays
pinned; only non-default rows are draggable (shown via a grip handle on
hover). Wraps each row in a draggable container and persists the new order
through the existing reorderAccounts store action.

Ordering logic extracted to pure helpers (sortDefaultFirst,
reorderNonDefaultIds) in account-utils with unit tests.
2026-07-04 14:51:46 +02:00
dealerwebandLinus Rath 51c3a69be7 Fix: don't toggle mailbox subfolders on Arrow keys while typing
The sidebar registers a global window keydown listener that expands/collapses the selected mailbox's subfolders on ArrowLeft/ArrowRight. It didn't check where focus was, so pressing Left/Right while typing in a new email (the contentEditable composer, the subject field, search, etc.) toggled the inbox's subfolders open/closed.

Bail out of the handler when the event target is an editable element (INPUT/TEXTAREA/SELECT/contentEditable). Folder-tree arrow navigation still works when focus isn't in an input.
2026-07-04 14:50:32 +02:00
Harry YoudandLinus Rath 717b1d8397 feat(headers): add parsing for Stalwart spam headers
Example of headers created by Stalwart below

X-Spam-Result: TRUSTED_DOMAIN (-7.00),
	PROB_HAM_LOW (-2.00),
	RBL_SENDERSCORE_REPUT_9 (-1.00),
	DMARC_POLICY_ALLOW (-0.50),
	RCVD_DKIM_ARC_DNSWL_MED (-0.50)
X-Spam-Score: ham, score=-5.60, avg_confidence=0.26
X-Spam-Status: No

Only need to add small tweak by detecting spam|ham as well as Yes|No
The most useful header is X-Spam-Score, so we make sure to parse that
before X-Spam-Status
2026-07-04 14:50:07 +02:00
Shuki VakninandLinus Rath f291480565 feat(email): send quick reply with Ctrl/Cmd+Enter
The message-viewer quick-reply box only sent via the Send button. Add a
keyboard shortcut (Ctrl+Enter / Cmd+Enter) mirroring the composer (#344),
so a reply can be fired without reaching for the mouse. preventDefault stops
the newline; the shortcut and button share one handleSendQuickReply().
2026-07-04 14:49:45 +02:00
Chris RowlandandLinus Rath 95b5a81924 fix(plugins): preserve settings slot and privileged tier 2026-07-04 14:49:26 +02:00
Linus RathandGitHub 9da27df249 Update README.md 2026-06-30 18:41:58 +02:00
Patrick RotterandLinus Rath b716f95a73 feat(compose): preselect identity of the active mailbox for new messages
Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.

Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
2026-06-30 16:31:34 +02:00
Shuki VakninandLinus Rath 6c49427c7c fix(list): shift-click on the checkbox extends the selection (range)
selectRangeEmails was only wired to shift-clicking the row, but the
checkbox handler called stopPropagation and a plain toggle — so shift-
clicking checkboxes (the obvious affordance in selection mode) selected
single messages instead of the range. Make all three checkbox handlers
(email-list-item, thread single-email, thread header) shift-aware:
shift -> selectRangeEmails, otherwise toggle. Adds a regression test.
2026-06-30 09:35:20 +02:00
Kristofer PettijohnandLinus Rath 2a41e73bf9 fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X 2026-06-30 06:51:24 +02:00
Kristofer PettijohnandLinus Rath 16daf6ea03 fix(pro): show Edit button on draft emails opened in a new tab 2026-06-30 06:51:12 +02:00
Maarten DraijerandClaude Opus 4.8 65cb6be8a9 feat(login): add LOGIN_SHOW_TOTP and LOGIN_SHOW_VERSION config flags
Two opt-out branding/login flags, both default true (no behaviour change
for existing deployments):

- LOGIN_SHOW_TOTP=false hides the manual "I have a 2FA code" toggle on the
  login form. Deployments that delegate auth to an external directory
  (LDAP/OIDC) where 2FA lives in the IdP have no server-side TOTP, so the
  toggle only ever leads to a failed login. Server-required TOTP
  (totp_required, which auto-shows the field) is unaffected.
- LOGIN_SHOW_VERSION=false hides the build version in the login footer, so
  the exact version isn't disclosed to unauthenticated visitors.

Wired through the existing config registry (CONFIG_ENV_MAP) → /api/config →
useConfig, matching the surrounding LOGIN_* options.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:04:10 +00:00
Linus Rath 2704d5dc23 fix: clone source when setup.sh runs detached from a checkout #518 2026-06-28 23:31:32 +02:00
Linus Rath 2f5b133000 chore: update version to 1.7.6 2026-06-28 20:47:39 +02:00
Linus Rath 1f21a5213f fix: hide server scheduled folder when virtual one is shown #495 2026-06-28 20:36:32 +02:00
Linus Rath d4c066622b i18n: add missing translation keys across 19 locales 2026-06-28 20:35:23 +02:00
Linus Rath e141abc849 feat: add option to hide total message count on folders (#498) 2026-06-28 20:31:35 +02:00
Linus Rath 1f4afe082b fix: show all built-in themes in admin theme controls #496 2026-06-28 20:31:00 +02:00
Linus Rath e0747c12ee fix: send calendar invites by setting organizerCalendarAddress 2026-06-28 20:27:23 +02:00
Linus Rath f90cd6abc4 fix: sync default identity (preferredPrimaryId) to server settings #507 2026-06-28 20:12:54 +02:00
Linus Rath 63e087f3ef fix: support MFA login via structured auth endpoint 2026-06-28 19:53:08 +02:00
Linus Rath f8b8e0b108 refactor: move S/MIME to generic crypto plugin hooks 2026-06-28 19:13:02 +02:00
Linus Rath 512adab7e3 feat: add privileged same-origin plugin tier + crypto API surface 2026-06-28 16:51:42 +02:00
Linus Rath 4cdc15fc3c chore: update version to 1.7.6 2026-06-25 01:06:51 +02:00
Linus Rath 5e67671f57 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-25 01:05:00 +02:00
Linus Rath 155d99a069 feat: add plugin hooks for email details, headers, and source 2026-06-25 01:04:26 +02:00
Stefan HildebrandtandLinus Rath d863b1fd4b fix: HTML-escape sender/subject in reply/forward quote header (#482)
The forward quote header renders "From: Name <email>", but the HTML variant
interpolated the sender string unescaped. In the rich-text composer the
"<email>" portion is parsed by the browser as a bogus HTML tag and dropped, so
the address silently disappears - the user sees only "From: Display Name". The
plain-text variant and the details panel escape correctly, which is why the
address shows there. This is the regression from #367, which added the
"<email>" into the HTML string without escaping it.

Fix: HTML-escape the user-controlled values (sender, subject, date) in every
HTML quote-header path - the production builder in lib/quote-header.ts and the
composer's inline fallback (both htmlBody and plain-body branches), for forward
and reply. The reply line keeps the bare display name by design (#367), but its
HTML form is now escaped too so a display name containing markup can't break
out. As a side benefit this closes an HTML-injection vector: a crafted subject
or display name was previously injected raw into the composer document.

Adds lib/__tests__/quote-header.test.ts covering: forward text keeps
"Name <email>"; forward HTML escapes the angle brackets (address survives) and
a markup subject/display name; reply stays bare-name and HTML-safe.
2026-06-25 00:25:28 +02:00
Stefan HildebrandtandLinus Rath 70aaf0aac1 fix: stop unified-mailbox from mutating client-returned email objects
fetchUnifiedEmails, fanOutUnifiedQuery and the cross-account fanOutCrossQuery
stamped accountId/accountLabel/source* directly onto each email object
returned by the per-account client. Those objects are shared references;
mutating them in place could surprise any caller that retained them (and
corrupt an account-state snapshot). Decorate shallow copies instead, at all
three fan-out sites.

The original fix/unified-mailbox-no-mutation branch predated the cross-account
"All accounts" feature and only covered two sites; this re-applies the fix to
main's current code, including the third (shared/group) fan-out site, and
preserves all five stamped fields. Flips the characterisation test to assert
the client's object is left untouched.
2026-06-25 00:25:00 +02:00
Linus Rath de56229ef2 chore: update version to 1.7.5 2026-06-24 20:06:03 +02:00
Linus Rath 1ff23790ae Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-24 19:56:15 +02:00
198407ebf5 fix(composer): keep HTML signature styling in the editor and on send
Rich, table-based identity signatures lost all their inline CSS
(background/text colors, fonts, border-radius, bgcolor). The composer
embeds the signature into the TipTap editor, and parsing it into the
ProseMirror schema flattened it to a generic bordered table. That
normalized version was then shown while composing AND delivered to the
recipient, even though Identity settings stored and previewed it
correctly.

Hold the signature as a dedicated, non-editable atom node
(SignatureBlock) that keeps the verbatim HTML in an attribute and renders
it inside a Shadow Root, mirroring the existing QuotedHtml island. The
markup is never parsed into the schema, so the styling survives 1:1 both
in the in-editor preview and in the outgoing mail
(serializeEditorContent inlines the verbatim HTML, as it already does for
quoted originals). The signature stays a single unit: select it and
Backspace/Delete to remove it; identity switching still swaps it via the
existing data-signature-block markers.

Adds unit coverage (parse + serialize round-trip preserves inline styles).

Fixes #475

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:56:10 +02:00
Linus Rath de68d68fb7 feat: add "New address book" creation UI #415 2026-06-24 19:53:41 +02:00
Linus Rath f285a2bd64 fix: add missing fa locale to client IntlProvider messages map 2026-06-24 19:40:10 +02:00
Linus Rath 84b6d0fd8b i18n: add missing translation keys across 19 locales 2026-06-24 19:36:55 +02:00
Linus Rath f972068143 fix: localize special-folder names by JMAP role #404 2026-06-24 19:32:13 +02:00
Linus Rath 7882b254a0 fix: disable iMIP scheduling on calendar import #411 2026-06-24 19:29:56 +02:00
Linus Rath ae5d397512 feat: add "Download all" button to bundle attachments into a zip #466 2026-06-24 18:46:41 +02:00
Linus Rath 7a022596c3 feat: add option to disable calendar 2026-06-24 18:39:38 +02:00
Linus Rath 8c575e8ed8 fix: load mailboxes in Filters when opened directly #485 2026-06-24 18:20:52 +02:00
Linus Rath 85fbab9eb4 fix: surface server errors on password change and TOTP toggle 2026-06-24 18:02:18 +02:00
Linus Rath 1119d8ed73 fix: strip display names from EmailSubmission envelope addresses 2026-06-24 17:01:51 +02:00
Linus Rath 32f0a67dbc fix: gate Send-now toolbar label, translate send_now across locales 2026-06-24 16:52:36 +02:00
Shuki VakninandLinus Rath baf094d026 feat(scheduled): Send now button on scheduled/delayed messages
Adds a 'Send now' action to the scheduled-send view (both the toolbar and the
inline banner) so a queued message — whether explicitly scheduled or held by the
undo-send delay — can be sent immediately instead of only cancel/reschedule/edit.
Reuses the existing reschedule path (reschedules the submission to now), so no
new JMAP plumbing. Toolbar 'Cancel send' demoted to ghost so 'Send now' is the
single primary action. i18n added across locales.
2026-06-24 16:46:18 +02:00
hamedf62andLinus Rath f1b9f4ba50 feat(i18n): complete Farsi (fa) translation - 2654 translated strings
- Comprehensive Persian translation of all 2705 locale keys
- Covers login, sidebar, email viewer, composer, settings,
  calendar, contacts, files, S/MIME, tour, and all other sections
- 51 keys intentionally match English (language names, placeholders, templates)
- 98.1% of all strings fully translated to Persian
2026-06-24 16:34:27 +02:00
hamedf62andLinus Rath cc20d29636 feat: add Farsi (fa) locale support
Add comprehensive Farsi translation for the webmail interface:
- Create locales/fa/common.json with Farsi translations
- Register 'fa' locale in i18n/routing.ts and i18n/request.ts
- Add Iran flag component (FlagIR) to flag-icons.tsx
- Add ف��رسی to language switcher dropdown
- Add Farsi language name to English locale for language selector

Translation covers login, sidebar, email viewer, composer, settings,
notifications, calendar, contacts, errors, shortcuts, and more.
2026-06-24 16:34:27 +02:00
Shuki VakninandLinus Rath 5f713a033b fix(mail-list): truncate long subjects so they don't overlap the timestamp
In the single-line (focused) message-list layout, the subject span used
`shrink-0`, which prevented `truncate` from engaging: a long subject sized to
its full content width and overflowed the bounded subject/preview group,
rendering on top of the timestamp on the right.

Let the subject shrink and truncate (`shrink-0` -> `min-w-0`), and give the
inline preview a high shrink factor (`shrink-[9999]`) so it collapses first —
the subject stays fully visible while there's room and only truncates with an
ellipsis once the preview is gone, never colliding with the time.

Applied to both email-list-item and thread-list-item (single-email and
thread-aggregate rows).
2026-06-24 16:33:43 +02:00
Linus RathandGitHub 079ec57204 Merge pull request #458 from hildebrandttk/feat/all-mail-cross-account-views
feat: "All accounts" view extended by unread, stared and all filter and improved shared folder handling
2026-06-24 16:12:56 +02:00
Linus Rath cd247e9c47 Merge remote-tracking branch 'origin/main' into feat/all-mail-cross-account-views
# Conflicts:
#	stores/settings-store.ts
2026-06-24 16:05:00 +02:00
Stefan HildebrandtandLinus Rath 8af6694152 fix: strip reply/forward prefixes followed by a full-width colon
The prefix-stripping regex only matched an ASCII ":", so a localized
prefix from a CJK mail client (e.g. "回复:foo", using the full-width
colon U+FF1A) was left in place. On reply this caused the user's own
prefix to be stacked on top, growing the subject chain.

Accept both ":" and ":" after the prefix token. Adds tests.
2026-06-24 15:56:38 +02:00
Stefan HildebrandtandLinus Rath 751f3c1685 feat: per-account All Mail folder selection
Replaces the global allMailFolderIds (string[] | null) with a per-account
Record<accountId, string[]>, so each account chooses which of its own folders
the "All Mail" view merges. A missing entry = "not configured" (defaults to
every no-role folder); an explicit [] = "no folders".

- settings-store: type/default -> Record (default {}); persist version 4 -> 5,
  migration drops the legacy global list (the active account isn't known at
  migrate time); onRehydrate + importSettings coerce/ignore any non-record
  (legacy global string[] | null) shape. isPlainRecord() guard.
- email-store.resolveAllMailJmapIds: reads the entry for the account the view is
  scoped to (viewingAccountId ?? activeAccountId); undefined -> all no-role,
  [] -> none.
- layout-settings: read/write the active account's entry; when more than one
  account is logged in, an italic hint names the account the selection applies
  to (settings.appearance.all_mail.account_hint, 19 locales; de/ro translated).
- Test: stores/__tests__/settings-store-all-mail.test.ts (per-account
  independence, explicit-empty vs not-configured, importSettings legacy guard).
2026-06-24 15:52:09 +02:00
Shuki VakninandLinus Rath 5c2f206c74 feat(mail): return to the list after marking an open message unread
Gmail-style: marking the currently-open message unread returns to the message
list instead of staying in the reading pane (where the viewer's auto-mark-read
would just flip it back to read). Gated on the returnToListAfterAction setting
added in #477 (default on); when off, you stay in the viewer.

Only the single-message viewer, and only on mark-unread (read === false).
2026-06-24 12:09:22 +02:00
Shuki VakninandLinus Rath acc61db6f2 feat(settings): make return-to-list-after-action configurable (default on)
Per review: gate the return-to-list behaviour behind a setting,
returnToListAfterAction, defaulting to true (the Gmail/Yahoo default). When off,
deleting the open message keeps the previous auto-advance-to-next behaviour.

Adds the setting to the store (persisted), a toggle under Reading settings, and
i18n keys across all locales (English; non-English need translation). The same
setting will govern mark-as-unread (#468).
2026-06-24 02:23:57 +02:00
Shuki VakninandLinus Rath d671c606a1 feat(mail): deleting the open message returns to the list, not the next email
Deleting from inside an open message advanced to the next email. Gmail (and most
clients) return you to the message list instead. In the viewer's onDelete,
deselect first (handleMobileBack) so the store's remove-and-advance sees no
selection and won't auto-open the next message, then delete the captured email.
Returning to the list immediately also avoids a flash of the next email.

Scoped to the single-message viewer; list and keyboard deletes (which keep
auto-advance) are unchanged. Consistent with the mark-unread-returns-to-list
behaviour.
2026-06-24 02:23:57 +02:00
Stefan Hildebrandt fa3c57467b fix: route all counter updates to the email's own account in aggregate views
Extend the counter-routing fix beyond markAsRead to every optimistic mailbox
counter update, so a different account's email never adjusts the active
account's folder counters (JMAP ids can collide across accounts).

- Add applyBatchMailboxCounterUpdate() + applyDeleteCounters() and apply the
  per-account routing to: deleteEmail (trash + permanent), moveToMailbox,
  moveEmailsToMailbox, batchMarkAsRead, batchDelete, and markThreadAsRead.
- markAsSpam/batchMarkAsSpam/batchMoveToMailbox don't touch counters (rely on
  refresh) and folder-level ops (rename/empty/markMailboxAsRead) are already
  account-scoped — left as-is.
- Test: batchMarkAsRead adjusts each account's counter in its own list.
2026-06-23 19:16:23 +02:00
Stefan Hildebrandt befee332d2 fix: route unread-counter update to the email's own account in aggregate views
In a cross-account view, marking a second account's email read/unread updated
the *active* account's folder counter instead of the email's. Two causes: the
optimistic counter update only touched `state.mailboxes` (the active account),
and JMAP mailbox ids can collide across accounts so the id match hit the wrong
folder.

Add applyMailboxCounterUpdate(): route the counter delta to the list that holds
the email's folders — the active account's `mailboxes` (incl. its shared
folders) for active-account/shared emails, otherwise that account's
`accountMailboxes[sourceClientAccountId]` entry. Use it in markAsRead.

Regression test: a 2nd-account email with a colliding inbox id decrements that
account's counter and leaves the active account's untouched.
2026-06-23 19:16:22 +02:00
Stefan Hildebrandt a29c33b50a feat: cross-account "All accounts" views + full group/shared-account support
Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
2026-06-23 19:16:22 +02:00
3dd596ba50 fix: guard compose Send against double-submit
Every Send control was disabled only by `canSend` (recipient/subject/body
validity), which never reflects an in-flight submission, so the composer
stayed interactive during the JMAP round-trip. Clicking Send quickly more
than once - or a click racing the keyboard send shortcut - invoked
handleSend once per click and sent the message multiple times (duplicate
deliveries and duplicate Sent entries), most easily hit on higher-latency
connections.

Add a synchronous re-entry guard: a ref (not state, which updates
asynchronously and wouldn't block a second click in the same tick) set once
handleSend clears its "don't send" early returns and reset in a finally,
plus an isSending state that disables every Send control. Covers all entry
points - the three Send buttons, the keyboard shortcut, the schedule dialog,
and the attachment-warning confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:42:39 +02:00
Shuki VakninandLinus Rath e50fe0d4db fix(mail-list): add breathing room between the unread dot and the avatar
The unread indicator dot was absolutely positioned at `left-1` (4px), leaving
only ~4px between it and the avatar (which starts at the row's `px-4` gutter),
so the dot read as flush against the avatar. Move it to `left-0.5` so it sits
nearer the panel edge (like other mail clients) and opens the dot-to-avatar gap
to ~6px. Applied to both email-list-item and thread-list-item (all three
absolute dot instances).
2026-06-22 17:10:46 +02:00
Linus Rath 3d3ad8f0ef Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-22 00:10:13 +02:00
Linus Rath d0ed4b4dfe fix: block remaining email tracking vectors #457 2026-06-22 00:09:09 +02:00
Stefan HildebrandtandLinus Rath 5306f7c548 fix: cap filename tokens at the full 200-char limit, not 80
renderRaw (and the attachment-template renderer) sanitised each {token}
with sanitizePart's default 80-char cap, so a single long token such as
{subject} was truncated to 80 — well before the documented 200-char
filename limit, which was therefore unreachable per token. Introduce a
FILENAME_MAX_LEN (200) constant and use it for the per-token cap so the
overall limit governs. Adds tests.
2026-06-21 19:28:57 +02:00
Stefan HildebrandtandLinus Rath ddb596affc fix: isolate per-account state snapshots from leakage and mutation
account-state-manager had two latent correctness issues:

1. Shared references: snapshotAccount stored the live store arrays/objects
   directly, so a later in-place mutation (array push/splice, or a shared
   email object being stamped) retroactively corrupted an earlier snapshot.
   Now copies the captured collections.

2. Incomplete restore: the snapshot only captures a subset of each store's
   fields, but restoreAccount applied it with a merge, leaving every other
   field (email selection, loading flags, tag counts, …) at the previously
   active account's values. It only worked because every caller happened to
   call clearAllStores() first. restoreAccount now resets the stores to
   baseline itself before layering the snapshot back on, so it is correct
   standalone and can't leak state across accounts.

Adds tests pinning the isolation guarantees.
2026-06-20 13:09:26 +02:00
Stefan HildebrandtandLinus Rath bfc8ba851a chore: switch lint scripts from removed next lint to eslint
Next 16 removed the `next lint` subcommand, so `npm run lint` failed with
"Invalid project directory provided, no such directory: .../lint". Point the
lint and lint:fix scripts at ESLint directly, using the existing flat config
(eslint.config.mjs).
2026-06-19 23:53:37 +02:00
Stefan HildebrandtandLinus Rath 3f9e60843d fix: repair pre-existing failing vitest suite
Fixes failures across the suite that fail on main independently of any branch.

Documented + skipped
- smime/smime-crypto: this suite OOMs its worker (~4 GB heap) generating and
  using real 2048-bit RSA keys via pkijs/asn1js — a pre-existing memory issue,
  not a logical failure. Skipped behind a single SKIP_SMIME_CRYPTO_OOM flag with
  an in-file explanation and re-enable instructions, and the beforeAll bails
  early so the skipped file runs in ~2s instead of crashing the worker.

Code fixes
- jmap/client: getSubmissionAccountId honoured the requested (mail) account
  even when it lacks the submission capability, so EmailSubmission/set was
  addressed to the wrong account when JMAP hosts submission in a separate
  account. Prefer an account that actually advertises submission, falling back
  to primaryAccounts['…:submission'].
- plugin-sandbox/loader: deactivateAllSandboxed used require('./registry'),
  which is unresolvable under the Vite/ESM test runtime. registry only imports
  types (no cycle), so use a static import; all() already returns a copy, so
  iterating while deregister mutates is safe.

Test fixes (tests trailed intentional code/behaviour changes)
- vitest.setup: add a matchMedia stub (jsdom lacks it) — unblocks 8
  email-list-item tests.
- calendar-utils: pin TZ=UTC for the timezone-sensitive bounds/layout assertions
  (host runs at UTC+2) and update expected minutes to UTC.
- calendar-participants: buildParticipantMap keys entries by generated UUIDs
  (RFC 8984), not 'organizer'/'attendee-N'. Look entries up by identity so the
  test no longer depends on a generateUUID mock leaking from another file.
- email-headers: softfail now returns the semantic 'text-warning' token.
- email-list-item: unknown keyword ids intentionally render a gray fallback badge.
- plugin-loader: exposePluginExternals is now a documented no-op.
- plugin-slot: PluginSlot reads the sandbox registry and renders iframe slots;
  rewrite the tests against that architecture with a referentially stable snapshot.
- plugin-types: MAX_THEME_SIZE was raised to 2 MB.
2026-06-19 23:53:20 +02:00
Stefan HildebrandtandLinus Rath 2fac6ebfb8 test: add characterisation tests for untested integration seams
Golden-master tests pinning the CURRENT behavior of high-value modules
that had no coverage — integration seams, security helpers, two API
route handlers, and complex pure utils. 111 tests across 12 files.

New tests:
- auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip,
  tamper/version/missing-secret handling; cookie-slot naming.
- unified-mailbox: multi-account fan-out, sort, totals, per-account error
  isolation, personal-vs-shared JMAP target resolution, counts/roles.
- account-state-manager: snapshot/restore across the six real Zustand
  stores; clearAllStores reset shape; evict.
- mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers).
- tnef: winmail.dat binary parsing from hand-built fixtures.
- download-filename / subject-prefix / birthday-calendar / eml-import:
  filename templating, multilingual prefix stripping, birthday event
  generation, .eml/.zip import.
- webdav / caldav-discover route handlers: auth guards, path validation,
  upstream URL construction, candidate probing.
- helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient.

Tests follow the repo's existing patterns (route-import, fake IJMAPClient,
fetch spy, real store singletons). Where current behavior looks buggy it
is pinned and flagged with a // CHARACTERISATION: comment (see PR for the
suspected-bugs list); no production code is changed.
2026-06-19 23:52:42 +02:00
Paul HandLinus Rath dda9fd1433 feat(i18n): add Romanian (ro) locale
Adds locales/ro/common.json and wires ro through routing, request,
intl-provider, the language switcher and the flag list. Plurals use
Romanian one/few/other forms.
2026-06-19 15:35:32 +02:00
Stefan HildebrandtandLinus Rath 3516d3c727 chore: resolve react-hooks/exhaustive-deps warnings
Goes through the 7 exhaustive-deps warnings individually:

Added the genuinely-missing dependency (safe, no extra churn):
- email-viewer useMemo: add effectiveEmailContent.hasStyleTag (used for
  hasOwnLayout; changes in lockstep with .html, closing a latent staleness gap).
- pro-compose-tab-body handleSend: add refreshCurrentMailbox (stable zustand
  selector) and drop the stale fetchEmails/selectedMailbox deps — which left
  those two selectors entirely unused, so remove them too.
- use-mailbox-drop handleDrop: add sourceMailboxId (changes in lockstep with
  draggedEmails, already a dep).

Suppressed with a justified comment where depending on the whole object would
regress behavior — these are intentional fine-grained deps:
- email-composer signature-swap effect (keyed to signature fields + prev*Ref
  guards; whole signatureIdentity would re-splice the live editor).
- email-viewer auto-mark-as-read (whole email would reset the delay timer on
  any unrelated field update).
- email-viewer effective-attachments memo (derives from email.attachments;
  whole email would churn the list + its layout measurement).
- email-viewer auto-MDN effect (email already captured via id +
  sendReadReceiptNow; autoMdnRef guards double-send).

tsc --noEmit clean; eslint now reports 0 problems.
2026-06-19 12:31:07 +02:00
Stefan HildebrandtandLinus Rath 0b7203df0f chore: clear pre-commit eslint warnings (unused symbols, stale disables, test any)
Cleans up the lint warnings the pre-commit hook surfaces, without any
behavioral change:

- Remove unused imports/vars/destructured props (parseISO, useEffect,
  format, durMin, roles, daysInYear, ALLOWED_PLUGIN_FILES, continuesBefore,
  isPushConnected, isSelected) and the now-unused parseDuration import.
- Drop three stale `// eslint-disable-next-line no-undef` directives that
  no longer suppress anything (browser-navigation, smime/crypto-engine).
- recurrence-expansion.test.ts: replace 39 `as any` casts with a cast-only
  `rule()` helper for partial recurrence-rule fixtures, typed access to
  utcStart/utcEnd (now on CalendarEvent), and the source's
  `Partial<CalendarEvent> & { excluded?: boolean }` for the excluded
  override. No defaults are injected, so the expansion logic sees the same
  partial rules as before (35 tests still green).

Remaining: 7 react-hooks/exhaustive-deps warnings are left as-is — adding
the missing deps changes effect/memo timing and needs per-hook review, not
a mechanical fix. tsc --noEmit clean; eslint 0 errors / 7 warnings.
2026-06-19 12:31:07 +02:00
Stefan HildebrandtandLinus Rath 344795a8d9 feat: split a pasted address list into recipient chips
Pasting a list of addresses into To/Cc/Bcc now creates one chip per
address instead of dropping the whole blob in as a single invalid chip.
A paste is split only when it actually contains a separator; a lone
address falls through to normal editing.

- Separators: commas, semicolons, and any whitespace/newline - covers
  comma/space dumps, spreadsheet columns and Outlook-style `;` lists.
- Display names are preserved: `Name <email>`, a fully-quoted
  `"Name <email>"` entry, and `"Doe, John" <email>` (comma inside a
  quoted name) each stay a single chip with the name intact.
- Bare-address runs split per address; a `<addr>` token is unwrapped;
  tokens that aren't valid addresses are left behind in the input for
  the user to fix rather than becoming junk chips.
- Deduped case-insensitively within the paste and against existing chips.

Implemented as splitPastedRecipients in email-composer-utils, layered on
the shared quote/angle-aware splitter: splitRecipients gains an optional
`separators` argument so the composer/mailto serialization boundary
(comma-only) and the paste path (`,;\n\r`) share one implementation.
Wired into the recipient chip input's onPaste handler (To/Cc/Bcc).
2026-06-19 12:30:43 +02:00
Loïs PostulaandLinus Rath 638fc7db4e feat(oauth): add OAUTH_AUTHORIZE_URL to override authorize endpoint
Lets a per-brand authorize host front a single canonical issuer, so the
IdP token's `iss` stays constant for downstream validation while login
branding varies per domain. Discovery, token exchange and refresh keep
using OAUTH_ISSUER_URL.
2026-06-19 12:30:23 +02:00
Stefan HildebrandtandLinus Rath ab3e0e717a feat: email a contact or group via the in-app composer
Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.

Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).

- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
  handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
  and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
  (shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
  group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
  splitRecipients) so a comma in a display name survives — still useful for real
  OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
  locales.

Display names round-trip via formatRecipient -> parseRecipientList.
2026-06-19 12:29:43 +02:00
Max HaoandLinus Rath c9eae3b3a1 fix: update markAsSpam to fetch mailboxes with accountId 2026-06-18 21:38:29 +02:00
Max HaoandLinus Rath 6f615f4c32 fix: fix directory fetching display names. 2026-06-17 15:51:34 +02:00
Linus Rath 52eacf87b9 ix: reap only relay-confirmed-dead leftover push subscriptions 2026-06-17 09:11:29 +02:00
Max HaoandLinus Rath c4f1cc23d7 fix blank space with plain-text emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath 4f1390fdeb fix toolbar re-render when opening emails 2026-06-16 15:48:50 +02:00
Max HaoandLinus Rath c0ca6d3102 fix: add collapse all threads functionality to email selection in thread list 2026-06-16 13:34:56 +02:00
Linus Rath 0872d3dc8d chore: update version to 1.7.4 2026-06-15 23:28:20 +02:00
Linus Rath 1f889b0965 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 23:16:10 +02:00
Linus Rath 0b9fe5451f fix: preserve line breaks in generated text/plain alternative #421 2026-06-15 23:15:04 +02:00
Max HaoandLinus Rath fd700f412e fix: fix inconsistent behavior with threading email messages in the inbox/folders 2026-06-15 23:00:30 +02:00
Max HaoandLinus Rath f7d4f9d53c fix: prevent draft emails from being marked as unread 2026-06-15 23:00:00 +02:00
Stefan HildebrandtandLinus Rath 84aced7b4e test(dev-mock): use comma display names in a mock email
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
2026-06-15 22:59:46 +02:00
Stefan HildebrandtandLinus Rath 94b1f5aa48 refactor: model composer recipients as arrays instead of delimited strings
Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.

- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
  and parseRecipientList/formatRecipientList for the (de)serialization
  boundary (ComposerDraftData stays a string; quoting keeps it lossless).
  Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
  the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
  drop (payload now carries the structured recipient), send/draft/validation
  and template paths all operate on arrays. withInput() folds uncommitted
  typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
2026-06-15 22:59:46 +02:00
Linus Rath c51c3655d5 feat: add "All Mail" view 2026-06-15 18:42:23 +02:00
Linus Rath 404a1e847c fix: move "Plain Text Only" setting from Reading to Composing #422 2026-06-15 17:39:18 +02:00
Linus Rath dcea4fdd5e feat: show recipient address in chip drag preview 2026-06-15 15:28:03 +02:00
Linus Rath 701f96adb3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 15:23:09 +02:00
Stefan HildebrandtandLinus Rath 1ebfb286ad feat: drag and drop recipient chips between To/CC/BCC fields
Adds native HTML5 drag-and-drop so users can move recipient email
address chips between the To, CC, and BCC fields in the composer.
Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden
field and place the chip there.
2026-06-15 15:23:00 +02:00
Linus Rath b5f15dfdb7 i18n: add missing translation keys across 17 locales 2026-06-15 15:18:47 +02:00
Stefan HildebrandtandLinus Rath aee4bd78db feat: add Edit contact button to email viewer contact sidebar
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.
2026-06-15 15:06:35 +02:00
Linus Rath 798a33495e refactor: remove JMAP status from admin dashboard 2026-06-14 17:15:52 +02:00
Linus Rath 4c6c1aab60 feat: manage shared/group account settings from Accounts page 2026-06-14 17:05:29 +02:00
Linus Rath 848ed9774d feat: show avatars in recipient autocomplete suggestions 2026-06-14 14:54:27 +02:00
Linus Rath b32e102ff9 feat: include directory users in recipient autocomplete 2026-06-14 14:52:13 +02:00
Linus Rath e8feb11983 feat: add telemetry to web setup wizard 2026-06-14 14:35:48 +02:00
Linus Rath 3e12fca517 fix: make telemetry opt-in 2026-06-14 14:31:26 +02:00
Linus Rath 8df483d8c7 fix: don't send connected-account key as JMAP accountId when sharing files #408 2026-06-12 00:36:45 +02:00
Linus Rath 1e63e2469a fix: strip build-time basePath from router.push redirects after login #390 2026-06-12 00:23:00 +02:00
Linus Rath e1c28e767a fix: context menu invisible on first right-click after page load 2026-06-12 00:21:09 +02:00
Linus Rath fe5645c818 refactor: redesign custom recurrence editor to match modal UI 2026-06-12 00:15:54 +02:00
Linus Rath 38570b1723 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-12 00:03:52 +02:00
Linus Rath a4f476945d feat: JMAP file/folder sharing in Files app #408 2026-06-12 00:02:45 +02:00
Linus Rath 20fd9ff4de fix: prevent wide email tables from rendering with rotated headers #409 2026-06-11 19:24:05 +02:00
Linus Rath 569dde9985 fix: preserve folder list when mailbox refetch hits concurrent-request limit 2026-06-11 19:10:06 +02:00
Linus Rath 08f344403b feat: recurrence editor, set-default calendar, and timezone-aware calendar queries 2026-06-11 19:05:11 +02:00
Linus Rath be58cee989 fix: dedupe scheduling emails, Stalwart-compatible calendar filters 2026-06-11 18:24:43 +02:00
Max HaoandLinus Rath 7c11e2b3c9 add localized translation placeholders 2026-06-10 19:06:44 +02:00
Max HaoandLinus Rath f2913a7c7d feat: add email display name support to the composer. 2026-06-10 19:06:44 +02:00
Linus Rath a27a5be3dd fix: correct dark-mode background-image inversion and height clipping in email viewer 2026-06-08 15:53:44 +02:00
Linus Rath 964136b540 feat: require re-authentication for device pairing and SSO 2026-06-05 19:23:06 +02:00
Linus Rath 569f688fbf feat: QR-code SSO login between webmail and mobile app 2026-06-05 18:33:20 +02:00
Linus Rath 1d050f8469 feat: add QR code device pairing for mobile app login 2026-06-05 17:41:55 +02:00
Linus Rath 2e4c0f9eea Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-05 16:09:12 +02:00
Linus Rath fd0b866339 fix: open recent contact emails at "/" instead of 404ing on "/mail" 2026-06-05 16:07:38 +02:00
MaartenandLinus Rath 1518ba04dd fix(nav): hide Add App button when sidebarAppsEnabled is false 2026-06-05 14:32:56 +02:00
Norbert Balák-HorváthandLinus Rath c5672c64fb res lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath 5997579f54 rem lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath f77d2e9103 Fix HU i18n 2026-06-04 19:52:42 +02:00
Linus Rath 6350e9dabc chore: update version to 1.7.4 2026-06-04 12:59:28 +02:00
Linus Rath 7723c134ff chore: update version to 1.7.4 2026-06-04 12:39:50 +02:00
Linus Rath d11ed904a9 feat: calendar agenda plugin sidecar + persist email detail sidebar state 2026-06-04 12:35:56 +02:00
516 changed files with 66009 additions and 12761 deletions
+5
View File
@@ -9,3 +9,8 @@ scripts/
TODO.md
*.md
!README.md
# Sibling projects / test harness - not part of the webmail image
examples/
integration/
e2e/
**/node_modules
+10
View File
@@ -39,6 +39,16 @@ SETTINGS_SYNC_ENABLED=true
LOG_FORMAT=text
LOG_LEVEL=debug
# =============================================================================
# Plugin Development
# =============================================================================
# Load plugins from a directory on disk instead of installing them as ZIPs.
# Each immediate subfolder is one plugin and needs a manifest.json. When the
# manifest's entrypoint exists under src/, it's bundled on demand with esbuild,
# so you can edit sources and just refresh the browser.
# PLUGIN_DEV_DIR=../my-plugins
# =============================================================================
# Login Page Customization (optional)
# =============================================================================
+175 -5
View File
@@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# Offer several JMAP servers on the login form. JSON array; each entry needs
# id, label, and url. "domains" and a per-server "oauth" block are optional.
# Prefer configuring this from the admin dashboard - the env form exists for
# stateless deployments.
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
# Pick the server automatically from the domain of the address the user types,
# matching against each entry's "domains" list. Default: false.
# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true
# =============================================================================
# Stalwart Mail Server Integration
# =============================================================================
@@ -49,11 +59,29 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
# Leave unset to use the authorization_endpoint from discovery.
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# Replace the scopes requested at authorization. Space-separated. Leave unset
# to use the defaults the client already asks for.
# OAUTH_SCOPES=openid email profile offline_access
# Append scopes instead of replacing them. Use this when your IdP needs one
# extra scope and you don't want to restate the defaults.
# OAUTH_EXTRA_SCOPES=groups
# Send the user straight to the identity provider, skipping the login form.
# Intended for embedded deployments where the parent app already authenticated
# them. Default: false.
# AUTO_SSO_ENABLED=true
# =============================================================================
# Session & Security
# =============================================================================
@@ -110,12 +138,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
# helps us understand how Bulwark is used so we can make the product better.
# Heartbeats contain no PII: version, platform, bucketed account counts, and
# feature toggles only - never email addresses, hostnames, or IPs. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Enable telemetry (also toggleable in the admin UI):
# BULWARK_TELEMETRY=on
#
# Setting this (on or off) locks the choice and disables the admin UI toggle.
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
@@ -123,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# so the instance id and consent choice survive upgrades.
# TELEMETRY_DATA_DIR=./data/telemetry
# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset.
# BULWARK_TELEMETRY_DISABLED=1
# Let heartbeats reach a private/loopback address. Off by default as an SSRF
# guard; only useful when running a collector locally during development.
# BULWARK_TELEMETRY_ALLOW_PRIVATE=1
# Report a fixed Stalwart version instead of probing the JMAP server's Server
# header. Useful when a proxy strips that header.
# STALWART_VERSION=0.16.0
# =============================================================================
# Server Listen Address
# =============================================================================
@@ -188,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Should match your app's main background color. Default: #ffffff
# PWA_BACKGROUND_COLOR=#ffffff
# Screenshots shown in the browser's install prompt. Absolute URLs or paths
# relative to public/. Both are optional; per-domain overrides are available
# through DOMAIN_BRANDING.
# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png
# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png
# ---------------------------------------------------------------------------
# Logos
# ---------------------------------------------------------------------------
@@ -225,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
# Unset means the logo renders at its natural size.
# LOGIN_LOGO_MAX_HEIGHT=96px
# LOGIN_LOGO_MAX_WIDTH=320px
# Hide parts of the login page. All default to true.
# Turn the heading and subtitle off when the logo already reads as the brand.
# LOGIN_SHOW_HEADING=false
# LOGIN_SHOW_SUBTITLE=false
#
# Hide the optional TOTP field. A server that requires TOTP (totp_required)
# still shows it regardless of this setting.
# LOGIN_SHOW_TOTP=false
#
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
# LOGIN_SHOW_VERSION=false
# ---------------------------------------------------------------------------
# Per-domain branding overrides (optional)
# ---------------------------------------------------------------------------
@@ -257,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
# Admin Dashboard Access
# =============================================================================
# Bootstrap password for the admin dashboard. Read only when admin.json does
# not already exist; the app hashes it, writes admin.json, and logs a warning
# telling you to remove this variable. Without it (and without the setup
# wizard) the admin dashboard stays disabled.
# Accepts a plaintext password or an existing hash.
# ADMIN_PASSWORD=change-me
# Admin session lifetime in seconds. Default: 3600 (1 hour).
# ADMIN_SESSION_TTL=3600
# How many trusted reverse proxies sit in front of the app. The client IP is
# taken that many entries from the right of X-Forwarded-For, so an attacker
# can't spoof it by prepending values. Default: 1.
# TRUSTED_PROXY_DEPTH=2
# Allow search engines to index the app (robots.txt / noindex). Default: false.
# SEARCH_ENGINE_INDEXING=true
# =============================================================================
# Cookies, Embedding & Reverse Proxies
# =============================================================================
# SameSite attribute for session cookies: lax (default), strict, or none.
# Embedding the app cross-origin in an iframe requires "none".
# COOKIE_SAME_SITE=none
# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or
# COOKIE_SAME_SITE=none. Set to false only for local HTTP development.
# COOKIE_SECURE=false
# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none',
# which blocks all framing. Space-separate multiple origins.
# ALLOWED_FRAME_ANCESTORS=https://portal.example.com
# Origin of the parent page when embedded, used for postMessage handshakes.
# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com
# =============================================================================
# Update Check
# =============================================================================
# The app periodically checks for new releases and shows a notice. Set to
# "off" (or false/0/no) to disable the check entirely.
# BULWARK_UPDATE_CHECK=off
# Override the endpoint it checks. Takes priority over the on-disk state file.
# An explicit empty value also disables the check.
# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json
# Where the check stores its state. Default: ./data/version-check
# VERSION_CHECK_DATA_DIR=./data/version-check
# =============================================================================
# Translation Proxy (optional)
# =============================================================================
# /api/translate defaults to the public MyMemory API, which needs no setup.
# Point it at a LibreTranslate instance instead to keep message text on
# infrastructure you control. LibreTranslate also auto-detects the source
# language natively.
# LIBRETRANSLATE_URL=https://libretranslate.example.com
# LIBRETRANSLATE_API_KEY=
# =============================================================================
# Web Push
# =============================================================================
# Push notifications go through a hosted relay so self-hosters don't need
# their own VAPID keys and Firebase project. Point this at your own relay to
# avoid the default. Build-time variable.
# Default: https://notifications.relay.bulwarkmail.org
# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com
# =============================================================================
# Demo Mode
# =============================================================================
# Serve fixture data instead of talking to a mail server. Default: false.
# DEMO_MODE=true
# =============================================================================
# Stalwart Impersonation (advanced)
# =============================================================================
# Lets a trusted platform mint a JWT that logs a user in without their
# password, using a Stalwart master account. Intended for embedded
# deployments where an outer platform already authenticated the user.
#
# SECURITY: this grants sign-in as any mailbox on the server. The endpoint
# returns 404 unless all three required variables below are set, so leaving
# them unset keeps the feature fully off. Treat the secret and the master
# password as you would a root credential.
#
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
# BULWARK_STALWART_MASTER_PASSWORD= # required
# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail"
# =============================================================================
# Internationalization
# =============================================================================
@@ -265,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
#
# Fallback UI locale used when the visitor's Accept-Language header does not
# match any supported locale. Defaults to "en".
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl,
# pt, ro, ru, sk, tr, uk, zh
# An unsupported value falls back to "en".
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
# Locale prefix mode for URLs. Recommended "always" when proxying under a
@@ -118,3 +118,114 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
build-always:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
NEXT_PUBLIC_LOCALE_PREFIX=always
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=always-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests-always
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests-always/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests-always/*
if-no-files-found: error
retention-days: 1
merge-always:
runs-on: ubuntu-latest
needs: build-always
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests-always
pattern: digests-always-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
flavor: |
suffix=-always,onlatest=true
tags: |
type=raw,value=latest
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern={{major}}
- name: Create manifest list and push
working-directory: /tmp/digests-always
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+11
View File
@@ -50,3 +50,14 @@ next-env.d.ts
# Sibling repos
/repos/
# k8s deploy secret (create from deploy/k8s/secret.example.yaml)
/deploy/k8s/secret.yaml
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
vnc/plugins/smime/node_modules/
vnc/plugins/smime/dist/
vnc/plugins/smime/smime-vnc.zip
# macOS
.DS_Store
+230
View File
@@ -1,5 +1,235 @@
# Changelog
## 1.7.8 (2026-07-22)
### Features
- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509)
- **Unified Mailbox**: Search in the unified views
- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts
- **Mail**: Message-list category tabs
- **Mail**: Drag-and-drop reorder for all folders
- **Mail**: Collapse quoted reply text behind a "..." toggle (#480)
- **Mail**: Bulk Not-Spam action in the junk selection toolbar
- **Mail**: Unread count badge on the favicon
- **Mail**: Message spacing setting (auto/always/edge-to-edge)
- **Mail**: Open external links in a new tab (safely)
- **Mail**: Strip external `url()`/`@import` from `<style>` blocks in the sanitizer (#457)
- **Composer**: Text color picker in the composer toolbar
- **Composer**: Contact groups as single expandable recipient chips
- **Composer**: Drag-to-reorder To/Cc/Bcc recipient chips (#593)
- **Composer**: Auto-detect paragraph text direction by default
- **Templates**: HTML template support
- **Vacation**: HTML body support in the vacation responder
- **Send**: 'Send now' action on the send-delay toast
- **Accounts**: Remove a specific account from the switcher
- **Settings**: "Refresh cached data" recovery action
- **i18n**: Full Arabic (ar) translation with RTL support
- **Login**: `LOGIN_SHOW_TOTP` and `LOGIN_SHOW_VERSION` config flags (#520)
- **Docker**: `NEXT_PUBLIC_LOCALE_PREFIX` build argument
- **Plugins**: `ui.rerenderFetchedEmails` method (#668)
- **Plugins**: `onEmailsFetched` and `onSearchResults` hooks and `getSomeEmails` JMAP method
- **Plugins**: `onRecipientChipsChange` hook
- **Plugins**: `webauthn.getOrCreate` API method
- **Plugins**: Download files generated by a plugin (with `ui:download-file` consent permission)
- **Plugins**: Submit mail without moving to a mailbox and import-to-mailbox APIs
### Fixes
- **Mail**: Render the email body on DOM parse instead of iframe load (#635)
- **Mail**: Keep sidebar tag counts in step with read/unread changes
- **Mail**: Enable thread expansion in the focused list
- **Mail**: Show the quote bar in email replies
- **Mail**: Honor part-type fallback when quoting replies (#649)
- **Mail**: Detect typing inside the quoted-HTML shadow island (#654)
- **Mail**: Keep `target`/`rel` on links in plain-text message bodies and open signature links in a new tab
- **Accounts**: Eliminate the full-screen flash when switching accounts (including cached accounts)
- **Accounts**: Recognize canonicalized login usernames in the account-switch guard
- **Auth**: Guard account switch against slot→token desync and basic-auth identity mismatches
- **Auth**: End refresh loops on sign-out and back off failed retries
- **OAuth**: Harden OIDC discovery (timeout, retry, serve-stale)
- **JMAP**: Preserve POST across redirects in the Stalwart JMAP passthrough (#627)
- **JMAP**: File the post-send message with a full `mailboxIds` replacement
- **JMAP**: Generate the Message-ID client-side using the sender's domain
- **Identity**: Sync the default sender identity per account (#507)
- **Attachments**: Download/view attachments on cross-account All-Mail messages
- **Shared folders**: Route batch actions to the owner account
- **Templates**: Insert a mail template at the caret in replies instead of prepending (#539)
- **Templates**: Keep the signature when inserting a template (#621)
- **Templates**: Hide template buttons when templates are disabled
- **Calendar**: Honor "Show time in month view" on mobile instead of forcing dots (#666)
- **Calendar**: Classify self-organized imported events as editable
- **Contacts**: Assign a UID to contact cards on creation (#644)
- **Spam**: Stop HELO `spf=none` from downgrading a MAIL FROM `spf=pass` (#650)
- **Drafts**: Label the close-dialog draft button with the generic Save
- **RTL**: Flip JS-positioned popovers and anchor floating menus with logical start/end
- **RTL**: Isolate Latin address text from RTL bidi reordering and force LTR identity options
- **i18n**: Register Arabic messages in the client IntlProvider
- **i18n**: Fix the Hebrew Drafts folder label
- **i18n**: Add missing translation keys across 22 locales
- **Deps**: Bump `dompurify` to 3.4.12 and `next-intl` to 4.13.3
## 1.7.7 (2026-07-09)
### Features
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
- **i18n**: Hebrew locale with full RTL support
- **i18n**: Slovak translation
- **i18n**: User-selectable regional date format
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
- **Mail**: Pin emails to the top of the folder list
- **Mail**: Setting to disable the tag-color row tint in the message list
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
- **Composer**: Preselect the identity of the active mailbox for new messages
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
- **Headers**: Parse Stalwart spam headers
- **Login**: Configurable logo size and hideable heading/subtitle
- **PWA**: Apple Touch icons for the iOS home screen
### Fixes
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
- **Mail**: Keep advanced search filters applied when switching folders (#553)
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
- **Mail**: Route keyword writes to the email's own account in unified view
- **Mail**: Render emails that set `height:100%` on a wrapper element
- **Mail**: Hide images that fail to load
- **Mail**: Storage quota not shown with Stalwart (#577)
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
- **Spam**: Fix stale folder counters and open message after spam actions
- **Composer**: Wait for in-flight attachment uploads before sending
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
- **Auth**: Keep the session when the auth server is briefly unreachable
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
- **Plugins**: Preserve the settings slot and privileged tier
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
- **Pro**: Show the Edit button on draft emails opened in a new tab
- **List**: Shift-click on the checkbox extends the selection (range)
- **CSP**: Allow external/data fonts so email webfonts render
- **Notifications**: Brand push notifications with the configured PWA icon
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
- **Branding**: Apply per-domain favicon override in root metadata (#585)
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
## 1.7.6 (2026-06-28)
### Breaking Changes
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
### Features
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
- **Plugins**: Plugin hooks for email details, headers, and source
- **Mail**: Option to hide the total message count on folders (#498)
### Fixes
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
- **Auth**: Support MFA login via the structured auth endpoint
- **Admin**: Show all built-in themes in the admin theme controls (#496)
- **i18n**: Add missing translation keys across 19 locales
## 1.7.5 (2026-06-24)
### Features
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
- **Mail**: Per-account "All Mail" folder selection
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
- **Mail**: Collapse-all-threads action in thread-list selection
- **Calendar**: Option to disable the calendar
- **Composer**: Send-now button on scheduled/delayed messages
- **Composer**: Email a contact or group via the in-app composer
- **Composer**: Split a pasted address list into recipient chips
- **Contacts**: "New address book" creation UI (#415)
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
- **i18n**: Farsi (fa) locale — complete (2654 strings)
- **i18n**: Romanian (ro) locale
### Fixes
- **Composer**: Keep HTML signature styling in the editor and on send
- **Composer**: Guard Send against double-submit
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
- **Mail**: Localize special-folder names by JMAP role (#404)
- **Mail**: Block remaining email tracking vectors (#457)
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
- **Mail**: Fix blank space in plain-text emails
- **Mail**: Fix toolbar re-render when opening emails
- **Mail**: Truncate long subjects so they don't overlap the timestamp
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
- **Mail**: Add breathing room between the unread dot and the avatar
- **Mail**: Isolate per-account state snapshots from leakage and mutation
- **Mail**: Cap filename tokens at the full 200-char limit
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
- **Filters**: Load mailboxes when opened directly (#485)
- **Settings**: Surface server errors on password change and TOTP toggle
- **Send now**: Gate the toolbar label and translate `send_now` across locales
- **Directory**: Fix fetching display names
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
- **i18n**: Add missing translation keys across 19 locales
## 1.7.4 (2026-06-15)
### Features
- **Mail**: New "All Mail" view across folders and accounts
- **Mail**: Edit contact directly from the email viewer contact sidebar
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
- **Calendar**: Agenda plugin sidecar
- **Composer**: Email display name support
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
- **Files**: JMAP file/folder sharing in the Files app (#408)
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
- **Auth**: Require re-authentication for device pairing and SSO
- **Accounts**: Manage shared/group account settings from the Accounts page
- **Setup**: Opt-in telemetry in the web setup wizard
- **Mail**: Persist the email detail sidebar state
### Fixes
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
- **Mail**: Stop draft emails from being marked as unread
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
- **Privacy**: Make telemetry opt-in
- **UI**: Fix the context menu being invisible on first right-click after page load
- **Admin**: Remove the JMAP status from the admin dashboard
- **i18n**: Add missing translation keys across 17 locales
## 1.7.3 (2026-06-04)
### Features
+75 -29
View File
@@ -10,13 +10,13 @@
# Contributing to Bulwark Webmail
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
If that sounds like your kind of project, we'd love the help.
## Join the Community
## Join the community
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
@@ -26,9 +26,9 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
---
## Getting Started
## Getting started
### Development Setup
### Development setup
1. **Fork and clone** the repository:
@@ -46,16 +46,22 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
3. **Set up environment**:
```bash
cp .env.example .env.local
# Edit .env.local with your JMAP server URL
cp .env.dev.example .env.local
```
This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
develop without a mail server. Log in with any username and password. To work
against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
4. **Start development server**:
```bash
npm run dev
```
### Code Quality
Then open http://localhost:3000.
### Code quality
Before submitting a pull request, ensure your code passes all checks:
@@ -72,7 +78,20 @@ npm run lint:fix
These checks run automatically on commit via Husky pre-commit hooks.
## Code Style Guidelines
### Testing
| Suite | Command | What it covers |
| ---------------- | -------------------------- | ------------------------------------------------------------------ |
| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
Run a single unit test file with `npx vitest run lib/__tests__/<name>.test.ts`, or `npx vitest` to watch.
The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
## Code style guidelines
### TypeScript
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
- Avoid `any` types when possible
- Use meaningful variable and function names
### React Components
### React components
- Use functional components with hooks
- Keep components focused and single-purpose
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n)
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
### Rules
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
router.push(`/${params.locale}/settings`);
```
## Pull Request Process
### Adding a new locale
### Before Submitting
Registering a new locale takes edits in four places:
1. `locales/<code>/common.json` - copy `locales/en/common.json` and translate
2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
3. `i18n/request.ts` - add a `case` to the static-import switch
4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
## Pull request process
### Before submitting
1. **Create a feature branch**:
@@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
2. **Make your changes** following the code style guidelines
3. **Test your changes** thoroughly
3. **Test your changes** thoroughly, and add unit tests for new logic
4. **Update translations** if you added user-facing text
5. **Run all checks**:
```bash
npm run typecheck && npm run lint
npm run typecheck && npm run lint && npx vitest run
```
### Submitting
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
- Screenshots for UI changes
- Reference to any related issues
### Commit Message Convention
### Commit message convention
Follow the conventional commits format:
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
docs: update README with keyboard shortcuts
```
## Project Structure
## Project structure
```
webmail/
├── app/ # Next.js App Router pages
── [locale]/ # Locale-aware routing
├── app/ # Next.js App Router
── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
│ ├── (main)/admin/ # Admin dashboard
│ ├── (main)/setup/ # First-launch setup wizard
│ ├── (sandbox)/ # Isolated plugin sandbox routes
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
├── components/ # React components
│ ├── email/ # Email-related components
│ ├── layout/ # Layout components
│ ├── settings/ # Settings components
── ui/ # Reusable UI components
│ ├── email/ # Email list, viewer, composer
│ ├── calendar/ contacts/ files/ filters/ templates/
│ ├── layout/ # Sidebar, shell, navigation
── settings/ # Settings panels
│ ├── plugins/ # Plugin host UI
│ └── ui/ # Reusable primitives
├── contexts/ # React contexts
├── hooks/ # Custom React hooks
├── i18n/ # next-intl routing, locale detection, RTL direction
├── lib/ # Utilities and libraries
── jmap/ # JMAP client implementation
├── locales/ # Translation files
│ ├── en/ # English translations
── fr/ # French translations
└── stores/ # Zustand state stores
── jmap/ # JMAP client implementation
│ ├── stalwart/ # Stalwart-specific admin/API helpers
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
── plugin-sandbox/ # Plugin sandbox bridge and hardening
│ └── __tests__/ # Vitest unit tests
├── locales/ # Translation files, one directory per locale
├── stores/ # Zustand state stores
├── public/ # Static assets and branding
├── e2e/ # Playwright smoke tests (against `npm run dev`)
└── integration/ # Dockerized Stalwart + Playwright suite
```
## Security
+4
View File
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Optional: avoid next-intl rewrite loops when served under a subpath.
# Baked in at build time.
ARG NEXT_PUBLIC_LOCALE_PREFIX=
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
# Accept-Language header does not match any supported locale. Baked in at
# build time because next-intl wires it into client-side routing too.
+111 -102
View File
@@ -2,140 +2,149 @@
## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
- Scheduled send and configurable send delay
- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account.
- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from.
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
- Scheduled send, plus a configurable delay before anything leaves the outbox
- Read receipts (MDN, RFC 8098)
- Editable, layout-preserving quote island when replying
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support
- Configurable signature position (above or below quoted text) per identity
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
- `.eml` file import via folder right-click menu
- Quoted text lands in an editable island that keeps the original layout
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Multi-select for batch archive, delete, move, and tag
- Archive directly, by year, or by month
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
- Each tag can be configured to show always, only when there are unread mails or always be hidden
- Star or unstar, with a configurable mark-as-read delay
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
- Plain-text composer mode and Reply-To
- The signature sits above or below the quoted text, per identity
- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
- Import `.eml` files from the folder right-click menu
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer
- Browser history sync for back/forward navigation
- Folders take an icon, nest, and show counts in the sidebar
- Print from the viewer
- Browser back and forward move through mail history
## Calendar
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
- Recurring events with scoped edit/delete (this / this and following / all)
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
- Inline calendar invitations in the email viewer auto-detect `.ics`, RSVP, import
- iCalendar import with preview, bulk create, and UID deduplication
- iCal / webcal subscriptions with editing and batch import
- Auto-generated birthday calendar from contacts
- Virtual locations (video conference URLs) as first-class event fields
- Task management with due dates, priority, and completion status
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
- Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
- Recurring events edit and delete by scope: this occurrence, this and following, or all
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
- iCalendar import previews first, then bulk-creates, deduplicating on UID
- iCal / webcal subscriptions, editable, with batch import
- A birthday calendar generated from your contacts
- Virtual locations (video-conference URLs) are first-class event fields
- Tasks with due dates, priority, and completion status
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
- Week numbers, hover preview, notifications with a sound picker
- JMAP push keeps everything in sync
## Contacts
- JMAP sync (RFC 9553 / 9610) with local fallback
- Multiple address books with drag-and-drop between books
- Contact groups with member management
- vCard import/export (RFC 6350) with duplicate detection
- Trusted senders stored in a dedicated JMAP address book
- Autocomplete in the composer (To / Cc / Bcc)
- JMAP sync (RFC 9553 / 9610), falling back to local storage
- Several address books, with drag-and-drop between them
- Groups with member management
- vCard import/export (RFC 6350) that flags duplicates
- Trusted senders live in their own JMAP address book
- Autocomplete on To, Cc, and Bcc
## Filters & Templates
## Filters & templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients
- Server-side filters as JMAP Sieve Scripts (RFC 9661)
- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
- Rules written in other clients survive the round-trip
- Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
- A vacation responder you can schedule to a date range
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
- Streamed WebDAV PUT upload and folder upload with progress tracking
- Dynamic upload limits based on server configuration
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
- Streamed WebDAV PUT upload, whole folders included, with progress
- Upload limits follow the server's own configuration
- Grid or list, sorted by name, size, or date
- Preview images, text, audio, and video
- Cut, copy, paste, duplicate; favorites; recent files
- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
## Security & Privacy
## Security & privacy
- External content blocked by default, with a trusted senders list
- HTML sanitization via DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
- SPF / DKIM / DMARC status indicators surfaces the most severe SPF result and hides the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- External content stays blocked until you say otherwise, and trusted senders are remembered
- HTML sanitized through DOMPurify
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
- Plugin hardening with dangerous-pattern detection and admin approval
- Password and 2FA management through the Stalwart admin API
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
- Plugins are scanned for dangerous patterns and need admin approval
- Newsletter unsubscribe (RFC 2369)
## Interface
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
- Dark and light themes with intelligent email color transformation
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
- Responsive desktop, tablet, and mobile layouts
- Split three-pane, focused list, or bottom reading pane, columns resizable
- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
- Layouts for desktop, tablet, and mobile
- Full keyboard navigation
- Drag-and-drop email organization and tag assignment
- Interactive guided tour for new users
- Right-click context menus, toast notifications with undo
- Customizable toolbar position, favicon, and login branding
- Pinnable sidebar apps with drag-and-drop reordering
- Encrypted settings sync across devices
- Drag and drop to organize mail and assign tags
- A guided tour for first-time users
- Right-click menus, and toasts that offer an undo
- Toolbar position, favicon, and login branding are configurable
- Sidebar apps pin and reorder by drag
- Settings sync between devices, encrypted
- Storage quota display
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
## Internationalization
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
- The browser's `Accept-Language` picks the first language, and the choice persists per user
- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
## Identity & Multi-Account
## Identity & multi-account
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
- Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
- An account switcher showing connection status, and a default account
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
- Signature above or below the quoted text
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
- Shared folders across accounts
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
- Several JMAP servers per deployment, optionally auto-picked by email domain
- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
## Admin & Extensibility
## Admin & extensibility
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
- Bundled plugins including Jitsi Meet calendar integration
- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
- Themes upload as ZIP bundles, and admins can enforce one
- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
- Bundled plugins, including Jitsi Meet for the calendar
## Operations
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
- Structured logging (`text` or `json`) with per-category levels
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
- Docker images on GHCR, for release (`main`) and development (`dev`)
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
- Demo mode runs on fixture data, no mail server required
+70 -30
View File
@@ -8,26 +8,19 @@
# Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.8-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
</div>
---
## Installer
New in **1.6.4**: a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
Since **1.6.4**, a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
Point a browser at the running container and the wizard guides you through:
@@ -72,27 +65,27 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Light mode</b> full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
## Overview
## What Bulwark includes
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Mail** threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four.
Full feature list: **[FEATURES.md](FEATURES.md)**.
---
## Quick Start
## Quick start
### Docker
@@ -106,9 +99,9 @@ Or with Docker Compose:
docker compose up -d
```
On first launch, open `http://localhost:3000` the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration).
### From Source
### From source
```bash
git clone https://github.com/bulwarkmail/webmail.git
@@ -121,16 +114,20 @@ npm run build && npm start
### Development
```bash
npm run dev # Dev server with a mock JMAP server
cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
npm run dev # Dev server
npm run typecheck
npm run lint
npx vitest run # Unit tests
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
```
## Configuration
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI.
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`:
```env
# Optional overrides whatever the wizard writes
@@ -153,13 +150,28 @@ PORT=3000
```env
OAUTH_ENABLED=true
OAUTH_ONLY=true # hide the username/password form entirely
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.
</details>
<details>
<summary>Anonymous telemetry</summary>
```env
BULWARK_TELEMETRY=on # opt-in; off by default
TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume
```
Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
</details>
@@ -257,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
</details>
<details>
<summary>Default UI locale</summary>
The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`):
```env
NEXT_PUBLIC_DEFAULT_LOCALE=de
```
Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`.
Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own:
```bash
docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
```
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
@@ -273,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
</details>
## Keyboard Shortcuts
## Keyboard shortcuts
| Key | Action |
| ------------- | ----------------------- |
| `j` / `k` | Navigate between emails |
| -------------------- | ----------------------- |
| `j` `↓` / `k` `↑` | Navigate between emails |
| `Enter` / `o` | Open email |
| `Esc` | Close / deselect |
| `x` | Expand / collapse thread |
| `c` | Compose |
| `r` / `R` | Reply / Reply all |
| `r` / `R` `a` | Reply / Reply all |
| `f` | Forward |
| `s` | Star |
| `e` | Archive |
| `#` | Delete |
| `#` / `Del` | Delete |
| `u` / `Shift`+`I` | Mark unread / read |
| `!` | Toggle spam |
| `Ctrl`+`A` | Select all |
| `Shift`+`G` | Refresh |
| `/` | Search |
| `?` | Show all shortcuts |
## Tech Stack
In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker.
## Tech stack
| | |
| ------------- | ------------------------------------------------- |
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
| **Language** | TypeScript |
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
| **Protocol** | Custom JMAP client (RFC 8620) |
| **Editor** | [Tiptap](https://tiptap.dev/) |
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
| **Icons** | [Lucide React](https://lucide.dev/) |
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
## Why Stalwart?
+1 -1
View File
@@ -1 +1 @@
1.7.3
1.7.8
+76
View File
@@ -0,0 +1,76 @@
# VNCmail+ — setup & deploy runbook
VNCmail+ is VNC's fork of [Bulwark](https://github.com/bulwarkmail/webmail), a
Next.js (App Router) JMAP webmail client for **Stalwart**. Stalwart is the source
of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
(microk8s)** at `vncmail.sandbox.vnc.de` — see **[deploy/k8s/](deploy/k8s/README.md)**.
> **License:** AGPL-3.0. Serving a modified VNCmail+ to users over the network
> obligates VNC to offer those users the corresponding source. Keeping this fork
> public (with a "Source" link in the imprint/UI) satisfies that. Loop in legal
> before a public/customer-facing launch if a closed fork is ever desired.
## Architecture — why a container, not Vercel
- Bulwark is a **stateful, long-lived server**: it persists settings-sync, admin
config/state, and telemetry to a **local data directory** (`/app/data/*`).
- **Vercel serverless was tried and dropped** — its filesystem is read-only
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
You cannot point its data dirs at a remote host either (they're POSIX paths,
not URLs). Bulwark's native model is a container + persistent volumes.
- So VNCmail+ runs as a Docker image (`ghcr.io/brvncde-dotcom/vncmail-plus-*`)
with **4 persistent volumes**, exactly like the existing `bulwark.sandbox.vnc.de`.
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
## Branches (dev-first)
| Branch | Role |
|--------|------|
| `main` | **Production** — CI builds `…/vncmail-plus-beta`. Only updated by an explicit promote. |
| `dev` | Integration + QA — CI builds `…/vncmail-plus-dev` on push. Default working branch. |
| `vnc/*`| Feature branches for UI work (branch off `dev`, PR into `dev`). |
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
## Deploy (Kubernetes / microk8s)
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
1. CI builds the image on push to `dev`/`main``ghcr.io/brvncde-dotcom/vncmail-plus-dev` (`.github/workflows/docker-publish.yml`).
2. `kubectl apply` the manifests in `deploy/k8s/` (namespace, 4 PVCs, deployment, service, ingress) + a `secret.yaml` (from `secret.example.yaml`) + a `ghcr-pull` image-pull secret.
3. Point `vncmail.sandbox.vnc.de` DNS at the ingress; cert-manager issues TLS.
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
## Deploy workflow (dev-first — ALWAYS)
Same flow as every other VNC/SRC repo:
1. Work on `dev` (or `vnc/*` → PR into `dev`). Push to `dev` → CI builds the `-dev` image → `kubectl -n vncmail rollout restart deploy/vncmail-plus` to pull it. QA at `vncmail.sandbox.vnc.de`.
2. **Promote to production only on explicit go-live** — merge `dev``main`:
```bash
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
git checkout main && git merge --ff-only dev
git push origin main # CI builds the production image
git checkout dev
```
Then roll the production deployment to the new image (pin its digest — see deploy/k8s/README.md).
Never push straight to `main`. Never let a dev→main merge silently revert prod.
## Syncing upstream (Bulwark releases)
Bring upstream into `dev` (NOT main), integrate + QA on the dev image, then promote as above:
```bash
git fetch upstream
git checkout dev && git merge upstream/main # resolve conflicts via vnc/VNC-CHANGES.md; QA on preview
```
## Auth
Basic auth via Stalwart is the default — users sign in with their
`@sandbox.vnc.de` address + password; VNCmail+ authenticates them over JMAP. No
extra config. (SSO via vncdirectory/OIDC is a later option — see
`vnc/vercel.env.template`.)
+40 -4
View File
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
return;
}
// Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
// Security settings, where the QR generation auto-resumes.
let pairReauthResume = false;
try {
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
} catch { /* sessionStorage unavailable */ }
if (pairReauthResume && state) {
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
(async () => {
try {
const res = await apiFetch("/api/auth/reauth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
try {
sessionStorage.setItem("pair_reauth_done", "1");
// Land back on the Security tab (readPersistedTab reads this key).
sessionStorage.setItem("settings-deep-link-tab", "security");
} catch { /* ignore */ }
const prefix = getPathPrefix(params.locale as string);
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (savedState) {
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
>
{t("oauth_error.back_to_login")}
</Button>
+12 -6
View File
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -97,6 +98,7 @@ export default function CalendarPage() {
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
@@ -179,10 +181,13 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
} else if (client && !calendarEnabled) {
// Calendar disabled by admin policy - send the user back to mail.
router.push("/");
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/");
}
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => {
if (error) {
@@ -1144,13 +1149,13 @@ export default function CalendarPage() {
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
@@ -1164,6 +1169,7 @@ export default function CalendarPage() {
) : null;
if (!isAuthenticated) return null;
if (!calendarEnabled) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => {
@@ -1330,7 +1336,7 @@ export default function CalendarPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -1469,7 +1475,7 @@ export default function CalendarPage() {
{/* Desktop event panel */}
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
@@ -1492,7 +1498,7 @@ export default function CalendarPage() {
{/* Desktop task panel */}
{!isMobile && showTaskModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
+111 -8
View File
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -82,6 +84,7 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
createAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
@@ -93,6 +96,7 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
@@ -173,12 +177,13 @@ export default function ContactsPage() {
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
const viewParam = searchParams.get('view');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
setView(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
@@ -297,6 +302,34 @@ export default function ContactsPage() {
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
// Refresh address books (and contacts) after a structural change, staying
// multi-account aware so a freshly created book lands in the sidebar.
const refreshAddressBooks = useCallback(async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
await fetchAllAccountsAddressBooks(accountClients, activeId);
return;
}
}
await useContactStore.getState().fetchAddressBooks(client);
}, [client, multiAccountEnabled, accountClients]);
const handleCreateAddressBook = useCallback(async (name: string) => {
if (!client) return;
try {
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
setCreatingAddressBook(false);
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
}
}, [client, createAddressBook, refreshAddressBooks, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
@@ -367,8 +400,8 @@ export default function ContactsPage() {
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
void _id; void _uid; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
@@ -460,6 +493,59 @@ export default function ContactsPage() {
setView("group-edit");
}, []);
// Open the in-app composer in the current session rather than routing through
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
// (which may open a different mail app), and the mailto protocol round-trip
// reloads the app - dropping the in-memory per-account JMAP clients of a
// multi-account session, which reads as a logout. Stashing the recipients and
// doing a client-side router.push keeps the session and the active account
// intact; the main route consumes the pending compose and opens the composer
// (see consumePendingMailto in page.tsx).
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
savePendingMailto({
to: field === "to" ? recipients : [],
cc: field === "cc" ? recipients : [],
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
});
router.push("/");
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Hand the composer a single group chip (RFC 5322 group syntax survives
// the string hand-off) instead of one entry per member - the chip expands
// into the members when the message is sent. Dedupe by email,
// case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const members: Array<{ name?: string; email: string }> = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
const name = getContactDisplayName(member);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (members.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
const chip: Recipient = {
name: (group && getContactDisplayName(group)) || "Group",
email: "",
group: { members },
};
openComposeInApp([formatRecipientEntry(chip)], field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
const email = getContactPrimaryEmail(contact).trim();
if (!email) return;
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
@@ -624,6 +710,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile}
onSelectMember={(id) => {
setSelectedContact(id);
@@ -672,7 +759,7 @@ export default function ContactsPage() {
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
@@ -701,6 +788,11 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onCompose={
selectedContact
? () => handleComposeContact(selectedContact)
: undefined
}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
@@ -785,7 +877,7 @@ export default function ContactsPage() {
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0 bg-background",
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -803,9 +895,11 @@ export default function ContactsPage() {
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
@@ -851,7 +945,7 @@ export default function ContactsPage() {
<div
data-tour="contacts-list"
className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0",
"border-e border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
@@ -905,7 +999,7 @@ export default function ContactsPage() {
onClick={mobileBackToList}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
</div>
@@ -951,6 +1045,15 @@ export default function ContactsPage() {
}}
/>
)}
{creatingAddressBook && (
<RenameDialog
currentName=""
title={t("address_books.create")}
label={t("address_books.name_label")}
onCancel={() => setCreatingAddressBook(false)}
onConfirm={handleCreateAddressBook}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
+2 -2
View File
@@ -38,11 +38,11 @@ export default function LocaleError({
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 mr-2" />
<Home className="w-4 h-4 me-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
+16 -2
View File
@@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
@@ -88,6 +89,7 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
shareResource,
} = useFileStore();
const isMobile = useIsMobile();
@@ -401,6 +403,14 @@ export default function FilesPage() {
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Sharing: the browsing client (store-attached) drives the principal picker
// and share mutations. supportsPrincipals() gates the whole Share affordance.
const sharingEnabled = !!storeClient?.supportsPrincipals();
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
await shareResource(id, principalId, rights);
}, [shareResource]);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
@@ -467,7 +477,7 @@ export default function FilesPage() {
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t("title")}
</Button>
</div>
@@ -540,6 +550,10 @@ export default function FilesPage() {
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/>
</div>
)}
@@ -597,7 +611,7 @@ export default function FilesPage() {
: '0%' }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
{migrationProgress.current} / {migrationProgress.total}
</p>
</div>
+2
View File
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<PluginConsentDialog />
+38 -16
View File
@@ -9,9 +9,10 @@ import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { resolveThemeLogo } from "@/lib/theme-logo";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
@@ -133,8 +134,19 @@ export default function LoginPage() {
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
// fall back to the globally configured login logo.
const effLoginLogo = resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', loginLogoLightUrl, loginLogoDarkUrl);
// Login logo sizing: when a max height/width is configured, drop the fixed
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
const loginLogoStyle = hasLogoSize
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
: undefined;
const [formData, setFormData] = useState({
username: "",
@@ -279,7 +291,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
@@ -655,7 +667,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
};
@@ -718,7 +730,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -733,7 +745,7 @@ export default function LoginPage() {
<div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
src={withBasePath(effLoginLogo)}
alt={appName}
className="max-w-20 max-h-20 object-contain"
/>
@@ -815,7 +827,7 @@ export default function LoginPage() {
)}
</div>
)}
<VersionBadge />
{loginShowVersion && <VersionBadge />}
</div>
</div>
</div>
@@ -867,7 +879,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -881,19 +893,24 @@ export default function LoginPage() {
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header section with logo */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
<img
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
src={withBasePath(effLoginLogo)}
alt={appName}
className="max-w-16 max-h-16 object-contain"
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
style={loginLogoStyle}
/>
</div>
{loginShowHeading && (
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{isAddAccountMode ? t("add_account_title") : appName}
</h1>
)}
{loginShowSubtitle && (
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
</p>
)}
</div>
{/* Form section */}
@@ -1121,7 +1138,7 @@ export default function LoginPage() {
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
@@ -1142,8 +1159,12 @@ export default function LoginPage() {
</div>
</div>
{/* 2FA toggle / field */}
{/* 2FA toggle / field. The manual toggle can be hidden via
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
server has no per-account TOTP (auth delegated to an
external directory); server-required TOTP still shows. */}
{!showTotpField ? (
loginShowTotp ? (
<button
type="button"
onClick={() => {
@@ -1155,6 +1176,7 @@ export default function LoginPage() {
<Shield className="w-3.5 h-3.5" />
{t("totp_toggle")}
</button>
) : null
) : (
<div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
@@ -1241,9 +1263,9 @@ export default function LoginPage() {
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
<LogIn className="w-4 h-4 me-2" />
)}
{t("sign_in_sso")}
</Button>
@@ -1349,7 +1371,7 @@ export default function LoginPage() {
)}
</div>
)}
<VersionBadge />
{loginShowVersion && <VersionBadge />}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -139,7 +139,7 @@ export default function ProHome() {
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
@@ -354,7 +354,7 @@ export default function ProHome() {
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onClose={requestCloseTab}
onDragStateChange={setIsTabDragging}
/>
+115 -36
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useMessages } from 'next-intl';
import {
@@ -21,7 +21,6 @@ import {
Tags,
HardDrive,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
@@ -63,17 +62,19 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
@@ -101,7 +102,6 @@ type Tab =
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
@@ -115,8 +115,14 @@ type Tab =
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
// A plugin that exposes a `settings-section` slot gets its own first-class
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
// discoverable as a menu point rather than buried inside another panel.
type PluginTabId = `plugin:${string}`;
type SettingsTabId = Tab | PluginTabId;
interface TabDef {
id: Tab;
id: SettingsTabId;
label: string;
icon: LucideIcon;
group: TabGroup;
@@ -138,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
@@ -182,6 +187,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.unified_mailbox',
'settings.appearance.all_mail',
'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout',
],
@@ -198,11 +204,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
@@ -215,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
folders: ['settings.folders'],
keywords: ['settings.keywords'],
security: ['settings.security'],
encryption: ['smime'],
content_senders: [
'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content',
@@ -250,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
folders: 'mailbox subscribe',
keywords: 'tags labels colors',
security: 'password 2fa two-factor passkey app password mfa',
encryption: 's/mime smime certificate pgp gpg',
content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact',
@@ -334,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
advanced: 'about_data',
};
function readPersistedTab(): Tab {
function readPersistedTab(): SettingsTabId {
try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
// Used only as the initial tab and intentionally NOT written to
@@ -342,7 +346,7 @@ function readPersistedTab(): Tab {
// default that the regular Settings button lands on. Cleared on mount.
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
if (deepLink) {
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
}
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
@@ -351,7 +355,7 @@ function readPersistedTab(): Tab {
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
return saved as SettingsTabId;
} catch {
return 'appearance';
}
@@ -368,7 +372,15 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
// safe to feed useSyncExternalStore directly.
const pluginSettingsOffers = useSyncExternalStore(
pluginRegistrySubscribe,
() => pluginOffersForSlot('settings-section'),
() => pluginOffersForSlot('settings-section'),
);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
@@ -376,7 +388,7 @@ export default function SettingsPage() {
}, []);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>;
@@ -385,6 +397,12 @@ export default function SettingsPage() {
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// When set, the settings panel is scoped to a shared/group account: a reduced
// tab list and a "Managing: <name>" header. null = the user's own account.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
@@ -488,6 +506,10 @@ export default function SettingsPage() {
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
// Leaving the settings panel drops any shared-account scope so it never
// leaks into the next visit or another session.
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
@@ -611,14 +633,21 @@ export default function SettingsPage() {
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Plugin-contributed settings pages: one entry per active plugin that
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
...pluginSettingsOffers.map((offer): TabDef => ({
id: `plugin:${offer.pluginId}` as PluginTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
icon: Puzzle,
group: 'apps',
})),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
@@ -626,12 +655,28 @@ export default function SettingsPage() {
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
// is hardwired to the active account). Gated on both the per-account
// capability and the session-level support/feature flags.
const scopedTabIds: Tab[] = managedAccount
? ([
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
].filter(Boolean) as Tab[])
: [];
const visibleTabs = managedAccountId
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
: tabs;
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
items: visibleTabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -639,12 +684,12 @@ export default function SettingsPage() {
const matchesQuery = (tab: TabDef) => {
if (!trimmedQuery) return true;
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
};
const subResultsForTab = (tabId: Tab): SubResult[] => {
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId] ?? [];
const list = tabSubResults[tabId as Tab] ?? [];
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
@@ -659,11 +704,15 @@ export default function SettingsPage() {
.filter((g) => g.items.length > 0)
: groupedTabs;
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: SettingsTabId = isActiveVisible
? activeTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: Tab) => {
const handleTabSelect = (tabId: SettingsTabId) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
@@ -671,15 +720,31 @@ export default function SettingsPage() {
}
};
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{managedAccountId && managedAccount && (
<button
type="button"
onClick={() => {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ms-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
)}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
@@ -695,10 +760,17 @@ export default function SettingsPage() {
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
@@ -706,6 +778,13 @@ export default function SettingsPage() {
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
{effectiveActiveTab.startsWith('plugin:') && (
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice('plugin:'.length)}
slot="settings-section"
/>
)}
</>
);
@@ -772,7 +851,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-9 pr-9 h-10"
className="ps-9 pe-9 h-10"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -820,7 +899,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
>
<span className="truncate">{sub.label}</span>
</button>
@@ -884,7 +963,7 @@ export default function SettingsPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
"border-e border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
@@ -897,7 +976,7 @@ export default function SettingsPage() {
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t('back_to_mail')}
</Button>
</div>
@@ -912,7 +991,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-8 pr-8 h-9 text-sm"
className="ps-8 pe-8 h-9 text-sm"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -949,7 +1028,7 @@ export default function SettingsPage() {
<button
onClick={() => handleTabSelect(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -965,7 +1044,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
</button>
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<input
-20
View File
@@ -32,7 +32,6 @@ export function DashboardTab() {
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
@@ -82,15 +81,6 @@ export function DashboardTab() {
}
}
if (configData?.jmapServerUrl) {
try {
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
@@ -128,16 +118,6 @@ export function DashboardTab() {
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
+4 -4
View File
@@ -96,10 +96,10 @@ export function LogsTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
+2 -2
View File
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
{field.required && <span className="text-destructive ms-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
+73 -6
View File
@@ -26,6 +26,10 @@ export function PluginsTab() {
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Bundle held back by the pattern scanner, awaiting an explicit admin decision.
const [pendingScan, setPendingScan] = useState<
{ file: File; findings: Array<{ file: string; patterns: string[] }> } | null
>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
@@ -104,15 +108,17 @@ export function PluginsTab() {
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// Upload a bundle. The scanner may refuse it for containing patterns that are
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
// server returns `canOverride` and we hold the file so the admin can review
// the findings and decide. `override` re-posts the same file with consent.
async function uploadPlugin(file: File, override: boolean) {
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
if (override) formData.append('overrideWarnings', 'true');
try {
const res = await apiFetch('/api/admin/plugins', {
@@ -122,13 +128,22 @@ export function PluginsTab() {
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
setPendingScan(null);
const accepted = data.findings?.length
? `${data.findings.length} scanner finding(s) accepted and logged`
: '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` });
await fetchPlugins();
} else if (data.canOverride && Array.isArray(data.findings) && !override) {
// Hold the file rather than the error: the admin needs to see WHAT
// tripped, in WHICH file, before deciding.
setPendingScan({ file, findings: data.findings });
} else {
setPendingScan(null);
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setPendingScan(null);
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
@@ -136,6 +151,13 @@ export function PluginsTab() {
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPendingScan(null);
await uploadPlugin(file, false);
}
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
@@ -302,6 +324,51 @@ export function PluginsTab() {
</div>
)}
{pendingScan && (
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
</p>
<p className="text-xs text-muted-foreground">
These patterns can indicate malicious code, but they also appear in legitimate
minified crypto libraries such as openpgp.js and pkijs. Review the findings before
proceeding installing anyway is recorded in the audit log.
</p>
</div>
</div>
<ul className="space-y-1">
{pendingScan.findings.map(f => (
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
<span className="text-foreground">{f.file}</span>
<span className="text-muted-foreground"> {f.patterns.join(', ')}</span>
</li>
))}
</ul>
<div className="flex items-center gap-2">
<button
onClick={() => uploadPlugin(pendingScan.file, true)}
disabled={uploading}
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
>
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
Install anyway
</button>
<button
onClick={() => { setPendingScan(null); setMessage(null); }}
disabled={uploading}
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
>
Cancel
</button>
</div>
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
+8 -1
View File
@@ -6,13 +6,16 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
@@ -21,6 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
};
const RESTRICTABLE_SETTINGS = [
+2 -2
View File
@@ -117,7 +117,7 @@ export function SettingsTab() {
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
@@ -145,7 +145,7 @@ export function SettingsTab() {
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
+6 -5
View File
@@ -118,9 +118,10 @@ export function TelemetryTab() {
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
@@ -138,8 +139,8 @@ export function TelemetryTab() {
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
+4 -5
View File
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
{ id: 'builtin-solarized', name: 'Solarized' },
];
// Derive from the single source of truth so newly added built-in themes show
// up here automatically (was previously a hardcoded subset — see #496).
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
interface ThemeEntry {
id: string;
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="current-password"
/>
</div>
+12 -12
View File
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
type="button"
onClick={handleClick}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
+3 -3
View File
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowManifest(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowSource(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
+1 -1
View File
@@ -44,7 +44,7 @@ export default function GlobalError({
onClick={reset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
Try again
</button>
</div>
+22 -2
View File
@@ -1,9 +1,16 @@
import type { Metadata, Viewport } from "next";
import { getLocaleDirection } from "@/i18n/direction";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { FaviconBadge } from "@/components/favicon-badge";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
import "../globals.css";
@@ -38,7 +45,19 @@ export const viewport: Viewport = {
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// The <head> favicon must honor per-domain branding, exactly like
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
// request host and prefer its override; fall back to the global
// admin/env/default value when the host has no favicon override (#585).
const host = pickRequestHost(await headers());
const domainOverride = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
).faviconUrl;
const faviconUrl =
domainOverride && domainOverride.length > 0
? domainOverride
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// Localize the <head> description to match the UI language; a hardcoded
// English description is another signal that makes Chrome offer to
// "translate this page". Resolve the locale from the request path, since this
@@ -76,7 +95,7 @@ export default async function RootLayout({
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return (
<html lang={locale} suppressHydrationWarning>
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
@@ -114,6 +133,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ServiceWorkerRegistration />
<FaviconBadge />
{children}
</body>
</html>
+21 -6
View File
@@ -38,6 +38,7 @@ interface WizardConfig {
// Security
sessionSecret: string;
settingsSyncEnabled: boolean;
telemetryEnabled: boolean;
// Logging
logFormat: 'text' | 'json';
logLevel: 'error' | 'warn' | 'info' | 'debug';
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
oauthIssuerUrl: '',
sessionSecret: '',
settingsSyncEnabled: true,
telemetryEnabled: false,
logFormat: 'text',
logLevel: 'info',
faviconUrl: '',
@@ -778,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
</p>
</div>
</div>
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmedNonJmap}
@@ -868,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
)}
{hasRowErrors && (
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
{rowErrors.map((err, i) => (
<li key={i}>{err}</li>
))}
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
e.preventDefault();
setSubmitting(true);
try {
const values: Partial<WizardConfig> = {
// telemetryConsent is persisted to the telemetry state file by the API,
// not to admin config - see app/api/setup/step/route.ts.
const values: Record<string, unknown> = {
settingsSyncEnabled: config.settingsSyncEnabled,
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
};
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
await onNext('security', values);
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
hint="Stores user preferences server-side, encrypted with the session secret."
disabled={!config.sessionSecret}
/>
<div className="rounded-md border border-border bg-muted/20 p-3">
<Toggle
checked={config.telemetryEnabled}
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
label="Send anonymous usage stats to help improve Bulwark"
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
/>
</div>
<Footer>
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
<PrimaryButton type="submit" disabled={submitting}>
@@ -1370,7 +1384,7 @@ function BrandingAsset({
</div>
{showUrlField && (
<div className="mt-3 pl-[4.75rem]">
<div className="mt-3 ps-[4.75rem]">
<Input
value={value}
onChange={onChange}
@@ -1380,7 +1394,7 @@ function BrandingAsset({
)}
{uploadError && (
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
)}
</div>
);
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
: 'Off'
}
/>
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
</SummaryGroup>
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
@@ -1586,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
return (
<div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
{value || <span className="text-muted-foreground italic">-</span>}
</span>
</div>
@@ -0,0 +1,15 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
// approval) is enforced host-side before this route is ever framed; the page
// itself carries no extra privilege.
//
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
// Next's injected hydration/chunk scripts.
export const dynamic = 'force-dynamic';
export default function PrivilegedPluginSandboxPage() {
return <SandboxRuntime />;
}
+39 -8
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
/**
* POST /api/account/stalwart/jmap
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
const body = await request.text();
const response = await fetch(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body,
const directUrl = `${creds.serverUrl}/jmap/`;
let response = await postJmap(directUrl, creds.authHeader, body);
if (response.status === 404) {
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
// (path prefix, non-Stalwart URL layout). Resolve the session's
// advertised apiUrl on the same host and retry once.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
if (apiUrl && apiUrl !== directUrl) {
response = await postJmap(apiUrl, creds.authHeader, body);
}
}
if (!response.ok) {
logger.warn('Stalwart JMAP passthrough upstream error', {
status: response.status,
serverUrl: creds.serverUrl,
});
}
const responseText = await response.text();
return new NextResponse(responseText, {
@@ -38,9 +51,27 @@ export async function POST(request: NextRequest) {
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
});
} catch (error) {
if (error instanceof JmapRedirectError) {
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
return NextResponse.json({ error: error.message }, { status: 502 });
}
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('Stalwart JMAP passthrough error', {
error: error instanceof Error ? error.message : 'Unknown',
error: err?.message ?? 'Unknown',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
// The server this process failed to reach is the user's own mail server,
// so the reason is worth surfacing: an opaque 500 leaves operators with
// nothing to act on.
if (err?.cause?.code) {
return NextResponse.json(
{ error: `Cannot reach the JMAP server (${err.cause.code})` },
{ status: 502 },
);
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+2 -1
View File
@@ -19,7 +19,7 @@ import {
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions,
entrypoint,
enabled: existingPlugin?.enabled ?? true,
+51 -6
View File
@@ -158,15 +158,47 @@ export async function POST(request: NextRequest) {
}
const code = await entryFile.async('string');
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
// Security: scan for dangerous JS patterns across EVERY script in the
// bundle, not just the entrypoint - a second .js file was previously never
// looked at.
//
// The result is a reviewable finding rather than an unconditional reject.
// Minified crypto libraries (openpgp.js, pkijs) legitimately contain these
// patterns, so a hard block makes S/MIME and PGP plugins uninstallable.
// This route is already admin-authenticated, so the scan is defence in
// depth against an accidental or compromised upload, not a trust boundary:
// an admin may proceed with `overrideWarnings`, and the override is
// recorded in the audit log with the exact findings.
const findings: Array<{ file: string; patterns: string[] }> = [];
for (const [filePath, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
if (ext !== '.js' && ext !== '.mjs') continue;
const source = filePath === root + (manifest.entrypoint as string)
? code
: await entry.async('string');
const hits: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
if (pattern.test(source)) hits.push(label);
pattern.lastIndex = 0;
}
if (warnings.length > 0) {
if (hits.length > 0) {
findings.push({ file: filePath.slice(root.length), patterns: hits });
}
}
const overrideWarnings = formData.get('overrideWarnings') === 'true';
if (findings.length > 0 && !overrideWarnings) {
const summary = findings
.map(f => `${f.file}: ${f.patterns.join(', ')}`)
.join('; ');
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{
error: `Plugin rejected: ${summary}. Review the bundle; if these are expected `
+ `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`,
findings,
canOverride: true,
},
{ status: 400 },
);
}
@@ -183,6 +215,7 @@ export async function POST(request: NextRequest) {
author: manifest.author as string,
description: (manifest.description as string) || '',
type: manifest.type as string,
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
@@ -211,8 +244,20 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
if (findings.length > 0) {
// Record WHAT was waved through, not merely that an override happened -
// otherwise the audit trail can't answer "which patterns did we accept?".
await auditLog(
'plugin.install.scan_override',
{ id: plugin.id, version: plugin.version, findings },
ip,
);
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
}
return NextResponse.json({ plugin });
// Echo accepted findings back so the admin UI can confirm exactly what was
// waved through, rather than reporting a bare success.
return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+3 -2
View File
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
* ImpersonationReconciler, GH #646) and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
headers: { Location: '/?impersonated=1' },
});
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
export async function POST(request: NextRequest) {
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
});
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
}
const pending = decryptPayload(pendingCookie);
cookieStore.delete(SSO_PENDING_COOKIE);
if (!pending) {
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
}
// Only honor pending sessions that were started for the reauth purpose, so
// a normal login code can't be redirected into setting a pairing proof.
if (pending.purpose !== 'reauth') {
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
}
if (pending.state !== state) {
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
}
const createdAt = pending.created_at as number;
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
await setPairReauth();
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
}
}
+22 -2
View File
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
@@ -30,8 +31,14 @@ export async function POST(request: NextRequest) {
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
purpose: rawPurpose,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
// short-lived pairing re-auth proof instead of logging the user in again.
const isReauth = rawPurpose === 'reauth';
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
@@ -85,6 +92,7 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
};
const encrypted = encryptPayload(pendingData);
@@ -95,8 +103,12 @@ export async function POST(request: NextRequest) {
maxAge: SSO_PENDING_MAX_AGE,
});
// Build authorize URL
const authUrl = new URL(metadata.authorization_endpoint);
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
// token exchange and refresh keep using the canonical discovered endpoints.
const authorizeOverride =
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
@@ -109,6 +121,14 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('ui_locales', locale);
}
// Force a fresh credential entry for step-up re-auth. prompt=login and
// max_age=0 both ask the IdP to re-authenticate even if it has an active
// session; honoring them depends on the IdP supporting these OIDC params.
if (isReauth) {
authUrl.searchParams.set('prompt', 'login');
authUrl.searchParams.set('max_age', '0');
}
return NextResponse.json({
authorize_url: authUrl.toString(),
state,
+7
View File
@@ -82,10 +82,17 @@ export async function PUT(request: NextRequest) {
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
// Drop the refresh token only when the server definitively rejected it
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
// the cookie lets the session resume once the server is back.
const status = tokenResponse.status;
if (status === 400 || status === 401 || status === 403) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
}
const tokens = await tokenResponse.json();
+155 -145
View File
@@ -1,8 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
* Exchange a password + (optional) TOTP code for OAuth tokens.
*
* This allows 2FA users who log in with basic auth + TOTP to upgrade
* to token-based auth, avoiding session expiry when the TOTP rotates.
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
* splits the secret on `$`, so any TOTP appended to the password is verified
* verbatim against the password hash and fails. The MFA token must instead be
* supplied as a distinct field through the structured login endpoint.
*
* Tries three strategies:
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
* 2. ROPC grant without client_id
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
* This route drives that flow server-side (avoiding browser CORS against the
* mail server, same as OAuth discovery):
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* receiving a short-lived authorization `clientCode`.
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
* the code (with PKCE) for access/refresh tokens.
*
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
* the (30s) code in every request.
*/
async function tryTokenRequest(
tokenEndpoint: string,
params: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
// Fallback OAuth client id used when no client is configured. Stalwart accepts
// any client id unless `require_client_registration` is enabled (default off);
// when it is enabled the admin must configure `oauthClientId` with this
// redirect URI registered.
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
interface LoginResult {
type?: string;
// The response keeps snake_case: only the LoginResponse variant *tags* are
// camelCased server-side, not the struct fields (the request fields are).
client_code?: string;
}
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
async function attemptLogin(
upstreamUrl: string,
username: string,
password: string,
totp: string | undefined,
redirectUri: string,
slot: number,
serverId: string | null,
): Promise<NextResponse> {
const base = trimUrl(upstreamUrl);
// Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID
|| DEFAULT_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|| '';
// PKCE proves the token exchange originates from the same client that
// initiated the login, so no client secret is required for public clients.
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
try {
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
const response = await fetch(tokenEndpoint, {
const loginResponse = await fetch(`${base}/api/auth`, {
method: 'POST',
headers,
body: params.toString(),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: username,
accountSecret: password,
...(totp ? { mfaToken: totp } : {}),
clientId,
redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
if (!loginResponse.ok) {
const detail = (await loginResponse.text()).substring(0, 500);
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
// A 404 means the server predates the structured login endpoint; let the
// caller fall back to the legacy basic-auth path.
return NextResponse.json(
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
{ status: loginResponse.status === 404 ? 404 : 502 },
);
}
const tokens = await response.json();
if (!tokens.access_token) {
return { ok: false, status: 502, error: 'Response missing access_token' };
}
return { ok: true, tokens };
login = await loginResponse.json();
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
}
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
// Admin-trusted callers (matched server entry or configured JMAP server URL)
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
// go through the SSRF validator regardless of the setting.
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
if (metadata?.token_endpoint) return metadata.token_endpoint;
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'failure':
default:
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
}
// 2. Try common Stalwart token endpoint paths directly
const candidates = [
`${serverUrl}/auth/token`,
`${serverUrl}/api/oauth/token`,
];
if (!login.client_code) {
logger.warn('TOTP login: authenticated response missing client_code');
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
}
for (const url of candidates) {
// Step 2: exchange the authorization code for tokens.
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
if (clientSecret) tokenParams.set('client_secret', clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
if (probe.status !== 404 && probe.status !== 405) {
return url;
}
} catch {
// Network error - endpoint not reachable
}
const tokenResponse = await fetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const detail = (await tokenResponse.text()).substring(0, 500);
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
}
return null;
tokens = await tokenResponse.json();
} catch (err) {
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
}
if (!tokens.access_token) {
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
}
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
slot,
serverId,
);
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
// Pin the upstream URL to a configured JMAP server. The list of allowed
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string;
let resolvedServerId: string | null = null;
let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) {
upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id;
adminTrusted = true;
} else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
adminTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL');
logger.warn('TOTP login: rejected non-public server URL');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
upstreamUrl = serverUrl;
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
// The redirect URI must be identical in the login and token-exchange steps,
// and (when require_client_registration is on) registered for the client.
// Prefer the browser-supplied callback URL the OAuth client already uses;
// fall back to the upstream URL so the two steps still agree.
const redirectUri =
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
? bodyRedirectUri
: trimUrl(upstreamUrl);
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function attemptAllStrategies(
tokenEndpoint: string,
serverUrl: string,
username: string,
password: string,
slot: number,
serverId: string | null,
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
// Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
// Strategy 1: ROPC with client_id (if configured)
if (clientId) {
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
if (clientSecret) params.set('client_secret', clientSecret);
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
}
// Strategy 2: ROPC without client_id
{
const params = new URLSearchParams({ grant_type: 'password', username, password });
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
}
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
{
const params = new URLSearchParams({ grant_type: 'password' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
}
// Strategy 4: client_credentials with Basic Auth (last resort)
{
const params = new URLSearchParams({ grant_type: 'client_credentials' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot, serverId);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
}
logger.warn('TOTP token exchange: all strategies failed', { attempts });
return NextResponse.json({
error: 'token_exchange_failed',
detail: 'All token exchange strategies failed',
attempts,
}, { status: 502 });
}
async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number,
+296
View File
@@ -0,0 +1,296 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';
/**
* POST /api/calendar-agenda
*
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
* account from the stored Stalwart auth context, queries upcoming
* CalendarEvents over JMAP, expands recurring series server-side, and returns
* a slim, structured-cloneable agenda the plugin can render directly.
*
* Credentials never leave the server the sandboxed plugin only ever sees
* the resulting agenda DTOs.
*
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
*/
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
// agenda actually needs (start/recurrence/display fields).
const EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;
interface AgendaEvent {
id: string;
uid: string | null;
title: string;
start: string;
end: string;
allDay: boolean;
status: string | null;
color: string | null;
location: string | null;
calendarId: string | null;
}
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
// getEventEndDate. We can't import that module here because it transitively
// pulls in a "use client" calendar component (which throws at load on the
// server). Logic mirrors the originals.
function parseDurationMinutes(duration: string | undefined): number {
if (!duration) return 0;
let total = 0;
const week = duration.match(/(\d+)W/);
const day = duration.match(/(\d+)D/);
const hour = duration.match(/(\d+)H/);
const min = duration.match(/(\d+)M/);
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
if (day) total += parseInt(day[1], 10) * 24 * 60;
if (hour) total += parseInt(hour[1], 10) * 60;
if (min) total += parseInt(min[1], 10);
return total;
}
function eventStart(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start as string);
}
function eventEnd(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcEnd) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = eventStart(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
}
function firstLocationName(event: Partial<CalendarEvent>): string | null {
const locations = event.locations;
if (!locations || typeof locations !== 'object') return null;
for (const loc of Object.values(locations)) {
const name = (loc as { name?: unknown })?.name;
if (typeof name === 'string' && name.trim()) return name.trim();
}
return null;
}
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
const ids = event.calendarIds;
if (!ids || typeof ids !== 'object') return null;
const keys = Object.keys(ids);
return keys.length > 0 ? keys[0] : null;
}
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { days?: unknown; limit?: unknown } = {};
try {
body = await request.json();
} catch {
/* empty body is fine */
}
const days = clampInt(body.days, 1, 90, 7);
const limit = clampInt(body.limit, 1, 200, 50);
// ── Resolve the calendar account from the JMAP session ──
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
// We deliberately avoid /.well-known/jmap: it 301s to the server's
// configured public hostname, which the host process may not be able to
// resolve (the browser client rewrites those URLs back to the origin for
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
if (!accountId) {
// No calendar account for this user — return an empty agenda, not an error.
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
}
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
using.push('urn:ietf:params:jmap:principals:owner');
}
// Send method calls to the session's apiUrl rebased onto serverUrl's host
// — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
const now = new Date();
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
// Expand from the start of today so all-day / already-running events still
// show in the agenda.
const windowStart = new Date(now);
windowStart.setHours(0, 0, 0, 0);
// ── 1) Query event IDs in range + load calendars (colours) ──
const queryReq = {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
// Mirror the app's calendar store: an { after, before } window lets
// Stalwart evaluate recurrence so masters with occurrences in range
// are returned (a `before`-only filter can drop unbounded series).
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
};
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (!queryResp) {
const err = findResponse(queryRes, 'error', '0');
return NextResponse.json(
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calColors = new Map<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(cal.id, {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch full event objects (batched) ──
const raw: Array<Record<string, unknown>> = [];
const BATCH = 100;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
const getRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
],
});
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
}
// ── 3) Normalize + expand recurrences server-side ──
const normalized = raw
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
const expanded = expandRecurringEvents(
normalized,
windowStart.toISOString(),
horizon.toISOString(),
);
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
const agenda: AgendaEvent[] = expanded
.filter((e) => eventEnd(e).getTime() >= now.getTime())
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
.slice(0, limit)
.map((e) => {
const calId = firstCalendarId(e);
const cal = calId ? calColors.get(calId) : undefined;
return {
id: String(e.id ?? ''),
uid: e.uid ?? null,
title: (e.title ?? '').trim() || '(no title)',
start: eventStart(e).toISOString(),
end: eventEnd(e).toISOString(),
allDay: !!e.showWithoutTime,
status: e.status ?? null,
color: e.color || cal?.color || null,
location: firstLocationName(e),
calendarId: calId,
};
});
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('Calendar agenda error', {
error: err?.message ?? 'Unknown',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.round(n)));
}
async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
): Promise<unknown> {
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
return res.json();
}
function findResponse(
res: unknown,
name: string,
callId: string,
): Record<string, unknown> | null {
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
if (!Array.isArray(responses)) return null;
for (const entry of responses) {
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
return entry[1] as Record<string, unknown>;
}
}
return null;
}
+6
View File
@@ -74,6 +74,12 @@ export async function GET(request: NextRequest) {
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
File diff suppressed because one or more lines are too long
+3
View File
@@ -37,6 +37,9 @@ export async function GET() {
author: p.author,
description: p.description,
type: p.type,
// Requested execution tier; clients gate the same-origin privileged
// sandbox on this (plus signature + approval + consent).
tier: p.tier,
permissions: p.permissions,
entrypoint: p.entrypoint,
// Policy is the canonical source for force-enable. The per-plugin field
+6 -4
View File
@@ -59,10 +59,12 @@ export async function GET(
domainOverrides.pwaIconUrl ||
domainOverrides.faviconUrl ||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
// Fall back to the built-in default so this endpoint ALWAYS returns an app
// icon (custom if configured, else the bundled default). This lets callers
// that can't run the custom-vs-default check themselves - notably the
// service worker's notifications - use a single stable URL.
`/icon-${size}x${size}.png`;
const pngHeaders = {
'Content-Type': 'image/png',
+26 -1
View File
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const valuesObj = { ...(values as Record<string, unknown>) };
// Telemetry consent lives in the telemetry state file, not admin config, so
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
if (step === 'security' && 'telemetryConsent' in valuesObj) {
const consent = valuesObj.telemetryConsent;
delete valuesObj.telemetryConsent;
if (consent !== 'on' && consent !== 'off') {
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
}
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
const { source } = await effectiveConsent();
if (source !== 'env') {
const tstate = await loadState();
tstate.consent = consent;
if (consent === 'on' && !tstate.consentedAt) {
tstate.consentedAt = new Date().toISOString();
}
await saveState(tstate);
await reschedule();
}
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
for (const [key, value] of Object.entries(valuesObj)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
+180
View File
@@ -0,0 +1,180 @@
/**
* S/MIME certificate enrolment (`C-08`, server half).
*
* The plugin generates a keypair in the browser and sends only a CSR here. The
* private key never leaves the device this route never sees it and has no way
* to ask for it.
*
* What this route exists to decide: **which addresses the issued certificate is
* allowed to assert.** That question cannot be answered in the browser, and it
* must not be answered by the CSR a CSR is a self-assertion, and honouring its
* `subjectAltName` would let anyone mint a certificate for any address, which is
* indistinguishable from having no CA at all.
*/
import { NextResponse } from 'next/server';
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { CaError, getCaProvider } from '@/lib/smime-ca';
export const runtime = 'nodejs';
const MAX_CSR_BYTES = 8 * 1024;
export async function POST(request: Request) {
const provider = getCaProvider();
if (!provider) {
return NextResponse.json(
{ error: 'S/MIME enrolment is not configured on this server' },
{ status: 503 },
);
}
let body: { csrPem?: unknown; slot?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const csrPem = typeof body.csrPem === 'string' ? body.csrPem.trim() : '';
if (!csrPem) {
return NextResponse.json({ error: 'csrPem is required' }, { status: 400 });
}
if (csrPem.length > MAX_CSR_BYTES) {
return NextResponse.json({ error: 'csrPem too large' }, { status: 413 });
}
// Shape check only. This is not a security control — see the module comment on
// why the CSR's contents are not trusted regardless of what they contain.
if (!/^-----BEGIN (NEW )?CERTIFICATE REQUEST-----[\s\S]+-----END (NEW )?CERTIFICATE REQUEST-----$/
.test(csrPem)) {
return NextResponse.json({ error: 'csrPem is not a PEM PKCS#10 request' }, { status: 400 });
}
const slot = Number.isInteger(body.slot) ? (body.slot as number) : 0;
if (slot < 0 || slot > 9) {
return NextResponse.json({ error: 'invalid slot' }, { status: 400 });
}
// The auth context is an encrypted, server-minted cookie, so `username` cannot
// be forged by the client. It still isn't sufficient on its own — see below.
const auth = await readStalwartAuthContext(slot);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
let identity: { addresses: string[]; displayName?: string };
try {
identity = await resolveIdentity(auth.serverUrl, auth.authHeader);
} catch (cause) {
console.error('[smime-enroll] identity resolution failed:', cause);
return NextResponse.json(
{ error: 'could not confirm your sending addresses with the mail server' },
{ status: 502 },
);
}
if (identity.addresses.length === 0) {
// An authenticated principal with no sending identity — an admin-only
// account, or a mailbox with submission disabled. Refuse rather than falling
// back to the cookie's username, which would issue a certificate for an
// address the mail server will not actually let this account send from.
return NextResponse.json(
{ error: 'this account has no sending address, so no certificate can be issued for it' },
{ status: 403 },
);
}
try {
const issued = await provider.enroll({
csrPem,
addresses: identity.addresses,
commonName: identity.displayName || identity.addresses[0],
});
// Audit before returning. A certificate that exists with no record of who
// asked for it is the thing you most want during an incident.
console.info(
`[smime-enroll] issued serial=${issued.serialNumber} ca=${provider.id} `
+ `account=${auth.username} addresses=${identity.addresses.join(',')}`,
);
return NextResponse.json({
certificatePem: issued.certificatePem,
chainPem: issued.chainPem,
serialNumber: issued.serialNumber,
issuerDn: issued.issuerDn,
notAfter: issued.notAfter,
addresses: identity.addresses,
});
} catch (error) {
if (error instanceof CaError) {
console.error(`[smime-enroll] CA error for ${auth.username}:`, error.message, error.cause);
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[smime-enroll] unexpected error:', error);
return NextResponse.json({ error: 'enrolment failed' }, { status: 500 });
}
}
/**
* Ask Stalwart which addresses this session may send from, via `Identity/get`.
*
* This is deliberately not derived from the auth cookie's `username`. The right
* authority for "may this person have a signing certificate for this address" is
* the mail server that already decides "may this person send from this address"
* anything else invents a second, weaker answer to a question already settled.
*
* It also handles the cases the cookie cannot: an alias the account legitimately
* sends as (which should be on the certificate) and an administrative principal
* with no mailbox at all (which should get no certificate). The latter is not
* hypothetical here `admin@sandbox.vnc.de` authenticates successfully and has
* no mail session, and trusting the cookie would have issued it a certificate.
*/
async function resolveIdentity(
serverUrl: string,
authHeader: string,
): Promise<{ addresses: string[]; displayName?: string }> {
const session = await fetchJmapSession(serverUrl, authHeader);
if (!session) throw new Error('no JMAP session');
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!accountId) throw new Error('no primary mail account');
const apiUrl = rebaseApiUrl(session, serverUrl);
if (!apiUrl) throw new Error('session advertises no usable apiUrl');
const res = await postJmap(apiUrl, authHeader, JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'],
methodCalls: [['Identity/get', { accountId }, '0']],
}));
if (!res.ok) throw new Error(`Identity/get returned ${res.status}`);
const payload = await res.json() as {
methodResponses?: [string, { list?: { email?: string; name?: string }[] }, string][];
};
const first = payload.methodResponses?.[0];
if (!first || first[0] !== 'Identity/get') {
throw new Error('Identity/get failed');
}
const seen = new Set<string>();
const addresses: string[] = [];
let displayName: string | undefined;
for (const entry of first[1]?.list ?? []) {
const email = typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : '';
// Stalwart can report a wildcard identity (`*@domain`) for accounts allowed
// to send as anything in a domain. That is a real capability, but it is not
// an address and must never reach a certificate — a `rfc822Name` SAN of
// `*@vnc.de` is either rejected by clients or, worse, honoured.
if (!email || email.includes('*') || !email.includes('@')) continue;
if (seen.has(email)) continue;
seen.add(email);
addresses.push(email);
if (!displayName && typeof entry.name === 'string' && entry.name.trim()) {
displayName = entry.name.trim();
}
}
return { addresses, displayName };
}
+57 -5
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:where(.dark, .dark *));
@@ -558,6 +559,40 @@ body {
overscroll-behavior: none;
}
/* Shake animation (for rejected input) */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.4s ease-in-out;
}
/* Fade in animation (for popovers) */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
/* Slide in from right animation (for mobile views) */
@keyframes slide-in-from-right {
from {
@@ -660,13 +695,13 @@ body {
.tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
.tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
@@ -675,8 +710,8 @@ body {
}
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
border-inline-start: 3px solid var(--color-border);
padding-inline-start: 1rem;
margin: 0.5rem 0;
color: var(--color-muted-foreground);
}
@@ -719,7 +754,7 @@ body {
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
float: inline-start;
color: var(--color-muted-foreground);
pointer-events: none;
height: 0;
@@ -808,3 +843,20 @@ body {
border-radius: 8px;
animation: settings-search-pulse 1.6s ease-in-out forwards;
}
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
and panel-collapse affordances point the correct way in right-to-left layouts.
lucide-react emits a `lucide-<name>` class per icon, so we target the
directional ones only vertical chevrons (up/down) are intentionally left. */
[dir="rtl"] .lucide-chevron-left,
[dir="rtl"] .lucide-chevron-right,
[dir="rtl"] .lucide-chevrons-left,
[dir="rtl"] .lucide-chevrons-right,
[dir="rtl"] .lucide-arrow-left,
[dir="rtl"] .lucide-arrow-right,
[dir="rtl"] .lucide-arrow-big-left,
[dir="rtl"] .lucide-arrow-big-right,
[dir="rtl"] .lucide-panel-left,
[dir="rtl"] .lucide-panel-right {
transform: scaleX(-1);
}
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { FaviconBadge } from '@/components/favicon-badge';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-favicon-badge', () => ({
useFaviconBadge: vi.fn(),
}));
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: patch.id,
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,
},
...patch,
} as Mailbox;
}
const initialMailboxes = useEmailStore.getState().mailboxes;
beforeEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
});
afterEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
vi.clearAllMocks();
});
describe('FaviconBadge', () => {
it('badges the unread count of the primary inbox', () => {
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
const { container } = render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
expect(container.firstChild).toBeNull(); // renders no markup
});
it('disables the badge when the setting is off', () => {
useSettingsStore.setState({ faviconUnreadBadge: false });
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
});
it('ignores a shared inbox, even when it sorts first', () => {
// Shared and group inboxes ship in the same `mailboxes` array. A plain
// `role === 'inbox'` lookup would badge somebody else's inbox on a
// delegated setup, so the store's canonical `!isShared` filter is required.
useEmailStore.setState({
mailboxes: [
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
});
it('badges zero when there is no inbox yet', () => {
useEmailStore.setState({ mailboxes: [] });
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
});
});
+10 -5
View File
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
)}>
{formatDateHeader(group.date)}
</span>
<span className="text-xs text-muted-foreground ml-2">
<span className="text-xs text-muted-foreground ms-2">
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
@@ -147,8 +147,10 @@ export function CalendarAgendaView({
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration);
const end = getEventEndDate(ev);
// iTIP CANCEL marks the attendee's copy with status "cancelled"
// instead of deleting it (#572).
const isCancelled = ev.status === "cancelled";
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
@@ -160,7 +162,10 @@ export function CalendarAgendaView({
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
className={cn(
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
isCancelled && "opacity-60"
)}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
@@ -182,7 +187,7 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
{ev.title || t("events.no_title")}
</div>
{locationName && (
+4 -4
View File
@@ -218,7 +218,7 @@ export function CalendarDayView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -231,7 +231,7 @@ export function CalendarDayView({
</div>
<div
className="flex-1 relative border-l border-border"
className="flex-1 relative border-s border-border"
role="row"
aria-label={t("views.day")}
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
@@ -312,7 +312,7 @@ export function CalendarDayView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -346,7 +346,7 @@ export function CalendarDayView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
+51 -40
View File
@@ -1,19 +1,18 @@
"use client";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
} from "date-fns";
import { useTranslations } from "next-intl";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -47,16 +46,28 @@ export function CalendarMonthView({
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
// On mobile the month view collapses events to dots unless the user opted
// into full entries via "Show time in month view" (#666).
const showChips = !isMobile || showTimeInMonthView;
const overlayTop = isMobile ? 34 : 30;
const rowHeight = isMobile ? 18 : 22;
const chipHeight = rowHeight - 2;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatFullDate,
} = useCalendarLocale();
const days = useMemo(() => {
const monthStart = startOfMonth(selectedDate);
const monthEnd = endOfMonth(selectedDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [selectedDate, weekStart]);
const days = useMemo(
() => getMonthGridDays(selectedDate),
[selectedDate, getMonthGridDays],
);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -83,10 +94,6 @@ export function CalendarMonthView({
return map;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const weeks = useMemo(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
@@ -141,11 +148,11 @@ export function CalendarMonthView({
}, [t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
@@ -158,15 +165,15 @@ export function CalendarMonthView({
<div key={wi} className={cn(
"relative flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
)} role="row" style={showChips ? { minHeight: Math.max(isMobile ? 52 : 100, overlayTop + 4 + rowCount * rowHeight + 8) } : undefined}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, selectedDate);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
const fullDateLabel = formatFullDate(day);
return (
<div
@@ -181,7 +188,7 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
!inMonth && "bg-muted/30",
"hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
@@ -199,10 +206,10 @@ export function CalendarMonthView({
inMonth && !selected && !today && "font-medium"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
</span>
</div>
{isMobile ? (
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
@@ -219,7 +226,7 @@ export function CalendarMonthView({
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
@@ -232,25 +239,28 @@ export function CalendarMonthView({
})}
</div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
{showChips && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
top: previewRow * rowHeight,
height: chipHeight,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
className={cn(
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
)}
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
@@ -260,8 +270,8 @@ export function CalendarMonthView({
);
})()}
{!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{showChips && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{segments.map((segment) => {
const calId = getPrimaryCalendarId(segment.event);
return (
@@ -271,8 +281,8 @@ export function CalendarMonthView({
style={{
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
top: segment.row * 22,
height: 20,
top: segment.row * rowHeight,
height: chipHeight,
}}
>
<EventCard
@@ -286,6 +296,7 @@ export function CalendarMonthView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
</div>
);
+24 -5
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
@@ -219,6 +220,16 @@ export function CalendarSidebarPanel({
}
};
const handleSetDefault = async (calendarId: string) => {
if (!client) return;
try {
await setDefaultCalendar(client, calendarId);
toast.success(tMgmt('default_updated'));
} catch {
toast.error(tMgmt('error_default'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
const renderCalendarItem = (cal: Calendar) => {
@@ -306,12 +317,13 @@ export function CalendarSidebarPanel({
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -330,6 +342,13 @@ export function CalendarSidebarPanel({
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={tMgmt('set_default')}
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
@@ -378,7 +397,7 @@ export function CalendarSidebarPanel({
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
{pendingTaskCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
)}
{overdueTaskCount > 0 && (
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
@@ -418,7 +437,7 @@ export function CalendarSidebarPanel({
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
@@ -426,7 +445,7 @@ export function CalendarSidebarPanel({
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
<div className="mt-1 ps-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
+32 -30
View File
@@ -1,13 +1,14 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -50,7 +51,14 @@ export function CalendarToolbar({
onMenuClick,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
const {
weekStartsOn,
formatMonthYear,
formatMonthYearShort,
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"];
@@ -72,28 +80,22 @@ export function CalendarToolbar({
switch (viewMode) {
case "month":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6);
if (isMobile) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`;
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
}
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
const ws = startOfWeek(selectedDate, { weekStartsOn });
return isMobile
? formatWeekRangeShort(ws)
: formatWeekRange(ws);
}
case "day":
return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
? formatFullDate(selectedDate)
: formatFullDate(selectedDate);
case "agenda":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "tasks":
return t("views.tasks");
}
@@ -124,7 +126,7 @@ export function CalendarToolbar({
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
className="h-8 w-8 -ms-1 me-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -138,7 +140,7 @@ export function CalendarToolbar({
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -147,7 +149,7 @@ export function CalendarToolbar({
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
@@ -162,7 +164,7 @@ export function CalendarToolbar({
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
</div>
@@ -199,7 +201,7 @@ export function CalendarToolbar({
<CalendarDays className="w-4 h-4" />
</button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
@@ -284,7 +286,7 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
@@ -293,7 +295,7 @@ export function CalendarToolbar({
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
<span className="text-base font-semibold ms-2 select-none">
{getDateLabel()}
</span>
</div>
@@ -328,12 +330,12 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
<Upload className="w-4 h-4 me-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
<ChevronDown className="w-3 h-3 ms-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
@@ -359,7 +361,7 @@ export function CalendarToolbar({
{!isMobile && (
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("events.create")}
</Button>
)}
+9 -9
View File
@@ -58,7 +58,7 @@ export function CalendarWeekView({
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const weekDays = useMemo(() => {
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
@@ -211,7 +211,7 @@ export function CalendarWeekView({
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
@@ -306,7 +306,7 @@ export function CalendarWeekView({
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
<div className="flex-1 border-s border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -318,7 +318,7 @@ export function CalendarWeekView({
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
@@ -345,7 +345,7 @@ export function CalendarWeekView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -357,7 +357,7 @@ export function CalendarWeekView({
))}
</div>
<div className="flex-1 border-l border-border relative grid grid-cols-7">
<div className="flex-1 border-s border-border relative grid grid-cols-7">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -367,7 +367,7 @@ export function CalendarWeekView({
return (
<div
key={key}
className="relative border-r border-border last:border-r-0"
className="relative border-e border-border last:border-e-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
@@ -448,7 +448,7 @@ export function CalendarWeekView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -482,7 +482,7 @@ export function CalendarWeekView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{tCommon("loading")}
</>
) : (
+15 -9
View File
@@ -71,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -88,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
try { return format(d, fmt); } catch { return "--:--"; }
};
const timeString = `${safeFormat(startDate, timeFmt)} ${safeFormat(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572) - render it struck through and dimmed.
const isCancelled = event.status === "cancelled";
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
@@ -131,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}20`, color, ...style }}
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="truncate">{event.title || t("events.no_title")}</span>
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
continuesAfter && "pr-2",
continuesAfter && "pe-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
@@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
+25 -27
View File
@@ -1,18 +1,19 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventDetailPopover({
@@ -130,6 +124,7 @@ export function EventDetailPopover({
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
@@ -158,7 +153,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
@@ -295,20 +290,23 @@ export function EventDetailPopover({
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<h3 className="text-base font-semibold truncate text-foreground">
<h3 className={cn(
"text-base font-semibold truncate text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
@@ -348,13 +346,13 @@ export function EventDetailPopover({
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(startDate)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(endDate)}
</span>
</div>
@@ -369,11 +367,11 @@ export function EventDetailPopover({
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</>
@@ -439,7 +437,7 @@ export function EventDetailPopover({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({t("participants.organizer").toLowerCase()})
</span>
)}
@@ -517,7 +515,7 @@ export function EventDetailPopover({
disabled={!noteText.trim() || isSavingNote}
className="h-7 text-xs"
>
<Send className="w-3 h-3 mr-1" />
<Send className="w-3 h-3 me-1" />
{t("detail.save_note")}
</Button>
</div>
@@ -551,7 +549,7 @@ export function EventDetailPopover({
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -564,7 +562,7 @@ export function EventDetailPopover({
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -577,7 +575,7 @@ export function EventDetailPopover({
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -611,7 +609,7 @@ export function EventDetailPopover({
) : (
<>
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 mr-1" />
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
<Button
@@ -621,7 +619,7 @@ export function EventDetailPopover({
className="h-7 text-xs"
title={t("events.duplicate")}
>
<Copy className="w-3.5 h-3.5 mr-1" />
<Copy className="w-3.5 h-3.5 me-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
+101 -38
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventModal({
@@ -186,6 +180,7 @@ export function EventModal({
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
@@ -270,8 +265,35 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
setCustomRule(rule);
setRecurrence("custom");
setShowRecurrenceEditor(false);
}, []);
const handleRecurrenceEditorCancel = useCallback(() => {
setShowRecurrenceEditor(false);
if (!customRule) {
setRecurrence(recurrenceBeforeCustomRef.current);
}
}, [customRule]);
const customRuleSummary = useMemo(
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
@@ -444,7 +466,9 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence !== "none") {
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -498,10 +522,16 @@ export function EventModal({
{ name: organizerName, email: organizerEmail },
effectiveAttendees
) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null;
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
data.replyTo = null;
data.organizerCalendarAddress = null;
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
@@ -511,7 +541,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -593,7 +623,6 @@ export function EventModal({
if (isAttendeeMode && event) {
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event);
@@ -637,11 +666,11 @@ export function EventModal({
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
@@ -650,7 +679,7 @@ export function EventModal({
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
<span className="text-muted-foreground ms-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
@@ -672,7 +701,7 @@ export function EventModal({
<Users className="w-4 h-4" />
{t("participants.title")}
</div>
<div className="space-y-1 pl-5">
<div className="space-y-1 ps-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">{p.name || p.email}</span>
@@ -697,7 +726,7 @@ export function EventModal({
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -708,7 +737,7 @@ export function EventModal({
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -719,7 +748,7 @@ export function EventModal({
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -737,7 +766,7 @@ export function EventModal({
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -755,7 +784,7 @@ export function EventModal({
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
</div>
{eventCalendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
@@ -787,13 +816,13 @@ export function EventModal({
<>
<div className="font-medium text-foreground">
{formatEventDate(startD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(startD, timeDisplayFmt)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(endD, timeDisplayFmt)}
</span>
</div>
@@ -809,11 +838,11 @@ export function EventModal({
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</>
@@ -860,7 +889,7 @@ export function EventModal({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
@@ -913,21 +942,21 @@ export function EventModal({
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
)}
{onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
</Button>
)}
@@ -1131,17 +1160,51 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<div className="flex items-center gap-2">
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
<div>
@@ -1257,7 +1320,7 @@ export function EventModal({
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
@@ -1269,7 +1332,7 @@ export function EventModal({
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 mr-1" />
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
+30 -32
View File
@@ -1,25 +1,19 @@
"use client";
import { useState, useMemo, Fragment } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format,
getISOWeek, getWeek, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
type PickerView = "days" | "months" | "years";
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
interface MiniCalendarProps {
selectedDate: Date;
displayMonth: Date;
@@ -40,17 +34,25 @@ export function MiniCalendar({
showWeekNumbers = false,
}: MiniCalendarProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(() => {
const monthStart = startOfMonth(displayMonth);
const monthEnd = endOfMonth(displayMonth);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [displayMonth, weekStart]);
const days = useMemo(
() => getMonthGridDays(displayMonth),
[displayMonth, getMonthGridDays],
);
const eventDates = useMemo(() => {
const set = new Set<string>();
@@ -67,20 +69,16 @@ export function MiniCalendar({
return set;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
}
return nums;
}, [days, showWeekNumbers, weekStart]);
}, [days, showWeekNumbers, weekStartsOn]);
const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth);
@@ -116,7 +114,7 @@ export function MiniCalendar({
const headerLabel =
pickerView === "days"
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
? formatMonthYear(displayMonth)
: pickerView === "months"
? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`;
@@ -160,15 +158,15 @@ export function MiniCalendar({
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)}
</div>
))}
{days.map((day, index) => {
const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
@@ -193,7 +191,7 @@ export function MiniCalendar({
selected && "bg-primary text-primary-foreground"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
{hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
@@ -206,7 +204,7 @@ export function MiniCalendar({
{pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1">
{MONTH_LABELS.map((label, i) => {
{monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return (
@@ -220,7 +218,7 @@ export function MiniCalendar({
!isSelected && !isCurrentMonth && "hover:bg-muted"
)}
>
{label}
{t(`months.${labelKey}`)}
</button>
);
})}
+5 -1
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
<div className="min-w-0">
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</div>
</li>
))}
</ul>
+427
View File
@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
type MonthlyMode = "day" | "nth";
type EndsMode = "never" | "on" | "after";
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks",
monthly: "recurrence.editor_unit_months",
yearly: "recurrence.editor_unit_years",
};
type CalendarT = ReturnType<typeof useTranslations>;
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
}
function monthName(month: number, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
}
function nthLabel(nth: number, t: CalendarT): string {
if (nth === -1) return t("recurrence.nth_last");
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
return String(nth);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(
rule: CalendarRecurrenceRule,
t: CalendarT,
locale: string,
): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
break;
case "weekly":
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
break;
case "monthly":
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
break;
case "yearly":
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, locale, "short"))
.join(", ");
if (days) parts.push(t("recurrence.on_days", { days }));
}
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
if (rule.frequency === "yearly" && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(t("recurrence.on_the_nth", {
nth: nthLabel(nthDay.nth, t),
day: weekdayName(nthDay.day, locale),
}));
} else if (rule.byMonthDay?.length) {
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
}
}
let summary = parts.join(" ");
if (rule.count) {
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
}
}
return summary;
}
interface RecurrenceEditorProps {
rule: CalendarRecurrenceRule | null;
eventStart: Date;
onSave: (rule: CalendarRecurrenceRule) => void;
onCancel: () => void;
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const initialNthDay = rule ? getNthDay(rule) : null;
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: "weekly"
);
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
const [weekDays, setWeekDays] = useState<string[]>(() => {
if (rule?.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
});
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
const [monthDay, setMonthDay] = useState<number>(() => {
const md = rule?.byMonthDay?.[0];
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
});
const [nth, setNth] = useState<number>(() => {
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
return initialNthDay.nth;
}
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
});
const [nthDay, setNthDay] = useState<string>(() =>
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
);
const [month, setMonth] = useState<number>(() => {
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
});
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
const [untilDate, setUntilDate] = useState<string>(() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
}
return format(addYears(eventStart, 1), "yyyy-MM-dd");
});
const [count, setCount] = useState<number>(rule?.count ?? 12);
const toggleWeekDay = (day: string) => {
setWeekDays((prev) =>
prev.includes(day)
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
: [...prev, day]
);
};
const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule",
frequency,
interval: Math.max(1, interval),
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: endsMode === "after" ? Math.max(1, count) : null,
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
};
if (frequency === "weekly") {
const days = weekDays.length ? weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (frequency === "monthly" || frequency === "yearly") {
if (monthlyMode === "nth") {
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
}
if (frequency === "yearly") {
built.byMonth = [String(month)];
}
}
return built;
};
const handleSave = () => onSave(buildRule());
const summary = buildRecurrenceSummary(buildRule(), t, locale);
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_every")}
/>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.title")}
>
{EDITOR_FREQUENCIES.map((f) => (
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
))}
</select>
</div>
{frequency === "weekly" && (
<div className="flex gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
aria-pressed={weekDays.includes(day)}
className={
weekDays.includes(day)
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))}
</div>
)}
{frequency === "yearly" && (
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_in")}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
))}
</select>
</div>
)}
{(frequency === "monthly" || frequency === "yearly") && (
<div className="flex items-center gap-2 text-sm">
<select
value={monthlyMode}
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
className={`${selectCls} shrink-0`}
aria-label={t("recurrence.editor_repeats_on")}
>
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
</select>
{monthlyMode === "day" ? (
<Input
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_on_day")}
/>
) : (
<>
<select
value={nth}
onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{[1, 2, 3, 4, -1].map((n) => (
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
))}
</select>
<select
value={nthDay}
onChange={(e) => setNthDay(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))}
</select>
</>
)}
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
<select
value={endsMode}
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
aria-label={t("recurrence.editor_ends")}
>
<option value="never">{t("recurrence.editor_never")}</option>
<option value="on">{t("recurrence.until")}</option>
<option value="after">{t("recurrence.editor_ends_after")}</option>
</select>
{endsMode === "on" && (
<input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_ends_on")}
/>
)}
{endsMode === "after" && (
<>
<Input
type="number"
min={1}
max={999}
value={count}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_ends_after")}
/>
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
</>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
{summary}
</p>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -302,7 +302,7 @@ export function TaskModal({
className="text-destructive hover:text-destructive"
onClick={() => onDelete(task.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
<Trash2 className="h-4 w-4 me-1" />
{t("tasks.delete")}
</Button>
)}
+2 -2
View File
@@ -44,7 +44,7 @@ export function TaskToolbar({
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
<input
type="checkbox"
checked={showCompleted}
@@ -57,7 +57,7 @@ export function TaskToolbar({
<div className="flex-1" />
<Button size="sm" onClick={onCreateTask}>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("tasks.create")}
</Button>
</div>
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
});
it('sends media: null when an existing photo is removed', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const contactWithPhoto: ContactCard = {
...existingContact,
media: {
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
},
};
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('remove_photo'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.media).toBeNull();
});
it('submits form data correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
@@ -81,4 +102,98 @@ describe('ContactForm', () => {
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
);
});
it('saves an organization-only card when the organization type is selected', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.kind).toBe('org');
expect(savedData.organizations.o0.name).toBe('Acme Corp');
// No personal name components; the org name carries the display name instead.
expect(savedData.name.components).toBeUndefined();
expect(savedData.name.full).toBe('Acme Corp');
});
it('hides the personal name fields in organization mode', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument();
fireEvent.click(screen.getByText('type_organization'));
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument();
// The organization field moves into the identity section, so it appears once.
expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1);
});
it('still requires a name in organization mode', async () => {
const onSave = vi.fn();
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
});
expect(onSave).not.toHaveBeenCalled();
});
it('accepts an organization instead of a personal name in person mode', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('section_work'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp');
});
it('opens an existing org card in organization mode', () => {
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
});
it('switches an org card back to a person', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_person'));
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].kind).toBe('individual');
});
});
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactsSidebar } from '../contacts-sidebar';
import type { ContactCard } from '@/lib/jmap/types';
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], activeAccountId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
return { useAccountStore: hook };
});
const group = {
id: 'g1',
kind: 'group',
name: { full: 'Team' },
members: { '1': true },
} as unknown as ContactCard;
function renderSidebar(onComposeGroup = vi.fn()) {
render(
<ContactsSidebar
groups={[group]}
individuals={[]}
addressBooks={[]}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
onComposeGroup={onComposeGroup}
/>,
);
return onComposeGroup;
}
describe('ContactsSidebar — compose to group', () => {
beforeEach(() => vi.clearAllMocks());
it('shows a "Send email to group" submenu in the group context menu', () => {
renderSidebar();
fireEvent.contextMenu(screen.getByText('Team'));
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
// and the existing Edit/Delete entries still render
expect(screen.getByText('groups.edit')).toBeInTheDocument();
expect(screen.getByText('form.delete')).toBeInTheDocument();
});
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
const onComposeGroup = renderSidebar();
fireEvent.contextMenu(screen.getByText('Team'));
// Open the submenu (hover) then click "Cc".
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
fireEvent.mouseOver(trigger);
fireEvent.mouseEnter(trigger);
fireEvent.click(screen.getByText('groups.send_email_cc'));
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
});
});
+3 -3
View File
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
router.push("/");
};
const handleOpenEvent = (event: CalendarEvent) => {
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
+21 -122
View File
@@ -2,16 +2,13 @@
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
@@ -32,6 +29,8 @@ interface ContactDetailProps {
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
useEffect(() => {
if (!contact) return;
let cancelled = false;
const parseCerts = async () => {
const results = new Map<number, CertificateInfo>();
for (let i = 0; i < cryptoKeys.length; i++) {
const key = cryptoKeys[i];
if (typeof key.uri !== 'string') continue;
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
}
if (!derBytes) continue;
const cert = parseCertificatePemOrDer(derBytes);
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
const info = await extractCertificateInfo(cert, der);
if (!cancelled) results.set(i, info);
} catch { /* skip unparseable keys */ }
}
if (!cancelled) setParsedCerts(results);
};
if (cryptoKeys.length > 0) {
parseCerts();
} else {
setParsedCerts(new Map());
}
return () => { cancelled = true; };
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!contact) {
return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const handleImportContactCert = async (keyIndex: number) => {
const key = cryptoKeys[keyIndex];
if (!key?.uri || typeof key.uri !== 'string') return;
try {
let derBytes: ArrayBuffer | string;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) return;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
} else {
return;
}
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
toast.success(t("detail.cert_imported"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
}
};
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
@@ -237,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
// On an organization card the org name is already the heading; don't repeat it.
const orgName = orgs[0]?.name;
const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
@@ -260,26 +196,28 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</div>
</div>
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
{email && onCompose && (
<Button
variant="outline"
size="sm"
onClick={onCompose}
className="touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
<Send className="w-4 h-4 me-1" />
{t("detail.compose_email")}
</a>
</Button>
)}
{phone && (
<a
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
<Phone className="w-4 h-4 me-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
@@ -490,45 +428,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
const alreadyImported = certInfo?.emailAddresses?.[0]
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
: false;
return (
{cryptoKeys.map((key, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
{isExpired ? (
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
</div>
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
<p>
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
</p>
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
)}
{alreadyImported && (
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
)}
</>
) : (
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
@@ -539,10 +440,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
)}
</div>
);
})}
))}
</div>
</Section>
)}
@@ -674,7 +573,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
@@ -690,7 +589,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
+96 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useState, useMemo, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
<button
type="button"
className={cn(
"flex items-center gap-2 w-full text-left",
"flex items-center gap-2 w-full text-start",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
);
// A card may describe an organization instead of a person (RFC 9553 kind "org").
// Older cards predate the explicit kind, so fall back to "has an org name but no
// personal name".
const [isOrg, setIsOrg] = useState(() => {
if (!contact) return false;
if (contact.kind) return contact.kind === "org";
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name;
});
const [jobTitle, setJobTitle] = useState(() => {
if (contact?.titles) {
const t = Object.values(contact.titles).find(t => t.kind !== "role");
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
e.preventDefault();
setError(null);
if (!givenName.trim() && !surname.trim()) {
// An organization name identifies the card just as well as a personal name.
const orgName = organization.trim();
const hasPersonName = !!(givenName.trim() || surname.trim());
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
setError(t("name_required"));
return;
}
@@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (!isOrg) {
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
}
// Without personal name components, carry the organization name in `name.full`
// so servers and other clients have something to display.
const nameValue: ContactCard["name"] = nameComponents.length > 0
? { components: nameComponents, isOrdered: true }
: { full: orgName };
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
@@ -525,14 +546,25 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
// Set mediaVluae to null if we are removing media, so the server removes it
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
const mediaValue: Record<string, ContactMedia> | null | undefined =
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
const kindValue: ContactCard["kind"] | undefined =
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
name: nameValue,
...(kindValue ? { kind: kindValue } : {}),
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim(), units: orgUnits } }
organizations: orgName
? { o0: { name: orgName, units: orgUnits } }
: undefined,
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
@@ -551,7 +583,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
media: mediaValue as Record<string, ContactMedia> | undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
@@ -565,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
};
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
@@ -656,6 +688,47 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
<FormSection icon={User} title={t("section_identity")}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
{[
{ org: false, label: t("type_person"), icon: User },
{ org: true, label: t("type_organization"), icon: Building },
].map(({ org, label, icon: Icon }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={isOrg === org}
onClick={() => setIsOrg(org)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
isOrg === org
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
{isOrg ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -688,6 +761,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</>
)}
</FormSection>
{/* Email */}
@@ -731,12 +806,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_email")}
</Button>
</div>
@@ -794,19 +869,22 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_phone")}
</Button>
</div>
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* In organization mode the org name is the card's identity, edited above. */}
{!isOrg && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
@@ -850,7 +928,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_address")}
</Button>
</div>
@@ -879,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
@@ -911,7 +989,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_anniversary")}
</Button>
</div>
@@ -952,7 +1030,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
@@ -1140,7 +1218,7 @@ function CategoryComboBox({
<button
key={kw}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
>
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
@@ -1150,7 +1228,7 @@ function CategoryComboBox({
{canAddNew && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
+26 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
/** Compose an email to every member with the recipients placed in `field`. */
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
isMobile?: boolean;
className?: string;
}
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
onDelete,
onRemoveMember,
onSelectMember,
onComposeGroup,
isMobile,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
@@ -49,7 +53,7 @@ export function ContactGroupDetail({
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<Button
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
</Button>
</div>
</div>
{onComposeGroup && hasEmailMembers && (
<div className="flex flex-wrap items-center gap-2 mt-4">
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
<div className="inline-flex gap-1">
{(["to", "cc", "bcc"] as const).map((field) => (
<Button
key={field}
variant="outline"
size="sm"
onClick={() => onComposeGroup(field)}
className="touch-manipulation"
>
{t(`groups.send_email_${field}`)}
</Button>
))}
</div>
</div>
)}
</div>
<div className="px-6 py-4">
@@ -83,7 +106,7 @@ export function ContactGroupDetail({
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
>
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
className="flex items-center gap-3 flex-1 min-w-0 text-start"
onClick={() => onSelectMember(member.id)}
>
<Avatar name={mName} email={mEmail} size="sm" />
+2 -2
View File
@@ -114,7 +114,7 @@ export function ContactGroupForm({
placeholder={t("groups.search_members")}
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="pl-9"
className="ps-9"
/>
</div>
@@ -134,7 +134,7 @@ export function ContactGroupForm({
type="button"
onClick={() => toggleMember(contact.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
"hover:bg-muted",
isSelected && "bg-primary/5"
)}
+2 -2
View File
@@ -45,7 +45,7 @@ export function ContactGroupList({
<div className={cn("flex flex-col", className)}>
<div className="px-4 py-2 border-b border-border">
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("groups.create")}
</Button>
</div>
@@ -69,7 +69,7 @@ export function ContactGroupList({
key={group.id}
onClick={() => onSelectGroup(group.id)}
className={cn(
"w-full flex items-center px-4 text-left transition-colors",
"w-full flex items-center px-4 text-start transition-colors",
"hover:bg-muted",
group.id === selectedGroupId && "bg-accent text-accent-foreground"
)}
@@ -187,7 +187,7 @@ export function ContactImportDialog({
type="button"
onClick={() => toggleSelect(idx)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
isSelected && "bg-primary/5"
)}
>
+7 -7
View File
@@ -310,7 +310,7 @@ export function ContactList({
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
/>
{searchQuery && (
<button
@@ -359,7 +359,7 @@ export function ContactList({
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("filters.clear")}
</Button>
<Button
@@ -479,11 +479,11 @@ export function ContactList({
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 mr-1" />
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
<Download className="w-3.5 h-3.5 mr-1" />
<Download className="w-3.5 h-3.5 me-1" />
{t("bulk.export")}
</Button>
<Button
@@ -492,7 +492,7 @@ export function ContactList({
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
</Button>
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
@@ -526,7 +526,7 @@ export function ContactList({
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
<RotateCcw className="w-3.5 h-3.5 me-1" />
{t("filters.clear")}
</Button>
)}
@@ -538,7 +538,7 @@ export function ContactList({
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<Button size="sm" className="mt-3" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
<UserPlus className="w-4 h-4 me-1.5" />
{t("create_new")}
</Button>
</>
+50 -22
View File
@@ -2,10 +2,10 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
onCreateAddressBook?: () => void;
onImport?: () => void;
onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
@@ -90,9 +92,11 @@ export function ContactsSidebar({
onSelectCategory,
onCreateGroup,
onCreateContact,
onCreateAddressBook,
onImport,
onEditGroup,
onDeleteGroup,
onComposeGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
@@ -280,25 +284,34 @@ export function ContactsSidebar({
{showMenu && (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateContact(); }}
>
<UserPlus className="w-4 h-4" />
{t("create_new")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateGroup(); }}
>
<UsersRound className="w-4 h-4" />
{t("groups.create")}
</button>
{onCreateAddressBook && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
>
<BookPlus className="w-4 h-4" />
{t("address_books.create")}
</button>
)}
{onImport && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onImport(); }}
>
<Upload className="w-4 h-4" />
@@ -325,7 +338,7 @@ export function ContactsSidebar({
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
</span>
</button>
@@ -342,7 +355,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
@@ -356,7 +369,7 @@ export function ContactsSidebar({
</button>
</div>
{expanded && (
<div className="pl-2">
<div className="ps-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
@@ -405,7 +418,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
className="flex items-center gap-1 flex-1 text-start"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -448,7 +461,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("groups")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.groups ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -470,7 +483,7 @@ export function ContactsSidebar({
onClick={() => onSelectCategory({ groupId: group.id })}
onContextMenu={(e) => openGroupContextMenu(e, group)}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -479,7 +492,7 @@ export function ContactsSidebar({
>
<Users className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{getContactDisplayName(group)}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{memberCount}
</span>
</button>
@@ -492,7 +505,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("categories")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.categories ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -510,7 +523,7 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("uncategorized")}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
activeCategory === "uncategorized"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -519,7 +532,7 @@ export function ContactsSidebar({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate italic">{t("no_category")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{uncategorizedCount}
</span>
</button>
@@ -548,7 +561,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -684,6 +697,21 @@ export function ContactsSidebar({
onEditGroup?.(groupContextMenu.data!.id);
}}
/>
{onComposeGroup && (
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
{(["to", "cc", "bcc"] as const).map((field) => (
<ContextMenuItem
key={field}
label={t(`groups.send_email_${field}`)}
onClick={() => {
const groupId = groupContextMenu.data!.id;
closeGroupContextMenu();
onComposeGroup(groupId, field);
}}
/>
))}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
@@ -751,7 +779,7 @@ function CategoryItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -761,7 +789,7 @@ function CategoryItem({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
@@ -819,7 +847,7 @@ function AddressBookItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -830,11 +858,11 @@ function AddressBookItem({
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
)}>
{contactCount}
</span>
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
mocks.clientMock,
'event-8',
expect.objectContaining({
replyTo: { imip: 'mailto:organizer@example.com' },
// The stored event lacks an ORGANIZER, so the RSVP repair writes
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
@@ -0,0 +1,106 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactSidebarPanel } from '../email-viewer';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: 'c1',
addressBookIds: {},
name: {
components: [
{ kind: 'given', value: 'Alice' },
{ kind: 'surname', value: 'Smith' },
],
isOrdered: true,
},
emails: { e0: { address: 'alice@example.com' } },
};
const unknownEmail = 'unknown@example.com';
describe('ContactSidebarPanel', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
it('shows Edit button when contact is known and onEditContact is provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
// useTranslations mock returns the key, so we look for the common.edit key
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
expect(screen.getByText('edit')).toBeInTheDocument();
});
it('calls onEditContact when Edit button is clicked', () => {
const onEditContact = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={onEditContact}
/>,
);
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
expect(onEditContact).toHaveBeenCalledOnce();
});
it('does not show Edit button when contact is null', () => {
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('does not show Edit button when onEditContact is not provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('shows "not in contacts" message and Add button for unknown email', () => {
const onAddToContacts = vi.fn();
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onAddToContacts={onAddToContacts}
/>,
);
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -1,121 +0,0 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailListItem } from '../email-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '@/lib/jmap/types';
// Mock the drag hook
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
// Mock identity badge
vi.mock('../email-identity-badge', () => ({
EmailIdentityBadge: () => null,
}));
// Mock auth store
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('EmailListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
// No keyword label should appear
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows tag badge with label when email has $label: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('does not show badge when keyword id not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
});
it('shows custom keyword label', () => {
useSettingsStore.setState({
emailKeywords: [
...DEFAULT_KEYWORDS,
{ id: 'work', label: 'Work', color: 'teal' },
],
});
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
@@ -0,0 +1,466 @@
import { render, screen, act } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks ────────────────────────────────────────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so all values must be defined inline.
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
class MockDataTransfer {
private _data: Record<string, string> = {};
types: string[] = [];
effectAllowed = '';
dropEffect = '';
setData(type: string, data: string) {
this._data[type] = data;
if (!this.types.includes(type)) this.types.push(type);
}
getData(type: string): string {
return this._data[type] ?? '';
}
setDragImage(_image: Element, _x: number, _y: number) {
// no-op: jsdom has no rendering, but the chip drag handler calls this.
}
}
// ─── Shared test data ─────────────────────────────────────────────────────────
const BASE_DATA = {
to: 'alice@example.com, ',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput drag and drop', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders recipient chips with draggable="true"', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]');
expect(chipSpan).not.toBeNull();
expect(chipSpan).toHaveAttribute('draggable', 'true');
});
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
// The enrichment pass may have stamped extra display metadata on the
// chip by drag time, so match the essential fields rather than deep-equal.
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toMatchObject({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toMatchObject({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
});
it('onDragEnd clears the opacity class on the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
expect(chipSpan.className).toContain('opacity-50');
fireEvent.dragEnd(chipSpan);
expect(chipSpan.className).not.toContain('opacity-50');
});
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
// The flex-wrap containers are the actual drop zones
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
// To-container has the draggable chip; cc-container doesn't
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
expect(ccContainer.className).toContain('ring-primary');
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
expect(ccContainer.className).not.toContain('ring-primary');
});
it('drop on a different field container moves the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!toContainer || !ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccContainer, { dataTransfer: dt });
});
// Chip should still appear exactly once (moved, not duplicated or lost)
await screen.findByText('alice@example.com');
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
// The To container must now be empty
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
});
it('drop on the same field container is a no-op', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(toContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(toContainer, { dataTransfer: dt });
});
// Chip stays present exactly once
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
});
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
await screen.findByText('alice@example.com');
const ccButton = screen.getByRole('button', { name: 'Cc' });
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccButton, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccButton, { dataTransfer: dt });
});
// cc_label is rendered by the mock translation as its key string
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
// ─── Reordering within / across fields (#593) ─────────────────────────────────
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
// read-only MouseEvent getter) and gives every element a zero-size rect at
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
const chipByText = async (text: string) =>
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(e, 'clientX', { value: clientX });
Object.defineProperty(e, 'dataTransfer', { value: dt });
act(() => { fireEvent(el, e); });
};
const BEFORE = -100;
const AFTER = 100;
/** Ordered chip labels of the field-container that holds `anchorText`. */
const orderIn = (anchorText: string) => {
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const c = containers.find(el =>
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
) as HTMLElement;
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
};
/** All draggable chips (across fields) whose label contains `text`. */
const draggableChipsWith = (text: string) =>
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const carol = await chipByText('carol@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
fireDnd('drop', carol, dt, AFTER);
expect(orderIn('bob@example.com')).toEqual([
'bob@example.com', 'carol@example.com', 'alice@example.com',
]);
});
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('carol@example.com');
const carol = await chipByText('carol@example.com');
const alice = await chipByText('alice@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
fireDnd('drop', alice, dt, BEFORE);
expect(orderIn('alice@example.com')).toEqual([
'carol@example.com', 'alice@example.com', 'bob@example.com',
]);
});
it('dropping a chip onto its own position leaves the order unchanged', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('bob@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
fireDnd('drop', bob, dt, BEFORE);
expect(orderIn('bob@example.com')).toEqual([
'alice@example.com', 'bob@example.com', 'carol@example.com',
]);
});
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com'); // To
const y = await chipByText('y@example.com'); // Cc
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
fireDnd('drop', y, dt, BEFORE);
// alice lands between x and y; To no longer holds it (count only real chips,
// not the leftover jsdom drag-preview element)
expect(orderIn('x@example.com')).toEqual([
'x@example.com', 'alice@example.com', 'y@example.com',
]);
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
});
it('shows a drop caret only while a chip is dragged over the field', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
fireDnd('dragover', bob, dt, BEFORE);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
fireEvent.dragEnd(alice);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
});
});
@@ -0,0 +1,300 @@
import { render, screen } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors recipient-chip-drag.test.tsx) ──────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Shared test data ─────────────────────────────────────────────────────────
const EMPTY_DATA = {
to: '',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
const paste = (input: HTMLElement, text: string) =>
fireEvent.paste(input, { clipboardData: { getData: () => text } });
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput paste', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput(); // capture once — placeholder disappears once chips exist
paste(input, 'a@x.com b@y.com; c@z.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
// all consumed → input cleared
expect(input.value).toBe('');
});
it('chips the valid addresses and leaves invalid text in the input', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'foo bar a@x.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(input.value).toBe('foo bar');
});
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
paste(toInput(), 'single@x.com');
// The handler bailed out, so no chip was created from the single token.
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
});
it('keeps display names from a fully-quoted "Name <email>" list', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
// Chips render as "Name (email)" when a display name is present.
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('keeps a `Name <email>` pair as a single named chip', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'John Doe <j@x.com>, jane@y.com');
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('keeps a comma inside a quoted display name intact', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'a@x.com\nb@y.com\nc@z.com');
expect(screen.getByText('a@x.com')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
it('dedupes case-insensitively within the paste and against existing chips', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(screen.getByText('c@z.com')).toBeInTheDocument();
// a@x.com appears exactly once despite three case variants across two pastes.
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
expect(input.value).toBe('');
});
it('chips the valid (named) entries and leaves a non-address token behind', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(input.value).toBe('not-an-email');
});
it('works on the Cc field (shared handler)', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
paste(ccInput(), 'x@a.com; y@b.com');
expect(screen.getByText('x@a.com')).toBeInTheDocument();
expect(screen.getByText('y@b.com')).toBeInTheDocument();
});
});
@@ -0,0 +1,228 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
{ id: 'id-info', email: 'info@example.com', name: 'Info' },
],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const RECEIVED = {
from: [{ email: 'bob@other.com', name: 'Bob' }],
to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }],
cc: [{ email: 'dave@other.com', name: 'Dave' }],
subject: 'Hello',
};
/** The same conversation, but the message opened is the one we sent back. */
const SELF_SENT = {
from: [{ email: 'me@example.com', name: 'Me' }],
to: [{ email: 'bob@other.com', name: 'Bob' }],
cc: [{ email: 'carol@other.com', name: 'Carol' }],
subject: 'Re: Hello',
};
/** Chip labels currently shown in a recipient row, in order. Chips are the
* draggable spans inside the row; next-intl is mocked to return the key, so
* the Cc row is found via its "cc_label" caption. */
const chipsIn = (row: HTMLElement) =>
Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim());
const toChips = () => chipsIn(screen.getByTestId('composer-to'));
const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement);
const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement;
describe('composer reply addressing', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('addresses a reply to the sender of a received message', () => {
render(<EmailComposer mode="reply" replyTo={RECEIVED} />);
expect(toChips()).toEqual(['Bob (bob@other.com)']);
});
it('reply-all keeps the other recipients but not our own address', () => {
render(<EmailComposer mode="replyAll" replyTo={RECEIVED} />);
expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']);
expect(ccChips()).toEqual(['Dave (dave@other.com)']);
});
// #703: replying to our own message inside a thread used to address the
// reply back to ourselves instead of continuing the conversation.
it('addresses a reply to our own message to the original recipient', () => {
render(<EmailComposer mode="reply" replyTo={SELF_SENT} />);
expect(toChips()).toEqual(['Bob (bob@other.com)']);
});
it('reply-all on our own message restores the original To and Cc', () => {
render(<EmailComposer mode="replyAll" replyTo={SELF_SENT} />);
expect(toChips()).toEqual(['Bob (bob@other.com)']);
expect(ccChips()).toEqual(['Carol (carol@other.com)']);
});
it('sends the reply to our own message from the identity that sent it', () => {
render(<EmailComposer mode="reply" replyTo={{ ...SELF_SENT, from: [{ email: 'info@example.com', name: 'Info' }] }} />);
expect(identitySelect().value).toBe('id-info');
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SelectableAvatar } from '../selectable-avatar';
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
// care about the selection wrapper behaviour here.
vi.mock('@/components/ui/avatar', () => ({
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
}));
describe('SelectableAvatar', () => {
it('renders the wrapped avatar', () => {
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
});
it('fires onToggle and stops propagation when the avatar is clicked', () => {
const onToggle = vi.fn();
const onRowClick = vi.fn();
render(
<div onClick={onRowClick}>
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
</div>,
);
fireEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledTimes(1);
// Clicking the avatar must not bubble up to open/select the row.
expect(onRowClick).not.toHaveBeenCalled();
});
it('reflects the checked state via aria-checked', () => {
const { rerender } = render(
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
import { serializeEditorContent } from '../quoted-html';
describe('signature-block', () => {
it('buildSignatureBlock wraps html in the marker div', () => {
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
});
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
const styled =
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
'</tr></tbody></table>';
const editor = new Editor({
element: document.createElement('div'),
extensions: [StarterKit, SignatureBlock],
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
});
try {
const out = serializeEditorContent(editor);
// Original inline styling survives - it is NOT re-parsed into the schema.
expect(out).toContain('background:#0a0e16');
expect(out).toContain('border-radius:8px');
expect(out).toContain('color:#c6f24e');
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
// Surrounding body is preserved.
expect(out).toContain('Hello');
// The signature did not get the editor's generic table styling.
expect(out).not.toContain('rgb(204, 204, 204)');
} finally {
editor.destroy();
}
});
});
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagBadge } from '../tag-badge';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
];
describe('TagBadge', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names the tag by its full path', () => {
render(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagPicker } from '../tag-picker';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
{ id: 'personal', label: 'Personal', color: 'purple' },
];
/** Ten tags is the point at which the filter box appears. */
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
id: `tag-${i}`,
label: i === 0 ? 'Invoices' : `Tag ${i}`,
color: 'blue',
}));
describe('TagPicker', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names a nested tag by its own label, not the whole path', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
// The tree conveys the hierarchy, so a child needs only its own name.
expect(screen.getByText('Clients')).toBeInTheDocument();
expect(screen.getByText('Acme')).toBeInTheDocument();
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
});
it('indents each level below its parent', () => {
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
const acme = screen.getByText('Acme');
// Two levels down: two nested indent wrappers between it and the list.
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
expect(indents).not.toBeNull();
expect(container.querySelectorAll('.ps-4').length).toBe(2);
});
it('marks the applied tags and reports toggles by id', () => {
const onToggle = vi.fn();
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
const row = screen.getByText('Clients').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('work/clients');
});
it('lists a tag it has no definition for, so it can be taken off', () => {
const onToggle = vi.fn();
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
const row = screen.getByText('from-elsewhere').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
// Nothing but the message says it exists, so deselecting is the last of it.
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
});
it('counts undefined tags towards the filter box, and matches them', () => {
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
expect(within(container).getByText('stray-3')).toBeInTheDocument();
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
});
it('hides the filter box until the list is long enough to need one', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
});
it('flattens to matches while filtering, and says so when there are none', () => {
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
expect(within(container).getByText('Invoices')).toBeInTheDocument();
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
});
it('matches the full path, so a child is reachable by its parent name', () => {
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
// Filtered rows are flat, so they carry the whole path.
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
});
it('lists tags flat when nesting is off', () => {
useSettingsStore.setState({ nestedTags: false });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(container.querySelectorAll('.ps-4').length).toBe(0);
expect(screen.getByText('Clients')).toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { groupEmailsByThread } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
/**
* A one-message thread, built through the real grouping so the fixture cannot
* drift from what the list actually feeds this component. `ThreadListItem`
* delegates to `SingleEmailItem` at that size, which is what draws every
* single-message row in the app.
*/
function renderRow(email: Email) {
const [thread] = groupEmailsByThread([email]);
return render(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
describe('ThreadListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show a tag badge when the email has no label keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true } }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows a tag badge for a $label: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows a tag badge for the legacy $color: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('falls back to the raw id when the tag is not in settings', () => {
// A keyword created by another client, or one whose definition was deleted.
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows a custom tag label', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
});
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('follows a renamed tag definition', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = renderRow(email);
expect(screen.getByText('Red')).toBeInTheDocument();
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
const [thread] = groupEmailsByThread([email]);
rerender(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
});
describe('ThreadListItem multi-message thread', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
function renderThread(emails: Email[], expanded = false) {
const [thread] = groupEmailsByThread(emails);
return render(
<ThreadListItem
thread={thread}
isExpanded={expanded}
expandedEmails={expanded ? emails : undefined}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
it('carries the tags of every message, not just the first', () => {
// A collapsed row stands in for the whole thread, so a tag applied only to
// a later message still has to surface.
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
]);
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('names a tag shared by several messages once', () => {
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
]);
expect(screen.getAllByText('Red')).toHaveLength(1);
});
it('shows each message its own tags once the thread is expanded', () => {
renderThread(
[
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
],
true,
);
// Once on the header and once on the message that carries it.
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
});
});
describe('ThreadListItem row content', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('renders the subject without a tag', () => {
renderRow(makeEmail({ subject: 'Hello World' }));
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders preview text inline in the focused layout', () => {
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
// Focused rows are one line: the preview shares the subject's element
// rather than getting a paragraph of its own.
expect(container.querySelector('p')).toBeNull();
});
});
describe('ThreadListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// Selection mode active so the checkbox renders, with the anchor on e1.
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
renderRow(e3);
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const selected = useEmailStore.getState().selectedEmailIds;
expect(selected.has('e1')).toBe(true);
expect(selected.has('e2')).toBe(true); // the row in between got filled in
expect(selected.has('e3')).toBe(true);
});
});
describe('ThreadListItem row tint', () => {
const rowClasses = (container: HTMLElement) =>
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
tintListRowsByTag: true,
});
useEmailStore.setState({
selectedEmailIds: new Set(['email-1']),
selectedMailbox: 'inbox',
});
});
it('keeps a checked row tinted, and says so to either theme', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-red-50');
expect(classes).toContain('dark:bg-red-950/30');
expect(classes).not.toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('washes a checked row that has no tint to keep', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('leaves the tint alone when the setting is off', () => {
useSettingsStore.setState({ tintListRowsByTag: false });
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).not.toContain('bg-red-50');
expect(classes).toContain('bg-accent/40');
});
});
+20 -43
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [actionError, setActionError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
@@ -553,40 +554,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -601,7 +572,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -613,9 +583,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else {
await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
// Only repair a missing organizer - attendees may not modify it.
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -632,7 +606,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
@@ -1054,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
}
if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect();
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
setPickerPosition(
isDocumentRTL()
? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
: { top: rect.bottom + 4, left: rect.left }
);
}
setShowCalendarPicker(true);
}}
@@ -1069,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
<div
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
style={{ top: pickerPosition.top, left: pickerPosition.left }}
style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')}
@@ -1081,7 +1058,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
@@ -1123,7 +1100,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
)}
</div>
)}
File diff suppressed because it is too large Load Diff
+55 -65
View File
@@ -17,12 +17,12 @@ import {
Mail,
MailOpen,
Star,
Pin,
PinOff,
Trash2,
Archive,
FolderInput,
Tag,
X,
Check,
Inbox,
Send,
File,
@@ -32,9 +32,12 @@ import {
EditIcon,
CalendarClock,
XCircle,
Paperclip,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
interface Position {
x: number;
@@ -56,11 +59,13 @@ interface EmailContextMenuProps {
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
@@ -95,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
export function EmailContextMenu({
email,
position,
@@ -123,11 +114,13 @@ export function EmailContextMenu({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
@@ -143,24 +136,21 @@ export function EmailContextMenu({
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tSidebar = useTranslations("sidebar");
const tEmailViewer = useTranslations("email_viewer");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const currentTagIds = getEmailTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
@@ -267,6 +257,12 @@ export function EmailContextMenu({
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuItem
icon={Paperclip}
label={tEmailViewer("forward_as_attachment")}
onClick={() => handleAction(onForwardAsAttachment!)}
disabled={!onForwardAsAttachment || !email.blobId}
/>
<ContextMenuSeparator />
</>
)}
@@ -285,6 +281,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
@@ -296,18 +293,20 @@ export function EmailContextMenu({
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={node.name}
label={nodeLabel}
testId={`move-to:${node.id}`}
onClick={() =>
handleAction(() =>
showBatchActions
@@ -319,11 +318,11 @@ export function EmailContextMenu({
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="pl-4">
<div className="ps-4">
{renderNodes(node.children)}
</div>
)}
@@ -346,48 +345,36 @@ export function EmailContextMenu({
/>
)}
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
{!showBatchActions && onTogglePinned && (
<ContextMenuItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? t("unpin") : t("pin")}
onClick={() => handleAction(onTogglePinned)}
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
</>
)}
</div>
</ContextMenuSubMenu>
)}
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
{spamApplicable && (
<>
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
onClick={() =>
handleAction(
showBatchActions
@@ -398,6 +385,8 @@ export function EmailContextMenu({
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
</>
)}
<ContextMenuSeparator />
@@ -405,6 +394,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() =>
handleAction(() =>
showBatchActions

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