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 type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; 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 { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
@@ -155,8 +157,54 @@ export default function Home() {
hasMoreEmails, hasMoreEmails,
fetchTagCounts, fetchTagCounts,
fetchEmailContent, fetchEmailContent,
isUnifiedView,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
} = useEmailStore(); } = 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 // Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on // 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). // 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]); }, [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) // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => { useEffect(() => {
if (!selectedEmail || !client) return; if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content // If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) { if (!selectedEmail.bodyValues) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true); setLoadingEmail(true);
fetchEmailContent(client, selectedEmail.id).finally(() => { fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
setLoadingEmail(false); setLoadingEmail(false);
}); });
} }
@@ -693,8 +758,8 @@ export default function Home() {
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}; };
const handleDelete = async () => { const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder // Check if we're currently in the trash or junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -713,7 +778,7 @@ export default function Home() {
if (!confirmed) return; if (!confirmed) return;
try { try {
await deleteEmail(client, selectedEmail.id, true); await deleteEmail(client, emailToDelete.id, true);
} catch (error) { } catch (error) {
console.error("Failed to permanently delete email:", 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); const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
if (trashMailbox) { if (trashMailbox) {
try { try {
await moveToMailbox(client, selectedEmail.id, trashMailbox.id); await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) { } catch (error) {
console.error("Failed to move email to trash:", error); console.error("Failed to move email to trash:", error);
} }
@@ -795,10 +860,10 @@ export default function Home() {
} }
}; };
const handleMarkAsSpam = async () => { const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToMark) return;
const emailId = selectedEmail.id; const emailId = emailToMark.id;
try { try {
await markAsSpam(client, emailId); await markAsSpam(client, emailId);
@@ -826,11 +891,11 @@ export default function Home() {
} }
}; };
const handleUndoSpam = async () => { const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
if (!client || !selectedEmail) return; if (!client || !emailToRestore) return;
try { try {
await undoSpam(client, selectedEmail.id); await undoSpam(client, emailToRestore.id);
const toastInstance = (await import('sonner')).toast; const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success')); toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
@@ -886,6 +951,32 @@ export default function Home() {
}; };
const handleMailboxSelect = async (mailboxId: string) => { 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); selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes selectEmail(null); // Clear selected email when switching mailboxes
@@ -969,6 +1060,7 @@ export default function Home() {
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query); setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) { if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client); await advancedSearch(client);
@@ -987,6 +1079,7 @@ export default function Home() {
const handleAdvancedSearch = async () => { const handleAdvancedSearch = async () => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client); await advancedSearch(client);
}; };
@@ -996,9 +1089,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current); clearTimeout(advancedSearchDebounceRef.current);
} }
advancedSearchDebounceRef.current = setTimeout(() => { advancedSearchDebounceRef.current = setTimeout(() => {
if (client) advancedSearch(client); if (client && !isUnifiedView) advancedSearch(client);
}, 300); }, 300);
}, [client, advancedSearch]); }, [client, advancedSearch, isUnifiedView]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1127,13 +1220,29 @@ export default function Home() {
// Fetch the full content // Fetch the full content
try { try {
// Find selected mailbox to determine accountId (for shared folders) // In unified view each email carries its own accountId. Use that
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // account's client so we fetch from the server that actually owns it.
// Only pass accountId for shared mailboxes const listEmail = emails.find(e => e.id === email.id);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined; 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 (fullEmail) {
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
}
selectEmail(fullEmail); selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect // 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")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input" data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -1412,13 +1523,15 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={toggleAdvancedSearch} onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn( className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors", "relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0 isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "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" /> <Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && ( {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -1602,8 +1715,7 @@ export default function Home() {
} }
}} }}
onDelete={async (email) => { onDelete={async (email) => {
selectEmail(email); await handleDelete(email);
await handleDelete();
}} }}
onArchive={async (email) => { onArchive={async (email) => {
await handleArchive(email); await handleArchive(email);
@@ -1617,12 +1729,10 @@ export default function Home() {
} }
}} }}
onMarkAsSpam={async (email) => { onMarkAsSpam={async (email) => {
selectEmail(email); await handleMarkAsSpam(email);
await handleMarkAsSpam();
}} }}
onUndoSpam={async (email) => { onUndoSpam={async (email) => {
selectEmail(email); await handleUndoSpam(email);
await handleUndoSpam();
}} }}
onEditDraft={(email) => { onEditDraft={(email) => {
handleEditDraft(email); handleEditDraft(email);
+18
View File
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -84,6 +85,7 @@ interface EmailComposerProps {
body?: string; body?: string;
htmlBody?: string; htmlBody?: string;
receivedAt?: string; receivedAt?: string;
accountId?: string;
}; };
} }
@@ -254,12 +256,28 @@ export function EmailComposer({
if (matchedIdentityId) { if (matchedIdentityId) {
setSelectedIdentityId(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, autoSelectReplyIdentity,
identities, identities,
initialData?.selectedIdentityId, initialData?.selectedIdentityId,
mode, mode,
replyTo?.accountId,
replyTo?.bcc, replyTo?.bcc,
replyTo?.cc, replyTo?.cc,
replyTo?.to, replyTo?.to,
+2 -2
View File
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; 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 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 // Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null; const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
+1 -1
View File
@@ -176,7 +176,7 @@ export function EmailList({
setIsProcessing(true); setIsProcessing(true);
try { try {
await batchDelete(client); await batchDelete(client, isInTrash);
} finally { } finally {
setTimeout(() => setIsProcessing(false), 500); setTimeout(() => setIsProcessing(false), 500);
} }
+8 -8
View File
@@ -3074,13 +3074,13 @@ export function EmailViewer({
<> <>
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => { {currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId); const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null; return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})} })}
</span> </span>
{showToolbarLabels && currentColors.length === 1 && ( {showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground"> <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> </span>
)} )}
</> </>
@@ -3678,11 +3678,11 @@ export function EmailViewer({
{currentColors.length > 0 && ( {currentColors.length > 0 && (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
{currentColors.map((tagId) => { {currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId); const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return dotClass ? ( return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> <span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
) : null; );
})} })}
</span> </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 { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
@@ -63,13 +64,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); 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 isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; 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 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 resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{isFocusedMailLayout ? ( {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center 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( <span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40', 'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' 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 justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-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( <span className={cn(
"truncate text-sm", "truncate text-sm",
isUnread isUnread
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; 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") // In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
@@ -375,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails); const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords); 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 colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id || const isSelected = selectedEmailId === latestEmail.id ||
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{isFocusedMailLayout ? ( {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center 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( <span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44', 'w-32 shrink-0 truncate text-sm lg:w-44',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80' 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 justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-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( <span className={cn(
"truncate text-sm", "truncate text-sm",
hasUnread hasUnread
+11 -4
View File
@@ -17,6 +17,7 @@ import type {
} from "@/lib/jmap/sieve-types"; } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types"; import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
interface FilterRuleModalProps { interface FilterRuleModalProps {
rule?: FilterRule; rule?: FilterRule;
@@ -58,6 +59,7 @@ export function FilterRuleModal({
}: FilterRuleModalProps) { }: FilterRuleModalProps) {
const t = useTranslations("settings.filters"); const t = useTranslations("settings.filters");
const isEdit = !!rule; const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const [name, setName] = useState(rule?.name || ""); const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -375,12 +377,17 @@ export function FilterRuleModal({
)} )}
{action.type === "add_label" && ( {action.type === "add_label" && (
<Input <select
value={action.value || ""} value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })} onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("label_placeholder")} className={`${selectClass} flex-1 min-w-[140px]`}
className="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 <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 { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react'; import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; 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 }> = { 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 }, '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 t = useTranslations('settings.appearance');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); 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 { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
/> />
</SettingItem> </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 */} {/* Animations */}
{!isSettingHidden('animationsEnabled') && ( {!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}> <SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
+127 -75
View File
@@ -23,8 +23,13 @@ import {
Filter, Filter,
RotateCcw, RotateCcw,
PalmtreeIcon, PalmtreeIcon,
Lock,
} from "lucide-react"; } from "lucide-react";
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
function RuleSummary({ rule }: { rule: FilterRule }) { function RuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters"); const t = useTranslations("settings.filters");
@@ -429,90 +434,137 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && ( {!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}> <div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => ( {rules.map((rule, index) => {
<div const readonly = isReadonlyRule(rule);
key={rule.id}
role="listitem" if (readonly) {
draggable const label = rule.originLabel || t("origin_external");
onDragStart={(e) => handleDragStart(e, index)} const tooltip = t("managed_by_tooltip", { source: label });
onDragOver={(e) => handleDragOver(e, index)} const hasStructuredSummary =
onDrop={(e) => handleDrop(e, index)} rule.origin === "external" &&
onDragEnd={handleDragEnd} rule.conditions.length > 0 &&
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${ rule.actions.length > 0;
dragOverIndex === index return (
? "border-primary bg-primary/5" <div
: "border-border hover:bg-muted/50" key={rule.id}
} ${!rule.enabled ? "opacity-60" : ""}`} 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 <div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5" key={rule.id}
aria-label={t("drag_to_reorder")} 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"> <div className="pt-0.5">
<ToggleSwitch <ToggleSwitch
checked={rule.enabled} checked={rule.enabled}
onChange={() => handleToggle(rule.id)} onChange={() => handleToggle(rule.id)}
/> />
</div> </div>
<div <div
className="flex-1 min-w-0 cursor-pointer" className="flex-1 min-w-0 cursor-pointer"
onClick={() => { onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(rule); setEditingRule(rule);
setShowRuleModal(true); setShowRuleModal(true);
} }}
}} role="button"
> tabIndex={0}
<p className="text-sm font-medium text-foreground truncate"> onKeyDown={(e) => {
{rule.name} if (e.key === "Enter" || e.key === " ") {
</p> e.preventDefault();
{expandedFilterView ? ( setEditingRule(rule);
<VisualRuleSummary rule={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> </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> </div>
)} )}
</SettingsSection> </SettingsSection>
+178 -248
View File
@@ -1,17 +1,19 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; 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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const GAME_WIDTH = 400; const GAME_WIDTH = 400;
const GAME_HEIGHT = 520; const GAME_HEIGHT = 520;
const FORTRESS_Y = GAME_HEIGHT - 48; const INBOX_Y = GAME_HEIGHT - 40;
const SPAWN_INTERVAL_START = 850; const SPAWN_INTERVAL_START = 900;
const SPAWN_INTERVAL_MIN = 320; const SPAWN_INTERVAL_MIN = 340;
const GAME_DURATION = 30; const GAME_DURATION = 30;
const ENEMY_SPEED_START = 1.2; const ENEMY_SPEED_START = 1.2;
const ENEMY_SPEED_INCREASE = 0.04; const ENEMY_SPEED_INCREASE = 0.04;
const MAX_MISSES = 3;
interface Enemy { interface Enemy {
id: number; id: number;
@@ -21,81 +23,85 @@ interface Enemy {
type: "spam" | "phishing" | "legit"; type: "spam" | "phishing" | "legit";
} }
type GameState = "idle" | "playing" | "won" | "lost"; type GameState = "idle" | "playing" | "over";
export function SpamSiegeGame({ onClose }: { onClose: () => void }) { export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
const [gameState, setGameState] = useState<GameState>("idle"); const [gameState, setGameState] = useState<GameState>("idle");
const [enemies, setEnemies] = useState<Enemy[]>([]); const [enemies, setEnemies] = useState<Enemy[]>([]);
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [timeLeft, setTimeLeft] = useState(GAME_DURATION); const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
const [shieldHealth, setShieldHealth] = useState(3); const [misses, setMisses] = useState(0);
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]); const [survived, setSurvived] = useState(false);
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
const nextId = useRef(0); const nextId = useRef(0);
const animFrameRef = useRef<number>(0); const animFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0); const lastTimeRef = useRef<number>(0);
const spawnTimerRef = useRef<number>(0); const spawnTimerRef = useRef<number>(0);
const gameStateRef = useRef<GameState>("idle"); const gameStateRef = useRef<GameState>("idle");
const elapsedRef = useRef(0); 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(() => { useEffect(() => {
gameStateRef.current = gameState; gameStateRef.current = gameState;
}, [gameState]); }, [gameState]);
const endGame = useCallback((didSurvive: boolean) => {
setSurvived(didSurvive);
setGameState("over");
}, []);
const startGame = useCallback(() => { const startGame = useCallback(() => {
setGameState("playing"); setGameState("playing");
setEnemies([]); setEnemies([]);
setScore(0); setScore(0);
setTimeLeft(GAME_DURATION); setTimeLeft(GAME_DURATION);
setShieldHealth(3); setMisses(0);
setHitEffects([]); setSurvived(false);
setDestroyEffects([]);
setDeliverEffects([]);
nextId.current = 0; nextId.current = 0;
spawnTimerRef.current = 0; spawnTimerRef.current = 0;
elapsedRef.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(); lastTimeRef.current = performance.now();
}, []); }, []);
const spawnEnemy = useCallback(() => { const spawnEnemy = useCallback(() => {
const id = nextId.current++; const id = nextId.current++;
const rand = Math.random(); 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 x = 20 + Math.random() * (GAME_WIDTH - 60);
const elapsed = elapsedRef.current; const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE; enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]); setEnemies(enemiesRef.current);
}, []); }, []);
const handleHover = useCallback((enemy: Enemy) => { const handleClick = useCallback(
if (destroyedRef.current.has(enemy.id)) return; (ev: React.MouseEvent, enemy: Enemy) => {
destroyedRef.current.add(enemy.id); ev.stopPropagation();
if (clickedRef.current.has(enemy.id)) return;
clickedRef.current.add(enemy.id);
if (enemy.type === "legit") { enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
// Penalty for blocking legit mail setEnemies(enemiesRef.current);
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);
}
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(() => { useEffect(() => {
if (gameState !== "playing") return; if (gameState !== "playing") return;
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
lastTimeRef.current = now; lastTimeRef.current = now;
elapsedRef.current += dt; elapsedRef.current += dt;
// Timer
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000); const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
setTimeLeft(Math.max(0, newTimeLeft)); setTimeLeft(Math.max(0, newTimeLeft));
if (newTimeLeft <= 0) { if (newTimeLeft <= 0) {
setGameState("won"); endGame(true);
return; return;
} }
// Spawn
spawnTimerRef.current += dt; spawnTimerRef.current += dt;
const spawnInterval = Math.max( const spawnInterval = Math.max(
SPAWN_INTERVAL_MIN, SPAWN_INTERVAL_MIN,
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
spawnEnemy(); spawnEnemy();
} }
// Move enemies const nextEnemies: Enemy[] = [];
setEnemies((prev) => { let missed = 0;
const next: Enemy[] = []; let scoreDelta = 0;
let spamBreached = false; for (const e of enemiesRef.current) {
for (const e of prev) { const ny = e.y + e.speed * (dt / 16);
const ny = e.y + e.speed * (dt / 16); if (ny >= INBOX_Y) {
if (ny >= FORTRESS_Y) { if (e.type === "legit") scoreDelta += 5;
if (e.type === "legit") { else missed++;
// Legit mail delivered — bonus } else {
setScore((s) => s + 5); nextEnemies.push({ ...e, y: ny });
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 });
}
} }
if (spamBreached) { }
setShieldHealth((prev) => { enemiesRef.current = nextEnemies;
const nh = prev - 1; setEnemies(nextEnemies);
if (nh <= 0) setGameState("lost");
return Math.max(0, nh); 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);
}; };
animFrameRef.current = requestAnimationFrame(tick); animFrameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animFrameRef.current); return () => cancelAnimationFrame(animFrameRef.current);
}, [gameState, spawnEnemy]); }, [gameState, spawnEnemy, endGame]);
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)" };
}
};
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <div
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none" 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" }} 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">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} /> <Shield className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-foreground">Spam Siege</span> <span className="text-sm font-medium text-foreground">Spam Siege</span>
</div> </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" /> <X className="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
{/* HUD */} <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 justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs"> <div className="flex items-center gap-4">
<div className="flex items-center gap-3"> <span>
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span> Score <span className="font-medium text-foreground tabular-nums">{score}</span>
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span> </span>
</div> <span>
<div className="flex items-center gap-1"> Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
{[...Array(3)].map((_, i) => ( </span>
<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> </div>
<span>
Misses{" "}
<span
className={cn(
"font-medium tabular-nums",
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
)}
>
{misses}/{MAX_MISSES}
</span>
</span>
</div> </div>
{/* Game area */}
<div <div
className="relative bg-background overflow-hidden" className="relative bg-background overflow-hidden"
style={{ height: GAME_HEIGHT }} style={{ height: GAME_HEIGHT }}
> >
{/* Grid lines for depth */} <div
<div className="absolute inset-0 opacity-[0.03]" style={{ className="absolute left-0 right-0 flex items-center gap-2 px-4"
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)", style={{ top: INBOX_Y }}
backgroundSize: "40px 40px", >
}} /> <div className="h-px flex-1 bg-border" />
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{/* Fortress wall */} Inbox
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}> </span>
<div className="relative w-full"> <div className="h-px flex-1 bg-border" />
{/* 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> </div>
{/* Enemies */}
{enemies.map((e) => { {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 ( return (
<div <button
key={e.id} key={e.id}
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform" type="button"
style={{ className={cn(
left: e.x, "absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
top: e.y, "active:scale-95 transition-transform",
backgroundColor: style.bg, variant
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 }} />
)} )}
</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" && ( {gameState === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" /> <Shield className="w-10 h-10 text-primary" />
<div className="text-center"> <div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Spam Siege</p> <p className="text-base font-medium text-foreground">Spam Siege</p>
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds. Click spam and phishing before they hit your inbox. Don&apos;t block legitimate
mail. Three misses and it&apos;s over.
</p> </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> </div>
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}> <div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<Shield className="w-3.5 h-3.5 mr-1.5" /> <span className="inline-flex items-center gap-1.5">
Defend <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> </Button>
</div> </div>
)} )}
{/* Won overlay */} {gameState === "over" && (
{gameState === "won" && ( <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80"> <Shield
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} /> className={cn(
<div className="text-center"> "w-10 h-10",
<p className="text-base font-semibold text-foreground">Fortress Secured</p> survived ? "text-success" : "text-muted-foreground/40"
<p className="text-xs text-muted-foreground mt-1"> )}
Score: <span className="font-semibold text-foreground">{score}</span> />
<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> </p>
</div> </div>
<div className="flex gap-2 mt-1"> <div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onClose}> <Button size="sm" variant="outline" onClick={onClose}>
Close Close
</Button> </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" /> <RotateCcw className="w-3.5 h-3.5 mr-1.5" />
Again Again
</Button> </Button>
</div> </div>
</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> </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"; "use client";
import { useState, useRef, useEffect } from "react";
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store'; 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 }) { export function LanguageSwitcher({ className }: { className?: string }) {
const currentLocale = useLocale(); const currentLocale = useLocale();
const setLocale = useLocaleStore((state) => state.setLocale); const setLocale = useLocaleStore((state) => state.setLocale);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const languages = [ const current = languages.find((l) => l.value === currentLocale) ?? languages[0];
{ value: 'en', label: '🇬🇧 English' },
{ value: 'fr', label: '🇫🇷 Français' }, // Close on outside click
{ value: 'ja', label: '🇯🇵 日本語' }, useEffect(() => {
{ value: 'ko', label: '🇰🇷 한국어' }, if (!open) return;
{ value: 'es', label: '🇪🇸 Español' }, function handleClick(e: MouseEvent) {
{ value: 'it', label: '🇮🇹 Italiano' }, if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
{ value: 'de', label: '🇩🇪 Deutsch' }, setOpen(false);
{ value: 'lv', label: '🇱🇻 Latviešu' }, }
{ value: 'nl', label: '🇳🇱 Nederlands' }, }
{ value: 'pl', label: '🇵🇱 Polski' }, document.addEventListener("mousedown", handleClick);
{ value: 'pt', label: '🇧🇷 Português' }, return () => document.removeEventListener("mousedown", handleClick);
{ value: 'ru', label: '🇷🇺 Русский' }, }, [open]);
{ value: 'uk', label: '🇺🇦 Українська' },
{ value: 'zh', label: '🇨🇳 简体中文' } // 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 ( return (
<div className={className}> <div ref={containerRef} className={cn("relative", className)}>
<Select <button
value={currentLocale} type="button"
onChange={setLocale} onClick={() => setOpen((v) => !v)}
options={languages} 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> </div>
); );
} }
+7 -2
View File
@@ -196,10 +196,15 @@ describe('sieve generator', () => {
expect(result.vacation?.isEnabled).toBe(true); 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 script = `require ["vacation", "fileinto"];\n\nvacation "Away";\n\nif header :contains "From" "boss@example.com" {\n fileinto "Important";\n}\n`;
const result = parseScript(script); 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', () => { it('should handle Stalwart :mime format vacation script', () => {
+5
View File
@@ -39,6 +39,8 @@ export interface FilterAction {
value?: string; value?: string;
} }
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
export interface FilterRule { export interface FilterRule {
id: string; id: string;
name: string; name: string;
@@ -47,6 +49,9 @@ export interface FilterRule {
conditions: FilterCondition[]; conditions: FilterCondition[];
actions: FilterAction[]; actions: FilterAction[];
stopProcessing: boolean; stopProcessing: boolean;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
} }
export interface VacationSieveConfig { export interface VacationSieveConfig {
+30
View File
@@ -39,6 +39,9 @@ export interface Email {
// S/MIME support // S/MIME support
blobId?: string; blobId?: string;
bodyStructure?: EmailBodyPart; bodyStructure?: EmailBodyPart;
// Unified mailbox support — set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
} }
export interface AuthenticationResults { export interface AuthenticationResults {
@@ -725,3 +728,30 @@ export interface FileNodeFilter {
name?: string; name?: string;
type?: 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, AdminPageSection,
CalendarEventAction, CalendarEventAction,
SlotName, SlotName,
PluginI18n,
} from './plugin-types'; } from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import { import {
@@ -22,8 +23,9 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, sidebarAppHooks, avatarHooks, renderHooks,
} from './plugin-hooks'; } from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store'; import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
@@ -110,6 +112,8 @@ function createPluginLogger(pluginId: string) {
export interface PluginAPI { export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> }; plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API — register translations and call t() to get strings */
i18n: PluginI18n;
ui: { ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable; registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable; registerEmailBanner: (factory: BannerFactory) => Disposable;
@@ -150,6 +154,8 @@ export interface PluginHooksAPI {
onEmailClose: (handler: () => void) => Disposable; onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (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; onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -158,6 +164,10 @@ export interface PluginHooksAPI {
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (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; onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -174,6 +184,8 @@ export interface PluginHooksAPI {
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable; onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (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 // Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -216,6 +228,8 @@ export interface PluginHooksAPI {
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (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; onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable; onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable; onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -317,6 +331,9 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar // Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable; 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 ---------------------------- // --- Permission mapping for hooks ----------------------------
@@ -325,14 +342,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email // Email
onEmailOpen: 'email:read', onEmailClose: 'email:read', onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: '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', onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read', onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read', onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read', onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write', onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write', onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write', onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write', onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write', onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
@@ -359,6 +379,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write', onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write', onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write', onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write', onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write', onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth // Auth
@@ -470,6 +491,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(sidebarAppHooks)), ...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar // Avatar
...Object.fromEntries(Object.entries(avatarHooks)), ...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
}; };
// --- Slot registration bridge -------------------------------- // --- Slot registration bridge --------------------------------
@@ -531,6 +554,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
settings: { ...plugin.settings }, settings: { ...plugin.settings },
}, },
i18n: createPluginI18n(plugin.id),
ui: { ui: {
registerToolbarAction: (action: ToolbarAction) => { registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar'); requirePermission(plugin, 'ui:toolbar');
+26 -1
View File
@@ -172,6 +172,10 @@ export const emailHooks = {
onEmailClose: new HookBus(), onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(), onEmailContentRender: new HookBus(),
onThreadExpand: 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(), onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(), onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(), onAfterEmailSend: new HookBus(),
@@ -180,6 +184,10 @@ export const emailHooks = {
onAfterEmailDelete: new HookBus(), onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(), onBeforeEmailMove: new HookBus(),
onAfterEmailMove: 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(), onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(), onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(), onEmailSpamToggle: new HookBus(),
@@ -196,6 +204,9 @@ export const emailHooks = {
onNewEmailReceived: new HookBus(), onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(), onPushConnectionChange: new HookBus(),
onQuotaChange: 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 // §7.2 Calendar Hooks
@@ -250,6 +261,10 @@ export const fileHooks = {
onDirectoryCreate: new HookBus(), onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(), onBeforeFileDelete: new HookBus(),
onAfterFileDelete: 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(), onFileRename: new HookBus(),
onFileMove: new HookBus(), onFileMove: new HookBus(),
onFileCopy: new HookBus(), onFileCopy: new HookBus(),
@@ -406,6 +421,16 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(), 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 ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
@@ -414,7 +439,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks, avatarHooks, renderHooks,
]; ];
export function removeAllPluginHooks(pluginId: string): void { 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 type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage'; import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api'; import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks'; import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime'; import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) ------------- // --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
export function exposePluginExternals(): void { export function exposePluginExternals(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
ReactDOM, ReactDOM,
ReactJSX, 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 ---------------------------------- // --- Active plugin tracking ----------------------------------
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
// 4. Build sandboxed API // 4. Build sandboxed API
const api = createPluginAPI(plugin); 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 // 5. Call activate
const disposable = await mod.activate(api); const disposable = await mod.activate(api);
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
// Remove all hook subscriptions for this plugin // Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId); removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker // Reset error tracker
pluginErrorTracker.reset(pluginId); pluginErrorTracker.reset(pluginId);
+89
View File
@@ -34,6 +34,13 @@ export interface PluginManifest {
entrypoint: string; entrypoint: string;
minAppVersion?: string; minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>; 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 { export interface SettingFieldSchema {
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
adminApproved?: boolean; adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>; settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>; settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
} }
// ─── UI Slots ──────────────────────────────────────────────── // ─── UI Slots ────────────────────────────────────────────────
@@ -388,6 +397,86 @@ export interface ComposerContext {
originalSubject?: string; 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 ──────────────────────────────────── // ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [ 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";'); 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' }] })]); const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
expect(script).toContain('addflag "$Important";'); expect(script).toContain('addflag "$label:Important";');
}); });
it('generates discard', () => { it('generates discard', () => {
+10 -5
View File
@@ -25,10 +25,14 @@ describe('parseScript', () => {
expect(result.rules).toEqual(rules); 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"; }'); const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
expect(result.isOpaque).toBe(true); expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]); 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', () => { it('returns isOpaque for corrupted JSON', () => {
@@ -90,9 +94,10 @@ describe('parseScript', () => {
expect(result.isOpaque).toBe(true); 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(''); const result = parseScript('');
expect(result.isOpaque).toBe(true); expect(result.isOpaque).toBe(false);
expect(result.rules).toEqual([]);
}); });
describe('round-trip', () => { describe('round-trip', () => {
+59 -9
View File
@@ -65,7 +65,7 @@ function generateActions(actions: FilterAction[]): string[] {
case 'star': case 'star':
return 'addflag "\\\\Flagged";'; return 'addflag "\\\\Flagged";';
case 'add_label': case 'add_label':
return `addflag "$${escapeString(action.value || '')}";`; return `addflag "$label:${escapeString(action.value || '')}";`;
case 'discard': case 'discard':
return 'discard;'; return 'discard;';
case 'reject': 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 { function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
const metadata: FilterMetadata = { version: 1, rules }; 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) { if (vacation?.isEnabled) {
metadata.vacation = vacation; metadata.vacation = vacation;
} }
@@ -127,9 +163,12 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('@metadata:end */'); lines.push('@metadata:end */');
lines.push(''); lines.push('');
const requires = computeRequires(rules, vacation); const bulwarkRequires = computeRequires(bulwarkRules, vacation);
if (requires.length > 0) { const externalRequires = options.externalRequires ?? [];
lines.push(`require [${requires.map(r => `"${r}"`).join(', ')}];`); const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
if (allRequires.length > 0) {
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
} }
if (vacation?.isEnabled) { if (vacation?.isEnabled) {
@@ -143,9 +182,9 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push(`vacation ${vacationParts.join(' ')};`); 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) { if (rule.conditions.length === 0 || rule.actions.length === 0) {
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`); debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue; continue;
@@ -182,6 +221,17 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf
lines.push('}'); 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(''); lines.push('');
return lines.join('\n'); 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'; import { debug } from '@/lib/debug';
export interface ParseResult { export interface ParseResult {
rules: FilterRule[]; rules: FilterRule[];
isOpaque: boolean; isOpaque: boolean;
vacation?: VacationSieveConfig; vacation?: VacationSieveConfig;
externalRequires: string[];
} }
const OPAQUE: ParseResult = { rules: [], isOpaque: true }; const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
const METADATA_BEGIN = '/* @metadata:begin'; const METADATA_BEGIN = '/* @metadata:begin';
const METADATA_END = '@metadata:end */'; 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 { function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false; if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>; 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). * 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 { function detectVacationOnlyScript(content: string): ParseResult | null {
// Must contain a vacation command
if (!/\bvacation\b/.test(content)) return null; if (!/\bvacation\b/.test(content)) return null;
// Strip requires, comments, and whitespace to see if only vacation remains
const stripped = content const stripped = content
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '') .replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
.replace(/#[^\n]*/g, '') .replace(/#[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\*[\s\S]*?\*\//g, '')
.trim(); .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, '""'); const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
// Check there are no if/elsif/else filter blocks
if (/\b(?:if|elsif|else)\b/.test(structural)) return null; 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; if (!/\bvacation\b/.test(structural)) return null;
// Extract subject if present (:subject "...")
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/); 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 = ''; let textBody = '';
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/); const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
if (mimeBodyMatch) { if (mimeBodyMatch) {
textBody = mimeBodyMatch[1].trim(); textBody = mimeBodyMatch[1].trim();
} else { } else {
// Plain format: last quoted string argument in the vacation statement
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)]; const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
const last = allQuoted[allQuoted.length - 1]; const last = allQuoted[allQuoted.length - 1];
if (last) { if (last) textBody = unescapeSieveString(last[1]);
textBody = last[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
} }
return { return {
rules: [], rules: [],
isOpaque: false, isOpaque: false,
vacation: { isEnabled: true, subject, textBody }, 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 { export function parseScript(content: string): ParseResult {
const beginIdx = content.indexOf(METADATA_BEGIN); const beginIdx = content.indexOf(METADATA_BEGIN);
if (beginIdx === -1) {
// No metadata — check if it's a Stalwart vacation-only script if (beginIdx !== -1) {
return detectVacationOnlyScript(content) || OPAQUE; 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); // No metadata — check vacation-only first
if (endIdx === -1) return OPAQUE; const vacationOnly = detectVacationOnlyScript(content);
if (vacationOnly) return vacationOnly;
const jsonStart = beginIdx + METADATA_BEGIN.length; // Try to parse the whole script as external rules.
const jsonStr = content.slice(jsonStart, endIdx).trim(); const external = parseExternalRules(content, 'ext');
let metadata: FilterMetadata; if (!external.hasContent) {
try { // Entirely empty or whitespace/comments only — treat as empty, editable.
metadata = JSON.parse(jsonStr); return { rules: [], isOpaque: false, externalRequires: [] };
} catch (e) {
debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e);
return OPAQUE;
} }
if (!metadata || metadata.version !== 1) return OPAQUE; // If at least one block parsed into a structured rule, expose them as external.
if (!Array.isArray(metadata.rules)) return OPAQUE; const anyParsed = external.rules.some(r => r.origin === 'external');
if (anyParsed || external.rules.length > 0) {
for (const rule of metadata.rules) { return { rules: external.rules, isOpaque: false, externalRequires: external.externalRequires };
if (!isValidRule(rule)) return OPAQUE;
} }
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 { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; 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"; import { debug } from "./debug";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -380,6 +381,39 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return rootMailboxes; 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 // Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] { export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = []; const result: MailboxNode[] = [];
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "Spam", "spam": "Spam",
"important": "Wichtig" "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", "expand": "Erweitern",
"collapse": "Einklappen", "collapse": "Einklappen",
"expand_tooltip": "Erweitern", "expand_tooltip": "Erweitern",
@@ -657,7 +664,7 @@
"filters": "Filter", "filters": "Filter",
"templates": "Vorlagen", "templates": "Vorlagen",
"folders": "Ordner", "folders": "Ordner",
"keywords": "Schlüsselwörter", "keywords": "Labels",
"security": "Sicherheit", "security": "Sicherheit",
"encryption": "Verschlüsselung", "encryption": "Verschlüsselung",
"files": "Dateien", "files": "Dateien",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Konto-Avatare in der Navigationsleiste anzeigen", "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." "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": { "keywords": {
"title": "E-Mail-Schlüsselwörter", "title": "E-Mail-Labels",
"description": "Definieren Sie Schlüsselwörter (Labels/Tags) zum Organisieren Ihrer E-Mails mit Farben.", "description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
"add_keyword": "Schlüsselwort hinzufügen", "add_keyword": "Label hinzufügen",
"reset_defaults": "Auf Standard zurücksetzen", "reset_defaults": "Auf Standard zurücksetzen",
"label_field": "Anzeigename", "label_field": "Anzeigename",
"label_placeholder": "z.B. Arbeit, Privat, Dringend", "label_placeholder": "z.B. Arbeit, Privat, Dringend",
"id_field": "Schlüsselwort-ID", "id_field": "Label-ID",
"id_placeholder": "z.B. arbeit, privat", "id_placeholder": "z.B. arbeit, privat",
"color_field": "Farbe", "color_field": "Farbe",
"id_exists": "Diese Schlüsselwort-ID existiert bereits", "id_exists": "Diese Label-ID existiert bereits",
"edit": "Schlüsselwort bearbeiten", "edit": "Label bearbeiten",
"delete": "Schlüsselwort löschen", "delete": "Label löschen",
"save": "Speichern", "save": "Speichern",
"add": "Hinzufügen", "add": "Hinzufügen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…", "migrating": "Label auf vorhandenen E-Mails aktualisieren…",
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden" "migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
}, },
"notifications": { "notifications": {
"test_sound": "Benachrichtigungston testen", "test_sound": "Benachrichtigungston testen",
@@ -923,7 +934,7 @@
"star": "Markieren / Markierung aufheben", "star": "Markieren / Markierung aufheben",
"mark_read": "Als gelesen / ungelesen markieren", "mark_read": "Als gelesen / ungelesen markieren",
"archive": "Archivieren", "archive": "Archivieren",
"tag": "Schlagwort", "tag": "Label",
"spam": "Als Spam markieren", "spam": "Als Spam markieren",
"none_selected": "Keine Aktionen ausgewählt", "none_selected": "Keine Aktionen ausgewählt",
"mode_label": "Anzeigemodus", "mode_label": "Anzeigemodus",
@@ -1350,7 +1361,7 @@
"reject_message": "Ablehnungsnachricht", "reject_message": "Ablehnungsnachricht",
"reject_placeholder": "Ihre E-Mail wurde abgelehnt", "reject_placeholder": "Ihre E-Mail wurde abgelehnt",
"label_name": "Label-Name", "label_name": "Label-Name",
"label_placeholder": "z.B. wichtig", "label_placeholder": "Label auswählen",
"header_name": "Header-Name", "header_name": "Header-Name",
"header_placeholder": "z.B. X-Mailing-List", "header_placeholder": "z.B. X-Mailing-List",
"size_bytes": "Größe in Bytes", "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'.", "demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
"quota_title": "Speichernutzung", "quota_title": "Speichernutzung",
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch." "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", "spam": "Spam",
"important": "Important" "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", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"expand_tooltip": "Expand", "expand_tooltip": "Expand",
@@ -120,6 +127,7 @@
"demo_tour": "Tour", "demo_tour": "Tour",
"tags": "Tags", "tags": "Tags",
"folders": "Folders", "folders": "Folders",
"shared": "Shared",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps"
@@ -657,7 +665,7 @@
"filters": "Filters", "filters": "Filters",
"templates": "Templates", "templates": "Templates",
"folders": "Folders", "folders": "Folders",
"keywords": "Keywords", "keywords": "Tags",
"security": "Security", "security": "Security",
"files": "Files", "files": "Files",
"contacts": "Contacts", "contacts": "Contacts",
@@ -722,26 +730,34 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Show Account Avatars on Navigation Rail", "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." "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": { "keywords": {
"title": "Email Keywords", "title": "Email Tags",
"description": "Define keywords (labels/tags) to organize your emails with colors. These are stored as JMAP keywords on the server.", "description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
"add_keyword": "Add Keyword", "add_keyword": "Add Tag",
"reset_defaults": "Reset to Defaults", "reset_defaults": "Reset to Defaults",
"label_field": "Display Name", "label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent", "label_placeholder": "e.g. Work, Personal, Urgent",
"id_field": "Keyword ID", "id_field": "Tag ID",
"id_placeholder": "e.g. work, personal", "id_placeholder": "e.g. work, personal",
"color_field": "Color", "color_field": "Color",
"id_exists": "This keyword ID already exists", "id_exists": "This tag ID already exists",
"edit": "Edit keyword", "edit": "Edit tag",
"delete": "Delete keyword", "delete": "Delete tag",
"save": "Save", "save": "Save",
"add": "Add", "add": "Add",
"cancel": "Cancel", "cancel": "Cancel",
"migrating": "Updating keyword on existing emails…", "migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update keyword on existing emails" "migration_error": "Failed to update tag on existing emails"
}, },
"notifications": { "notifications": {
"test_sound": "Test notification sound", "test_sound": "Test notification sound",
@@ -1337,7 +1353,7 @@
"forward": "Forward to", "forward": "Forward to",
"mark_read": "Mark as read", "mark_read": "Mark as read",
"star": "Star message", "star": "Star message",
"add_label": "Add label", "add_label": "Add tag",
"discard": "Discard (delete silently)", "discard": "Discard (delete silently)",
"reject": "Reject with message", "reject": "Reject with message",
"keep": "Keep in inbox", "keep": "Keep in inbox",
@@ -1349,8 +1365,8 @@
"forward_placeholder": "email@example.com", "forward_placeholder": "email@example.com",
"reject_message": "Rejection message", "reject_message": "Rejection message",
"reject_placeholder": "Your email has been rejected", "reject_placeholder": "Your email has been rejected",
"label_name": "Label name", "label_name": "Tag name",
"label_placeholder": "e.g., important", "label_placeholder": "Select tag",
"header_name": "Header name", "header_name": "Header name",
"header_placeholder": "e.g., X-Mailing-List", "header_placeholder": "e.g., X-Mailing-List",
"size_bytes": "Size in bytes", "size_bytes": "Size in bytes",
@@ -1400,7 +1416,9 @@
"rule_summary": { "rule_summary": {
"conditions_count": "{count, plural, one {# condition} other {# conditions}}", "conditions_count": "{count, plural, one {# condition} other {# conditions}}",
"actions_count": "{count, plural, one {# action} other {# actions}}" "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": { "templates": {
"title": "Email Templates", "title": "Email Templates",
@@ -1524,8 +1542,8 @@
"delete": "Delete", "delete": "Delete",
"mark_as_spam": "Report spam", "mark_as_spam": "Report spam",
"not_spam": "Not spam", "not_spam": "Not spam",
"color_tag": "Label", "color_tag": "Tag",
"remove_color": "Remove Label", "remove_color": "Remove tag",
"items_selected": "{count} emails selected", "items_selected": "{count} emails selected",
"edit_draft": "Edit Draft" "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.", "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_title": "Storage usage",
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space." "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", "spam": "Spam",
"important": "Importante" "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", "expand": "Expandir",
"collapse": "Contraer", "collapse": "Contraer",
"expand_tooltip": "Expandir", "expand_tooltip": "Expandir",
@@ -657,7 +664,7 @@
"filters": "Filtros", "filters": "Filtros",
"templates": "Plantillas", "templates": "Plantillas",
"folders": "Carpetas", "folders": "Carpetas",
"keywords": "Palabras clave", "keywords": "Etiquetas",
"security": "Seguridad", "security": "Seguridad",
"encryption": "Cifrado", "encryption": "Cifrado",
"files": "Archivos", "files": "Archivos",
@@ -722,21 +729,25 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Mostrar avatares de cuentas en la barra de navegación", "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." "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": { "keywords": {
"title": "Palabras clave de correo", "title": "Etiquetas de correo",
"description": "Define palabras clave (etiquetas) para organizar tus correos con colores.", "description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
"add_keyword": "Añadir palabra clave", "add_keyword": "Añadir etiqueta",
"reset_defaults": "Restablecer valores predeterminados", "reset_defaults": "Restablecer valores predeterminados",
"label_field": "Nombre para mostrar", "label_field": "Nombre para mostrar",
"label_placeholder": "ej. Trabajo, Personal, Urgente", "label_placeholder": "ej. Trabajo, Personal, Urgente",
"id_field": "ID de palabra clave", "id_field": "ID de etiqueta",
"id_placeholder": "ej. trabajo, personal", "id_placeholder": "ej. trabajo, personal",
"color_field": "Color", "color_field": "Color",
"id_exists": "Esta ID de palabra clave ya existe", "id_exists": "Este ID de etiqueta ya existe",
"edit": "Editar palabra clave", "edit": "Editar etiqueta",
"delete": "Eliminar palabra clave", "delete": "Eliminar etiqueta",
"save": "Guardar", "save": "Guardar",
"add": "Añadir", "add": "Añadir",
"cancel": "Cancelar", "cancel": "Cancelar",
@@ -1350,7 +1361,7 @@
"reject_message": "Mensaje de rechazo", "reject_message": "Mensaje de rechazo",
"reject_placeholder": "Su correo ha sido rechazado", "reject_placeholder": "Su correo ha sido rechazado",
"label_name": "Nombre de la etiqueta", "label_name": "Nombre de la etiqueta",
"label_placeholder": "ej. importante", "label_placeholder": "Seleccionar etiqueta",
"header_name": "Nombre del encabezado", "header_name": "Nombre del encabezado",
"header_placeholder": "ej. X-Mailing-List", "header_placeholder": "ej. X-Mailing-List",
"size_bytes": "Tamaño en bytes", "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.", "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_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." "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", "spam": "Spam",
"important": "Important" "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", "expand": "Développer",
"collapse": "Réduire", "collapse": "Réduire",
"expand_tooltip": "Développer", "expand_tooltip": "Développer",
@@ -657,7 +664,7 @@
"filters": "Filtres", "filters": "Filtres",
"templates": "Modèles", "templates": "Modèles",
"folders": "Dossiers", "folders": "Dossiers",
"keywords": "Mots-clés", "keywords": "Étiquettes",
"security": "Sécurité", "security": "Sécurité",
"encryption": "Chiffrement", "encryption": "Chiffrement",
"files": "Fichiers", "files": "Fichiers",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Afficher les avatars de compte sur la barre de navigation", "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." "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": { "keywords": {
"title": "Mots-clés des e-mails", "title": "Étiquettes de messagerie",
"description": "Définissez des mots-clés (étiquettes) pour organiser vos e-mails avec des couleurs.", "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 un mot-clé", "add_keyword": "Ajouter une étiquette",
"reset_defaults": "Réinitialiser par défaut", "reset_defaults": "Réinitialiser par défaut",
"label_field": "Nom d'affichage", "label_field": "Nom d'affichage",
"label_placeholder": "ex. Travail, Personnel, Urgent", "label_placeholder": "ex. Travail, Personnel, Urgent",
"id_field": "ID du mot-clé", "id_field": "ID d'étiquette",
"id_placeholder": "ex. travail, personnel", "id_placeholder": "ex. travail, personnel",
"color_field": "Couleur", "color_field": "Couleur",
"id_exists": "Cet ID de mot-clé existe déjà", "id_exists": "Cet ID d'étiquette existe déjà",
"edit": "Modifier le mot-clé", "edit": "Éditer l'étiquette",
"delete": "Supprimer le mot-clé", "delete": "Supprimer l'étiquette",
"save": "Enregistrer", "save": "Enregistrer",
"add": "Ajouter", "add": "Ajouter",
"cancel": "Annuler", "cancel": "Annuler",
"migrating": "Mise à jour du mot-clé sur les e-mails existants…", "migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants" "migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
}, },
"notifications": { "notifications": {
"test_sound": "Tester le son de notification", "test_sound": "Tester le son de notification",
@@ -1337,7 +1348,7 @@
"forward": "Transférer à", "forward": "Transférer à",
"mark_read": "Marquer comme lu", "mark_read": "Marquer comme lu",
"star": "Marquer d'une étoile", "star": "Marquer d'une étoile",
"add_label": "Ajouter un libellé", "add_label": "Ajouter une étiquette",
"discard": "Supprimer silencieusement", "discard": "Supprimer silencieusement",
"reject": "Rejeter avec un message", "reject": "Rejeter avec un message",
"keep": "Conserver dans la boîte de réception", "keep": "Conserver dans la boîte de réception",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@exemple.com", "forward_placeholder": "email@exemple.com",
"reject_message": "Message de rejet", "reject_message": "Message de rejet",
"reject_placeholder": "Votre e-mail a été rejeté", "reject_placeholder": "Votre e-mail a été rejeté",
"label_name": "Nom du libellé", "label_name": "Nom de l'étiquette",
"label_placeholder": "ex. important", "label_placeholder": "Sélectionner une étiquette",
"header_name": "Nom de l'en-tête", "header_name": "Nom de l'en-tête",
"header_placeholder": "ex. X-Mailing-List", "header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Taille en octets", "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.", "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_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." "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", "spam": "Spam",
"important": "Importanti" "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", "expand": "Espandi",
"collapse": "Comprimi", "collapse": "Comprimi",
"expand_tooltip": "Espandi", "expand_tooltip": "Espandi",
@@ -657,7 +664,7 @@
"filters": "Filtri", "filters": "Filtri",
"templates": "Modelli", "templates": "Modelli",
"folders": "Cartelle", "folders": "Cartelle",
"keywords": "Parole chiave", "keywords": "Etichette",
"security": "Sicurezza", "security": "Sicurezza",
"encryption": "Cifratura", "encryption": "Cifratura",
"files": "File", "files": "File",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Mostra avatar account nella barra di navigazione", "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." "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": { "keywords": {
"title": "Parole chiave e-mail", "title": "Etichette e-mail",
"description": "Definisci parole chiave (etichette) per organizzare le tue e-mail con colori.", "description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
"add_keyword": "Aggiungi parola chiave", "add_keyword": "Aggiungi etichetta",
"reset_defaults": "Ripristina predefiniti", "reset_defaults": "Ripristina predefiniti",
"label_field": "Nome visualizzato", "label_field": "Nome visualizzato",
"label_placeholder": "es. Lavoro, Personale, Urgente", "label_placeholder": "es. Lavoro, Personale, Urgente",
"id_field": "ID parola chiave", "id_field": "ID etichetta",
"id_placeholder": "es. lavoro, personale", "id_placeholder": "es. lavoro, personale",
"color_field": "Colore", "color_field": "Colore",
"id_exists": "Questo ID parola chiave esiste già", "id_exists": "Questo ID etichetta esiste già",
"edit": "Modifica parola chiave", "edit": "Modifica etichetta",
"delete": "Elimina parola chiave", "delete": "Elimina etichetta",
"save": "Salva", "save": "Salva",
"add": "Aggiungi", "add": "Aggiungi",
"cancel": "Annulla", "cancel": "Annulla",
"migrating": "Aggiornamento parola chiave sulle email esistenti…", "migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti" "migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
}, },
"notifications": { "notifications": {
"test_sound": "Testa il suono di notifica", "test_sound": "Testa il suono di notifica",
@@ -1350,7 +1361,7 @@
"reject_message": "Messaggio di rifiuto", "reject_message": "Messaggio di rifiuto",
"reject_placeholder": "La tua email è stata rifiutata", "reject_placeholder": "La tua email è stata rifiutata",
"label_name": "Nome dell'etichetta", "label_name": "Nome dell'etichetta",
"label_placeholder": "es. importante", "label_placeholder": "Seleziona etichetta",
"header_name": "Nome dell'intestazione", "header_name": "Nome dell'intestazione",
"header_placeholder": "es. X-Mailing-List", "header_placeholder": "es. X-Mailing-List",
"size_bytes": "Dimensione in byte", "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.", "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_title": "Utilizzo dello spazio",
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più 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": "迷惑メール", "spam": "迷惑メール",
"important": "重要" "important": "重要"
}, },
"unified_inbox": "統合受信トレイ",
"unified_sent": "すべての送信済み",
"unified_drafts": "すべての下書き",
"unified_trash": "すべてのゴミ箱",
"unified_archive": "すべてのアーカイブ",
"unified_junk": "すべての迷惑メール",
"all_accounts": "すべてのアカウント",
"expand": "展開", "expand": "展開",
"collapse": "折りたたむ", "collapse": "折りたたむ",
"expand_tooltip": "展開", "expand_tooltip": "展開",
@@ -657,7 +664,7 @@
"filters": "フィルター", "filters": "フィルター",
"templates": "テンプレート", "templates": "テンプレート",
"folders": "フォルダー", "folders": "フォルダー",
"keywords": "キーワード", "keywords": "ラベル",
"security": "セキュリティ", "security": "セキュリティ",
"encryption": "暗号化", "encryption": "暗号化",
"files": "ファイル", "files": "ファイル",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "ナビゲーションレールにアカウントアバターを表示", "label": "ナビゲーションレールにアカウントアバターを表示",
"description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。" "description": "ナビゲーションレールの下部に個々のアカウントの丸を表示して素早く切り替えできるようにし、その下にサインアウトボタンを配置します。"
},
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
} }
}, },
"keywords": { "keywords": {
"title": "メールキーワード", "title": "メールラベル",
"description": "色でメールを整理するためのキーワード(ラベル/タグ)を定義します。", "description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
"add_keyword": "キーワードを追加", "add_keyword": "ラベルを追加",
"reset_defaults": "デフォルトに戻す", "reset_defaults": "デフォルトに戻す",
"label_field": "表示名", "label_field": "表示名",
"label_placeholder": "例:仕事、個人、緊急", "label_placeholder": "例:仕事、個人、緊急",
"id_field": "キーワードID", "id_field": "ラベルID",
"id_placeholder": "例:work、personal", "id_placeholder": "例:work、personal",
"color_field": "色", "color_field": "色",
"id_exists": "このキーワードIDは既に存在します", "id_exists": "このラベルIDは既に存在します",
"edit": "キーワードを編集", "edit": "ラベルを編集",
"delete": "キーワードを削除", "delete": "ラベルを削除",
"save": "保存", "save": "保存",
"add": "追加", "add": "追加",
"cancel": "キャンセル", "cancel": "キャンセル",
"migrating": "既存のメールでキーワードを更新中…", "migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールでのキーワード更新に失敗しました" "migration_error": "既存のメールのラベルの更新に失敗しました"
}, },
"notifications": { "notifications": {
"test_sound": "通知音をテスト", "test_sound": "通知音をテスト",
@@ -923,7 +934,7 @@
"star": "スター付け / 解除", "star": "スター付け / 解除",
"mark_read": "既読 / 未読にする", "mark_read": "既読 / 未読にする",
"archive": "アーカイブ", "archive": "アーカイブ",
"tag": "タグ", "tag": "ラベル",
"spam": "スパムとしてマーク", "spam": "スパムとしてマーク",
"none_selected": "アクションが選択されていません", "none_selected": "アクションが選択されていません",
"mode_label": "表示モード", "mode_label": "表示モード",
@@ -1350,7 +1361,7 @@
"reject_message": "拒否メッセージ", "reject_message": "拒否メッセージ",
"reject_placeholder": "あなたのメールは拒否されました", "reject_placeholder": "あなたのメールは拒否されました",
"label_name": "ラベル名", "label_name": "ラベル名",
"label_placeholder": "例:重要", "label_placeholder": "ラベルを選択",
"header_name": "ヘッダー名", "header_name": "ヘッダー名",
"header_placeholder": "例:X-Mailing-List", "header_placeholder": "例:X-Mailing-List",
"size_bytes": "サイズ(バイト)", "size_bytes": "サイズ(バイト)",
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。", "demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
"quota_title": "ストレージ使用量", "quota_title": "ストレージ使用量",
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。" "quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
},
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
} }
} }
+29 -15
View File
@@ -104,6 +104,13 @@
"spam": "스팸함", "spam": "스팸함",
"important": "중요 편지함" "important": "중요 편지함"
}, },
"unified_inbox": "통합 받은편지함",
"unified_sent": "모든 보낸편지함",
"unified_drafts": "모든 임시보관함",
"unified_trash": "모든 휴지통",
"unified_archive": "모든 보관함",
"unified_junk": "모든 스팸함",
"all_accounts": "모든 계정",
"expand": "펼치기", "expand": "펼치기",
"collapse": "접기", "collapse": "접기",
"expand_tooltip": "펼치기", "expand_tooltip": "펼치기",
@@ -657,7 +664,7 @@
"filters": "필터", "filters": "필터",
"templates": "템플릿", "templates": "템플릿",
"folders": "폴더", "folders": "폴더",
"keywords": "키워드", "keywords": "태그",
"security": "보안", "security": "보안",
"files": "파일", "files": "파일",
"contacts": "연락처", "contacts": "연락처",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "내비게이션 바에 계정 아바타 표시", "label": "내비게이션 바에 계정 아바타 표시",
"description": "내비게이션 바 아래에 계정 프로필을 표시해서 빠르게 전환할 수 있어요." "description": "내비게이션 바 아래에 계정 프로필을 표시해서 빠르게 전환할 수 있어요."
},
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
} }
}, },
"keywords": { "keywords": {
"title": "이메일 키워드", "title": "이메일 태그",
"description": "이메일을 분류할 키워드(라벨/태그)설정해 보세요. 설정한 키워드는 서버에 저장돼요.", "description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
"add_keyword": "키워드 추가", "add_keyword": "태그 추가",
"reset_defaults": "기본값으로 초기화", "reset_defaults": "기본값으로 초기화",
"label_field": "표시 이름", "label_field": "표시 이름",
"label_placeholder": "예: 업무, 개인, 긴급", "label_placeholder": "예: 업무, 개인, 긴급",
"id_field": "키워드 ID", "id_field": "태그 ID",
"id_placeholder": "예: work, personal", "id_placeholder": "예: work, personal",
"color_field": "색상", "color_field": "색상",
"id_exists": "이미 존재하는 키워드 ID예요", "id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "키워드 수정", "edit": "태그 편집",
"delete": "키워드 삭제", "delete": "태그 삭제",
"save": "저장", "save": "저장",
"add": "추가", "add": "추가",
"cancel": "취소", "cancel": "취소",
"migrating": "기존 이메일의 키워드를 업데이트하는 중...", "migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 키워드를 업데이트하지 못했어요" "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
}, },
"notifications": { "notifications": {
"test_sound": "알림음 테스트", "test_sound": "알림음 테스트",
@@ -1337,7 +1348,7 @@
"forward": "다음으로 전달", "forward": "다음으로 전달",
"mark_read": "읽은 상태로 표시", "mark_read": "읽은 상태로 표시",
"star": "별표 달기", "star": "별표 달기",
"add_label": "라벨(태그) 추가", "add_label": "태그 추가",
"discard": "삭제 (조용히 지움)", "discard": "삭제 (조용히 지움)",
"reject": "메시지와 함께 수신 거부", "reject": "메시지와 함께 수신 거부",
"keep": "받은편지함에 유지", "keep": "받은편지함에 유지",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@example.com", "forward_placeholder": "email@example.com",
"reject_message": "거부 메시지", "reject_message": "거부 메시지",
"reject_placeholder": "메일 수신이 거부되었습니다", "reject_placeholder": "메일 수신이 거부되었습니다",
"label_name": "라벨 이름", "label_name": "태그 이름",
"label_placeholder": "예: important", "label_placeholder": "태그 선택",
"header_name": "헤더 이름", "header_name": "헤더 이름",
"header_placeholder": "예: X-Mailing-List", "header_placeholder": "예: X-Mailing-List",
"size_bytes": "크기 (바이트)", "size_bytes": "크기 (바이트)",
@@ -1524,8 +1535,8 @@
"delete": "삭제", "delete": "삭제",
"mark_as_spam": "스팸 신고", "mark_as_spam": "스팸 신고",
"not_spam": "정상 메일", "not_spam": "정상 메일",
"color_tag": "라벨 지정", "color_tag": "태그",
"remove_color": "라벨 제거", "remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨", "items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정" "edit_draft": "임시보관 메일 수정"
}, },
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "현재 데모 모드예요. 모든 작업은 브라우저 안에서만 이뤄집니다. 언제든 '초기화'를 누르면 처음의 깨끗한 샘플 데이터로 돌아가요.", "demo_banner_desc": "현재 데모 모드예요. 모든 작업은 브라우저 안에서만 이뤄집니다. 언제든 '초기화'를 누르면 처음의 깨끗한 샘플 데이터로 돌아가요.",
"quota_title": "저장 공간 사용량", "quota_title": "저장 공간 사용량",
"quota_desc": "편지함 용량을 여기서 확인하세요. 공간을 많이 쓸수록 원이 점점 채워질 거예요." "quota_desc": "편지함 용량을 여기서 확인하세요. 공간을 많이 쓸수록 원이 점점 채워질 거예요."
},
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
} }
} }
+29 -15
View File
@@ -104,6 +104,13 @@
"spam": "Mēstules", "spam": "Mēstules",
"important": "Svarīgi" "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", "expand": "Izvērst",
"collapse": "Sairt", "collapse": "Sairt",
"expand_tooltip": "Izvērst", "expand_tooltip": "Izvērst",
@@ -657,7 +664,7 @@
"filters": "Filtri", "filters": "Filtri",
"templates": "Veidnes", "templates": "Veidnes",
"folders": "Mapes", "folders": "Mapes",
"keywords": "Atslēgvārdi", "keywords": "Tagi",
"security": "Drošība", "security": "Drošība",
"files": "Faili", "files": "Faili",
"contacts": "Kontakti", "contacts": "Kontakti",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Rādīt kontu avatarus navigācijas joslā", "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." "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": { "keywords": {
"title": "Vēstuļu atslēgvārdi", "title": "E-pasta tagi",
"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.", "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 atslēgvārdu", "add_keyword": "Pievienot tagu",
"reset_defaults": "Atiestatīt noklusējumu", "reset_defaults": "Atiestatīt noklusējumu",
"label_field": "Redzamais nosaukums", "label_field": "Redzamais nosaukums",
"label_placeholder": "piem., Darbs, Personīgi, Steidzami", "label_placeholder": "piem., Darbs, Personīgi, Steidzami",
"id_field": "Atslēgvārda identifikators", "id_field": "Taga identifikators",
"id_placeholder": "piem., darbs, personigi", "id_placeholder": "piem., darbs, personigi",
"color_field": "Krāsa", "color_field": "Krāsa",
"id_exists": "Šāds atslēgvārda identifikators jau eksistē", "id_exists": "Šāds taga identifikators jau pastāv",
"edit": "Rediģēt atslēgvārdu", "edit": "Rediģēt tagu",
"delete": "Dzēst atslēgvārdu", "delete": "Dzēst tagu",
"save": "Saglabāt", "save": "Saglabāt",
"add": "Pievienot", "add": "Pievienot",
"cancel": "Atcelt", "cancel": "Atcelt",
"migrating": "Atjaunina atslēgvārdu esošajās vēstulēs...", "migrating": "Taga atjaunināšana esošajos e-pastos…",
"migration_error": "Neizdevās atjaunināt atslēgvārdu esošajās vēstulēs" "migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
}, },
"notifications": { "notifications": {
"test_sound": "Pārbaudīt paziņojuma skaņu", "test_sound": "Pārbaudīt paziņojuma skaņu",
@@ -1337,7 +1348,7 @@
"forward": "Pārsūtīt uz", "forward": "Pārsūtīt uz",
"mark_read": "Atzīmēt kā izlasītu", "mark_read": "Atzīmēt kā izlasītu",
"star": "Pievienot zvaigznīti", "star": "Pievienot zvaigznīti",
"add_label": "Pievienot etiķeti", "add_label": "Pievienot tagu",
"discard": "Dzēst (bez paziņojuma)", "discard": "Dzēst (bez paziņojuma)",
"reject": "Noraidīt ar ziņojumu", "reject": "Noraidīt ar ziņojumu",
"keep": "Atstāt iesūtnē", "keep": "Atstāt iesūtnē",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "lietotajs@piemers.lv", "forward_placeholder": "lietotajs@piemers.lv",
"reject_message": "Noraidīšanas ziņojums", "reject_message": "Noraidīšanas ziņojums",
"reject_placeholder": "Jūsu e-pasts tika noraidīts", "reject_placeholder": "Jūsu e-pasts tika noraidīts",
"label_name": "Etiķetes nosaukums", "label_name": "Taga nosaukums",
"label_placeholder": "piem., svarigi", "label_placeholder": "Izvēlēties tagu",
"header_name": "Galvenes nosaukums", "header_name": "Galvenes nosaukums",
"header_placeholder": "piem., X-Mailing-List", "header_placeholder": "piem., X-Mailing-List",
"size_bytes": "Izmērs baitos", "size_bytes": "Izmērs baitos",
@@ -1524,8 +1535,8 @@
"delete": "Dzēst", "delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli", "mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule", "not_spam": "Nav mēstule",
"color_tag": "Etiķete", "color_tag": "Tags",
"remove_color": "Noņemt etiķeti", "remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas", "items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu" "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.", "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_title": "Krātuves izmantošana",
"quota_desc": "Sekojiet līdzi savas pastkastes aizpildījumam šeit." "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", "spam": "Spam",
"important": "Belangrijk" "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", "expand": "Uitklappen",
"collapse": "Inklappen", "collapse": "Inklappen",
"expand_tooltip": "Uitklappen", "expand_tooltip": "Uitklappen",
@@ -657,7 +664,7 @@
"filters": "Filters", "filters": "Filters",
"templates": "Sjablonen", "templates": "Sjablonen",
"folders": "Mappen", "folders": "Mappen",
"keywords": "Sleutelwoorden", "keywords": "Labels",
"security": "Beveiliging", "security": "Beveiliging",
"encryption": "Versleuteling", "encryption": "Versleuteling",
"files": "Bestanden", "files": "Bestanden",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Accountavatars tonen op navigatiebalk", "label": "Accountavatars tonen op navigatiebalk",
"description": "Toon individuele accountcirkels onderaan de navigatiebalk voor snel wisselen, met een afmeldknop eronder." "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": { "keywords": {
"title": "E-mail trefwoorden", "title": "E-maillabels",
"description": "Definieer trefwoorden (labels/tags) om uw e-mails met kleuren te organiseren.", "description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
"add_keyword": "Trefwoord toevoegen", "add_keyword": "Label toevoegen",
"reset_defaults": "Standaardwaarden herstellen", "reset_defaults": "Standaardwaarden herstellen",
"label_field": "Weergavenaam", "label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent", "label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
"id_field": "Trefwoord-ID", "id_field": "Label-ID",
"id_placeholder": "bijv. werk, persoonlijk", "id_placeholder": "bijv. werk, persoonlijk",
"color_field": "Kleur", "color_field": "Kleur",
"id_exists": "Dit trefwoord-ID bestaat al", "id_exists": "Deze label-ID bestaat al",
"edit": "Trefwoord bewerken", "edit": "Label bewerken",
"delete": "Trefwoord verwijderen", "delete": "Label verwijderen",
"save": "Opslaan", "save": "Opslaan",
"add": "Toevoegen", "add": "Toevoegen",
"cancel": "Annuleren", "cancel": "Annuleren",
"migrating": "Trefwoord bijwerken op bestaande e-mails…", "migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails" "migration_error": "Label bijwerken op bestaande e-mails mislukt"
}, },
"notifications": { "notifications": {
"test_sound": "Meldingsgeluid testen", "test_sound": "Meldingsgeluid testen",
@@ -1350,7 +1361,7 @@
"reject_message": "Afwijzingsbericht", "reject_message": "Afwijzingsbericht",
"reject_placeholder": "Uw e-mail is afgewezen", "reject_placeholder": "Uw e-mail is afgewezen",
"label_name": "Labelnaam", "label_name": "Labelnaam",
"label_placeholder": "bijv. belangrijk", "label_placeholder": "Label kiezen",
"header_name": "Headernaam", "header_name": "Headernaam",
"header_placeholder": "bijv. X-Mailing-List", "header_placeholder": "bijv. X-Mailing-List",
"size_bytes": "Grootte in bytes", "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.", "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_title": "Opslaggebruik",
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt." "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", "spam": "Spam",
"important": "Ważne" "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ń", "expand": "Rozwiń",
"collapse": "Zwiń", "collapse": "Zwiń",
"expand_tooltip": "Rozwiń", "expand_tooltip": "Rozwiń",
@@ -657,7 +664,7 @@
"filters": "Filtry", "filters": "Filtry",
"templates": "Szablony", "templates": "Szablony",
"folders": "Foldery", "folders": "Foldery",
"keywords": "Słowa kluczowe", "keywords": "Etykiety",
"security": "Bezpieczeństwo", "security": "Bezpieczeństwo",
"files": "Pliki", "files": "Pliki",
"contacts": "Kontakty", "contacts": "Kontakty",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Pokazuj awatary kont na pasku nawigacyjnym", "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." "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": { "keywords": {
"title": "Słowa kluczowe wiadomości e-mail", "title": "Etykiety 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.", "description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
"add_keyword": "Dodaj słowo kluczowe", "add_keyword": "Dodaj etykietę",
"reset_defaults": "Przywróć domyślne", "reset_defaults": "Przywróć domyślne",
"label_field": "Nazwa wyświetlana", "label_field": "Nazwa wyświetlana",
"label_placeholder": "np. Praca, Osobiste, Pilne", "label_placeholder": "np. Praca, Osobiste, Pilne",
"id_field": "Identyfikator słowa kluczowego", "id_field": "ID etykiety",
"id_placeholder": "np. praca, osobiste", "id_placeholder": "np. praca, osobiste",
"color_field": "Kolor", "color_field": "Kolor",
"id_exists": "Ten identyfikator słowa kluczowego już istnieje", "id_exists": "Ten ID etykiety już istnieje",
"edit": "Edytuj słowo kluczowe", "edit": "Edytuj etykietę",
"delete": "Usuń słowo kluczowe", "delete": "Usuń etykietę",
"save": "Zapisz", "save": "Zapisz",
"add": "Dodaj", "add": "Dodaj",
"cancel": "Anuluj", "cancel": "Anuluj",
"migrating": "Aktualizowanie 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ć słowa kluczowego w istniejących wiadomościach e-mail" "migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
}, },
"notifications": { "notifications": {
"test_sound": "Przetestuj dźwięk powiadomienia", "test_sound": "Przetestuj dźwięk powiadomienia",
@@ -1350,7 +1361,7 @@
"reject_message": "Wiadomość odrzucenia", "reject_message": "Wiadomość odrzucenia",
"reject_placeholder": "Twoja wiadomość e-mail została odrzucona", "reject_placeholder": "Twoja wiadomość e-mail została odrzucona",
"label_name": "Nazwa etykiety", "label_name": "Nazwa etykiety",
"label_placeholder": "np. ważne", "label_placeholder": "Wybierz etykietę",
"header_name": "Nazwa nagłówka", "header_name": "Nazwa nagłówka",
"header_placeholder": "np. X-Mailing-List", "header_placeholder": "np. X-Mailing-List",
"size_bytes": "Rozmiar w bajtach", "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.", "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_title": "Wykorzystanie miejsca",
"quota_desc": "Tutaj możesz śledzić rozmiar swojej skrzynki pocztowej. Okrąg wypełnia się wraz ze wzrostem użycia przestrzeni." "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", "spam": "Spam",
"important": "Importante" "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", "expand": "Expandir",
"collapse": "Recolher", "collapse": "Recolher",
"expand_tooltip": "Expandir", "expand_tooltip": "Expandir",
@@ -657,7 +664,7 @@
"filters": "Filtros", "filters": "Filtros",
"templates": "Modelos", "templates": "Modelos",
"folders": "Pastas", "folders": "Pastas",
"keywords": "Palavras-chave", "keywords": "Etiquetas",
"security": "Segurança", "security": "Segurança",
"encryption": "Criptografia", "encryption": "Criptografia",
"files": "Arquivos", "files": "Arquivos",
@@ -722,25 +729,29 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Mostrar avatares de conta na barra de navegação", "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." "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": { "keywords": {
"title": "Palavras-chave de e-mail", "title": "Etiquetas de e-mail",
"description": "Defina palavras-chave (rótulos/tags) para organizar seus e-mails com cores.", "description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
"add_keyword": "Adicionar palavra-chave", "add_keyword": "Adicionar etiqueta",
"reset_defaults": "Restaurar padrões", "reset_defaults": "Restaurar padrões",
"label_field": "Nome de exibição", "label_field": "Nome de exibição",
"label_placeholder": "ex. Trabalho, Pessoal, Urgente", "label_placeholder": "ex. Trabalho, Pessoal, Urgente",
"id_field": "ID da palavra-chave", "id_field": "ID da etiqueta",
"id_placeholder": "ex. trabalho, pessoal", "id_placeholder": "ex. trabalho, pessoal",
"color_field": "Cor", "color_field": "Cor",
"id_exists": "Este ID de palavra-chave já existe", "id_exists": "Este ID de etiqueta já existe",
"edit": "Editar palavra-chave", "edit": "Editar etiqueta",
"delete": "Excluir palavra-chave", "delete": "Eliminar etiqueta",
"save": "Salvar", "save": "Salvar",
"add": "Adicionar", "add": "Adicionar",
"cancel": "Cancelar", "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" "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
}, },
"notifications": { "notifications": {
@@ -1337,7 +1348,7 @@
"forward": "Encaminhar para", "forward": "Encaminhar para",
"mark_read": "Marcar como lido", "mark_read": "Marcar como lido",
"star": "Destacar mensagem", "star": "Destacar mensagem",
"add_label": "Adicionar rótulo", "add_label": "Adicionar etiqueta",
"discard": "Descartar (excluir silenciosamente)", "discard": "Descartar (excluir silenciosamente)",
"reject": "Rejeitar com mensagem", "reject": "Rejeitar com mensagem",
"keep": "Manter na caixa de entrada", "keep": "Manter na caixa de entrada",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "email@exemplo.com", "forward_placeholder": "email@exemplo.com",
"reject_message": "Mensagem de rejeição", "reject_message": "Mensagem de rejeição",
"reject_placeholder": "Seu e-mail foi rejeitado", "reject_placeholder": "Seu e-mail foi rejeitado",
"label_name": "Nome do rótulo", "label_name": "Nome da etiqueta",
"label_placeholder": "ex. importante", "label_placeholder": "Selecionar etiqueta",
"header_name": "Nome do cabeçalho", "header_name": "Nome do cabeçalho",
"header_placeholder": "ex. X-Mailing-List", "header_placeholder": "ex. X-Mailing-List",
"size_bytes": "Tamanho em bytes", "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.", "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_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." "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": "Спам", "spam": "Спам",
"important": "Важные" "important": "Важные"
}, },
"unified_inbox": "Общие входящие",
"unified_sent": "Все отправленные",
"unified_drafts": "Все черновики",
"unified_trash": "Все корзины",
"unified_archive": "Все архивы",
"unified_junk": "Весь спам",
"all_accounts": "Все аккаунты",
"expand": "Развернуть", "expand": "Развернуть",
"collapse": "Свернуть", "collapse": "Свернуть",
"expand_tooltip": "Развернуть", "expand_tooltip": "Развернуть",
@@ -657,7 +664,7 @@
"filters": "Фильтры", "filters": "Фильтры",
"templates": "Шаблоны", "templates": "Шаблоны",
"folders": "Папки", "folders": "Папки",
"keywords": "Ключевые слова", "keywords": "Теги",
"security": "Безопасность", "security": "Безопасность",
"files": "Файлы", "files": "Файлы",
"contacts": "Контакты", "contacts": "Контакты",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Показать аватары аккаунтов на панели навигации", "label": "Показать аватары аккаунтов на панели навигации",
"description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже." "description": "Отображать отдельные круги аккаунтов в нижней части панели навигации для быстрого переключения, с кнопкой выхода ниже."
},
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
} }
}, },
"keywords": { "keywords": {
"title": "Ключевые слова писем", "title": "Теги электронной почты",
"description": "Определите ключевые слова (метки/теги) для организации писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.", "description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
"add_keyword": "Добавить ключевое слово", "add_keyword": "Добавить тег",
"reset_defaults": "Сбросить по умолчанию", "reset_defaults": "Сбросить по умолчанию",
"label_field": "Отображаемое название", "label_field": "Отображаемое название",
"label_placeholder": "напр., Работа, Личное, Срочно", "label_placeholder": "напр., Работа, Личное, Срочно",
"id_field": "Идентификатор ключевого слова", "id_field": "Идентификатор тега",
"id_placeholder": "напр., work, personal", "id_placeholder": "напр., work, personal",
"color_field": "Цвет", "color_field": "Цвет",
"id_exists": "Этот идентификатор ключевого слова уже существует", "id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать ключевое слово", "edit": "Редактировать тег",
"delete": "Удалить ключевое слово", "delete": "Удалить тег",
"save": "Сохранить", "save": "Сохранить",
"add": "Добавить", "add": "Добавить",
"cancel": "Отмена", "cancel": "Отмена",
"migrating": "Обновление ключевого слова в существующих письмах…", "migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить ключевое слово в существующих письмах" "migration_error": "Не удалось обновить тег в существующих письмах"
}, },
"notifications": { "notifications": {
"test_sound": "Проверить звук уведомления", "test_sound": "Проверить звук уведомления",
@@ -1337,7 +1348,7 @@
"forward": "Переслать на", "forward": "Переслать на",
"mark_read": "Отметить прочитанным", "mark_read": "Отметить прочитанным",
"star": "Пометить сообщение", "star": "Пометить сообщение",
"add_label": "Добавить метку", "add_label": "Добавить тег",
"discard": "Удалить (без уведомления)", "discard": "Удалить (без уведомления)",
"reject": "Отклонить с сообщением", "reject": "Отклонить с сообщением",
"keep": "Оставить во входящих", "keep": "Оставить во входящих",
@@ -1349,8 +1360,8 @@
"forward_placeholder": "user@пример.рф", "forward_placeholder": "user@пример.рф",
"reject_message": "Сообщение об отклонении", "reject_message": "Сообщение об отклонении",
"reject_placeholder": "Ваше письмо было отклонено", "reject_placeholder": "Ваше письмо было отклонено",
"label_name": "Название метки", "label_name": "Название тега",
"label_placeholder": "напр., важное", "label_placeholder": "Выбрать тег",
"header_name": "Имя заголовка", "header_name": "Имя заголовка",
"header_placeholder": "напр., X-Mailing-List", "header_placeholder": "напр., X-Mailing-List",
"size_bytes": "Размер в байтах", "size_bytes": "Размер в байтах",
@@ -1524,8 +1535,8 @@
"delete": "Удалить", "delete": "Удалить",
"mark_as_spam": "Отметить как спам", "mark_as_spam": "Отметить как спам",
"not_spam": "Не спам", "not_spam": "Не спам",
"color_tag": "Метка", "color_tag": "Тег",
"remove_color": "Убрать метку", "remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано", "items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик" "edit_draft": "Редактировать черновик"
}, },
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Вы в демо-режиме — всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.", "demo_banner_desc": "Вы в демо-режиме — всё остаётся в вашем браузере. Нажмите «Сбросить демо» в любое время, чтобы начать заново с чистыми данными.",
"quota_title": "Использование хранилища", "quota_title": "Использование хранилища",
"quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства." "quota_desc": "Отслеживайте размер вашего почтового ящика здесь. Круг заполняется по мере использования пространства."
},
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
} }
} }
+14
View File
@@ -104,6 +104,13 @@
"spam": "Спам", "spam": "Спам",
"important": "важливо" "important": "важливо"
}, },
"unified_inbox": "Спільні вхідні",
"unified_sent": "Усі надіслані",
"unified_drafts": "Усі чернетки",
"unified_trash": "Усі кошики",
"unified_archive": "Усі архіви",
"unified_junk": "Весь спам",
"all_accounts": "Усі облікові записи",
"expand": "Розгорнути", "expand": "Розгорнути",
"collapse": "Згорнути", "collapse": "Згорнути",
"expand_tooltip": "Розгорнути", "expand_tooltip": "Розгорнути",
@@ -722,6 +729,10 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "Показувати аватари облікових записів на панелі навігації", "label": "Показувати аватари облікових записів на панелі навігації",
"description": "Відображати кола окремих облікових записів у нижній частині панелі навігації для швидкого перемикання з кнопкою виходу внизу." "description": "Відображати кола окремих облікових записів у нижній частині панелі навігації для швидкого перемикання з кнопкою виходу внизу."
},
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
} }
}, },
"keywords": { "keywords": {
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "Ви в демонстраційному режимі — все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.", "demo_banner_desc": "Ви в демонстраційному режимі — все залишається у вашому браузері. Будь-коли натисніть «Скинути демонстрацію», щоб почати заново з чистими зразками даних.",
"quota_title": "Використання сховища", "quota_title": "Використання сховища",
"quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця." "quota_desc": "Відстежуйте розмір своєї поштової скриньки тут. Коло заповнюється, коли ви використовуєте більше місця."
},
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
} }
} }
+26 -12
View File
@@ -104,6 +104,13 @@
"spam": "垃圾邮件", "spam": "垃圾邮件",
"important": "重要" "important": "重要"
}, },
"unified_inbox": "统一收件箱",
"unified_sent": "所有已发送",
"unified_drafts": "所有草稿",
"unified_trash": "所有已删除",
"unified_archive": "所有归档",
"unified_junk": "所有垃圾邮件",
"all_accounts": "所有账户",
"expand": "展开", "expand": "展开",
"collapse": "收起", "collapse": "收起",
"expand_tooltip": "展开", "expand_tooltip": "展开",
@@ -657,7 +664,7 @@
"filters": "过滤器", "filters": "过滤器",
"templates": "模板", "templates": "模板",
"folders": "文件夹", "folders": "文件夹",
"keywords": "关键词", "keywords": "标签",
"security": "安全", "security": "安全",
"files": "文件", "files": "文件",
"contacts": "联系人", "contacts": "联系人",
@@ -722,26 +729,30 @@
"show_rail_account_list": { "show_rail_account_list": {
"label": "在导航导轨上显示账户头像", "label": "在导航导轨上显示账户头像",
"description": "在导航栏底部显示账户头像,方便快速切换;下方会保留退出按钮。" "description": "在导航栏底部显示账户头像,方便快速切换;下方会保留退出按钮。"
},
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
} }
}, },
"keywords": { "keywords": {
"title": "邮件关键字", "title": "电子邮件标签",
"description": "定义关键字(标签)并用颜色整理邮件。这些关键字会作为 JMAP 关键字保存在服务器上。", "description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
"add_keyword": "添加关键字", "add_keyword": "添加标签",
"reset_defaults": "重置为默认值", "reset_defaults": "重置为默认值",
"label_field": "显示名称", "label_field": "显示名称",
"label_placeholder": "例如工作、个人、紧急", "label_placeholder": "例如工作、个人、紧急",
"id_field": "关键字 ID", "id_field": "标签ID",
"id_placeholder": "例如工作、个人", "id_placeholder": "例如工作、个人",
"color_field": "颜色", "color_field": "颜色",
"id_exists": "该关键字 ID 已存在", "id_exists": "此标签ID已存在",
"edit": "编辑关键字", "edit": "编辑标签",
"delete": "删除关键字", "delete": "删除标签",
"save": "保存", "save": "保存",
"add": "添加", "add": "添加",
"cancel": "取消", "cancel": "取消",
"migrating": "正在更新现有邮件的关键字...", "migrating": "正在更新现有邮件的标签…",
"migration_error": "无法更新现有邮件的关键字" "migration_error": "更新现有邮件的标签失败"
}, },
"notifications": { "notifications": {
"test_sound": "测试通知声音", "test_sound": "测试通知声音",
@@ -1350,7 +1361,7 @@
"reject_message": "拒绝留言", "reject_message": "拒绝留言",
"reject_placeholder": "您的邮件已被拒绝", "reject_placeholder": "您的邮件已被拒绝",
"label_name": "标签名称", "label_name": "标签名称",
"label_placeholder": "例如,重要", "label_placeholder": "选择标签",
"header_name": "标头名称", "header_name": "标头名称",
"header_placeholder": "例如,X-Mailing-List", "header_placeholder": "例如,X-Mailing-List",
"size_bytes": "大小(以字节为单位)", "size_bytes": "大小(以字节为单位)",
@@ -1525,7 +1536,7 @@
"mark_as_spam": "举报垃圾邮件", "mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件", "not_spam": "不是垃圾邮件",
"color_tag": "标签", "color_tag": "标签",
"remove_color": "除标签", "remove_color": "除标签",
"items_selected": "已选择 {count} 封邮件", "items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿" "edit_draft": "编辑草稿"
}, },
@@ -2533,5 +2544,8 @@
"demo_banner_desc": "当前为演示模式,所有数据仅保存在浏览器中。随时点击\"重置演示\"即可恢复初始示例数据。", "demo_banner_desc": "当前为演示模式,所有数据仅保存在浏览器中。随时点击\"重置演示\"即可恢复初始示例数据。",
"quota_title": "存储使用情况", "quota_title": "存储使用情况",
"quota_desc": "在这里查看邮箱的存储使用情况。随着空间使用增加,进度圆环会逐渐填满。" "quota_desc": "在这里查看邮箱的存储使用情况。随着空间使用增加,进度圆环会逐渐填满。"
},
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
} }
} }
+13 -2
View File
@@ -151,15 +151,26 @@ describe('filter-store', () => {
}); });
describe('fetchFilters', () => { describe('fetchFilters', () => {
it('should set isOpaque for scripts without metadata', async () => { it('parses external rules from scripts without metadata', async () => {
const mockClient = { const mockClient = {
getSieveCapabilities: () => null, getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }], getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }', getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
}; };
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient); 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().isOpaque).toBe(true);
expect(useFilterStore.getState().rules).toEqual([]);
}); });
it('should parse rules from metadata-bearing script', async () => { it('should parse rules from metadata-bearing script', async () => {
+5
View File
@@ -49,6 +49,7 @@ interface AuthState {
syncIdentities: () => void; syncIdentities: () => void;
refreshIdentities: () => Promise<void>; refreshIdentities: () => Promise<void>;
getClientForAccount: (accountId: string) => JMAPClient | undefined; getClientForAccount: (accountId: string) => JMAPClient | undefined;
getAllConnectedClients: () => Map<string, JMAPClient>;
} }
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
@@ -1529,6 +1530,10 @@ export const useAuthStore = create<AuthState>()(
getClientForAccount: (accountId: string) => { getClientForAccount: (accountId: string) => {
return clients.get(accountId); return clients.get(accountId);
}, },
getAllConnectedClients: () => {
return new Map(clients);
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',
+236 -9
View File
@@ -1,10 +1,14 @@
import { create } from "zustand"; 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 type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks"; 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 { interface EmailStore {
emails: Email[]; emails: Email[];
@@ -39,6 +43,12 @@ interface EmailStore {
isAdvancedSearchOpen: boolean; isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null; searchAbortController: AbortController | null;
// Unified mailbox state
isUnifiedView: boolean;
unifiedRole: UnifiedMailboxRole | null;
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
setEmails: (emails: Email[]) => void; setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void; selectEmail: (email: Email | null) => void;
@@ -77,7 +87,7 @@ interface EmailStore {
// Batch operations // Batch operations
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>; 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>; batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Spam operations // Spam operations
@@ -107,6 +117,12 @@ interface EmailStore {
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>; setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => 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 // Mock data for demo
loadMockData: () => void; loadMockData: () => void;
} }
@@ -175,6 +191,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isAdvancedSearchOpen: false, isAdvancedSearchOpen: false,
searchAbortController: null, searchAbortController: null,
// Unified mailbox state
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
unifiedCounts: [],
// Spam undo cache // Spam undo cache
spamUndoCache: new Map(), spamUndoCache: new Map(),
@@ -334,11 +356,52 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}, },
loadMoreEmails: async (client) => { 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 // Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return; 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 }); set({ isLoadingMore: true, error: null });
try { try {
// Get emails per page from settings // Get emails per page from settings
@@ -919,7 +982,26 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const emailIdsArray = Array.from(selectedEmailIds); 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 // Update local state
const updatedEmails = emails.map(email => const updatedEmails = emails.map(email =>
@@ -962,14 +1044,60 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
batchDelete: async (client) => { batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, mailboxes } = get(); const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get();
if (selectedEmailIds.size === 0) return; if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const emailIdsArray = Array.from(selectedEmailIds); 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 // Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); 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 }); set({ isLoading: true, error: null });
try { try {
const emailIdsArray = Array.from(selectedEmailIds); 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 // Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); 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 // Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox); if (!get().isUnifiedView) {
await get().fetchEmails(client, get().selectedMailbox);
}
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : "Failed to move emails", 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: () => { loadMockData: () => {
const mockEmails: Email[] = [ const mockEmails: Email[] = [
{ {
+54 -15
View File
@@ -16,6 +16,7 @@ interface FilterStore {
isOpaque: boolean; isOpaque: boolean;
rawScript: string; rawScript: string;
vacationSettings: VacationSieveConfig | null; vacationSettings: VacationSieveConfig | null;
externalRequires: string[];
setSupported: (supported: boolean) => void; setSupported: (supported: boolean) => void;
fetchFilters: (client: IJMAPClient) => Promise<void>; fetchFilters: (client: IJMAPClient) => Promise<void>;
@@ -43,6 +44,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false, isOpaque: false,
rawScript: '', rawScript: '',
vacationSettings: null, vacationSettings: null,
externalRequires: [],
setSupported: (supported) => set({ isSupported: supported }), setSupported: (supported) => set({ isSupported: supported }),
@@ -74,10 +76,22 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
if (result.isOpaque) { if (result.isOpaque) {
debug.log('filters', 'Sieve script is opaque (hand-edited)'); 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 { } else {
debug.log('filters', 'Parsed', result.rules.length, 'filter rules'); 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) { } catch (error) {
debug.error('Failed to fetch filters:', error); debug.error('Failed to fetch filters:', error);
@@ -91,13 +105,13 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
saveFilters: async (client) => { saveFilters: async (client) => {
set({ isSaving: true, error: null }); set({ isSaving: true, error: null });
try { try {
const { isOpaque, rawScript, rules, activeScriptId, vacationSettings } = get(); const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get();
let content: string; let content: string;
if (isOpaque) { if (isOpaque) {
content = rawScript; content = rawScript;
} else { } else {
content = generateScript(rules, vacationSettings || undefined); content = generateScript(rules, vacationSettings || undefined, { externalRequires });
} }
if (activeScriptId) { if (activeScriptId) {
@@ -124,40 +138,60 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
}, },
addRule: (rule) => { 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) => { updateRule: (ruleId, updates) => {
set((state) => ({ 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) => { deleteRule: (ruleId) => {
set((state) => ({ 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) => { reorderRules: (ruleIds) => {
// Only reorder bulwark rules; external rules always stay at the end in
// their original order.
set((state) => { set((state) => {
const ruleMap = new Map(state.rules.map(r => [r.id, r])); const bulwarkMap = new Map(
const reordered = ruleIds.map(id => ruleMap.get(id)).filter(Boolean) as FilterRule[]; state.rules.filter(r => !r.origin || r.origin === 'bulwark').map(r => [r.id, r]),
return { rules: reordered }; );
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) => { toggleRule: (ruleId) => {
set((state) => ({ set((state) => ({
rules: state.rules.map(r => rules: state.rules.map(r => {
r.id === ruleId ? { ...r, enabled: !r.enabled } : 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 }), setRawScript: (content) => set({ rawScript: content }),
resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [] }), resetToVisualBuilder: () => set({ isOpaque: false, rawScript: '', rules: [], externalRequires: [] }),
syncVacationToScript: async (client, vacation) => { syncVacationToScript: async (client, vacation) => {
try { try {
@@ -173,6 +207,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
const activeScript = scripts.find(s => s.isActive) || scripts[0]; const activeScript = scripts.find(s => s.isActive) || scripts[0];
let rules = previousRules; let rules = previousRules;
let externalRequires = get().externalRequires;
// If there's an active script, try to parse our metadata from it. // 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. // 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); const parsed = parseScript(content);
if (!parsed.isOpaque) { if (!parsed.isOpaque) {
rules = parsed.rules; rules = parsed.rules;
externalRequires = parsed.externalRequires;
} }
} }
// Generate a combined script with our metadata, rules, and vacation // 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) { if (activeScript) {
// Preserve the script's current activation state — don't pass activate: true // Preserve the script's current activation state — don't pass activate: true
@@ -198,6 +234,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rules, rules,
vacationSettings: vacation, vacationSettings: vacation,
isOpaque: false, isOpaque: false,
externalRequires,
}); });
} else { } else {
// Don't activate; there may be a server-managed 'vacation' script active. // Don't activate; there may be a server-managed 'vacation' script active.
@@ -209,6 +246,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rules, rules,
vacationSettings: vacation, vacationSettings: vacation,
isOpaque: false, isOpaque: false,
externalRequires,
}); });
} }
@@ -229,5 +267,6 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
isOpaque: false, isOpaque: false,
rawScript: '', rawScript: '',
vacationSettings: null, vacationSettings: null,
externalRequires: [],
}), }),
})); }));
+14
View File
@@ -176,12 +176,18 @@ interface SettingsState {
hideAccountSwitcher: boolean; hideAccountSwitcher: boolean;
showRailAccountList: boolean; showRailAccountList: boolean;
// Unified Mailbox
enableUnifiedMailbox: boolean;
// Email Display // Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
// Experimental // Experimental
senderFavicons: boolean; senderFavicons: boolean;
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
// Folders // Folders
folderIcons: Record<string, string>; // mailboxId -> icon name folderIcons: Record<string, string>; // mailboxId -> icon name
@@ -310,12 +316,18 @@ const DEFAULT_SETTINGS = {
hideAccountSwitcher: false, hideAccountSwitcher: false,
showRailAccountList: false, showRailAccountList: false,
// Unified Mailbox
enableUnifiedMailbox: false,
// Email Display // Email Display
disableThreading: false, disableThreading: false,
// Experimental // Experimental
senderFavicons: true, senderFavicons: true,
// Sidebar
colorfulSidebarIcons: true,
// Folders // Folders
folderIcons: {} as Record<string, string>, folderIcons: {} as Record<string, string>,
@@ -448,7 +460,9 @@ export const useSettingsStore = create<SettingsState>()(
toolbarPosition: state.toolbarPosition, toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher, hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList, showRailAccountList: state.showRailAccountList,
enableUnifiedMailbox: state.enableUnifiedMailbox,
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
colorfulSidebarIcons: state.colorfulSidebarIcons,
folderIcons: state.folderIcons, folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords, emailKeywords: state.emailKeywords,
attachmentReminderEnabled: state.attachmentReminderEnabled, attachmentReminderEnabled: state.attachmentReminderEnabled,