Merge branch 'dev'

This commit is contained in:
Linus Rath
2026-04-16 18:51:01 +02:00
49 changed files with 3530 additions and 1002 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}
+136 -26
View File
@@ -9,7 +9,9 @@ import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email } from "@/lib/jmap/types";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
@@ -155,8 +157,54 @@ export default function Home() {
hasMoreEmails,
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const clients = useAuthStore.getState().getAllConnectedClients();
const result: UnifiedAccountClient[] = [];
for (const account of connected) {
const accountClient = clients.get(account.id);
if (!accountClient) continue;
result.push({
accountId: account.id,
accountLabel: account.label || account.email,
client: accountClient,
mailboxes: [],
});
}
return result;
}, []);
const populateUnifiedAccountMailboxes = useCallback(
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
const populated = await Promise.all(
list.map(async (entry) => {
try {
const mailboxes = await entry.client.getMailboxes();
return { ...entry, mailboxes };
} catch (err) {
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
return entry;
}
}),
);
return populated;
},
[],
);
// Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
@@ -485,13 +533,30 @@ export default function Home() {
};
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// 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
// or the primary account's mailboxes change (a proxy for "something worth
// recounting happened").
useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
const built = buildUnifiedAccounts();
if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated);
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => {
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
setLoadingEmail(false);
});
}
@@ -693,8 +758,8 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleDelete = async () => {
if (!client || !selectedEmail) return;
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -713,7 +778,7 @@ export default function Home() {
if (!confirmed) return;
try {
await deleteEmail(client, selectedEmail.id, true);
await deleteEmail(client, emailToDelete.id, true);
} catch (error) {
console.error("Failed to permanently delete email:", error);
}
@@ -722,7 +787,7 @@ export default function Home() {
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
if (trashMailbox) {
try {
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) {
console.error("Failed to move email to trash:", error);
}
@@ -795,10 +860,10 @@ export default function Home() {
}
};
const handleMarkAsSpam = async () => {
if (!client || !selectedEmail) return;
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
if (!client || !emailToMark) return;
const emailId = selectedEmail.id;
const emailId = emailToMark.id;
try {
await markAsSpam(client, emailId);
@@ -826,11 +891,11 @@ export default function Home() {
}
};
const handleUndoSpam = async () => {
if (!client || !selectedEmail) return;
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
if (!client || !emailToRestore) return;
try {
await undoSpam(client, selectedEmail.id);
await undoSpam(client, emailToRestore.id);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
@@ -886,6 +951,32 @@ export default function Home() {
};
const handleMailboxSelect = async (mailboxId: string) => {
if (isUnifiedMailboxId(mailboxId)) {
const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes
@@ -969,6 +1060,7 @@ export default function Home() {
const handleSearch = async (query: string) => {
if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
@@ -987,6 +1079,7 @@ export default function Home() {
const handleAdvancedSearch = async () => {
if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client);
};
@@ -996,9 +1089,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current);
}
advancedSearchDebounceRef.current = setTimeout(() => {
if (client) advancedSearch(client);
if (client && !isUnifiedView) advancedSearch(client);
}, 300);
}, [client, advancedSearch]);
}, [client, advancedSearch, isUnifiedView]);
useEffect(() => {
return () => {
@@ -1127,13 +1220,29 @@ export default function Home() {
// Fetch the full content
try {
// Find selected mailbox to determine accountId (for shared folders)
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
const fullEmail = await client.getEmail(email.id, accountId);
// For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
}
@@ -1397,6 +1506,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/>
{searchQuery && (
<button
@@ -1412,13 +1523,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={t("advanced_search.toggle_filters")}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -1602,8 +1715,7 @@ export default function Home() {
}
}}
onDelete={async (email) => {
selectEmail(email);
await handleDelete();
await handleDelete(email);
}}
onArchive={async (email) => {
await handleArchive(email);
@@ -1617,12 +1729,10 @@ export default function Home() {
}
}}
onMarkAsSpam={async (email) => {
selectEmail(email);
await handleMarkAsSpam();
await handleMarkAsSpam(email);
}}
onUndoSpam={async (email) => {
selectEmail(email);
await handleUndoSpam();
await handleUndoSpam(email);
}}
onEditDraft={(email) => {
handleEditDraft(email);
+18
View File
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -84,6 +85,7 @@ interface EmailComposerProps {
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
};
}
@@ -254,12 +256,28 @@ export function EmailComposer({
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
return;
}
// Fallback: match identity by the account's email when replying from unified view
if (replyTo?.accountId) {
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
if (account?.email) {
const accountEmail = account.email.trim().toLowerCase();
const accountIdentity = identities.find(
(identity) => identity.email.trim().toLowerCase() === accountEmail
);
if (accountIdentity) {
setSelectedIdentityId(accountIdentity.id);
}
}
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.accountId,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
+2 -2
View File
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
+1 -1
View File
@@ -176,7 +176,7 @@ export function EmailList({
setIsProcessing(true);
try {
await batchDelete(client);
await batchDelete(client, isInTrash);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
+8 -8
View File
@@ -3074,13 +3074,13 @@ export function EmailViewer({
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label}
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
@@ -3678,11 +3678,11 @@ export function EmailViewer({
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
+38 -4
View File
@@ -9,6 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
@@ -63,13 +64,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -228,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && email.accountId && accountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={email.accountLabel}
/>
)}
<span className={cn(
"truncate text-sm",
isUnread
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
@@ -375,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id ||
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
@@ -602,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={latestEmail.accountLabel}
/>
)}
<span className={cn(
"truncate text-sm",
hasUnread
+11 -4
View File
@@ -17,6 +17,7 @@ import type {
} from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -58,6 +59,7 @@ export function FilterRuleModal({
}: FilterRuleModalProps) {
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -375,12 +377,17 @@ export function FilterRuleModal({
)}
{action.type === "add_label" && (
<Input
<select
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")}
className="flex-1 min-w-[140px]"
/>
className={`${selectClass} flex-1 min-w-[140px]`}
aria-label={t("label_placeholder")}
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
))}
</select>
)}
<button
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -67,9 +68,10 @@ export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
+127 -75
View File
@@ -23,8 +23,13 @@ import {
Filter,
RotateCcw,
PalmtreeIcon,
Lock,
} from "lucide-react";
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
function RuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters");
@@ -429,90 +434,137 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => (
<div
key={rule.id}
role="listitem"
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
dragOverIndex === index
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
} ${!rule.enabled ? "opacity-60" : ""}`}
>
{rules.map((rule, index) => {
const readonly = isReadonlyRule(rule);
if (readonly) {
const label = rule.originLabel || t("origin_external");
const tooltip = t("managed_by_tooltip", { source: label });
const hasStructuredSummary =
rule.origin === "external" &&
rule.conditions.length > 0 &&
rule.actions.length > 0;
return (
<div
key={rule.id}
role="listitem"
className="flex items-start gap-3 p-3 rounded-md border border-border"
title={tooltip}
>
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
<Lock className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
{label}
</span>
</div>
{hasStructuredSummary ? (
expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)
) : rule.rawBlock ? (
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
{rule.rawBlock.trim()}
</pre>
) : null}
</div>
</div>
);
}
return (
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
aria-label={t("drag_to_reorder")}
key={rule.id}
role="listitem"
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
dragOverIndex === index
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
} ${!rule.enabled ? "opacity-60" : ""}`}
>
<GripVertical className="w-4 h-4" />
</div>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
aria-label={t("drag_to_reorder")}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="pt-0.5">
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
/>
</div>
<div className="pt-0.5">
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
/>
</div>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
{expandedFilterView ? (
<VisualRuleSummary rule={rule} />
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(rule);
setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
{expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)}
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<RuleSummary rule={rule} />
<button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
);
})}
</div>
)}
</SettingsSection>
+178 -248
View File
@@ -1,17 +1,19 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const GAME_WIDTH = 400;
const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48;
const SPAWN_INTERVAL_START = 850;
const SPAWN_INTERVAL_MIN = 320;
const INBOX_Y = GAME_HEIGHT - 40;
const SPAWN_INTERVAL_START = 900;
const SPAWN_INTERVAL_MIN = 340;
const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04;
const MAX_MISSES = 3;
interface Enemy {
id: number;
@@ -21,81 +23,85 @@ interface Enemy {
type: "spam" | "phishing" | "legit";
}
type GameState = "idle" | "playing" | "won" | "lost";
type GameState = "idle" | "playing" | "over";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [misses, setMisses] = useState(0);
const [survived, setSurvived] = useState(false);
const nextId = useRef(0);
const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0);
const destroyedRef = useRef(new Set<number>());
const clickedRef = useRef(new Set<number>());
const enemiesRef = useRef<Enemy[]>([]);
const missesRef = useRef(0);
const scoreRef = useRef(0);
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
const endGame = useCallback((didSurvive: boolean) => {
setSurvived(didSurvive);
setGameState("over");
}, []);
const startGame = useCallback(() => {
setGameState("playing");
setEnemies([]);
setScore(0);
setTimeLeft(GAME_DURATION);
setShieldHealth(3);
setHitEffects([]);
setDestroyEffects([]);
setDeliverEffects([]);
setMisses(0);
setSurvived(false);
nextId.current = 0;
spawnTimerRef.current = 0;
elapsedRef.current = 0;
destroyedRef.current = new Set();
clickedRef.current = new Set();
enemiesRef.current = [];
missesRef.current = 0;
scoreRef.current = 0;
lastTimeRef.current = performance.now();
}, []);
const spawnEnemy = useCallback(() => {
const id = nextId.current++;
const rand = Math.random();
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam";
const x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
setEnemies(enemiesRef.current);
}, []);
const handleHover = useCallback((enemy: Enemy) => {
if (destroyedRef.current.has(enemy.id)) return;
destroyedRef.current.add(enemy.id);
const handleClick = useCallback(
(ev: React.MouseEvent, enemy: Enemy) => {
ev.stopPropagation();
if (clickedRef.current.has(enemy.id)) return;
clickedRef.current.add(enemy.id);
if (enemy.type === "legit") {
// Penalty for blocking legit mail
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
setScore((prev) => Math.max(0, prev - 15));
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
} else {
setScore((prev) => prev + 10);
const effectId = nextId.current++;
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
}
enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
setEnemies(enemiesRef.current);
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
}, []);
if (enemy.type === "legit") {
missesRef.current += 1;
setMisses(missesRef.current);
scoreRef.current = Math.max(0, scoreRef.current - 15);
setScore(scoreRef.current);
if (missesRef.current >= MAX_MISSES) endGame(false);
} else {
scoreRef.current += enemy.type === "phishing" ? 15 : 10;
setScore(scoreRef.current);
}
},
[endGame]
);
// Game loop
useEffect(() => {
if (gameState !== "playing") return;
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
lastTimeRef.current = now;
elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) {
setGameState("won");
endGame(true);
return;
}
// Spawn
spawnTimerRef.current += dt;
const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN,
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
spawnEnemy();
}
// Move enemies
setEnemies((prev) => {
const next: Enemy[] = [];
let spamBreached = false;
for (const e of prev) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= FORTRESS_Y) {
if (e.type === "legit") {
// Legit mail delivered — bonus
setScore((s) => s + 5);
const effectId = nextId.current++;
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
} else {
spamBreached = true;
const effectId = nextId.current++;
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
}
} else {
next.push({ ...e, y: ny });
}
const nextEnemies: Enemy[] = [];
let missed = 0;
let scoreDelta = 0;
for (const e of enemiesRef.current) {
const ny = e.y + e.speed * (dt / 16);
if (ny >= INBOX_Y) {
if (e.type === "legit") scoreDelta += 5;
else missed++;
} else {
nextEnemies.push({ ...e, y: ny });
}
if (spamBreached) {
setShieldHealth((prev) => {
const nh = prev - 1;
if (nh <= 0) setGameState("lost");
return Math.max(0, nh);
});
}
enemiesRef.current = nextEnemies;
setEnemies(nextEnemies);
if (scoreDelta > 0) {
scoreRef.current += scoreDelta;
setScore(scoreRef.current);
}
if (missed > 0) {
missesRef.current += missed;
setMisses(missesRef.current);
if (missesRef.current >= MAX_MISSES) {
endGame(false);
return;
}
return next;
});
}
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]);
const getEnemyStyle = (type: Enemy["type"]) => {
switch (type) {
case "phishing":
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
case "legit":
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
default:
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
}
};
}, [gameState, spawnEnemy, endGame]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
<Shield className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-foreground">Spam Siege</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Close"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* HUD */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
</div>
<div className="flex items-center gap-1">
{[...Array(3)].map((_, i) => (
<Shield
key={i}
className="w-3.5 h-3.5 transition-colors"
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
strokeWidth={i < shieldHealth ? 0 : 1.5}
/>
))}
<div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
Score <span className="font-medium text-foreground tabular-nums">{score}</span>
</span>
<span>
Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
</span>
</div>
<span>
Misses{" "}
<span
className={cn(
"font-medium tabular-nums",
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
)}
>
{misses}/{MAX_MISSES}
</span>
</span>
</div>
{/* Game area */}
<div
className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }}
>
{/* Grid lines for depth */}
<div className="absolute inset-0 opacity-[0.03]" style={{
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
backgroundSize: "40px 40px",
}} />
{/* Fortress wall */}
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
<div className="relative w-full">
{/* Shield centered above the line */}
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
<Shield
className="w-7 h-7 drop-shadow-sm"
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
/>
</div>
{/* Solid line */}
<div
className="h-[2px] w-full"
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
/>
</div>
{/* Subtle gradient fill below */}
<div
className="flex-1 w-full"
style={{
background: shieldHealth > 0
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
}}
/>
<div
className="absolute left-0 right-0 flex items-center gap-2 px-4"
style={{ top: INBOX_Y }}
>
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Inbox
</span>
<div className="h-px flex-1 bg-border" />
</div>
{/* Enemies */}
{enemies.map((e) => {
const style = getEnemyStyle(e.type);
const variant =
e.type === "phishing"
? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20"
: e.type === "legit"
? "text-success border-success/40 bg-success/10 hover:bg-success/20"
: "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20";
const Icon =
e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail;
return (
<div
<button
key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
style={{
left: e.x,
top: e.y,
backgroundColor: style.bg,
border: `1px solid ${style.border}`,
}}
onMouseEnter={() => handleHover(e)}
>
{e.type === "phishing" ? (
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
) : e.type === "legit" ? (
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
) : (
<Mail className="w-4 h-4" style={{ color: style.color }} />
type="button"
className={cn(
"absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
"active:scale-95 transition-transform",
variant
)}
</div>
style={{ left: e.x, top: e.y }}
onMouseEnter={(ev) => handleClick(ev, e)}
onClick={(ev) => handleClick(ev, e)}
>
<Icon className="w-4 h-4" />
</button>
);
})}
{/* Destroy effects */}
{destroyEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y + 4 }}
>
<X className="w-5 h-5 text-muted-foreground/50" />
</div>
))}
{/* Deliver effects (legit mail arrived) */}
{deliverEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none animate-ping"
style={{ left: e.x + 4, top: e.y - 8 }}
>
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
</div>
))}
{/* Hit effects on fortress */}
{hitEffects.map((e) => (
<div
key={e.id}
className="absolute pointer-events-none"
style={{ left: e.x, top: e.y - 10 }}
>
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
</div>
))}
{/* Idle overlay */}
{gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield className="w-10 h-10 text-primary" />
<div className="space-y-1.5">
<p className="text-base font-medium text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Click spam and phishing before they hit your inbox. Don&apos;t block legitimate
mail. Three misses and it&apos;s over.
</p>
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
</span>
<span className="inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
</span>
<span className="inline-flex items-center gap-1">
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
</span>
</div>
</div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Shield className="w-3.5 h-3.5 mr-1.5" />
Defend
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Mail className="w-3 h-3 text-destructive" />
Spam
</span>
<span className="inline-flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-warning" />
Phishing
</span>
<span className="inline-flex items-center gap-1.5">
<MailCheck className="w-3 h-3 text-success" />
Legit
</span>
</div>
<Button size="sm" onClick={startGame}>
Start
</Button>
</div>
)}
{/* Won overlay */}
{gameState === "won" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
{gameState === "over" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield
className={cn(
"w-10 h-10",
survived ? "text-success" : "text-muted-foreground/40"
)}
/>
<div className="space-y-1">
<p className="text-base font-medium text-foreground">
{survived ? "Inbox held" : "Inbox overrun"}
</p>
<p className="text-xs text-muted-foreground">
Final score{" "}
<span className="font-medium text-foreground tabular-nums">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<Button size="sm" onClick={startGame}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again
</Button>
</div>
</div>
)}
{/* Lost overlay */}
{gameState === "lost" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
<Shield className="w-14 h-14 text-muted-foreground/40" />
<div className="text-center">
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
<p className="text-xs text-muted-foreground mt-1">
Score: <span className="font-semibold text-foreground">{score}</span>
</p>
</div>
<div className="flex gap-2 mt-1">
<Button size="sm" variant="outline" onClick={onClose}>
Close
</Button>
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Retry
</Button>
</div>
</div>
)}
</div>
</div>
</div>
+184
View File
@@ -0,0 +1,184 @@
import { type SVGProps, type ReactElement } from "react";
type FlagProps = SVGProps<SVGSVGElement>;
const flagClass = "inline-block rounded-[2px] shrink-0";
const W = 20;
const H = 15;
/** Great Britain Union Jack (simplified) */
export function FlagGB(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width={W} height={H} className={flagClass} {...props}>
<rect width="60" height="30" fill="#012169" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="2" />
<path d="M30,0 V30 M0,15 H60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 V30 M0,15 H60" stroke="#C8102E" strokeWidth="6" />
</svg>
);
}
/** France Blue, White, Red vertical */
export function FlagFR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="1" height="2" fill="#002395" />
<rect x="1" width="1" height="2" fill="#fff" />
<rect x="2" width="1" height="2" fill="#ED2939" />
</svg>
);
}
/** Japan White with red circle */
export function FlagJP(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.6" fill="#BC002D" />
</svg>
);
}
/** South Korea Simplified */
export function FlagKR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
</svg>
);
}
/** Spain Red, Yellow, Red horizontal */
export function FlagES(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="0.5" fill="#AA151B" />
<rect y="0.5" width="3" height="1" fill="#F1BF00" />
<rect y="1.5" width="3" height="0.5" fill="#AA151B" />
</svg>
);
}
/** Italy Green, White, Red vertical */
export function FlagIT(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="1" height="2" fill="#009246" />
<rect x="1" width="1" height="2" fill="#fff" />
<rect x="2" width="1" height="2" fill="#CE2B37" />
</svg>
);
}
/** Germany Black, Red, Gold horizontal */
export function FlagDE(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3" width={W} height={H} className={flagClass} {...props}>
<rect width="5" height="1" fill="#000" />
<rect y="1" width="5" height="1" fill="#DD0000" />
<rect y="2" width="5" height="1" fill="#FFCC00" />
</svg>
);
}
/** Latvia Maroon, White, Maroon horizontal */
export function FlagLV(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10" width={W} height={H} className={flagClass} {...props}>
<rect width="20" height="4" fill="#9E3039" />
<rect y="4" width="20" height="2" fill="#fff" />
<rect y="6" width="20" height="4" fill="#9E3039" />
</svg>
);
}
/** Netherlands Red, White, Blue horizontal */
export function FlagNL(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
<rect width="9" height="2" fill="#AE1C28" />
<rect y="2" width="9" height="2" fill="#fff" />
<rect y="4" width="9" height="2" fill="#21468B" />
</svg>
);
}
/** Poland White, Red horizontal */
export function FlagPL(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 5" width={W} height={H} className={flagClass} {...props}>
<rect width="8" height="2.5" fill="#fff" />
<rect y="2.5" width="8" height="2.5" fill="#DC143C" />
</svg>
);
}
/** Brazil Green, yellow diamond (simplified) */
export function FlagBR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 14" width={W} height={H} className={flagClass} {...props}>
<rect width="20" height="14" fill="#009B3A" />
<polygon points="10,1.5 18.5,7 10,12.5 1.5,7" fill="#FEDF00" />
<circle cx="10" cy="7" r="3" fill="#002776" />
</svg>
);
}
/** Russia White, Blue, Red horizontal */
export function FlagRU(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
<rect width="9" height="2" fill="#fff" />
<rect y="2" width="9" height="2" fill="#0039A6" />
<rect y="4" width="9" height="2" fill="#D52B1E" />
</svg>
);
}
/** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="1" fill="#005BBB" />
<rect y="1" width="3" height="1" fill="#FFD500" />
</svg>
);
}
/** China Red with yellow stars (simplified) */
export function FlagCN(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
<rect width="30" height="20" fill="#DE2910" />
<g fill="#FFDE00">
<polygon points="5,2 6,5 3.2,3.2 6.8,3.2 4,5" />
<polygon points="10,1 10.6,2.7 9,1.8 11,1.8 9.4,2.7" />
<polygon points="12,3 12.6,4.7 11,3.8 13,3.8 11.4,4.7" />
<polygon points="12,6 12.6,7.7 11,6.8 13,6.8 11.4,7.7" />
<polygon points="10,8 10.6,9.7 9,8.8 11,8.8 9.4,9.7" />
</g>
</svg>
);
}
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
en: FlagGB,
fr: FlagFR,
ja: FlagJP,
ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV,
nl: FlagNL,
pl: FlagPL,
pt: FlagBR,
ru: FlagRU,
uk: FlagUA,
zh: FlagCN,
};
+96 -23
View File
@@ -1,37 +1,110 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useLocale } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import { Select } from '@/components/settings/settings-section';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' },
];
function FlagIcon({ locale }: { locale: string }) {
const Flag = flagComponents[locale];
if (!Flag) return null;
return <Flag />;
}
export function LanguageSwitcher({ className }: { className?: string }) {
const currentLocale = useLocale();
const setLocale = useLocaleStore((state) => state.setLocale);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const languages = [
{ value: 'en', label: '🇬🇧 English' },
{ value: 'fr', label: '🇫🇷 Français' },
{ value: 'ja', label: '🇯🇵 日本語' },
{ value: 'ko', label: '🇰🇷 한국어' },
{ value: 'es', label: '🇪🇸 Español' },
{ value: 'it', label: '🇮🇹 Italiano' },
{ value: 'de', label: '🇩🇪 Deutsch' },
{ value: 'lv', label: '🇱🇻 Latviešu' },
{ value: 'nl', label: '🇳🇱 Nederlands' },
{ value: 'pl', label: '🇵🇱 Polski' },
{ value: 'pt', label: '🇧🇷 Português' },
{ value: 'ru', label: '🇷🇺 Русский' },
{ value: 'uk', label: '🇺🇦 Українська' },
{ value: 'zh', label: '🇨🇳 简体中文' }
];
const current = languages.find((l) => l.value === currentLocale) ?? languages[0];
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
return (
<div className={className}>
<Select
value={currentLocale}
onChange={setLocale}
options={languages}
/>
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
aria-haspopup="listbox"
aria-expanded={open}
>
<FlagIcon locale={current.value} />
<span className="flex-1 text-left">{current.label}</span>
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-150", open && "rotate-180")} />
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-activedescendant={`lang-${currentLocale}`}
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-background shadow-lg py-1"
>
{languages.map((lang) => (
<li
key={lang.value}
id={`lang-${lang.value}`}
role="option"
aria-selected={lang.value === currentLocale}
onClick={() => {
setLocale(lang.value);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-100",
lang.value === currentLocale
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-accent/50"
)}
>
<FlagIcon locale={lang.value} />
<span>{lang.label}</span>
</li>
))}
</ul>
)}
</div>
);
}
+7 -2
View File
@@ -196,10 +196,15 @@ describe('sieve generator', () => {
expect(result.vacation?.isEnabled).toBe(true);
});
it('should mark as opaque when real filter rules exist alongside vacation', () => {
it('parses filter rules alongside vacation as external when no metadata is present', () => {
const script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
// New behavior: preserve both the vacation statement (as opaque) and
// the if-block (as a structured external rule) instead of dropping them.
expect(result.isOpaque).toBe(false);
const ifRule = result.rules.find(r => r.origin === 'external');
expect(ifRule?.conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'boss@example.com' });
expect(ifRule?.actions[0]).toEqual({ type: 'move', value: 'Important' });
});
it('should handle Stalwart :mime format vacation script', () => {
+5
View File
@@ -39,6 +39,8 @@ export interface FilterAction {
value?: string;
}
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
export interface FilterRule {
id: string;
name: string;
@@ -47,6 +49,9 @@ export interface FilterRule {
conditions: FilterCondition[];
actions: FilterAction[];
stopProcessing: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
}
export interface VacationSieveConfig {
+30
View File
@@ -39,6 +39,9 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support — set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
}
export interface AuthenticationResults {
@@ -724,4 +727,31 @@ export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
// Unified mailbox virtual IDs and types
export const UNIFIED_INBOX = '__unified_inbox__';
export const UNIFIED_SENT = '__unified_sent__';
export const UNIFIED_DRAFTS = '__unified_drafts__';
export const UNIFIED_TRASH = '__unified_trash__';
export const UNIFIED_ARCHIVE = '__unified_archive__';
export const UNIFIED_JUNK = '__unified_junk__';
export type UnifiedMailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'junk';
export const UNIFIED_MAILBOX_IDS: Record<UnifiedMailboxRole, string> = {
inbox: UNIFIED_INBOX,
sent: UNIFIED_SENT,
drafts: UNIFIED_DRAFTS,
trash: UNIFIED_TRASH,
archive: UNIFIED_ARCHIVE,
junk: UNIFIED_JUNK,
};
export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fromEntries(
Object.entries(UNIFIED_MAILBOX_IDS).map(([role, id]) => [id, role as UnifiedMailboxRole])
) as Record<string, UnifiedMailboxRole>;
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
+27 -2
View File
@@ -14,6 +14,7 @@ import type {
AdminPageSection,
CalendarEventAction,
SlotName,
PluginI18n,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
@@ -22,8 +23,9 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks,
sidebarAppHooks, avatarHooks, renderHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
@@ -110,6 +112,8 @@ function createPluginLogger(pluginId: string) {
export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API — register translations and call t() to get strings */
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
@@ -150,6 +154,8 @@ export interface PluginHooksAPI {
onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -158,6 +164,10 @@ export interface PluginHooksAPI {
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Emitted after emails are moved to the Archive mailbox */
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
/** Emitted after emails are moved out of the Archive mailbox */
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -174,6 +184,8 @@ export interface PluginHooksAPI {
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -216,6 +228,8 @@ export interface PluginHooksAPI {
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -317,6 +331,9 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
// Render — transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -325,14 +342,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email
onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
onDraftAutoSave: 'email:read',
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
@@ -359,6 +379,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth
@@ -470,6 +491,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
};
// --- Slot registration bridge --------------------------------
@@ -531,6 +554,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
settings: { ...plugin.settings },
},
i18n: createPluginI18n(plugin.id),
ui: {
registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar');
+26 -1
View File
@@ -172,6 +172,10 @@ export const emailHooks = {
onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(),
onThreadExpand: new HookBus(),
// Intercept hook — fires before the composer opens.
// Handlers receive ComposeOptions and may mutate fields in place.
// Return false to cancel opening the composer.
onBeforeCompose: new HookBus(),
onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(),
@@ -180,6 +184,10 @@ export const emailHooks = {
onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(),
onAfterEmailMove: new HookBus(),
// Fired after one or more emails are archived to the Archive mailbox
onEmailArchive: new HookBus(),
// Fired after one or more emails are moved out of the Archive mailbox
onEmailUnarchive: new HookBus(),
onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(),
@@ -196,6 +204,9 @@ export const emailHooks = {
onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(),
onQuotaChange: new HookBus(),
// Intercept hook — fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -250,6 +261,10 @@ export const fileHooks = {
onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(),
onAfterFileDelete: new HookBus(),
// Intercept hook — fires before a file is renamed.
// Receives { file: FileResourceView, newName: string }.
// Return false to cancel the rename.
onBeforeFileRename: new HookBus(),
onFileRename: new HookBus(),
onFileMove: new HookBus(),
onFileCopy: new HookBus(),
@@ -406,6 +421,16 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook — runs for each visible email list row.
// Initial value: EmailListBadge[] (always starts as [])
// Second argument: { emailId: string; email: EmailReadView }
// Handlers return a new (or extended) badges array.
// Rendered by the email list row component next to the subject line.
onEmailListItemRender: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
@@ -414,7 +439,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
avatarHooks, renderHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+118
View File
@@ -0,0 +1,118 @@
// Plugin i18n registry — manages per-plugin translation tables
//
// Each plugin gets its own namespace keyed by:
// pluginId → locale → { messageKey → translated string }
//
// Resolution order when calling t(key):
// 1. Exact locale match ("fr-CA")
// 2. Language-prefix match ("fr" from "fr-CA")
// 3. English fallback ("en")
// 4. Raw key (plugin is never broken by missing strings)
//
// Interpolation uses {paramName} placeholders.
// ─── Registry ────────────────────────────────────────────────
/** pluginId → locale → key → translated string */
const registry = new Map<string, Map<string, Record<string, string>>>();
let currentLocale = 'en';
// ─── Locale sync (called by plugin-loader) ───────────────────
/** Keep the registry in sync with the app locale */
export function setPluginI18nLocale(locale: string): void {
currentLocale = locale;
}
export function getPluginI18nLocale(): string {
return currentLocale;
}
// ─── Cleanup ─────────────────────────────────────────────────
/** Remove all translations for a plugin (called on deactivation) */
export function clearPluginI18nTranslations(pluginId: string): void {
registry.delete(pluginId);
}
// ─── Helpers ─────────────────────────────────────────────────
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
function resolve(pluginId: string, key: string): string | undefined {
const byLocale = registry.get(pluginId);
if (!byLocale) return undefined;
// 1. Exact locale (e.g. "fr-CA")
const exact = byLocale.get(currentLocale)?.[key];
if (exact !== undefined) return exact;
// 2. Language prefix (e.g. "fr" from "fr-CA")
const lang = currentLocale.split('-')[0];
if (lang !== currentLocale) {
const langMatch = byLocale.get(lang)?.[key];
if (langMatch !== undefined) return langMatch;
}
// 3. English fallback
return byLocale.get('en')?.[key];
}
// ─── Public API factory ──────────────────────────────────────
/**
* Build the i18n API object exposed as `api.i18n` inside each plugin.
*
* @example
* // In your plugin activate():
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
*
* // Later, in any React component the plugin renders:
* const title = api.i18n.t('banner.title');
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
*/
export function createPluginI18n(pluginId: string) {
return {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins on key collision).
*
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*/
addTranslations(locale: string, strings: Record<string, string>): void {
let byLocale = registry.get(pluginId);
if (!byLocale) {
byLocale = new Map<string, Record<string, string>>();
registry.set(pluginId, byLocale);
}
const existing = byLocale.get(locale) ?? {};
byLocale.set(locale, { ...existing, ...strings });
},
/**
* Translate a key using the current app locale.
* Falls back through: exact locale → language prefix → 'en' → raw key.
*
* @param key Translation key, e.g. `'banner.title'`
* @param params Optional interpolation values, e.g. `{ count: 3 }`
*/
t(key: string, params?: Record<string, string | number>): string {
const template = resolve(pluginId, key);
if (template !== undefined) return interpolate(template, params);
return key; // never throw — just return the key
},
/** The current app locale (e.g. "en", "de", "fr") */
getLocale(): string {
return currentLocale;
},
};
}
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
+25 -1
View File
@@ -1,15 +1,18 @@
// Plugin Loader — loads and activates plugins via blob URL dynamic import
// Plugin Loader loads and activates plugins via blob URL dynamic import
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
ReactDOM,
ReactJSX,
};
// Sync plugin i18n with the app locale (runs once per page load)
if (!localeSyncInitialised) {
localeSyncInitialised = true;
// Dynamic import avoids a circular dependency chain at module evaluation time
import('@/stores/locale-store').then(({ useLocaleStore }) => {
setPluginI18nLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
}).catch(() => {/* locale sync is best-effort */});
}
}
// --- Active plugin tracking ----------------------------------
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
if (plugin.locales) {
for (const [locale, strings] of Object.entries(plugin.locales)) {
api.i18n.addTranslations(locale, strings);
}
}
// 5. Call activate
const disposable = await mod.activate(api);
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
+89
View File
@@ -34,6 +34,13 @@ export interface PluginManifest {
entrypoint: string;
minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>;
/**
* Bundled translations shipped inside the plugin ZIP.
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
* The loader auto-registers these before calling activate(),
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
}
export interface SettingFieldSchema {
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
}
// ─── UI Slots ────────────────────────────────────────────────
@@ -388,6 +397,86 @@ export interface ComposerContext {
originalSubject?: string;
}
// ─── New hook context types ──────────────────────────────────
/**
* Passed to onBeforeCompose handlers.
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
*/
export interface ComposeOptions {
to: string[];
cc: string[];
subject: string;
body: string;
mode: 'new' | 'reply' | 'reply-all' | 'forward';
}
/**
* A small visual indicator injected into an email list row via onEmailListItemRender.
*/
export interface EmailListBadge {
/** Stable unique key within the plugin — used as React key */
key: string;
/** Short label text displayed in the badge */
label: string;
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
color?: string;
/** Tooltip / aria-label */
title?: string;
}
/**
* Passed to onMailtoIntercept handlers.
* Return false to prevent the browser from opening the system mail client.
*/
export interface MailtoContext {
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
href: string;
/** Parsed list of recipient addresses */
to: string[];
subject?: string;
body?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
* Localisation API exposed as `api.i18n` inside every plugin.
*
* Plugins ship their own translation tables; the app locale is tracked
* automatically so `t()` always returns the right string without any
* extra setup from the plugin side.
*/
export interface PluginI18n {
/**
* Register translations for one locale.
* Multiple calls for the same locale are merged (last-write-wins per key).
*
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
* @param strings Key → translated string map. Use {paramName} for interpolation.
*
* @example
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
*/
addTranslations(locale: string, strings: Record<string, string>): void;
/**
* Return the translated string for `key` using the current app locale,
* with optional {param} interpolation.
*
* Falls back: exact locale → language prefix → "en" → raw key.
*
* @example
* api.i18n.t('banner.title')
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
*/
t(key: string, params?: Record<string, string | number>): string;
/** The current app locale string (e.g. "en", "de", "fr") */
getLocale(): string;
}
// ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseScript } from '../parser';
import { generateScript } from '../generator';
import type { FilterRule } from '@/lib/jmap/sieve-types';
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
return {
id: 'bw-1',
name: 'Bulwark Rule',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
...overrides,
};
}
describe('external rule preservation (issue #201)', () => {
describe('parser — external rule recognition', () => {
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
const rule = result.rules[0];
expect(rule.origin).toBe('external');
expect(rule.originLabel).toBe('Roundcube');
expect(rule.name).toBe('Archive Newsletters');
expect(rule.conditions[0]).toMatchObject({
field: 'header',
comparator: 'contains',
value: 'news',
headerName: 'List-Id',
});
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
});
it('labels rules near a Nextcloud marker comment', () => {
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('Nextcloud');
});
it('falls back to "External" label when no known marker is present', () => {
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].originLabel).toBe('External');
});
it('parses anyof/allof conditions in external rules', () => {
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
const result = parseScript(script);
expect(result.rules[0].matchType).toBe('any');
expect(result.rules[0].conditions).toHaveLength(2);
});
it('parses negated conditions (not header :is)', () => {
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.rules[0].conditions[0]).toMatchObject({
field: 'from',
comparator: 'not_is',
value: 'spam@x.com',
});
});
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
const result = parseScript(script);
expect(result.isOpaque).toBe(false);
const rule = result.rules[0];
expect(rule.origin).toBe('opaque');
expect(rule.rawBlock).toContain('if header :value');
});
it('collects all external require tokens', () => {
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
const result = parseScript(script);
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
});
});
describe('parser — mixed Bulwark + external', () => {
it('returns Bulwark rules from metadata and external rules from the rest', () => {
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
const bulwarkScript = generateScript(bulwark);
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
const result = parseScript(mixedScript);
expect(result.isOpaque).toBe(false);
expect(result.rules.length).toBeGreaterThanOrEqual(2);
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
const externalParsed = result.rules.filter(r => r.origin === 'external');
expect(bulwarkParsed).toHaveLength(1);
expect(bulwarkParsed[0].name).toBe('Bulwark A');
expect(externalParsed).toHaveLength(1);
expect(externalParsed[0].originLabel).toBe('Nextcloud');
});
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
const script = generateScript(bulwark);
const result = parseScript(script);
// Only the metadata-sourced rule, no duplicate "external" entry.
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBeUndefined();
});
});
describe('generator — external splice', () => {
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
const externalRule: FilterRule = {
id: 'ext-0',
name: 'External',
enabled: true,
matchType: 'all',
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
actions: [{ type: 'move', value: 'Lists' }],
stopProcessing: false,
origin: 'external',
originLabel: 'Nextcloud',
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
};
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
const script = generateScript(rules);
expect(script).toContain('# Rule: Bulwark Rule');
expect(script).toContain('# Nextcloud Mail');
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
const externalIdx = script.indexOf('# Nextcloud Mail');
expect(bulwarkIdx).toBeLessThan(externalIdx);
});
it('unions external requires into the top-level require line', () => {
const script = generateScript([makeBulwarkRule()], undefined, {
externalRequires: ['fileinto', 'imap4flags', 'body'],
});
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
expect(requireLine).toContain('"fileinto"');
expect(requireLine).toContain('"imap4flags"');
expect(requireLine).toContain('"body"');
});
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
const bulwarkWithJunk: FilterRule = {
...makeBulwarkRule(),
origin: 'bulwark',
originLabel: 'shouldnotbehere',
rawBlock: 'shouldnotbehere',
};
const script = generateScript([bulwarkWithJunk]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules[0]).not.toHaveProperty('origin');
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
});
it('never writes external rules into metadata', () => {
const ext: FilterRule = {
id: 'ext-0',
name: 'Ext',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
actions: [{ type: 'keep' }],
stopProcessing: false,
origin: 'external',
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
};
const script = generateScript([makeBulwarkRule(), ext]);
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
const metadata = JSON.parse(match![1]);
expect(metadata.rules).toHaveLength(1);
expect(metadata.rules[0].name).toBe('Bulwark Rule');
});
});
describe('fixture: mixed-origins.sieve', () => {
const fixture = readFileSync(
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
'utf-8',
);
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
const result = parseScript(fixture);
expect(result.isOpaque).toBe(false);
expect(result.vacation).toBeUndefined();
const byOrigin = {
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
external: result.rules.filter(r => r.origin === 'external'),
opaque: result.rules.filter(r => r.origin === 'opaque'),
};
expect(byOrigin.bulwark).toHaveLength(2);
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
expect(byOrigin.opaque).toHaveLength(1);
const labels = byOrigin.external.map(r => r.originLabel);
expect(labels).toContain('Roundcube');
expect(labels).toContain('Nextcloud');
expect(labels).toContain('External');
const opaqueRule = byOrigin.opaque[0];
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
});
it('preserves unknown-Sieve content through save round-trip', () => {
const parsed = parseScript(fixture);
const regenerated = generateScript(parsed.rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
});
// The unparseable construct must appear verbatim in the regenerated script.
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
// Require tokens from the external content are preserved.
expect(regenerated).toContain('"relational"');
// Bulwark rules are still present.
expect(regenerated).toContain('# Rule: Archive newsletters');
});
});
describe('round-trip', () => {
it('preserves external rules through parse → generate → parse', () => {
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
const firstParse = parseScript(initial);
expect(firstParse.rules).toHaveLength(2);
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
externalRequires: firstParse.externalRequires,
});
const secondParse = parseScript(regenerated);
expect(secondParse.rules).toHaveLength(2);
const names = secondParse.rules.map(r => r.name).sort();
expect(names).toContain('VIP');
});
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
const parsed = parseScript(initial);
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
expect(externalBefore).toHaveLength(1);
// Simulate a user edit — update the Bulwark rule name
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
const reparsed = parseScript(regenerated);
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
expect(externalAfter).toHaveLength(1);
expect(externalAfter[0].name).toBe('Untouchable');
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
});
});
});
@@ -0,0 +1,44 @@
/* @metadata:begin
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
@metadata:end */
require ["body", "copy", "fileinto", "imap4flags", "relational"];
# Rule: Archive newsletters
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
fileinto "Newsletters";
}
# Rule: Flag VIP senders
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
addflag "\\Flagged";
addflag "\\Seen";
}
# --- External rules (managed outside Bulwark) ---
# rule:[Finance — auto-file invoices]
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
fileinto :copy "Finance/Invoices";
keep;
}
# Nextcloud Mail - begin
# Filter installed by Nextcloud Mail app
if header :contains "Subject" "[Support]" {
fileinto "Support";
}
# Nextcloud Mail - end
# A handwritten rule without a tool-specific marker.
# Bulwark should recognize this as generic "External" and preserve it.
if not header :is "X-Spam-Status" "No" {
fileinto "Junk";
}
# A rule using a Sieve construct Bulwark's visual editor does not understand.
# It must survive round-trips verbatim, shown to the user as read-only.
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
fileinto "LowPriority";
stop;
}
+2 -2
View File
@@ -194,9 +194,9 @@ describe('generateScript', () => {
expect(script).toContain('addflag "\\\\Flagged";');
});
it('generates add_label as addflag $Label', () => {
it('generates add_label as addflag $label:Label', () => {
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$Important";');
expect(script).toContain('addflag "$label:Important";');
});
it('generates discard', () => {
+10 -5
View File
@@ -25,10 +25,14 @@ describe('parseScript', () => {
expect(result.rules).toEqual(rules);
});
it('returns isOpaque for missing metadata', () => {
it('parses external rules when no Bulwark metadata is present', () => {
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
expect(result.isOpaque).toBe(false);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].origin).toBe('external');
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
expect(result.externalRequires).toContain('fileinto');
});
it('returns isOpaque for corrupted JSON', () => {
@@ -90,9 +94,10 @@ describe('parseScript', () => {
expect(result.isOpaque).toBe(true);
});
it('returns isOpaque for empty string', () => {
it('treats an empty string as an empty, editable script (not opaque)', () => {
const result = parseScript('');
expect(result.isOpaque).toBe(true);
expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
});
describe('round-trip', () => {
+59 -9
View File
@@ -65,7 +65,7 @@ function generateActions(actions: FilterAction[]): string[] {
case 'star':
return 'addflag "\\\\Flagged";';
case 'add_label':
return `addflag "$${escapeString(action.value || '')}";`;
return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard':
return 'discard;';
case 'reject':
@@ -111,11 +111,47 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
}
}
return [...extensions].sort();
return [...extensions];
}
export function generateScript(rules: FilterRule[], vacation?: VacationSieveConfig): string {
const metadata: FilterMetadata = { version: 1, rules };
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
matchType: r.matchType,
conditions: r.conditions,
actions: r.actions,
stopProcessing: r.stopProcessing,
};
}
export interface GenerateOptions {
/**
* Require extensions used by external (non-Bulwark) rules that we must
* preserve in the top-level `require` directive. Duplicates with Bulwark's
* own requires are deduplicated.
*/
externalRequires?: string[];
}
export function generateScript(
rules: FilterRule[],
vacation?: VacationSieveConfig,
options: GenerateOptions = {},
): string {
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
const bulwarkRules: FilterRule[] = [];
const externalRules: FilterRule[] = [];
for (const r of rules) {
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
else bulwarkRules.push(r);
}
const metadata: FilterMetadata = {
version: 1,
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
};
if (vacation?.isEnabled) {
metadata.vacation = vacation;
}
@@ -127,9 +163,12 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('@metadata:end */');
lines.push('');
const requires = computeRequires(rules, vacation);
if (requires.length > 0) {
lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`);
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
if (allRequires.length > 0) {
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
}
if (vacation?.isEnabled) {
@@ -143,9 +182,9 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push(`vacation ${vacationParts.join(' ')};`);
}
const enabledRules = rules.filter(r => r.enabled);
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledRules) {
for (const rule of enabledBulwarkRules) {
if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
@@ -182,6 +221,17 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('}');
}
// Append preserved external rules verbatim. Each rawBlock already carries its
// own leading comments and trailing whitespace from the source script.
if (externalRules.length > 0) {
lines.push('');
lines.push('# --- External rules (managed outside Bulwark) ---');
for (const ext of externalRules) {
if (!ext.rawBlock) continue;
lines.push(ext.rawBlock.replace(/\s+$/, ''));
}
}
lines.push('');
return lines.join('\n');
}
+527 -38
View File
@@ -1,17 +1,33 @@
import type { FilterRule, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types';
import type {
FilterAction,
FilterCondition,
FilterComparator,
FilterConditionField,
FilterMetadata,
FilterRule,
VacationSieveConfig,
} from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
export interface ParseResult {
rules: FilterRule[];
isOpaque: boolean;
vacation?: VacationSieveConfig;
externalRequires: string[];
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true };
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */';
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
from: 'from',
to: 'to',
cc: 'cc',
subject: 'subject',
};
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
@@ -42,83 +58,556 @@ function isValidRule(rule: unknown): rule is FilterRule {
/**
* Detect Stalwart-generated vacation-only scripts (no metadata).
* These contain `vacation` command but no other filter logic we need to preserve.
*/
function detectVacationOnlyScript(content: string): ParseResult | null {
// Must contain a vacation command
if (!/\bvacation\b/.test(content)) return null;
// Strip requires, comments, and whitespace to see if only vacation remains
const stripped = content
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
.replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
// Strip quoted string *contents* before checking for structural keywords so that
// message body text like "if you need urgent help..." doesn't cause false rejection.
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
// Check there are no if/elsif/else filter blocks
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
// Must still have a vacation command after stripping boilerplate
if (!/\bvacation\b/.test(structural)) return null;
// Extract subject if present (:subject "...")
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
const subject = subjectMatch ? subjectMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : '';
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
// Extract the body text. Stalwart uses :mime format where the body is a full MIME
// message. Extract the plain text after the Content-Transfer-Encoding header.
// Handle both LF and CRLF line endings.
let textBody = '';
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
if (mimeBodyMatch) {
textBody = mimeBodyMatch[1].trim();
} else {
// Plain format: last quoted string argument in the vacation statement
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
const last = allQuoted[allQuoted.length - 1];
if (last) {
textBody = last[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (last) textBody = unescapeSieveString(last[1]);
}
return {
rules: [],
isOpaque: false,
vacation: { isEnabled: true, subject, textBody },
externalRequires: [],
};
}
function unescapeSieveString(s: string): string {
return s.replace(/\\(.)/g, '$1');
}
function skipStringLit(s: string, i: number): number {
i++;
while (i < s.length) {
if (s[i] === '\\') { i += 2; continue; }
if (s[i] === '"') return i + 1;
i++;
}
return i;
}
function skipHashComment(s: string, i: number): number {
while (i < s.length && s[i] !== '\n') i++;
return i;
}
function skipBlockComment(s: string, i: number): number {
const end = s.indexOf('*/', i + 2);
return end === -1 ? s.length : end + 2;
}
function skipStatement(s: string, i: number): number {
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === ';') return i + 1;
i++;
}
return i;
}
function skipBalanced(s: string, i: number, open: string, close: string): number {
let depth = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
if (c === open) { depth++; i++; continue; }
if (c === close) {
depth--;
i++;
if (depth === 0) return i;
continue;
}
i++;
}
return i;
}
function skipIfStatement(s: string, i: number): number {
// positioned after 'if' keyword; skip through condition expression and body braces
while (i < s.length && s[i] !== '{') {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
if (c === '#') { i = skipHashComment(s, i); continue; }
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
i++;
}
if (i >= s.length) return i;
return skipBalanced(s, i, '{', '}');
}
interface TopBlock {
kind: 'require' | 'if' | 'vacation' | 'other';
raw: string; // from start-of-leading-text to end of statement
statement: string; // the statement itself (no leading comments/whitespace)
startIdx: number;
endIdx: number;
}
function scanTopLevel(content: string): TopBlock[] {
const blocks: TopBlock[] = [];
let i = 0;
let segmentStart = 0;
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
blocks.push({
kind,
raw: content.slice(segmentStart, stmtEnd),
statement: content.slice(stmtStart, stmtEnd),
startIdx: segmentStart,
endIdx: stmtEnd,
});
segmentStart = stmtEnd;
};
while (i < content.length) {
// Skip whitespace
while (i < content.length && /\s/.test(content[i])) i++;
if (i >= content.length) break;
const c = content[i];
// Comments (stay attached to next block as leading text)
if (c === '#') { i = skipHashComment(content, i); continue; }
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
// Identifier
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
if (!m) { i++; continue; }
const ident = m[0];
const stmtStart = i;
i += ident.length;
if (ident === 'require') {
i = skipStatement(content, i);
consume('require', stmtStart, i);
} else if (ident === 'if') {
i = skipIfStatement(content, i);
consume('if', stmtStart, i);
} else if (ident === 'vacation') {
i = skipStatement(content, i);
consume('vacation', stmtStart, i);
} else {
i = skipStatement(content, i);
consume('other', stmtStart, i);
}
}
return blocks;
}
function extractRequireTokens(stmt: string): string[] {
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
return mSingle ? [mSingle[1]] : [];
}
/**
* Extract the last contiguous block of comments immediately preceding a
* statement comments separated from the statement by a blank line are not
* considered its leading commentary (they likely belong to the previous
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
*/
function lastCommentChunk(leading: string): string {
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
function detectOriginLabel(leading: string): string {
const chunk = lastCommentChunk(leading);
const lower = chunk.toLowerCase();
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
if (/nextcloud/.test(lower)) return 'Nextcloud';
if (/horde|ingo/.test(lower)) return 'Horde';
if (/kolab/.test(lower)) return 'Kolab';
if (/dovecot/.test(lower)) return 'Dovecot';
if (/thunderbird/.test(lower)) return 'Thunderbird';
return 'External';
}
function extractName(leading: string, fallback: string): string {
// Roundcube: "# rule:[Name]"
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
if (rc) return rc[1].trim();
// "# Rule: Name"
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
if (rr) return rr[1].trim();
// Last non-empty trimmed comment line
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
const last = lines[lines.length - 1];
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
return fallback;
}
function splitTopLevelComma(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === '"') { i = skipStringLit(s, i); continue; }
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
if (c === ',' && depth === 0) {
parts.push(s.slice(start, i));
start = i + 1;
}
i++;
}
parts.push(s.slice(start));
return parts.map(p => p.trim()).filter(Boolean);
}
function splitStatements(body: string): string[] {
const stmts: string[] = [];
let start = 0;
let i = 0;
while (i < body.length) {
const c = body[i];
if (c === '"') { i = skipStringLit(body, i); continue; }
if (c === '#') { i = skipHashComment(body, i); continue; }
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
if (c === ';') {
stmts.push(body.slice(start, i));
start = i + 1;
}
i++;
}
const tail = body.slice(start).trim();
if (tail) stmts.push(tail);
return stmts.map(s => s.trim()).filter(Boolean);
}
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
const lc = name.toLowerCase();
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
return { field: 'header', headerName: name };
}
function parseAtom(raw: string): FilterCondition | null {
let s = raw.trim();
let negated = false;
if (/^not\b/.test(s)) {
negated = true;
s = s.replace(/^not\s*/, '').trim();
if (s.startsWith('(') && s.endsWith(')')) {
s = s.slice(1, -1).trim();
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
} else {
// :matches — distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
}
const cond: FilterCondition = { field, comparator, value };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
if (m) {
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
}
return null;
}
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
const s = raw.trim();
if (!s) return null;
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
let matchType: 'all' | 'any' = 'all';
let inner: string;
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
else inner = s;
const parts = splitTopLevelComma(inner);
const conditions: FilterCondition[] = [];
for (const part of parts) {
const atom = parseAtom(part);
if (!atom) return null;
conditions.push(atom);
}
return { matchType, conditions };
}
function parseAction(raw: string): FilterAction | null {
const s = raw.trim();
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) {
const flag = unescapeSieveString(m[1]);
if (flag === '\\Seen') return { type: 'mark_read' };
if (flag === '\\Flagged') return { type: 'star' };
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
return null;
}
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
if (/^discard$/.test(s)) return { type: 'discard' };
if (/^keep$/.test(s)) return { type: 'keep' };
if (/^stop$/.test(s)) return { type: 'stop' };
return null;
}
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
const stmt = block.statement;
const afterIf = stmt.replace(/^if\s+/, '');
const braceIdx = afterIf.indexOf('{');
const lastBraceIdx = afterIf.lastIndexOf('}');
if (braceIdx === -1 || lastBraceIdx === -1 || lastBraceIdx < braceIdx) return null;
const condStr = afterIf.slice(0, braceIdx).trim();
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
const cond = parseCondition(condStr);
if (!cond || cond.conditions.length === 0) return null;
const actionStmts = splitStatements(bodyStr);
const actions: FilterAction[] = [];
for (const st of actionStmts) {
const a = parseAction(st);
if (!a) return null;
actions.push(a);
}
if (actions.length === 0) return null;
let stopProcessing = false;
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
const hasNonStop = actions.some(a => a.type !== 'stop');
if (hasNonStop) {
stopProcessing = true;
actions.pop();
}
}
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `Rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: cond.matchType,
conditions: cond.conditions,
actions,
stopProcessing,
origin: 'external',
originLabel,
rawBlock: block.raw,
};
}
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
const originLabel = detectOriginLabel(leading);
const name = extractName(leading, `External rule ${index + 1}`);
return {
id: `${idPrefix}-${index}`,
name,
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
origin: 'opaque',
originLabel,
rawBlock: block.raw,
};
}
function parseExternalRules(
content: string,
idPrefix: string,
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
const blocks = scanTopLevel(content);
const rules: FilterRule[] = [];
const externalRequires: string[] = [];
let index = 0;
let sawAnyStatement = false;
for (const block of blocks) {
if (block.kind === 'require') {
sawAnyStatement = true;
for (const tok of extractRequireTokens(block.statement)) {
if (!externalRequires.includes(tok)) externalRequires.push(tok);
}
continue;
}
if (block.kind === 'if') {
sawAnyStatement = true;
const rule = parseIfBlockToRule(block, idPrefix, index);
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
index++;
continue;
}
// vacation/other: treat as opaque preserved block
sawAnyStatement = true;
rules.push(makeOpaqueRule(block, idPrefix, index));
index++;
}
return { rules, externalRequires, hasContent: sawAnyStatement };
}
export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx === -1) {
// No metadata — check if it's a Stalwart vacation-only script
return detectVacationOnlyScript(content) || OPAQUE;
if (beginIdx !== -1) {
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
// Scan the portion AFTER the metadata block for external rules.
const afterMetadata = content.slice(endIdx + METADATA_END.length);
const external = parseExternalRules(afterMetadata, 'ext');
// Parsed bulwark rules intentionally omit an explicit `origin` field so
// round-trip equality with metadata-only callers holds. Absence of origin
// is treated as 'bulwark' everywhere downstream.
const bulwarkRules: FilterRule[] = metadata.rules;
// Exclude requires and the vacation line that we emit ourselves from externalRequires.
const externalRequires = external.externalRequires;
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
const filteredExternal = external.rules.filter(r => {
const raw = r.rawBlock || '';
if (/#\s*Rule:\s*/.test(raw) && r.origin === 'external') {
// If the name matches a bulwark rule name exactly, treat as bulwark-emitted
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
const name = match ? match[1].trim() : '';
if (bulwarkRules.some(b => b.name === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
return true;
});
return {
rules: [...bulwarkRules, ...filteredExternal],
isOpaque: false,
vacation: metadata.vacation,
externalRequires,
};
}
const endIdx = content.indexOf(METADATA_END, beginIdx);
if (endIdx === -1) return OPAQUE;
// No metadata — check vacation-only first
const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
const jsonStart = beginIdx + METADATA_BEGIN.length;
const jsonStr = content.slice(jsonStart, endIdx).trim();
// Try to parse the whole script as external rules.
const external = parseExternalRules(content, 'ext');
let metadata: FilterMetadata;
try {
metadata = JSON.parse(jsonStr);
} catch (e) {
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
if (!external.hasContent) {
// Entirely empty or whitespace/comments only — treat as empty, editable.
return { rules: [], isOpaque: false, externalRequires: [] };
}
if (!metadata || metadata.version !== 1) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
// If at least one block parsed into a structured rule, expose them as external.
const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || external.rules.length > 0) {
return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
}
return { rules: metadata.rules, isOpaque: false, vacation: metadata.vacation };
return OPAQUE;
}
+170
View File
@@ -0,0 +1,170 @@
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export interface UnifiedAccountClient {
accountId: string;
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
}
export interface UnifiedFetchResult {
emails: Email[];
total: number;
hasMore: boolean;
errors: Map<string, string>; // accountId -> error message
}
export interface UnifiedMailboxCounts {
role: UnifiedMailboxRole;
unreadEmails: number;
totalEmails: number;
}
const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
];
/**
* Finds the first mailbox matching the given role.
*/
export function findMailboxByRole(
mailboxes: Mailbox[],
role: UnifiedMailboxRole,
): Mailbox | undefined {
return mailboxes.find((m) => m.role === role);
}
/**
* Fetches emails from all accounts for a given unified role, merges and sorts
* them by receivedAt descending. Per-account failures are collected in the
* errors map while successful results are still returned.
*/
export async function fetchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
// Build one fetch task per account, wrapping each in a catch so we can
// track per-account errors while still using Promise.allSettled.
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(
async (account): Promise<AccountResult> => {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
try {
const result = await account.client.getEmails(
mailbox.id,
undefined,
limit,
position,
);
return { account, result };
} catch (err) {
errors.set(
account.accountId,
err instanceof Error ? err.message : String(err),
);
return null;
}
},
);
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
// Decorate each email with the source account info.
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) {
anyHasMore = true;
}
}
// Sort merged emails by receivedAt descending.
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return {
emails: mergedEmails,
total: totalSum,
hasMore: anyHasMore,
errors,
};
}
/**
* Aggregates unread and total email counts across all accounts for each
* unified mailbox role. Only includes roles that exist in at least one account.
*/
export function fetchUnifiedMailboxCounts(
accounts: UnifiedAccountClient[],
): UnifiedMailboxCounts[] {
const counts: UnifiedMailboxCounts[] = [];
for (const role of ALL_UNIFIED_ROLES) {
let unreadEmails = 0;
let totalEmails = 0;
let found = false;
for (const account of accounts) {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (mailbox) {
found = true;
unreadEmails += mailbox.unreadEmails;
totalEmails += mailbox.totalEmails;
}
}
if (found) {
counts.push({ role, unreadEmails, totalEmails });
}
}
return counts;
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
*/
export function getUnifiedRoles(
accounts: UnifiedAccountClient[],
): UnifiedMailboxRole[] {
const roles: UnifiedMailboxRole[] = [];
for (const role of ALL_UNIFIED_ROLES) {
for (const account of accounts) {
if (findMailboxByRole(account.mailboxes, role)) {
roles.push(role);
break;
}
}
}
return roles;
}
+35 -1
View File
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) {
@@ -380,6 +381,39 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return rootMailboxes;
}
/**
* Builds virtual MailboxNode entries for unified mailbox roles with aggregated counts.
*/
export function buildUnifiedMailboxNodes(
counts: Array<{ role: UnifiedMailboxRole; unreadEmails: number; totalEmails: number }>,
): MailboxNode[] {
return counts.map((count) => ({
id: UNIFIED_MAILBOX_IDS[count.role],
name: count.role, // Display name is handled by i18n in the component
role: count.role,
parentId: undefined,
sortOrder: 0,
totalEmails: count.totalEmails,
unreadEmails: count.unreadEmails,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
children: [],
depth: 0,
}));
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Wichtig"
},
"unified_inbox": "Gemeinsamer Posteingang",
"unified_sent": "Alle Gesendet",
"unified_drafts": "Alle Entwürfe",
"unified_trash": "Alle Papierkörbe",
"unified_archive": "Alle Archive",
"unified_junk": "Alle Spam",
"all_accounts": "Alle Konten",
"expand": "Erweitern",
"collapse": "Einklappen",
"expand_tooltip": "Erweitern",
@@ -657,7 +664,7 @@
"filters": "Filter",
"templates": "Vorlagen",
"folders": "Ordner",
"keywords": "Schlüsselwörter",
"keywords": "Labels",
"security": "Sicherheit",
"encryption": "Verschlüsselung",
"files": "Dateien",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Konto-Avatare in der Navigationsleiste anzeigen",
"description": "Individuelle Kontokreise am unteren Rand der Navigationsleiste für schnelles Umschalten anzeigen, mit einer Abmelde-Schaltfläche darunter."
},
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
}
},
"keywords": {
"title": "E-Mail-Schlüsselwörter",
"description": "Definieren Sie Schlüsselwörter (Labels/Tags) zum Organisieren Ihrer E-Mails mit Farben.",
"add_keyword": "Schlüsselwort hinzufügen",
"title": "E-Mail-Labels",
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
"add_keyword": "Label hinzufügen",
"reset_defaults": "Auf Standard zurücksetzen",
"label_field": "Anzeigename",
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
"id_field": "Schlüsselwort-ID",
"id_field": "Label-ID",
"id_placeholder": "z.B. arbeit, privat",
"color_field": "Farbe",
"id_exists": "Diese Schlüsselwort-ID existiert bereits",
"edit": "Schlüsselwort bearbeiten",
"delete": "Schlüsselwort löschen",
"id_exists": "Diese Label-ID existiert bereits",
"edit": "Label bearbeiten",
"delete": "Label löschen",
"save": "Speichern",
"add": "Hinzufügen",
"cancel": "Abbrechen",
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…",
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden"
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
},
"notifications": {
"test_sound": "Benachrichtigungston testen",
@@ -923,7 +934,7 @@
"star": "Markieren / Markierung aufheben",
"mark_read": "Als gelesen / ungelesen markieren",
"archive": "Archivieren",
"tag": "Schlagwort",
"tag": "Label",
"spam": "Als Spam markieren",
"none_selected": "Keine Aktionen ausgewählt",
"mode_label": "Anzeigemodus",
@@ -1350,7 +1361,7 @@
"reject_message": "Ablehnungsnachricht",
"reject_placeholder": "Ihre E-Mail wurde abgelehnt",
"label_name": "Label-Name",
"label_placeholder": "z.B. wichtig",
"label_placeholder": "Label auswählen",
"header_name": "Header-Name",
"header_placeholder": "z.B. X-Mailing-List",
"size_bytes": "Größe in Bytes",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
"quota_title": "Speichernutzung",
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
},
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
}
}
+37 -16
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Important"
},
"unified_inbox": "Unified Inbox",
"unified_sent": "All Sent",
"unified_drafts": "All Drafts",
"unified_trash": "All Trash",
"unified_archive": "All Archive",
"unified_junk": "All Junk",
"all_accounts": "All Accounts",
"expand": "Expand",
"collapse": "Collapse",
"expand_tooltip": "Expand",
@@ -120,6 +127,7 @@
"demo_tour": "Tour",
"tags": "Tags",
"folders": "Folders",
"shared": "Shared",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
@@ -657,7 +665,7 @@
"filters": "Filters",
"templates": "Templates",
"folders": "Folders",
"keywords": "Keywords",
"keywords": "Tags",
"security": "Security",
"files": "Files",
"contacts": "Contacts",
@@ -722,26 +730,34 @@
"show_rail_account_list": {
"label": "Show Account Avatars on Navigation Rail",
"description": "Display individual account circles at the bottom of the navigation rail for quick switching, with a sign-out button below."
},
"unified_mailbox": {
"label": "Unified Mailbox",
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
},
"colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons",
"description": "Tint folder and tag icons by type (blue Inbox, red Junk, green Sent, etc.). Disable for a monochrome sidebar."
}
},
"keywords": {
"title": "Email Keywords",
"description": "Define keywords (labels/tags) to organize your emails with colors. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Keyword",
"title": "Email Tags",
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Tag",
"reset_defaults": "Reset to Defaults",
"label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent",
"id_field": "Keyword ID",
"id_field": "Tag ID",
"id_placeholder": "e.g. work, personal",
"color_field": "Color",
"id_exists": "This keyword ID already exists",
"edit": "Edit keyword",
"delete": "Delete keyword",
"id_exists": "This tag ID already exists",
"edit": "Edit tag",
"delete": "Delete tag",
"save": "Save",
"add": "Add",
"cancel": "Cancel",
"migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails"
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails"
},
"notifications": {
"test_sound": "Test notification sound",
@@ -1337,7 +1353,7 @@
"forward": "Forward to",
"mark_read": "Mark as read",
"star": "Star message",
"add_label": "Add label",
"add_label": "Add tag",
"discard": "Discard (delete silently)",
"reject": "Reject with message",
"keep": "Keep in inbox",
@@ -1349,8 +1365,8 @@
"forward_placeholder": "email@example.com",
"reject_message": "Rejection message",
"reject_placeholder": "Your email has been rejected",
"label_name": "Label name",
"label_placeholder": "e.g., important",
"label_name": "Tag name",
"label_placeholder": "Select tag",
"header_name": "Header name",
"header_placeholder": "e.g., X-Mailing-List",
"size_bytes": "Size in bytes",
@@ -1400,7 +1416,9 @@
"rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}"
}
},
"origin_external": "External",
"managed_by_tooltip": "Managed by {source}. Edit it in that app, or use the raw Sieve editor."
},
"templates": {
"title": "Email Templates",
@@ -1524,8 +1542,8 @@
"delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Label",
"remove_color": "Remove Label",
"color_tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
@@ -2533,5 +2551,8 @@
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
"quota_title": "Storage usage",
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
},
"unified_mailbox": {
"search_unavailable": "Search is not available in the unified view"
}
}
+23 -9
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importante"
},
"unified_inbox": "Bandeja unificada",
"unified_sent": "Todos los enviados",
"unified_drafts": "Todos los borradores",
"unified_trash": "Todas las papeleras",
"unified_archive": "Todos los archivos",
"unified_junk": "Todo el spam",
"all_accounts": "Todas las cuentas",
"expand": "Expandir",
"collapse": "Contraer",
"expand_tooltip": "Expandir",
@@ -657,7 +664,7 @@
"filters": "Filtros",
"templates": "Plantillas",
"folders": "Carpetas",
"keywords": "Palabras clave",
"keywords": "Etiquetas",
"security": "Seguridad",
"encryption": "Cifrado",
"files": "Archivos",
@@ -722,21 +729,25 @@
"show_rail_account_list": {
"label": "Mostrar avatares de cuentas en la barra de navegación",
"description": "Mostrar círculos de cuentas individuales en la parte inferior de la barra de navegación para un cambio rápido, con un botón de cerrar sesión debajo."
},
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
}
},
"keywords": {
"title": "Palabras clave de correo",
"description": "Define palabras clave (etiquetas) para organizar tus correos con colores.",
"add_keyword": "Añadir palabra clave",
"title": "Etiquetas de correo",
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
"add_keyword": "Añadir etiqueta",
"reset_defaults": "Restablecer valores predeterminados",
"label_field": "Nombre para mostrar",
"label_placeholder": "ej. Trabajo, Personal, Urgente",
"id_field": "ID de palabra clave",
"id_field": "ID de etiqueta",
"id_placeholder": "ej. trabajo, personal",
"color_field": "Color",
"id_exists": "Esta ID de palabra clave ya existe",
"edit": "Editar palabra clave",
"delete": "Eliminar palabra clave",
"id_exists": "Este ID de etiqueta ya existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"save": "Guardar",
"add": "Añadir",
"cancel": "Cancelar",
@@ -1350,7 +1361,7 @@
"reject_message": "Mensaje de rechazo",
"reject_placeholder": "Su correo ha sido rechazado",
"label_name": "Nombre de la etiqueta",
"label_placeholder": "ej. importante",
"label_placeholder": "Seleccionar etiqueta",
"header_name": "Nombre del encabezado",
"header_placeholder": "ej. X-Mailing-List",
"size_bytes": "Tamaño en bytes",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
"quota_title": "Uso de almacenamiento",
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
},
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
}
}
+27 -13
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Important"
},
"unified_inbox": "Boîte de réception unifiée",
"unified_sent": "Tous les envoyés",
"unified_drafts": "Tous les brouillons",
"unified_trash": "Toutes les corbeilles",
"unified_archive": "Toutes les archives",
"unified_junk": "Tous les indésirables",
"all_accounts": "Tous les comptes",
"expand": "Développer",
"collapse": "Réduire",
"expand_tooltip": "Développer",
@@ -657,7 +664,7 @@
"filters": "Filtres",
"templates": "Modèles",
"folders": "Dossiers",
"keywords": "Mots-clés",
"keywords": "Étiquettes",
"security": "Sécurité",
"encryption": "Chiffrement",
"files": "Fichiers",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Afficher les avatars de compte sur la barre de navigation",
"description": "Afficher les cercles de comptes individuels en bas de la barre de navigation pour un changement rapide, avec un bouton de déconnexion en dessous."
},
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
}
},
"keywords": {
"title": "Mots-clés des e-mails",
"description": "Définissez des mots-clés (étiquettes) pour organiser vos e-mails avec des couleurs.",
"add_keyword": "Ajouter un mot-clé",
"title": "Étiquettes de messagerie",
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
"add_keyword": "Ajouter une étiquette",
"reset_defaults": "Réinitialiser par défaut",
"label_field": "Nom d'affichage",
"label_placeholder": "ex. Travail, Personnel, Urgent",
"id_field": "ID du mot-clé",
"id_field": "ID d'étiquette",
"id_placeholder": "ex. travail, personnel",
"color_field": "Couleur",
"id_exists": "Cet ID de mot-clé existe déjà",
"edit": "Modifier le mot-clé",
"delete": "Supprimer le mot-clé",
"id_exists": "Cet ID d'étiquette existe déjà",
"edit": "Éditer l'étiquette",
"delete": "Supprimer l'étiquette",
"save": "Enregistrer",
"add": "Ajouter",
"cancel": "Annuler",
"migrating": "Mise à jour du mot-clé sur les e-mails existants…",
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants"
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
},
"notifications": {
"test_sound": "Tester le son de notification",
@@ -1337,7 +1348,7 @@
"forward": "Transférer à",
"mark_read": "Marquer comme lu",
"star": "Marquer d'une étoile",
"add_label": "Ajouter un libellé",
"add_label": "Ajouter une étiquette",
"discard": "Supprimer silencieusement",
"reject": "Rejeter avec un message",
"keep": "Conserver dans la boîte de réception",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@exemple.com",
"reject_message": "Message de rejet",
"reject_placeholder": "Votre e-mail a été rejeté",
"label_name": "Nom du libellé",
"label_placeholder": "ex. important",
"label_name": "Nom de l'étiquette",
"label_placeholder": "Sélectionner une étiquette",
"header_name": "Nom de l'en-tête",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Taille en octets",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
"quota_title": "Utilisation du stockage",
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
},
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
}
}
+25 -11
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importanti"
},
"unified_inbox": "Posta in arrivo unificata",
"unified_sent": "Tutti gli inviati",
"unified_drafts": "Tutte le bozze",
"unified_trash": "Tutti i cestini",
"unified_archive": "Tutti gli archivi",
"unified_junk": "Tutto lo spam",
"all_accounts": "Tutti gli account",
"expand": "Espandi",
"collapse": "Comprimi",
"expand_tooltip": "Espandi",
@@ -657,7 +664,7 @@
"filters": "Filtri",
"templates": "Modelli",
"folders": "Cartelle",
"keywords": "Parole chiave",
"keywords": "Etichette",
"security": "Sicurezza",
"encryption": "Cifratura",
"files": "File",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Mostra avatar account nella barra di navigazione",
"description": "Visualizza i cerchi degli account individuali nella parte inferiore della barra di navigazione per un cambio rapido, con un pulsante di disconnessione sotto."
},
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
}
},
"keywords": {
"title": "Parole chiave e-mail",
"description": "Definisci parole chiave (etichette) per organizzare le tue e-mail con colori.",
"add_keyword": "Aggiungi parola chiave",
"title": "Etichette e-mail",
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
"add_keyword": "Aggiungi etichetta",
"reset_defaults": "Ripristina predefiniti",
"label_field": "Nome visualizzato",
"label_placeholder": "es. Lavoro, Personale, Urgente",
"id_field": "ID parola chiave",
"id_field": "ID etichetta",
"id_placeholder": "es. lavoro, personale",
"color_field": "Colore",
"id_exists": "Questo ID parola chiave esiste già",
"edit": "Modifica parola chiave",
"delete": "Elimina parola chiave",
"id_exists": "Questo ID etichetta esiste già",
"edit": "Modifica etichetta",
"delete": "Elimina etichetta",
"save": "Salva",
"add": "Aggiungi",
"cancel": "Annulla",
"migrating": "Aggiornamento parola chiave sulle email esistenti…",
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti"
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
},
"notifications": {
"test_sound": "Testa il suono di notifica",
@@ -1350,7 +1361,7 @@
"reject_message": "Messaggio di rifiuto",
"reject_placeholder": "La tua email è stata rifiutata",
"label_name": "Nome dell'etichetta",
"label_placeholder": "es. importante",
"label_placeholder": "Seleziona etichetta",
"header_name": "Nome dell'intestazione",
"header_placeholder": "es. X-Mailing-List",
"size_bytes": "Dimensione in byte",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
"quota_title": "Utilizzo dello spazio",
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
},
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
}
}
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "迷惑メール",
"important": "重要"
},
"unified_inbox": "統合受信トレイ",
"unified_sent": "すべての送信済み",
"unified_drafts": "すべての下書き",
"unified_trash": "すべてのゴミ箱",
"unified_archive": "すべてのアーカイブ",
"unified_junk": "すべての迷惑メール",
"all_accounts": "すべてのアカウント",
"expand": "展開",
"collapse": "折りたたむ",
"expand_tooltip": "展開",
@@ -657,7 +664,7 @@
"filters": "フィルター",
"templates": "テンプレート",
"folders": "フォルダー",
"keywords": "キーワード",
"keywords": "ラベル",
"security": "セキュリティ",
"encryption": "暗号化",
"files": "ファイル",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
},
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
}
},
"keywords": {
"title": "メールキーワード",
"description": "色でメールを整理するためのキーワード(ラベル/タグ)を定義します。",
"add_keyword": "キーワードを追加",
"title": "メールラベル",
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
"add_keyword": "ラベルを追加",
"reset_defaults": "デフォルトに戻す",
"label_field": "表示名",
"label_placeholder": "例:仕事、個人、緊急",
"id_field": "キーワードID",
"id_field": "ラベルID",
"id_placeholder": "例:work、personal",
"color_field": "色",
"id_exists": "このキーワードIDは既に存在します",
"edit": "キーワードを編集",
"delete": "キーワードを削除",
"id_exists": "このラベルIDは既に存在します",
"edit": "ラベルを編集",
"delete": "ラベルを削除",
"save": "保存",
"add": "追加",
"cancel": "キャンセル",
"migrating": "既存のメールでキーワードを更新中…",
"migration_error": "既存のメールでのキーワード更新に失敗しました"
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました"
},
"notifications": {
"test_sound": "通知音をテスト",
@@ -923,7 +934,7 @@
"star": "スター付け / 解除",
"mark_read": "既読 / 未読にする",
"archive": "アーカイブ",
"tag": "タグ",
"tag": "ラベル",
"spam": "スパムとしてマーク",
"none_selected": "アクションが選択されていません",
"mode_label": "表示モード",
@@ -1350,7 +1361,7 @@
"reject_message": "拒否メッセージ",
"reject_placeholder": "あなたのメールは拒否されました",
"label_name": "ラベル名",
"label_placeholder": "例:重要",
"label_placeholder": "ラベルを選択",
"header_name": "ヘッダー名",
"header_placeholder": "例:X-Mailing-List",
"size_bytes": "サイズ(バイト)",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
"quota_title": "ストレージ使用量",
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
},
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
}
}
+29 -15
View File
@@ -104,6 +104,13 @@
"spam": "스팸함",
"important": "중요 편지함"
},
"unified_inbox": "통합 받은편지함",
"unified_sent": "모든 보낸편지함",
"unified_drafts": "모든 임시보관함",
"unified_trash": "모든 휴지통",
"unified_archive": "모든 보관함",
"unified_junk": "모든 스팸함",
"all_accounts": "모든 계정",
"expand": "펼치기",
"collapse": "접기",
"expand_tooltip": "펼치기",
@@ -657,7 +664,7 @@
"filters": "필터",
"templates": "템플릿",
"folders": "폴더",
"keywords": "키워드",
"keywords": "태그",
"security": "보안",
"files": "파일",
"contacts": "연락처",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "내비게이션 바에 계정 아바타 표시",
"description": "내비게이션 바 아래에 계정 프로필을 표시해서 빠르게 전환할 수 있어요."
},
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
}
},
"keywords": {
"title": "이메일 키워드",
"description": "이메일을 분류할 키워드(라벨/태그)설정해 보세요. 설정한 키워드는 서버에 저장돼요.",
"add_keyword": "키워드 추가",
"title": "이메일 태그",
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
"add_keyword": "태그 추가",
"reset_defaults": "기본값으로 초기화",
"label_field": "표시 이름",
"label_placeholder": "예: 업무, 개인, 긴급",
"id_field": "키워드 ID",
"id_field": "태그 ID",
"id_placeholder": "예: work, personal",
"color_field": "색상",
"id_exists": "이미 존재하는 키워드 ID예요",
"edit": "키워드 수정",
"delete": "키워드 삭제",
"id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "태그 편집",
"delete": "태그 삭제",
"save": "저장",
"add": "추가",
"cancel": "취소",
"migrating": "기존 이메일의 키워드를 업데이트하는 중...",
"migration_error": "기존 이메일의 키워드를 업데이트하지 못했어요"
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
},
"notifications": {
"test_sound": "알림음 테스트",
@@ -1337,7 +1348,7 @@
"forward": "다음으로 전달",
"mark_read": "읽은 상태로 표시",
"star": "별표 달기",
"add_label": "라벨(태그) 추가",
"add_label": "태그 추가",
"discard": "삭제 (조용히 지움)",
"reject": "메시지와 함께 수신 거부",
"keep": "받은편지함에 유지",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@example.com",
"reject_message": "거부 메시지",
"reject_placeholder": "메일 수신이 거부되었습니다",
"label_name": "라벨 이름",
"label_placeholder": "예: important",
"label_name": "태그 이름",
"label_placeholder": "태그 선택",
"header_name": "헤더 이름",
"header_placeholder": "예: X-Mailing-List",
"size_bytes": "크기 (바이트)",
@@ -1524,8 +1535,8 @@
"delete": "삭제",
"mark_as_spam": "스팸 신고",
"not_spam": "정상 메일",
"color_tag": "라벨 지정",
"remove_color": "라벨 제거",
"color_tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
},
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "현재 데모 모드예요. 모든 작업은 브라우저 안에서만 이뤄집니다. 언제든 '초기화'를 누르면 처음의 깨끗한 샘플 데이터로 돌아가요.",
"quota_title": "저장 공간 사용량",
"quota_desc": "편지함 용량을 여기서 확인하세요. 공간을 많이 쓸수록 원이 점점 채워질 거예요."
},
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
}
}
+29 -15
View File
@@ -104,6 +104,13 @@
"spam": "Mēstules",
"important": "Svarīgi"
},
"unified_inbox": "Apvienotā iesūtne",
"unified_sent": "Visi nosūtītie",
"unified_drafts": "Visi melnraksti",
"unified_trash": "Visas mēstules",
"unified_archive": "Visi arhīvi",
"unified_junk": "Viss mēstules",
"all_accounts": "Visi konti",
"expand": "Izvērst",
"collapse": "Sairt",
"expand_tooltip": "Izvērst",
@@ -657,7 +664,7 @@
"filters": "Filtri",
"templates": "Veidnes",
"folders": "Mapes",
"keywords": "Atslēgvārdi",
"keywords": "Tagi",
"security": "Drošība",
"files": "Faili",
"contacts": "Kontakti",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Rādīt kontu avatarus navigācijas joslā",
"description": "Rādīt kontu apļus navigācijas joslas apakšā ātrai pārslēgšanai."
},
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
}
},
"keywords": {
"title": "Vēstuļu atslēgvārdi",
"description": "Definējiet atslēgvārdus (etiķetes/tagus) vēstuļu organizēšanai ar krāsām. Tie tiek saglabāti serverī kā JMAP atslēgvārdi.",
"add_keyword": "Pievienot atslēgvārdu",
"title": "E-pasta tagi",
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
"add_keyword": "Pievienot tagu",
"reset_defaults": "Atiestatīt noklusējumu",
"label_field": "Redzamais nosaukums",
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
"id_field": "Atslēgvārda identifikators",
"id_field": "Taga identifikators",
"id_placeholder": "piem., darbs, personigi",
"color_field": "Krāsa",
"id_exists": "Šāds atslēgvārda identifikators jau eksistē",
"edit": "Rediģēt atslēgvārdu",
"delete": "Dzēst atslēgvārdu",
"id_exists": "Šāds taga identifikators jau pastāv",
"edit": "Rediģēt tagu",
"delete": "Dzēst tagu",
"save": "Saglabāt",
"add": "Pievienot",
"cancel": "Atcelt",
"migrating": "Atjaunina atslēgvārdu esošajās vēstulēs...",
"migration_error": "Neizdevās atjaunināt atslēgvārdu esošajās vēstulēs"
"migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
},
"notifications": {
"test_sound": "Pārbaudīt paziņojuma skaņu",
@@ -1337,7 +1348,7 @@
"forward": "Pārsūtīt uz",
"mark_read": "Atzīmēt kā izlasītu",
"star": "Pievienot zvaigznīti",
"add_label": "Pievienot etiķeti",
"add_label": "Pievienot tagu",
"discard": "Dzēst (bez paziņojuma)",
"reject": "Noraidīt ar ziņojumu",
"keep": "Atstāt iesūtnē",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "lietotajs@piemers.lv",
"reject_message": "Noraidīšanas ziņojums",
"reject_placeholder": "Jūsu e-pasts tika noraidīts",
"label_name": "Etiķetes nosaukums",
"label_placeholder": "piem., svarigi",
"label_name": "Taga nosaukums",
"label_placeholder": "Izvēlēties tagu",
"header_name": "Galvenes nosaukums",
"header_placeholder": "piem., X-Mailing-List",
"size_bytes": "Izmērs baitos",
@@ -1524,8 +1535,8 @@
"delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule",
"color_tag": "Etiķete",
"remove_color": "Noņemt etiķeti",
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
},
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Jūs esat demo režīmā — visi dati paliek jūsu pārlūkā. Nospiediet «Atiestatīt demo», lai sāktu no jauna.",
"quota_title": "Krātuves izmantošana",
"quota_desc": "Sekojiet līdzi savas pastkastes aizpildījumam šeit."
},
"unified_mailbox": {
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
}
}
+25 -11
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Belangrijk"
},
"unified_inbox": "Gecombineerd postvak IN",
"unified_sent": "Alle verzonden",
"unified_drafts": "Alle concepten",
"unified_trash": "Alle prullenbakken",
"unified_archive": "Alle archieven",
"unified_junk": "Alle spam",
"all_accounts": "Alle accounts",
"expand": "Uitklappen",
"collapse": "Inklappen",
"expand_tooltip": "Uitklappen",
@@ -657,7 +664,7 @@
"filters": "Filters",
"templates": "Sjablonen",
"folders": "Mappen",
"keywords": "Sleutelwoorden",
"keywords": "Labels",
"security": "Beveiliging",
"encryption": "Versleuteling",
"files": "Bestanden",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Accountavatars tonen op navigatiebalk",
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder."
},
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
}
},
"keywords": {
"title": "E-mail trefwoorden",
"description": "Definieer trefwoorden (labels/tags) om uw e-mails met kleuren te organiseren.",
"add_keyword": "Trefwoord toevoegen",
"title": "E-maillabels",
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"add_keyword": "Label toevoegen",
"reset_defaults": "Standaardwaarden herstellen",
"label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
"id_field": "Trefwoord-ID",
"id_field": "Label-ID",
"id_placeholder": "bijv. werk, persoonlijk",
"color_field": "Kleur",
"id_exists": "Dit trefwoord-ID bestaat al",
"edit": "Trefwoord bewerken",
"delete": "Trefwoord verwijderen",
"id_exists": "Deze label-ID bestaat al",
"edit": "Label bewerken",
"delete": "Label verwijderen",
"save": "Opslaan",
"add": "Toevoegen",
"cancel": "Annuleren",
"migrating": "Trefwoord bijwerken op bestaande e-mails…",
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails"
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
},
"notifications": {
"test_sound": "Meldingsgeluid testen",
@@ -1350,7 +1361,7 @@
"reject_message": "Afwijzingsbericht",
"reject_placeholder": "Uw e-mail is afgewezen",
"label_name": "Labelnaam",
"label_placeholder": "bijv. belangrijk",
"label_placeholder": "Label kiezen",
"header_name": "Headernaam",
"header_placeholder": "bijv. X-Mailing-List",
"size_bytes": "Grootte in bytes",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
"quota_title": "Opslaggebruik",
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
},
"unified_mailbox": {
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
}
}
+25 -11
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Ważne"
},
"unified_inbox": "Wspólne odebrane",
"unified_sent": "Wszystkie wysłane",
"unified_drafts": "Wszystkie szkice",
"unified_trash": "Wszystkie kosze",
"unified_archive": "Wszystkie archiwa",
"unified_junk": "Wszystkie spam",
"all_accounts": "Wszystkie konta",
"expand": "Rozwiń",
"collapse": "Zwiń",
"expand_tooltip": "Rozwiń",
@@ -657,7 +664,7 @@
"filters": "Filtry",
"templates": "Szablony",
"folders": "Foldery",
"keywords": "Słowa kluczowe",
"keywords": "Etykiety",
"security": "Bezpieczeństwo",
"files": "Pliki",
"contacts": "Kontakty",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Pokazuj awatary kont na pasku nawigacyjnym",
"description": "Wyświetlaj osobne ikony kont na dole paska nawigacyjnego, aby szybko się przełączać, z przyciskiem wylogowania poniżej."
},
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
}
},
"keywords": {
"title": "Słowa kluczowe wiadomości e-mail",
"description": "Zdefiniuj słowa kluczowe (etykiety/tagi), aby porządkować wiadomości e-mail kolorami. Są one przechowywane na serwerze jako słowa kluczowe JMAP.",
"add_keyword": "Dodaj słowo kluczowe",
"title": "Etykiety e-mail",
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
"add_keyword": "Dodaj etykietę",
"reset_defaults": "Przywróć domyślne",
"label_field": "Nazwa wyświetlana",
"label_placeholder": "np. Praca, Osobiste, Pilne",
"id_field": "Identyfikator słowa kluczowego",
"id_field": "ID etykiety",
"id_placeholder": "np. praca, osobiste",
"color_field": "Kolor",
"id_exists": "Ten identyfikator słowa kluczowego już istnieje",
"edit": "Edytuj słowo kluczowe",
"delete": "Usuń słowo kluczowe",
"id_exists": "Ten ID etykiety już istnieje",
"edit": "Edytuj etykietę",
"delete": "Usuń etykietę",
"save": "Zapisz",
"add": "Dodaj",
"cancel": "Anuluj",
"migrating": "Aktualizowanie słowa kluczowego w istniejących wiadomościach e-mail…",
"migration_error": "Nie udało się zaktualizować słowa kluczowego w istniejących wiadomościach e-mail"
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
},
"notifications": {
"test_sound": "Przetestuj dźwięk powiadomienia",
@@ -1350,7 +1361,7 @@
"reject_message": "Wiadomość odrzucenia",
"reject_placeholder": "Twoja wiadomość e-mail została odrzucona",
"label_name": "Nazwa etykiety",
"label_placeholder": "np. ważne",
"label_placeholder": "Wybierz etykietę",
"header_name": "Nazwa nagłówka",
"header_placeholder": "np. X-Mailing-List",
"size_bytes": "Rozmiar w bajtach",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Jesteś w trybie demo — wszystko pozostaje w Twojej przeglądarce. W każdej chwili kliknij „Reset Demo”, aby zacząć od nowa z czystymi danymi przykładowymi.",
"quota_title": "Wykorzystanie miejsca",
"quota_desc": "Tutaj możesz śledzić rozmiar swojej skrzynki pocztowej. Okrąg wypełnia się wraz ze wzrostem użycia przestrzeni."
},
"unified_mailbox": {
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
}
}
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "Spam",
"important": "Importante"
},
"unified_inbox": "Caixa de entrada unificada",
"unified_sent": "Todos os enviados",
"unified_drafts": "Todos os rascunhos",
"unified_trash": "Todas as lixeiras",
"unified_archive": "Todos os arquivos",
"unified_junk": "Todo o spam",
"all_accounts": "Todas as contas",
"expand": "Expandir",
"collapse": "Recolher",
"expand_tooltip": "Expandir",
@@ -657,7 +664,7 @@
"filters": "Filtros",
"templates": "Modelos",
"folders": "Pastas",
"keywords": "Palavras-chave",
"keywords": "Etiquetas",
"security": "Segurança",
"encryption": "Criptografia",
"files": "Arquivos",
@@ -722,25 +729,29 @@
"show_rail_account_list": {
"label": "Mostrar avatares de conta na barra de navegação",
"description": "Exibir círculos de contas individuais na parte inferior da barra de navegação para troca rápida, com um botão de sair abaixo."
},
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
}
},
"keywords": {
"title": "Palavras-chave de e-mail",
"description": "Defina palavras-chave (rótulos/tags) para organizar seus e-mails com cores.",
"add_keyword": "Adicionar palavra-chave",
"title": "Etiquetas de e-mail",
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
"add_keyword": "Adicionar etiqueta",
"reset_defaults": "Restaurar padrões",
"label_field": "Nome de exibição",
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
"id_field": "ID da palavra-chave",
"id_field": "ID da etiqueta",
"id_placeholder": "ex. trabalho, pessoal",
"color_field": "Cor",
"id_exists": "Este ID de palavra-chave já existe",
"edit": "Editar palavra-chave",
"delete": "Excluir palavra-chave",
"id_exists": "Este ID de etiqueta já existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"save": "Salvar",
"add": "Adicionar",
"cancel": "Cancelar",
"migrating": "Atualizando etiqueta nos e-mails existentes…",
"migrating": "A atualizar etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
},
"notifications": {
@@ -1337,7 +1348,7 @@
"forward": "Encaminhar para",
"mark_read": "Marcar como lido",
"star": "Destacar mensagem",
"add_label": "Adicionar rótulo",
"add_label": "Adicionar etiqueta",
"discard": "Descartar (excluir silenciosamente)",
"reject": "Rejeitar com mensagem",
"keep": "Manter na caixa de entrada",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@exemplo.com",
"reject_message": "Mensagem de rejeição",
"reject_placeholder": "Seu e-mail foi rejeitado",
"label_name": "Nome do rótulo",
"label_placeholder": "ex. importante",
"label_name": "Nome da etiqueta",
"label_placeholder": "Selecionar etiqueta",
"header_name": "Nome do cabeçalho",
"header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Tamanho em bytes",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
"quota_title": "Utilização do armazenamento",
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
},
"unified_mailbox": {
"search_unavailable": "A pesquisa não está disponível na vista unificada"
}
}
+29 -15
View File
@@ -104,6 +104,13 @@
"spam": "Спам",
"important": "Важные"
},
"unified_inbox": "Общие входящие",
"unified_sent": "Все отправленные",
"unified_drafts": "Все черновики",
"unified_trash": "Все корзины",
"unified_archive": "Все архивы",
"unified_junk": "Весь спам",
"all_accounts": "Все аккаунты",
"expand": "Развернуть",
"collapse": "Свернуть",
"expand_tooltip": "Развернуть",
@@ -657,7 +664,7 @@
"filters": "Фильтры",
"templates": "Шаблоны",
"folders": "Папки",
"keywords": "Ключевые слова",
"keywords": "Теги",
"security": "Безопасность",
"files": "Файлы",
"contacts": "Контакты",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
},
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
}
},
"keywords": {
"title": "Ключевые слова писем",
"description": "Определите ключевые слова (метки/теги) для организации писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить ключевое слово",
"title": "Теги электронной почты",
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить тег",
"reset_defaults": "Сбросить по умолчанию",
"label_field": "Отображаемое название",
"label_placeholder": "напр., Работа, Личное, Срочно",
"id_field": "Идентификатор ключевого слова",
"id_field": "Идентификатор тега",
"id_placeholder": "напр., work, personal",
"color_field": "Цвет",
"id_exists": "Этот идентификатор ключевого слова уже существует",
"edit": "Редактировать ключевое слово",
"delete": "Удалить ключевое слово",
"id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать тег",
"delete": "Удалить тег",
"save": "Сохранить",
"add": "Добавить",
"cancel": "Отмена",
"migrating": "Обновление ключевого слова в существующих письмах…",
"migration_error": "Не удалось обновить ключевое слово в существующих письмах"
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах"
},
"notifications": {
"test_sound": "Проверить звук уведомления",
@@ -1337,7 +1348,7 @@
"forward": "Переслать на",
"mark_read": "Отметить прочитанным",
"star": "Пометить сообщение",
"add_label": "Добавить метку",
"add_label": "Добавить тег",
"discard": "Удалить (без уведомления)",
"reject": "Отклонить с сообщением",
"keep": "Оставить во входящих",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "user@пример.рф",
"reject_message": "Сообщение об отклонении",
"reject_placeholder": "Ваше письмо было отклонено",
"label_name": "Название метки",
"label_placeholder": "напр., важное",
"label_name": "Название тега",
"label_placeholder": "Выбрать тег",
"header_name": "Имя заголовка",
"header_placeholder": "напр., X-Mailing-List",
"size_bytes": "Размер в байтах",
@@ -1524,8 +1535,8 @@
"delete": "Удалить",
"mark_as_spam": "Отметить как спам",
"not_spam": "Не спам",
"color_tag": "Метка",
"remove_color": "Убрать метку",
"color_tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
},
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Вы в демо-режиме — всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.",
"quota_title": "Использование хранилища",
"quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
},
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
}
}
+14
View File
@@ -104,6 +104,13 @@
"spam": "Спам",
"important": "важливо"
},
"unified_inbox": "Спільні вхідні",
"unified_sent": "Усі надіслані",
"unified_drafts": "Усі чернетки",
"unified_trash": "Усі кошики",
"unified_archive": "Усі архіви",
"unified_junk": "Весь спам",
"all_accounts": "Усі облікові записи",
"expand": "Розгорнути",
"collapse": "Згорнути",
"expand_tooltip": "Розгорнути",
@@ -722,6 +729,10 @@
"show_rail_account_list": {
"label": "Показувати аватари облікових записів на панелі навігації",
"description": "Відображати кола окремих облікових записів у нижній частині панелі навігації для швидкого перемикання з кнопкою виходу внизу."
},
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
}
},
"keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Ви в демонстраційному режимі — все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.",
"quota_title": "Використання сховища",
"quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
},
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
}
}
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "垃圾邮件",
"important": "重要"
},
"unified_inbox": "统一收件箱",
"unified_sent": "所有已发送",
"unified_drafts": "所有草稿",
"unified_trash": "所有已删除",
"unified_archive": "所有归档",
"unified_junk": "所有垃圾邮件",
"all_accounts": "所有账户",
"expand": "展开",
"collapse": "收起",
"expand_tooltip": "展开",
@@ -657,7 +664,7 @@
"filters": "过滤器",
"templates": "模板",
"folders": "文件夹",
"keywords": "关键词",
"keywords": "标签",
"security": "安全",
"files": "文件",
"contacts": "联系人",
@@ -722,26 +729,30 @@
"show_rail_account_list": {
"label": "在导航导轨上显示账户头像",
"description": "在导航栏底部显示账户头像,方便快速切换;下方会保留退出按钮。"
},
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
}
},
"keywords": {
"title": "邮件关键字",
"description": "定义关键字(标签)并用颜色整理邮件。这些关键字会作为 JMAP 关键字保存在服务器上。",
"add_keyword": "添加关键字",
"title": "电子邮件标签",
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
"add_keyword": "添加标签",
"reset_defaults": "重置为默认值",
"label_field": "显示名称",
"label_placeholder": "例如工作、个人、紧急",
"id_field": "关键字 ID",
"id_field": "标签ID",
"id_placeholder": "例如工作、个人",
"color_field": "颜色",
"id_exists": "该关键字 ID 已存在",
"edit": "编辑关键字",
"delete": "删除关键字",
"id_exists": "此标签ID已存在",
"edit": "编辑标签",
"delete": "删除标签",
"save": "保存",
"add": "添加",
"cancel": "取消",
"migrating": "正在更新现有邮件的关键字...",
"migration_error": "无法更新现有邮件的关键字"
"migrating": "正在更新现有邮件的标签…",
"migration_error": "更新现有邮件的标签失败"
},
"notifications": {
"test_sound": "测试通知声音",
@@ -1350,7 +1361,7 @@
"reject_message": "拒绝留言",
"reject_placeholder": "您的邮件已被拒绝",
"label_name": "标签名称",
"label_placeholder": "例如,重要",
"label_placeholder": "选择标签",
"header_name": "标头名称",
"header_placeholder": "例如,X-Mailing-List",
"size_bytes": "大小(以字节为单位)",
@@ -1525,7 +1536,7 @@
"mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件",
"color_tag": "标签",
"remove_color": "除标签",
"remove_color": "除标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
},
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "当前为演示模式,所有数据仅保存在浏览器中。随时点击\"重置演示\"即可恢复初始示例数据。",
"quota_title": "存储使用情况",
"quota_desc": "在这里查看邮箱的存储使用情况。随着空间使用增加,进度圆环会逐渐填满。"
},
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
}
}
+13 -2
View File
@@ -151,15 +151,26 @@ describe('filter-store', () => {
});
describe('fetchFilters', () => {
it('should set isOpaque for scripts without metadata', async () => {
it('parses external rules from scripts without metadata', async () => {
const mockClient = {
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
};
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
expect(useFilterStore.getState().isOpaque).toBe(false);
expect(useFilterStore.getState().rules).toHaveLength(1);
expect(useFilterStore.getState().rules[0].origin).toBe('external');
});
it('sets isOpaque for truly unparseable content', async () => {
const mockClient = {
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
};
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
expect(useFilterStore.getState().isOpaque).toBe(true);
expect(useFilterStore.getState().rules).toEqual([]);
});
it('should parse rules from metadata-bearing script', async () => {
+5
View File
@@ -49,6 +49,7 @@ interface AuthState {
syncIdentities: () => void;
refreshIdentities: () => Promise<void>;
getClientForAccount: (accountId: string) => JMAPClient | undefined;
getAllConnectedClients: () => Map<string, JMAPClient>;
}
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
@@ -1529,6 +1530,10 @@ export const useAuthStore = create<AuthState>()(
getClientForAccount: (accountId: string) => {
return clients.get(accountId);
},
getAllConnectedClients: () => {
return new Map(clients);
},
}),
{
name: 'auth-storage',
+236 -9
View File
@@ -1,10 +1,14 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
interface EmailStore {
emails: Email[];
@@ -39,6 +43,12 @@ interface EmailStore {
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
// Unified mailbox state
isUnifiedView: boolean;
unifiedRole: UnifiedMailboxRole | null;
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void;
@@ -77,7 +87,7 @@ interface EmailStore {
// Batch operations
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: IJMAPClient) => Promise<void>;
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
@@ -107,6 +117,12 @@ interface EmailStore {
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Unified mailbox operations
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void;
// Mock data for demo
loadMockData: () => void;
}
@@ -175,6 +191,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isAdvancedSearchOpen: false,
searchAbortController: null,
// Unified mailbox state
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
unifiedCounts: [],
// Spam undo cache
spamUndoCache: new Map(),
@@ -334,11 +356,52 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword } = get();
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
// Unified view uses a different fan-out loader. Rebuild the per-account
// client list from auth/account stores and delegate.
if (isUnifiedView && unifiedRole) {
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const authAccounts = useAccountStore.getState().accounts.filter(a => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
const result = await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false,
});
}
return;
}
set({ isLoadingMore: true, error: null });
try {
// Get emails per page from settings
@@ -919,7 +982,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMarkAsRead(emailIdsArray, read);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMarkAsRead(ids, read);
});
await Promise.allSettled(promises);
} else {
await client.batchMarkAsRead(emailIdsArray, read);
}
// Update local state
const updatedEmails = emails.map(email =>
@@ -962,14 +1044,60 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
batchDelete: async (client) => {
const { selectedEmailIds, emails, mailboxes } = get();
batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
// Determine if the current folder forces permanent deletion.
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const isInJunk = currentMailbox?.role === 'junk';
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
// Group emails by accountId (handles unified view and search results spanning accounts).
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const getClient = (acctId: string) =>
acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (forceDestroy) {
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
await acctClient.batchDeleteEmails(ids);
});
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
const trashMailbox = mailboxes.find(mb => {
if (mb.role !== 'trash') return false;
if (acctId === '__default__') return !mb.isShared;
return mb.accountId === acctId;
});
if (!trashMailbox) {
// No trash available for this account — fall back to destroy so the action isn't silently dropped.
await acctClient.batchDeleteEmails(ids);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
});
await Promise.allSettled(promises);
}
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
@@ -1020,7 +1148,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMoveEmails(emailIdsArray, toMailboxId);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, toMailboxId);
});
await Promise.allSettled(promises);
} else {
await client.batchMoveEmails(emailIdsArray, toMailboxId);
}
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
@@ -1032,7 +1179,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
// Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox);
if (!get().isUnifiedView) {
await get().fetchEmails(client, get().selectedMailbox);
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
@@ -1481,6 +1630,84 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
// Unified mailbox operations
fetchUnifiedEmails: async (accounts, role) => {
set({
isLoading: true,
error: null,
isUnifiedView: true,
unifiedRole: role,
selectedKeyword: null,
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to fetch unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch unified emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
});
}
},
loadMoreUnifiedEmails: async (accounts) => {
const { isLoadingMore, hasMoreEmails, emails, unifiedRole } = get();
if (isLoadingMore || !hasMoreEmails || !unifiedRole) return;
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more unified emails",
isLoadingMore: false,
});
}
},
refreshUnifiedCounts: async (accounts) => {
try {
const counts = fetchUnifiedMailboxCounts(accounts);
set({ unifiedCounts: counts });
} catch (error) {
console.error('Failed to refresh unified counts:', error);
}
},
exitUnifiedView: () => {
set({
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
});
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+54 -15
View File
@@ -16,6 +16,7 @@ interface FilterStore {
isOpaque: boolean;
rawScript: string;
vacationSettings: VacationSieveConfig | null;
externalRequires: string[];
setSupported: (supported: boolean) => void;
fetchFilters: (client: IJMAPClient) => Promise<void>;
@@ -43,6 +44,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
setSupported: (supported) => set({ isSupported: supported }),
@@ -74,10 +76,22 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
if (result.isOpaque) {
debug.log('filters', 'Sieve script is opaque (hand-edited)');
set({ isLoading: false, isOpaque: true, rules: [], vacationSettings: result.vacation || null });
set({
isLoading: false,
isOpaque: true,
rules: [],
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
});
} else {
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
set({ isLoading: false, isOpaque: false, rules: result.rules, vacationSettings: result.vacation || null });
set({
isLoading: false,
isOpaque: false,
rules: result.rules,
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
});
}
} catch (error) {
debug.error('Failed to fetch filters:', error);
@@ -91,13 +105,13 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
saveFilters: async (client) => {
set({ isSaving: true, error: null });
try {
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings } = get();
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
let content: string;
if (isOpaque) {
content = rawScript;
} else {
content = generateScript(rules, vacationSettings || undefined);
content = generateScript(rules, vacationSettings || undefined, { externalRequires });
}
if (activeScriptId) {
@@ -124,40 +138,60 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
},
addRule: (rule) => {
set((state) => ({ rules: [...state.rules, rule] }));
// Insert new bulwark rules before external/opaque rules so Bulwark's
// managed section stays contiguous.
set((state) => {
const bulwark = state.rules.filter(r => !r.origin || r.origin === 'bulwark');
const external = state.rules.filter(r => r.origin === 'external' || r.origin === 'opaque');
return { rules: [...bulwark, rule, ...external] };
});
},
updateRule: (ruleId, updates) => {
set((state) => ({
rules: state.rules.map(r => r.id === ruleId ? { ...r, ...updates } : r),
rules: state.rules.map(r => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, ...updates };
}),
}));
},
deleteRule: (ruleId) => {
set((state) => ({
rules: state.rules.filter(r => r.id !== ruleId),
rules: state.rules.filter(r => {
if (r.id !== ruleId) return true;
return r.origin === 'external' || r.origin === 'opaque';
}),
}));
},
reorderRules: (ruleIds) => {
// Only reorder bulwark rules; external rules always stay at the end in
// their original order.
set((state) => {
const ruleMap = new Map(state.rules.map(r => [r.id, r]));
const reordered = ruleIds.map(id => ruleMap.get(id)).filter(Boolean) as FilterRule[];
return { rules: reordered };
const bulwarkMap = new Map(
state.rules.filter(r => !r.origin || r.origin === 'bulwark').map(r => [r.id, r]),
);
const external = state.rules.filter(r => r.origin === 'external' || r.origin === 'opaque');
const reordered = ruleIds.map(id => bulwarkMap.get(id)).filter(Boolean) as FilterRule[];
return { rules: [...reordered, ...external] };
});
},
toggleRule: (ruleId) => {
set((state) => ({
rules: state.rules.map(r =>
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
),
rules: state.rules.map(r => {
if (r.id !== ruleId) return r;
if (r.origin === 'external' || r.origin === 'opaque') return r; // read-only
return { ...r, enabled: !r.enabled };
}),
}));
},
setRawScript: (content) => set({ rawScript: content }),
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [] }),
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
syncVacationToScript: async (client, vacation) => {
try {
@@ -173,6 +207,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const activeScript = scripts.find(s => s.isActive) || scripts[0];
let rules = previousRules;
let externalRequires = get().externalRequires;
// If there's an active script, try to parse our metadata from it.
// If the server overwrote it (no metadata), fall back to stored rules.
@@ -181,11 +216,12 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const parsed = parseScript(content);
if (!parsed.isOpaque) {
rules = parsed.rules;
externalRequires = parsed.externalRequires;
}
}
// Generate a combined script with our metadata, rules, and vacation
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined);
const content = generateScript(rules, vacation.isEnabled ? vacation : undefined, { externalRequires });
if (activeScript) {
// Preserve the script's current activation state — don't pass activate: true
@@ -198,6 +234,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
} else {
// Don't activate; there may be a server-managed 'vacation' script active.
@@ -209,6 +246,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rules,
vacationSettings: vacation,
isOpaque: false,
externalRequires,
});
}
@@ -229,5 +267,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false,
rawScript: '',
vacationSettings: null,
externalRequires: [],
}),
}));
+14
View File
@@ -176,12 +176,18 @@ interface SettingsState {
hideAccountSwitcher: boolean;
showRailAccountList: boolean;
// Unified Mailbox
enableUnifiedMailbox: boolean;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
// Experimental
senderFavicons: boolean;
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
// Folders
folderIcons: Record<string, string>; // mailboxId -> icon name
@@ -310,12 +316,18 @@ const DEFAULT_SETTINGS = {
hideAccountSwitcher: false,
showRailAccountList: false,
// Unified Mailbox
enableUnifiedMailbox: false,
// Email Display
disableThreading: false,
// Experimental
senderFavicons: true,
// Sidebar
colorfulSidebarIcons: true,
// Folders
folderIcons: {} as Record<string, string>,
@@ -448,7 +460,9 @@ export const useSettingsStore = create<SettingsState>()(
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
enableUnifiedMailbox: state.enableUnifiedMailbox,
senderFavicons: state.senderFavicons,
colorfulSidebarIcons: state.colorfulSidebarIcons,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
attachmentReminderEnabled: state.attachmentReminderEnabled,