Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7511d8ea78 | ||
|
|
959d4bd6ce | ||
|
|
b7c8cd999e | ||
|
|
813185e58d | ||
|
|
3f22a3323a | ||
|
|
0e4efb5a2a | ||
|
|
b354319b82 | ||
|
|
5a8c69dac2 | ||
|
|
24056e4698 | ||
|
|
5818e60401 | ||
|
|
7beaf991e8 | ||
|
|
5105e000f5 | ||
|
|
66bc10fa0f | ||
|
|
07c473e057 | ||
|
|
0e47c3b039 | ||
|
|
e1a973663f | ||
|
|
de55fb6b73 | ||
|
|
a909593dda | ||
|
|
4ad9267a2d | ||
|
|
23a017d4b7 | ||
|
|
c7250dc921 | ||
|
|
80f76abc38 | ||
|
|
adb8686293 | ||
|
|
d531ad1930 | ||
|
|
953355d2a5 | ||
|
|
8155f98a28 | ||
|
|
fda898fc96 | ||
|
|
3dceecb4c5 | ||
|
|
53461d1142 | ||
|
|
4d9d992f3f | ||
|
|
162e420a1f | ||
|
|
e8f01871c2 | ||
|
|
6dfcb07b9a | ||
|
|
0f3459c2e5 | ||
|
|
d5017a211f | ||
|
|
f51ec50443 | ||
|
|
f2703bcc27 | ||
|
|
cda4dcbf01 | ||
|
|
f15edd336b | ||
|
|
a3f9055541 | ||
|
|
a779e101e6 | ||
|
|
c38bcc4a95 | ||
|
|
5716d91115 | ||
|
|
2f791318df | ||
|
|
60b9ae66ea | ||
|
|
abd493fb4c | ||
|
|
b4739c111f | ||
|
|
88b07a1713 | ||
|
|
18e9cf6ee6 | ||
|
|
2cb5c739b4 | ||
|
|
0a30b2fb3a | ||
|
|
0b62afb0f8 | ||
|
|
f749ee1f2a | ||
|
|
4a4950c3e5 | ||
|
|
739b72d251 | ||
|
|
682e47c970 | ||
|
|
a6d8671306 | ||
|
|
6f278845f3 | ||
|
|
334fdbfb86 | ||
|
|
578339c400 | ||
|
|
8d8bc7cb13 | ||
|
|
4dc76bbb47 | ||
|
|
04444003b2 | ||
|
|
7da3d4ae80 | ||
|
|
cb5d754113 | ||
|
|
511f9e5195 | ||
|
|
996fa7eea6 | ||
|
|
01e5cd69cf | ||
|
|
20d02214df | ||
|
|
432ba0516b | ||
|
|
37152504b4 | ||
|
|
9072bf8470 | ||
|
|
b1f6758f98 | ||
|
|
a679d82cc3 | ||
|
|
a08a9e9ed3 | ||
|
|
622adc34de | ||
|
|
4a3394cf8c | ||
|
|
a8598db44d | ||
|
|
d3addf54b4 | ||
|
|
e1e83c4a83 | ||
|
|
cdb31634a6 | ||
|
|
26c3d07d56 | ||
|
|
c3acb537d0 | ||
|
|
060c5d00d1 | ||
|
|
e05fbb2fe9 | ||
|
|
b8809c2e69 | ||
|
|
bc11450f3f | ||
|
|
034f7a4b9b | ||
|
|
2e42693228 | ||
|
|
fdad60cf03 | ||
|
|
dc72122ed8 | ||
|
|
7c221c4a4a | ||
|
|
c1acf58c5f | ||
|
|
42798c2b7c | ||
|
|
75d17d4e37 | ||
|
|
38a396d150 | ||
|
|
c6bd5f645a | ||
|
|
65cb6be8a9 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-5
@@ -4,9 +4,9 @@
|
||||
|
||||
- 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 – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
||||
- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
|
||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
||||
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
||||
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
|
||||
- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
|
||||
- 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
|
||||
@@ -117,7 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
@@ -125,7 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
- 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
|
||||
- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
|
||||
- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle
|
||||
- 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`)
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
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";
|
||||
@@ -400,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);
|
||||
@@ -513,23 +513,31 @@ export default function ContactsPage() {
|
||||
}, [router]);
|
||||
|
||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||
// Format each member as "Name <email>" so the composer keeps the display
|
||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||
// email, case-insensitively; members without an email are skipped.
|
||||
// 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 recipients: 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);
|
||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||
const name = getContactDisplayName(member);
|
||||
members.push({ name: name && name !== email ? name : undefined, email });
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
if (members.length === 0) {
|
||||
toast.error(t("groups.no_member_emails"));
|
||||
return;
|
||||
}
|
||||
openComposeInApp(recipients, field);
|
||||
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) => {
|
||||
|
||||
@@ -133,7 +133,7 @@ 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, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, 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);
|
||||
|
||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||
@@ -822,7 +822,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VersionBadge />
|
||||
{loginShowVersion && <VersionBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1154,8 +1154,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={() => {
|
||||
@@ -1167,6 +1171,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">
|
||||
@@ -1361,7 +1366,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VersionBadge />
|
||||
{loginShowVersion && <VersionBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+179
-62
@@ -5,13 +5,14 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { MessageListTabs } from "@/components/email/message-list-tabs";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -70,7 +71,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
@@ -110,7 +111,7 @@ export default function Home() {
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -356,10 +357,7 @@ export default function Home() {
|
||||
useProMultiAccountMailboxes();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
|
||||
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
|
||||
|
||||
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
|
||||
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||
@@ -377,18 +375,36 @@ export default function Home() {
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
||||
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
// Cross-account is "active" when the user opted in, the admin allows it, and
|
||||
// more than one account is connected. Drives the sidebar header label: the
|
||||
// old "All accounts" when spanning accounts, else "Unified Mailbox".
|
||||
const crossAccountActive =
|
||||
unifiedCrossAccount &&
|
||||
unifiedCrossAccountGate &&
|
||||
accounts.filter((a) => a.isConnected).length > 1;
|
||||
|
||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||
// effects and one-shot actions in this page. Reads the includeGroup
|
||||
// setting at call time so the latest toggle value is always honored.
|
||||
// effects and one-shot actions in this page. Reads the settings at call time
|
||||
// so the latest toggle values are always honored. When the cross-account
|
||||
// sub-option is off, the unified mailbox stays within the active account
|
||||
// boundary (its own + shared folders); when on, it spans every login account.
|
||||
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
||||
// Cross-account scope requires both the per-user opt-in and the admin
|
||||
// capability gate; otherwise stay within the active account boundary.
|
||||
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
|
||||
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||
return buildUnifiedAccountClients({
|
||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||
scopeToClientAccountId: crossAccount
|
||||
? undefined
|
||||
: (useAccountStore.getState().activeAccountId ?? undefined),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -772,8 +788,7 @@ export default function Home() {
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
@@ -965,11 +980,16 @@ export default function Home() {
|
||||
|
||||
await refreshScheduledMetadata(client);
|
||||
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is available.
|
||||
// Fetch emails for the selected mailbox after scheduled metadata is
|
||||
// available. If the list is already populated (an account switch
|
||||
// restored a cached snapshot, or login prefetched it), refresh in the
|
||||
// background so the visible mail doesn't flash a loading overlay; only
|
||||
// a genuine empty first load shows the skeleton.
|
||||
const background = state.emails.length > 0;
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
await fetchEmails(client, selectedMailboxId, { background });
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
await fetchEmails(client, undefined, { background });
|
||||
}
|
||||
|
||||
fetchTagCounts(client);
|
||||
@@ -986,29 +1006,52 @@ export default function Home() {
|
||||
};
|
||||
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||
|
||||
// Push notifications: set up once per client and tear down when the client
|
||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
||||
// above so it still runs when data was prefetched at login time.
|
||||
// Push notifications: set up once per CONNECTED client and tear down when the
|
||||
// clients go away (logout or account switch). Kept separate from the fetch
|
||||
// effect above so it still runs when data was prefetched at login time.
|
||||
//
|
||||
// We bind every connected login, not just the active one: background accounts
|
||||
// must drive the unified-section counters too. The active client keeps the
|
||||
// full handler (current list / scheduled / calendar / filters); background
|
||||
// logins only re-project the unified counts by rebuilding the unified scope
|
||||
// (which refreshes every account's cached mailbox list), since their changes
|
||||
// never touch the active `mailboxes`. (#281 background push)
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
try {
|
||||
client.onStateChange((change) => handleStateChange(change, client));
|
||||
const pushEnabled = client.setupPushNotifications();
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('push', '[Push] Push notifications not available on this server');
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
for (const [accId, c] of clients) {
|
||||
try {
|
||||
if (accId === activeAccountId) {
|
||||
c.onStateChange((change) => handleStateChange(change, c));
|
||||
} else {
|
||||
c.onStateChange(() => {
|
||||
buildPopulatedUnifiedAccounts()
|
||||
.then((built) => {
|
||||
refreshCrossCounts(built);
|
||||
refreshUnifiedCounts(built);
|
||||
})
|
||||
.catch(() => { /* per-account fetch failures surface elsewhere */ });
|
||||
});
|
||||
}
|
||||
c.setupPushNotifications();
|
||||
cleanups.push(() => c.closePushNotifications());
|
||||
} catch (error) {
|
||||
debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error);
|
||||
}
|
||||
} catch (error) {
|
||||
debug.log('push', '[Push] Failed to setup push notifications:', error);
|
||||
}
|
||||
|
||||
if (cleanups.length > 0) {
|
||||
setPushConnected(true);
|
||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
client.closePushNotifications();
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
|
||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||
|
||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||
// than one account is connected. Runs whenever the set of connected accounts
|
||||
@@ -1025,7 +1068,7 @@ export default function Home() {
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1349,8 +1392,13 @@ export default function Home() {
|
||||
const bodyText = draft.bodyValues
|
||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||
: '';
|
||||
const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId]
|
||||
? draft.bodyValues[draft.htmlBody[0].partId].value
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = draft.htmlBody?.[0];
|
||||
const htmlBody = draftHtmlPart?.partId
|
||||
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
||||
&& draft.bodyValues?.[draftHtmlPart.partId]
|
||||
? draft.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Try to find the identity that matches the draft's from address to preserve it
|
||||
@@ -1390,6 +1438,27 @@ export default function Home() {
|
||||
|
||||
toast.success(t('email_viewer.scheduled_send_created'), {
|
||||
duration: undoDurationMs,
|
||||
secondaryAction: (pending.emailId && pending.identityId)
|
||||
? {
|
||||
label: t('email_viewer.send_now'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await client.rescheduleEmailSubmission(
|
||||
pending.submissionId,
|
||||
pending.emailId!,
|
||||
pending.identityId!,
|
||||
new Date(Date.now() + 1000).toISOString(),
|
||||
);
|
||||
clearPendingUndoSend();
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to send now:', error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
action: {
|
||||
label: t('email_viewer.undo_send'),
|
||||
onClick: () => {
|
||||
@@ -1823,7 +1892,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
// Keep an active search across the switch and re-run it in this view
|
||||
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, searchQuery);
|
||||
}
|
||||
} else {
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
}
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
}
|
||||
@@ -1845,7 +1925,18 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchCrossViewAction(populated, view);
|
||||
// Keep an active search across the switch and re-run it in this view
|
||||
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, searchQuery);
|
||||
}
|
||||
} else {
|
||||
await fetchCrossViewAction(populated, view);
|
||||
}
|
||||
refreshCrossCounts(populated);
|
||||
return;
|
||||
}
|
||||
@@ -2191,13 +2282,16 @@ export default function Home() {
|
||||
setSearchQuery("");
|
||||
clearSearchFilters();
|
||||
if (!client) return;
|
||||
// In unified view the active "mailbox" is a virtual role, so refresh via
|
||||
// the unified fan-out instead of fetchEmails.
|
||||
// In unified view the active "mailbox" is a virtual role or cross view, so
|
||||
// refresh via the unified fan-out instead of fetchEmails.
|
||||
if (isUnifiedView) {
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
const role = useEmailStore.getState().unifiedRole;
|
||||
const cross = useEmailStore.getState().crossView;
|
||||
if (role) {
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
} else if (cross) {
|
||||
await fetchCrossViewAction(populated, cross);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2229,41 +2323,64 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
|
||||
// message may belong to another login (route to its client) or to a delegated
|
||||
// shared account (same client, but the owner's accountId in the download URL).
|
||||
// Resolve both from the email's source so attachments on cross-account
|
||||
// messages can be viewed/downloaded instead of 404ing against the active
|
||||
// account.
|
||||
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
|
||||
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
const blobClient = clientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
return { blobClient, accountId, clientAccountId };
|
||||
}, [isUnifiedView, client]);
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||
if (!client) return;
|
||||
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
|
||||
if (!blobClient) return;
|
||||
|
||||
try {
|
||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||
|
||||
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||
setPreviewAttachment({ blobId, name, type });
|
||||
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||
return;
|
||||
}
|
||||
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
await blobClient.downloadBlob(blobId, name, type, accountId);
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
if (!client || !previewAttachment) return;
|
||||
const previewBlobClient = useCallback(() => {
|
||||
const id = previewAttachment?.clientAccountId;
|
||||
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
|
||||
}, [previewAttachment, client]);
|
||||
|
||||
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
}, [client, previewAttachment]);
|
||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||
const c = previewBlobClient();
|
||||
if (!c || !previewAttachment) return;
|
||||
|
||||
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
|
||||
const getPreviewAttachmentContent = useCallback(async () => {
|
||||
if (!client || !previewAttachment) {
|
||||
const c = previewBlobClient();
|
||||
if (!c || !previewAttachment) {
|
||||
throw new Error('No attachment selected');
|
||||
}
|
||||
|
||||
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||
const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||
|
||||
return {
|
||||
blob,
|
||||
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
||||
};
|
||||
}, [client, previewAttachment]);
|
||||
}, [previewBlobClient, previewAttachment]);
|
||||
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
@@ -2413,14 +2530,12 @@ export default function Home() {
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = isScheduledView
|
||||
? t('sidebar.scheduled')
|
||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||
? t('sidebar.mailboxes.all_mail')
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
@@ -2708,7 +2823,7 @@ export default function Home() {
|
||||
selectedKeyword={selectedKeyword}
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
showAllMailMailbox={showAllMailMailbox}
|
||||
crossAccountActive={crossAccountActive}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
showCrossAll={showCrossAll}
|
||||
@@ -2835,8 +2950,8 @@ export default function Home() {
|
||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
disabled={isScheduledView}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -2852,15 +2967,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
disabled={isScheduledView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||
isScheduledView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -3017,6 +3132,9 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* Plugin-registered category tabs (Gmail-style). Renders nothing
|
||||
unless an enabled plugin registered tabs via api.tabs.set. */}
|
||||
{!isScheduledView && <MessageListTabs />}
|
||||
<WelcomeBanner />
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
@@ -3195,8 +3313,7 @@ export default function Home() {
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
...getQuoteBodies(selectedEmail),
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
|
||||
@@ -6,7 +6,9 @@ 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' },
|
||||
@@ -22,10 +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.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
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 = [
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -132,6 +133,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<FaviconBadge />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,6 +51,10 @@ 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 });
|
||||
}
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||
] as const;
|
||||
|
||||
interface JmapSession {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
uid: string | null;
|
||||
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
|
||||
using.push('urn:ietf:params:jmap:principals:owner');
|
||||
}
|
||||
|
||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
||||
// 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);
|
||||
@@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
|
||||
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
|
||||
* fallback for other servers. Returns null if neither yields a usable session.
|
||||
*/
|
||||
async function fetchJmapSession(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<JmapSession | null> {
|
||||
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const session = (await res.json()) as JmapSession;
|
||||
if (session && typeof session === 'object' && session.primaryAccounts) {
|
||||
return session;
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||
if (!res.ok) {
|
||||
throw new Error(`JMAP request failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export async function GET(request: NextRequest) {
|
||||
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', []))),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
|
||||
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";
|
||||
@@ -45,6 +46,13 @@ export function CalendarMonthView({
|
||||
pendingPreview,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
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,
|
||||
@@ -157,7 +165,7 @@ 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 = checkIsSameMonth(day, selectedDate);
|
||||
@@ -201,7 +209,7 @@ export function CalendarMonthView({
|
||||
{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);
|
||||
@@ -231,25 +239,28 @@ export function CalendarMonthView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isMobile && pendingPreview && (() => {
|
||||
{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}
|
||||
@@ -259,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 (
|
||||
@@ -270,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
|
||||
@@ -285,6 +296,7 @@ export function CalendarMonthView({
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -201,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>
|
||||
@@ -335,7 +335,7 @@ export function CalendarToolbar({
|
||||
<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); }}
|
||||
|
||||
@@ -571,7 +571,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) {
|
||||
|
||||
@@ -284,7 +284,7 @@ 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-start"
|
||||
|
||||
@@ -136,6 +136,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -148,7 +149,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
@@ -226,8 +230,10 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
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).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
||||
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 () => {
|
||||
@@ -241,7 +247,7 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
||||
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 () => {
|
||||
@@ -340,4 +346,121 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -147,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
|
||||
@@ -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');
|
||||
@@ -1026,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);
|
||||
}}
|
||||
@@ -1041,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')}
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
@@ -26,11 +27,11 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders } from "@/lib/template-utils";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature } from "@/lib/template-utils";
|
||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
@@ -44,16 +45,20 @@ import {
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
expandRecipients,
|
||||
splitPastedRecipients,
|
||||
waitForPendingUploads,
|
||||
extractUserAuthoredText,
|
||||
type Recipient,
|
||||
enrichChipsWithColorsAndIcons,
|
||||
ICON_MAP,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
|
||||
/**
|
||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||
@@ -92,6 +97,10 @@ function createChipDragPreview(label: string): HTMLElement {
|
||||
return preview;
|
||||
}
|
||||
|
||||
// An autocomplete entry: a person, or a contact group (empty email) that
|
||||
// inserts as a single chip and expands into its members on send.
|
||||
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
|
||||
|
||||
export interface ComposerDraftData {
|
||||
to: string;
|
||||
cc: string;
|
||||
@@ -285,6 +294,9 @@ export function EmailComposer({
|
||||
: [];
|
||||
const primaryIdentity = activeIdentities[0] ?? null;
|
||||
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
@@ -817,12 +829,33 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const processEnrichment = async (
|
||||
recipients: Recipient[],
|
||||
setRecipients: (items: Recipient[]) => void
|
||||
) => {
|
||||
const hasUnenriched = recipients.some((r) => !r.extra?.enriched);
|
||||
if (!hasUnenriched) return;
|
||||
|
||||
const newChips = await enrichChipsWithColorsAndIcons(recipients);
|
||||
const fullyEnriched = newChips.map((chip) => ({
|
||||
...chip,
|
||||
extra: { ...chip.extra, enriched: true },
|
||||
}));
|
||||
setRecipients(fullyEnriched);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => { processEnrichment(to, setTo); }, [to]);
|
||||
useEffect(() => { processEnrichment(cc, setCc); }, [cc]);
|
||||
useEffect(() => { processEnrichment(bcc, setBcc); }, [bcc]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const getGroupMembers = useContactStore((s) => s.getGroupMembers);
|
||||
const searchRecipients = useContactStore((s) => s.searchRecipients);
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
@@ -899,7 +932,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<SuggestionItem>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
// Current trimmed query behind the open dropdown, plus the in-flight flag for
|
||||
@@ -930,15 +963,26 @@ export function EmailComposer({
|
||||
}
|
||||
}, [plainTextMode]);
|
||||
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
|
||||
// Move a chip from one recipient field to another. `toIndex`, when given,
|
||||
// inserts at that position in the destination (drag-and-drop reordering,
|
||||
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
|
||||
if (fromField === toField) return;
|
||||
const setters = { to: setTo, cc: setCc, bcc: setBcc };
|
||||
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
|
||||
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
|
||||
const sameRecipient = (a: Recipient, b: Recipient) =>
|
||||
a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b);
|
||||
setters[fromField](prev => {
|
||||
const idx = prev.findIndex(r => sameRecipient(r, recipient));
|
||||
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
|
||||
setters[toField](prev => {
|
||||
if (prev.some(r => sameRecipient(r, recipient))) return prev;
|
||||
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
|
||||
const next = [...prev];
|
||||
next.splice(at, 0, recipient);
|
||||
return next;
|
||||
});
|
||||
if (toField === 'cc') setShowCc(true);
|
||||
if (toField === 'bcc') setShowBcc(true);
|
||||
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
|
||||
@@ -961,9 +1005,9 @@ export function EmailComposer({
|
||||
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||
const localResults = getAutocomplete(query);
|
||||
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group }));
|
||||
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group })));
|
||||
// Keep the dropdown open even without local hits when a server search is
|
||||
// available, so the "search the server" row stays reachable (OWA-style).
|
||||
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
|
||||
@@ -998,11 +1042,30 @@ export function EmailComposer({
|
||||
}
|
||||
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
|
||||
|
||||
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
|
||||
const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
|
||||
|
||||
setter(prev => [...prev, toRecipient(suggestion)]);
|
||||
if (suggestion.group) {
|
||||
// Insert the group as a single chip carrying a snapshot of its members
|
||||
// (deduped, members without an address skipped). The chip is expanded
|
||||
// into the members when the message is sent or saved as a draft.
|
||||
const seen = new Set<string>();
|
||||
const members: Array<{ name?: string; email: string }> = [];
|
||||
for (const m of getGroupMembers(suggestion.group.id)) {
|
||||
const email = getContactPrimaryEmail(m).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const name = getContactDisplayName(m);
|
||||
members.push({ name: name && name !== email ? name : undefined, email });
|
||||
}
|
||||
if (members.length > 0) {
|
||||
setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]);
|
||||
}
|
||||
} else {
|
||||
setter(prev => [...prev, toRecipient(suggestion)]);
|
||||
}
|
||||
inputSetter('');
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
@@ -1052,13 +1115,22 @@ export function EmailComposer({
|
||||
: template.body;
|
||||
|
||||
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||
const bodyContent = plainTextMode
|
||||
const bodyContent = plainTextMode || template.isHTML
|
||||
? filledBody
|
||||
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
|
||||
if (mode === 'compose') {
|
||||
setSubject(filledSubject);
|
||||
setBody(bodyContent);
|
||||
// Compose bodies carry the embedded signature (see
|
||||
// shouldEmbedSignatureInNewMail) and the send path assumes it stays
|
||||
// there, so replace only the message content, not the signature block.
|
||||
if (plainTextMode) {
|
||||
setBody(shouldEmbedSignatureInNewMail
|
||||
? appendPlainTextSignature(bodyContent, signatureIdentity, { separator: signatureSeparatorEnabled })
|
||||
: bodyContent);
|
||||
} else {
|
||||
setBody((prev) => spliceTemplateAboveSignature(prev, bodyContent));
|
||||
}
|
||||
if (template.defaultRecipients?.to?.length) {
|
||||
setTo(template.defaultRecipients.to.map(parseRecipient));
|
||||
}
|
||||
@@ -1071,7 +1143,30 @@ export function EmailComposer({
|
||||
setShowBcc(true);
|
||||
}
|
||||
} else {
|
||||
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
|
||||
// Reply/forward: insert the template at the caret so it lands after any
|
||||
// text the user has already typed, instead of always prepending it (#539).
|
||||
if (plainTextMode) {
|
||||
const textarea = bodyRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart ?? textarea.value.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
setBody((prev) => prev.slice(0, start) + bodyContent + prev.slice(end));
|
||||
// Restore the caret just past the inserted text once React re-renders.
|
||||
requestAnimationFrame(() => {
|
||||
const caret = start + bodyContent.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(caret, caret);
|
||||
});
|
||||
} else {
|
||||
setBody((prev) => bodyContent + '\n' + prev);
|
||||
}
|
||||
} else if (editorRef.current) {
|
||||
// insertContent lands at the editor's current selection; onUpdate
|
||||
// propagates the resulting HTML back through onChange → setBody.
|
||||
editorRef.current.chain().focus().insertContent(bodyContent).run();
|
||||
} else {
|
||||
setBody((prev) => bodyContent + prev);
|
||||
}
|
||||
}
|
||||
|
||||
if (template.identityId) {
|
||||
@@ -1079,14 +1174,14 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
setShowTemplatePicker(false);
|
||||
}, [mode, plainTextMode]);
|
||||
}, [mode, plainTextMode, shouldEmbedSignatureInNewMail, signatureIdentity, signatureSeparatorEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
if (target?.getAttribute('contenteditable') === 'true') return;
|
||||
// composedPath-based check so editing inside the QuotedHtml shadow
|
||||
// island doesn't trigger the picker (#654).
|
||||
if (isEditableEventTarget(e)) return;
|
||||
if (!templatesEnabled) return;
|
||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
setShowTemplatePicker(true);
|
||||
@@ -1094,7 +1189,7 @@ export function EmailComposer({
|
||||
};
|
||||
window.addEventListener('keydown', handleTemplateKey);
|
||||
return () => window.removeEventListener('keydown', handleTemplateKey);
|
||||
}, []);
|
||||
}, [templatesEnabled]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (!client || files.length === 0) return;
|
||||
@@ -1303,9 +1398,9 @@ export function EmailComposer({
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
|
||||
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
|
||||
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
|
||||
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
|
||||
const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
|
||||
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
|
||||
return null;
|
||||
@@ -1474,7 +1569,9 @@ export function EmailComposer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toAddresses = withInput(to, toInput);
|
||||
// Groups expand here so validation and every outgoing payload see the
|
||||
// actual member addresses.
|
||||
const toAddresses = expandRecipients(withInput(to, toInput));
|
||||
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
|
||||
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||
@@ -1603,8 +1700,8 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
const ccAddresses = withInput(cc, ccInput);
|
||||
const bccAddresses = withInput(bcc, bccInput);
|
||||
const ccAddresses = expandRecipients(withInput(cc, ccInput));
|
||||
const bccAddresses = expandRecipients(withInput(bcc, bccInput));
|
||||
|
||||
if (!canSend) {
|
||||
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
|
||||
@@ -2012,7 +2109,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
@@ -2042,7 +2139,7 @@ export function EmailComposer({
|
||||
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
|
||||
<h3 className="font-semibold text-base">{t('new_message')}</h3>
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -2070,6 +2167,7 @@ export function EmailComposer({
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
@@ -2104,6 +2202,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
) : identities.length > 1 ? (
|
||||
<select
|
||||
data-testid="composer-from"
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
@@ -2116,7 +2215,7 @@ export function EmailComposer({
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
<option key={identity.id} value={identity.id} dir="ltr">
|
||||
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||
</option>
|
||||
);
|
||||
@@ -2128,24 +2227,24 @@ export function EmailComposer({
|
||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||
: identity.email;
|
||||
return (
|
||||
<option key={identity.id} value={identity.id}>
|
||||
<option key={identity.id} value={identity.id} dir="ltr">
|
||||
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
|
||||
{subAddressTag ? (
|
||||
<span className="font-mono">
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<bdi>
|
||||
{primaryIdentity?.name
|
||||
? `${primaryIdentity.name} <${primaryIdentity.email}>`
|
||||
: primaryIdentity?.email || ''}
|
||||
</>
|
||||
</bdi>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -2198,7 +2297,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{/* To field */}
|
||||
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
|
||||
<RecipientChipInput
|
||||
chips={to}
|
||||
@@ -2343,6 +2442,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
data-testid="composer-subject"
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -2512,24 +2612,26 @@ export function EmailComposer({
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowTemplatePicker(true)}
|
||||
title={t('use_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
title={t('save_as_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{templatesEnabled && <>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowTemplatePicker(true)}
|
||||
title={t('use_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
title={t('save_as_template')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</>}
|
||||
{/* Sign/encrypt controls are contributed by crypto plugins via the
|
||||
composer-toolbar slot (rendered below). */}
|
||||
|
||||
@@ -2565,6 +2667,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="rounded-e-none border-e border-primary-foreground/20"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2584,7 +2687,7 @@ export function EmailComposer({
|
||||
{showSendMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2603,6 +2706,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2725,7 +2829,7 @@ export function EmailComposer({
|
||||
</Button>
|
||||
<Button onClick={handleSaveDraftAndClose}>
|
||||
<Save className="w-4 h-4 me-2" />
|
||||
{t('save_draft')}
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2751,13 +2855,14 @@ export function EmailComposer({
|
||||
|
||||
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
id: string;
|
||||
results: Array<{ name: string; email: string }>;
|
||||
results: Array<SuggestionItem>;
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: { name: string; email: string }) => void;
|
||||
onSelect: (suggestion: SuggestionItem) => void;
|
||||
onSearchServer?: () => void;
|
||||
isSearchingServer?: boolean;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tContacts = useTranslations('contacts');
|
||||
return (
|
||||
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
@@ -2776,9 +2881,19 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
onSelect(r);
|
||||
}}
|
||||
>
|
||||
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
|
||||
{r.group ? (
|
||||
<span className="shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
|
||||
)}
|
||||
<span className="font-medium truncate">{r.name || r.email}</span>
|
||||
{r.name && (
|
||||
{r.group ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{tContacts('groups.member_count', { count: r.group.memberCount })}
|
||||
</span>
|
||||
) : r.name && (
|
||||
<span className="text-muted-foreground truncate"><{r.email}></span>
|
||||
)}
|
||||
</button>
|
||||
@@ -2844,10 +2959,10 @@ function RecipientChipInput({
|
||||
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
|
||||
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
|
||||
activeAutoField: 'to' | 'cc' | 'bcc' | null;
|
||||
autocompleteResults: Array<{ name: string; email: string }>;
|
||||
autocompleteResults: Array<SuggestionItem>;
|
||||
autoSelectedIndex: number;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
|
||||
onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void;
|
||||
canSearchServer: boolean;
|
||||
onServerSearch: () => void;
|
||||
isSearchingServer: boolean;
|
||||
@@ -2855,7 +2970,7 @@ function RecipientChipInput({
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
|
||||
}) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -2864,6 +2979,9 @@ function RecipientChipInput({
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
|
||||
// caret and positional drop for reordering (#593). null when not dragging.
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Focus edit input when editing starts
|
||||
@@ -2880,7 +2998,9 @@ function RecipientChipInput({
|
||||
|
||||
// Format a recipient for display in a chip / context menu
|
||||
const formatChipDisplay = (r: Recipient): string =>
|
||||
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
|
||||
r.group
|
||||
? `${r.name || 'Group'} (${r.group.members.length})`
|
||||
: r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
|
||||
|
||||
// Handle saving an edited chip
|
||||
const handleSaveEdit = (newValue: string) => {
|
||||
@@ -2900,10 +3020,10 @@ function RecipientChipInput({
|
||||
setEditingChip(null);
|
||||
return;
|
||||
}
|
||||
newChip = { name: chip.name, email: trimmedNew };
|
||||
newChip = { ...chip, email: trimmedNew };
|
||||
} else {
|
||||
// Update name, keep email. Empty name clears the display name.
|
||||
newChip = { name: trimmedNew || undefined, email: chip.email };
|
||||
newChip = { ...chip, name: trimmedNew || undefined };
|
||||
}
|
||||
|
||||
const newChips = [...chips];
|
||||
@@ -3018,27 +3138,86 @@ function RecipientChipInput({
|
||||
onAutoBlur(e, field);
|
||||
};
|
||||
|
||||
const isChipDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes('application/x-recipient-chip');
|
||||
|
||||
// Dragging over empty container space (past the last chip / over the input)
|
||||
// targets the end of the list.
|
||||
const handleContainerDragOver = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setIsDragOver(true);
|
||||
setDropIndex(chips.length);
|
||||
};
|
||||
|
||||
const handleContainerDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Dragging over a chip picks the gap before or after it based on which half
|
||||
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
|
||||
// handler from overriding this finer target.
|
||||
const handleChipDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rtl = typeof window !== 'undefined' &&
|
||||
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
|
||||
const past = rtl
|
||||
? e.clientX < rect.left + rect.width / 2
|
||||
: e.clientX > rect.left + rect.width / 2;
|
||||
setIsDragOver(true);
|
||||
setDropIndex(past ? index + 1 : index);
|
||||
};
|
||||
|
||||
// Insert the dragged chip at `target`. Same-field is a local reorder;
|
||||
// cross-field routes through onMoveChip with the destination index (#593).
|
||||
const performDrop = (e: React.DragEvent, target: number) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
setDraggingIndex(null);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { recipient, fromField, fromIndex } = payload;
|
||||
const to = Math.max(0, Math.min(target, chips.length));
|
||||
|
||||
if (fromField === field) {
|
||||
const from = typeof fromIndex === 'number'
|
||||
? fromIndex
|
||||
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
|
||||
if (from < 0 || from >= chips.length) return;
|
||||
// Removing the source before `to` shifts the target left by one.
|
||||
const insertAt = to > from ? to - 1 : to;
|
||||
if (insertAt === from) return; // dropped onto its own position
|
||||
const next = [...chips];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(insertAt, 0, moved);
|
||||
onChipsChange(next);
|
||||
} else {
|
||||
onMoveChip(recipient, fromField, field, to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
|
||||
if (fromField === field) return;
|
||||
onMoveChip(recipient, fromField, field);
|
||||
performDrop(e, dropIndex ?? chips.length);
|
||||
};
|
||||
const colorStyles: Record<'success' | 'destructive' | 'warning', string> = {
|
||||
success: "bg-success/15 text-secondary-foreground hover:bg-success/30 !border-success",
|
||||
destructive: "bg-destructive/15 text-secondary-foreground hover:bg-destructive/30 !border-destructive",
|
||||
warning: "bg-warning/15 text-secondary-foreground hover:bg-warning/30 !border-warning",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3057,30 +3236,60 @@ function RecipientChipInput({
|
||||
{chips.map((chip, i) => {
|
||||
const isEditing = editingChip?.index === i;
|
||||
const chipDisplay = formatChipDisplay(chip);
|
||||
let IconComponent = null;
|
||||
if(chip.extra?.icon){
|
||||
IconComponent = ICON_MAP[chip.extra?.icon];
|
||||
}
|
||||
const customColor = chip.extra?.color;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${chip.email}-${i}`}>
|
||||
{dropIndex === i && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
key={`${chip.email}-${i}`}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
|
||||
// Show the address while dragging, matching the email-list drag preview.
|
||||
const dragPreview = createChipDragPreview(chip.email);
|
||||
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
requestAnimationFrame(() => dragPreview.remove());
|
||||
setDraggingIndex(i);
|
||||
}}
|
||||
onDragEnd={() => setDraggingIndex(null)}
|
||||
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
|
||||
onDragOver={(e) => handleChipDragOver(e, i)}
|
||||
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||
isEditing
|
||||
? "bg-background ring-1 ring-ring"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing",
|
||||
: ( customColor && colorStyles[customColor]
|
||||
? `${colorStyles[customColor]} cursor-grab active:cursor-grabbing`
|
||||
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing"),
|
||||
!isEditing && draggingIndex === i && "opacity-50"
|
||||
)}
|
||||
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
customColor === "success"
|
||||
? "text-success"
|
||||
: customColor === "warning"
|
||||
? "text-warning"
|
||||
: "text-destructive"
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
@@ -3109,7 +3318,13 @@ function RecipientChipInput({
|
||||
data-bwignore="true"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[200px]">{chipDisplay}</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 max-w-[200px]"
|
||||
title={chip.group ? chip.group.members.map(m => m.email).join(', ') : undefined}
|
||||
>
|
||||
{chip.group && <Users className="w-3 h-3 shrink-0" aria-hidden />}
|
||||
<span className="truncate">{chipDisplay}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -3131,8 +3346,16 @@ function RecipientChipInput({
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{dropIndex === chips.length && chips.length > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{!editingChip && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -3185,7 +3408,9 @@ function RecipientChipInput({
|
||||
{formatChipDisplay(contextMenu.data.recipient)}
|
||||
</div>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
{!contextMenu.data.recipient.group && (
|
||||
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
)}
|
||||
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem label={tCommon('delete')} onClick={() => {
|
||||
|
||||
@@ -295,6 +295,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete")}
|
||||
testId="ctx-delete"
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||
}
|
||||
@@ -306,7 +307,7 @@ 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) => {
|
||||
@@ -319,6 +320,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={nodeLabel}
|
||||
testId={`move-to:${node.id}`}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
@@ -410,6 +412,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
showBatchActions
|
||||
@@ -429,6 +432,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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
@@ -191,6 +191,22 @@ export function EmailList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUndoSpam = async () => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const emailIds = Array.from(selectedEmailIds);
|
||||
await batchUndoSpam(client, emailIds);
|
||||
const { toast } = await import('sonner');
|
||||
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
|
||||
} catch {
|
||||
const { toast } = await import('sonner');
|
||||
toast.error(t('../email_viewer.spam.error_not_spam'));
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!client || isProcessing) return;
|
||||
|
||||
@@ -357,6 +373,22 @@ export function EmailList({
|
||||
<Mail className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{effectiveMailboxRole === 'junk' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBatchUndoSpam}
|
||||
title={t('../context_menu.not_spam')}
|
||||
disabled={isProcessing}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -5,8 +5,9 @@ import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -560,6 +561,8 @@ export function ContactSidebarPanel({
|
||||
interface DraggableAttachmentChipProps {
|
||||
attachment: EffectiveAttachment;
|
||||
client: IJMAPClient | null;
|
||||
/** Owner accountId for the blob when it lives in a delegated/shared account. */
|
||||
accountId?: string;
|
||||
enabled: boolean;
|
||||
downloadName?: string;
|
||||
children: (dragProps: {
|
||||
@@ -570,14 +573,14 @@ interface DraggableAttachmentChipProps {
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||
const source = useMemo<AttachmentDragSource>(() => ({
|
||||
name: downloadName || attachment.name || 'download',
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
getBlobUrl: async () => {
|
||||
if (attachment.blobId && client) {
|
||||
try {
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
|
||||
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -595,7 +598,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}), [attachment, client, downloadName]);
|
||||
}), [attachment, client, accountId, downloadName]);
|
||||
const drag = useAttachmentDrag(source, enabled);
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
@@ -652,6 +655,7 @@ export function EmailViewer({
|
||||
const tDemoWelcome = useTranslations('demo_welcome');
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
@@ -714,6 +718,18 @@ export function EmailViewer({
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
|
||||
// downloads) are account-scoped. In the unified / All-Mail view the open
|
||||
// message may belong to another login (route to its client) or a delegated
|
||||
// shared account (same client, owner accountId in the URL). Resolve both from
|
||||
// the message's source so cross-account blob fetches don't 404 against the
|
||||
// active account.
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const blobClient = useMemo(() => {
|
||||
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
|
||||
}, [isUnifiedView, email?.sourceClientAccountId, client]);
|
||||
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
|
||||
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
|
||||
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
||||
@@ -1175,6 +1191,7 @@ export function EmailViewer({
|
||||
id: email.id,
|
||||
contentType,
|
||||
bodyStructure: email.bodyStructure,
|
||||
bodyValues: email.bodyValues,
|
||||
attachments: email.attachments,
|
||||
blobId: email.blobId,
|
||||
from: email.from,
|
||||
@@ -1250,7 +1267,7 @@ export function EmailViewer({
|
||||
async function processTnef() {
|
||||
try {
|
||||
debug.time('TNEF fetch blob', 'email');
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
|
||||
debug.timeEnd('TNEF fetch blob', 'email');
|
||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||
|
||||
@@ -1303,7 +1320,7 @@ export function EmailViewer({
|
||||
processTnef();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [email, client]);
|
||||
}, [email, client, blobClient, blobAccountId]);
|
||||
|
||||
// Embedded message/rfc822 unwrapping
|
||||
// When Outlook forwards an email as an attachment, the outer email body is
|
||||
@@ -1340,7 +1357,7 @@ export function EmailViewer({
|
||||
|
||||
async function unwrapEmbedded() {
|
||||
try {
|
||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
||||
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
if (blobBytes.byteLength === 0) {
|
||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||
@@ -1380,7 +1397,7 @@ export function EmailViewer({
|
||||
unwrapEmbedded();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [email, client]);
|
||||
}, [email, client, blobClient, blobAccountId]);
|
||||
|
||||
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||
useEffect(() => {
|
||||
@@ -1426,7 +1443,7 @@ export function EmailViewer({
|
||||
await Promise.all(cidAttachments.map(async (att) => {
|
||||
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
||||
try {
|
||||
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
|
||||
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
|
||||
if (!cancelled) {
|
||||
urls[cidValue] = objectUrl;
|
||||
objectUrls.push(objectUrl);
|
||||
@@ -1448,7 +1465,7 @@ export function EmailViewer({
|
||||
cancelled = true;
|
||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
|
||||
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
|
||||
|
||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||
if (pluginRenderedAttachments.length > 0) {
|
||||
@@ -1647,10 +1664,8 @@ export function EmailViewer({
|
||||
}
|
||||
}
|
||||
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
// http(s) links open in a new tab; other schemes keep their default.
|
||||
applyNewTabToAnchor(node);
|
||||
|
||||
// No dark mode color transforms - emails render true-to-life in iframe
|
||||
});
|
||||
@@ -1688,7 +1703,11 @@ export function EmailViewer({
|
||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||
|
||||
return {
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
}),
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
externalBlocked: false,
|
||||
@@ -1728,6 +1747,11 @@ export function EmailViewer({
|
||||
|
||||
// Override email content with S/MIME decrypted content when available
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
const plainToHtml = (text: string) =>
|
||||
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
});
|
||||
if (pluginRenderedHtml) {
|
||||
const htmlWithCidUrls = pluginRenderedHtml.replace(
|
||||
/\bcid:([^"'\s)]+)/gi,
|
||||
@@ -1739,7 +1763,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
|
||||
}
|
||||
if (pluginRenderedText) {
|
||||
return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
@@ -1747,7 +1771,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
|
||||
}
|
||||
if (tnefText) {
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
@@ -1755,10 +1779,10 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
|
||||
|
||||
const resolveAttachmentName = useCallback(
|
||||
(attachment: EffectiveAttachment) => {
|
||||
@@ -1926,8 +1950,8 @@ export function EmailViewer({
|
||||
for (const attachment of effectiveAttachments) {
|
||||
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
||||
try {
|
||||
if (attachment.blobId && client) {
|
||||
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
|
||||
if (attachment.blobId && blobClient) {
|
||||
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
|
||||
zip.file(entryName, blob);
|
||||
added++;
|
||||
} else if (attachment.tnefData) {
|
||||
@@ -1959,7 +1983,7 @@ export function EmailViewer({
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, client, email]);
|
||||
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
|
||||
|
||||
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
||||
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
||||
@@ -2001,8 +2025,8 @@ export function EmailViewer({
|
||||
await Promise.all(imageAttachments.map(async (att) => {
|
||||
let url: string | undefined;
|
||||
try {
|
||||
if (att.blobId && client) {
|
||||
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
|
||||
if (att.blobId && blobClient) {
|
||||
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
|
||||
} else if (att.decryptedAttachment) {
|
||||
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
|
||||
if (!bytes || bytes.byteLength === 0) return;
|
||||
@@ -2033,7 +2057,7 @@ export function EmailViewer({
|
||||
cancelled = true;
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
|
||||
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
|
||||
|
||||
// Iframe for rendering HTML emails true-to-life
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -2103,9 +2127,21 @@ export function EmailViewer({
|
||||
// Word/Outlook HTML emails ship a <style> block but put their gutter in
|
||||
// @page margins (print-only), so they need a fallback body padding too.
|
||||
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
|
||||
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
|
||||
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
|
||||
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
|
||||
// background canvas (a width:100% element carrying a background colour) --
|
||||
// the one case where the gutter shows as a frame around the email's own
|
||||
// background. A <style> tag alone is too weak a signal: plenty of
|
||||
// transactional mails ship one for web fonts yet have no gutter of their
|
||||
// own, and zeroing the padding glues their content to the corner.
|
||||
const emailHtml = effectiveEmailContent.html;
|
||||
const hasFullBleedCanvas =
|
||||
/<(?:table|div|body)\b[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)/i.test(emailHtml) ||
|
||||
/<(?:table|div|body)\b[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)/i.test(emailHtml);
|
||||
const autoDropsGutter = effectiveEmailContent.hasStyleTag && !isWordHtml && hasFullBleedCanvas;
|
||||
const dropGutter =
|
||||
messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
|
||||
const bodyPadding = dropGutter ? '0' : '1rem 1.25rem';
|
||||
const mobileBodyPaddingX = dropGutter ? '0' : '0.75rem';
|
||||
|
||||
// Word emails rely on empty <p class=MsoNormal> </p> spacers for vertical
|
||||
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
|
||||
@@ -2166,7 +2202,7 @@ export function EmailViewer({
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
|
||||
|
||||
// Unblocking external content is handled by rebuilding the iframe srcDoc:
|
||||
// toggling allowExternalContent (both "Load images" and "Trust sender" set
|
||||
@@ -2190,8 +2226,20 @@ export function EmailViewer({
|
||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||
// it doesn't flash in below a still-resizing iframe.
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// Tracks which parsed document we've already wired up, so setup runs exactly
|
||||
// once per srcDoc even though both the readiness poll below and the iframe
|
||||
// 'load' event can trigger it.
|
||||
const initializedDocRef = useRef<Document | null>(null);
|
||||
// The document present at the instant srcDoc changed - i.e. the one about to
|
||||
// be torn down. contentDocument keeps pointing at it until the browser swaps
|
||||
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
|
||||
const staleDocRef = useRef<Document | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
setIframeReady(false);
|
||||
initializedDocRef.current = null;
|
||||
// Runs during commit, before the browser processes the new srcDoc, so
|
||||
// contentDocument here is still the outgoing document.
|
||||
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
|
||||
}, [emailIframeSrcDoc]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
@@ -2199,7 +2247,21 @@ export function EmailViewer({
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc?.body) {
|
||||
// Ignore the outgoing document, a transient about:blank (a fresh srcDoc
|
||||
// document reports URL 'about:srcdoc'), and anything that hasn't finished
|
||||
// parsing yet; run the setup below at most once per document.
|
||||
if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return;
|
||||
if (initializedDocRef.current === doc) return;
|
||||
initializedDocRef.current = doc;
|
||||
{
|
||||
// Collapse the quoted original of a reply behind a "•••" toggle
|
||||
// (#480). Before the height wiring, so the initial measurement
|
||||
// already reflects the collapsed body.
|
||||
setupQuoteCollapse(doc, {
|
||||
show: t('show_quoted_text'),
|
||||
hide: t('hide_quoted_text'),
|
||||
});
|
||||
|
||||
// Auto-resize iframe to fit content
|
||||
// Measure max(documentElement, body): a height:100% wrapper can leave
|
||||
// documentElement.scrollHeight short while the real content lives in body.
|
||||
@@ -2247,11 +2309,9 @@ export function EmailViewer({
|
||||
}
|
||||
});
|
||||
|
||||
// Make links open in new tab
|
||||
doc.querySelectorAll('a').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
// Second pass over the rendered iframe DOM (the hook above only sees
|
||||
// DOMPurify's output); http(s) → new tab, other schemes left in place.
|
||||
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
|
||||
|
||||
// Plugin intercept: let plugins cancel or rewrite external links inside
|
||||
// the email body before navigation happens. Bound on the iframe doc so
|
||||
@@ -2373,7 +2433,27 @@ export function EmailViewer({
|
||||
} catch {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, [isDark, emailHasNativeDarkMode, email?.id]);
|
||||
}, [isDark, emailHasNativeDarkMode, email?.id, t]);
|
||||
|
||||
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
|
||||
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
|
||||
// so a single unreachable remote image (server accepts the TCP connection but
|
||||
// never responds) stalls it for the browser's ~60s timeout - freezing the body
|
||||
// at its placeholder height that entire time. The parsed DOM we need for
|
||||
// height, links and dark-mode is ready long before images resolve. Poll the
|
||||
// fresh document's readyState because a sandbox without allow-scripts can't
|
||||
// postMessage a DOMContentLoaded signal out, and the onLoad handler is
|
||||
// idempotent per document so it stays a harmless backstop.
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
const readyPoll = window.setInterval(() => {
|
||||
handleIframeLoad();
|
||||
if (initializedDocRef.current) window.clearInterval(readyPoll);
|
||||
}, 50);
|
||||
// Safety stop: the 'load' backstop covers anything the poll somehow misses.
|
||||
const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000);
|
||||
return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); };
|
||||
}, [emailIframeSrcDoc, handleIframeLoad]);
|
||||
|
||||
// Export email as .eml file
|
||||
const handleExportEmail = async () => {
|
||||
@@ -2781,6 +2861,7 @@ export function EmailViewer({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEditDraft()}
|
||||
data-testid="edit-draft"
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
@@ -3692,7 +3773,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -3703,6 +3784,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -3730,7 +3813,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -3767,13 +3850,13 @@ export function EmailViewer({
|
||||
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -3791,7 +3874,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -4466,7 +4549,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4477,6 +4560,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -4509,7 +4594,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-r-md rounded-l-none",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -4546,13 +4631,13 @@ export function EmailViewer({
|
||||
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
|
||||
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4570,7 +4655,7 @@ export function EmailViewer({
|
||||
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -4610,7 +4695,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -4621,6 +4706,8 @@ export function EmailViewer({
|
||||
)}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name}
|
||||
draggable={dragProps.draggable}
|
||||
onPointerEnter={dragProps.onPointerEnter}
|
||||
onDragStart={dragProps.onDragStart}
|
||||
@@ -4648,7 +4735,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -4684,13 +4771,13 @@ export function EmailViewer({
|
||||
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
@@ -4708,7 +4795,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
// Native tab strip for plugin-registered message-list category tabs
|
||||
// (Gmail-style Primary / Promotions / Social / Updates). Renders above the
|
||||
// email list; the active tab's resolved JMAP filter is ANDed into the
|
||||
// mailbox query by email-store.fetchEmails. Plugins only contribute tab
|
||||
// DEFINITIONS (stores/message-list-tabs-store.ts) - no plugin iframe here.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MessageListTabs() {
|
||||
const tabs = useMessageListTabsStore((s) => s.tabs);
|
||||
const mailboxRoles = useMessageListTabsStore((s) => s.mailboxRoles);
|
||||
const activeTabId = useMessageListTabsStore((s) => s.activeTabId);
|
||||
const tabCounts = useMessageListTabsStore((s) => s.tabCounts);
|
||||
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const selectedKeyword = useEmailStore((s) => s.selectedKeyword);
|
||||
const searchQuery = useEmailStore((s) => s.searchQuery);
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox);
|
||||
const role = mailbox?.role?.toLowerCase() ?? null;
|
||||
// Tabs only make sense on a plain mailbox view: tag views, searches and
|
||||
// unified fan-outs bypass the category filter in fetchEmails, so the strip
|
||||
// must disappear rather than lie about what's being shown.
|
||||
const visible =
|
||||
tabs.length > 0 &&
|
||||
!!role &&
|
||||
mailboxRoles.includes(role) &&
|
||||
!selectedKeyword &&
|
||||
!searchQuery &&
|
||||
!isUnifiedView;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || !mailbox) return;
|
||||
const jmapMailboxId = mailbox.originalId || mailbox.id;
|
||||
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
void useMessageListTabsStore.getState().refreshCounts(client, jmapMailboxId, accountId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, client, selectedMailbox, tabs]);
|
||||
|
||||
// A plugin registering (or clearing) tabs after the list was fetched leaves
|
||||
// the visible list out of sync with the strip's active-tab filter - refetch
|
||||
// exactly when the merged tab set changes, never on ordinary view switches
|
||||
// (those already refetch through their own flows).
|
||||
const prevTabsRef = useRef(tabs);
|
||||
useEffect(() => {
|
||||
if (prevTabsRef.current === tabs) return;
|
||||
prevTabsRef.current = tabs;
|
||||
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||
}, [tabs, client]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleSelect = (tabId: string) => {
|
||||
if (tabId === activeTabId) return;
|
||||
useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox);
|
||||
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-stretch gap-1 px-2 border-b border-border overflow-x-auto shrink-0"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
role="tablist"
|
||||
aria-label="Inbox categories"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
? (lucideIcons[tab.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: undefined;
|
||||
const unread = tabCounts[tab.id] ?? 0;
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(tab.id)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-1.5 px-3.5 py-2.5 text-sm whitespace-nowrap select-none',
|
||||
'border-b-2 -mb-px rounded-t-md transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'border-primary text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
style={isActive && tab.color ? { borderBottomColor: tab.color } : undefined}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn('h-4 w-4 flex-shrink-0', !isActive && 'opacity-70')}
|
||||
style={isActive && tab.color ? { color: tab.color } : undefined}
|
||||
/>
|
||||
)}
|
||||
<span>{tab.label}</span>
|
||||
{tab.showUnreadBadge !== false && unread > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold tabular-nums',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
title={`${unread} unread`}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
// HTML, so parseHTML can recognise it on the way back in.
|
||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||
|
||||
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||
const QUOTE_BAR_STYLE =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
|
||||
/**
|
||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||
dom.className = "quoted-html-island";
|
||||
dom.style.cssText =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||
|
||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayLabel || name || email}
|
||||
<bdi>{displayLabel || name || email}</bdi>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Table as TableIcon,
|
||||
Baseline,
|
||||
Trash2,
|
||||
Rows3,
|
||||
Columns3,
|
||||
@@ -143,6 +144,14 @@ function ToolbarSeparator() {
|
||||
const TABLE_PICKER_ROWS = 6;
|
||||
const TABLE_PICKER_COLS = 8;
|
||||
|
||||
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||||
// round-trips; the TextStyle/Color extensions are already registered to
|
||||
// preserve pasted colours - this palette just adds a UI to set them.
|
||||
const TEXT_COLORS = [
|
||||
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||||
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||||
];
|
||||
|
||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||
return (
|
||||
@@ -336,6 +345,19 @@ export function RichTextEditor({
|
||||
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||||
setColorMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [colorMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableMenuOpen) return;
|
||||
@@ -386,6 +408,49 @@ export function RichTextEditor({
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<div ref={colorWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
active={!!editor.getAttributes("textStyle").color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title="Text color"
|
||||
>
|
||||
{/* The icon itself previews the active colour - no layout shift. */}
|
||||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||
</ToolbarButton>
|
||||
{colorMenuOpen && (
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||||
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" /> Remove color
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
@@ -494,7 +559,7 @@ export function RichTextEditor({
|
||||
<TableIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{tableMenuOpen && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||
{editor.isActive("table") ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||
|
||||
/**
|
||||
* Force every link in the rendered signature to open in a new tab.
|
||||
*
|
||||
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
|
||||
* what serializeEditorContent emits into the sent message, and the recipient's
|
||||
* copy should stay exactly as the user wrote it. Without this the composer's
|
||||
* signature is a set of live, target-less anchors in the main document (the
|
||||
* message body gets a sandboxed iframe; this does not), so one stray click
|
||||
* navigates the whole app away and takes the unsent draft with it.
|
||||
*/
|
||||
function forceLinksToNewTab(root: HTMLElement): void {
|
||||
root.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
|
||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||
dom.className = "signature-block-island";
|
||||
|
||||
|
||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||
// would otherwise cascade INTO the signature and destroy its layout -
|
||||
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
|
||||
const inner = document.createElement("div");
|
||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||
inner.contentEditable = "false";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
// Track what we were given, not what's in the DOM: forceLinksToNewTab
|
||||
// rewrites the markup, so inner.innerHTML no longer round-trips against
|
||||
// attrs.html and comparing the two would rewrite on every transaction.
|
||||
let appliedHtml = node.attrs.html || "";
|
||||
inner.innerHTML = appliedHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
shadow.appendChild(inner);
|
||||
|
||||
return {
|
||||
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
|
||||
stopEvent: () => false,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
const nextHtml = updatedNode.attrs.html || "";
|
||||
if (nextHtml !== appliedHtml) {
|
||||
appliedHtml = nextHtml;
|
||||
inner.innerHTML = nextHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
|
||||
|
||||
/**
|
||||
* Adds a `dir` attribute to block nodes so the composer can mark individual
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
|
||||
*
|
||||
* The default is `"auto"`: each block detects its own direction from its first
|
||||
* strong character, so a paragraph typed in English renders LTR and one typed
|
||||
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
|
||||
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
|
||||
* attribute round-trips to HTML so the direction is preserved in the sent mail.
|
||||
*/
|
||||
export const TextDirection = Extension.create({
|
||||
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
dir: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("dir") || null,
|
||||
default: "auto",
|
||||
parseHTML: (element) => element.getAttribute("dir") || "auto",
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dir ? { dir: attributes.dir } : {},
|
||||
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -424,7 +425,14 @@ function EmailCard({
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||
return {
|
||||
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
}),
|
||||
isHtml: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +446,7 @@ function EmailCard({
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
|
||||
|
||||
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||
@@ -469,6 +477,12 @@ function EmailCard({
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc?.body) return;
|
||||
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
|
||||
// before the first resize so the height reflects the collapsed body.
|
||||
setupQuoteCollapse(doc, {
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
});
|
||||
const resize = () => {
|
||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||
};
|
||||
@@ -482,7 +496,7 @@ function EmailCard({
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
@@ -97,7 +97,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
// Show the originating folder in the aggregate "All …" views.
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
|
||||
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
ref={ref}
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
data-testid="email-list-item"
|
||||
data-email-id={email.id}
|
||||
data-subject={email.subject || ''}
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
@@ -459,7 +463,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
||||
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
@@ -660,7 +664,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onToggle={toggleThreadSelection}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
@@ -892,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -147,7 +147,7 @@ export function UnsubscribeBanner({
|
||||
{showConfirm && isDesktop && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
||||
className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
|
||||
>
|
||||
<p className="text-sm text-foreground mb-2">
|
||||
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
|
||||
|
||||
/**
|
||||
* Badges the browser-tab favicon with the inbox unread count, so new mail is
|
||||
* visible without focusing the tab. See issue #560.
|
||||
*
|
||||
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
|
||||
* default.
|
||||
*
|
||||
* Mounted in the root layout rather than on the mail route: the badge belongs
|
||||
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
|
||||
* cleared the badge, and flickered the icon — on every hop to /settings,
|
||||
* /calendar or /contacts.
|
||||
*
|
||||
* Renders nothing.
|
||||
*/
|
||||
export function FaviconBadge() {
|
||||
// The store's canonical inbox selector. `role === 'inbox'` alone is not
|
||||
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
|
||||
// a delegated setup the first match can be somebody else's inbox.
|
||||
const inboxUnread = useEmailStore(
|
||||
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
|
||||
);
|
||||
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
|
||||
|
||||
useFaviconBadge(inboxUnread, enabled);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{message.from && (
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||
<div><span className="font-medium text-foreground">{t("from")}: </span><bdi>{formatAddress(message.from)}</bdi></div>
|
||||
)}
|
||||
{message.to && message.to.length > 0 && (
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeSignatureHtml(formData.htmlSignature)
|
||||
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
// Persist the choice per account in the synced settings store so it
|
||||
// survives clearing site data, follows the user across devices, and shows
|
||||
// up in exported settings (issue #507). JMAP identity ids are account-
|
||||
// scoped, so the default is keyed by the active account.
|
||||
const activeAccountId = useAccountStore.getState().activeAccountId;
|
||||
if (activeAccountId) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
[activeAccountId]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SubAddressHelper({
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'absolute top-full right-0 mt-1 z-50',
|
||||
'absolute top-full end-0 mt-1 z-50',
|
||||
'bg-background border border-border rounded-lg shadow-lg',
|
||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -48,23 +49,41 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const rtl = isDocumentRTL();
|
||||
if (variant === "rail") {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
setPopoverStyle(
|
||||
rtl
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: rect.bottom + 4,
|
||||
});
|
||||
setPopoverStyle(
|
||||
rtl
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.right,
|
||||
top: rect.bottom + 4,
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: rect.bottom + 4,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
@@ -100,6 +119,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
router.push(`/login?mode=add-account` as never);
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
|
||||
e.stopPropagation();
|
||||
const label = account.email || account.username;
|
||||
if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
|
||||
removeAccount(account.id);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
@@ -151,6 +177,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
data-testid="account-switcher"
|
||||
data-active-account-id={activeAccountId ?? undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md transition-colors",
|
||||
variant === "rail"
|
||||
@@ -213,10 +241,13 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSwitch(account.id)}
|
||||
data-testid="account-option"
|
||||
data-account-id={account.id}
|
||||
data-account-email={account.email || account.username}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isDraggable && "pe-7"
|
||||
(!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
|
||||
)}
|
||||
role="menuitem"
|
||||
disabled={isActive}
|
||||
@@ -259,11 +290,22 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
{isDraggable && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
{!isActive && !account.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemove(e, account)}
|
||||
aria-label={t("remove_account")}
|
||||
title={t("remove_account")}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -274,6 +316,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
data-testid="add-account"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
@@ -70,11 +71,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
});
|
||||
setPopoverStyle(
|
||||
isDocumentRTL()
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -218,11 +227,19 @@ export function NavigationRail({
|
||||
const updateLogoutPosition = useCallback(() => {
|
||||
if (!logoutBtnRef.current) return;
|
||||
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||
setLogoutPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
setLogoutPopoverStyle(
|
||||
isDocumentRTL()
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
@@ -79,9 +80,10 @@ interface SidebarProps {
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
|
||||
showAllMailMailbox?: boolean;
|
||||
/** Gated cross-account views in the "All accounts" section. */
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
* Drives the section header: "All accounts" when true, else "Unified Mailbox". */
|
||||
crossAccountActive?: boolean;
|
||||
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
|
||||
showCrossUnread?: boolean;
|
||||
showCrossStarred?: boolean;
|
||||
showCrossAll?: boolean;
|
||||
@@ -220,8 +222,13 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
|
||||
<span
|
||||
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
|
||||
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
|
||||
data-testid="folder-counts"
|
||||
data-unread={unreadCount}
|
||||
data-total={totalCount}
|
||||
>
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -249,6 +256,11 @@ interface SidebarRowProps {
|
||||
isValidDropTarget?: boolean;
|
||||
isInvalidDropTarget?: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
/** Stable identifiers for integration tests (not user-visible). */
|
||||
testRole?: string | null;
|
||||
testName?: string;
|
||||
testMailboxId?: string;
|
||||
testShared?: boolean;
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
@@ -269,6 +281,10 @@ function SidebarRow({
|
||||
isValidDropTarget,
|
||||
isInvalidDropTarget,
|
||||
onContextMenu,
|
||||
testRole,
|
||||
testName,
|
||||
testMailboxId,
|
||||
testShared,
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||
@@ -277,6 +293,11 @@ function SidebarRow({
|
||||
<div
|
||||
{...(dropHandlers || {})}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid="folder-row"
|
||||
data-folder-role={testRole ?? undefined}
|
||||
data-folder-name={testName ?? undefined}
|
||||
data-mailbox-id={testMailboxId ?? undefined}
|
||||
data-shared={testShared ? 'true' : undefined}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||
@@ -356,6 +377,7 @@ function SidebarSectionHeader({
|
||||
first,
|
||||
icon,
|
||||
sub,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
@@ -366,6 +388,7 @@ function SidebarSectionHeader({
|
||||
first?: boolean;
|
||||
icon?: ReactNode;
|
||||
sub?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
if (isCollapsed) {
|
||||
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
||||
@@ -380,6 +403,9 @@ function SidebarSectionHeader({
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
data-testid={testId}
|
||||
data-section-name={label}
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
||||
paddingX,
|
||||
@@ -477,6 +503,10 @@ function MailboxTreeItem({
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||
label={label}
|
||||
testRole={node.role}
|
||||
testName={node.name}
|
||||
testMailboxId={node.id}
|
||||
testShared={node.isShared}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isVirtual={isVirtualNode}
|
||||
@@ -692,7 +722,7 @@ export function Sidebar({
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
showAllMailMailbox = false,
|
||||
crossAccountActive = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
showCrossAll = false,
|
||||
@@ -858,16 +888,8 @@ export function Sidebar({
|
||||
// window listener, so without this guard typing in a new email (the
|
||||
// contentEditable composer, the subject field, search, etc.) toggled the
|
||||
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// composedPath-based so it also sees the QuotedHtml shadow island (#654).
|
||||
if (isEditableEventTarget(e)) return;
|
||||
if (!selectedMailbox || isCollapsed) return;
|
||||
|
||||
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
|
||||
@@ -1017,20 +1039,10 @@ export function Sidebar({
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
{showAllMailMailbox && (
|
||||
<SidebarRow
|
||||
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('mailboxes.all_mail')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
|
||||
onClick={() => onMailboxSelect?.('__all_mail__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
|
||||
expanded={unifiedExpanded}
|
||||
onToggle={toggleUnified}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -1047,6 +1059,9 @@ export function Sidebar({
|
||||
key={unifiedId}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
|
||||
label={t(`unified_${count.role}`)}
|
||||
testRole={count.role}
|
||||
testName={`unified-${count.role}`}
|
||||
testMailboxId={unifiedId}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={count.unreadEmails}
|
||||
@@ -1068,6 +1083,8 @@ export function Sidebar({
|
||||
key={id}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
|
||||
label={label}
|
||||
testName={id}
|
||||
testMailboxId={id}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unread}
|
||||
@@ -1197,6 +1214,7 @@ export function Sidebar({
|
||||
expanded={sharedExpanded}
|
||||
onToggle={toggleShared}
|
||||
isCollapsed={isCollapsed}
|
||||
testId="section-shared"
|
||||
/>
|
||||
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
@@ -1211,6 +1229,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
/>
|
||||
{accountExpanded && !isCollapsed && account.children.map((child) => (
|
||||
<MailboxTreeItem
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -19,8 +20,6 @@ interface ProEmailTabBodyProps {
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
@@ -28,8 +27,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
||||
...getQuoteBodies(email),
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
attachments: email.attachments,
|
||||
@@ -235,8 +233,13 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const bodyText = email.bodyValues
|
||||
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
||||
: '';
|
||||
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
|
||||
? email.bodyValues[email.htmlBody[0].partId].value
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = email.htmlBody?.[0];
|
||||
const htmlBody = draftHtmlPart?.partId
|
||||
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
||||
&& email.bodyValues?.[draftHtmlPart.partId]
|
||||
? email.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Preserve the identity that matches the draft's From address.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import arMessages from '@/locales/ar/common.json';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
@@ -31,6 +32,7 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
ar: arMessages,
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
@@ -54,6 +55,7 @@ export function AboutDataSettings() {
|
||||
useSettingsStore();
|
||||
const { settingsSyncEnabled } = useConfig();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
@@ -105,6 +107,15 @@ export function AboutDataSettings() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleRefreshCache = () => {
|
||||
if (showRefreshConfirm) {
|
||||
clearCachedData(); // reloads the page
|
||||
} else {
|
||||
setShowRefreshConfirm(true);
|
||||
setTimeout(() => setShowRefreshConfirm(false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (showResetConfirm) {
|
||||
resetToDefaults();
|
||||
@@ -187,6 +198,16 @@ export function AboutDataSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
|
||||
<Button
|
||||
variant={showRefreshConfirm ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleRefreshCache}
|
||||
>
|
||||
{showRefreshConfirm ? tCommon('yes') : t('refresh_cache.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
|
||||
<Button
|
||||
variant={showResetConfirm ? 'destructive' : 'outline'}
|
||||
|
||||
@@ -475,7 +475,7 @@ export function CalendarManagementSettings() {
|
||||
{colorPickerId === cal.id && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
className="absolute start-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
|
||||
@@ -17,7 +17,16 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, KeyboardSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable,
|
||||
arrayMove, sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
|
||||
|
||||
@@ -81,7 +90,7 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute left-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
|
||||
className="absolute start-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
|
||||
>
|
||||
{ICON_CHOICES.map(({ name, icon: Icon }) => (
|
||||
<button
|
||||
@@ -102,10 +111,47 @@ function IconPicker({ currentIcon, onSelect, onClose }: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a folder row with a drag handle so it can be reordered within its
|
||||
* sibling group. The handle carries the dnd-kit listeners; the rest of the row
|
||||
* (buttons, inline editors) stays fully interactive.
|
||||
*/
|
||||
function SortableFolderRow({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex items-center px-1 text-muted-foreground/40 hover:text-foreground cursor-grab active:cursor-grabbing touch-none rounded-md focus:outline-none focus:ring-2 focus:ring-ring flex-shrink-0"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes } = useEmailStore();
|
||||
|
||||
const sensors = useSensors(
|
||||
// Small activation distance so clicking the row's buttons still works.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const { folderIcons, setFolderIcon } = useSettingsStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const folderIconsAllowed = isFeatureEnabled('folderIconsEnabled');
|
||||
@@ -129,6 +175,31 @@ export function FolderSettings() {
|
||||
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
|
||||
const folderTree = buildMailboxTree(ownMailboxes);
|
||||
|
||||
// Reorder folders within a sibling group (same parent). Drops onto a folder
|
||||
// in a different group are ignored — this reorders, it doesn't reparent.
|
||||
const handleFolderDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !client) return;
|
||||
|
||||
const groups: MailboxNode[][] = [];
|
||||
const collectGroups = (nodes: MailboxNode[]) => {
|
||||
groups.push(nodes);
|
||||
nodes.forEach(n => { if (n.children.length > 0) collectGroups(n.children); });
|
||||
};
|
||||
collectGroups(folderTree);
|
||||
|
||||
const group = groups.find(g => g.some(n => n.id === active.id));
|
||||
if (!group) return;
|
||||
const oldIndex = group.findIndex(n => n.id === active.id);
|
||||
const newIndex = group.findIndex(n => n.id === over.id);
|
||||
if (newIndex < 0) return; // dropped outside the active folder's sibling group
|
||||
|
||||
const orderedIds = arrayMove(group, oldIndex, newIndex).map(n => n.id);
|
||||
reorderMailboxes(client, orderedIds).catch(() => {
|
||||
toast.error(t('reorder_error'));
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleMailboxId = (role: string): string => {
|
||||
const mb = ownMailboxes.find(m => m.role === role);
|
||||
return mb?.id ?? '';
|
||||
@@ -385,6 +456,7 @@ export function FolderSettings() {
|
||||
|
||||
return (
|
||||
<div key={mb.id}>
|
||||
<SortableFolderRow id={mb.id} title={t('reorder')}>
|
||||
<div
|
||||
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
@@ -482,12 +554,15 @@ export function FolderSettings() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableFolderRow>
|
||||
{/* Inline subfolder creation */}
|
||||
{renderCreateInline(mb.id, depth + 1)}
|
||||
{/* Render children if expanded */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map(child => renderFolderNode(child))}
|
||||
<SortableContext items={node.children.map(c => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{node.children.map(child => renderFolderNode(child))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -505,7 +580,11 @@ export function FolderSettings() {
|
||||
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
folderTree.map(node => renderFolderNode(node))
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleFolderDragEnd}>
|
||||
<SortableContext items={folderTree.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{folderTree.map(node => renderFolderNode(node))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,19 +118,26 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
|
||||
// Cross-account "All accounts" views, each gated independently by the admin.
|
||||
const connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
|
||||
const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||
// Unified Mailbox entries (All mail / Unread / Starred), each gated independently
|
||||
// by the admin. Scope (single account vs. cross-account) is governed by
|
||||
// `unifiedCrossAccount`; the folder picker below narrows which own folders feed them.
|
||||
const crossViews = [
|
||||
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
|
||||
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
|
||||
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
|
||||
] as const;
|
||||
// The folder picker narrows the own folders included in the entries above; show
|
||||
// it once the user has enabled at least one of them.
|
||||
const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView;
|
||||
const anyCrossAllowed = crossViews.some(c => c.allowed);
|
||||
|
||||
// Own (non-shared) folders and the active account's All Mail selection. The
|
||||
// selection is per account: a missing entry = never configured, which
|
||||
@@ -231,6 +238,13 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
|
||||
<ToggleSwitch
|
||||
checked={faviconUnreadBadge}
|
||||
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
@@ -244,6 +258,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.cross_account.label')}
|
||||
description={t('unified_mailbox.cross_account.description')}
|
||||
locked={isSettingLocked('unifiedCrossAccount')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={unifiedCrossAccount}
|
||||
onChange={(v) => updateSetting('unifiedCrossAccount', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
|
||||
<SettingItem
|
||||
@@ -259,8 +288,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
||||
allowed && !isSettingHidden(setting) && (
|
||||
<SettingItem
|
||||
@@ -279,21 +309,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
|
||||
<SettingItem
|
||||
label={t('all_mail.label')}
|
||||
description={t('all_mail.description')}
|
||||
locked={isSettingLocked('enableAllMailView')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableAllMailView}
|
||||
onChange={(v) => updateSetting('enableAllMailView', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && enableAllMailView && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ReadingSettings() {
|
||||
hoverActionsCorner,
|
||||
hideInlineImageAttachments,
|
||||
attachmentImagePreviewsEnabled,
|
||||
messageSpacing,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -115,6 +116,20 @@ export function ReadingSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!isSettingHidden('messageSpacing') && (
|
||||
<SettingItem label={t('message_spacing.label')} description={t('message_spacing.description')} locked={isSettingLocked('messageSpacing')}>
|
||||
<Select
|
||||
value={messageSpacing}
|
||||
onChange={(value) => updateSetting('messageSpacing', value as typeof messageSpacing)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('message_spacing.auto') },
|
||||
{ value: 'always', label: t('message_spacing.always') },
|
||||
{ value: 'edge', label: t('message_spacing.edge') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!isSettingHidden('deleteAction') && (
|
||||
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -4,9 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -27,6 +30,7 @@ export function VacationSettings() {
|
||||
toDate,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
@@ -40,6 +44,8 @@ export function VacationSettings() {
|
||||
const [localToDate, setLocalToDate] = useState(toDate || '');
|
||||
const [localSubject, setLocalSubject] = useState(subject);
|
||||
const [localTextBody, setLocalTextBody] = useState(textBody);
|
||||
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
|
||||
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
|
||||
@@ -55,7 +61,9 @@ export function VacationSettings() {
|
||||
setLocalToDate(toDate || '');
|
||||
setLocalSubject(subject);
|
||||
setLocalTextBody(textBody);
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody]);
|
||||
setHtmlEnabled(!!htmlBody);
|
||||
setLocalHtmlBody(htmlBody || '');
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const warnings: string[] = [];
|
||||
@@ -70,13 +78,14 @@ export function VacationSettings() {
|
||||
warnings.push(t('warnings.start_in_past'));
|
||||
}
|
||||
|
||||
if (localEnabled && !localTextBody.trim()) {
|
||||
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
|
||||
warnings.push(t('warnings.empty_body'));
|
||||
}
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, t]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
@@ -87,7 +96,8 @@ export function VacationSettings() {
|
||||
(localFromDate || null) !== (fromDate || null) ||
|
||||
(localToDate || null) !== (toDate || null) ||
|
||||
localSubject !== subject ||
|
||||
localTextBody !== textBody;
|
||||
localTextBody !== textBody ||
|
||||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
|
||||
|
||||
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
|
||||
|
||||
@@ -96,13 +106,25 @@ export function VacationSettings() {
|
||||
validate();
|
||||
if (hasBlockingError) return;
|
||||
|
||||
const sanitizedHtml =
|
||||
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
|
||||
? sanitizeEmailHtml(localHtmlBody)
|
||||
: null;
|
||||
// Keep a plain-text part as the fallback for clients that don't render
|
||||
// HTML. If the user left it blank, derive it from the HTML body.
|
||||
const textBody =
|
||||
localTextBody.trim() || !sanitizedHtml
|
||||
? localTextBody
|
||||
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
|
||||
|
||||
try {
|
||||
await updateVacationResponse(client, {
|
||||
isEnabled: localEnabled,
|
||||
fromDate: localFromDate || null,
|
||||
toDate: localToDate || null,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
textBody,
|
||||
htmlBody: sanitizedHtml,
|
||||
}, managedAccountId ?? undefined);
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
@@ -217,28 +239,66 @@ export function VacationSettings() {
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||
/>
|
||||
</div>
|
||||
<SettingItem
|
||||
label={t('message.html_label')}
|
||||
description={t('message.html_description')}
|
||||
>
|
||||
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
|
||||
</SettingItem>
|
||||
{htmlEnabled && (
|
||||
<div className="pb-3">
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<RichTextEditor
|
||||
content={localHtmlBody}
|
||||
onChange={setLocalHtmlBody}
|
||||
placeholder={t('message.html_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{localTextBody.trim() && (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{(() => {
|
||||
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
||||
if (!localTextBody.trim() && !showHtmlPreview) return null;
|
||||
return (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
{showHtmlPreview ? (
|
||||
<div
|
||||
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
|
||||
// Preview renders into the app's own DOM. Intercept anchor
|
||||
// clicks so following a link doesn't navigate the whole app
|
||||
// away (and lose the unsaved responder), opening a new tab.
|
||||
onClick={(e) => {
|
||||
const anchor = (e.target as HTMLElement).closest('a');
|
||||
if (anchor?.href) {
|
||||
e.preventDefault();
|
||||
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
})()}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PlaceholderFillModal({
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t('preview')}</p>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap p-3 rounded-md bg-muted/50 border border-border max-h-32 overflow-y-auto">
|
||||
{preview}
|
||||
{template.isHTML ? <div dangerouslySetInnerHTML={{ __html: preview }}></div> : preview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Star, Plus } from 'lucide-react';
|
||||
import { Star, Plus, Square, CheckSquare } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { validateTemplateName } from '@/lib/template-utils';
|
||||
import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types';
|
||||
@@ -38,6 +38,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
const [category, setCategory] = useState(template?.category || '');
|
||||
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
|
||||
const [body, setBody] = useState(template?.body || initialData?.body || '');
|
||||
const [isHTML, setIsHTML] = useState(template?.isHTML || false);
|
||||
const [toRecipients, setToRecipients] = useState(
|
||||
template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || ''
|
||||
);
|
||||
@@ -76,6 +77,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
name: name.trim(),
|
||||
subject,
|
||||
body,
|
||||
isHTML,
|
||||
category: category.trim(),
|
||||
defaultRecipients: to.length || cc.length || bcc.length
|
||||
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
|
||||
@@ -190,6 +192,14 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHTML(!isHTML)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isHTML ? <CheckSquare className={cn('w-4 h-4')}></CheckSquare> : <Square className={cn('w-4 h-4')}></Square>}
|
||||
HTML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
@@ -232,7 +242,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
>
|
||||
<option value="">{tSettings('default_identity')}</option>
|
||||
{identities.map((id) => (
|
||||
<option key={id.id} value={id.id}>
|
||||
<option key={id.id} value={id.id} dir="ltr">
|
||||
{id.name ? `${id.name} <${id.email}>` : id.email}
|
||||
</option>
|
||||
))}
|
||||
@@ -275,7 +285,7 @@ function PlaceholderDropdown({
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
|
||||
<div className="absolute end-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
|
||||
<div className="p-1">
|
||||
{BUILT_IN_PLACEHOLDERS.map((p) => (
|
||||
<button
|
||||
|
||||
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
shortcut?: string;
|
||||
/** Stable hook for integration tests (not user-visible). */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
@@ -117,10 +119,12 @@ export function ContextMenuItem({
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
shortcut,
|
||||
testId,
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
|
||||
@@ -153,12 +157,15 @@ interface ContextMenuSubMenuProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Stable hook for integration tests (not user-visible). */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuSubMenu({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
testId,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||
@@ -232,6 +239,7 @@ export function ContextMenuSubMenu({
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
|
||||
@@ -274,6 +274,18 @@ const skCross =
|
||||
" 7.22 49.54 7.11-.62-20.5-6.6-46.33-6.6-46.33s12.3.96 17.22.96c4.92 0" +
|
||||
" 17.21-.96 17.21-.96s-5.97 25.83-6.6 46.33c12.18.1 29.72-.49 49.55-7.11" +
|
||||
" 0 0-.62 8.3-.62 17.98 0 9.67.62 17.98.62 17.98-19.86-6.64-37.42-7.22-49.6-7.12v32.37";
|
||||
/** United Arab Emirates – Red hoist stripe, green/white/black horizontal bands */
|
||||
export function FlagAE(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="4" height="3" fill="#fff" />
|
||||
<rect x="1" width="3" height="1" fill="#00732F" />
|
||||
<rect x="1" y="2" width="3" height="1" fill="#000" />
|
||||
<rect width="1" height="3" fill="#FF0000" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const skHills =
|
||||
"M270 329.1c-24.87 0-38.19 34.46-38.19 34.46s-7.4-16.34-27.68-16.34" +
|
||||
"c-13.73 0-23.82 12.2-30.25 23.5 24.97 39.7 64.8 64.2 96.11 79.28" +
|
||||
@@ -319,4 +331,5 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
zh: FlagCN,
|
||||
fa: FlagIR,
|
||||
he: FlagIL,
|
||||
ar: FlagAE,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'sk', label: 'Slovenčina' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
|
||||
+42
-18
@@ -21,6 +21,7 @@ export interface Toast {
|
||||
onClick?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
action?: ToastAction;
|
||||
secondaryAction?: ToastAction;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
@@ -117,25 +118,48 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
{toast.message && (
|
||||
<p className="text-[12px] mt-1 text-muted-foreground leading-snug">{toast.message}</p>
|
||||
)}
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2 text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
|
||||
"text-foreground"
|
||||
{(toast.action || toast.secondaryAction) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{toast.secondaryAction && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.secondaryAction!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
>
|
||||
{toast.secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
} catch {
|
||||
// Don't close toast on error so user can retry
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors",
|
||||
"bg-foreground/5 hover:bg-foreground/10 dark:bg-white/10 dark:hover:bg-white/15",
|
||||
"text-foreground"
|
||||
)}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ export default [
|
||||
"e2e/**",
|
||||
"local-data/**/*.mjs",
|
||||
"benchmark/**",
|
||||
"examples/**",
|
||||
"integration/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { StrictMode } from 'react';
|
||||
import { render, renderHook, waitFor } from '@testing-library/react';
|
||||
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||
import { renderBadgedFavicon } from '@/lib/favicon-badge';
|
||||
|
||||
vi.mock('@/lib/favicon-badge', () => ({
|
||||
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
|
||||
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
|
||||
),
|
||||
}));
|
||||
|
||||
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
|
||||
|
||||
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
|
||||
|
||||
/** The link the hook owns: the only one it may ever touch. */
|
||||
function badgeLink(): HTMLLinkElement | null {
|
||||
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
|
||||
}
|
||||
|
||||
/** The base icon link the page (or React) rendered: must survive untouched. */
|
||||
function baseLinks(): HTMLLinkElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every icon link in <head>, in document order. The browser honours the last. */
|
||||
function iconLinks(): HTMLLinkElement[] {
|
||||
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
|
||||
}
|
||||
|
||||
function lastIconLink(): HTMLLinkElement | null {
|
||||
return iconLinks().at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Next/React does on a client-side navigation: it re-hoists its metadata
|
||||
* icon link into <head>, appending a *fresh* node after everything already
|
||||
* there — including our badge link.
|
||||
*/
|
||||
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
|
||||
}
|
||||
|
||||
function Badger({ count }: { count: number }) {
|
||||
useFaviconBadge(count);
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
|
||||
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
// document.head outlives every test, so a spy on it that a failing assertion
|
||||
// never got to restore would leak into the next test's counts.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useFaviconBadge', () => {
|
||||
it('appends its own badged icon link when the count is positive', async () => {
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
|
||||
// Last-declared icon wins, so ours must be last in <head>.
|
||||
expect(document.head.lastElementChild).toBe(badgeLink());
|
||||
});
|
||||
|
||||
it('never removes or mutates an icon link it did not create', async () => {
|
||||
const before = baseLinks()[0];
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
expect(before.isConnected).toBe(true);
|
||||
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
|
||||
document.head.innerHTML =
|
||||
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
||||
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
||||
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
const survivors = baseLinks();
|
||||
expect(survivors).toHaveLength(2);
|
||||
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
|
||||
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
|
||||
expect(survivors[1].getAttribute('href')).toBe('/a.png');
|
||||
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
||||
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
||||
});
|
||||
|
||||
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
|
||||
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
|
||||
// that DOM node. Removing it out from under React makes the commit phase throw
|
||||
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
|
||||
// later deleted. The hook must therefore never touch a node it did not create.
|
||||
document.head.innerHTML = '';
|
||||
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
||||
<Badger count={3} />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
// The React-owned node is still there, untouched.
|
||||
const reactOwned = baseLinks();
|
||||
expect(reactOwned).toHaveLength(1);
|
||||
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(badgeLink()).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces its own link rather than mutating its href', async () => {
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
const first = badgeLink();
|
||||
|
||||
rerender({ n: 4 });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
|
||||
});
|
||||
|
||||
// Firefox ignores an in-place href change on the favicon link.
|
||||
expect(badgeLink()).not.toBe(first);
|
||||
expect(first!.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
|
||||
// The field bug: Firefox does not re-evaluate the favicon when an icon link
|
||||
// is *removed* — a removal is not an insertion, so it keeps painting the
|
||||
// last icon it was handed and the stale "99+" badge sticks until a hard
|
||||
// reload. Clearing must therefore be an insertion: our own link is replaced
|
||||
// by a brand-new node carrying the original base href.
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
const badged = badgeLink()!;
|
||||
|
||||
rerender({ n: 0 });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
|
||||
const restored = badgeLink()!;
|
||||
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
||||
expect(badged.isConnected).toBe(false);
|
||||
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
||||
expect(lastIconLink()).toBe(restored);
|
||||
|
||||
// And the base link the page rendered is still untouched.
|
||||
const survivors = baseLinks();
|
||||
expect(survivors).toHaveLength(1);
|
||||
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('inserts the base-href link exactly once while the count stays at zero', async () => {
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||
const ownAppends = () =>
|
||||
appendSpy.mock.calls.filter(
|
||||
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
|
||||
).length;
|
||||
|
||||
rerender({ n: 0 });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
const restored = badgeLink()!;
|
||||
expect(ownAppends()).toBe(1);
|
||||
|
||||
// Neither further renders at the same count nor unrelated <head> churn (an
|
||||
// observer tick) may re-insert it: no remove/append thrash on every tick.
|
||||
rerender({ n: 0 });
|
||||
rerender({ n: 0 });
|
||||
document.head.appendChild(document.createElement('meta'));
|
||||
await settle();
|
||||
|
||||
expect(ownAppends()).toBe(1);
|
||||
expect(badgeLink()).toBe(restored);
|
||||
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps its own link last even while it is only carrying the base href', async () => {
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
rerender({ n: 0 });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
const own = badgeLink()!;
|
||||
|
||||
rehoistBaseIcon();
|
||||
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||
});
|
||||
|
||||
it('badges again with a fresh insertion when the count leaves zero', async () => {
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
rerender({ n: 0 });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
const cleared = badgeLink()!;
|
||||
|
||||
rerender({ n: 7 });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
|
||||
});
|
||||
expect(badgeLink()).not.toBe(cleared);
|
||||
expect(cleared.isConnected).toBe(false);
|
||||
expect(lastIconLink()).toBe(badgeLink());
|
||||
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes only its own link on unmount', async () => {
|
||||
const { unmount } = renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
unmount();
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('does not fetch, and adds no link, while the count is zero', async () => {
|
||||
renderHook(() => useFaviconBadge(0));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the icon alone when the base is not SVG', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
|
||||
);
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
|
||||
// Assert on the observable end state, not merely on the href being
|
||||
// unchanged: the href is also unchanged *before* the catch block runs,
|
||||
// so a href-only assertion would pass even if the code went on to swap
|
||||
// the icon a tick later. The renderer must never be reached.
|
||||
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('leaves the icon alone when the base fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('does nothing when there is no icon link to read', async () => {
|
||||
document.head.innerHTML = '';
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await Promise.resolve(); // let any deferred async work start
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(badgeLink()).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
|
||||
// StrictMode double-invokes effects, and a count change while the fetch is in
|
||||
// flight re-runs the effect: neither may issue a second request.
|
||||
let resolveFetch: (response: Response) => void = () => {};
|
||||
const inFlight = new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => inFlight),
|
||||
);
|
||||
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
||||
initialProps: { n: 1 },
|
||||
wrapper: StrictMode,
|
||||
});
|
||||
|
||||
rerender({ n: 2 });
|
||||
rerender({ n: 5 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFetch(svgResponse());
|
||||
await waitFor(() => {
|
||||
// The badge lands on the latest count, not the one in flight at fetch time.
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
|
||||
});
|
||||
|
||||
rerender({ n: 6 });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not re-render the badge when the count is unchanged', async () => {
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
||||
initialProps: { n: 3 },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
|
||||
const settled = badgeLink();
|
||||
renderBadgedFaviconMock.mockClear();
|
||||
rerender({ n: 3 });
|
||||
|
||||
// The element identity alone is not evidence: `count` is the effect's only
|
||||
// dependency, so React would skip the effect regardless. Assert the
|
||||
// renderer was not invoked again — that is the behaviour under test.
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(badgeLink()).toBe(settled);
|
||||
});
|
||||
|
||||
describe('when the setting is off', () => {
|
||||
it('adds no link and does not fetch, however high the count', async () => {
|
||||
renderHook(() => useFaviconBadge(3, false));
|
||||
await settle();
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
|
||||
// Same guarantee as the count-back-to-zero clear, and for the same reason:
|
||||
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
|
||||
// Turning the setting off by *removing* our link would leave the stale
|
||||
// badge painted on the tab until a hard reload.
|
||||
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||
initialProps: { on: true },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
const badged = badgeLink()!;
|
||||
|
||||
rerender({ on: false });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
|
||||
const restored = badgeLink()!;
|
||||
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
||||
expect(badged.isConnected).toBe(false);
|
||||
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
||||
expect(lastIconLink()).toBe(restored);
|
||||
|
||||
// And the base link the page rendered is still untouched.
|
||||
const survivors = baseLinks();
|
||||
expect(survivors).toHaveLength(1);
|
||||
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||
});
|
||||
|
||||
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
|
||||
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||
initialProps: { on: true },
|
||||
});
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
rerender({ on: false });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
const cleared = badgeLink()!;
|
||||
|
||||
rerender({ on: true });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
expect(badgeLink()).not.toBe(cleared);
|
||||
expect(cleared.isConnected).toBe(false);
|
||||
expect(lastIconLink()).toBe(badgeLink());
|
||||
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||
expect(baseLinks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps its base-href link last when React re-hoists its icon', async () => {
|
||||
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||
initialProps: { on: true },
|
||||
});
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
rerender({ on: false });
|
||||
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||
const own = badgeLink()!;
|
||||
|
||||
rehoistBaseIcon();
|
||||
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||
});
|
||||
});
|
||||
|
||||
describe('when React re-hoists its icon link on a client-side navigation', () => {
|
||||
it('moves its own link back to the end so the badge keeps winning', async () => {
|
||||
// The field bug: Inbox badges the tab, a hop to /calendar makes React
|
||||
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
|
||||
// wins again and the badge vanishes.
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
expect(lastIconLink()).toBe(badgeLink());
|
||||
|
||||
const own = badgeLink()!;
|
||||
const rehoisted = rehoistBaseIcon();
|
||||
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
|
||||
|
||||
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||
expect(document.head.lastElementChild).toBe(own);
|
||||
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
|
||||
});
|
||||
|
||||
it('restores the badge without the count changing', async () => {
|
||||
// The "never comes back" half of the bug. Navigating back to the inbox
|
||||
// does not change the unread count, so nothing re-runs the count effect:
|
||||
// the observer alone must put the badge back on top.
|
||||
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
|
||||
renderBadgedFaviconMock.mockClear();
|
||||
rehoistBaseIcon();
|
||||
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
||||
|
||||
rerender({ n: 3 }); // same count: no effect re-run to lean on
|
||||
await settle();
|
||||
|
||||
expect(lastIconLink()).toBe(badgeLink());
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-applies the badge if its own link is removed entirely', async () => {
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
badgeLink()!.remove(); // React blows away part of <head>
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||
});
|
||||
expect(lastIconLink()).toBe(badgeLink());
|
||||
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||
});
|
||||
|
||||
it('settles: re-appending its own link does not feed the observer a loop', async () => {
|
||||
// Moving our link fires the observer again. If the move is not guarded by
|
||||
// "am I already last?", that second run moves it again, for ever. Count
|
||||
// the appends of *our* node: exactly one, and it must stop growing.
|
||||
renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
const own = badgeLink()!;
|
||||
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
|
||||
|
||||
rehoistBaseIcon();
|
||||
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||
expect(ownAppends()).toBe(1);
|
||||
|
||||
await settle();
|
||||
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
|
||||
expect(lastIconLink()).toBe(own);
|
||||
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
|
||||
expect(iconLinks()).toHaveLength(3);
|
||||
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('still never removes or mutates a link it did not create', async () => {
|
||||
document.head.innerHTML =
|
||||
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
||||
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
||||
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
||||
<Badger count={3} />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
const rehoisted = rehoistBaseIcon();
|
||||
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
||||
|
||||
const survivors = baseLinks();
|
||||
expect(survivors).toHaveLength(4);
|
||||
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
|
||||
'/a.svg',
|
||||
'/a.png',
|
||||
ORIGINAL_HREF,
|
||||
ORIGINAL_HREF,
|
||||
]);
|
||||
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
||||
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
||||
expect(rehoisted.isConnected).toBe(true);
|
||||
|
||||
// The React-owned node is still React's to delete.
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
|
||||
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
|
||||
const { unmount } = renderHook(() => useFaviconBadge(3));
|
||||
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
expect(badgeLink()).toBeNull();
|
||||
|
||||
// A post-unmount re-hoist must not resurrect the badge.
|
||||
rehoistBaseIcon();
|
||||
await settle();
|
||||
expect(badgeLink()).toBeNull();
|
||||
expect(baseLinks()).toHaveLength(2);
|
||||
disconnect.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ interface ConfigData {
|
||||
loginLogoMaxWidth: string;
|
||||
loginShowHeading: boolean;
|
||||
loginShowSubtitle: boolean;
|
||||
loginShowTotp: boolean;
|
||||
loginShowVersion: boolean;
|
||||
demoMode: boolean;
|
||||
autoSsoEnabled: boolean;
|
||||
allowCustomJmapEndpoint: boolean;
|
||||
@@ -113,6 +115,8 @@ export function useConfig(): AppConfig {
|
||||
loginLogoMaxWidth: configCache?.loginLogoMaxWidth || '',
|
||||
loginShowHeading: configCache?.loginShowHeading ?? true,
|
||||
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
|
||||
loginShowTotp: configCache?.loginShowTotp ?? true,
|
||||
loginShowVersion: configCache?.loginShowVersion ?? true,
|
||||
demoMode: configCache?.demoMode || false,
|
||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||
@@ -152,6 +156,8 @@ export function useConfig(): AppConfig {
|
||||
loginLogoMaxWidth: configCache.loginLogoMaxWidth,
|
||||
loginShowHeading: configCache.loginShowHeading,
|
||||
loginShowSubtitle: configCache.loginShowSubtitle,
|
||||
loginShowTotp: configCache.loginShowTotp,
|
||||
loginShowVersion: configCache.loginShowVersion,
|
||||
demoMode: configCache.demoMode,
|
||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||
@@ -192,6 +198,8 @@ export function useConfig(): AppConfig {
|
||||
loginLogoMaxWidth: data.loginLogoMaxWidth,
|
||||
loginShowHeading: data.loginShowHeading,
|
||||
loginShowSubtitle: data.loginShowSubtitle,
|
||||
loginShowTotp: data.loginShowTotp,
|
||||
loginShowVersion: data.loginShowVersion,
|
||||
demoMode: data.demoMode,
|
||||
autoSsoEnabled: data.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { renderBadgedFavicon } from '@/lib/favicon-badge';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
|
||||
// layout.tsx) renders <link rel="icon"> through React, which hoists it into
|
||||
// <head> and keeps a fiber pointing at that DOM node. Removing it out from
|
||||
// under React leaves the fiber holding a detached node, and the next commit
|
||||
// that deletes that fiber throws "Cannot read properties of null (reading
|
||||
// 'removeChild')". So we never remove or mutate a node we did not create:
|
||||
// instead we append an *extra* icon link, marked as ours. The last-declared
|
||||
// icon wins in browsers, so ours overrides the base without deleting it.
|
||||
//
|
||||
// Ours is never removed to clear the badge, though — only on unmount. Firefox
|
||||
// re-evaluates the favicon on an *insertion* and on nothing else: a removal
|
||||
// leaves it painting the last icon it was handed, which is how a read inbox
|
||||
// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
|
||||
// the original base href in place of the badge (see `apply`).
|
||||
const MARKER = 'data-favicon-badge';
|
||||
const OWN_SELECTOR = `link[${MARKER}]`;
|
||||
const ICON_SELECTOR = 'link[rel~="icon"]';
|
||||
const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
|
||||
|
||||
// Both the badged icon and the untouched base we fall back to are SVG: the hook
|
||||
// disables itself unless the fetched base is served as image/svg+xml, so by the
|
||||
// time either link exists that content type is a proven fact, not a guess.
|
||||
const ICON_TYPE = 'image/svg+xml';
|
||||
|
||||
function removeOwnLink(): void {
|
||||
document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
function ownLink(): HTMLLinkElement | null {
|
||||
return document.head.querySelector<HTMLLinkElement>(OWN_SELECTOR);
|
||||
}
|
||||
|
||||
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
|
||||
function isLastIconLink(link: HTMLLinkElement): boolean {
|
||||
const icons = document.head.querySelectorAll<HTMLLinkElement>(ICON_SELECTOR);
|
||||
return icons[icons.length - 1] === link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a fresh icon link of ours, replacing any previous one of ours.
|
||||
*
|
||||
* Always a remove-then-append of a *new* node, never an href mutation: Firefox
|
||||
* only re-evaluates the favicon when an icon link is inserted. It ignores an
|
||||
* in-place href change, and — the count-back-to-zero bug — it equally ignores a
|
||||
* removal, happily painting the last icon it was handed. So even *clearing* the
|
||||
* badge is done by inserting: see `apply`, which re-inserts our link carrying
|
||||
* the original base href rather than deleting it.
|
||||
*/
|
||||
function setOwnLink(href: string): void {
|
||||
removeOwnLink();
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = ICON_TYPE;
|
||||
link.href = href;
|
||||
link.setAttribute(MARKER, '');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
|
||||
* false (the `faviconUnreadBadge` setting).
|
||||
*
|
||||
* The base icon is read from the rendered <link rel="icon">, so admin and
|
||||
* per-domain branding overrides (configManager `faviconUrl`) are respected
|
||||
* without plumbing config to the client.
|
||||
*
|
||||
* Every failure — no icon link, a fetch error, a non-SVG base, unparseable
|
||||
* source — leaves the existing favicon untouched.
|
||||
*/
|
||||
export function useFaviconBadge(count: number, enabled = true): void {
|
||||
// Disabled is just "nothing to show", i.e. exactly a count of zero, so it
|
||||
// rides the same paths: no fetch while we have never badged, and — the part
|
||||
// that matters — clearing an *existing* badge by inserting a fresh link
|
||||
// carrying the base href rather than removing ours, which Firefox would
|
||||
// ignore (see `apply`). Switching the setting off therefore restores the
|
||||
// plain icon immediately, with no reload.
|
||||
const effectiveCount = enabled ? count : 0;
|
||||
const baseSource = useRef<string | null>(null);
|
||||
const baseHref = useRef<string | null>(null);
|
||||
// The href our own link currently carries, and the hook's whole state machine:
|
||||
// null -> nothing of ours is in <head> (we have never badged)
|
||||
// baseHref -> ours is in <head>, showing the unbadged base icon
|
||||
// a data: URL -> ours is in <head>, showing the badge
|
||||
const appliedHref = useRef<string | null>(null);
|
||||
const disabled = useRef(false);
|
||||
const fetchStarted = useRef(false);
|
||||
const unmounted = useRef(false);
|
||||
const latestCount = useRef(effectiveCount);
|
||||
latestCount.current = effectiveCount;
|
||||
|
||||
// Declared before the badge effect so that on a StrictMode remount it runs
|
||||
// first and clears `unmounted` before the badge effect reads it.
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
// Restore the server-rendered favicon by removing our override. Nothing
|
||||
// else in <head> is ours to touch.
|
||||
removeOwnLink();
|
||||
appliedHref.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reads the refs rather than a closure over `count`, so that the reply to an
|
||||
// in-flight fetch — and the MutationObserver below, which outlives any single
|
||||
// render — lands on the newest count, not the one that started it.
|
||||
const apply = useCallback(() => {
|
||||
if (unmounted.current || disabled.current) return;
|
||||
|
||||
const current = latestCount.current;
|
||||
if (current <= 0) {
|
||||
// Clearing the badge is an *insertion*, not a removal.
|
||||
//
|
||||
// The field bug: with 133 unread the tab showed "99+", the user read
|
||||
// everything, the store went to 0 — and Firefox kept painting "99+" until
|
||||
// a hard reload. Removing our link is not an insertion, and Firefox only
|
||||
// re-evaluates the favicon on an insertion; a removal leaves it painting
|
||||
// the last icon it was handed. So instead of deleting our link we replace
|
||||
// it with a fresh one carrying the *original* base href: same pixels as
|
||||
// the untouched base link below it, but handed to the browser as a new
|
||||
// icon, which it does repaint.
|
||||
//
|
||||
// Never badged (`appliedHref` still null)? Then nothing of ours is in
|
||||
// <head> and nothing should be: a fully-read inbox adds no link at all.
|
||||
const base = baseHref.current;
|
||||
if (appliedHref.current === null || base === null) return;
|
||||
if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
|
||||
|
||||
setOwnLink(base);
|
||||
appliedHref.current = base;
|
||||
return;
|
||||
}
|
||||
|
||||
const source = baseSource.current;
|
||||
if (source === null) return; // still fetching; the fetch will call back
|
||||
|
||||
const next = renderBadgedFavicon(source, current);
|
||||
if (!next) return;
|
||||
if (next === appliedHref.current && ownLink()) return;
|
||||
|
||||
setOwnLink(next);
|
||||
appliedHref.current = next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled.current) return;
|
||||
|
||||
// Nothing to show and nothing applied: do not even fetch. A fully-read
|
||||
// inbox — or the setting switched off before we ever badged — should cost
|
||||
// no request.
|
||||
if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
|
||||
|
||||
// The base is fetched at most once, ever. Without this guard a StrictMode
|
||||
// double-invoke issues two requests, and any count change while the fetch
|
||||
// is in flight issues another.
|
||||
if (baseSource.current !== null || fetchStarted.current) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
// The one and only read of the base link. Its href is both what we fetch the
|
||||
// source from and what we hand back to the browser when the badge clears.
|
||||
const link = document.querySelector<HTMLLinkElement>(BASE_SELECTOR);
|
||||
const href = link?.getAttribute('href');
|
||||
if (!href) {
|
||||
disabled.current = true;
|
||||
return;
|
||||
}
|
||||
baseHref.current = href;
|
||||
fetchStarted.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(href);
|
||||
if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.includes('image/svg+xml')) {
|
||||
throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
baseSource.current = await response.text();
|
||||
apply();
|
||||
} catch (error) {
|
||||
disabled.current = true;
|
||||
debug.log('[favicon-badge] disabled:', error);
|
||||
}
|
||||
})();
|
||||
}, [effectiveCount, apply]);
|
||||
|
||||
// Keep ours the last icon link in <head>.
|
||||
//
|
||||
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
|
||||
// <link rel="icon"> from app/(main)/layout.tsx into <head>. The re-inserted
|
||||
// node lands *after* our badge link, the last-declared icon wins, and the
|
||||
// badge vanishes. Coming back to the inbox did not bring it back either: the
|
||||
// count is unchanged, so the effect above never re-ran and our link just sat
|
||||
// there outranked. Watching <head> fixes both halves at once.
|
||||
//
|
||||
// Termination: moving our own link is itself a <head> mutation, so it feeds
|
||||
// the observer a fresh record. The guard is `isLastIconLink` — on that second
|
||||
// run ours *is* last, so we do nothing and the cascade stops. One move per
|
||||
// foreign insertion, never two.
|
||||
const keepOwnLinkLast = useCallback(() => {
|
||||
if (unmounted.current || disabled.current) return;
|
||||
// Ours must stay last in *both* states — badged, and showing the base href
|
||||
// after a clear (`appliedHref` is only null when we have never badged, and
|
||||
// then nothing of ours is in <head> to keep last). Gating this on the count
|
||||
// instead would strand our base-href link behind a re-hoisted React icon,
|
||||
// and the next badge would have to fight its way back on top.
|
||||
if (appliedHref.current === null) return;
|
||||
|
||||
const own = ownLink();
|
||||
if (!own) {
|
||||
// React blew our link away with the rest of the head: re-apply from scratch.
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
if (isLastIconLink(own)) return;
|
||||
|
||||
// Re-appending *our own* element is the only mutation we ever make; a node
|
||||
// we did not create is never removed, moved or touched (see above).
|
||||
document.head.appendChild(own);
|
||||
}, [apply]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(keepOwnLinkLast);
|
||||
observer.observe(document.head, { childList: true });
|
||||
return () => observer.disconnect();
|
||||
}, [keepOwnLinkLast]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
@@ -43,18 +44,6 @@ export interface UseKeyboardShortcutsOptions {
|
||||
handlers: KeyboardShortcutHandlers;
|
||||
}
|
||||
|
||||
// Check if user is typing in an input field
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
|
||||
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
|
||||
|
||||
return isInput || isContentEditable;
|
||||
}
|
||||
|
||||
// Shortcuts must fire regardless of the active keyboard layout (e.g. Cyrillic,
|
||||
// Greek). Derive the key from the PHYSICAL key (event.code) instead of the
|
||||
// layout-dependent event.key: letters from KeyA..KeyZ, and the symbol shortcuts
|
||||
@@ -94,8 +83,10 @@ export function useKeyboardShortcuts({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
// Don't trigger shortcuts when typing in inputs. Must be event-based
|
||||
// (composedPath), not document.activeElement: the QuotedHtml island's
|
||||
// shadow root retargets activeElement to its plain-div host (#654).
|
||||
if (isEditableEventTarget(event)) return;
|
||||
|
||||
const h = handlersRef.current;
|
||||
const key = physicalShortcutKey(event);
|
||||
|
||||
+12
-2
@@ -1,6 +1,16 @@
|
||||
// RTL locales: Hebrew (he) and Persian/Farsi (fa). Everything else is LTR.
|
||||
const rtlLocales = new Set(['he', 'fa']);
|
||||
// RTL locales: Arabic (ar), Hebrew (he), and Persian/Farsi (fa). Everything else is LTR.
|
||||
const rtlLocales = new Set(['ar', 'he', 'fa']);
|
||||
|
||||
export function getLocaleDirection(locale: string): 'ltr' | 'rtl' {
|
||||
return rtlLocales.has(locale) ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the document is currently rendering right-to-left. For popovers
|
||||
* positioned in JS via getBoundingClientRect() (Tailwind's logical start-0/
|
||||
* end-0 utilities don't apply to inline fixed-position styles), check this
|
||||
* to anchor on the correct physical side.
|
||||
*/
|
||||
export function isDocumentRTL(): boolean {
|
||||
return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Use static imports for better compatibility
|
||||
let messages;
|
||||
switch (locale) {
|
||||
case 'ar':
|
||||
messages = (await import('../locales/ar/common.json')).default;
|
||||
break;
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
|
||||
const SUPPORTED_LOCALES = ['ar', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
import { initAdminPassword } from "./lib/admin/password";
|
||||
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
|
||||
import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate";
|
||||
import { detectSetupState } from "./lib/setup/state";
|
||||
import { ensureSetupToken } from "./lib/setup/token";
|
||||
|
||||
@@ -14,6 +14,7 @@ console.info(`Bulwark Webmail v${current}`);
|
||||
// Initialize admin config and password bootstrap. Migration runs first so
|
||||
// existing v1 layouts are split before anything reads admin.json.
|
||||
migrateLegacyAdminLayout()
|
||||
.then(() => migratePolicyUnifiedMailbox())
|
||||
.then(() => configManager.load())
|
||||
.then(() => initAdminPassword())
|
||||
.then(async () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Credentials for the integration-test Stalwart mail server.
|
||||
# Copy to `.env` (docker compose reads it automatically): cp .env.example .env
|
||||
|
||||
# Recovery admin (format user:password). Stays valid after bootstrap so the
|
||||
# Stalwart admin UI on http://localhost:8025 remains reachable and stalwart-cli
|
||||
# can be invoked via `docker exec`.
|
||||
STALWART_RECOVERY_ADMIN=admin:bootstrap-secret
|
||||
|
||||
# Shared password for every test mailbox (alice/bob/carol @ example.org).
|
||||
# The Playwright harness reads the same value from IT_ACCOUNT_PASSWORD.
|
||||
TEST_ACCOUNT_PASSWORD=test-pass-123
|
||||
@@ -0,0 +1,10 @@
|
||||
# Local docker env (copied from .env.example)
|
||||
.env
|
||||
|
||||
# Arch-specific stalwart-cli binary, fetched by stalwart/prepare-stalwart-cli.sh
|
||||
stalwart/stalwart-cli
|
||||
|
||||
# Playwright/test artifacts
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,167 @@
|
||||
# Integration tests — webmail ⇆ Stalwart
|
||||
|
||||
End-to-end tests that run the **Bulwark webmail against a real Stalwart mail
|
||||
server** in Docker and drive it with Playwright. The focus is the mail/folder
|
||||
**synchronisation** behaviour that multi-account webmail clients get wrong:
|
||||
unread/total counters, folder-list sync, and the account-scoped Unified Mailbox.
|
||||
|
||||
Everything here is self-contained and separate from the app's root
|
||||
`playwright.config.ts` (which only smoke-tests the UI against `npm run dev`).
|
||||
|
||||
## What's in the stack
|
||||
|
||||
| Service | Image | Ports (host) | Purpose |
|
||||
| --------- | --------------------------------------- | ---------------------------------- | -------------------------------------------------------- |
|
||||
| `stalwart`| built from [`stalwart/`](stalwart/) | `8025` JMAP+admin, `1025` SMTP, `1143` IMAP | Real MTA, declaratively bootstrapped with test mailboxes |
|
||||
| `webmail` | built from [`webmail.Dockerfile`](webmail.Dockerfile) | `3000` | The app under test (Next.js, **dev mode** — see below) |
|
||||
|
||||
Provisioned mailboxes (domain `example.org`, shared password `test-pass-123`):
|
||||
`alice`, `bob`, `carol`. Admin: `admin` / `bootstrap-secret`.
|
||||
|
||||
### Two things worth knowing
|
||||
|
||||
- **The webmail runs in Next.js dev mode.** The browser talks JMAP *directly*
|
||||
to Stalwart at `http://localhost:8025` (cross-origin, plain HTTP). The app's
|
||||
production CSP pins `connect-src` to `'self' https:` and would block that;
|
||||
dev mode widens it to allow `http:`. Dev mode also ships the test hooks from
|
||||
source without a production rebuild. See the header of `webmail.Dockerfile`.
|
||||
- **CORS.** Stalwart doesn't emit CORS headers by default. The bootstrap enables
|
||||
`usePermissiveCors` (see `stalwart/plan-accounts.ndjson.tpl`) so the browser
|
||||
origin (`:3000`) may call the JMAP origin (`:8025`).
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# One-shot: brings the stack up and runs the whole suite in the Playwright
|
||||
# container (browsers preinstalled, host networking to reach the stack).
|
||||
integration/run-tests.sh
|
||||
|
||||
# A single spec:
|
||||
integration/run-tests.sh 01-login
|
||||
```
|
||||
|
||||
`run-tests.sh` is the recommended entry point because Playwright's browser
|
||||
bundles can't always be downloaded/installed on the host; the official
|
||||
`mcr.microsoft.com/playwright` image sidesteps that.
|
||||
|
||||
### Running against a host browser instead
|
||||
|
||||
If you *can* install Playwright browsers on your machine:
|
||||
|
||||
```bash
|
||||
cd integration && cp .env.example .env
|
||||
bash stalwart/prepare-stalwart-cli.sh
|
||||
docker compose up -d --build --wait
|
||||
npx playwright test -c playwright.integration.config.ts # from the repo root
|
||||
```
|
||||
|
||||
The Playwright `globalSetup` brings the stack up for you (unless `IT_NO_DOCKER=1`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
integration/
|
||||
├── docker-compose.yml # stalwart + webmail
|
||||
├── webmail.Dockerfile # dev-mode webmail image (built from repo source)
|
||||
├── webmail-config/policy.json # enables the cross-account Unified Mailbox feature gate
|
||||
├── run-tests.sh # bring up stack + run suite in the Playwright container
|
||||
├── stalwart/ # bootstrap image (adapted from examples/docker/stalwart)
|
||||
│ ├── Dockerfile
|
||||
│ ├── entrypoint.sh # two-phase declarative bootstrap
|
||||
│ ├── plan-bootstrap.ndjson # domain + datastore
|
||||
│ ├── plan-accounts.ndjson.tpl # alice/bob/carol + listeners + CORS
|
||||
│ └── prepare-stalwart-cli.sh # host-side fetch of stalwart-cli (offline-friendly build)
|
||||
└── tests/
|
||||
├── global-setup.ts / global-teardown.ts
|
||||
├── helpers/
|
||||
│ ├── config.ts # accounts, URLs, ports (env-overridable)
|
||||
│ ├── smtp.ts # dependency-free SMTP submission client
|
||||
│ ├── jmap.ts # JMAP client for seeding/inspecting server state
|
||||
│ └── app.ts # login, add/switch account, folder-counter reads
|
||||
├── 01-login.spec.ts
|
||||
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
|
||||
├── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
|
||||
├── 04-all-mail.spec.ts # All Mail view: single-account merge + cross-account
|
||||
├── 04-shared-identity.spec.ts# composer From offers shared/group send-as identities (issue #569)
|
||||
├── 05-actions.spec.ts # context-menu read/unread, delete, spam (inbox)
|
||||
├── 06-shared-folders.spec.ts # delegated folder: appears + read/unread/delete/spam
|
||||
├── 07-drafts.spec.ts # multiple recipients, changed sender, continue-draft button
|
||||
├── 08-shared-moves.spec.ts # moving mail across own/shared and shared/shared
|
||||
├── 09-live-counters.spec.ts # live unified/All-Mail counters (login + shared)
|
||||
└── 10-attachments.spec.ts # cross-account attachment download from All Mail
|
||||
```
|
||||
|
||||
## Findings surfaced by the suite
|
||||
|
||||
Some tests assert server-side truth (or use `test.fail` to pin a known gap)
|
||||
because the UI behaviour is currently incomplete. Worth a look:
|
||||
|
||||
- **Shared-account counters now reconcile on focus/interval** (`09-live-counters`).
|
||||
Stalwart's SSE only pushes StateChange for the *primary* account, so a
|
||||
background change in a shared/delegated account is never pushed. The client
|
||||
now also polls the session's secondary accounts, so their folder badges and
|
||||
the unified/All-Mail counter refresh on the visibility reconcile and on a slow
|
||||
background poll. (A *login* account already updates live via its own SSE.)
|
||||
Note: these shared counters still don't update the instant a local action
|
||||
runs — they follow the reconcile, not the optimistic path.
|
||||
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
|
||||
way `delete` does; it settles after a reconcile.
|
||||
- **Reopening a draft resets the From selector** to the default identity even
|
||||
though the draft was saved with (and the server retains) the chosen sender.
|
||||
Pinned with `test.fail` in `07-drafts`.
|
||||
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
|
||||
"Move to" submenu offers the shared folder, but clicking it is a no-op.
|
||||
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
|
||||
`08-shared-moves`.
|
||||
- **Cross-account attachments & inline images (fixed).** Blobs are account-
|
||||
scoped, so viewing/downloading/previewing an attachment, rendering an inline
|
||||
`cid:` image, dragging out, and the bundle/S-MIME/TNEF/embedded-message
|
||||
fetches on an All-Mail message from another account 404'd against the active
|
||||
account. Every viewer blob fetch now routes to the message's owning client +
|
||||
accountId (`10-attachments` covers download + inline image).
|
||||
|
||||
## How the tests work
|
||||
|
||||
- **Mutations** are made out-of-band — mail is injected over SMTP
|
||||
(`helpers/smtp.ts`) and server-side reads/moves/deletes/folder-creates are
|
||||
driven over JMAP (`helpers/jmap.ts`). Assertions are on the **rendered UI**,
|
||||
so a test tells you whether the webmail *synced* the change.
|
||||
- **Counters** are read from `data-unread` / `data-total` on the
|
||||
`[data-testid="folder-counts"]` element, which makes assertions locale-
|
||||
independent. These and the other `data-testid` hooks (`folder-row`,
|
||||
`email-list-item`, `account-switcher`, `account-option`, `add-account`,
|
||||
`email-composer`, …) were added to the app for these tests.
|
||||
- **`forceSync(page)`** dispatches a `visibilitychange` to trigger the client's
|
||||
`checkForStateChanges()` — the same reconcile a real user gets when tabbing
|
||||
back. It makes external-mutation assertions deterministic instead of racing
|
||||
the SSE push channel right after login.
|
||||
|
||||
## Environment knobs
|
||||
|
||||
| Var | Default | Effect |
|
||||
| --------------- | ------------------ | ---------------------------------------------------------- |
|
||||
| `IT_NO_DOCKER` | unset | `1` = don't manage docker in global-setup (stack already up) |
|
||||
| `IT_TEARDOWN` | unset | `1` = `docker compose down -v` after the suite |
|
||||
| `IT_WEBMAIL_URL`| `http://localhost:3000` | Webmail origin |
|
||||
| `IT_JMAP_URL` | `http://localhost:8025` | Stalwart JMAP/admin base URL |
|
||||
| `IT_SMTP_PORT` | `1025` | Stalwart submission port |
|
||||
| `IT_VIDEO` | `retain-on-failure`| Video capture: `on` records a `.webm` for **every** test; also `off` / `on-first-retry`. Videos land at `integration/test-results/<test>/video.webm` |
|
||||
|
||||
Record videos for a whole run (passing tests included):
|
||||
|
||||
```bash
|
||||
IT_VIDEO=on integration/run-tests.sh # or a single spec: IT_VIDEO=on integration/run-tests.sh 01-login
|
||||
```
|
||||
|
||||
By default the stack is **left running** after the suite so re-runs are fast and
|
||||
you can poke around (webmail on :3000, Stalwart admin on :8025). Tear it down
|
||||
with `IT_TEARDOWN=1` or `docker compose -f integration/docker-compose.yml down -v`.
|
||||
|
||||
## Resetting
|
||||
|
||||
The Stalwart data lives in the `bulwark-it-stalwart-data` volume. To re-run the
|
||||
bootstrap from scratch:
|
||||
|
||||
```bash
|
||||
docker compose -f integration/docker-compose.yml down -v
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
name: bulwark-integration
|
||||
|
||||
# Integration-test backend for the Bulwark webmail. A single Stalwart mail
|
||||
# server (JMAP + SMTP submission + IMAP), declaratively bootstrapped with the
|
||||
# alice/bob/carol test mailboxes. The webmail itself is started by Playwright
|
||||
# (webServer in playwright.integration.config.ts) so the dev-loop / debugger
|
||||
# stays on the host; only the hard-to-provision mail backend is containerised.
|
||||
|
||||
services:
|
||||
stalwart:
|
||||
build:
|
||||
context: stalwart
|
||||
container_name: bulwark-it-stalwart
|
||||
# JMAP + Webmail + admin on 8025, SMTP submission on 1025 (internal 587),
|
||||
# IMAP on 1143 (internal 143). The browser talks JMAP to localhost:8025;
|
||||
# the test harness submits mail over SMTP to localhost:1025.
|
||||
ports:
|
||||
- "8025:8080"
|
||||
- "1025:587"
|
||||
- "1143:143"
|
||||
volumes:
|
||||
- stalwart-data:/var/lib/stalwart
|
||||
- stalwart-config:/etc/stalwart
|
||||
environment:
|
||||
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN:?set in .env}
|
||||
TEST_ACCOUNT_PASSWORD: ${TEST_ACCOUNT_PASSWORD:?set in .env}
|
||||
healthcheck:
|
||||
# /jmap/session answers 200 only once the account bootstrap has finished
|
||||
# and the server is in normal mode.
|
||||
test: ["CMD-SHELL", "curl -fsS -u alice@example.org:$${TEST_ACCOUNT_PASSWORD} http://127.0.0.1:8080/jmap/session >/dev/null || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
webmail:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: integration/webmail.Dockerfile
|
||||
container_name: bulwark-it-webmail
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# Admin policy that turns on the cross-account Unified Mailbox feature
|
||||
# gate (off by default), so the multi-account unified sync tests can
|
||||
# exercise it. Read by /api/admin/policy -> usePolicyStore.isFeatureEnabled.
|
||||
- ./webmail-config/policy.json:/app/data/admin/policy.json:ro
|
||||
environment:
|
||||
# Browser-facing JMAP URL. The browser (Playwright) reaches Stalwart on
|
||||
# the host-published port; the webmail server never fetches this URL
|
||||
# itself for trusted basic-auth logins, so it need not be container-
|
||||
# reachable. Setting JMAP_SERVER_URL also puts the app in "env-managed"
|
||||
# mode, which skips the first-run setup wizard.
|
||||
JMAP_SERVER_URL: http://localhost:8025
|
||||
STALWART_FEATURES: "true"
|
||||
APP_NAME: "Bulwark Webmail (Integration)"
|
||||
# Enables "Remember me" / settings-sync cookies. Not required for the
|
||||
# sync tests but harmless and avoids noisy warnings.
|
||||
SESSION_SECRET: integration-not-a-real-secret
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
stalwart:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
# next dev compiles routes lazily; give the first boot ample runway.
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
stalwart-data:
|
||||
name: bulwark-it-stalwart-data
|
||||
stalwart-config:
|
||||
name: bulwark-it-stalwart-config
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the Playwright integration suite.
|
||||
#
|
||||
# Playwright's browser download host is often unreachable (and some host OSes
|
||||
# aren't supported by the browser bundles), so the tests run inside the official
|
||||
# Playwright container, which ships the browsers. The container uses host
|
||||
# networking to reach the published stack ports (webmail :3000, Stalwart :8025).
|
||||
#
|
||||
# The docker stack itself is brought up here (on the host) and the in-container
|
||||
# run is told to skip its own docker management via IT_NO_DOCKER=1.
|
||||
#
|
||||
# Usage:
|
||||
# integration/run-tests.sh # whole suite
|
||||
# integration/run-tests.sh 01-login # a single spec (grep on file name)
|
||||
#
|
||||
# Env:
|
||||
# IT_VIDEO=on record a video.webm for every test (not just failures);
|
||||
# also: off | retain-on-failure (default) | on-first-retry.
|
||||
# e.g. IT_VIDEO=on integration/run-tests.sh 01-login
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
INTEGRATION_DIR="${REPO_ROOT}/integration"
|
||||
PW_IMAGE="mcr.microsoft.com/playwright:v1.59.1-noble"
|
||||
|
||||
cd "${INTEGRATION_DIR}"
|
||||
[ -f .env ] || cp .env.example .env
|
||||
|
||||
echo "== bringing up stack =="
|
||||
bash stalwart/prepare-stalwart-cli.sh
|
||||
docker compose --env-file .env up -d --build --wait --wait-timeout 300
|
||||
|
||||
echo "== running Playwright in ${PW_IMAGE} =="
|
||||
FILTER="${1:-}"
|
||||
docker run --rm --network host \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "${REPO_ROOT}":/work -w /work \
|
||||
-e IT_NO_DOCKER=1 \
|
||||
-e HOME=/tmp \
|
||||
-e IT_VIDEO="${IT_VIDEO:-}" \
|
||||
"${PW_IMAGE}" \
|
||||
npx playwright test -c playwright.integration.config.ts ${FILTER:+"$FILTER"}
|
||||
@@ -0,0 +1,2 @@
|
||||
prepare-stalwart-cli.sh
|
||||
README.md
|
||||
@@ -0,0 +1,29 @@
|
||||
# Stalwart Mail Server with a declarative bootstrap for webmail integration
|
||||
# testing.
|
||||
#
|
||||
# Extends the official image with stalwart-cli and an entrypoint that, on first
|
||||
# container start, applies the bootstrap + account plans against the freshly
|
||||
# started server. On subsequent starts the `.bootstrap-applied` marker short-
|
||||
# circuits both phases and Stalwart boots straight into normal mode.
|
||||
#
|
||||
# NOTE: stalwart-cli is COPYed in rather than downloaded during the build. The
|
||||
# binary is fetched by ./prepare-stalwart-cli.sh (run for you by the Playwright
|
||||
# global-setup / integration runner). This keeps the build offline-friendly and
|
||||
# avoids the base image's apt sources, which are unreachable in sandboxed CI.
|
||||
|
||||
FROM stalwartlabs/stalwart:v0.16
|
||||
|
||||
USER root
|
||||
|
||||
# Host-prefetched stalwart-cli matching the build architecture.
|
||||
COPY stalwart-cli /usr/local/bin/stalwart-cli
|
||||
|
||||
RUN mkdir -p /etc/stalwart-bootstrap
|
||||
COPY plan-bootstrap.ndjson /etc/stalwart-bootstrap/plan-bootstrap.ndjson
|
||||
COPY plan-accounts.ndjson.tpl /etc/stalwart-bootstrap/plan-accounts.ndjson.tpl
|
||||
COPY entrypoint.sh /usr/local/bin/stalwart-bootstrap-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/stalwart-cli /usr/local/bin/stalwart-bootstrap-entrypoint.sh
|
||||
|
||||
USER stalwart
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/stalwart-bootstrap-entrypoint.sh"]
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/bin/sh
|
||||
# Declarative bootstrap for the integration-test Stalwart Mail Server.
|
||||
#
|
||||
# Phase 1 (first start only, marker absent):
|
||||
# - Stalwart starts in bootstrap mode (no config.json -> HTTP on :8080).
|
||||
# - plan-bootstrap.ndjson is applied via stalwart-cli. This writes
|
||||
# config.json, initialises RocksDB and creates the default domain +
|
||||
# admin account.
|
||||
# - Stalwart is restarted in normal mode (config.json now exists).
|
||||
# - plan-accounts.ndjson.tpl is materialised with the resolved DOMAIN_ID
|
||||
# and the shared TEST_ACCOUNT_PASSWORD, then applied (test accounts +
|
||||
# submission/IMAP listeners + cleartext auth for the dev lanes).
|
||||
# - Stalwart is stopped and the marker is written.
|
||||
#
|
||||
# Phase 2 (regular start, marker present):
|
||||
# - exec stalwart as PID 1.
|
||||
#
|
||||
# Adapted from examples/docker/stalwart for webmail<->Stalwart integration
|
||||
# testing: no ticket/service accounts, no Sieve, a single shared password for
|
||||
# the alice/bob/carol test mailboxes.
|
||||
|
||||
set -eu
|
||||
|
||||
# stalwart-cli caches its schema under $HOME/.cache/stalwart-cli. The stalwart
|
||||
# user has no home, so redirect to /tmp.
|
||||
export HOME=/tmp
|
||||
|
||||
DATA_DIR=/var/lib/stalwart
|
||||
MARKER="${DATA_DIR}/.bootstrap-applied"
|
||||
PLAN_DIR=/etc/stalwart-bootstrap
|
||||
STALWART_BIN=/usr/local/bin/stalwart
|
||||
STALWART_CLI=/usr/local/bin/stalwart-cli
|
||||
STALWART_CFG=/etc/stalwart/config.json
|
||||
LOCAL_URL=http://127.0.0.1:8080
|
||||
|
||||
log() { printf '[stalwart-bootstrap] %s\n' "$*" >&2; }
|
||||
|
||||
wait_for_http() {
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS -u "admin:${ADMIN_PASS}" "${LOCAL_URL}/jmap/session" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log "Stalwart HTTP on :8080 did not come up in time"
|
||||
return 1
|
||||
}
|
||||
|
||||
run_stalwart_bg() {
|
||||
"${STALWART_BIN}" --config "${STALWART_CFG}" &
|
||||
STALWART_PID=$!
|
||||
}
|
||||
|
||||
stop_stalwart_bg() {
|
||||
if [ -n "${STALWART_PID:-}" ]; then
|
||||
kill -TERM "${STALWART_PID}" 2>/dev/null || true
|
||||
wait "${STALWART_PID}" 2>/dev/null || true
|
||||
STALWART_PID=
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -f "${MARKER}" ]; then
|
||||
: "${STALWART_RECOVERY_ADMIN:?must be set for first-run bootstrap}"
|
||||
: "${TEST_ACCOUNT_PASSWORD:?must be set for first-run bootstrap}"
|
||||
|
||||
ADMIN_PASS=${STALWART_RECOVERY_ADMIN#*:}
|
||||
|
||||
log "Phase 1: starting Stalwart in bootstrap mode"
|
||||
run_stalwart_bg
|
||||
wait_for_http
|
||||
|
||||
log "Applying plan-bootstrap.ndjson"
|
||||
STALWART_URL=${LOCAL_URL} \
|
||||
STALWART_USER=admin \
|
||||
STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" apply --file "${PLAN_DIR}/plan-bootstrap.ndjson" --quiet
|
||||
|
||||
log "Restarting Stalwart to leave bootstrap mode"
|
||||
stop_stalwart_bg
|
||||
run_stalwart_bg
|
||||
wait_for_http
|
||||
|
||||
log "Resolving DOMAIN_ID for example.org"
|
||||
DOMAIN_ID=$(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" query Domain --json 2>/dev/null \
|
||||
| head -1 \
|
||||
| sed -E 's/.*"id":"([^"]+)".*/\1/')
|
||||
if [ -z "${DOMAIN_ID}" ]; then
|
||||
log "Could not resolve DOMAIN_ID after bootstrap"
|
||||
stop_stalwart_bg
|
||||
exit 1
|
||||
fi
|
||||
log "DOMAIN_ID=${DOMAIN_ID}"
|
||||
|
||||
# Materialise the account plan. gettext/envsubst is not in the base image,
|
||||
# so substitute the two placeholders with sed. Passwords are escaped for the
|
||||
# sed replacement (& and / are the only metacharacters that matter here).
|
||||
PLAN_ACCOUNTS=/tmp/plan-accounts.ndjson
|
||||
esc_pw=$(printf '%s' "${TEST_ACCOUNT_PASSWORD}" | sed -e 's/[&/\\]/\\&/g')
|
||||
sed -e "s/\${DOMAIN_ID}/${DOMAIN_ID}/g" \
|
||||
-e "s/\${TEST_ACCOUNT_PASSWORD}/${esc_pw}/g" \
|
||||
"${PLAN_DIR}/plan-accounts.ndjson.tpl" > "${PLAN_ACCOUNTS}"
|
||||
|
||||
log "Applying plan-accounts.ndjson"
|
||||
STALWART_URL=${LOCAL_URL} \
|
||||
STALWART_USER=admin \
|
||||
STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet
|
||||
rm -f "${PLAN_ACCOUNTS}"
|
||||
|
||||
# Make 'carol' a member of the 'team' group *before her first login*, so she
|
||||
# already has the shared group mailbox (its folders show under "Shared") and
|
||||
# the team@ send-as identity. This provisions the issue #569 scenario: the
|
||||
# composer's From dropdown should then offer the group address. Membership is
|
||||
# a set keyed by the group's server-assigned account id (see the User schema's
|
||||
# memberGroupIds), so the ids are resolved here, mirroring DOMAIN_ID above.
|
||||
# carol (not alice/bob) is used so the single-/multi-account sync specs, which
|
||||
# drive alice and bob, keep a clean unshared environment.
|
||||
q_account_id() {
|
||||
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" query Account --json 2>/dev/null \
|
||||
| grep "\"emailAddress\":\"$1\"" \
|
||||
| sed -E 's/.*"id":"([^"]+)".*/\1/'
|
||||
}
|
||||
CAROL_ID=$(q_account_id "carol@example.org")
|
||||
TEAM_ID=$(q_account_id "team@example.org")
|
||||
if [ -n "${CAROL_ID}" ] && [ -n "${TEAM_ID}" ]; then
|
||||
log "Adding carol (${CAROL_ID}) to the team group (${TEAM_ID}) [issue #569]"
|
||||
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" update Account "${CAROL_ID}" \
|
||||
--field "memberGroupIds={\"${TEAM_ID}\":true}" >/dev/null
|
||||
else
|
||||
log "WARNING: could not resolve account ids (carol='${CAROL_ID}' team='${TEAM_ID}'); skipping group membership"
|
||||
fi
|
||||
|
||||
# Default inbound throttles (sender->recipient + sender-IP) otherwise trip
|
||||
# 452 4.4.5 when a test blasts many messages. Stalwart re-seeds the defaults
|
||||
# on every start when absent, so deleting is useless; disable them instead,
|
||||
# which survives restarts.
|
||||
for tid in $(STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" query MtaInboundThrottle --json 2>/dev/null \
|
||||
| sed -E 's/.*"id":"([^"]+)".*/\1/'); do
|
||||
log "Disabling MtaInboundThrottle ${tid}"
|
||||
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||
"${STALWART_CLI}" update MtaInboundThrottle "${tid}" --field enable=false >/dev/null
|
||||
done
|
||||
|
||||
log "Stopping bootstrap instance, marking complete"
|
||||
stop_stalwart_bg
|
||||
touch "${MARKER}"
|
||||
fi
|
||||
|
||||
log "Starting Stalwart (final, foreground)"
|
||||
exec "${STALWART_BIN}" --config "${STALWART_CFG}"
|
||||
@@ -0,0 +1,9 @@
|
||||
{"@type":"create","object":"Account","value":{"alice":{"@type":"User","name":"alice","domainId":"${DOMAIN_ID}","description":"Integration test mailbox alice","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||
{"@type":"create","object":"Account","value":{"bob":{"@type":"User","name":"bob","domainId":"${DOMAIN_ID}","description":"Integration test mailbox bob","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||
{"@type":"create","object":"Account","value":{"carol":{"@type":"User","name":"carol","domainId":"${DOMAIN_ID}","description":"Integration test mailbox carol","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||
{"@type":"create","object":"Account","value":{"team":{"@type":"Group","name":"team","domainId":"${DOMAIN_ID}","description":"Team shared mailbox"}}}
|
||||
{"@type":"create","object":"NetworkListener","value":{"submission":{"name":"submission","protocol":"smtp","bind":{"[::]:587":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
||||
{"@type":"create","object":"NetworkListener","value":{"imap":{"name":"imap","protocol":"imap","bind":{"[::]:143":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
||||
{"@type":"update","object":"MtaStageAuth","value":{"saslMechanisms":{"match":{"0":{"if":"local_port != 25","then":"[plain, login, oauthbearer, xoauth2]"}},"else":"false"}}}
|
||||
{"@type":"update","object":"Imap","value":{"allowPlainTextAuth":true}}
|
||||
{"@type":"update","object":"Http","value":{"usePermissiveCors":true}}
|
||||
@@ -0,0 +1 @@
|
||||
{"@type":"update","object":"Bootstrap","value":{"serverHostname":"mail.example.org","defaultDomain":"example.org","generateDkimKeys":false,"requestTlsCertificate":false,"dataStore":{"@type":"RocksDb","path":"/var/lib/stalwart/data","blobSize":16834,"bufferSize":134217728}}}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fetch the stalwart-cli binary used by the Stalwart bootstrap image.
|
||||
#
|
||||
# The Dockerfile COPYs ./stalwart-cli instead of downloading it during the
|
||||
# build, because the base image's apt sources and the build network are
|
||||
# unreachable in the sandboxed CI environment. This script does the fetch on
|
||||
# the host (which has working outbound HTTPS) and extracts the binary with
|
||||
# Python's lzma module (xz is not guaranteed to be installed).
|
||||
#
|
||||
# Idempotent: skips the download when a matching binary already exists.
|
||||
set -euo pipefail
|
||||
|
||||
CLI_VERSION="${STALWART_CLI_VERSION:-1.0.6}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT="${HERE}/stalwart-cli"
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64) TRIPLE=x86_64-unknown-linux-gnu ;;
|
||||
aarch64|arm64) TRIPLE=aarch64-unknown-linux-gnu ;;
|
||||
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -x "${OUT}" ] && "${OUT}" --version 2>/dev/null | grep -q "${CLI_VERSION}"; then
|
||||
echo "stalwart-cli ${CLI_VERSION} already present at ${OUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
URL="https://github.com/stalwartlabs/cli/releases/download/v${CLI_VERSION}/stalwart-cli-${TRIPLE}.tar.xz"
|
||||
TARBALL="$(mktemp)"
|
||||
trap 'rm -f "${TARBALL}"' EXIT
|
||||
|
||||
echo "Downloading ${URL}"
|
||||
curl -sfL -o "${TARBALL}" "${URL}"
|
||||
|
||||
python3 - "${TARBALL}" "${OUT}" <<'PY'
|
||||
import io, lzma, os, sys, tarfile
|
||||
tarball, out = sys.argv[1], sys.argv[2]
|
||||
with lzma.open(tarball) as f:
|
||||
data = f.read()
|
||||
tf = tarfile.open(fileobj=io.BytesIO(data))
|
||||
member = next(m for m in tf.getmembers() if m.name.endswith("stalwart-cli"))
|
||||
with open(out, "wb") as w:
|
||||
w.write(tf.extractfile(member).read())
|
||||
os.chmod(out, 0o755)
|
||||
print(f"wrote {out}")
|
||||
PY
|
||||
|
||||
"${OUT}" --version
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { login, folderRow, accountSwitcher, activeAccountEmail } from './helpers/app';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
|
||||
test.describe('Login & session', () => {
|
||||
test('logs in against Stalwart and loads the mailbox', async ({ page }) => {
|
||||
await login(page, ACCOUNTS.alice);
|
||||
|
||||
// The Inbox folder row is a reliable "mailbox loaded" signal.
|
||||
await expect(folderRow(page, { role: 'inbox' }).first()).toBeVisible();
|
||||
|
||||
// The active account in the switcher is alice. The account id is
|
||||
// `${email}@${serverHost}`, so assert on the email it reports instead.
|
||||
await expect(accountSwitcher(page)).toBeVisible();
|
||||
expect(await activeAccountEmail(page)).toBe(ACCOUNTS.alice.email);
|
||||
});
|
||||
|
||||
test('rejects invalid credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('#username', ACCOUNTS.alice.email);
|
||||
await page.fill('#password', 'definitely-wrong');
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(
|
||||
page.locator('[role="alert"], .text-red-600, .text-destructive').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('JMAP helper can reach every provisioned account', async () => {
|
||||
for (const acct of Object.values(ACCOUNTS)) {
|
||||
const client = await JmapClient.connect(acct.email, acct.password);
|
||||
expect(client.accountId).toBeTruthy();
|
||||
const inbox = await client.mailboxByRole('inbox');
|
||||
expect(inbox, `${acct.email} has an inbox`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
folderRow,
|
||||
folderCounts,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectFolderCountsSynced,
|
||||
emailItem,
|
||||
expectEmailVisible,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Single-account mail & folder synchronisation.
|
||||
*
|
||||
* These exercise the webmail's ability to reflect *external* changes to the
|
||||
* mailbox — new deliveries, server-side reads/moves/deletes, and folder
|
||||
* creation — which is where "my counts are wrong / my folder didn't show up"
|
||||
* sync bugs live. Mutations are made over SMTP/JMAP and the assertions are on
|
||||
* the rendered UI.
|
||||
*/
|
||||
const alice = ACCOUNTS.alice;
|
||||
|
||||
// Unique subject per test run avoids cross-test contamination if a reset lags.
|
||||
let seq = 0;
|
||||
const subj = (label: string) => `IT ${label} ${Date.now()}-${seq++}`;
|
||||
|
||||
test.describe('Single-account sync', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('incoming mail appears and bumps the Inbox unread counter', async ({ page }) => {
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
|
||||
const subject = subj('incoming');
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'hi' });
|
||||
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
await expectEmailVisible(page, subject);
|
||||
});
|
||||
|
||||
test('opening a message clears its unread state (UI -> server -> counter)', async ({ page }) => {
|
||||
const subject = subj('read');
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'read me' });
|
||||
await jmap.waitForEmail(subject);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailItem(page, subject).first().click();
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
});
|
||||
|
||||
test('a folder created on the server shows up in the sidebar', async ({ page }) => {
|
||||
await login(page, alice);
|
||||
await expect(folderRow(page, { name: 'SyncFolder' })).toHaveCount(0);
|
||||
|
||||
await jmap.createMailbox('SyncFolder');
|
||||
|
||||
await expect(folderRow(page, { name: 'SyncFolder' }).first()).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('a server-side move updates both source and destination counters', async ({ page }) => {
|
||||
const subject = subj('move');
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'move me' });
|
||||
const email = await jmap.waitForEmail(subject);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
|
||||
// Create destination + move the message there (server-side).
|
||||
const destId = await jmap.createMailbox('Archive2');
|
||||
const inbox = await jmap.mailboxByRole('inbox');
|
||||
await jmap.request([
|
||||
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
||||
]);
|
||||
|
||||
// Source Inbox drains, destination gains the message. These follow a
|
||||
// reconcile (not live push), so nudge one before every poll to stay robust
|
||||
// against a single missed reconcile under load.
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
|
||||
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
|
||||
expect(inbox).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a server-side delete drains the Inbox total', async ({ page }) => {
|
||||
const subject = subj('delete');
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'delete me' });
|
||||
const email = await jmap.waitForEmail(subject);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
|
||||
|
||||
// The folder counter is the sync-critical signal and drains to zero (via a
|
||||
// reconcile, nudged before every poll). (The already-rendered list view is
|
||||
// not re-queried on a background delete, so we don't assert on the row
|
||||
// disappearing here.)
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
|
||||
});
|
||||
|
||||
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
|
||||
const subjects = Array.from({ length: 3 }, (_, i) => subj(`burst-${i}`));
|
||||
for (const s of subjects) {
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: s, body: 'burst' });
|
||||
}
|
||||
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 3);
|
||||
const counts = await folderCounts(page, { role: 'inbox' });
|
||||
expect(counts.total).toBe(3);
|
||||
for (const s of subjects) await expectEmailVisible(page, s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
switchAccount,
|
||||
accountSwitcher,
|
||||
seedUnifiedSettings,
|
||||
folderRow,
|
||||
expectFolderUnread,
|
||||
expectFolderCountsSynced,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Multi-account synchronisation — the account-scoped Unified Mailbox.
|
||||
*
|
||||
* Covers the two failure modes that dog multi-account webmail: counters
|
||||
* bleeding between accounts, and the cross-account unified view mis-aggregating
|
||||
* (or not updating when a background account receives mail).
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
|
||||
async function send(to: typeof alice, subject: string) {
|
||||
await sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
|
||||
}
|
||||
|
||||
test.describe('Multi-account sync', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('both accounts connect and their Inbox counters stay isolated', async ({ page }) => {
|
||||
// Pre-seed: two unread for alice, one for bob.
|
||||
await send(alice, subj('iso-a1'));
|
||||
await send(alice, subj('iso-a2'));
|
||||
await send(bob, subj('iso-b1'));
|
||||
|
||||
await login(page, alice);
|
||||
// Active = alice: her own Inbox shows 2 unread.
|
||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
||||
|
||||
await addAccount(page, bob);
|
||||
// Both accounts are now registered in the switcher.
|
||||
await accountSwitcher(page).click();
|
||||
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||
|
||||
// Switch back to alice: her count is intact.
|
||||
await switchAccount(page, alice.email);
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 2 });
|
||||
});
|
||||
|
||||
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
|
||||
await send(alice, subj('agg-a'));
|
||||
await send(bob, subj('agg-b'));
|
||||
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
|
||||
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
||||
// (bob) still reports just its own 1.
|
||||
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||
});
|
||||
|
||||
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob); // bob is now the active account
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
|
||||
|
||||
// Mail lands in alice's inbox while bob is the active account.
|
||||
await send(alice, subj('bg'));
|
||||
|
||||
// The unified counter reflects the background account's new mail.
|
||||
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 1 });
|
||||
// bob (active) own Inbox is unaffected.
|
||||
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
seedAllMailSettings,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* The "All Mail" view — a virtual folder that merges messages across an
|
||||
* account's folders (Inbox + custom, excluding junk/sent/trash/drafts/archive),
|
||||
* and across every logged-in account when the cross-account sub-option is on.
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
const ALL_MAIL = '__cross_all__';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (to: typeof alice, subject: string) =>
|
||||
sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
|
||||
|
||||
test.describe('All Mail — single account', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('merges Inbox + custom folders and excludes Junk', async ({ page }) => {
|
||||
const inboxSubj = subj('am-inbox');
|
||||
const folderSubj = subj('am-folder');
|
||||
const junkSubj = subj('am-junk');
|
||||
await send(alice, inboxSubj);
|
||||
await send(alice, folderSubj);
|
||||
await send(alice, junkSubj);
|
||||
|
||||
// File one into a custom folder and one into Junk (excluded from All Mail).
|
||||
const folderMail = await jmap.waitForEmail(folderSubj);
|
||||
await jmap.moveEmailToFolder(folderMail.id, 'Projects');
|
||||
const junkMail = await jmap.waitForEmail(junkSubj);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
await jmap.moveEmail(junkMail.id, junk!.id);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: false });
|
||||
await login(page, alice);
|
||||
|
||||
// The All Mail entry is present and shows the two included-folder unreads.
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||
|
||||
// Its list merges the Inbox and custom-folder messages, but not Junk.
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, inboxSubj);
|
||||
await expectEmailVisible(page, folderSubj);
|
||||
await expect(emailItem(page, junkSubj)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('All Mail — cross account', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('merges mail from every logged-in account', async ({ page }) => {
|
||||
const aSubj = subj('am-a');
|
||||
const bSubj = subj('am-b');
|
||||
await send(alice, aSubj);
|
||||
await send(bob, bSubj);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: true });
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
|
||||
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, aSubj);
|
||||
await expectEmailVisible(page, bSubj);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS, GROUP } from './helpers/config';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
forceSync,
|
||||
openComposer,
|
||||
composerFromOptions,
|
||||
selectComposerFrom,
|
||||
selectedComposerFrom,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Issue #569 — the composer's "From" dropdown should include identities from
|
||||
* shared/group accounts, not only the logged-in (connected) accounts.
|
||||
*
|
||||
* Scenario under test (the one expected to already work): a Stalwart *group*
|
||||
* mailbox `team@example.org` is provisioned and `carol` is made a member of it
|
||||
* *before her first login* (integration/stalwart/plan-accounts.ndjson.tpl +
|
||||
* entrypoint.sh). As a member she gets the group's shared folders (shown under
|
||||
* "Shared") and — per Stalwart — a `team@` send-as identity (Stalwart returns
|
||||
* it among the member's own account identities). The composer should therefore
|
||||
* offer `team@example.org` as a sender alongside her own address.
|
||||
*/
|
||||
const member = ACCOUNTS[GROUP.team.memberOf];
|
||||
const { team } = GROUP;
|
||||
|
||||
test.describe('Composer From: shared/group identities (issue #569)', () => {
|
||||
test('the pre-provisioned group account is reachable in the member’s JMAP session', async () => {
|
||||
// Server-side guard for the UI expectation below: if this fails, the
|
||||
// bootstrap group provisioning is broken (not the app). The member must see
|
||||
// the group account in her session, and it must expose a team@ identity she
|
||||
// can send as.
|
||||
const memberClient = await JmapClient.connect(member.email, member.password);
|
||||
expect(memberClient.sharedAccountNames()).toContain(team.email);
|
||||
|
||||
const groupAccountId = Object.entries(memberClient.accounts).find(
|
||||
([, name]) => name === team.email,
|
||||
)?.[0];
|
||||
expect(groupAccountId).toBeTruthy();
|
||||
|
||||
const res = await memberClient.request([
|
||||
['Identity/get', { accountId: groupAccountId! }, '0'],
|
||||
]);
|
||||
const groupIdentityEmails = (res.methodResponses[0][1].list as { email: string }[]).map(
|
||||
(i) => i.email,
|
||||
);
|
||||
expect(groupIdentityEmails).toContain(team.email);
|
||||
});
|
||||
|
||||
test('the composer From selector offers and can select the group address', async ({ page }) => {
|
||||
await login(page, member);
|
||||
// Shared accounts/identities are discovered from the JMAP session; give the
|
||||
// client a beat to settle them after the first render.
|
||||
await forceSync(page);
|
||||
|
||||
await openComposer(page);
|
||||
|
||||
// The group address the member can send as should be one of the From
|
||||
// choices. If #569 were unaddressed the control would collapse to her own
|
||||
// address only and this poll would time out — which is the point: it pins
|
||||
// the expected behaviour.
|
||||
await expect
|
||||
.poll(async () => (await composerFromOptions(page)).join(' | '), { timeout: 15000 })
|
||||
.toContain(team.email);
|
||||
|
||||
// Pick the group address as the sender and confirm it becomes the selected
|
||||
// From identity (not just a listed option).
|
||||
await selectComposerFrom(page, team.email);
|
||||
await expect.poll(() => selectedComposerFrom(page), { timeout: 5000 }).toContain(team.email);
|
||||
|
||||
// Hold on the composer so the final video frames clearly show the group
|
||||
// address selected in the From field.
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
openFolder,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Message actions from the list context menu — mark read/unread, delete, spam —
|
||||
* performed in the Inbox, with the outcome checked on both the UI (counters,
|
||||
* row state) and the server (which mailbox the message ended up in).
|
||||
*/
|
||||
const alice = ACCOUNTS.alice;
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (subject: string) =>
|
||||
sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
|
||||
test.describe('Inbox message actions', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('mark read then unread toggles the row state and Inbox unread counter', async ({ page }) => {
|
||||
const s = subj('act-read');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
await expectEmailUnread(page, s, true);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectEmailUnread(page, s, true);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
});
|
||||
|
||||
test('delete moves the message to Trash and updates both counters', async ({ page }) => {
|
||||
const s = subj('act-del');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
|
||||
// Leaves the Inbox, lands in Trash — on the UI...
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
||||
await expectFolderTotal(page, { role: 'trash' }, 1);
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
|
||||
// ...and on the server.
|
||||
const trash = await jmap.mailboxByRole('trash');
|
||||
const found = await jmap.findEmailBySubject(s, trash!.id);
|
||||
expect(found, 'deleted message is in Trash on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam moves the message to Junk', async ({ page }) => {
|
||||
const s = subj('act-spam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The destination (Junk) counter updates optimistically, but the source
|
||||
// (Inbox) counter isn't always decremented until the next reconcile when
|
||||
// the action fires moments after login — unlike delete, which decrements
|
||||
// the source immediately. The synced assertion nudges a reconcile per poll.
|
||||
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||
await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 });
|
||||
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const found = await jmap.findEmailBySubject(s, junk!.id);
|
||||
expect(found, 'spammed message is in Junk on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('spam then not-spam round-trips the message back out of Junk', async ({ page }) => {
|
||||
const s = subj('act-notspam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||
|
||||
// Open Junk, then mark not-spam.
|
||||
await openFolder(page, { role: 'junk' });
|
||||
await expectEmailVisible(page, s);
|
||||
await emailContextAction(page, s, 'ctx-not-spam');
|
||||
|
||||
// The message leaves the open Junk list (optimistic) and round-trips on the
|
||||
// server: out of Junk, back in Inbox. (Asserted on the optimistic list +
|
||||
// authoritative server state rather than the Junk badge, whose reconcile
|
||||
// can stall under heavy concurrent load.)
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const inbox = await jmap.mailboxByRole('inbox');
|
||||
expect(await jmap.findEmailBySubject(s, junk!.id), 'message no longer in Junk').toBeFalsy();
|
||||
expect(await jmap.findEmailBySubject(s, inbox!.id), 'message back in Inbox').toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderCountsSynced,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Shared (delegated) folders. Alice shares a custom folder — plus her Trash and
|
||||
* Junk so delete/spam can route to the owner's system folders — with carol, who
|
||||
* then acts on the mail from her own session and checks the shared counters.
|
||||
*
|
||||
* carol is the grantee (not asserted on by other specs), so the shared-account
|
||||
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const SHARED = 'TeamShared';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
|
||||
test.describe('Shared folder actions', () => {
|
||||
let ja: JmapClient; // owner (alice)
|
||||
let sharedId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
// Delegate a custom folder + Trash + Junk to carol.
|
||||
sharedId = await ja.createSharedFolder(SHARED, carol.email);
|
||||
await ja.shareMailboxByRole('trash', carol.email);
|
||||
await ja.shareMailboxByRole('junk', carol.email);
|
||||
});
|
||||
|
||||
async function seedIntoShared(subject: string): Promise<void> {
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
const m = await ja.waitForEmail(subject);
|
||||
await ja.moveEmail(m.id, sharedId);
|
||||
}
|
||||
|
||||
test('shared folder appears with its counter and message', async ({ page }) => {
|
||||
const s = subj('sh-show');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
|
||||
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, s);
|
||||
});
|
||||
|
||||
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
|
||||
const s = subj('sh-read');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||
|
||||
// Owner sees the same state on the server.
|
||||
const found = await ja.findEmailBySubject(s, sharedId);
|
||||
expect(found.keywords?.$seen).toBeFalsy();
|
||||
});
|
||||
|
||||
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
|
||||
const s = subj('sh-del');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
|
||||
// Source shared folder drains, and the message really is in the owner's
|
||||
// Trash on the server. (We assert the destination server-side rather than
|
||||
// the shared Trash badge to keep the check independent of sidebar layout.)
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
|
||||
const trash = await ja.mailboxByRole('trash');
|
||||
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
|
||||
const s = subj('sh-spam');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The message leaves the shared folder's list, and on the server it has
|
||||
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
|
||||
// spam doesn't optimistically drain the source *counter*, and forceSync
|
||||
// can't reconcile a shared account — so we assert list + server state.)
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
const junk = await ja.mailboxByRole('junk');
|
||||
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
openComposer,
|
||||
addRecipient,
|
||||
setFrom,
|
||||
setSubject,
|
||||
waitDraftSaved,
|
||||
closeComposer,
|
||||
composerRecipients,
|
||||
openFolder,
|
||||
emailItem,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Draft handling. Focus areas reported as flaky by the user:
|
||||
* - the "continue draft" (edit-draft) button in the message view,
|
||||
* - multiple recipients being persisted to the draft,
|
||||
* - a changed sender identity being persisted to the draft.
|
||||
*
|
||||
* Each test drives the composer, lets it auto-save, then verifies the draft on
|
||||
* the server (JMAP) and by reopening it in the UI.
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
async function draftBody(page: import('@playwright/test').Page, text: string) {
|
||||
await page.locator('.ProseMirror').first().fill(text);
|
||||
}
|
||||
|
||||
test.describe('Drafts', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('multiple recipients save and reopen via the continue-draft button', async ({ page }) => {
|
||||
const subject = subj('draft-multi');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email);
|
||||
await addRecipient(page, carol.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'draft body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
// Server: the draft carries BOTH recipients.
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
|
||||
// UI: opening the draft shows the continue-draft button, which reopens the
|
||||
// composer with both recipients intact.
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
|
||||
const recips = await composerRecipients(page);
|
||||
expect(recips).toContain(bob.email);
|
||||
expect(recips).toContain(carol.email);
|
||||
});
|
||||
|
||||
test('a recipient typed but not committed to a chip is still saved', async ({ page }) => {
|
||||
const subject = subj('draft-uncommitted');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email); // committed chip
|
||||
// Type a second address but do NOT press Enter — leave it as raw input.
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(carol.email);
|
||||
await setSubject(page, subject); // blur the To field
|
||||
await draftBody(page, 'uncommitted body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
// Both the committed and the still-in-the-input recipient must survive.
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
});
|
||||
|
||||
test('a server-created draft shows the continue-draft button when viewed', async ({ page }) => {
|
||||
const subject = subj('draft-server');
|
||||
await jmap.createDraft(subject, bob.email);
|
||||
|
||||
await login(page, alice);
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The edit-draft ("continue draft") button must be present for any message
|
||||
// carrying the $draft keyword, regardless of how the draft was created.
|
||||
await expect(page.locator('[data-testid="edit-draft"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a changed sender identity is saved to the draft (server)', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'from-change body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
expect((draft.from ?? [])[0]?.name, 'draft From carries the selected identity').toBe('Alice Team');
|
||||
});
|
||||
|
||||
// KNOWN BUG (documented via test.fail): a draft composed with a non-default
|
||||
// identity is saved with the right From on the server (see the test above),
|
||||
// but reopening the draft resets the composer's From selector to the default
|
||||
// identity instead of restoring the one the draft was written with. If this
|
||||
// starts passing, the reopen path was fixed — flip this back to a plain test.
|
||||
test.fail('reopening a draft restores the changed sender in the From selector', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from-reopen');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'reopen body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await expect(page.locator('[data-testid="composer-from"]')).toHaveValue(altId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
openFolder,
|
||||
folderMailboxId,
|
||||
moveEmailTo,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Moving mail across the own-account / shared-folder boundary, in both
|
||||
* directions, and between two shared folders. The move is driven from the list
|
||||
* context menu's "Move to" submenu; the authoritative check is the server-side
|
||||
* mailbox the message ends up in, with the reliably-updating (own-account)
|
||||
* counters checked in the UI too.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
test.describe('Shared-folder moves', () => {
|
||||
let ja: JmapClient; // owner
|
||||
let jc: JmapClient; // grantee
|
||||
let teamA: string;
|
||||
let teamB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
||||
teamB = await ja.createSharedFolder('TeamB', carol.email);
|
||||
});
|
||||
|
||||
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
|
||||
const acct = owner === ja ? alice : carol;
|
||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||
const m = await owner.waitForEmail(subject);
|
||||
await owner.moveEmail(m.id, mailboxId);
|
||||
}
|
||||
|
||||
test('shared folder A -> shared folder B', async ({ page }) => {
|
||||
const s = subj('mv-a2b');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
|
||||
});
|
||||
|
||||
test('shared folder B -> shared folder A', async ({ page }) => {
|
||||
const s = subj('mv-b2a');
|
||||
await seedInto(teamB, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { name: 'TeamB', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
|
||||
});
|
||||
|
||||
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
|
||||
// shared folder as a destination for an own-account message, but clicking it
|
||||
// does NOT relocate the message across the account boundary — it stays put.
|
||||
// Same in reverse (shared -> own). If cross-account moves get implemented,
|
||||
// these will start passing; flip them back to plain tests then.
|
||||
test.fail('own account -> shared folder', async ({ page }) => {
|
||||
const s = subj('mv-own2sh');
|
||||
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
|
||||
await jc.waitForEmail(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { role: 'inbox', shared: false });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message moves to the owner's shared TeamA.
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
|
||||
});
|
||||
|
||||
test.fail('shared folder -> own account', async ({ page }) => {
|
||||
const s = subj('mv-sh2own');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message arrives in carol's own Inbox.
|
||||
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
seedUnifiedSettings,
|
||||
seedAllMailSettings,
|
||||
expandSharedFolders,
|
||||
folderCounts,
|
||||
expectFolderUnread,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Live currency of the unified / All-Mail counters across every source folder.
|
||||
*
|
||||
* Stalwart's SSE only pushes StateChange for the *primary* account, so:
|
||||
* - a background *login* account updates the badge live (each login has its
|
||||
* own SSE) — asserted with no reconcile;
|
||||
* - a *shared/delegated* account gets no push at all, so the client polls the
|
||||
* session's secondary accounts too; the badge reconciles on focus/interval.
|
||||
* (Regression test for the shared-account state-poll.)
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
async function deliverIntoSharedFolder(owner: JmapClient, folderId: string, subject: string) {
|
||||
const acct = ACCOUNTS.alice;
|
||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||
const m = await owner.waitForEmail(subject);
|
||||
await owner.moveEmail(m.id, folderId);
|
||||
}
|
||||
|
||||
test.describe('Live unified/All-Mail counters', () => {
|
||||
test('a background login account updates the unified counter live (no reconcile)', async ({ page }) => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
await seedUnifiedSettings(page);
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob); // bob active, alice in the background
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
|
||||
|
||||
// Mail lands in alice's inbox while bob is active — no focus/forceSync here.
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: subj('bg-live'), body: 'x' });
|
||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
|
||||
});
|
||||
|
||||
test('a shared-folder change reconciles the All-Mail counter on focus', async ({ page }) => {
|
||||
const ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
const shared = await ja.createSharedFolder('TeamShared', carol.email);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: false });
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
expect((await folderCounts(page, { name: '__cross_all__' })).unread).toBe(0);
|
||||
|
||||
// A background change in the shared (delegated) account gets no SSE push.
|
||||
await deliverIntoSharedFolder(ja, shared, subj('sh-live'));
|
||||
|
||||
// Focus reconcile now polls the shared account too, so the All-Mail badge
|
||||
// picks up the shared folder's new unread.
|
||||
await forceSync(page);
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, { name: '__cross_all__' })).unread, { timeout: 15000 })
|
||||
.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
switchAccount,
|
||||
seedSettings,
|
||||
folderRow,
|
||||
openFolder,
|
||||
emailItem,
|
||||
expectEmailVisible,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Attachments on a message that belongs to a *different* account, opened from
|
||||
* the cross-account All-Mail view. Blobs are account-scoped, so downloading one
|
||||
* must route to the owning account's client + accountId — otherwise it 404s
|
||||
* against the active account (the reported bug).
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
const ATT = { filename: 'report.bin', contentType: 'application/octet-stream', content: 'hello-attachment-content-12345' };
|
||||
|
||||
test.describe('Cross-account attachments', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('an attachment on another account\'s All-Mail message downloads correctly', async ({ page }) => {
|
||||
const subject = `IT attach ${Date.now()}`;
|
||||
// Deliver a message with an attachment to bob.
|
||||
await sendMail({ from: bob.email, authPass: bob.password, to: bob.email, subject, body: 'see attachment', attachment: ATT });
|
||||
|
||||
// Cross-account All Mail + always download attachments (don't preview).
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
mailAttachmentAction: 'download',
|
||||
});
|
||||
|
||||
// Make alice the active account, with bob added, so bob's message is
|
||||
// genuinely cross-account when opened.
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await switchAccount(page, alice.email);
|
||||
await forceSync(page);
|
||||
|
||||
// Open the All-Mail view and bob's message.
|
||||
await expect(folderRow(page, { name: '__cross_all__' }).first()).toBeVisible();
|
||||
await openFolder(page, { name: '__cross_all__' });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, subject);
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The attachment chip is present; clicking it downloads the blob from bob's
|
||||
// account (pre-fix this 404s against alice and no download fires).
|
||||
const chip = page.locator(`[data-testid="attachment"][data-attachment-name="${ATT.filename}"]`).first();
|
||||
await chip.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 15000 }),
|
||||
chip.click(),
|
||||
]);
|
||||
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(c as Buffer);
|
||||
expect(Buffer.concat(chunks).toString()).toContain(ATT.content);
|
||||
});
|
||||
|
||||
test('an inline image on another account\'s All-Mail message renders', async ({ page }) => {
|
||||
const subject = `IT inline ${Date.now()}`;
|
||||
// 1x1 PNG referenced from the HTML body via cid.
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
await sendMail({
|
||||
from: bob.email, authPass: bob.password, to: bob.email, subject, body: '',
|
||||
inlineImage: { cid: 'inlinepic', contentType: 'image/png', base64: png, html: '<p>see below</p><img src="cid:inlinepic" alt="pic" width="1" height="1" />' },
|
||||
});
|
||||
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
});
|
||||
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await switchAccount(page, alice.email);
|
||||
await forceSync(page);
|
||||
|
||||
await openFolder(page, { name: '__cross_all__' });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, subject);
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The inline cid: image resolves to a blob URL fetched from bob's account.
|
||||
// Pre-fix the fetch 404s and it falls back to the data:image/gif placeholder.
|
||||
const img = page.frameLocator('iframe[title="Email content"]').locator('img').first();
|
||||
await expect
|
||||
.poll(async () => (await img.getAttribute('src').catch(() => '')) ?? '', { timeout: 15000 })
|
||||
.toMatch(/^blob:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Brings the integration stack up before the suite runs:
|
||||
* 1. fetch the arch-specific stalwart-cli (offline-friendly build input),
|
||||
* 2. ensure integration/.env exists (compose credentials),
|
||||
* 3. docker compose up -d --build --wait (Stalwart + webmail),
|
||||
* 4. block until Stalwart JMAP and the webmail health endpoint answer.
|
||||
*
|
||||
* Set IT_NO_DOCKER=1 to skip container management entirely (useful when the
|
||||
* stack is already running, e.g. during test authoring against `npm run dev`).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, copyFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { JMAP_URL, WEBMAIL_URL, ACCOUNTS, ACCOUNT_PASSWORD } from './helpers/config';
|
||||
|
||||
const INTEGRATION_DIR = path.resolve(__dirname, '..');
|
||||
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
|
||||
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
|
||||
|
||||
function run(cmd: string, args: string[], cwd = INTEGRATION_DIR): void {
|
||||
execFileSync(cmd, args, { cwd, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
async function waitFor(label: string, url: string, check: (r: Response) => boolean, timeoutMs = 240000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { Authorization: 'Basic ' + Buffer.from(`${ACCOUNTS.alice.email}:${ACCOUNT_PASSWORD}`).toString('base64') } });
|
||||
if (check(res)) return;
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label} at ${url}`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
if (process.env.IT_NO_DOCKER === '1') {
|
||||
console.log('[global-setup] IT_NO_DOCKER=1 — skipping docker compose management');
|
||||
} else {
|
||||
console.log('[global-setup] fetching stalwart-cli');
|
||||
run('bash', [path.join(INTEGRATION_DIR, 'stalwart', 'prepare-stalwart-cli.sh')]);
|
||||
|
||||
if (!existsSync(ENV_FILE)) {
|
||||
console.log('[global-setup] creating integration/.env from .env.example');
|
||||
copyFileSync(path.join(INTEGRATION_DIR, '.env.example'), ENV_FILE);
|
||||
}
|
||||
|
||||
console.log('[global-setup] docker compose up -d --build --wait');
|
||||
run('docker', [
|
||||
'compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE,
|
||||
'up', '-d', '--build', '--wait', '--wait-timeout', '300',
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('[global-setup] waiting for Stalwart JMAP');
|
||||
await waitFor('Stalwart JMAP', `${JMAP_URL}/jmap/session`, (r) => r.ok);
|
||||
|
||||
console.log('[global-setup] waiting for webmail');
|
||||
await waitFor('webmail', `${WEBMAIL_URL}/api/health`, (r) => r.ok, 240000);
|
||||
|
||||
console.log('[global-setup] stack ready');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* By default the stack is left running after the suite so re-runs are fast and
|
||||
* the state can be inspected (webmail on :3000, Stalwart admin on :8025).
|
||||
* Set IT_TEARDOWN=1 to tear the containers (and volumes) down instead.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const INTEGRATION_DIR = path.resolve(__dirname, '..');
|
||||
const COMPOSE_FILE = path.join(INTEGRATION_DIR, 'docker-compose.yml');
|
||||
const ENV_FILE = path.join(INTEGRATION_DIR, '.env');
|
||||
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
if (process.env.IT_TEARDOWN !== '1' || process.env.IT_NO_DOCKER === '1') {
|
||||
console.log('[global-teardown] leaving stack up (set IT_TEARDOWN=1 to remove it)');
|
||||
return;
|
||||
}
|
||||
console.log('[global-teardown] docker compose down -v');
|
||||
execFileSync('docker', ['compose', '-f', COMPOSE_FILE, '--env-file', ENV_FILE, 'down', '-v'], {
|
||||
cwd: INTEGRATION_DIR,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Page-level helpers for driving the Bulwark webmail in integration tests.
|
||||
*
|
||||
* Selectors rely on the data-testid hooks added to the mail UI (sidebar folder
|
||||
* rows + counters, account switcher, composer). Folder counters are read from
|
||||
* the `data-unread` / `data-total` attributes on `[data-testid=folder-counts]`
|
||||
* rather than parsing rendered text, so assertions are locale-independent.
|
||||
*/
|
||||
import { expect, type Page, type Locator } from '@playwright/test';
|
||||
import type { TestAccount } from './config';
|
||||
|
||||
/**
|
||||
* The account switcher renders twice (collapsed nav rail + expanded sidebar);
|
||||
* both carry the same data-testid and state, so always target the first.
|
||||
*/
|
||||
export function accountSwitcher(page: Page): Locator {
|
||||
return page.locator('[data-testid="account-switcher"]').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Next.js dev-mode overlay (`<nextjs-portal>`) sits in the bottom-left
|
||||
* corner and intercepts pointer events over the account switcher. Disable
|
||||
* pointer events on the portal host (light DOM) so it can't swallow clicks.
|
||||
* Registered as an init script so it survives navigations within the test.
|
||||
*/
|
||||
export async function neutralizeDevOverlay(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const inject = () => {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = 'nextjs-portal{pointer-events:none!important}';
|
||||
document.documentElement.appendChild(s);
|
||||
};
|
||||
if (document.documentElement) inject();
|
||||
else document.addEventListener('DOMContentLoaded', inject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the persisted settings store before the app boots. Merges over the
|
||||
* store defaults on rehydrate. Must be called before {@link login} so the init
|
||||
* script is registered before the first navigation.
|
||||
*/
|
||||
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
|
||||
await page.addInitScript((s) => {
|
||||
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
|
||||
}, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox. Requires the
|
||||
* `unifiedCrossAccountEnabled` admin feature gate (provided by
|
||||
* integration/webmail-config/policy.json).
|
||||
*/
|
||||
export async function seedUnifiedSettings(page: Page): Promise<void> {
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
|
||||
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
|
||||
* bounded (spans the active account's own + shared folders). The "All mail"
|
||||
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
|
||||
*/
|
||||
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
includeGroupInUnified: true,
|
||||
unifiedCrossAccount: !!opts.crossAccount,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill and submit the login form (works for first login and add-account). */
|
||||
async function submitCredentials(page: Page, account: TestAccount): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.fill('#username', account.email);
|
||||
await page.fill('#password', account.password);
|
||||
await page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
/** Log in as `account` from a clean context and wait for the mailbox to load. */
|
||||
export async function login(page: Page, account: TestAccount): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await submitCredentials(page, account);
|
||||
// Landed in the app once the account switcher (sidebar chrome) is present.
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 30000 });
|
||||
}
|
||||
|
||||
/** Add a second (or later) account via the account switcher + login form. */
|
||||
export async function addAccount(page: Page, account: TestAccount): Promise<void> {
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator('[data-testid="add-account"]').click();
|
||||
await submitCredentials(page, account);
|
||||
// Wait until the switcher reports the newly added account as active.
|
||||
await expect
|
||||
.poll(async () => activeAccountEmail(page), { timeout: 30000 })
|
||||
.toBe(account.email);
|
||||
}
|
||||
|
||||
/** Email of the currently active account, read from the switcher option list. */
|
||||
export async function activeAccountEmail(page: Page): Promise<string | null> {
|
||||
const switcher = accountSwitcher(page);
|
||||
const id = await switcher.getAttribute('data-active-account-id');
|
||||
if (!id) return null;
|
||||
await switcher.click();
|
||||
const email = await page
|
||||
.locator(`[data-testid="account-option"][data-account-id="${id}"]`)
|
||||
.first()
|
||||
.getAttribute('data-account-email');
|
||||
// Close the popover again.
|
||||
await page.keyboard.press('Escape');
|
||||
return email;
|
||||
}
|
||||
|
||||
/** Switch the active account to the one matching `email`. */
|
||||
export async function switchAccount(page: Page, email: string): Promise<void> {
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator(`[data-testid="account-option"][data-account-email="${email}"]`).first().click();
|
||||
await expect.poll(async () => activeAccountEmail(page), { timeout: 30000 }).toBe(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the app to reconcile mailbox state immediately.
|
||||
*
|
||||
* The JMAP client refetches on `visibilitychange` (tab focus) via
|
||||
* checkForStateChanges(). Dispatching it makes reconciliation deterministic
|
||||
* after an *external* mutation, sidestepping the small window right after
|
||||
* login where a change can land before the SSE push channel has settled.
|
||||
* Mirrors what happens when a real user tabs back to the mailbox.
|
||||
*/
|
||||
export async function forceSync(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
|
||||
}
|
||||
|
||||
// ─── Composer / drafts ────────────────────────────────────────────────────
|
||||
|
||||
/** Open the composer via the keyboard shortcut and wait for it to render. */
|
||||
export async function openComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
|
||||
}
|
||||
|
||||
/** Add a recipient to the To field (commits it as a chip with Enter). */
|
||||
export async function addRecipient(page: Page, email: string): Promise<void> {
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(email);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
/** Select a sending identity in the From dropdown by its identity id. */
|
||||
export async function setFrom(page: Page, identityId: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
|
||||
}
|
||||
|
||||
/** Fill the subject field. */
|
||||
export async function setSubject(page: Page, subject: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-subject"]').fill(subject);
|
||||
}
|
||||
|
||||
/** Wait until the composer reports the draft as saved. */
|
||||
export async function waitDraftSaved(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the composer (draft is auto-saved). */
|
||||
export async function closeComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Recipient chips currently shown in the composer's To field. */
|
||||
export async function composerRecipients(page: Page): Promise<string[]> {
|
||||
const to = page.locator('[data-testid="composer-to"]');
|
||||
const text = (await to.innerText()).toLowerCase();
|
||||
return text.split(/\s+/).filter((t) => t.includes('@'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender addresses the composer's From control offers.
|
||||
*
|
||||
* With more than one identity the control is a <select> and each choice is an
|
||||
* <option>; with a single identity it collapses to a static <span> that shows
|
||||
* only that address. Returning the raw text of whichever is rendered lets a
|
||||
* test assert on the *set of senders* without caring which shape it took.
|
||||
*/
|
||||
export async function composerFromOptions(page: Page): Promise<string[]> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
await from.waitFor({ state: 'visible', timeout: 10000 });
|
||||
if ((await from.locator('option').count()) > 0) {
|
||||
return from.locator('option').allTextContents();
|
||||
}
|
||||
return [await from.innerText()];
|
||||
}
|
||||
|
||||
export interface FolderSelector {
|
||||
role?: string;
|
||||
name?: string;
|
||||
mailboxId?: string;
|
||||
/** true = only shared-account folders, false = only own folders. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/** Locator for a sidebar folder row. */
|
||||
export function folderRow(page: Page, sel: FolderSelector): Locator {
|
||||
let s = '[data-testid="folder-row"]';
|
||||
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
|
||||
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
||||
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
|
||||
if (sel.shared === true) s += '[data-shared="true"]';
|
||||
if (sel.shared === false) s += ':not([data-shared="true"])';
|
||||
return page.locator(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the sidebar "Shared" section and the given sharer's shared-account
|
||||
* group so its folders (data-shared="true") render. Idempotent.
|
||||
*/
|
||||
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
|
||||
const section = page.locator('[data-testid="section-shared"]');
|
||||
await section.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
|
||||
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
|
||||
await account.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
|
||||
}
|
||||
|
||||
export interface FolderCounts {
|
||||
unread: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a folder's unread/total counts. When both are zero the counts element
|
||||
* is not rendered, so a missing element is reported as {0,0}.
|
||||
*/
|
||||
export async function folderCounts(page: Page, sel: FolderSelector): Promise<FolderCounts> {
|
||||
const row = folderRow(page, sel).first();
|
||||
const counts = row.locator('[data-testid="folder-counts"]');
|
||||
if ((await counts.count()) === 0) return { unread: 0, total: 0 };
|
||||
const unread = await counts.getAttribute('data-unread');
|
||||
const total = await counts.getAttribute('data-total');
|
||||
return { unread: Number(unread ?? 0), total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
/** Poll until a folder's unread count reaches `expected`. */
|
||||
export async function expectFolderUnread(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, sel)).unread, { timeout })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
|
||||
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
|
||||
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
|
||||
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
|
||||
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
|
||||
*/
|
||||
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const submenu = page.locator('[data-testid="ctx-move-to"]');
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await submenu.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await submenu.hover();
|
||||
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
|
||||
await target.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await target.click();
|
||||
}
|
||||
|
||||
/** Poll until a folder's total count reaches `expected`. */
|
||||
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await folderCounts(page, sel)).total, { timeout })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a folder's counts, nudging a reconcile (visibilitychange ->
|
||||
* checkForStateChanges) before *every* poll. Use for counters that update via
|
||||
* reconcile rather than live SSE push — after a server-side move/delete, a
|
||||
* mark-as-spam, or a shared-account change — where a single missed reconcile
|
||||
* would otherwise flake. Only the provided fields are compared.
|
||||
*/
|
||||
export async function expectFolderCountsSynced(
|
||||
page: Page,
|
||||
sel: FolderSelector,
|
||||
expected: { unread?: number; total?: number },
|
||||
timeout = 45000,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await forceSync(page);
|
||||
const c = await folderCounts(page, sel);
|
||||
return {
|
||||
...(expected.unread !== undefined ? { unread: c.unread } : {}),
|
||||
...(expected.total !== undefined ? { total: c.total } : {}),
|
||||
};
|
||||
},
|
||||
{ timeout, intervals: [500, 1000, 1500, 2000, 2000, 3000] },
|
||||
)
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
/** Click a folder row to select it. */
|
||||
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
||||
await folderRow(page, sel).first().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the composer's From sender whose option text contains `emailNeedle`
|
||||
* (e.g. a shared/group address). Requires the multi-identity <select> to be
|
||||
* rendered. Scrolls it into view first so the change is captured on video.
|
||||
*/
|
||||
export async function selectComposerFrom(page: Page, emailNeedle: string): Promise<void> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
await from.scrollIntoViewIfNeeded();
|
||||
const value = await from.locator('option', { hasText: emailNeedle }).first().getAttribute('value');
|
||||
if (!value) throw new Error(`No composer From option matching "${emailNeedle}"`);
|
||||
await from.selectOption(value);
|
||||
}
|
||||
|
||||
/** The display text of the currently selected From sender. */
|
||||
export async function selectedComposerFrom(page: Page): Promise<string> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
return (await from.locator('option:checked').first().textContent())?.trim() ?? '';
|
||||
}
|
||||
/** Locator for an email row by (exact) subject. */
|
||||
export function emailItem(page: Page, subject: string): Locator {
|
||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
||||
}
|
||||
|
||||
/** Poll until an email with `subject` is present in the list. */
|
||||
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert an email row's unread state (from its `data-unread` attribute). */
|
||||
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an email's right-click context menu and click one of its actions.
|
||||
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
|
||||
* `ctx-mark-read`, `ctx-mark-unread`.
|
||||
*/
|
||||
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const item = page.locator(`[data-testid="${testId}"]`);
|
||||
// Right-click can occasionally land before the list row is interactive;
|
||||
// retry opening the menu until the action item is actually present.
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await item.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await item.click();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Shared configuration for the integration tests. Values mirror the Stalwart
|
||||
* bootstrap (integration/stalwart/*) and the docker-compose port mappings.
|
||||
* Everything is overridable via env so the suite can run against a differently
|
||||
* mapped stack (e.g. remote CI) without code changes.
|
||||
*/
|
||||
|
||||
export const DOMAIN = process.env.IT_DOMAIN ?? 'example.org';
|
||||
|
||||
/** Shared password for every test mailbox (TEST_ACCOUNT_PASSWORD in .env). */
|
||||
export const ACCOUNT_PASSWORD = process.env.IT_ACCOUNT_PASSWORD ?? 'test-pass-123';
|
||||
|
||||
/** Webmail app origin (containerised, published on the host). */
|
||||
export const WEBMAIL_URL = process.env.IT_WEBMAIL_URL ?? 'http://localhost:3000';
|
||||
|
||||
/** Stalwart JMAP + admin base URL (host-published). */
|
||||
export const JMAP_URL = process.env.IT_JMAP_URL ?? 'http://localhost:8025';
|
||||
|
||||
/** Stalwart SMTP submission listener (host-published, maps to container 587). */
|
||||
export const SMTP_HOST = process.env.IT_SMTP_HOST ?? 'localhost';
|
||||
export const SMTP_PORT = Number(process.env.IT_SMTP_PORT ?? 1025);
|
||||
|
||||
/** Recovery admin — `user:password`, used for stalwart-cli style admin JMAP. */
|
||||
export const ADMIN_CREDENTIALS = process.env.IT_ADMIN ?? 'admin:bootstrap-secret';
|
||||
|
||||
export interface TestAccount {
|
||||
/** Local part, e.g. "alice". */
|
||||
user: string;
|
||||
/** Full address, e.g. "alice@example.org". */
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function acct(user: string): TestAccount {
|
||||
return { user, email: `${user}@${DOMAIN}`, password: ACCOUNT_PASSWORD };
|
||||
}
|
||||
|
||||
/** The mailboxes provisioned by the Stalwart bootstrap plan. */
|
||||
export const ACCOUNTS = {
|
||||
alice: acct('alice'),
|
||||
bob: acct('bob'),
|
||||
carol: acct('carol'),
|
||||
} as const;
|
||||
|
||||
export type AccountKey = keyof typeof ACCOUNTS;
|
||||
|
||||
/**
|
||||
* The shared *group* account provisioned by the bootstrap (a Stalwart Group
|
||||
* principal, not a login). `carol` is made a member before her first login, so
|
||||
* she sees the group's folders under "Shared" and can send as its address.
|
||||
* Groups have no password of their own — access is via a member's session.
|
||||
* (carol, rather than alice/bob, keeps the sync specs' accounts unshared.)
|
||||
*/
|
||||
export const GROUP = {
|
||||
team: { user: 'team', email: `team@${DOMAIN}`, memberOf: 'carol' as AccountKey },
|
||||
} as const;
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Minimal JMAP client for test setup/inspection against Stalwart.
|
||||
*
|
||||
* Uses global fetch (Node 18+). Not a full JMAP implementation — just the
|
||||
* pieces the integration tests need: authenticate, read/reset mailboxes,
|
||||
* create folders, and poll for delivery. Assertions on *server* state (via
|
||||
* this client) are kept separate from assertions on *UI* state (via the page),
|
||||
* so a failing test can tell whether the bug is in delivery or in the webmail's
|
||||
* sync.
|
||||
*/
|
||||
import { JMAP_URL } from './config';
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
|
||||
const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/** Rights granted on a shared mailbox (JMAP ACL). */
|
||||
export const FULL_MAILBOX_RIGHTS = {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
|
||||
interface JmapMailbox {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
parentId: string | null;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
}
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
export class JmapClient {
|
||||
private authHeader: string;
|
||||
private apiUrl: string;
|
||||
accountId = '';
|
||||
/** Every account visible in this user's session (own + shared/group),
|
||||
* keyed by accountId -> account name (its email address). */
|
||||
accounts: Record<string, string> = {};
|
||||
|
||||
private constructor(private email: string, password: string) {
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
|
||||
// rewrite onto the reachable origin, exactly as the app client does.
|
||||
this.apiUrl = `${JMAP_URL}/jmap/`;
|
||||
}
|
||||
|
||||
static async connect(email: string, password: string): Promise<JmapClient> {
|
||||
const c = new JmapClient(email, password);
|
||||
const res = await fetch(`${JMAP_URL}/jmap/session`, {
|
||||
headers: { Authorization: c.authHeader },
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
|
||||
const session = await res.json();
|
||||
const primary = session.primaryAccounts?.[MAIL];
|
||||
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
|
||||
c.accountId = primary;
|
||||
c.accounts = Object.fromEntries(
|
||||
Object.entries(session.accounts ?? {}).map(([id, a]) => [id, (a as { name: string }).name]),
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Names (email addresses) of the shared/group accounts this user can access,
|
||||
* i.e. everything in the session except the user's own primary account. */
|
||||
sharedAccountNames(): string[] {
|
||||
return Object.entries(this.accounts)
|
||||
.filter(([id]) => id !== this.accountId)
|
||||
.map(([, name]) => name);
|
||||
}
|
||||
|
||||
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL, SUBMISSION]): Promise<any> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** All sending identities of this account. */
|
||||
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a second sending identity `name <email>` exists (idempotent by
|
||||
* name). Returns its id. Used to make the composer's From selector appear so
|
||||
* a changed sender can be exercised.
|
||||
*/
|
||||
async ensureIdentity(name: string, email: string): Promise<string> {
|
||||
const existing = (await this.identities()).find((i) => i.name === name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request(
|
||||
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
|
||||
[CORE, SUBMISSION],
|
||||
);
|
||||
const created = r.methodResponses[0][1].created?.alt;
|
||||
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Resolve another user's principal id (needed as the key in `shareWith`). */
|
||||
async principalIdByEmail(email: string): Promise<string> {
|
||||
const r = await this.request(
|
||||
[
|
||||
['Principal/query', { accountId: this.accountId, filter: { email } }, '0'],
|
||||
['Principal/get', { accountId: this.accountId, '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' } }, '1'],
|
||||
],
|
||||
[CORE, PRINCIPALS],
|
||||
);
|
||||
const list = r.methodResponses[1][1].list as Array<{ id: string; email?: string }>;
|
||||
const match = list.find((p) => p.email === email) ?? list[0];
|
||||
if (!match) throw new Error(`No principal found for ${email}`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder in this account and share it with `granteeEmail`. Returns
|
||||
* the new mailbox id. The grantee then sees this account as a shared account
|
||||
* in their JMAP session.
|
||||
*/
|
||||
async createSharedFolder(name: string, granteeEmail: string): Promise<string> {
|
||||
const principalId = await this.principalIdByEmail(granteeEmail);
|
||||
const r = await this.request([
|
||||
['Mailbox/set', {
|
||||
accountId: this.accountId,
|
||||
create: { shared: { name, shareWith: { [principalId]: FULL_MAILBOX_RIGHTS } } },
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.shared;
|
||||
if (!created) throw new Error(`createSharedFolder failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Grant `granteeEmail` access to an existing mailbox of this account. */
|
||||
async shareMailbox(mailboxId: string, granteeEmail: string): Promise<void> {
|
||||
const principalId = await this.principalIdByEmail(granteeEmail);
|
||||
await this.request([
|
||||
['Mailbox/set', {
|
||||
accountId: this.accountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: FULL_MAILBOX_RIGHTS } },
|
||||
}, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Grant `granteeEmail` access to a system folder (by role) of this account. */
|
||||
async shareMailboxByRole(role: string, granteeEmail: string): Promise<string> {
|
||||
const mb = await this.mailboxByRole(role);
|
||||
if (!mb) throw new Error(`No ${role} mailbox to share`);
|
||||
await this.shareMailbox(mb.id, granteeEmail);
|
||||
return mb.id;
|
||||
}
|
||||
|
||||
async mailboxes(): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
||||
}
|
||||
|
||||
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.role === role);
|
||||
}
|
||||
|
||||
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.name === name);
|
||||
}
|
||||
|
||||
/** Create a folder (top-level) and return its id. Idempotent by name. */
|
||||
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
|
||||
const existing = await this.mailboxByName(name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.new;
|
||||
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async deleteMailboxByName(name: string): Promise<void> {
|
||||
const mb = await this.mailboxByName(name);
|
||||
if (!mb) return;
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private async allEmailIds(): Promise<string[]> {
|
||||
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
|
||||
return r.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a mailbox to a clean slate: destroy every message and delete any
|
||||
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const ids = await this.allEmailIds();
|
||||
if (ids.length) {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
const custom = (await this.mailboxes()).filter((m) => !m.role);
|
||||
if (custom.length) {
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move an email so it lives solely in `toMailboxId`. */
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
await this.request([
|
||||
['Email/set', { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true } } } }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deliver-and-file: create/find a custom folder and drop a message id into it. */
|
||||
async moveEmailToFolder(emailId: string, folderName: string): Promise<string> {
|
||||
const id = await this.createMailbox(folderName);
|
||||
await this.moveEmail(emailId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
|
||||
async createDraft(subject: string, toEmail: string): Promise<string> {
|
||||
const drafts = await this.mailboxByRole('drafts');
|
||||
if (!drafts) throw new Error('No Drafts mailbox');
|
||||
const r = await this.request([
|
||||
['Email/set', {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
d: {
|
||||
mailboxIds: { [drafts.id]: true },
|
||||
keywords: { $draft: true },
|
||||
from: [{ email: this.email }],
|
||||
to: [{ email: toEmail }],
|
||||
subject,
|
||||
bodyValues: { b: { value: 'server-created draft body' } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.d;
|
||||
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Set or clear the $seen keyword on an email. */
|
||||
async setSeen(emailId: string, seen: boolean): Promise<void> {
|
||||
await this.request([
|
||||
['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$seen`]: seen ? true : null } } }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Look up an email id by subject within an optional mailbox. */
|
||||
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
||||
const filter: Record<string, unknown> = { subject };
|
||||
if (mailboxId) filter.inMailbox = mailboxId;
|
||||
const r = await this.request([
|
||||
['Email/query', { accountId: this.accountId, filter }, '0'],
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
}
|
||||
|
||||
/** Poll until a message with `subject` is delivered (or throw on timeout). */
|
||||
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
|
||||
for (;;) {
|
||||
const found = await this.findEmailBySubject(subject, opts.mailboxId);
|
||||
if (found) return found;
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Dependency-free SMTP submission client.
|
||||
*
|
||||
* Speaks just enough SMTP to authenticate against Stalwart's plaintext
|
||||
* submission listener (AUTH LOGIN, no STARTTLS) and inject a message. Used to
|
||||
* simulate real inbound mail so the webmail's sync behaviour can be observed.
|
||||
* A raw socket keeps the test harness free of a nodemailer dependency.
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import { SMTP_HOST, SMTP_PORT } from './config';
|
||||
|
||||
interface SendOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
/** Envelope + auth sender, e.g. "alice@example.org". */
|
||||
from: string;
|
||||
/** Auth username; defaults to `from`. */
|
||||
authUser?: string;
|
||||
authPass: string;
|
||||
/** One or more envelope recipients. */
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
/** Plain-text body. */
|
||||
body: string;
|
||||
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
|
||||
headers?: Record<string, string>;
|
||||
/** Optional single attachment (sent as multipart/mixed, base64). */
|
||||
attachment?: { filename: string; contentType: string; content: string };
|
||||
/**
|
||||
* Optional inline image referenced by the HTML body via `cid:<cid>`. Sent as
|
||||
* multipart/related; `base64` is the pre-encoded image payload.
|
||||
*/
|
||||
inlineImage?: { cid: string; contentType: string; base64: string; html: string };
|
||||
}
|
||||
|
||||
class SmtpError extends Error {}
|
||||
|
||||
function crlf(s: string): string {
|
||||
return s.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a single message. Resolves once the server has accepted it (250 after
|
||||
* end-of-DATA). Rejects on any non-2xx/3xx reply or socket error.
|
||||
*/
|
||||
export async function sendMail(opts: SendOptions): Promise<void> {
|
||||
const host = opts.host ?? SMTP_HOST;
|
||||
const port = opts.port ?? SMTP_PORT;
|
||||
const recipients = Array.isArray(opts.to) ? opts.to : [opts.to];
|
||||
const authUser = opts.authUser ?? opts.from;
|
||||
|
||||
const socket = net.createConnection({ host, port });
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(15000);
|
||||
|
||||
let buffer = '';
|
||||
let resolveLine: ((line: string) => void) | null = null;
|
||||
let pendingError: Error | null = null;
|
||||
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk;
|
||||
// A complete reply ends with "<code> ...\r\n" (space, not hyphen, after code).
|
||||
const lines = buffer.split('\r\n');
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\d{3} /.test(line) && resolveLine) {
|
||||
const r = resolveLine;
|
||||
resolveLine = null;
|
||||
buffer = lines.slice(i + 1).join('\r\n');
|
||||
r(line);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('timeout', () => { pendingError = new SmtpError('SMTP timeout'); socket.destroy(); });
|
||||
socket.on('error', (e) => { pendingError = e; });
|
||||
|
||||
const waitReply = (expect: string): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (pendingError) return reject(pendingError);
|
||||
resolveLine = (line) => {
|
||||
if (!line.startsWith(expect)) {
|
||||
reject(new SmtpError(`Expected ${expect}, got: ${line}`));
|
||||
} else {
|
||||
resolve(line);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const send = (line: string): void => { socket.write(line + '\r\n'); };
|
||||
const b64 = (s: string) => Buffer.from(s).toString('base64');
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
await waitReply('220');
|
||||
send('EHLO integration-tests');
|
||||
await waitReply('250');
|
||||
send('AUTH LOGIN');
|
||||
await waitReply('334');
|
||||
send(b64(authUser));
|
||||
await waitReply('334');
|
||||
send(b64(opts.authPass));
|
||||
await waitReply('235');
|
||||
send(`MAIL FROM:<${opts.from}>`);
|
||||
await waitReply('250');
|
||||
for (const rcpt of recipients) {
|
||||
send(`RCPT TO:<${rcpt}>`);
|
||||
await waitReply('250');
|
||||
}
|
||||
send('DATA');
|
||||
await waitReply('354');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
From: opts.from,
|
||||
To: recipients.join(', '),
|
||||
Subject: opts.subject,
|
||||
...opts.headers,
|
||||
};
|
||||
|
||||
let mime: string;
|
||||
if (opts.inlineImage) {
|
||||
const boundary = 'itrelated_boundary_0001';
|
||||
headers['MIME-Version'] = '1.0';
|
||||
headers['Content-Type'] = `multipart/related; boundary="${boundary}"`;
|
||||
const b64 = opts.inlineImage.base64.replace(/(.{76})/g, '$1\r\n');
|
||||
mime = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'',
|
||||
crlf(opts.inlineImage.html),
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${opts.inlineImage.contentType}`,
|
||||
`Content-ID: <${opts.inlineImage.cid}>`,
|
||||
'Content-Disposition: inline',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
b64,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
} else if (opts.attachment) {
|
||||
const boundary = 'itmixed_boundary_0001';
|
||||
headers['MIME-Version'] = '1.0';
|
||||
headers['Content-Type'] = `multipart/mixed; boundary="${boundary}"`;
|
||||
const b64 = Buffer.from(opts.attachment.content).toString('base64').replace(/(.{76})/g, '$1\r\n');
|
||||
mime = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
crlf(opts.body),
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${opts.attachment.contentType}; name="${opts.attachment.filename}"`,
|
||||
`Content-Disposition: attachment; filename="${opts.attachment.filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
b64,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
} else {
|
||||
headers['Content-Type'] = 'text/plain; charset=utf-8';
|
||||
mime = crlf(opts.body);
|
||||
}
|
||||
|
||||
const headerBlock = Object.entries(headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n');
|
||||
// Dot-stuff any line that begins with '.'
|
||||
const safeBody = mime.replace(/\r\n\./g, '\r\n..');
|
||||
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
|
||||
await waitReply('250');
|
||||
send('QUIT');
|
||||
await waitReply('221').catch(() => { /* some servers drop before 221 */ });
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"features": {
|
||||
"unifiedCrossAccountEnabled": true,
|
||||
"crossUnreadViewEnabled": true,
|
||||
"crossAllViewEnabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Webmail image for integration testing — runs Next.js in DEVELOPMENT mode.
|
||||
#
|
||||
# Why dev mode rather than the production Dockerfile at the repo root?
|
||||
# The browser talks JMAP directly to Stalwart at http://localhost:8025 (plain
|
||||
# HTTP, cross-origin). The app's production Content-Security-Policy pins
|
||||
# connect-src to `'self' https:`, which would block that plaintext cross-origin
|
||||
# fetch. In development mode proxy.ts widens connect-src to `'self' http:
|
||||
# https: ws: wss:` — exactly what a local, TLS-less Stalwart needs. Running
|
||||
# from source also ships the integration-test data-testid hooks without a
|
||||
# production rebuild.
|
||||
#
|
||||
# Build context is the repo root (see docker-compose.yml `context: ..`), so the
|
||||
# root .dockerignore keeps examples/, integration/ and node_modules out.
|
||||
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for layer caching.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# App source (data-testid hooks included).
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Bind to 0.0.0.0 so the published port is reachable from the host/browser.
|
||||
CMD ["npx", "next", "dev", "-H", "0.0.0.0", "-p", "3000"]
|
||||
@@ -132,6 +132,42 @@ describe('isOrganizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('matches the event-level organizerCalendarAddress when no owner role is set', () => {
|
||||
// Stalwart / imported self-organized events: the user's participant only
|
||||
// carries `attendee`, the organizer lives in organizerCalendarAddress.
|
||||
const event = makeEvent({
|
||||
self: {
|
||||
'@type': 'Participant',
|
||||
name: 'Alice',
|
||||
email: '',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
sendTo: { imip: 'mailto:alice@example.com' },
|
||||
kind: 'individual',
|
||||
},
|
||||
});
|
||||
event.organizerCalendarAddress = 'mailto:alice@example.com';
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the event-level organizerCalendarAddress case-insensitively', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
event.organizerCalendarAddress = 'mailto:Alice@Example.com';
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to replyTo when organizerCalendarAddress is absent', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
event.replyTo = { imip: 'mailto:alice@example.com' };
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the event organizer is someone else', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
event.organizerCalendarAddress = 'mailto:someoneelse@example.com';
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserParticipantId', () => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { clearCachedData } from '../clear-cached-data';
|
||||
|
||||
describe('clearCachedData', () => {
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
reload = vi.fn();
|
||||
originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it('clears re-fetchable caches but keeps accounts, sessions and prefs', () => {
|
||||
localStorage.setItem('contact-storage', '1');
|
||||
localStorage.setItem('calendar-storage', '1');
|
||||
localStorage.setItem('identity-storage', '1');
|
||||
localStorage.setItem('calendar-notification-storage', '1');
|
||||
// Must survive — losing these is exactly the pain we're avoiding.
|
||||
localStorage.setItem('account-registry', 'accounts');
|
||||
localStorage.setItem('auth-storage', 'session');
|
||||
localStorage.setItem('settings-storage', 'prefs');
|
||||
localStorage.setItem('template-storage', 'my templates');
|
||||
|
||||
clearCachedData();
|
||||
|
||||
expect(localStorage.getItem('contact-storage')).toBeNull();
|
||||
expect(localStorage.getItem('calendar-storage')).toBeNull();
|
||||
expect(localStorage.getItem('identity-storage')).toBeNull();
|
||||
expect(localStorage.getItem('calendar-notification-storage')).toBeNull();
|
||||
|
||||
expect(localStorage.getItem('account-registry')).toBe('accounts');
|
||||
expect(localStorage.getItem('auth-storage')).toBe('session');
|
||||
expect(localStorage.getItem('settings-storage')).toBe('prefs');
|
||||
expect(localStorage.getItem('template-storage')).toBe('my templates');
|
||||
});
|
||||
|
||||
it('reloads so data is re-fetched fresh', () => {
|
||||
clearCachedData();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import { unlink, writeFileSync } from "fs";
|
||||
import { mkdtempSync, unlink, writeFileSync } from "fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default
|
||||
// data/admin) before env vars. Point it at an empty temp dir so local admin
|
||||
// state on the developer's machine can't leak into these env-driven
|
||||
// assertions. Must happen before the first GET, because the config manager
|
||||
// singleton loads the directory once and caches it.
|
||||
process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-'));
|
||||
|
||||
// Mock NextResponse before importing the route
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user