diff --git a/.dockerignore b/.dockerignore index 771c8d7d..509fe74a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml index 2c052a00..031bc549 100644 --- a/.github/workflows/docker-publish-releases.yml +++ b/.github/workflows/docker-publish-releases.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index eb935f21..2767d4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 1.7.7 (2026-07-09) + +### Features + +- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner +- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586) +- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs +- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490) +- **i18n**: Hebrew locale with full RTL support +- **i18n**: Slovak translation +- **i18n**: User-selectable regional date format +- **Contacts**: Enable trusted-senders address book sync by default when contacts are available +- **Mail**: Pin emails to the top of the folder list +- **Mail**: Setting to disable the tag-color row tint in the message list +- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style) +- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher +- **Composer**: Recipient autocomplete from Sent, with on-demand server search +- **Composer**: Preselect the identity of the active mailbox for new messages +- **Email**: Send a quick reply with Ctrl/Cmd+Enter +- **Headers**: Parse Stalwart spam headers +- **Login**: Configurable logo size and hideable heading/subtitle +- **PWA**: Apple Touch icons for the iOS home screen + +### Fixes + +- **Mail**: Hide Files when the account lacks the filenode capability (#563) +- **Mail**: Keep advanced search filters applied when switching folders (#553) +- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected +- **Mail**: Route keyword writes to the email's own account in unified view +- **Mail**: Render emails that set `height:100%` on a wrapper element +- **Mail**: Hide images that fail to load +- **Mail**: Storage quota not shown with Stalwart (#577) +- **Spam**: Hide the spam action in Sent, Drafts and Scheduled +- **Spam**: Fix stale folder counters and open message after spam actions +- **Composer**: Wait for in-flight attachment uploads before sending +- **Composer**: Only commit a recipient on Space when the input is a valid email (#571) +- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570) +- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE +- **Calendar**: Strike through cancelled events and mute their reminders (#572) +- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500) +- **Auth**: Keep the session when the auth server is briefly unreachable +- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position +- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing +- **Contacts**: Clear the photo on the server by sending `media: null` when removed +- **Plugins**: Preserve the settings slot and privileged tier +- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X +- **Pro**: Show the Edit button on draft emails opened in a new tab +- **List**: Shift-click on the checkbox extends the selection (range) +- **CSP**: Allow external/data fonts so email webfonts render +- **Notifications**: Brand push notifications with the configured PWA icon +- **Notifications**: Notification sound preview — base-path prefix and longer default beep +- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler +- **Branding**: Apply per-domain favicon override in root metadata (#585) +- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0 +- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518) +- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost` + ## 1.7.6 (2026-06-28) ### Breaking Changes diff --git a/Dockerfile b/Dockerfile index 2b7c21ae..cd12db32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/FEATURES.md b/FEATURES.md index 3fb35ec4..1043c7c1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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`) diff --git a/README.md b/README.md index 717e3629..8b2ce340 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.7.6-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) -[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) - --- diff --git a/VERSION b/VERSION index de28578a..91c74a58 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.6 +1.7.7 diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index 07a2e98d..0c9ffefd 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -1149,13 +1149,13 @@ export default function CalendarPage() {
diff --git a/app/(main)/[locale]/error.tsx b/app/(main)/[locale]/error.tsx index 50706cff..7ddd9107 100644 --- a/app/(main)/[locale]/error.tsx +++ b/app/(main)/[locale]/error.tsx @@ -38,11 +38,11 @@ export default function LocaleError({

diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 4c3a5fad..88f24225 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -477,7 +477,7 @@ export default function FilesPage() { onClick={() => router.push("/")} className="justify-start" > - + {t("title")} @@ -611,7 +611,7 @@ export default function FilesPage() { : '0%' }} /> -

+

{migrationProgress.current} / {migrationProgress.total}

diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 645250c3..fbddc90e 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -133,9 +133,16 @@ 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, loginShowTotp, loginShowVersion, 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 + // 64×64 box so the logo (e.g. a wide wordmark) can render at its true size. + const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth); + const loginLogoStyle = hasLogoSize + ? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined } + : undefined; + const [formData, setFormData] = useState({ username: "", password: "", @@ -718,7 +725,7 @@ export default function LoginPage() { )} > - {option.label} + {option.label} {isActive && } ); @@ -867,7 +874,7 @@ export default function LoginPage() { )} > - {option.label} + {option.label} {isActive && } ); @@ -881,19 +888,24 @@ export default function LoginPage() {
{/* Header section with logo */}
-
+
{appName}
-

- {isAddAccountMode ? t("add_account_title") : appName} -

-

- {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")} -

+ {loginShowHeading && ( +

+ {isAddAccountMode ? t("add_account_title") : appName} +

+ )} + {loginShowSubtitle && ( +

+ {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")} +

+ )}
{/* Form section */} @@ -1121,7 +1133,7 @@ export default function LoginPage() { type={showPassword ? "text" : "password"} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} - className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" placeholder={t("password_placeholder")} required autoComplete="current-password" @@ -1246,9 +1258,9 @@ export default function LoginPage() { disabled={oauthLoading || isLoading} > {oauthLoading ? ( - + ) : ( - + )} {t("sign_in_sso")} diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 589d5315..f0ae5a56 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -11,7 +11,7 @@ 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,12 +70,12 @@ 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"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; -import { useLocaleStore } from "@/stores/locale-store"; +import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; const SCHEDULED_MAILBOX_ID = '__scheduled__'; @@ -110,7 +110,7 @@ export default function Home() { const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(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(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); @@ -121,7 +121,7 @@ export default function Home() { useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); - const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('email_viewer.reschedule_prompt')); @@ -342,15 +342,21 @@ export default function Home() { refreshCurrentMailbox, } = useEmailStore(); + // Load recent recipients (from the Sent folder) for compose autocomplete. + // Runs once when the Sent mailbox is known; the store guards against reloads. + useEffect(() => { + const sent = mailboxes.find((m) => m.role === 'sent'); + if (client && sent) { + loadRecentRecipients(client, sent.originalId || sent.id); + } + }, [client, mailboxes, loadRecentRecipients]); + // Pro shell: populate per-account mailbox cache so the sidebar can render // every connected account Thunderbird-style. 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 @@ -368,18 +374,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 => { + // 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), }); }, []); @@ -612,6 +636,10 @@ export default function Home() { onToggleSpam: async () => { if (isScheduledView) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + // Marking your own outgoing mail as spam makes no sense - the toolbar + // and menus hide the action in Sent/Drafts/Scheduled, so the shortcut + // is a no-op there too. + if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return; const isInJunk = currentMailbox?.role === 'junk'; if (selectedEmailIds.size > 0 && client) { const ids = Array.from(selectedEmailIds); @@ -759,8 +787,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, @@ -973,29 +1000,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 @@ -1012,7 +1062,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=` (specific email it built the toast from) or @@ -1172,18 +1222,22 @@ export default function Home() { return; } - // Mark the original email with $answered or $forwarded keyword - if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { + // Mark the original email with $answered or $forwarded keyword. Route the + // write to the email's own account so the flag lands on shared/group-mailbox + // messages instead of being dropped against the reaching account. (#281) + if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered'; try { - await client.setKeyword(originalEmailId, '$answered'); + await kwClient.setKeyword(originalEmailId, keyword, kwAccountId); } catch (e) { - debug.error('Failed to set $answered keyword:', e); - } - } else if (originalEmailId && effectiveMode === 'forward') { - try { - await client.setKeyword(originalEmailId, '$forwarded'); - } catch (e) { - debug.error('Failed to set $forwarded keyword:', e); + debug.error(`Failed to set ${keyword} keyword:`, e); } } @@ -1263,7 +1317,7 @@ export default function Home() { }, newTo, newCc, - locale: useLocaleStore.getState().locale, + locale: getEffectiveLocale(), timeFormat: useSettingsStore.getState().timeFormat, unknownLabel: tCommon('unknown'), labels: { @@ -1327,11 +1381,18 @@ export default function Home() { draft = fullDraft; } + draft = await emailHooks.onBeforeEditDraft.transform(draft); + 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 @@ -1371,6 +1432,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: () => { @@ -1635,6 +1717,45 @@ export default function Home() { } }; + const handleTogglePinned = async (emailToPin: Email) => { + if (!client) return; + + try { + const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin; + const isPinned = email.keywords?.['$pinned'] === true; + // JMAP keywords are a set of present keys - drop the key to unpin + // rather than writing a false value. + const keywords = { ...email.keywords }; + if (isPinned) { + delete keywords['$pinned']; + } else { + keywords['$pinned'] = true; + } + + // Same unified-view routing as color tags: write to the email's own + // account via the login it is reachable through. (#281) + const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const pinClient = pinClientId + ? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client) + : client; + + await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId); + + // Patch in place so the icon flips immediately, then refetch the first + // page so the mail floats/sinks per the server's pinned-first sort. + // Skip the refetch where that sort does not apply (unified views) or + // where it would replace a tag-filtered list (refreshCurrentMailbox + // fetches by folder only). + setEmailKeywordsLocal(email.id, keywords); + if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) { + void refreshCurrentMailbox(client); + } + } catch (error) { + console.error("Failed to toggle pin:", error); + } + }; + const handleSetColorTag = async (emailId: string, color: string | null) => { if (!client) return; @@ -1663,8 +1784,20 @@ export default function Home() { } } + // In unified view route the write to the email's own account, reached + // through the login it is reachable via (`sourceClientAccountId`) and + // applied to its owning JMAP account (`sourceAccountId`). For personal + // sources these resolve to the account itself, so behavior is unchanged. + // Without this, tags on shared/group-mailbox messages are written to the + // reaching account and silently dropped by the server. (#281) + const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const tagClient = tagClientId + ? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client) + : client; + // Update email keywords via JMAP - await client.updateEmailKeywords(emailId, keywords); + await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId); // Patch the email in place so the list keeps its scroll/pagination state // instead of being reset to the first page by a full refetch. @@ -1702,7 +1835,15 @@ export default function Home() { setTabletListVisible(true); } if (viewingClient) { - await fetchEmails(viewingClient, mailboxId); + // Keep an active search applied when switching folders (#553); the + // store actions resolve the viewing account's client internally. + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(viewingClient); + } else if (searchQuery) { + await searchEmails(viewingClient, searchQuery); + } else { + await fetchEmails(viewingClient, mailboxId); + } } }; @@ -1745,7 +1886,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; } @@ -1767,7 +1919,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; } @@ -1792,8 +1955,13 @@ export default function Home() { } if (client) { - // If there's an active search, re-run it in the new mailbox - if (searchQuery) { + // If there's an active search, re-run it in the new mailbox. Advanced + // filters must go through advancedSearch (which also includes the text + // query) — falling back to fetchEmails would silently drop them while + // the UI still shows them as active (#553). + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else if (searchQuery) { await searchEmails(client, searchQuery); } else { await fetchEmails(client, mailboxId); @@ -2108,13 +2276,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; } @@ -2146,41 +2317,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; @@ -2271,11 +2465,22 @@ export default function Home() { return; } - // Mark the original email as answered - try { - await client.setKeyword(originalEmailId, '$answered'); - } catch (e) { - debug.error('Failed to set $answered keyword:', e); + // Mark the original email as answered. Route the write to the email's own + // account so the flag lands on shared/group-mailbox messages instead of + // being dropped against the reaching account. (#281) + { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + try { + await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } } // Refresh emails to show the sent reply @@ -2319,14 +2524,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); @@ -2614,7 +2817,7 @@ export default function Home() { selectedKeyword={selectedKeyword} scheduledTotal={scheduledTotal} showScheduledMailbox={delayedSendSupported} - showAllMailMailbox={showAllMailMailbox} + crossAccountActive={crossAccountActive} showCrossUnread={showCrossUnread} showCrossStarred={showCrossStarred} showCrossAll={showCrossAll} @@ -2667,17 +2870,19 @@ export default function Home() {
@@ -2987,6 +3192,9 @@ export default function Home() { await toggleStar(client, email.id); } }} + onTogglePinned={async (email) => { + await handleTogglePinned(email); + }} onDelete={async (email) => { await handleDelete(email); }} @@ -3025,7 +3233,7 @@ export default function Home() { }} className={cn( "absolute z-40 rounded-full shadow-lg", - isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12" + isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12" )} aria-label={t('sidebar.compose')} title={t('sidebar.compose_hint')} @@ -3084,6 +3292,11 @@ export default function Home() {
{t('email_composer.continue_draft')} {pendingDraft.subject && ( - {pendingDraft.subject} + {pendingDraft.subject} )}
s.focusedPaneId); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); - const closeTab = useProTabStore((s) => s.closeTab); + const requestCloseTab = useProTabStore((s) => s.requestCloseTab); const setActiveTab = useProTabStore((s) => s.setActiveTab); const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane); @@ -354,7 +354,7 @@ export default function ProHome() { activeMainTabId={activeMainTabId} activeSplitTabId={activeSplitTabId} onActivate={setActiveTab} - onClose={closeTab} + onClose={requestCloseTab} onDragStateChange={setIsTabDragging} /> diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 2a10f986..7f94dc6b 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; import { @@ -66,6 +66,8 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; +import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; +import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -113,8 +115,14 @@ type Tab = type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; +// A plugin that exposes a `settings-section` slot gets its own first-class +// Settings entry, keyed `plugin:`, so its UI (e.g. S/MIME key import) is +// discoverable as a menu point rather than buried inside another panel. +type PluginTabId = `plugin:${string}`; +type SettingsTabId = Tab | PluginTabId; + interface TabDef { - id: Tab; + id: SettingsTabId; label: string; icon: LucideIcon; group: TabGroup; @@ -330,7 +338,7 @@ const LEGACY_TAB_MAP: Record = { advanced: 'about_data', }; -function readPersistedTab(): Tab { +function readPersistedTab(): SettingsTabId { try { // One-shot deep link from the sidebar section gears (Folders / Tags). // Used only as the initial tab and intentionally NOT written to @@ -338,7 +346,7 @@ function readPersistedTab(): Tab { // default that the regular Settings button lands on. Cleared on mount. const deepLink = sessionStorage.getItem('settings-deep-link-tab'); if (deepLink) { - return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab; + return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId; } const saved = localStorage.getItem('settings-active-tab'); if (!saved) return 'appearance'; @@ -347,7 +355,7 @@ function readPersistedTab(): Tab { try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ } return migrated; } - return saved as Tab; + return saved as SettingsTabId; } catch { return 'appearance'; } @@ -364,7 +372,15 @@ export default function SettingsPage() { const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); const { isFeatureEnabled } = usePolicyStore(); - const [activeTab, setActiveTab] = useState(readPersistedTab); + const [activeTab, setActiveTab] = useState(readPersistedTab); + // Active plugins that expose a `settings-section` slot — each becomes its own + // Settings menu entry. Referentially stable per registry mutation, so it is + // safe to feed useSyncExternalStore directly. + const pluginSettingsOffers = useSyncExternalStore( + pluginRegistrySubscribe, + () => pluginOffersForSlot('settings-section'), + () => pluginOffersForSlot('settings-section'), + ); // Consume the one-shot deep-link key so a section gear only steers this one // open, never the persisted default for future Settings-button clicks. useEffect(() => { @@ -372,7 +388,7 @@ export default function SettingsPage() { }, []); const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null); + const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null); const isDesktop = useIsDesktop(); const messages = useMessages() as Record; @@ -624,6 +640,14 @@ export default function SettingsPage() { ...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []), ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), + // Plugin-contributed settings pages: one entry per active plugin that + // offers a `settings-section` slot (e.g. S/MIME key & certificate manager). + ...pluginSettingsOffers.map((offer): TabDef => ({ + id: `plugin:${offer.pluginId}` as PluginTabId, + label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId, + icon: Puzzle, + group: 'apps', + })), // Advanced { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, @@ -644,7 +668,7 @@ export default function SettingsPage() { ].filter(Boolean) as Tab[]) : []; const visibleTabs = managedAccountId - ? tabs.filter((tab) => scopedTabIds.includes(tab.id)) + ? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab)) : tabs; // Group tabs by category @@ -660,12 +684,12 @@ export default function SettingsPage() { const matchesQuery = (tab: TabDef) => { if (!trimmedQuery) return true; if (tab.label.toLowerCase().includes(trimmedQuery)) return true; - return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false; + return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false; }; - const subResultsForTab = (tabId: Tab): SubResult[] => { + const subResultsForTab = (tabId: SettingsTabId): SubResult[] => { if (!trimmedQuery) return []; - const list = tabSubResults[tabId] ?? []; + const list = tabSubResults[tabId as Tab] ?? []; return list .filter((r) => r.label.toLowerCase().includes(trimmedQuery) || @@ -684,11 +708,11 @@ export default function SettingsPage() { // mode hides it), fall back. In scoped mode fall back to the first scoped tab; // otherwise the usual 'appearance' default. const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab); - const effectiveActiveTab: Tab = isActiveVisible + const effectiveActiveTab: SettingsTabId = isActiveVisible ? activeTab : (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance'); - const handleTabSelect = (tabId: Tab) => { + const handleTabSelect = (tabId: SettingsTabId) => { setActiveTab(tabId); try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { @@ -696,7 +720,7 @@ export default function SettingsPage() { } }; - const handleSubResultSelect = (tabId: Tab, sub: SubResult) => { + const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => { handleTabSelect(tabId); setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId }); }; @@ -712,11 +736,11 @@ export default function SettingsPage() { clearManagedAccount(); handleTabSelect('account'); }} - className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors" + className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors" > {t('scoped.back')} - + {t('scoped.managing', { name: managedAccount.name })} @@ -754,6 +778,13 @@ export default function SettingsPage() { {effectiveActiveTab === 'themes' && } {effectiveActiveTab === 'plugins' && } {effectiveActiveTab === 'debug' && } + {effectiveActiveTab.startsWith('plugin:') && ( + + )} ); @@ -820,7 +851,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-9 pr-9 h-10" + className="ps-9 pe-9 h-10" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -868,7 +899,7 @@ export default function SettingsPage() { @@ -932,7 +963,7 @@ export default function SettingsPage() { <>
router.push('/')} className="w-full justify-start" > - + {t('back_to_mail')}
@@ -960,7 +991,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-8 pr-8 h-9 text-sm" + className="ps-8 pe-8 h-9 text-sm" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -997,7 +1028,7 @@ export default function SettingsPage() { diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index 638e9fee..ef4e00ac 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) Per-server OAuth (optional, overrides global) {d.oauthExpanded && ( -
+
- Time - Action - Details - IP + Time + Action + Details + IP diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx index 64fda763..6bf82c38 100644 --- a/app/(main)/admin/_tabs/marketplace.tsx +++ b/app/(main)/admin/_tabs/marketplace.tsx @@ -171,7 +171,7 @@ export function MarketplaceTab() { placeholder="Search extensions..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} - className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" + className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" />
@@ -210,7 +210,7 @@ export function MarketplaceTab() { {loading && !error && (
- Searching extensions... + Searching extensions...
)} diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx index 70e83cb7..2cc5fe7f 100644 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx @@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { if (loading) { return (
- + Loading...
); @@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
{field.description && (

{field.description}

@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { value={formValues[key] ?? ''} onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))} placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} - className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" + className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" />
{logoUrl ? ( - + ) : ( - + )} Admin Panel
diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx index e8095b05..d601e66c 100644 --- a/app/(main)/admin/marketplace/[slug]/page.tsx +++ b/app/(main)/admin/marketplace/[slug]/page.tsx @@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() { if (loading) { return (
- + Loading...
); @@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 65d4bba1..34d9f94a 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,9 +1,16 @@ import type { Metadata, Viewport } from "next"; +import { getLocaleDirection } from "@/i18n/direction"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale, getTranslations } from "next-intl/server"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; +import { FaviconBadge } from "@/components/favicon-badge"; import { configManager } from "@/lib/admin/config-manager"; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, +} from "@/lib/admin/domain-branding"; import { withBasePath } from "@/lib/browser-navigation"; import { locales } from "@/i18n/routing"; import "../globals.css"; @@ -38,7 +45,19 @@ export const viewport: Viewport = { export async function generateMetadata(): Promise { await configManager.ensureLoaded(); - const faviconUrl = configManager.get("faviconUrl", "/branding/Bulwark_Favicon.svg"); + // The favicon must honor per-domain branding, exactly like + // /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the + // request host and prefer its override; fall back to the global + // admin/env/default value when the host has no favicon override (#585). + const host = pickRequestHost(await headers()); + const domainOverride = matchDomainBranding( + host, + parseDomainBranding(configManager.get("domainBranding", [])), + ).faviconUrl; + const faviconUrl = + domainOverride && domainOverride.length > 0 + ? domainOverride + : configManager.get("faviconUrl", "/branding/Bulwark_Favicon.svg"); // Localize the description to match the UI language; a hardcoded // English description is another signal that makes Chrome offer to // "translate this page". Resolve the locale from the request path, since this @@ -76,7 +95,7 @@ export default async function RootLayout({ const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; return ( - + @@ -114,6 +133,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > + {children} diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 6f3fd608..299715c5 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick
-
{showUrlField && ( -
+
{uploadError}

+

{uploadError}

)}
); @@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono return (
{label} - + {value || -}
diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts index 7f292c17..150e5e2a 100644 --- a/app/api/account/stalwart/jmap/route.ts +++ b/app/api/account/stalwart/jmap/route.ts @@ -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', }); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index d44887ce..b60dd6a6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -341,6 +341,7 @@ export async function POST(request: NextRequest) { author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', type: (manifest.type as string) || 'hook', + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions, entrypoint, enabled: existingPlugin?.enabled ?? true, diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index e734ba71..16768956 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) { if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); - cookieStore.delete(cookieName); - cookieStore.delete(refreshTokenServerCookieName(slot)); - return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + // Drop the refresh token only when the server definitively rejected it + // (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping + // the cookie lets the session resume once the server is back. + const status = tokenResponse.status; + if (status === 400 || status === 401 || status === 403) { + cookieStore.delete(cookieName); + cookieStore.delete(refreshTokenServerCookieName(slot)); + return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + } + return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); } const tokens = await tokenResponse.json(); diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4f39e455..923f3564 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -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; - capabilities?: Record; -} - 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 { - 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 { - 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})`); } diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 65d665c2..bf86013f 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -74,6 +74,10 @@ export async function GET(request: NextRequest) { loginImprintUrl: branded('loginImprintUrl', ''), loginPrivacyPolicyUrl: branded('loginPrivacyPolicyUrl', ''), loginWebsiteUrl: branded('loginWebsiteUrl', ''), + loginLogoMaxHeight: configManager.get('loginLogoMaxHeight', ''), + loginLogoMaxWidth: configManager.get('loginLogoMaxWidth', ''), + loginShowHeading: configManager.get('loginShowHeading', true), + loginShowSubtitle: configManager.get('loginShowSubtitle', true), loginShowTotp: configManager.get('loginShowTotp', true), loginShowVersion: configManager.get('loginShowVersion', true), demoMode: configManager.get('demoMode', false), diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 351c3c72..faec6968 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul } function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { - return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; + // mirroring Stalwart: resourceType "octets", scope "account" + return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; } function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult { diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 52870b18..93a538c5 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -59,10 +59,12 @@ export async function GET( domainOverrides.pwaIconUrl || domainOverrides.faviconUrl || (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || - (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); - if (!iconUrl) { - return new NextResponse('No PWA icon configured', { status: 404 }); - } + (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') || + // Fall back to the built-in default so this endpoint ALWAYS returns an app + // icon (custom if configured, else the bundled default). This lets callers + // that can't run the custom-vs-default check themselves - notably the + // service worker's notifications - use a single stable URL. + `/icon-${size}x${size}.png`; const pngHeaders = { 'Content-Type': 'image/png', diff --git a/app/globals.css b/app/globals.css index ce708b75..545a415d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -660,13 +660,13 @@ body { .tiptap ul { list-style-type: disc; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } .tiptap ol { list-style-type: decimal; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } @@ -675,8 +675,8 @@ body { } .tiptap blockquote { - border-left: 3px solid var(--color-border); - padding-left: 1rem; + border-inline-start: 3px solid var(--color-border); + padding-inline-start: 1rem; margin: 0.5rem 0; color: var(--color-muted-foreground); } @@ -719,7 +719,7 @@ body { .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); - float: left; + float: inline-start; color: var(--color-muted-foreground); pointer-events: none; height: 0; @@ -808,3 +808,20 @@ body { border-radius: 8px; animation: settings-search-pulse 1.6s ease-in-out forwards; } + +/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward, + and panel-collapse affordances point the correct way in right-to-left layouts. + lucide-react emits a `lucide-` class per icon, so we target the + directional ones only — vertical chevrons (up/down) are intentionally left. */ +[dir="rtl"] .lucide-chevron-left, +[dir="rtl"] .lucide-chevron-right, +[dir="rtl"] .lucide-chevrons-left, +[dir="rtl"] .lucide-chevrons-right, +[dir="rtl"] .lucide-arrow-left, +[dir="rtl"] .lucide-arrow-right, +[dir="rtl"] .lucide-arrow-big-left, +[dir="rtl"] .lucide-arrow-big-right, +[dir="rtl"] .lucide-panel-left, +[dir="rtl"] .lucide-panel-right { + transform: scaleX(-1); +} diff --git a/components/__tests__/favicon-badge.test.tsx b/components/__tests__/favicon-badge.test.tsx new file mode 100644 index 00000000..5524c01c --- /dev/null +++ b/components/__tests__/favicon-badge.test.tsx @@ -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 & { 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(); + + 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(); + + 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(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true); + }); + + it('badges zero when there is no inbox yet', () => { + useEmailStore.setState({ mailboxes: [] }); + + render(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 1aa0513c..d757305c 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -131,7 +131,7 @@ export function CalendarAgendaView({ )}> {formatDateHeader(group.date)} - + {intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
@@ -148,6 +148,9 @@ export function CalendarAgendaView({ const color = getEventColor(ev, calendar); const start = getEventStartDate(ev); const end = getEventEndDate(ev); + // iTIP CANCEL marks the attendee's copy with status "cancelled" + // instead of deleting it (#572). + const isCancelled = ev.status === "cancelled"; const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; @@ -159,7 +162,10 @@ export function CalendarAgendaView({ onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())} onMouseLeave={() => onHoverLeave?.()} onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined} - className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left" + className={cn( + "w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start", + isCancelled && "opacity-60" + )} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} >
@@ -181,7 +187,7 @@ export function CalendarAgendaView({ />
-
+
{ev.title || t("events.no_title")}
{locationName && ( diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index b06528fc..943480b3 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -218,7 +218,7 @@ export function CalendarDayView({ {HOURS.map((h) => (
{h > 0 && ( @@ -231,7 +231,7 @@ export function CalendarDayView({
handleGridPointerDown(e, dayKey, selectedDate)} @@ -312,7 +312,7 @@ export function CalendarDayView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -346,7 +346,7 @@ export function CalendarDayView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index c6b26e49..c91a71bd 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -1,11 +1,8 @@ "use client"; import { useMemo, useState, useCallback, type DragEvent } from "react"; -import { useTranslations, useFormatter } from "next-intl"; -import { - startOfMonth, endOfMonth, startOfWeek, endOfWeek, - eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO, -} from "date-fns"; +import { useTranslations } from "next-intl"; +import { format, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; interface CalendarMonthViewProps { selectedDate: Date; @@ -47,16 +45,21 @@ export function CalendarMonthView({ pendingPreview, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); - const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + const { + weekStartsOn, + dayHeaderKeys, + getMonthGridDays, + checkIsToday, + checkIsSameMonth, + checkIsSameDay, + formatDayNumber, + formatFullDate, + } = useCalendarLocale(); - const days = useMemo(() => { - const monthStart = startOfMonth(selectedDate); - const monthEnd = endOfMonth(selectedDate); - const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); - const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart }); - return eachDayOfInterval({ start: gridStart, end: gridEnd }); - }, [selectedDate, weekStart]); + const days = useMemo( + () => getMonthGridDays(selectedDate), + [selectedDate, getMonthGridDays], + ); const calendarMap = useMemo(() => { const map = new Map(); @@ -83,10 +86,6 @@ export function CalendarMonthView({ return map; }, [events]); - const dayHeaders = firstDayOfWeek === 0 - ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const - : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; - const weeks = useMemo(() => { const result: Date[][] = []; for (let i = 0; i < days.length; i += 7) { @@ -141,11 +140,11 @@ export function CalendarMonthView({ }, [t]); return ( -
+
- {dayHeaders.map((d) => ( + {dayHeaderKeys.map((d) => (
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} @@ -161,12 +160,12 @@ export function CalendarMonthView({ )} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
{week.map((day) => { - const inMonth = isSameMonth(day, selectedDate); - const selected = isSameDay(day, selectedDate); - const today = isToday(day); + const inMonth = checkIsSameMonth(day, selectedDate); + const selected = checkIsSameDay(day, selectedDate); + const today = checkIsToday(day); const key = format(day, "yyyy-MM-dd"); const dayEvents = eventsByDate.get(key) || []; - const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + const fullDateLabel = formatFullDate(day); return (
handleCellDrop(e, day)} className={cn( - "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation", + "border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation", !inMonth && "bg-muted/30", "hover:bg-muted/50", selected && isMobile && "bg-primary/10", @@ -199,7 +198,7 @@ export function CalendarMonthView({ inMonth && !selected && !today && "font-medium" )} > - {format(day, "d")} + {formatDayNumber(day)}
{isMobile ? ( @@ -219,7 +218,7 @@ export function CalendarMonthView({ {dayEvents.length > 3 && ( )} - {pendingPreview && isSameDay(pendingPreview.start, day) && ( + {pendingPreview && checkIsSameDay(pendingPreview.start, day) && ( {!isMobile && pendingPreview && (() => { - const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start)); + const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start)); if (previewDayIdx === -1) return null; const previewRow = rowCount; const cal = calendarMap.get(pendingPreview.calendarId); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 2fc8dc11..38dc2dcc 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -397,7 +397,7 @@ export function CalendarSidebarPanel({ {t('tasks.label')} {pendingTaskCount > 0 && ( - {pendingTaskCount} + {pendingTaskCount} )} {overdueTaskCount > 0 && ( {overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()} @@ -437,7 +437,7 @@ export function CalendarSidebarPanel({ onCreateCalendar(); } }} - className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" + className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" title={tMgmt('add_calendar')} > @@ -445,7 +445,7 @@ export function CalendarSidebarPanel({ )} {expanded && ( -
+
{owned.length > 0 && (
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index c642bd79..d26f8686 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -1,13 +1,14 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { useTranslations, useFormatter } from "next-intl"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react"; -import { addDays, startOfWeek } from "date-fns"; +import { startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; import type { Calendar } from "@/lib/jmap/types"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; interface CalendarToolbarProps { selectedDate: Date; @@ -50,7 +51,14 @@ export function CalendarToolbar({ onMenuClick, }: CalendarToolbarProps) { const t = useTranslations("calendar"); - const formatter = useFormatter(); + const { + weekStartsOn, + formatMonthYear, + formatMonthYearShort, + formatWeekRange, + formatWeekRangeShort, + formatFullDate, + } = useCalendarLocale(); const views: CalendarViewMode[] = enableCalendarTasks ? ["month", "week", "day", "agenda", "tasks"] : ["month", "week", "day", "agenda"]; @@ -72,28 +80,22 @@ export function CalendarToolbar({ switch (viewMode) { case "month": return isMobile - ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) - : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + ? formatMonthYearShort(selectedDate) + : formatMonthYear(selectedDate); case "week": { - const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); - const we = addDays(ws, 6); - if (isMobile) { - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`; - } - const sameMonth = ws.getMonth() === we.getMonth(); - if (sameMonth) { - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`; - } - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`; + const ws = startOfWeek(selectedDate, { weekStartsOn }); + return isMobile + ? formatWeekRangeShort(ws) + : formatWeekRange(ws); } case "day": return isMobile - ? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) - : formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + ? formatFullDate(selectedDate) + : formatFullDate(selectedDate); case "agenda": return isMobile - ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) - : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + ? formatMonthYearShort(selectedDate) + : formatMonthYear(selectedDate); case "tasks": return t("views.tasks"); } @@ -124,7 +126,7 @@ export function CalendarToolbar({ variant="ghost" size="icon" onClick={onMenuClick} - className="h-8 w-8 -ml-1 mr-1" + className="h-8 w-8 -ms-1 me-1" aria-label={t("nav_open_menu")} > @@ -138,7 +140,7 @@ export function CalendarToolbar({ {onMenuClick && ( -
@@ -199,7 +201,7 @@ export function CalendarToolbar({ {showCalendarDropdown && ( -
+

{t("my_calendars")}

@@ -284,7 +286,7 @@ export function CalendarToolbar({ {/* ── DESKTOP TOOLBAR ── */} {!isMobile && (
- - + {getDateLabel()}
@@ -328,12 +330,12 @@ export function CalendarToolbar({ {(onImport || onSubscribe) && !isMobile && (
{showImportDropdown && ( -
+
{onImport && ( )} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 16e822aa..dbfe08ce 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -58,7 +58,7 @@ export function CalendarWeekView({ const intlFormatter = useFormatter(); const scrollRef = useRef(null); const rootRef = useRef(null); - const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; const weekDays = useMemo(() => { const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); @@ -211,7 +211,7 @@ export function CalendarWeekView({
{hasAllDay && (
{t("events.all_day")} @@ -306,7 +306,7 @@ export function CalendarWeekView({
-
+
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); @@ -318,7 +318,7 @@ export function CalendarWeekView({ role="columnheader" aria-label={fullLabel} className={cn( - "text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation", + "text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation", "hover:bg-muted/50", todayCol && "font-bold", )} @@ -345,7 +345,7 @@ export function CalendarWeekView({ {HOURS.map((h) => (
{h > 0 && ( @@ -357,7 +357,7 @@ export function CalendarWeekView({ ))}
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; @@ -367,7 +367,7 @@ export function CalendarWeekView({ return (
handleGridPointerDown(e, key, day)} @@ -448,7 +448,7 @@ export function CalendarWeekView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -482,7 +482,7 @@ export function CalendarWeekView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx index e5b675e7..656dc8d9 100644 --- a/components/calendar/create-calendar-modal.tsx +++ b/components/calendar/create-calendar-modal.tsx @@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp ); } @@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM aria-label={ariaLabel} {...dragProps} className={cn( - "w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden", + "w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden", "hover:opacity-90 transition-opacity cursor-pointer", continuesAfter && "rounded-r-sm", - continuesAfter && "pr-2", + continuesAfter && "pe-2", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", + isCancelled && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} @@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {...dragProps} data-calendar-event className={cn( - "w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden", + "w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden", "hover:opacity-90 transition-opacity cursor-pointer", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", + isCancelled && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }} > -
{event.title || t("events.no_title")}
+
{event.title || t("events.no_title")}
{!event.showWithoutTime && (
{timeString} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 79af4181..bfc613bd 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -290,20 +290,23 @@ export function EventDetailPopover({ className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} /> -

+

{event.title || t("events.no_title")}

{calendar && ( -

+

{calendar.name} {event.status === "tentative" && ( - + {t("detail.tentative")} )} {event.status === "cancelled" && ( - + {t("detail.cancelled")} )} @@ -343,13 +346,13 @@ export function EventDetailPopover({ <>

{formatEventDate(startDate)} - + {formatTime(startDate)}
{formatEventDate(endDate)} - + {formatTime(endDate)}
@@ -364,11 +367,11 @@ export function EventDetailPopover({ {formatEventDate(startDate)} {event.showWithoutTime ? ( - {t("events.all_day")} + {t("events.all_day")} ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes)})
)} @@ -434,7 +437,7 @@ export function EventDetailPopover({ {p.name || p.email} {p.isOrganizer && ( - + ({t("participants.organizer").toLowerCase()}) )} @@ -512,7 +515,7 @@ export function EventDetailPopover({ disabled={!noteText.trim() || isSavingNote} className="h-7 text-xs" > - + {t("detail.save_note")}
@@ -546,7 +549,7 @@ export function EventDetailPopover({ : "text-success border-success/30 hover:bg-success/10" } > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -606,7 +609,7 @@ export function EventDetailPopover({ ) : ( <>
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4c2b3f28..cd65425d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -522,13 +522,14 @@ export function EventModal({ { name: organizerName, email: organizerEmail }, effectiveAttendees ) as Record; - data.replyTo = { imip: `mailto:${organizerEmail}` }; // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP // scheduling is silently skipped (NoSchedulingInfo), so no invites are sent. + // The RFC 8984 replyTo property is retired in jscalendarbis and ignored. data.organizerCalendarAddress = `mailto:${organizerEmail}`; } else if (effectiveAttendees.length === 0 && event?.participants) { data.participants = null; + // Also clear the retired replyTo that older releases (<= 1.7.6) wrote. data.replyTo = null; data.organizerCalendarAddress = null; } @@ -665,11 +666,11 @@ export function EventModal({
{formatEventDate(startD)} - {format(startD, timeDisplayFmt)} + {format(startD, timeDisplayFmt)}
{formatEventDate(endD)} - {format(endD, timeDisplayFmt)} + {format(endD, timeDisplayFmt)}
); @@ -678,7 +679,7 @@ export function EventModal({
{formatEventDate(startD)} {!event.showWithoutTime && ( - + {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} )} @@ -700,7 +701,7 @@ export function EventModal({ {t("participants.title")}
-
+
{participants.map(p => (
{p.name || p.email} @@ -725,7 +726,7 @@ export function EventModal({ ? "bg-success hover:bg-success/80 text-success-foreground" : "text-success border-success/30 hover:bg-success/10"} > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -783,7 +784,7 @@ export function EventModal({

{event.title || t("events.no_title")}

{eventCalendar && ( -

{eventCalendar.name}

+

{eventCalendar.name}

)}
) : ( ) )} {onDuplicate && !showDeleteConfirm && ( )}
{!showDeleteConfirm && ( )} @@ -1319,7 +1320,7 @@ export function EventModal({ onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400" > - + {t("events.delete")} ) @@ -1331,7 +1332,7 @@ export function EventModal({ onClick={handleDuplicate} aria-label={t("events.duplicate")} > - + {t("events.duplicate")} )} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 36215829..b08f0913 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClick={handleImport} disabled={selectedIndices.size === 0} > - + {t("import_button")} ({selectedIndices.size}) )} diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 7583ad12..05001fa2 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi ); })} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 92dedab1..53f7dfe4 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -302,7 +302,7 @@ export function TaskModal({ className="text-destructive hover:text-destructive" onClick={() => onDelete(task.id)} > - + {t("tasks.delete")} )} diff --git a/components/calendar/task-toolbar.tsx b/components/calendar/task-toolbar.tsx index 5111f69a..cdda536e 100644 --- a/components/calendar/task-toolbar.tsx +++ b/components/calendar/task-toolbar.tsx @@ -44,7 +44,7 @@ export function TaskToolbar({ ))}
-
diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 530c086a..75f4375b 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -62,6 +62,27 @@ describe('ContactForm', () => { expect(phoneAfter.length).toBe(phoneBefore.length + 1); }); + it('sends media: null when an existing photo is removed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const contactWithPhoto: ContactCard = { + ...existingContact, + media: { + photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' }, + }, + }; + render(); + + fireEvent.click(screen.getByText('remove_photo')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + + const savedData = onSave.mock.calls[0][0]; + expect(savedData.media).toBeNull(); + }); + it('submits form data correctly', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx index 86b50b98..d5d23e16 100644 --- a/components/contacts/contact-activity.tsx +++ b/components/contacts/contact-activity.tsx @@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={email.id} type="button" onClick={() => handleOpenEmail(email)} - className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" >
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={event.id} type="button" onClick={() => handleOpenEvent(event)} - className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" > {formatEventTime(event)} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 24292071..c4160b7e 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -201,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClick={onCompose} className="touch-manipulation" > - + {t("detail.compose_email")} )} @@ -210,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli href={`tel:${phone}`} className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation" > - + {t("context_menu.call")} )} @@ -571,7 +571,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {open && (
{items.map((item, i) => { if (item.separator) { @@ -587,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) setOpen(false); }} className={cn( - "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors", + "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors", item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950", )} > diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index ebf6fe1c..a2e898eb 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
{emailErrors[i] && ( -

{emailErrors[i]}

+

{emailErrors[i]}

)}
))}
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
@@ -134,7 +134,7 @@ export function ContactGroupForm({ type="button" onClick={() => toggleMember(contact.id)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors", "hover:bg-muted", isSelected && "bg-primary/5" )} diff --git a/components/contacts/contact-group-list.tsx b/components/contacts/contact-group-list.tsx index 0c3127b1..e5193e9d 100644 --- a/components/contacts/contact-group-list.tsx +++ b/components/contacts/contact-group-list.tsx @@ -45,7 +45,7 @@ export function ContactGroupList({
@@ -69,7 +69,7 @@ export function ContactGroupList({ key={group.id} onClick={() => onSelectGroup(group.id)} className={cn( - "w-full flex items-center px-4 text-left transition-colors", + "w-full flex items-center px-4 text-start transition-colors", "hover:bg-muted", group.id === selectedGroupId && "bg-accent text-accent-foreground" )} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index d62e6dcf..8e8331a1 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -187,7 +187,7 @@ export function ContactImportDialog({ type="button" onClick={() => toggleSelect(idx)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted", isSelected && "bg-primary/5" )} > diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 04eac01b..0d9f23c7 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -310,7 +310,7 @@ export function ContactList({ placeholder={t("search_placeholder")} value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} - className={cn("pl-9 h-9", searchQuery && "pr-8")} + className={cn("ps-9 h-9", searchQuery && "pe-8")} /> {searchQuery && ( )} @@ -538,7 +538,7 @@ export function ContactList({

{t("empty_state_title")}

{t("empty_state_subtitle")}

diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 1fd36bb8..5831e1b2 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -284,17 +284,17 @@ export function ContactsSidebar({ {showMenu && (
{onCreateAddressBook && ( @@ -355,7 +355,7 @@ export function ContactsSidebar({
{expanded && ( -
+
{owned.length > 0 && (
@@ -418,7 +418,7 @@ export function ContactsSidebar({
@@ -505,7 +505,7 @@ export function ContactsSidebar({
@@ -561,7 +561,7 @@ export function ContactsSidebar({
@@ -847,7 +847,7 @@ function AddressBookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={cn( - "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", + "w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors", isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted", @@ -858,11 +858,11 @@ function AddressBookItem({ {book.name} {!book.isShared && Object.keys(book.shareWith || {}).length > 0 && ( - + )} 0) && "ml-auto" + !(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto" )}> {contactCount} diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index fb08be4a..6e2c017a 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => { mocks.clientMock, 'event-8', expect.objectContaining({ - replyTo: { imip: 'mailto:organizer@example.com' }, + // The stored event lacks an ORGANIZER, so the RSVP repair writes + // organizerCalendarAddress (replyTo is retired in jscalendarbis). + organizerCalendarAddress: 'mailto:organizer@example.com', participants: expect.objectContaining({ attendee: expect.objectContaining({ participationStatus: 'accepted', diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 2a6101fd..72d41e35 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => { expect(container.querySelector('p')).toBeNull(); }); }); + +describe('EmailListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // selection mode active (so the checkbox renders), anchor on e1 + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + render(); + // the checkbox is the first button in the row (shown in selection mode) + const checkbox = screen.getAllByRole('button')[0]; + act(() => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + }); + + const sel = useEmailStore.getState().selectedEmailIds; + expect(sel.has('e1')).toBe(true); + expect(sel.has('e2')).toBe(true); // the in-between row got filled in + expect(sel.has('e3')).toBe(true); + }); +}); diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 461b4452..f3138b38 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -148,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: [] }), })); @@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => { fireEvent.dragStart(chipSpan, { dataTransfer: dt }); const payload = JSON.parse(dt.getData('application/x-recipient-chip')); - expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' }); + expect(payload).toEqual({ 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 +244,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).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 }); }); it('onDragEnd clears the opacity class on the chip', async () => { @@ -340,4 +343,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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); }); diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index 1a3ccef6..a8d4d15c 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -147,7 +147,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: [] }), })); diff --git a/components/email/__tests__/selectable-avatar.test.tsx b/components/email/__tests__/selectable-avatar.test.tsx new file mode 100644 index 00000000..51ac9269 --- /dev/null +++ b/components/email/__tests__/selectable-avatar.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SelectableAvatar } from '../selectable-avatar'; + +// Isolate from the real Avatar (image fetching, libravatar hashing) — we only +// care about the selection wrapper behaviour here. +vi.mock('@/components/ui/avatar', () => ({ + Avatar: (props: { name?: string }) => {props.name}, +})); + +describe('SelectableAvatar', () => { + it('renders the wrapped avatar', () => { + render( {}} selectLabel="Select" />); + expect(screen.getByTestId('avatar')).toHaveTextContent('Marta'); + }); + + it('fires onToggle and stops propagation when the avatar is clicked', () => { + const onToggle = vi.fn(); + const onRowClick = vi.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByRole('checkbox')); + expect(onToggle).toHaveBeenCalledTimes(1); + // Clicking the avatar must not bubble up to open/select the row. + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('reflects the checked state via aria-checked', () => { + const { rerender } = render( + {}} selectLabel="Select" />, + ); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false'); + rerender( {}} selectLabel="Select" />); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index bd21fc54..9b8b0fe4 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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(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(null); const [selectedCalendarId, setSelectedCalendarId] = useState(''); const [rawIcsMethod, setRawIcsMethod] = useState('unknown'); @@ -582,7 +583,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } else { await updateEvent(client, eventForRsvp.id, { participants: repairedParticipants, - replyTo: replyToForRsvp ?? undefined, + // Stalwart routes the iTIP REPLY via the stored ORGANIZER + // (organizerCalendarAddress; the RFC 8984 replyTo is retired). + // Only repair a missing organizer - attendees may not modify it. + ...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress + ? { organizerCalendarAddress: replyToForRsvp.imip } + : {}), }, true); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); @@ -1021,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); }} @@ -1036,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
{t('select_calendar')} @@ -1048,7 +1058,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setShowCalendarPicker(false); handleImport(cal.id); }} - className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2" + className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2" > + )}
)} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 23b24ae8..df454863 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,19 +5,20 @@ 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 } 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"; -import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; +import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; @@ -26,16 +27,16 @@ 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"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; -import { resolveReplyFrom } from "@/lib/reply-identity"; +import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, @@ -44,12 +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 @@ -88,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; @@ -133,12 +146,28 @@ interface EmailComposerProps { }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; + /** + * When provided, the composer assigns its close handler to `current`. The + * handler shows the unsaved-changes dialog when the draft is dirty, so a + * host (e.g. the Pro tab bar's close button) can route an external close + * request through the same guard instead of discarding silently. + */ + requestCloseRef?: React.MutableRefObject<(() => void) | null>; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; + /** + * Email of the mailbox/account the user is viewing when they start a new + * message. When set (and `autoSelectReplyIdentity` is on), a fresh compose + * preselects the identity matching this address instead of the primary + * identity, so "New message" from info@ defaults its From to info@. Mirrors + * the reply-time identity match; ignored for reply/replyAll/forward (those + * resolve from the original recipients). + */ + composeFromAccountEmail?: string; replyTo?: { from?: { email?: string; name?: string }[]; replyToAddresses?: { email?: string; name?: string }[]; @@ -229,12 +258,14 @@ export function EmailComposer({ onSend, onScheduledSendCreated, onClose, + requestCloseRef, onDiscardDraft, onSaveState, className, initialDraftText, initialData, mode = 'compose', + composeFromAccountEmail, replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); @@ -263,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 @@ -667,6 +701,19 @@ export function EmailComposer({ useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; + + // New message started from a specific mailbox/account: default the From to + // that mailbox's identity instead of the primary one, so composing while + // viewing info@ sends as info@. Reply/forward fall through to the + // recipient-based resolution below. + if (mode === 'compose') { + const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail); + if (composeIdentityId) { + setSelectedIdentityId(composeIdentityId); + } + return; + } + if (mode !== 'reply' && mode !== 'replyAll') return; const resolved = resolveReplyFrom(identities, { @@ -700,6 +747,7 @@ export function EmailComposer({ } }, [ autoSelectReplyIdentity, + composeFromAccountEmail, fromOverrideEnabled, identities, initialData?.selectedIdentityId, @@ -781,12 +829,37 @@ 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 - ? `
${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
` + ? `
${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
` : signatureIdentity?.textSignature ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; 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). + const canSearchServer = useContactStore((s) => s.sentMailboxId != null); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); @@ -859,9 +932,13 @@ export function EmailComposer({ } }, [mode]); - const [autocompleteResults, setAutocompleteResults] = useState>([]); + const [autocompleteResults, setAutocompleteResults] = useState>([]); 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 + // the on-demand Sent-folder lookup ("search the server" row). + const [autoQuery, setAutoQuery] = useState(''); + const [isSearchingServer, setIsSearchingServer] = useState(false); const autocompleteTimeoutRef = useRef(null); const toInputRef = useRef(null); const ccInputRef = useRef(null); @@ -886,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]); @@ -909,25 +997,75 @@ export function EmailComposer({ setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); + setAutoQuery(''); return; } + setAutoQuery(query); 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 }))); - setActiveAutoField(merged.length > 0 ? field : null); + 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); setAutoSelectedIndex(-1); }, 200); - }, [getAutocomplete]); + }, [getAutocomplete, canSearchServer]); - const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => { + // On-demand: search the Sent folder server-side for recipients matching the + // current query and merge fresh hits into the open dropdown (deduped by email). + const handleServerSearch = useCallback(async () => { + const query = autoQuery.trim(); + if (!composerClient || !query || isSearchingServer) return; + setIsSearchingServer(true); + try { + const serverResults = await searchRecipients(composerClient, query); + setAutocompleteResults((prev) => { + const seen = new Set(prev.map((r) => r.email.toLowerCase())); + const merged = [...prev]; + for (const r of serverResults) { + const key = r.email.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + merged.push(r); + } + } + return merged; + }); + } catch { + // Best-effort: a failed lookup just leaves the local suggestions in place. + } finally { + setIsSearchingServer(false); + } + }, [autoQuery, composerClient, isSearchingServer, searchRecipients]); + + 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(); + 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); @@ -977,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 : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; 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)); } @@ -1004,14 +1151,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); @@ -1019,7 +1166,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; @@ -1055,7 +1202,16 @@ export function EmailComposer({ const controller = newAttachments[i].abortController; try { if (controller?.signal.aborted) continue; - const { blobId } = await client.uploadBlob(file); + + const fileId = generateUUID(); + await fileStorage.saveFile(fileId, file); + + const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId); + + const newFile = await fileStorage.getFile(newFileId) || file; + await fileStorage.deleteFile(newFileId); + + const { blobId } = await client.uploadBlob(newFile); if (controller?.signal.aborted) continue; setAttachments(prev => @@ -1219,9 +1375,9 @@ export function EmailComposer({ const saveDraftOnce = async (): Promise => { 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; @@ -1263,21 +1419,36 @@ export function EmailComposer({ try { const previousDraftId = draftIdRef.current; + let savedDraft : AlmostSavedDraft = { + to: toAddresses, + subject: subject || t('no_subject'), + body: plainTextMode ? body : htmlToPlainText(body), + cc: ccAddresses, + bcc: bccAddresses, + identityId: currentIdentityRawId, + fromEmail, + draftId: previousDraftId || undefined, + attachments: uploadedAttachments, + fromName, + htmlBody: plainTextMode ? undefined : body + } + savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft); + // Use the JMAP client and raw identity id for the *owning* account // - falls back to active client for single-account / same-account // identities. See `composerClient` derivation above. const savedDraftId = await composerClient.createDraft( - toAddresses, - subject || t('no_subject'), - plainTextMode ? body : htmlToPlainText(body), - ccAddresses, - bccAddresses, - currentIdentityRawId, - fromEmail, - previousDraftId || undefined, - uploadedAttachments, - fromName, - plainTextMode ? undefined : body + savedDraft.to, + savedDraft.subject, + savedDraft.body, + savedDraft.cc, + savedDraft.bcc, + savedDraft.identityId, + savedDraft.fromEmail, + savedDraft.draftId, + savedDraft.attachments, + savedDraft.fromName, + savedDraft.htmlBody ); // Update the ref synchronously so a queued save sees the new id and @@ -1375,12 +1546,15 @@ 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; const getSendTooltip = (): string | undefined => { + if (isWaitingForUploads) return t('validation.attachments_uploading'); if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); if (!subject) return t('validation.subject_required'); @@ -1466,10 +1640,45 @@ export function EmailComposer({ const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); + // Attachments still uploading when Send is clicked used to be silently + // dropped from the outgoing message (the filters below exclude anything + // with uploading:true). attachmentsRef gives handleSend a way to read the + // freshest attachment state after waiting on in-flight uploads, since the + // `attachments` closure captured at click time won't reflect uploads that + // finish during that wait. + const attachmentsRef = useRef(attachments); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments]); + const [isWaitingForUploads, setIsWaitingForUploads] = useState(false); + const sendCancelledRef = useRef(false); + const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { if (isSendingRef.current) return; - const ccAddresses = withInput(cc, ccInput); - const bccAddresses = withInput(bcc, bccInput); + + if (attachmentsRef.current.some(att => att.uploading)) { + isSendingRef.current = true; + setIsSending(true); + setIsWaitingForUploads(true); + const uploadResult = await waitForPendingUploads( + () => attachmentsRef.current, + () => sendCancelledRef.current + ); + setIsWaitingForUploads(false); + isSendingRef.current = false; + setIsSending(false); + if (uploadResult === 'cancelled') return; + if (uploadResult === 'failed') { + // An upload broke while we were waiting - the user may not be + // looking at the composer, so auto-sending would silently drop + // the failed attachment. Abort and let them decide. + toast.error(t('validation.attachment_upload_failed')); + return; + } + } + + const ccAddresses = expandRecipients(withInput(cc, ccInput)); + const bccAddresses = expandRecipients(withInput(bcc, bccInput)); if (!canSend) { const errors: { to?: boolean; subject?: boolean; body?: boolean } = {}; @@ -1488,9 +1697,15 @@ export function EmailComposer({ // Attachment reminder check if (!skipAttachmentCheck && attachmentReminderEnabled) { - const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error); + const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); if (!hasAttachments) { - const bodyText = htmlToPlainText(body); + // Scan only the user-authored text: the quoted original of a + // reply/forward often mentions an attachment itself, which used to fire + // the reminder even when the user typed no keyword and added nothing (#570). + const bodyText = extractUserAuthoredText(body, { + plainTextMode, + forwardedSeparator: tQuote('forwarded_separator'), + }); const searchText = `${subject} ${bodyText}`.toLowerCase(); const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); if (matched) { @@ -1604,7 +1819,7 @@ export function EmailComposer({ textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, - attachments: attachments + attachments: attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], @@ -1631,7 +1846,7 @@ export function EmailComposer({ references: threadingHeaders?.references, delayedUntil: effectiveDelayedUntil, attachments: [ - ...attachments + ...attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })), ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), @@ -1648,7 +1863,7 @@ export function EmailComposer({ } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request - const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments + const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size })); uploadedAttachments.push(...inlineAttachments); @@ -1775,6 +1990,7 @@ export function EmailComposer({ }, []); const cleanClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1784,6 +2000,7 @@ export function EmailComposer({ }; const handleSaveDraftAndClose = async () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { @@ -1795,6 +2012,7 @@ export function EmailComposer({ }; const handleDiscardAndClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { @@ -1815,6 +2033,17 @@ export function EmailComposer({ } }; + // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab + // bar) can trigger the same "Save or discard draft?" guard. Re-assigned on + // every render to capture the latest closure over the live refs/state. + useEffect(() => { + if (!requestCloseRef) return; + requestCloseRef.current = handleClose; + return () => { + requestCloseRef.current = null; + }; + }); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; @@ -1857,10 +2086,10 @@ export function EmailComposer({ }; return ( -
+
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
-
+

{t('new_message')}

{saveStatus === 'saving' && (
@@ -1915,9 +2144,10 @@ export function EmailComposer({ disabled={!canSend || isSending} title={getSendTooltip()} size="sm" + data-testid="composer-send" className="md:hidden h-9 px-4" > - + {t('send')}
@@ -1949,6 +2179,7 @@ export function EmailComposer({
) : identities.length > 1 ? ( ) : ( - + {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)} ) : ( - <> + {primaryIdentity?.name ? `${primaryIdentity.name} <${primaryIdentity.email}>` : primaryIdentity?.email || ''} - + )} )} @@ -2043,7 +2274,7 @@ export function EmailComposer({
{/* To field */} -
+
{t('to')}:
@@ -2166,6 +2405,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={bccDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} onMoveChip={handleMoveChip} />
@@ -2176,6 +2419,7 @@ export function EmailComposer({ {t('subject_label')} removeAttachment(index)} - className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center" + className="ms-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center" title={att.uploading ? t('upload_cancel') : undefined} > @@ -2345,24 +2589,26 @@ export function EmailComposer({ > - - + {templatesEnabled && <> + + + } {/* Sign/encrypt controls are contributed by crypto plugins via the composer-toolbar slot (rendered below). */} @@ -2398,9 +2644,10 @@ export function EmailComposer({ onClick={() => handleSend()} disabled={!canSend || isSending} title={getSendTooltip()} - className="rounded-r-none border-r border-primary-foreground/20" + data-testid="composer-send" + className="rounded-e-none border-e border-primary-foreground/20" > - + {t('send')} )} @@ -2557,8 +2805,8 @@ export function EmailComposer({ {t('discard')}
@@ -2576,7 +2824,7 @@ export function EmailComposer({
); @@ -2584,10 +2832,14 @@ export function EmailComposer({ const AutocompleteDropdown = React.forwardRef; + results: Array; selectedIndex: number; - onSelect: (suggestion: { name: string; email: string }) => void; -}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) { + 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 (
{results.map((r, i) => ( @@ -2598,7 +2850,7 @@ const AutocompleteDropdown = React.forwardRef { @@ -2606,13 +2858,44 @@ const AutocompleteDropdown = React.forwardRef - + {r.group ? ( + + + + ) : ( + + )} {r.name || r.email} - {r.name && ( + {r.group ? ( + + {tContacts('groups.member_count', { count: r.group.memberCount })} + + ) : r.name && ( <{r.email}> )} ))} + {onSearchServer && ( + + )}
); }); @@ -2633,6 +2916,10 @@ function RecipientChipInput({ autoSelectedIndex, dropdownRef, onInsertAutocomplete, + canSearchServer, + onServerSearch, + isSearchingServer, + serverSearchQuery, validationError, validationMessage, onTab, @@ -2649,14 +2936,18 @@ 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; autoSelectedIndex: number; dropdownRef: React.RefObject; - 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; + serverSearchQuery: string; 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'); @@ -2665,6 +2956,9 @@ function RecipientChipInput({ const [editValue, setEditValue] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [draggingIndex, setDraggingIndex] = useState(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(null); const editInputRef = useRef(null); // Focus edit input when editing starts @@ -2681,7 +2975,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) => { @@ -2701,10 +2997,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]; @@ -2749,7 +3045,15 @@ function RecipientChipInput({ } } - if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) { + // Enter / Tab commit whatever is typed. Space only commits when the input + // is already a complete email address; otherwise Space is a normal + // character so a name search like "John Doe" can continue past the space + // instead of committing "John" as a bogus recipient (#571). + const trimmedInput = inputText.trim(); + const commitOnKey = + ((e.key === 'Enter' || e.key === 'Tab') && trimmedInput) || + (e.key === ' ' && isValidEmail(trimmedInput)); + if (commitOnKey) { if (e.key !== 'Tab') e.preventDefault(); commitCurrentInput(); if (e.key === 'Tab' && onTab) { @@ -2811,27 +3115,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 ( @@ -2850,30 +3213,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 ( + + {dropIndex === i && ( + + )} { 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 ? ( + + ) : null} + {isEditing ? ( ) : ( - {chipDisplay} + m.email).join(', ') : undefined} + > + {chip.group && } + {chipDisplay} + )}
)} {node.children.length > 0 && ( -
+
{renderNodes(node.children)}
)} @@ -349,6 +359,15 @@ export function EmailContextMenu({ /> )} + {/* Pin/Unpin - only for single email; pinned mails float to the top of the list */} + {!showBatchActions && onTogglePinned && ( + handleAction(onTogglePinned)} + /> + )} + {/* Set tag submenu - only for single email */} {!showBatchActions && ( @@ -360,7 +379,7 @@ export function EmailContextMenu({ role="menuitem" onClick={() => handleAction(() => onSetColorTag?.(option.value))} className={cn( - "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", + "w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer", isActive && "bg-accent font-medium" )} > @@ -385,22 +404,27 @@ export function EmailContextMenu({ )} - + {/* Spam - contextual based on folder; pointless on own outgoing mail */} + {spamApplicable && ( + <> + - {/* Spam - contextual based on folder */} - - handleAction( - showBatchActions - ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) - : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) - ) - } - disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} - destructive={!isInJunkFolder} - /> + + handleAction( + showBatchActions + ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) + : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) + ) + } + disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} + destructive={!isInJunkFolder} + /> + + )} @@ -408,6 +432,7 @@ export function EmailContextMenu({ handleAction(() => showBatchActions diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 4e465fe7..a2691da7 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -21,6 +21,8 @@ interface EmailHoverActionsProps { // the spam quick-action flips to "not spam". isInJunk?: boolean; onUndoSpam?: () => void; + // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). + spamApplicable?: boolean; } const ACTION_CONFIG: Record state.hoverActions); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); @@ -121,6 +124,7 @@ export function EmailHoverActions({ const actionButtons = hoverActions.map((actionId) => { const config = ACTION_CONFIG[actionId]; if (!config) return null; + if (actionId === "spam" && !spamApplicable) return null; const Icon = config.icon; // In a junk context the spam action becomes "not spam". @@ -177,16 +181,17 @@ export function EmailHoverActions({ return (
-
+
{actionButtons}
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index b6549346..44c7e915 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -5,8 +5,8 @@ import { useCallback } from "react"; import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; +import { SelectableAvatar } from "@/components/email/selectable-avatar"; +import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -34,16 +34,19 @@ interface EmailListItemProps { export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isImportant = email.keywords?.["$important"]; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; @@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); // Use first tag for background coloring const keywordDef = keywordDefs[0] ?? null; - const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; // Drag and drop functionality const { dragHandlers, isDragging } = useEmailDrag({ @@ -87,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + // Shift-click extends the selection from the anchor to here, like + // shift-clicking the row (the checkbox stops propagation, so the + // row's shift handler never runs — replicate it here). + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {/* Unread indicator */} {isUnread && ( -
+
)} {/* Avatar */} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} @@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
+ {isPinned && } {isStarred && } {isImportant && } {isAnswered && !isForwarded && } @@ -242,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} />
); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9c3ec0c7..8f1db908 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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"; @@ -35,6 +35,7 @@ interface EmailListProps { onForward?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; + onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; @@ -64,6 +65,7 @@ export function EmailList({ onForward, onMarkAsRead, onToggleStar, + onTogglePinned, onDelete, onArchive, onSetColorTag, @@ -189,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; @@ -355,6 +373,22 @@ export function EmailList({ )} + {effectiveMailboxRole === 'junk' && ( + + )} @@ -551,6 +585,7 @@ export function EmailList({ onForward={() => onForward?.(contextMenu.data!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onToggleStar={() => onToggleStar?.(contextMenu.data!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 03386e94..e45dab33 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,7 +5,7 @@ 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 { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; @@ -333,7 +333,7 @@ function renderClickableRecipients( return ( - {index > 0 && ,} + {index > 0 && ,} +
{/* Header */}

{t('contact_sidebar.title')}

@@ -560,6 +560,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 +572,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(() => ({ 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 +597,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)}; } @@ -607,7 +609,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component

{title}

-
{children}
+
{children}
); } @@ -652,6 +654,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); @@ -693,6 +696,9 @@ export function EmailViewer({ // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; + // Marking your own outgoing mail as spam makes no sense - hide the action + // in Sent, Drafts and Scheduled. + const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; @@ -711,6 +717,40 @@ 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 + // most users. Route to the email's own account in unified views and prefer + // the identity that received the newsletter, so the list can match the + // subscriber; sendEmail resolves the identity (with its own fallback to + // the account default) from the address we pass. + const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => { + const sendClient = (email?.sourceClientAccountId + ? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId) + : undefined) ?? client; + if (!sendClient) throw new Error('Not connected'); + + const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase()); + // In unified views the owning account's identities are not loaded here - + // pass nothing and let its client fall back to its default identity. + const fromIdentity = email?.sourceClientAccountId + ? undefined + : identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase())); + + await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name); + }; const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('reschedule_prompt')); if (!value) return null; @@ -752,6 +792,19 @@ export function EmailViewer({ const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + const handleSendQuickReply = async () => { + if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; + setIsSendingQuickReply(true); + try { + await onQuickReply(quickReplyText); + setQuickReplyText(""); + setIsQuickReplyFocused(false); + } catch (error) { + console.error("Failed to send quick reply:", error); + } finally { + setIsSendingQuickReply(false); + } + }; const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null); @@ -771,6 +824,15 @@ export function EmailViewer({ const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); const [pluginRenderedText, setPluginRenderedText] = useState(null); const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]); + // Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin + // after the user unlocks a key from the banner) to force the onRenderEmailBody + // hook to run again for the open message so the body re-decrypts. + const [pluginRenderNonce, setPluginRenderNonce] = useState(0); + useEffect(() => { + const bump = () => setPluginRenderNonce((n) => n + 1); + window.addEventListener('plugin:rerender-email', bump); + return () => window.removeEventListener('plugin:rerender-email', bump); + }, []); // TNEF (winmail.dat) support const [tnefHtml, setTnefHtml] = useState(null); @@ -1128,6 +1190,7 @@ export function EmailViewer({ id: email.id, contentType, bodyStructure: email.bodyStructure, + bodyValues: email.bodyValues, attachments: email.attachments, blobId: email.blobId, from: email.from, @@ -1167,7 +1230,7 @@ export function EmailViewer({ })(); return () => { cancelled = true; }; - }, [email]); + }, [email, pluginRenderNonce]); // TNEF (winmail.dat) detection and processing useEffect(() => { @@ -1203,7 +1266,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'); @@ -1256,7 +1319,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 @@ -1293,7 +1356,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'); @@ -1333,7 +1396,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(() => { @@ -1379,7 +1442,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); @@ -1401,7 +1464,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(() => { if (pluginRenderedAttachments.length > 0) { @@ -1600,10 +1663,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 }); @@ -1879,8 +1940,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) { @@ -1912,7 +1973,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 ? ( @@ -1954,8 +2015,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; @@ -1986,7 +2047,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(null); @@ -2056,9 +2117,21 @@ export function EmailViewer({ // Word/Outlook HTML emails ship a ${effectiveEmailContent.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 @@ -2137,8 +2216,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(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(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(() => { @@ -2146,25 +2237,64 @@ 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; + { // Auto-resize iframe to fit content - const resizeObserver = new ResizeObserver(() => { - const height = doc.documentElement.scrollHeight; + // Measure max(documentElement, body): a height:100% wrapper can leave + // documentElement.scrollHeight short while the real content lives in body. + const applyHeight = () => { + if (iframe.contentDocument !== doc) return; // navigated away; stale + const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight); iframe.style.height = height + 'px'; lastBodyHeightRef.current = height; - }); + }; + const resizeObserver = new ResizeObserver(applyHeight); resizeObserver.observe(doc.body); - const initialHeight = doc.documentElement.scrollHeight; - iframe.style.height = initialHeight + 'px'; - lastBodyHeightRef.current = initialHeight; + applyHeight(); + // The ResizeObserver only fires on body's border box; a content overflow + // that grows scrollHeight without resizing that box (e.g. a height:100% + // wrapper, or images that reflow the layout after onload) is otherwise + // missed and the iframe stays short. Re-measure on a fixed cadence over a + // short settle window, then stop — a self-clearing catch-all that does + // not depend on image load/error events firing (blocked images may fire + // neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to + // mis-trigger on a brief-stable-then-grow reflow. + const poll = window.setInterval(() => { + if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; } + applyHeight(); + }, 200); + window.setTimeout(() => window.clearInterval(poll), 2400); setIframeReady(true); - // Make links open in new tab - doc.querySelectorAll('a').forEach(a => { - a.setAttribute('target', '_blank'); - a.setAttribute('rel', 'noopener noreferrer'); + // Hide images that fail to load (dead/mixed-content/unreachable external + // URLs) rather than leaving the browser's broken-image placeholder and + // alt text, which read as stray label text in an otherwise image-only + // email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1 + // transparent pixel (naturalWidth 1) and display:none, so they're skipped. + const hideIfBroken = (img: HTMLImageElement) => { + if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) { + img.style.display = 'none'; + } + }; + doc.querySelectorAll('img').forEach((el) => { + const img = el as HTMLImageElement; + if (img.complete) { + hideIfBroken(img); + } else { + img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true }); + img.addEventListener('load', () => hideIfBroken(img), { once: true }); + } }); + // 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 // it survives DOM mutations from dark-mode pass below. @@ -2287,6 +2417,26 @@ export function EmailViewer({ } }, [isDark, emailHasNativeDarkMode, email?.id]); + // 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 () => { if (!email?.blobId || !client) return; @@ -2580,7 +2730,7 @@ export function EmailViewer({

{tDemoWelcome('title')}

{tDemoWelcome('description')}

-
+
{tDemoWelcome('feature_email')} @@ -2622,7 +2772,7 @@ export function EmailViewer({

{t('no_conversation_description')}

{onCompose && ( )} @@ -2647,7 +2797,7 @@ export function EmailViewer({ variant="ghost" size="icon" onClick={onBack} - className="h-9 w-9 flex-shrink-0 -ml-1" + className="h-9 w-9 flex-shrink-0 -ms-1" aria-label={t('back_to_list')} > @@ -2693,6 +2843,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')} > @@ -2782,7 +2933,7 @@ export function EmailViewer({ {showToolbarLabels && {t('move')}} {moveMenuOpen && ( -
+
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -2793,7 +2944,7 @@ export function EmailViewer({ {isTarget ? ( {tagMenuOpen && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -2861,13 +3012,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -2876,7 +3027,7 @@ export function EmailViewer({
{/* Spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( {moreMenuOpen && !isMobile && ( -
+
{/* Star toggle */} {moreMenuSub === 'move' && ( -
+
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -3047,7 +3198,7 @@ export function EmailViewer({ {isTarget ? ( {moreMenuSub === 'tag' && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -3096,13 +3247,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -3111,7 +3262,7 @@ export function EmailViewer({
)} {/* Overflow: spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( ); })} {currentColors.length > 0 && (
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -3512,7 +3663,7 @@ export function EmailViewer({ name={sender?.name} email={sender.email} onViewContact={handleViewContactSidebar} - className="font-semibold text-left" + className="font-semibold text-start" /> ) : ( {t('unknown_sender')} @@ -3522,6 +3673,7 @@ export function EmailViewer({ { const messageId = email?.messageId || ''; const newSet = new Set(dismissedUnsubBanners).add(messageId); @@ -3576,7 +3728,7 @@ export function EmailViewer({ )}
{/* Date/time + size on the right (mobile) */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -3949,7 +4104,7 @@ export function EmailViewer({ email={sender?.email || ''} displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined} onViewContact={handleViewContactSidebar} - className="text-sm text-left" + className="text-sm text-start" />
@@ -4376,7 +4531,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( - + {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + data-testid="attachment" + data-attachment-name={attachment.name} draggable={dragProps.draggable} onPointerEnter={dragProps.onPointerEnter} onDragStart={dragProps.onDragStart} @@ -4419,7 +4576,7 @@ export function EmailViewer({
- +
+
+ +
+
+
+
+ Read receipt +
+
+ {t('prompt')} +
+
+ Requested by {requestedBy} +
+
+
+ + +
); diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index 007e8b12..32b39b39 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla className )} > - {displayLabel || name || email} + {displayLabel || name || email} {isOpen && @@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla {onViewContact && (
+
+ +
+ )} +
@@ -454,6 +524,22 @@ export function RichTextEditor({ + {rtlEditingSupport && ( + { + const cur = + editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir; + editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run(); + }} + title="Text direction (RTL/LTR)" + > + + + )} + {tableMenuOpen && ( -
+
{editor.isActive("table") ? (
+ ); +} diff --git a/components/email/signature-block.ts b/components/email/signature-block.ts index 231b9f6a..9b0ee3ef 100644 --- a/components/email/signature-block.ts +++ b/components/email/signature-block.ts @@ -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; }, diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts new file mode 100644 index 00000000..6665c373 --- /dev/null +++ b/components/email/text-direction.ts @@ -0,0 +1,63 @@ +import { Extension } from "@tiptap/core"; + +export type TextDir = "ltr" | "rtl"; + +declare module "@tiptap/core" { + interface Commands { + textDirection: { + setTextDirection: (dir: TextDir) => ReturnType; + unsetTextDirection: () => ReturnType; + }; + } +} + +/** + * 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 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({ + name: "textDirection", + + addOptions() { + return { types: ["paragraph", "heading", "blockquote", "listItem"] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + dir: { + default: "auto", + parseHTML: (element) => element.getAttribute("dir") || "auto", + renderHTML: (attributes) => + attributes.dir ? { dir: attributes.dir } : { dir: "auto" }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setTextDirection: + (dir) => + ({ commands }) => + this.options.types.every((type: string) => + commands.updateAttributes(type, { dir }), + ), + unsetTextDirection: + () => + ({ commands }) => + this.options.types.every((type: string) => + commands.resetAttributes(type, "dir"), + ), + }; + }, +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 526599c2..37552e93 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -150,7 +150,7 @@ export function ThreadConversationView({
@@ -488,13 +488,13 @@ function EmailCard({
{/* Card Header - Always visible */} )} @@ -671,7 +671,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.reply_all")} )} @@ -685,7 +685,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.forward")} )} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 11cfd637..bd35f4c5 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -86,8 +86,8 @@ export function ThreadEmailItem({ {...longPressHandlers} className={cn( "relative cursor-pointer select-none transition-all duration-150", - "pl-12 pr-4", - "border-l-2 border-l-transparent", + "ps-12 pe-4", + "border-s-2 border-l-transparent", selected ? "bg-selection border-l-primary" : "hover:bg-muted/50", @@ -130,7 +130,7 @@ export function ThreadEmailItem({ {/* Unread indicator */} {isUnread && ( -
+
)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index ebd8ceb0..51503972 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -2,10 +2,10 @@ 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 { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; +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"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -75,8 +75,10 @@ interface SingleEmailItemProps { const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); @@ -88,13 +90,14 @@ const SingleEmailItem = React.forwardRef( const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); 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); @@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef( const tagIds = getEmailColorTags(email.keywords); const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = (() => { + const resolvedColorTag = !tintListRowsByTag ? null : (() => { if (colorTag) return colorTag; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; })(); @@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef( const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -159,6 +166,10 @@ const SingleEmailItem = React.forwardRef( 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 : ( @@ -211,18 +222,21 @@ const SingleEmailItem = React.forwardRef( )} {isUnread && ( -
+
)} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} @@ -256,6 +270,7 @@ const SingleEmailItem = React.forwardRef(
+ {isPinned && } {isStarred && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } @@ -308,6 +323,9 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -397,6 +415,7 @@ const SingleEmailItem = React.forwardRef( onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} /> )}
@@ -427,13 +446,14 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const isMobile = useUIStore((state) => state.isMobile); - const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; + const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? ''); @@ -443,7 +463,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById); const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; // In Sent/Drafts folders, show recipient instead of sender (which is always @@ -479,8 +499,9 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -511,9 +532,8 @@ export const ThreadListItem = React.forwardRef { - e.stopPropagation(); - // Toggle selection for all emails in this thread + // Toggle selection for all emails in this thread. + const toggleThreadSelection = () => { const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds); thread.emails.forEach(em => { @@ -526,6 +546,15 @@ export const ThreadListItem = React.forwardRef { + e.stopPropagation(); + if (e.shiftKey) { + selectRangeEmails(latestEmail.id); + return; + } + toggleThreadSelection(); + }; + const handleHeaderClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); @@ -618,21 +647,24 @@ export const ThreadListItem = React.forwardRef +
)} {density !== 'extra-compact' && (
- - {!isMobile && !isFocusedMailLayout && ( + {!isMobile && (
+ {hasPinned && } {hasStarred && } {hasAnswered && !hasForwarded && } {hasForwarded && !hasAnswered && } @@ -766,6 +799,9 @@ export const ThreadListItem = React.forwardRef
+ {hasPinned && ( + + )} {hasStarred && ( )} @@ -855,15 +891,16 @@ export const ThreadListItem = React.forwardRef onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} /> )}
- {isExpanded && !isMobile && !isFocusedMailLayout && ( + {isExpanded && !isMobile && (
{isLoading ? (
- + {t('loading')}
) : ( diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index 1f63a0e4..491f3c44 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from 'react'; import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { isValidUnsubscribeUrl } from '@/lib/validation'; +import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -14,12 +14,17 @@ interface UnsubscribeBannerProps { preferred?: 'http' | 'mailto'; }; senderEmail: string; + // Sends the unsubscribe message through the app's own account. This is a + // webmail client - handing a mailto: URL to the OS mail handler goes + // nowhere for most users. + onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise; onDismiss: () => void; } export function UnsubscribeBanner({ listUnsubscribe, senderEmail: _senderEmail, + onSendMailtoUnsubscribe, onDismiss }: UnsubscribeBannerProps) { const t = useTranslations(); @@ -74,12 +79,18 @@ export function UnsubscribeBanner({ setShowConfirm(false); setTimeout(onDismiss, 3000); } else { - const link = document.createElement('a'); - link.href = unsubUrl; - link.style.display = 'none'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + // Send the unsubscribe message ourselves and only report success + // once the server accepted it. The previous hidden-link click handed + // the mailto: to the OS mail handler and claimed success even though + // nothing was ever sent. + const fields = parseMailtoUrl(unsubUrl); + if (!fields) { + setError(true); + setProcessing(false); + setShowConfirm(false); + return; + } + await onSendMailtoUnsubscribe(fields); setSuccess(true); setProcessing(false); @@ -96,7 +107,7 @@ export function UnsubscribeBanner({ if (success) { return ( - + {t(unsubMethod === 'http' @@ -110,7 +121,7 @@ export function UnsubscribeBanner({ if (error) { return ( - +
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp */ export function SidebarErrorFallback({ resetError, t }: FallbackProps) { return ( -
+

{t("sidebar_error")}

@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) { {t("email_list_error")}

@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) { {t("viewer_error_description")}

@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) { {t("settings_error_description")}

diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx new file mode 100644 index 00000000..0e3a4295 --- /dev/null +++ b/components/favicon-badge.tsx @@ -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; +} diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index aff7eb8e..3ed5e75d 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -68,10 +68,10 @@ export function EmlPreview({ message }: { message: ParsedEml }) {

{message.subject || ""}

{message.from && ( -
{t("from")}: {formatAddress(message.from)}
+
{t("from")}: {formatAddress(message.from)}
)} {message.to && message.to.length > 0 && ( -
{t("to")}: {message.to.map(formatAddress).join(", ")}
+
{t("to")}: {message.to.map(formatAddress).join(", ")}
)} {message.date && (
{t("date")}: {new Date(message.date).toLocaleString()}
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index b712c158..10fbd330 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -821,8 +821,8 @@ export function FileBrowser({ const SortIndicator = ({ column }: { column: SortKey }) => { if (sortKey !== column) return null; return sortDir === "asc" - ? - : ; + ? + : ; }; // Keyboard shortcuts @@ -942,7 +942,7 @@ export function FileBrowser({ @@ -1003,7 +1003,7 @@ export function FileBrowser({ className="h-8" onClick={onPaste} > - + {t("paste")} ({clipboard.names.length}) )} @@ -1157,7 +1157,7 @@ export function FileBrowser({ className="h-7 text-destructive hover:text-destructive shrink-0" onClick={onRefresh} > - + {t("retry")}
@@ -1181,12 +1181,12 @@ export function FileBrowser({ {t("uploading")} {uploadProgress.name} {uploadProgress.totalFiles > 1 && ( - + ({uploadProgress.current}/{uploadProgress.totalFiles}) )} - + {uploadProgress.total > 0 ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` : "…"} @@ -1267,7 +1267,7 @@ export function FileBrowser({ )} {/* Favorites & Recent sidebar (when layout is inline) */} {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && ( -
+
{favorites.length > 0 && (

@@ -1280,7 +1280,7 @@ export function FileBrowser({ key={fav} onClick={() => onNavigate(fav)} className={cn( - "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left", + "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start", currentPath === fav && "bg-muted font-medium" )} > @@ -1301,7 +1301,7 @@ export function FileBrowser({ {recentFiles.slice(0, 10).map((recent) => (