Merge upstream/main to resolve conflicts
Both sides added adjacent LOGIN_* config entries (upstream: loginShowHeading/loginShowSubtitle/logo sizing; this branch: loginShowTotp/loginShowVersion) — resolution keeps both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
This commit is contained in:
@@ -1149,13 +1149,13 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="text-start">
|
||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="text-start">
|
||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||
</span>
|
||||
@@ -1336,7 +1336,7 @@ export default function CalendarPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
@@ -1475,7 +1475,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Desktop event panel */}
|
||||
{!isMobile && showEventModal && (
|
||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
@@ -1498,7 +1498,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Desktop task panel */}
|
||||
{!isMobile && showTaskModal && (
|
||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -513,23 +513,31 @@ export default function ContactsPage() {
|
||||
}, [router]);
|
||||
|
||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||
// Format each member as "Name <email>" so the composer keeps the display
|
||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||
// email, case-insensitively; members without an email are skipped.
|
||||
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||
// the string hand-off) instead of one entry per member - the chip expands
|
||||
// into the members when the message is sent. Dedupe by email,
|
||||
// case-insensitively; members without an email are skipped.
|
||||
const seen = new Set<string>();
|
||||
const recipients: string[] = [];
|
||||
const members: Array<{ name?: string; email: string }> = [];
|
||||
for (const member of getGroupMembers(groupId)) {
|
||||
const email = getContactPrimaryEmail(member).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||
const name = getContactDisplayName(member);
|
||||
members.push({ name: name && name !== email ? name : undefined, email });
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
if (members.length === 0) {
|
||||
toast.error(t("groups.no_member_emails"));
|
||||
return;
|
||||
}
|
||||
openComposeInApp(recipients, field);
|
||||
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
|
||||
const chip: Recipient = {
|
||||
name: (group && getContactDisplayName(group)) || "Group",
|
||||
email: "",
|
||||
group: { members },
|
||||
};
|
||||
openComposeInApp([formatRecipientEntry(chip)], field);
|
||||
}, [getGroupMembers, t, openComposeInApp]);
|
||||
|
||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||
@@ -751,7 +759,7 @@ export default function ContactsPage() {
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
@@ -869,7 +877,7 @@ export default function ContactsPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
@@ -937,7 +945,7 @@ export default function ContactsPage() {
|
||||
<div
|
||||
data-tour="contacts-list"
|
||||
className={cn(
|
||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||
isMobile ? "w-full" : "",
|
||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||
)}
|
||||
@@ -991,7 +999,7 @@ export default function ContactsPage() {
|
||||
onClick={mobileBackToList}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
<Home className="w-4 h-4 me-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -477,7 +477,7 @@ export default function FilesPage() {
|
||||
onClick={() => router.push("/")}
|
||||
className="justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -611,7 +611,7 @@ export default function FilesPage() {
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||
{migrationProgress.current} / {migrationProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{option.label}</span>
|
||||
<span className="flex-1 text-start">{option.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
@@ -867,7 +874,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{option.label}</span>
|
||||
<span className="flex-1 text-start">{option.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
@@ -881,19 +888,24 @@ export default function LoginPage() {
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||
{/* Header section with logo */}
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||
style={loginLogoStyle}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
{loginShowHeading && (
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
)}
|
||||
{loginShowSubtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
@@ -1121,7 +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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
) : (
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
<LogIn className="w-4 h-4 me-2" />
|
||||
)}
|
||||
{t("sign_in_sso")}
|
||||
</Button>
|
||||
|
||||
+304
-92
@@ -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<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -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<UnifiedAccountClient[]> => {
|
||||
// Cross-account scope requires both the per-user opt-in and the admin
|
||||
// capability gate; otherwise stay within the active account boundary.
|
||||
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
|
||||
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
|
||||
return buildUnifiedAccountClients({
|
||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||
scopeToClientAccountId: crossAccount
|
||||
? undefined
|
||||
: (useAccountStore.getState().activeAccountId ?? undefined),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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=<id>` (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() {
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background",
|
||||
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
|
||||
isHorizontalMailLayout
|
||||
? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
|
||||
: "h-full border-e border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
|
||||
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Tablet/Desktop: fixed width with collapse animation
|
||||
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
|
||||
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
|
||||
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
|
||||
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
|
||||
!isHorizontalMailLayout && "md:shadow-sm",
|
||||
!isResizing && "transition-all duration-200 ease-out",
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0"
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
@@ -2730,23 +2935,23 @@ export default function Home() {
|
||||
)}
|
||||
</button>
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("sidebar.search_placeholder_hint")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
disabled={isScheduledView}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("sidebar.clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -2756,15 +2961,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView || isScheduledView}
|
||||
disabled={isScheduledView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
|
||||
isScheduledView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -2803,7 +3008,7 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
<RotateCcw className="w-3 h-3 me-1" />
|
||||
{t("advanced_search.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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() {
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={
|
||||
useAccountStore
|
||||
.getState()
|
||||
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
|
||||
}
|
||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
@@ -3091,8 +3304,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,
|
||||
@@ -3148,13 +3360,13 @@ export default function Home() {
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left"
|
||||
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
|
||||
>
|
||||
<PenLine className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
|
||||
{pendingDraft.subject && (
|
||||
<span className="text-xs text-muted-foreground ml-2 truncate">{pendingDraft.subject}</span>
|
||||
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
|
||||
)}
|
||||
</div>
|
||||
<X
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function ProHome() {
|
||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||
const openTab = useProTabStore((s) => s.openTab);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
|
||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||
@@ -354,7 +354,7 @@ export default function ProHome() {
|
||||
activeMainTabId={activeMainTabId}
|
||||
activeSplitTabId={activeSplitTabId}
|
||||
onActivate={setActiveTab}
|
||||
onClose={closeTab}
|
||||
onClose={requestCloseTab}
|
||||
onDragStateChange={setIsTabDragging}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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:<id>`, so its UI (e.g. S/MIME key import) is
|
||||
// discoverable as a menu point rather than buried inside another panel.
|
||||
type PluginTabId = `plugin:${string}`;
|
||||
type SettingsTabId = Tab | PluginTabId;
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
id: SettingsTabId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
@@ -330,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
advanced: 'about_data',
|
||||
};
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
function readPersistedTab(): SettingsTabId {
|
||||
try {
|
||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||
// Used only as the initial tab and intentionally NOT written to
|
||||
@@ -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<Tab>(readPersistedTab);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
|
||||
// Active plugins that expose a `settings-section` slot — each becomes its own
|
||||
// Settings menu entry. Referentially stable per registry mutation, so it is
|
||||
// safe to feed useSyncExternalStore directly.
|
||||
const pluginSettingsOffers = useSyncExternalStore(
|
||||
pluginRegistrySubscribe,
|
||||
() => pluginOffersForSlot('settings-section'),
|
||||
() => pluginOffersForSlot('settings-section'),
|
||||
);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||
<span className="ml-auto text-sm font-medium truncate">
|
||||
<span className="ms-auto text-sm font-medium truncate">
|
||||
{t('scoped.managing', { name: managedAccount.name })}
|
||||
</span>
|
||||
</button>
|
||||
@@ -754,6 +778,13 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
{effectiveActiveTab.startsWith('plugin:') && (
|
||||
<PluginIframeSlot
|
||||
key={effectiveActiveTab}
|
||||
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||
slot="settings-section"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
||||
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
|
||||
>
|
||||
<span className="truncate">{sub.label}</span>
|
||||
</button>
|
||||
@@ -932,7 +963,7 @@ export default function SettingsPage() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
"border-e border-border bg-secondary flex flex-col",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
@@ -945,7 +976,7 @@ export default function SettingsPage() {
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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() {
|
||||
<button
|
||||
onClick={() => handleTabSelect(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
effectiveActiveTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -1013,7 +1044,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
key={`${tab.id}:${sub.label}`}
|
||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<span className="truncate block">{sub.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
|
||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
|
||||
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
|
||||
placeholder="Search extensions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<div key={key}>
|
||||
<label className="text-sm font-medium text-foreground block mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||
// forward on policy load), so it is hidden from the admin UI.
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
@@ -22,10 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SettingsTab() {
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</p>
|
||||
@@ -145,7 +145,7 @@ export function SettingsTab() {
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+12
-12
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowManifest(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowSource(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
||||
onClick={reset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+22
-2
@@ -1,9 +1,16 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { getLocaleDirection } from "@/i18n/direction";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { FaviconBadge } from "@/components/favicon-badge";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
@@ -38,7 +45,19 @@ export const viewport: Viewport = {
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// The <head> favicon must honor per-domain branding, exactly like
|
||||
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||
// request host and prefer its override; fall back to the global
|
||||
// admin/env/default value when the host has no favicon override (#585).
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverride = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
).faviconUrl;
|
||||
const faviconUrl =
|
||||
domainOverride && domainOverride.length > 0
|
||||
? domainOverride
|
||||
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
@@ -76,7 +95,7 @@ export default async function RootLayout({
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -114,6 +133,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<FaviconBadge />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmedNonJmap}
|
||||
@@ -870,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
)}
|
||||
|
||||
{hasRowErrors && (
|
||||
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
|
||||
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
|
||||
{rowErrors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
@@ -1384,7 +1384,7 @@ function BrandingAsset({
|
||||
</div>
|
||||
|
||||
{showUrlField && (
|
||||
<div className="mt-3 pl-[4.75rem]">
|
||||
<div className="mt-3 ps-[4.75rem]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@@ -1394,7 +1394,7 @@ function BrandingAsset({
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
||||
return (
|
||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
{value || <span className="text-muted-foreground italic">-</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
@@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||
] as const;
|
||||
|
||||
interface JmapSession {
|
||||
apiUrl?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
uid: string | null;
|
||||
@@ -141,9 +136,9 @@ export async function POST(request: NextRequest) {
|
||||
using.push('urn:ietf:params:jmap:principals:owner');
|
||||
}
|
||||
|
||||
// Send method calls to the same-origin JMAP endpoint the app's passthrough
|
||||
// uses — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = `${creds.serverUrl}/jmap/`;
|
||||
// Send method calls to the session's apiUrl rebased onto serverUrl's host
|
||||
// — never to session.apiUrl's (possibly unreachable) public host.
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
@@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
|
||||
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
|
||||
* fallback for other servers. Returns null if neither yields a usable session.
|
||||
*/
|
||||
async function fetchJmapSession(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<JmapSession | null> {
|
||||
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const session = (await res.json()) as JmapSession;
|
||||
if (session && typeof session === 'object' && session.primaryAccounts) {
|
||||
return session;
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||
if (!res.ok) {
|
||||
throw new Error(`JMAP request failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ export async function GET(request: NextRequest) {
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
|
||||
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
|
||||
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
|
||||
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
||||
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+22
-5
@@ -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-<name>` class per icon, so we target the
|
||||
directional ones only — vertical chevrons (up/down) are intentionally left. */
|
||||
[dir="rtl"] .lucide-chevron-left,
|
||||
[dir="rtl"] .lucide-chevron-right,
|
||||
[dir="rtl"] .lucide-chevrons-left,
|
||||
[dir="rtl"] .lucide-chevrons-right,
|
||||
[dir="rtl"] .lucide-arrow-left,
|
||||
[dir="rtl"] .lucide-arrow-right,
|
||||
[dir="rtl"] .lucide-arrow-big-left,
|
||||
[dir="rtl"] .lucide-arrow-big-right,
|
||||
[dir="rtl"] .lucide-panel-left,
|
||||
[dir="rtl"] .lucide-panel-right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user