Compare commits

..
16 Commits
Author SHA1 Message Date
Linus Rath b88026de82 Merge dev into main - version 1.4.6 2026-03-21 03:08:47 +01:00
Linus Rath 6ed8ae5812 chore: bump version to 1.4.6 2026-03-21 03:08:19 +01:00
Linus Rath 089583a9ef feat(contacts): add no-category filter, drag-drop to category, and category combo box
- Add 'No Category' sidebar item to filter uncategorized contacts
- Categories section now always visible (not just when keywords exist)
- Add drag-and-drop support on category items in sidebar to assign keywords
- Fix effectAllowed mismatch (move -> copyMove) for category drop targets
- Replace plain text categories input with combo box in contact edit form
  - Shows existing categories as clickable suggestions
  - Displays assigned categories as removable badges
  - Supports adding new categories inline
- Add translations for all 8 locales
2026-03-21 03:04:36 +01:00
Linus Rath 2d834213ee feat: enhance certificate extraction and legacy PBE support in crypto engine 2026-03-21 02:48:12 +01:00
Linus Rath 439a4dbe8a feat: add hover actions for emails and update settings for quick actions 2026-03-21 02:31:42 +01:00
Linus Rath 8350bad2a6 fix: adjust padding and size of sidebar buttons for improved layout 2026-03-21 02:06:52 +01:00
Linus Rath 2d56cc9be9 feat: implement keyword migration functionality and update related components 2026-03-21 01:58:48 +01:00
Linus Rath 2547c10060 feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface
- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`.
- Added demo file nodes representing directories and files in `files.ts`.
- Implemented demo Sieve capabilities and scripts in `filters.ts`.
- Defined demo identities for users in `identities.ts`.
- Established demo mailboxes with permissions and counts in `mailboxes.ts`.
- Created a demo vacation response in `vacation.ts`.
- Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
2026-03-21 01:38:42 +01:00
Linus RathandGitHub 01779fa59e Update SHA tag prefix in docker-publish.yml
Fix the tag prefix format for SHA in Docker publish workflow.
2026-03-21 00:15:01 +01:00
Linus RathandGitHub fc38427ed0 Merge pull request #68 from xnilsit/main
feat: add separate docker build for releases
2026-03-21 00:08:49 +01:00
xnilsit 3cbfb70860 feat: also build images for dev branch 2026-03-21 00:08:32 +01:00
xnilsit c32b740dac chore: rename release workflow 2026-03-20 23:11:41 +01:00
xnilsit a02091a7ad fix: don't tag releases with hash 2026-03-20 22:48:37 +01:00
xnilsit bc311adf6a feat: add separate docker build for releases 2026-03-20 22:35:37 +01:00
Linus Rath 8aca1623f4 Merge dev into main - version 1.4.5 (build fix) 2026-03-20 18:17:53 +01:00
Linus Rath a8be40579e fix: add missing showTimeInMonthView and showOnMobile type definitions to settings store 2026-03-20 18:17:31 +01:00
68 changed files with 5049 additions and 222 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## 1.4.6 (2026-03-21)
### Features
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
- **Email**: Add hover actions for emails with configurable quick-action buttons
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
- **Tour**: Add interactive guided tour overlay for new user onboarding
### Fixes
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
- **UI**: Adjust padding and size of sidebar buttons for improved layout
## 1.4.5 (2026-03-20)
### Features
+1 -1
View File
@@ -1 +1 @@
1.4.5
1.4.6
+1
View File
@@ -795,6 +795,7 @@ export default function CalendarPage() {
<div
className="flex flex-1 overflow-hidden relative"
data-tour="calendar-view"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
+45 -1
View File
@@ -126,9 +126,24 @@ export default function ContactsPage() {
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
for (const contact of individuals) {
if (!contact.keywords) continue;
for (const [kw, active] of Object.entries(contact.keywords)) {
if (active) kws.add(kw);
}
}
return Array.from(kws).sort((a, b) => a.localeCompare(b));
}, [individuals]);
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals;
if (activeCategory === "uncategorized") {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
}
if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId;
return individuals.filter(c => {
@@ -146,6 +161,7 @@ export default function ContactsPage() {
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
@@ -182,6 +198,31 @@ export default function ContactsPage() {
}
}, [client, moveContactToAddressBook, t]);
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
for (const contactId of contactIds) {
const contact = contacts.find(c => c.id === contactId);
if (!contact) continue;
const existingKeywords = contact.keywords || {};
if (existingKeywords[keyword]) continue; // already has this keyword
const updatedKeywords = { ...existingKeywords, [keyword]: true };
if (supportsSync && client) {
await updateContact(client, contactId, { keywords: updatedKeywords });
} else {
updateLocalContact(contactId, { keywords: updatedKeywords });
}
}
const msg = contactIds.length === 1
? t("category_added", { name: keyword })
: t("category_added_plural", { count: contactIds.length, name: keyword });
toast.success(msg);
} catch (error) {
console.error('Failed to add contacts to category:', error);
toast.error(t("toast.error_update"));
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
@@ -430,7 +471,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
@@ -438,6 +479,7 @@ export default function ContactsPage() {
<ContactForm
contact={selectedContact}
addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
@@ -590,6 +632,7 @@ export default function ContactsPage() {
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
/>
</div>
<ResizeHandle
@@ -606,6 +649,7 @@ export default function ContactsPage() {
{/* Panel 2: Contact list */}
<div
data-tour="contacts-list"
className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
+3
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -26,7 +27,9 @@ export default async function LocaleLayout({
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<CalendarAlertProvider>
<TourProvider>
{children}
</TourProvider>
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
+191 -4
View File
@@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
@@ -30,9 +30,9 @@ export default function LoginPage() {
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
@@ -54,6 +54,7 @@ export default function LoginPage() {
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -206,7 +207,7 @@ export default function LoginPage() {
);
}
if (!serverUrl) {
if (!serverUrl && !demoMode) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
@@ -353,9 +354,167 @@ export default function LoginPage() {
}
};
const handleDemoLogin = async () => {
setDemoLoading(true);
const success = await loginDemo();
if (success) {
router.push('/');
}
setDemoLoading(false);
};
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon;
// Demo-only mode: show only a large demo login button
if (demoMode && !isAddAccountMode) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle */}
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
showThemeMenu
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
</button>
{showThemeMenu && (
<div
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
isActive
? "bg-primary/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
})}
</div>
)}
</div>
<div className="w-full max-w-[440px] mx-auto">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header with logo */}
<div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
alt={appName}
className="max-w-20 max-h-20 object-contain"
/>
</div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
{appName}
</h1>
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
{t("demo_tagline")}
</p>
</div>
{/* Large demo button */}
<div className="px-8 pb-10 pt-4">
{error && (
<div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
shakeError && "animate-shake"
)}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
{t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
<Button
type="button"
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
onClick={handleDemoLogin}
disabled={demoLoading || isLoading}
>
{demoLoading ? (
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin" />
{t("demo_launching")}
</div>
) : (
<div className="flex items-center gap-3">
<Play className="w-5 h-5" />
{t("demo_login_button")}
</div>
)}
</Button>
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
{t("demo_no_signup")}
</p>
</div>
</div>
{/* Footer */}
<div className="mt-6 flex flex-col items-center gap-2">
{loginCompanyName && (
<p className="text-center text-xs text-muted-foreground/60 font-medium">
{loginCompanyName}
</p>
)}
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
<div className="flex items-center gap-3 flex-wrap justify-center">
{loginWebsiteUrl && (
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("website")}
</a>
)}
{loginImprintUrl && (
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("imprint")}
</a>
)}
{loginPrivacyPolicyUrl && (
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("privacy_policy")}
</a>
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle - top right, dropdown style */}
@@ -747,6 +906,34 @@ export default function LoginPage() {
</Button>
</div>
)}
{/* Demo Mode Button */}
{demoMode && !isAddAccountMode && (
<div className="mt-4 pt-4 border-t border-border/40">
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleDemoLogin}
disabled={demoLoading || isLoading}
>
{demoLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("demo_launching")}
</div>
) : (
<div className="flex items-center gap-2">
<Play className="w-4 h-4" />
{t("try_demo")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground mt-2">
{t("demo_description")}
</p>
</div>
)}
</div>
</div>
+3 -2
View File
@@ -1174,6 +1174,7 @@ export default function Home() {
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
/>
{searchQuery && (
<button
@@ -1579,8 +1580,8 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
+1 -1
View File
@@ -352,7 +352,7 @@ export default function SettingsPage() {
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
+1
View File
@@ -35,5 +35,6 @@ export async function GET() {
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
demoMode: process.env.DEMO_MODE === 'true',
});
}
@@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps {
calendars: Calendar[];
@@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
client?: JMAPClient | null;
client?: IJMAPClient | null;
}
export function CalendarSidebarPanel({
+1 -1
View File
@@ -326,7 +326,7 @@ export function CalendarToolbar({
)}
{!isMobile && (
<Button size="sm" onClick={onCreateEvent}>
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
+1 -1
View File
@@ -729,7 +729,7 @@ export function EventModal({
}
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
+2 -2
View File
@@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
interface ICalImportModalProps {
calendars: Calendar[];
client: JMAPClient;
client: IJMAPClient;
onClose: () => void;
}
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: JMAPClient;
client: IJMAPClient;
onClose: () => void;
}
+148 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -48,6 +48,7 @@ interface AddressEntry {
interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void;
}
@@ -123,7 +124,7 @@ function Select({ value, onChange, children, className }: {
);
}
export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) {
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
@@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<div>
<Input
value={keywordsStr}
onChange={(e) => setKeywordsStr(e.target.value)}
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")}
hint={t("categories_hint")}
addLabel={t("category_add")}
/>
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
</div>
</FormSection>
{/* Gender */}
@@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
</form>
);
}
function CategoryComboBox({
keywordsStr,
onChange,
allKeywords,
placeholder,
hint,
addLabel,
}: {
keywordsStr: string;
onChange: (value: string) => void;
allKeywords: string[];
placeholder: string;
hint: string;
addLabel: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
const currentKeywords = useMemo(() => {
return keywordsStr.split(",").map(k => k.trim()).filter(Boolean);
}, [keywordsStr]);
// Suggestions: existing keywords not already selected
const suggestions = useMemo(() => {
const lower = inputValue.toLowerCase();
return allKeywords.filter(kw =>
!currentKeywords.includes(kw) &&
(!lower || kw.toLowerCase().includes(lower))
);
}, [allKeywords, currentKeywords, inputValue]);
// Can add a new keyword if typed text is non-empty and not already in the list
const canAddNew = inputValue.trim() &&
!currentKeywords.includes(inputValue.trim()) &&
!allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase());
const addKeyword = useCallback((keyword: string) => {
const trimmed = keyword.trim();
if (!trimmed || currentKeywords.includes(trimmed)) return;
const next = [...currentKeywords, trimmed].join(", ");
onChange(next);
setInputValue("");
}, [currentKeywords, onChange]);
const removeKeyword = useCallback((keyword: string) => {
const next = currentKeywords.filter(k => k !== keyword).join(", ");
onChange(next);
}, [currentKeywords, onChange]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (inputValue.trim()) {
addKeyword(inputValue);
}
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
return (
<div ref={wrapperRef} className="relative">
{/* Keyword badges */}
{currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentKeywords.map(kw => (
<span
key={kw}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
>
{kw}
<button
type="button"
onClick={() => removeKeyword(kw)}
className="hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Input with dropdown */}
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""}
/>
<p className="text-xs text-muted-foreground mt-1.5">{hint}</p>
{/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
{suggestions.map(kw => (
<button
key={kw}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
>
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{kw}
</button>
))}
{canAddNew && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
{addLabel}: &quot;{inputValue.trim()}&quot;
</button>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
? Array.from(selectedContactIds)
: [contact.id];
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
e.dataTransfer.setData("text/plain", name || email || contact.id);
+94 -12
View File
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
interface ContactsSidebarProps {
groups: ContactCard[];
@@ -24,6 +24,7 @@ interface ContactsSidebarProps {
onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
className?: string;
}
@@ -56,6 +57,7 @@ export function ContactsSidebar({
onEditGroup,
onDeleteGroup,
onDropContacts,
onDropContactsToCategory,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
@@ -146,6 +148,11 @@ export function ContactsSidebar({
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
}, [individuals]);
// Count of contacts without any keywords
const uncategorizedCount = useMemo(() => {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length;
}, [individuals]);
// Resolve actual group member counts against living contacts
const memberCountByGroup = useMemo(() => {
const counts: Record<string, number> = {};
@@ -311,7 +318,6 @@ export function ContactsSidebar({
)}
{/* Categories section (from contact keywords) */}
{allKeywords.length > 0 && (
<div className="mt-2">
<button
onClick={() => toggleSection("categories")}
@@ -327,30 +333,41 @@ export function ContactsSidebar({
</span>
</button>
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
return (
{!collapsed.categories && (
<>
{/* No Category item */}
<button
key={keyword}
onClick={() => onSelectCategory({ keyword })}
onClick={() => onSelectCategory("uncategorized")}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
isActive
activeCategory === "uncategorized"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate italic">{t("no_category")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{count}
{uncategorizedCount}
</span>
</button>
{allKeywords.map(([keyword, count]) => {
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
return (
<CategoryItem
key={keyword}
keyword={keyword}
count={count}
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
/>
);
})}
</div>
</>
)}
</div>
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
@@ -415,6 +432,71 @@ export function ContactsSidebar({
);
}
function CategoryItem({
keyword,
count,
isActive,
onSelect,
onDropContacts,
}: {
keyword: string;
count: number;
isActive: boolean;
onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(false);
const data = e.dataTransfer.getData("application/x-contact-ids");
if (!data || !onDropContacts) return;
try {
const contactIds = JSON.parse(data) as string[];
if (contactIds.length > 0) {
onDropContacts(contactIds, keyword);
}
} catch {
// ignore invalid data
}
}, [keyword, onDropContacts]);
return (
<button
onClick={onSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
);
}
function AddressBookItem({
book,
isActive,
+1
View File
@@ -886,6 +886,7 @@ export function EmailComposer({
return (
<div
className={cn("flex flex-col h-full bg-background relative", className)}
data-tour="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl";
interface EmailHoverActionsProps {
email: Email;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
}
const ACTION_CONFIG: Record<HoverAction, {
icon: typeof Trash2;
titleKey: string;
className?: string;
}> = {
delete: {
icon: Trash2,
titleKey: "delete",
className: "hover:text-red-600 dark:hover:text-red-400",
},
star: {
icon: Star,
titleKey: "star",
className: "hover:text-amber-500 dark:hover:text-amber-400",
},
markRead: {
icon: Mail,
titleKey: "mark_read",
className: "hover:text-blue-600 dark:hover:text-blue-400",
},
archive: {
icon: Archive,
titleKey: "archive",
className: "hover:text-green-600 dark:hover:text-green-400",
},
tag: {
icon: Tag,
titleKey: "tag",
className: "hover:text-purple-600 dark:hover:text-purple-400",
},
spam: {
icon: ShieldAlert,
titleKey: "spam",
className: "hover:text-orange-600 dark:hover:text-orange-400",
},
};
export function EmailHoverActions({
email,
onToggleStar,
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const t = useTranslations("settings.email_behavior.hover_actions");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
if (hoverActions.length === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
e.stopPropagation();
e.preventDefault();
switch (action) {
case "delete":
onDelete?.();
break;
case "star":
onToggleStar?.();
break;
case "markRead":
onMarkAsRead?.(!isUnread);
break;
case "archive":
onArchive?.();
break;
case "tag":
onSetColorTag?.(null);
break;
case "spam":
onMarkAsSpam?.();
break;
}
};
return (
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
{hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
const Icon = config.icon;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
)}
>
<DisplayIcon
className={cn(
"w-4 h-4",
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
)}
/>
</button>
);
})}
</div>
</div>
);
}
+20 -2
View File
@@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils";
interface EmailListItemProps {
@@ -21,9 +22,15 @@ interface EmailListItemProps {
selected?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
@@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
@@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)}
</div>
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
</div>
);
}
+7 -1
View File
@@ -358,7 +358,7 @@ export function EmailList({
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
{isLoading && emails.length > 0 && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
@@ -422,6 +422,12 @@ export function EmailList({
onEmailSelect={(email) => onEmailSelect?.(email)}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
onDelete={onDelete ? (email) => onDelete(email) : undefined}
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
/>
</div>
);
+53 -1
View File
@@ -66,6 +66,7 @@ import {
Moon,
HelpCircle,
EditIcon,
PlayCircle,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -80,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
@@ -871,6 +873,8 @@ export function EmailViewer({
const tCommon = useTranslations('common');
const tSmime = useTranslations('smime');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
@@ -898,8 +902,9 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client } = useAuthStore();
const { identities, client, isDemoMode } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -2684,6 +2689,52 @@ export function EmailViewer({
}
if (!email) {
if (isDemoMode) {
const logoSrc = resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
alt="Bulwark Mail"
className="h-12 mx-auto mb-6"
/>
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
<div className="flex flex-col gap-3 items-center">
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_email')}</span>
</div>
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_organize')}</span>
</div>
<div className="flex items-center gap-2">
<Keyboard className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_shortcuts')}</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_privacy')}</span>
</div>
</div>
<button
onClick={startTour}
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<PlayCircle className="w-4 h-4" />
{tWelcome('start_tour')}
</button>
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8">
@@ -3233,6 +3284,7 @@ export function EmailViewer({
return (
<div
key={email.id}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
>
{/* Mobile More menu sidebar overlay */}
+50 -3
View File
@@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl";
interface ThreadListItemProps {
@@ -25,6 +26,12 @@ interface ThreadListItemProps {
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
onToggleStar?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
}
interface SingleEmailItemProps {
@@ -34,10 +41,16 @@ interface SingleEmailItemProps {
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
@@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
selected
? "bg-accent"
@@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
</div>
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
</div>
);
}
@@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onEmailSelect,
onContextMenu,
onOpenConversation,
onToggleStar,
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
}, ref) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
@@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
);
}
@@ -339,7 +375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{...dragHandlers}
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-all duration-200",
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
colorTag ? colorTag : (
isSelected
? "bg-accent"
@@ -493,6 +529,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
</div>
</div>
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
</div>
{isExpanded && !isMobile && (
+10
View File
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTour } from "@/components/tour/tour-provider";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const { startTour } = useTour();
const modalRef = useFocusTrap({
isActive: isOpen,
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
<p className="text-sm text-center mt-2">
<button
onClick={() => { onClose(); startTour(); }}
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
>
{t("tour.take_a_tour")}
</button>
</p>
</div>
</div>
</div>
+5
View File
@@ -303,6 +303,7 @@ export function NavigationRail({
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour={`nav-${item.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
@@ -401,6 +402,7 @@ export function NavigationRail({
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour="nav-settings"
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
isSettingsActive
@@ -418,6 +420,7 @@ export function NavigationRail({
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
@@ -426,7 +429,9 @@ export function NavigationRail({
)}
{quota && quota.total > 0 && (
<div data-tour="storage-quota">
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
</div>
)}
{isPushConnected != null && (
+2
View File
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
url: string;
icon: string;
openMode: 'tab' | 'inline';
showOnMobile: boolean;
}
function SidebarAppForm({
@@ -37,6 +38,7 @@ function SidebarAppForm({
url: app?.url || '',
icon: app?.icon || 'Globe',
openMode: app?.openMode || 'tab',
showOnMobile: app?.showOnMobile ?? false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
+77 -7
View File
@@ -24,6 +24,10 @@ import {
Settings,
X,
Tag,
RotateCcw,
FlaskConical,
PlayCircle,
Loader2,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -40,6 +44,7 @@ import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { AccountSwitcher } from "./account-switcher";
import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -328,6 +333,68 @@ function TagItem({
);
}
function DemoBanner() {
const t = useTranslations('sidebar');
const { isDemoMode, loginDemo } = useAuthStore();
const { startTour, resetTourCompletion } = useTour();
const router = useRouter();
const [isResetting, setIsResetting] = useState(false);
if (!isDemoMode) return null;
const handleReset = async () => {
setIsResetting(true);
// Navigate to home first so the mail page re-fetches data
router.push('/');
await loginDemo();
setIsResetting(false);
};
const handleStartTour = () => {
resetTourCompletion();
router.push('/');
setTimeout(() => startTour(), 100);
};
return (
<div
data-tour="demo-banner"
className={cn(
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
"bg-primary/10 dark:bg-primary/10 text-primary",
)}
>
<div className="flex items-center gap-2">
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("demo_banner")}</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={handleStartTour}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
title={t("demo_tour")}
>
<PlayCircle className="w-3 h-3" />
{t("demo_tour")}
</button>
<button
onClick={handleReset}
disabled={isResetting}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
title={t("demo_reset")}
>
{isResetting ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RotateCcw className="w-3 h-3" />
)}
{t("demo_reset")}
</button>
</div>
</div>
);
}
function VacationBanner() {
const t = useTranslations('sidebar');
const router = useRouter();
@@ -454,12 +521,12 @@ export function Sidebar({
)}
>
{/* Header */}
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-3" : "gap-2 px-4 py-3")}>
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
<Button
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="lg:hidden h-11 w-11 flex-shrink-0"
className="lg:hidden h-9 w-9 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-5 h-5" />
@@ -480,7 +547,7 @@ export function Sidebar({
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex flex-shrink-0"
className="hidden lg:flex h-8 w-8 flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
@@ -491,11 +558,14 @@ export function Sidebar({
)}
</div>
{/* Demo Banner */}
{!isCollapsed && <DemoBanner />}
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
@@ -582,7 +652,7 @@ export function Sidebar({
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<div className="relative">
<div className="relative" data-tour="keyword-tags">
{emailKeywords.map((kw) => {
const isSelected = selectedKeyword === kw.id;
return (
@@ -606,11 +676,11 @@ export function Sidebar({
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
+19 -1
View File
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name (show in demo mode or when identity has a name) */}
{displayName && (
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName}</span>
</SettingItem>
)}
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
@@ -49,6 +57,16 @@ export function AccountSettings() {
</div>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
</span>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
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 },
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
{/* Restart Tour */}
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
variant="outline"
size="sm"
onClick={() => { resetTourCompletion(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
{tTour('restart_button')}
</Button>
</SettingItem>
</SettingsSection>
);
}
+37 -1
View File
@@ -3,9 +3,11 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
@@ -27,6 +29,7 @@ export function EmailSettings() {
attachmentPosition,
emailAlwaysLightMode,
archiveMode,
hoverActions,
trustedSenders,
updateSetting,
} = useSettingsStore();
@@ -185,6 +188,39 @@ export function EmailSettings() {
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
{/* Quick Hover Actions */}
<div className="py-3 border-b border-border space-y-3">
<div>
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('hover_actions.description')}</p>
</div>
<div className="flex flex-wrap gap-2">
{ALL_HOVER_ACTIONS.map((action) => {
const isEnabled = hoverActions.includes(action.id);
return (
<button
key={action.id}
type="button"
onClick={() => {
const newActions = isEnabled
? hoverActions.filter((a: HoverAction) => a !== action.id)
: [...hoverActions, action.id];
updateSetting('hoverActions', newActions);
}}
className={cn(
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
isEnabled
? 'bg-primary text-primary-foreground font-medium'
: 'bg-muted hover:bg-accent text-foreground'
)}
>
{t(`hover_actions.${action.labelKey}`)}
</button>
);
})}
</div>
</div>
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select
value={mailAttachmentAction}
+41 -8
View File
@@ -3,8 +3,10 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
@@ -91,16 +93,14 @@ function KeywordEditForm({
const [color, setColor] = useState(initial?.color || "blue");
const isEditing = !!initial;
const normalizedId = isEditing
? initial.id
: label
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => {
@@ -159,10 +159,13 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
const existingIds = emailKeywords.map((k) => k.id);
@@ -171,8 +174,32 @@ export function KeywordSettings() {
setIsAdding(false);
};
const handleEdit = (keyword: KeywordDefinition) => {
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color });
const handleEdit = async (keyword: KeywordDefinition) => {
const oldId = editingId;
if (!oldId) return;
const idChanged = oldId !== keyword.id;
if (idChanged && client) {
setIsMigrating(true);
try {
const oldJmapKeyword = `$label:${oldId}`;
const newJmapKeyword = `$label:${keyword.id}`;
await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
renameKeyword(oldId, keyword);
fetchTagCounts(client);
} catch (error) {
console.error("Failed to migrate keyword:", error);
const toastModule = await import('sonner');
toastModule.toast.error(t("migration_error"));
setIsMigrating(false);
return;
}
setIsMigrating(false);
} else {
updateKeyword(oldId, { label: keyword.label, color: keyword.color });
}
setEditingId(null);
};
@@ -187,6 +214,12 @@ export function KeywordSettings() {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("migrating")}
</div>
)}
{emailKeywords.map((keyword) =>
editingId === keyword.id ? (
<KeywordEditForm
+413
View File
@@ -0,0 +1,413 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useTour } from "./tour-provider";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface Rect {
top: number;
left: number;
width: number;
height: number;
}
const PADDING = 8;
const TOOLTIP_GAP = 12;
const TOOLTIP_MAX_W = 360;
function getTargetRect(selector: string): Rect | null {
const el = document.querySelector(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
// Element might exist but be hidden (zero dimensions)
if (r.width === 0 && r.height === 0) return null;
return { top: r.top, left: r.left, width: r.width, height: r.height };
}
function computeTooltipPosition(
target: Rect,
placement: "top" | "bottom" | "left" | "right",
tooltipSize: { width: number; height: number }
): { top: number; left: number; actualPlacement: string } {
const vw = window.innerWidth;
const vh = window.innerHeight;
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
const positions = {
bottom: {
top: target.top + target.height + PADDING + TOOLTIP_GAP,
left: target.left + target.width / 2 - tw / 2,
},
top: {
top: target.top - PADDING - TOOLTIP_GAP - th,
left: target.left + target.width / 2 - tw / 2,
},
right: {
top: target.top + target.height / 2 - th / 2,
left: target.left + target.width + PADDING + TOOLTIP_GAP,
},
left: {
top: target.top + target.height / 2 - th / 2,
left: target.left - PADDING - TOOLTIP_GAP - tw,
},
};
const fits = (p: { top: number; left: number }) =>
p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8;
// Try preferred placement first, then fallback order
const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"];
for (const dir of order) {
const pos = positions[dir];
if (fits(pos)) return { ...pos, actualPlacement: dir };
}
// If nothing fits perfectly, use preferred but clamped
const pos = positions[placement];
return {
top: Math.max(8, Math.min(pos.top, vh - th - 8)),
left: Math.max(8, Math.min(pos.left, vw - tw - 8)),
actualPlacement: placement,
};
}
export function TourOverlay() {
const t = useTranslations();
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
const step = steps[currentStep];
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Use refs for callbacks to avoid stale closures in timers/intervals
const updatePositionRef = useRef<() => void>(() => {});
const nextStepRef = useRef<() => void>(() => {});
nextStepRef.current = nextStep;
const focusTrapRef = useFocusTrap({
isActive: visible,
onEscape: stopTour,
restoreFocus: true,
});
// Set mounted for portal
useEffect(() => { setMounted(true); }, []);
const updatePosition = useCallback(() => {
if (!step) return;
const rect = getTargetRect(step.target);
if (rect) {
setTargetRect(rect);
if (tooltipRef.current) {
const { width, height } = tooltipRef.current.getBoundingClientRect();
const pos = computeTooltipPosition(rect, step.placement, { width, height });
setTooltipPos({ top: pos.top, left: pos.left });
}
}
// If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize)
// Only the step-change effect should null out targetRect
}, [step]);
// Keep ref in sync
updatePositionRef.current = updatePosition;
// Wait for target element to appear, then show
useEffect(() => {
if (!step) return;
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
setVisible(false);
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
// instead of disappearing and reappearing
// Clear any pending timer from a previous step
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
// Run beforeAction if defined (e.g. click an email to open the viewer)
if (step.beforeAction) {
step.beforeAction();
}
let attempts = 0;
const maxAttempts = 50; // 5 seconds
let cancelled = false;
const tryFind = () => {
if (cancelled) return true;
const el = document.querySelector(step.target);
if (el) {
const rect = el.getBoundingClientRect();
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
// Delay after scroll for layout to settle
pendingTimerRef.current = setTimeout(() => {
if (cancelled) return;
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
updatePositionRef.current();
setVisible(true);
// Second position update after tooltip renders with final dimensions
requestAnimationFrame(() => {
if (!cancelled) updatePositionRef.current();
});
}, 200);
return true;
}
if (attempts % 10 === 0) {
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
}
return false;
};
if (tryFind()) return () => { cancelled = true; };
// Poll for element appearance (for page navigation)
const interval = setInterval(() => {
attempts++;
if (tryFind() || attempts >= maxAttempts) {
clearInterval(interval);
if (attempts >= maxAttempts && !cancelled) {
// Skip this step if element never appears
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
nextStepRef.current();
}
}
}, 100);
return () => {
cancelled = true;
clearInterval(interval);
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
};
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
// Recalculate on resize/scroll (debounced)
useEffect(() => {
if (!visible) return;
let rafId: number | null = null;
const handler = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
updatePosition();
});
};
window.addEventListener("resize", handler);
window.addEventListener("scroll", handler, true);
return () => {
window.removeEventListener("resize", handler);
window.removeEventListener("scroll", handler, true);
if (rafId) cancelAnimationFrame(rafId);
};
}, [visible, updatePosition]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
nextStep();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
prevStep();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
stopTour();
}
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [nextStep, prevStep, stopTour]);
// Re-position after tooltip content renders with new dimensions
useEffect(() => {
if (!visible || !tooltipRef.current) return;
// Use rAF to wait for the browser to lay out the tooltip content
const id = requestAnimationFrame(() => {
updatePosition();
});
return () => cancelAnimationFrame(id);
}, [visible, updatePosition, currentStep]);
if (!mounted || !step) return null;
const cutout = targetRect
? {
x: targetRect.left - PADDING,
y: targetRect.top - PADDING,
w: targetRect.width + PADDING * 2,
h: targetRect.height + PADDING * 2,
}
: null;
const isLast = currentStep >= totalSteps - 1;
const isFirst = currentStep === 0;
const isInteractive = step.interactive;
const reducedMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
return createPortal(
<>
{/* SVG overlay with cutout */}
<svg
className="fixed inset-0 z-[9998]"
width="100%"
height="100%"
style={{ pointerEvents: isInteractive ? "none" : "auto" }}
onClick={stopTour}
>
<defs>
<mask id="tour-mask">
<rect fill="white" width="100%" height="100%" />
{cutout && (
<rect
fill="black"
x={cutout.x}
y={cutout.y}
width={cutout.w}
height={cutout.h}
rx="8"
style={{ transition: transitionStyle }}
/>
)}
</mask>
</defs>
<rect
fill="black"
opacity="0.5"
mask="url(#tour-mask)"
width="100%"
height="100%"
/>
</svg>
{/* Click-through cutout zone for interactive steps */}
{isInteractive && cutout && (
<div
className="fixed z-[9998]"
style={{
top: cutout.y,
left: cutout.x,
width: cutout.w,
height: cutout.h,
pointerEvents: "none",
transition: transitionStyle,
}}
/>
)}
{/* Non-interactive overlay click blocker around cutout */}
{!isInteractive && cutout && (
<div
className="fixed z-[9998]"
style={{
top: cutout.y,
left: cutout.x,
width: cutout.w,
height: cutout.h,
pointerEvents: "none",
}}
/>
)}
{/* Tooltip */}
<div
ref={(node) => {
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
role="dialog"
aria-modal="true"
aria-label={t(step.titleKey)}
className={cn(
"fixed z-[9999] transition-all",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
)}
style={{
top: tooltipPos?.top ?? -9999,
left: tooltipPos?.left ?? -9999,
maxWidth: TOOLTIP_MAX_W,
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
pointerEvents: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="bg-background border border-border rounded-xl shadow-2xl p-4">
{/* Step counter */}
<p className="text-xs text-muted-foreground mb-1" aria-live="polite">
{t("tour.step_counter", { current: currentStep + 1, total: totalSteps })}
</p>
{/* Title */}
<h3 className="font-semibold text-sm text-foreground">{t(step.titleKey)}</h3>
{/* Description */}
<p className="text-sm text-muted-foreground mt-1">{t(step.descriptionKey)}</p>
{/* Navigation buttons */}
<div className="flex items-center justify-between mt-3">
<button
onClick={stopTour}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
>
{t("tour.skip")}
</button>
<div className="flex gap-2">
<button
onClick={prevStep}
disabled={isFirst}
className={cn(
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
isFirst
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted"
)}
>
{t("tour.back")}
</button>
<button
onClick={nextStep}
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{isLast ? t("tour.finish") : t("tour.next")}
</button>
</div>
</div>
{/* Progress dots */}
<div className="flex justify-center gap-1 mt-2">
{steps.map((_, i) => (
<span
key={i}
className={cn(
"w-1.5 h-1.5 rounded-full transition-colors",
i === currentStep ? "bg-primary" : "bg-muted-foreground/30"
)}
/>
))}
</div>
</div>
</div>
</>,
document.body
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay";
const TOUR_COMPLETED_KEY = "tour_completed";
const TOUR_CURRENT_STEP_KEY = "tour_current_step";
interface TourContextValue {
isActive: boolean;
currentStep: number;
totalSteps: number;
steps: TourStep[];
startTour: () => void;
stopTour: () => void;
nextStep: () => void;
prevStep: () => void;
hasCompletedTour: boolean;
resetTourCompletion: () => void;
}
const TourContext = createContext<TourContextValue | null>(null);
export function useTour() {
const ctx = useContext(TourContext);
if (!ctx) throw new Error("useTour must be used within TourProvider");
return ctx;
}
export function TourProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore();
const [isActive, setIsActive] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [hasCompletedTour, setHasCompletedTour] = useState(false);
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => {
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
} catch { /* */ }
}, []);
const startTour = useCallback(() => {
let resumeStep = 0;
try {
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
}
} catch { /* */ }
// If the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 0;
setCurrentStep(resumeStep);
setIsActive(true);
}, [steps.length]);
const stopTour = useCallback(() => {
setIsActive(false);
try {
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
const completeTour = useCallback(() => {
setIsActive(false);
setHasCompletedTour(true);
try {
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
const nextStep = useCallback(() => {
if (currentStep >= steps.length - 1) {
completeTour();
return;
}
const next = currentStep + 1;
const nextStepDef = steps[next];
setCurrentStep(next);
try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
} catch { /* */ }
// Navigate if the next step requires a different page
if (nextStepDef?.page) {
router.push(nextStepDef.page);
}
}, [currentStep, steps, completeTour, router]);
const prevStep = useCallback(() => {
if (currentStep <= 0) return;
const prev = currentStep - 1;
const prevStepDef = steps[prev];
setCurrentStep(prev);
try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
} catch { /* */ }
if (prevStepDef?.page) {
router.push(prevStepDef.page);
} else if (steps[currentStep]?.page) {
// Going back from a page-specific step to a non-page step => go to mail
router.push("/");
}
}, [currentStep, steps, router]);
const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false);
try {
localStorage.removeItem(TOUR_COMPLETED_KEY);
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
const value: TourContextValue = {
isActive,
currentStep,
totalSteps: steps.length,
steps,
startTour,
stopTour,
nextStep,
prevStep,
hasCompletedTour,
resetTourCompletion,
};
return (
<TourContext.Provider value={value}>
{children}
{isActive && <TourOverlay />}
</TourContext.Provider>
);
}
+221
View File
@@ -0,0 +1,221 @@
export interface TourStep {
id: string;
target: string;
titleKey: string;
descriptionKey: string;
placement: "top" | "bottom" | "left" | "right";
interactive?: boolean;
spotlight?: "rect" | "circle";
page?: string;
demoOnly?: boolean;
beforeAction?: () => void;
}
export const BASE_TOUR_STEPS: TourStep[] = [
{
id: "sidebar",
target: '[data-tour="sidebar"]',
titleKey: "tour.sidebar_title",
descriptionKey: "tour.sidebar_desc",
placement: "right",
},
{
id: "compose",
target: '[data-tour="compose-button"]',
titleKey: "tour.compose_title",
descriptionKey: "tour.compose_desc",
placement: "right",
interactive: true,
},
{
id: "search",
target: '[data-tour="search-input"]',
titleKey: "tour.search_title",
descriptionKey: "tour.search_desc",
placement: "bottom",
interactive: true,
},
{
id: "email-list",
target: '[data-tour="email-list"]',
titleKey: "tour.email_list_title",
descriptionKey: "tour.email_list_desc",
placement: "right",
},
{
id: "email-viewer",
target: '[data-tour="email-viewer"]',
titleKey: "tour.email_viewer_title",
descriptionKey: "tour.email_viewer_desc",
placement: "left",
beforeAction: () => {
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
const emailList = document.querySelector('[data-tour="email-list"]');
if (!emailList) return;
// Try to find the welcome email by subject text
const items = emailList.querySelectorAll('.cursor-pointer');
let target: HTMLElement | null = null;
for (const item of items) {
if (item.textContent?.includes("Welcome to Bulwark Mail")) {
target = item as HTMLElement;
break;
}
}
// Fallback to first email if welcome email not found
if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null;
if (target) target.click();
},
},
{
id: "keywords",
target: '[data-tour="keyword-tags"]',
titleKey: "tour.keywords_title",
descriptionKey: "tour.keywords_desc",
placement: "right",
},
{
id: "nav-calendar",
target: '[data-tour="nav-calendar"]',
titleKey: "tour.calendar_title",
descriptionKey: "tour.calendar_desc",
placement: "right",
},
{
id: "nav-contacts",
target: '[data-tour="nav-contacts"]',
titleKey: "tour.contacts_title",
descriptionKey: "tour.contacts_desc",
placement: "right",
},
{
id: "nav-settings",
target: '[data-tour="nav-settings"]',
titleKey: "tour.settings_title",
descriptionKey: "tour.settings_desc",
placement: "right",
},
{
id: "shortcuts",
target: '[data-tour="nav-shortcuts"]',
titleKey: "tour.shortcuts_title",
descriptionKey: "tour.shortcuts_desc",
placement: "right",
interactive: true,
},
];
export const DEMO_TOUR_STEPS: TourStep[] = [
{
id: "compose-open",
target: '[data-tour="composer"]',
titleKey: "tour.compose_open_title",
descriptionKey: "tour.compose_open_desc",
placement: "left",
demoOnly: true,
beforeAction: () => {
// Click the compose button to open the composer
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
if (btn) btn.click();
},
},
{
id: "calendar-view",
target: '[data-tour="calendar-view"]',
titleKey: "tour.calendar_view_title",
descriptionKey: "tour.calendar_view_desc",
placement: "bottom",
page: "/calendar",
demoOnly: true,
},
{
id: "create-event",
target: '[data-tour="create-event-button"]',
titleKey: "tour.create_event_title",
descriptionKey: "tour.create_event_desc",
placement: "bottom",
page: "/calendar",
interactive: true,
demoOnly: true,
},
{
id: "event-modal",
target: '[data-tour="event-modal"]',
titleKey: "tour.event_modal_title",
descriptionKey: "tour.event_modal_desc",
placement: "left",
page: "/calendar",
interactive: true,
demoOnly: true,
beforeAction: () => {
// Click the create event button to open the modal
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
if (btn) btn.click();
},
},
{
id: "contacts-list",
target: '[data-tour="contacts-list"]',
titleKey: "tour.contacts_list_title",
descriptionKey: "tour.contacts_list_desc",
placement: "right",
page: "/contacts",
demoOnly: true,
},
{
id: "settings-tabs",
target: '[data-tour="settings-tabs"]',
titleKey: "tour.settings_tabs_title",
descriptionKey: "tour.settings_tabs_desc",
placement: "right",
page: "/settings",
demoOnly: true,
},
{
id: "nav-files",
target: '[data-tour="nav-files"]',
titleKey: "tour.files_title",
descriptionKey: "tour.files_desc",
placement: "right",
demoOnly: true,
},
{
id: "demo-banner",
target: '[data-tour="demo-banner"]',
titleKey: "tour.demo_banner_title",
descriptionKey: "tour.demo_banner_desc",
placement: "bottom",
page: "/",
demoOnly: true,
},
{
id: "quota",
target: '[data-tour="storage-quota"]',
titleKey: "tour.quota_title",
descriptionKey: "tour.quota_desc",
placement: "right",
demoOnly: true,
},
];
export function getTourSteps(options: {
isDemoMode: boolean;
supportsCalendar: boolean;
supportsWebDAV: boolean;
}): TourStep[] {
let steps = [...BASE_TOUR_STEPS];
if (!options.supportsCalendar) {
steps = steps.filter((s) => s.id !== "nav-calendar");
}
if (options.isDemoMode) {
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
if (s.id === "nav-files" && !options.supportsWebDAV) return false;
if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false;
return true;
});
steps = [...steps, ...demoSteps];
}
return steps;
}
+12 -1
View File
@@ -2,15 +2,17 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { X, Lightbulb, Settings } from "lucide-react";
import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
const ONBOARDING_KEY = "onboarding_completed";
export function WelcomeBanner() {
const t = useTranslations("welcome");
const router = useRouter();
const { startTour } = useTour();
const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false);
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
</button>
</div>
<div className="mt-2.5 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { dismiss(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
{t("start_tour")}
</Button>
<Button
variant="outline"
size="sm"
+4
View File
@@ -22,6 +22,7 @@ interface ConfigData {
loginImprintUrl: string;
loginPrivacyPolicyUrl: string;
loginWebsiteUrl: string;
demoMode: boolean;
}
interface AppConfig extends ConfigData {
@@ -91,6 +92,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache?.loginImprintUrl || '',
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false,
isLoading: !configCache,
error: null,
});
@@ -118,6 +120,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache.loginImprintUrl,
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode,
isLoading: false,
error: null,
});
@@ -146,6 +149,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: data.loginImprintUrl,
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode,
isLoading: false,
error: null,
});
+812
View File
@@ -0,0 +1,812 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
/**
* In-memory JMAP client for demo mode.
* All data lives in memory no network calls, no cookies.
*/
export class DemoJMAPClient implements IJMAPClient {
private data: DemoData;
private blobStore = new Map<string, Blob>();
private connectionCallback: ((connected: boolean) => void) | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
private incomingTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.data = getDemoData();
}
// ── Connection lifecycle ──────────────────────────────────────
async connect(): Promise<void> {
// Start simulated incoming email timer
this.startIncomingEmailTimer();
}
disconnect(): void {
this.stopIncomingEmailTimer();
this.connectionCallback = null;
this.stateChangeCallback = null;
}
async reconnect(): Promise<void> { /* no-op */ }
async ping(): Promise<void> { /* no-op */ }
// ── Session / auth accessors ──────────────────────────────────
getServerUrl(): string { return 'https://demo.example.com'; }
getAuthHeader(): string { return 'Bearer demo-token'; }
updateAccessToken(): void { /* no-op */ }
getAccountId(): string { return 'demo-account'; }
getUsername(): string { return 'demo@example.com'; }
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:sieve': {},
'urn:ietf:params:jmap:quota': {},
'urn:ietf:params:jmap:files': {},
};
}
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getEventSourceUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
supportsVacationResponse(): boolean { return true; }
supportsContacts(): boolean { return true; }
supportsCalendars(): boolean { return true; }
supportsSieve(): boolean { return true; }
supportsFiles(): boolean { return true; }
// ── Push / state ──────────────────────────────────────────────
setupPushNotifications(): boolean { return true; }
closePushNotifications(): void { /* no-op in demo */ }
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
getLastStates(): AccountStates { return { ...this.lastStates }; }
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
// ── Quota ─────────────────────────────────────────────────────
async getQuota(): Promise<{ used: number; total: number } | null> {
return { used: 245_366_784, total: 1_073_741_824 };
}
// ── Mailboxes ─────────────────────────────────────────────────
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
const mb: Mailbox = {
id: generateDemoId('mailbox'),
name,
sortOrder: 100,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
parentId,
isSubscribed: true,
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true },
};
this.data.mailboxes.push(mb);
return mb;
}
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
if (mb) Object.assign(mb, changes);
}
async deleteMailbox(mailboxId: string): Promise<void> {
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
// Also remove emails in this mailbox
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
}
// ── Emails ────────────────────────────────────────────────────
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
let filtered = this.data.emails;
if (mailboxId) {
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
}
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
}
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
}
async getEmail(emailId: string): Promise<Email | null> {
return this.data.emails.find(e => e.id === emailId) ?? null;
}
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
const result: Record<string, { total: number; unread: number }> = {};
for (const tagId of tagIds) {
const tagged = this.data.emails.filter(e => e.keywords[tagId]);
result[tagId] = {
total: tagged.length,
unread: tagged.filter(e => !e.keywords.$seen).length,
};
}
return result;
}
async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
const q = query.toLowerCase();
let filtered = this.data.emails.filter(e => {
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
return text.includes(q);
});
if (mailboxId) filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
}
async advancedSearchEmails(filter: Record<string, unknown>, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
// Simplified: just return all emails for any advanced filter
let filtered = [...this.data.emails];
if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]);
if (filter.text) {
const q = (filter.text as string).toLowerCase();
filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q));
}
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
}
// ── Email mutations ───────────────────────────────────────────
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
if (read) {
email.keywords.$seen = true;
} else {
delete email.keywords.$seen;
}
this.recalcMailboxCounts();
}
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id);
if (email) {
if (read) email.keywords.$seen = true;
else delete email.keywords.$seen;
}
}
this.recalcMailboxCounts();
}
async toggleStar(emailId: string, starred: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
if (starred) email.keywords.$flagged = true;
else delete email.keywords.$flagged;
}
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.keywords = { ...email.keywords, ...keywords };
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
let count = 0;
for (const email of this.data.emails) {
if (email.keywords[oldKeyword]) {
delete email.keywords[oldKeyword];
email.keywords[newKeyword] = true;
count++;
}
}
return count;
}
async deleteEmail(emailId: string): Promise<void> {
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
this.recalcMailboxCounts();
}
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds = { [trashMailboxId]: true };
this.recalcMailboxCounts();
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
const idSet = new Set(emailIds);
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
this.recalcMailboxCounts();
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id);
if (email) email.mailboxIds = { [toMailboxId]: true };
}
this.recalcMailboxCounts();
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true };
this.recalcMailboxCounts();
}
async emptyMailbox(mailboxId: string): Promise<number> {
const before = this.data.emails.length;
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
const removed = before - this.data.emails.length;
this.recalcMailboxCounts();
return removed;
}
async markAsSpam(emailId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
this.recalcMailboxCounts();
}
async undoSpam(emailId: string, originalMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [originalMailboxId]: true };
this.recalcMailboxCounts();
}
// ── Threads ───────────────────────────────────────────────────
async getThread(threadId: string): Promise<Thread | null> {
const emails = this.data.emails.filter(e => e.threadId === threadId);
if (emails.length === 0) return null;
return { id: threadId, emailIds: emails.map(e => e.id) };
}
async getThreadEmails(threadId: string): Promise<Email[]> {
return this.data.emails
.filter(e => e.threadId === threadId)
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime());
}
// ── Compose / Send ────────────────────────────────────────────
async createDraft(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
_identityId?: string,
_fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
_fromName?: string,
): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
const id = draftId || generateDemoId('email');
const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1;
const email: Email = {
id, threadId: generateDemoId('thread'),
mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true },
keywords: { $seen: true, $draft: true },
size: body.length,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: to.map(e => ({ email: e })),
cc: cc?.map(e => ({ email: e })),
bcc: bcc?.map(e => ({ email: e })),
subject,
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
hasAttachment: !!attachments?.length,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: [],
bodyValues: { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${id}@demo.example.com>`,
};
if (existing >= 0) {
this.data.emails[existing] = email;
} else {
this.data.emails.push(email);
}
this.recalcMailboxCounts();
return id;
}
async sendEmail(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
_identityId?: string,
_fromEmail?: string,
draftId?: string,
_fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
): Promise<void> {
// Remove draft if updating
if (draftId) {
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
}
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
keywords: { $seen: true },
size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: to.map(e => ({ email: e })),
cc: cc?.map(e => ({ email: e })),
bcc: bcc?.map(e => ({ email: e })),
subject,
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
hasAttachment: !!attachments?.length,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: htmlBody ? [{ partId: '2', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }] : [],
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
};
this.data.emails.push(email);
this.recalcMailboxCounts();
}
async sendImipReply(): Promise<void> { /* no-op in demo */ }
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
// ── Blobs ─────────────────────────────────────────────────────
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
const blobId = generateDemoId('blob');
this.blobStore.set(blobId, file);
return { blobId, size: file.size, type: file.type };
}
getBlobDownloadUrl(blobId: string): string {
return `data:application/octet-stream;demo-blob=${blobId}`;
}
async fetchBlob(blobId: string): Promise<Blob> {
return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' });
}
async fetchBlobAsObjectUrl(blobId: string): Promise<string> {
const blob = await this.fetchBlob(blobId);
return URL.createObjectURL(blob);
}
async fetchBlobArrayBuffer(blobId: string): Promise<ArrayBuffer> {
const blob = await this.fetchBlob(blobId);
return blob.arrayBuffer();
}
async downloadBlob(blobId: string, name?: string): Promise<void> {
const blob = await this.fetchBlob(blobId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ── Identities ────────────────────────────────────────────────
async getIdentities(): Promise<Identity[]> { return [...this.data.identities]; }
async createIdentity(
name: string, email: string,
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
htmlSignature?: string, textSignature?: string,
): Promise<Identity> {
const identity: Identity = {
id: generateDemoId('identity'), name, email,
replyTo: replyTo ?? undefined, bcc: bcc ?? undefined,
htmlSignature: htmlSignature ?? '', textSignature: textSignature ?? '',
mayDelete: true,
};
this.data.identities.push(identity);
return identity;
}
async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise<void> {
const identity = this.data.identities.find(i => i.id === identityId);
if (identity) Object.assign(identity, updates);
}
async deleteIdentity(identityId: string): Promise<void> {
this.data.identities = this.data.identities.filter(i => i.id !== identityId);
}
// ── Vacation ──────────────────────────────────────────────────
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
Object.assign(this.data.vacationResponse, updates);
}
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string { return 'demo-account'; }
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts];
}
async getAllContacts(): Promise<ContactCard[]> { return [...this.data.contacts]; }
async getContact(contactId: string): Promise<ContactCard | null> {
return this.data.contacts.find(c => c.id === contactId) ?? null;
}
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
const full: ContactCard = {
id: generateDemoId('contact'),
addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true },
...contact,
} as ContactCard;
this.data.contacts.push(full);
return full;
}
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
const contact = this.data.contacts.find(c => c.id === contactId);
if (contact) Object.assign(contact, updates);
}
async deleteContact(contactId: string): Promise<void> {
this.data.contacts = this.data.contacts.filter(c => c.id !== contactId);
}
async searchContacts(query: string): Promise<ContactCard[]> {
const q = query.toLowerCase();
return this.data.contacts.filter(c => {
const nameStr = c.name?.components?.map(nc => nc.value).join(' ').toLowerCase() ?? '';
const emailStr = Object.values(c.emails ?? {}).map(e => e.address).join(' ').toLowerCase();
return nameStr.includes(q) || emailStr.includes(q);
});
}
// ── Calendars ─────────────────────────────────────────────────
getCalendarsAccountId(): string { return 'demo-account'; }
async getCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
const full: Calendar = {
id: generateDemoId('calendar'),
name: calendar.name ?? 'New Calendar',
description: calendar.description ?? null,
color: calendar.color ?? '#6366f1',
sortOrder: calendar.sortOrder ?? 99,
isSubscribed: true, isVisible: true, isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
timeZone: null, shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
...calendar,
} as Calendar;
this.data.calendars.push(full);
return full;
}
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
const cal = this.data.calendars.find(c => c.id === calendarId);
if (cal) Object.assign(cal, updates);
}
async deleteCalendar(calendarId: string): Promise<void> {
this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId);
this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]);
}
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
let events = [...this.data.calendarEvents];
if (calendarIds?.length) {
events = events.filter(e => calendarIds.some(cid => e.calendarIds[cid]));
}
return events;
}
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
return this.data.calendarEvents.find(e => e.id === id) ?? null;
}
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
const full: CalendarEvent = {
id: generateDemoId('event'),
calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true },
'@type': 'Event',
uid: generateDemoId('uid'),
title: event.title ?? 'New Event',
description: event.description ?? '',
descriptionContentType: 'text/plain',
isDraft: false, isOrigin: true,
created: new Date().toISOString(),
updated: new Date().toISOString(),
sequence: 0,
start: event.start ?? new Date().toISOString(),
duration: event.duration ?? 'PT1H',
timeZone: event.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
utcStart: event.utcStart ?? null,
utcEnd: event.utcEnd ?? null,
showWithoutTime: event.showWithoutTime ?? false,
status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
color: null, keywords: null, categories: null, locale: null,
replyTo: null, organizerCalendarAddress: null, participants: null,
mayInviteSelf: false, mayInviteOthers: false, hideAttendees: false,
recurrenceId: null, recurrenceIdTimeZone: null, recurrenceRules: null,
recurrenceOverrides: null, excludedRecurrenceRules: null,
useDefaultAlerts: true, alerts: null, locations: null,
virtualLocations: null, links: null, relatedTo: null,
...event,
} as CalendarEvent;
this.data.calendarEvents.push(full);
return full;
}
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found');
Object.assign(event, updates, { updated: new Date().toISOString() });
}
async deleteCalendarEvent(eventId: string): Promise<void> {
this.data.calendarEvents = this.data.calendarEvents.filter(e => e.id !== eventId);
}
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const idSet = new Set(eventIds);
this.data.calendarEvents = this.data.calendarEvents.filter(e => !idSet.has(e.id));
return { destroyed: eventIds, notDestroyed: [] };
}
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
return this.data.calendarEvents.filter(e => {
if (filter.after && e.start < filter.after) return false;
if (filter.before && e.start > filter.before) return false;
return true;
});
}
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
return this.queryCalendarEvents(filter);
}
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
return []; // no-op in demo
}
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string { return 'demo-account'; }
getSieveCapabilities(): SieveCapabilities | null {
return { ...this.data.sieveCapabilities };
}
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScriptContent(blobId: string): Promise<string> {
return this.data.sieveContent[blobId] ?? '';
}
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
const blobId = generateDemoId('sieve-blob');
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
this.data.sieveScripts.push(script);
this.data.sieveContent[blobId] = content;
if (activate) {
for (const s of this.data.sieveScripts) {
if (s.id !== script.id) s.isActive = false;
}
}
return script;
}
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
const script = this.data.sieveScripts.find(s => s.id === scriptId);
if (!script) return;
const blobId = generateDemoId('sieve-blob');
this.data.sieveContent[blobId] = content;
script.blobId = blobId;
if (activate !== undefined) {
script.isActive = activate;
if (activate) {
for (const s of this.data.sieveScripts) {
if (s.id !== scriptId) s.isActive = false;
}
}
}
}
async deleteSieveScript(scriptId: string): Promise<void> {
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
}
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
return { isValid: true };
}
// ── Files (FileNode) ─────────────────────────────────────────
getFilesAccountId(): string { return 'demo-account'; }
async probeFileNodeSupport(): Promise<boolean> { return true; }
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
return this.data.fileNodes.filter(n => n.parentId === parentId);
}
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
if (ids === null) return [...this.data.fileNodes];
return this.data.fileNodes.filter(n => ids.includes(n.id));
}
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
const node: FileNode = {
id: generateDemoId('file'),
parentId, name, type: 'd', blobId: null, size: 0,
created: new Date().toISOString(), updated: new Date().toISOString(),
};
this.data.fileNodes.push(node);
return node;
}
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
const node: FileNode = {
id: generateDemoId('file'),
parentId, name, type, blobId, size,
created: new Date().toISOString(), updated: new Date().toISOString(),
};
this.data.fileNodes.push(node);
return node;
}
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
const node = this.data.fileNodes.find(n => n.id === id);
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const idSet = new Set(ids);
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
return { destroyed: ids, notDestroyed: [] };
}
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
const original = this.data.fileNodes.find(n => n.id === id);
if (!original) throw new Error('File node not found');
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
}
// ── S/MIME raw-email helpers ──────────────────────────────────
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(): Promise<void> { /* no-op */ }
// ── Internal helpers ──────────────────────────────────────────
private recalcMailboxCounts(): void {
for (const mb of this.data.mailboxes) {
const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]);
mb.totalEmails = inMb.length;
mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length;
mb.totalThreads = new Set(inMb.map(e => e.threadId)).size;
mb.unreadThreads = new Set(inMb.filter(e => !e.keywords.$seen).map(e => e.threadId)).size;
}
}
private startIncomingEmailTimer(): void {
this.stopIncomingEmailTimer();
const scheduleNext = () => {
const delay = 60_000 + Math.random() * 60_000; // 60-120 seconds
this.incomingTimer = setTimeout(() => {
this.simulateIncomingEmail();
scheduleNext();
}, delay);
};
scheduleNext();
}
private stopIncomingEmailTimer(): void {
if (this.incomingTimer) {
clearTimeout(this.incomingTimer);
this.incomingTimer = null;
}
}
private simulateIncomingEmail(): void {
const senders = [
{ name: 'Alice Johnson', email: 'alice.johnson@example.com' },
{ name: 'Bob Chen', email: 'bob.chen@example.com' },
{ name: 'Sarah Kim', email: 'sarah.kim@example.com' },
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' },
];
const subjects = [
'Quick question about the project',
'Meeting rescheduled to tomorrow',
'FYI: Updated documentation',
'Can you review this PR?',
'Lunch today?',
'Important: deadline reminder',
];
const sender = senders[Math.floor(Math.random() * senders.length)];
const subject = subjects[Math.floor(Math.random() * subjects.length)];
const id = generateDemoId('email');
const email: Email = {
id, threadId: generateDemoId('thread'),
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 1800,
receivedAt: new Date().toISOString(),
from: [sender],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject, sentAt: new Date().toISOString(),
preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`,
hasAttachment: false,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: 120, type: 'text/plain' }],
bodyValues: {
'1': { value: `Hi,\n\n${subject}. Let me know what you think.\n\nBest,\n${sender.name}` },
},
messageId: `<${id}@demo.example.com>`,
};
this.data.emails.unshift(email);
this.recalcMailboxCounts();
// Notify state change to trigger UI refresh
this.stateChangeCallback?.({
'@type': 'StateChange',
changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import { cloneFixtures } from './demo-utils';
import { createDemoMailboxes } from './fixtures/mailboxes';
import { createDemoEmails } from './fixtures/emails';
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
import { createDemoIdentities } from './fixtures/identities';
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
import { createDemoFileNodes } from './fixtures/files';
import { createDemoVacationResponse } from './fixtures/vacation';
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
export interface DemoData {
mailboxes: Mailbox[];
emails: Email[];
contacts: ContactCard[];
addressBooks: AddressBook[];
calendars: Calendar[];
calendarEvents: CalendarEvent[];
identities: Identity[];
sieveScripts: SieveScript[];
sieveCapabilities: SieveCapabilities;
sieveContent: Record<string, string>;
fileNodes: FileNode[];
vacationResponse: VacationResponse;
}
/** Return a fresh deep-cloned copy of all demo data. */
export function getDemoData(): DemoData {
return cloneFixtures({
mailboxes: createDemoMailboxes(),
emails: createDemoEmails(),
contacts: createDemoContacts(),
addressBooks: createDemoAddressBooks(),
calendars: createDemoCalendars(),
calendarEvents: createDemoCalendarEvents(),
identities: createDemoIdentities(),
sieveScripts: createDemoSieveScripts(),
sieveCapabilities: createDemoSieveCapabilities(),
sieveContent: createDemoSieveContent(),
fileNodes: createDemoFileNodes(),
vacationResponse: createDemoVacationResponse(),
});
}
+35
View File
@@ -0,0 +1,35 @@
let demoIdCounter = 0;
/** Generate a unique demo ID with the given prefix. */
export function generateDemoId(prefix: string = 'demo'): string {
return `${prefix}-${Date.now()}-${++demoIdCounter}`;
}
/**
* Generate an ISO date string relative to "now".
* @param daysOffset whole days from today
* @param hoursOffset additional hours offset (default 0)
* @param minutesOffset additional minutes offset (default 0)
*/
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
const d = new Date();
d.setDate(d.getDate() + daysOffset);
d.setHours(d.getHours() + hoursOffset, d.getMinutes() + minutesOffset, 0, 0);
return d.toISOString();
}
/**
* Generate a local date-time string (YYYY-MM-DDTHH:mm:ss) for JSCalendar "start" fields.
*/
export function demoISODate(daysOffset: number, hours: number = 0, minutes: number = 0): string {
const d = new Date();
d.setDate(d.getDate() + daysOffset);
d.setHours(hours, minutes, 0, 0);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
/** Deep clone fixture data so in-memory mutations don't corrupt originals. */
export function cloneFixtures<T>(data: T): T {
return JSON.parse(JSON.stringify(data));
}
+274
View File
@@ -0,0 +1,274 @@
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { demoDate, demoISODate } from '../demo-utils';
export function createDemoCalendars(): Calendar[] {
return [
{
id: 'demo-calendar-personal',
name: 'Personal',
description: null,
color: '#3b82f6',
sortOrder: 1,
isSubscribed: true,
isVisible: true,
isDefault: true,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
},
{
id: 'demo-calendar-work',
name: 'Work',
description: null,
color: '#22c55e',
sortOrder: 2,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
},
{
id: 'demo-calendar-birthdays',
name: 'Birthdays',
description: null,
color: '#eab308',
sortOrder: 3,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
},
];
}
export function createDemoCalendarEvents(): CalendarEvent[] {
const baseEvent = {
'@type': 'Event' as const,
descriptionContentType: 'text/plain',
isDraft: false,
isOrigin: true,
sequence: 0,
status: 'confirmed' as const,
freeBusyStatus: 'busy' as const,
privacy: 'public' as const,
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: true,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
};
return [
// ── Personal calendar ──────────────────────────────────────
{
...baseEvent,
id: 'demo-event-1',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-1@example.com',
title: 'Dentist Appointment',
description: 'Regular checkup at Dr. Smith\'s office',
created: demoDate(-7),
updated: demoDate(-7),
start: demoISODate(2, 10, 0),
utcStart: demoDate(2, 10),
utcEnd: demoDate(2, 11),
duration: 'PT1H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'Dr. Smith Dental Clinic', description: '123 Medical Plaza', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
},
{
...baseEvent,
id: 'demo-event-2',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-2@example.com',
title: 'Birthday Party',
description: 'Emma\'s birthday celebration',
created: demoDate(-10),
updated: demoDate(-10),
start: demoISODate(5),
utcStart: demoDate(5),
utcEnd: demoDate(6),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
{
...baseEvent,
id: 'demo-event-3',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-3@example.com',
title: 'Weekend Trip',
description: 'Road trip to the mountains',
created: demoDate(-5),
updated: demoDate(-5),
start: demoISODate(8),
utcStart: demoDate(8),
utcEnd: demoDate(10),
duration: 'P2D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'busy' as const,
},
// ── Work calendar ──────────────────────────────────────────
{
...baseEvent,
id: 'demo-event-4',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-4@example.com',
title: 'Weekly Standup',
description: 'Team sync-up meeting',
created: demoDate(-30),
updated: demoDate(-1),
start: demoISODate(1, 9, 30),
utcStart: demoDate(1, 9, 30),
utcEnd: demoDate(1, 10, 0),
duration: 'PT30M',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 1,
rscale: 'gregorian',
skip: 'omit',
firstDayOfWeek: 'mo',
byDay: [{ day: 'mo' }],
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: null,
until: null,
}],
virtualLocations: { vl1: { '@type': 'VirtualLocation', name: 'Zoom', uri: 'https://zoom.example/123456', description: 'Weekly standup room', features: null } },
},
{
...baseEvent,
id: 'demo-event-5',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-5@example.com',
title: 'Quarterly Review',
description: 'Q4 performance review and planning session',
created: demoDate(-14),
updated: demoDate(-3),
start: demoISODate(4, 14, 0),
utcStart: demoDate(4, 14),
utcEnd: demoDate(4, 16),
duration: 'PT2H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
participants: {
p1: {
'@type': 'Participant', name: 'Demo User', email: 'demo@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { attendee: true }, participationStatus: 'accepted', participationComment: null,
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
p2: {
'@type': 'Participant', name: 'Alice Johnson', email: 'alice.johnson@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { owner: true }, participationStatus: 'accepted', participationComment: null,
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
p3: {
'@type': 'Participant', name: 'Bob Chen', email: 'bob.chen@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { attendee: true }, participationStatus: 'tentative', participationComment: null,
expectReply: true, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
},
},
{
...baseEvent,
id: 'demo-event-6',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-6@example.com',
title: 'Lunch Meeting with Sarah',
description: 'Design review over lunch',
created: demoDate(-3),
updated: demoDate(-3),
start: demoISODate(3, 12, 0),
utcStart: demoDate(3, 12),
utcEnd: demoDate(3, 13),
duration: 'PT1H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'The Garden Bistro', description: '123 Oak Street', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
},
// ── Birthdays calendar ─────────────────────────────────────
{
...baseEvent,
id: 'demo-event-7',
calendarIds: { 'demo-calendar-birthdays': true },
uid: 'demo-event-7@example.com',
title: 'Alice Johnson\'s Birthday',
description: '',
created: demoDate(-30),
updated: demoDate(-30),
start: demoISODate(12),
utcStart: demoDate(12),
utcEnd: demoDate(13),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
{
...baseEvent,
id: 'demo-event-8',
calendarIds: { 'demo-calendar-birthdays': true },
uid: 'demo-event-8@example.com',
title: 'Carlos Rivera\'s Birthday',
description: '',
created: demoDate(-30),
updated: demoDate(-30),
start: demoISODate(-3),
utcStart: demoDate(-3),
utcEnd: demoDate(-2),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
];
}
+194
View File
@@ -0,0 +1,194 @@
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
export function createDemoAddressBooks(): AddressBook[] {
return [
{
id: 'demo-addressbook-personal',
name: 'Personal',
isDefault: true,
isSubscribed: true,
sortOrder: 1,
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false },
},
{
id: 'demo-addressbook-work',
name: 'Work',
isDefault: false,
isSubscribed: true,
sortOrder: 2,
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
},
];
}
export function createDemoContacts(): ContactCard[] {
return [
// ── Personal address book ──────────────────────────────────
{
id: 'demo-contact-1',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] },
emails: { e1: { address: 'alice.johnson@example.com', contexts: { work: true }, pref: 1 } },
phones: { p1: { number: '+1-555-0101', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
},
{
id: 'demo-contact-2',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Chen' }] },
emails: {
e1: { address: 'bob.chen@example.com', contexts: { work: true }, pref: 1 },
e2: { address: 'bob.personal@email.example', contexts: { private: true } },
},
phones: {
p1: { number: '+1-555-0102', features: { voice: true }, contexts: { work: true } },
p2: { number: '+1-555-0103', features: { cell: true }, contexts: { private: true } },
},
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
},
{
id: 'demo-contact-3',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sarah' }, { kind: 'surname', value: 'Kim' }] },
emails: { e1: { address: 'sarah.kim@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
organizations: { o1: { name: 'DesignCo' } },
titles: { t1: { name: 'UX Designer', kind: 'title' } },
},
{
id: 'demo-contact-4',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Carlos' }, { kind: 'surname', value: 'Rivera' }] },
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
},
{
id: 'demo-contact-5',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Emma' }, { kind: 'surname', value: 'Wilson' }] },
emails: { e1: { address: 'emma.wilson@example.com', pref: 1 } },
addresses: {
a1: {
components: [
{ kind: 'number', value: '456' },
{ kind: 'name', value: 'Elm Street' },
{ kind: 'locality', value: 'Springfield' },
{ kind: 'region', value: 'IL' },
{ kind: 'postcode', value: '62701' },
],
contexts: { private: true },
},
},
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
},
{
id: 'demo-contact-6',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
},
{
id: 'demo-contact-7',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'org',
name: { components: [{ kind: 'surname', value: 'Local Coffee Shop' }] },
emails: { e1: { address: 'hello@localcoffee.example', pref: 1 } },
phones: { p1: { number: '+1-555-0200', features: { voice: true } } },
addresses: {
a1: {
components: [
{ kind: 'number', value: '789' },
{ kind: 'name', value: 'Main Street' },
{ kind: 'locality', value: 'Anytown' },
{ kind: 'region', value: 'CA' },
{ kind: 'postcode', value: '90210' },
],
},
},
},
{
id: 'demo-contact-8',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
},
// ── Work address book ──────────────────────────────────────
{
id: 'demo-contact-9',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Michael' }, { kind: 'surname', value: 'Torres' }] },
emails: { e1: { address: 'michael.torres@company.example', contexts: { work: true }, pref: 1 } },
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
titles: { t1: { name: 'Product Manager', kind: 'title' } },
},
{
id: 'demo-contact-10',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Rachel' }, { kind: 'surname', value: 'Green' }] },
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
},
{
id: 'demo-contact-11',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'James' }, { kind: 'surname', value: 'Miller' }] },
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'CTO', kind: 'title' } },
},
{
id: 'demo-contact-12',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Priya' }, { kind: 'surname', value: 'Sharma' }] },
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
},
{
id: 'demo-contact-13',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Ahmed' }, { kind: 'surname', value: 'Hassan' }] },
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
},
{
id: 'demo-contact-14',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Maria' }, { kind: 'surname', value: 'Lopez' }] },
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
},
{
id: 'demo-contact-15',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Wei' }, { kind: 'surname', value: 'Zhang' }] },
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
},
];
}
+364
View File
@@ -0,0 +1,364 @@
import type { Email } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
export function createDemoEmails(): Email[] {
const now = new Date();
return [
// ── Inbox ───────────────────────────────────────────────────
{
id: 'demo-email-1',
threadId: 'demo-thread-1',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 4200,
receivedAt: demoDate(0, -2),
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Welcome to Bulwark Mail!',
sentAt: demoDate(0, -2),
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
bodyValues: {
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
},
messageId: '<welcome@demo.bulwark.email>',
},
{
id: 'demo-email-2',
threadId: 'demo-thread-2',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 18500,
receivedAt: demoDate(-1, -5),
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'This Week in Tech: AI Developments & Open Source Updates',
sentAt: demoDate(-1, -5),
preview: 'Your weekly roundup of the most important technology news and open source developments...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
bodyValues: {
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
},
messageId: '<weekly-42@techdigest.example>',
},
// Thread: Project discussion (3 emails in same thread)
{
id: 'demo-email-3a',
threadId: 'demo-thread-3',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 3100,
receivedAt: demoDate(-3, -10),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Q4 Project Timeline',
sentAt: demoDate(-3, -10),
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
bodyValues: {
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review — Oct 15\n- Phase 2: Development — Nov 1-30\n- Phase 3: Testing — Dec 1-15\n- Phase 4: Launch — Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review — Oct 15</li><li>Phase 2: Development — Nov 1-30</li><li>Phase 3: Testing — Dec 1-15</li><li>Phase 4: Launch — Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
},
messageId: '<q4-timeline-1@example.com>',
},
{
id: 'demo-email-3b',
threadId: 'demo-thread-3',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 3500,
receivedAt: demoDate(-2, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-2, -8),
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n— Bob' },
},
messageId: '<q4-timeline-2@example.com>',
inReplyTo: ['<q4-timeline-1@example.com>'],
references: ['<q4-timeline-1@example.com>'],
},
{
id: 'demo-email-3c',
threadId: 'demo-thread-3',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 3800,
receivedAt: demoDate(-1, -3),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-1, -3),
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n— Alice' },
},
messageId: '<q4-timeline-3@example.com>',
inReplyTo: ['<q4-timeline-2@example.com>'],
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
},
// Email with attachments
{
id: 'demo-email-4',
threadId: 'demo-thread-4',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 245000,
receivedAt: demoDate(0, -6),
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Invoice #2024-089 & Project Screenshot',
sentAt: demoDate(0, -6),
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
hasAttachment: true,
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
},
attachments: [
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
],
messageId: '<invoice-089@example.com>',
},
// Starred email
{
id: 'demo-email-5',
threadId: 'demo-thread-5',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true, $flagged: true },
size: 2800,
receivedAt: demoDate(-2, -1),
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Reminder: Team Dinner Friday',
sentAt: demoDate(-2, -1),
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
},
messageId: '<dinner-reminder@example.com>',
},
// ── Sent ────────────────────────────────────────────────────
{
id: 'demo-email-6',
threadId: 'demo-thread-6',
mailboxIds: { 'demo-mailbox-sent': true },
keywords: { $seen: true },
size: 2100,
receivedAt: demoDate(-1, -4),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
subject: 'Updated Requirements Document',
sentAt: demoDate(-1, -4),
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
},
messageId: '<sent-1@example.com>',
},
{
id: 'demo-email-7',
threadId: 'demo-thread-7',
mailboxIds: { 'demo-mailbox-sent': true },
keywords: { $seen: true },
size: 1800,
receivedAt: demoDate(-4, -2),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
subject: 'Re: Design Feedback',
sentAt: demoDate(-4, -2),
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
},
messageId: '<sent-2@example.com>',
},
// ── Drafts ──────────────────────────────────────────────────
{
id: 'demo-email-8',
threadId: 'demo-thread-8',
mailboxIds: { 'demo-mailbox-drafts': true },
keywords: { $seen: true, $draft: true },
size: 900,
receivedAt: demoDate(0, -1),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Meeting Notes - Draft',
sentAt: demoDate(0, -1),
preview: 'Here are the notes from today\'s standup...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
},
messageId: '<draft-1@example.com>',
},
// ── Trash ───────────────────────────────────────────────────
{
id: 'demo-email-9',
threadId: 'demo-thread-9',
mailboxIds: { 'demo-mailbox-trash': true },
keywords: { $seen: true },
size: 15200,
receivedAt: demoDate(-5, -3),
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: '🎉 Flash Sale: 50% Off Everything!',
sentAt: demoDate(-5, -3),
preview: 'Limited time offer! Get 50% off all items in our store...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
},
messageId: '<promo-1@promostore.example>',
},
{
id: 'demo-email-10',
threadId: 'demo-thread-10',
mailboxIds: { 'demo-mailbox-trash': true },
keywords: { $seen: true },
size: 2300,
receivedAt: demoDate(-7, 0),
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Your password was changed',
sentAt: demoDate(-7, 0),
preview: 'Your account password was successfully changed on...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
},
messageId: '<notification-1@service.example>',
},
// ── Projects ────────────────────────────────────────────────
{
id: 'demo-email-11',
threadId: 'demo-thread-11',
mailboxIds: { 'demo-mailbox-projects': true },
keywords: { $seen: true, $flagged: true },
size: 4500,
receivedAt: demoDate(-2, -7),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: '[Project] Sprint Planning Agenda',
sentAt: demoDate(-2, -7),
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
},
messageId: '<project-1@example.com>',
},
{
id: 'demo-email-12',
threadId: 'demo-thread-12',
mailboxIds: { 'demo-mailbox-projects': true },
keywords: {},
size: 3200,
receivedAt: demoDate(0, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: '[Project] API Rate Limiting Discussion',
sentAt: demoDate(0, -8),
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n— Bob' },
},
messageId: '<project-2@example.com>',
},
// ── Archive ─────────────────────────────────────────────────
{
id: 'demo-email-13',
threadId: 'demo-thread-13',
mailboxIds: { 'demo-mailbox-archive': true },
keywords: { $seen: true },
size: 2600,
receivedAt: demoDate(-14, -6),
from: [{ name: 'HR Department', email: 'hr@company.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Updated PTO Policy - Effective January 1',
sentAt: demoDate(-14, -6),
preview: 'Please review the updated PTO policy that takes effect January 1st...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
},
messageId: '<hr-policy-1@company.example>',
},
// ── Receipts ────────────────────────────────────────────────
{
id: 'demo-email-14',
threadId: 'demo-thread-14',
mailboxIds: { 'demo-mailbox-receipts': true },
keywords: { $seen: true },
size: 5200,
receivedAt: demoDate(-3, -12),
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Payment Receipt - Invoice #INV-2024-1042',
sentAt: demoDate(-3, -12),
preview: 'Your payment of $49.99 has been processed successfully...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
},
messageId: '<receipt-1@cloudprovider.example>',
},
// ── Spam ────────────────────────────────────────────────────
{
id: 'demo-email-15',
threadId: 'demo-thread-15',
mailboxIds: { 'demo-mailbox-junk': true },
keywords: {},
size: 8900,
receivedAt: demoDate(-1, -9),
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Congratulations! You Won $1,000,000!!!',
sentAt: demoDate(-1, -9),
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
},
messageId: '<spam-1@totallylegit.example>',
},
];
}
+94
View File
@@ -0,0 +1,94 @@
import type { FileNode } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
export function createDemoFileNodes(): FileNode[] {
return [
// Root-level directories
{
id: 'demo-file-documents',
parentId: null,
name: 'Documents',
type: 'd',
blobId: null,
size: 0,
created: demoDate(-30),
updated: demoDate(-2),
},
{
id: 'demo-file-photos',
parentId: null,
name: 'Photos',
type: 'd',
blobId: null,
size: 0,
created: demoDate(-30),
updated: demoDate(-5),
},
// Documents contents
{
id: 'demo-file-meeting-notes',
parentId: 'demo-file-documents',
name: 'meeting-notes.md',
type: 'text/markdown',
blobId: 'demo-blob-file-1',
size: 2150,
created: demoDate(-7),
updated: demoDate(-2),
},
{
id: 'demo-file-quarterly-report',
parentId: 'demo-file-documents',
name: 'quarterly-report.pdf',
type: 'application/pdf',
blobId: 'demo-blob-file-2',
size: 148480,
created: demoDate(-14),
updated: demoDate(-14),
},
{
id: 'demo-file-todo',
parentId: 'demo-file-documents',
name: 'todo.txt',
type: 'text/plain',
blobId: 'demo-blob-file-3',
size: 410,
created: demoDate(-3),
updated: demoDate(-1),
},
// Photos contents
{
id: 'demo-file-vacation',
parentId: 'demo-file-photos',
name: 'vacation.jpg',
type: 'image/jpeg',
blobId: 'demo-blob-file-4',
size: 1258291,
created: demoDate(-10),
updated: demoDate(-10),
},
{
id: 'demo-file-team-photo',
parentId: 'demo-file-photos',
name: 'team-photo.png',
type: 'image/png',
blobId: 'demo-blob-file-5',
size: 911360,
created: demoDate(-21),
updated: demoDate(-21),
},
// Root-level file
{
id: 'demo-file-budget',
parentId: null,
name: 'budget.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
blobId: 'demo-blob-file-6',
size: 68608,
created: demoDate(-5),
updated: demoDate(-1),
},
];
}
+48
View File
@@ -0,0 +1,48 @@
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
export function createDemoSieveCapabilities(): SieveCapabilities {
return {
implementation: 'Demo Sieve Engine',
maxSizeScript: 65536,
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope'],
notificationMethods: [],
externalLists: [],
};
}
export function createDemoSieveScripts(): SieveScript[] {
return [
{
id: 'demo-sieve-1',
name: 'Default Filters',
blobId: 'demo-sieve-blob-1',
isActive: true,
},
];
}
// Sieve script content keyed by blobId
export function createDemoSieveContent(): Record<string, string> {
return {
'demo-sieve-blob-1': [
'require ["fileinto", "imap4flags"];',
'',
'# Newsletters to Receipts',
'if address :contains "from" "newsletter@" {',
' fileinto "Receipts";',
' stop;',
'}',
'',
'# Flag emails from boss',
'if address :is "from" "alice.johnson@example.com" {',
' addflag "\\\\Flagged";',
'}',
'',
'# Move project updates',
'if header :contains "subject" "[Project]" {',
' fileinto "Projects";',
' stop;',
'}',
].join('\n'),
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { Identity } from '@/lib/jmap/types';
export function createDemoIdentities(): Identity[] {
return [
{
id: 'demo-identity-primary',
name: 'Demo User',
email: 'demo@example.com',
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
mayDelete: false,
},
{
id: 'demo-identity-alias',
name: 'Demo User',
email: 'demo+newsletter@example.com',
textSignature: '',
htmlSignature: '',
mayDelete: true,
},
];
}
+17
View File
@@ -0,0 +1,17 @@
import type { Mailbox } from '@/lib/jmap/types';
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
export function createDemoMailboxes(): Mailbox[] {
return [
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
];
}
+13
View File
@@ -0,0 +1,13 @@
import type { VacationResponse } from '@/lib/jmap/types';
export function createDemoVacationResponse(): VacationResponse {
return {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: 'Out of Office',
textBody: 'Thank you for your email. I am currently out of the office and will return on Monday. For urgent matters, please contact support@example.com.',
htmlBody: '<p>Thank you for your email. I am currently out of the office and will return on Monday.</p><p>For urgent matters, please contact <a href="mailto:support@example.com">support@example.com</a>.</p>',
};
}
+229
View File
@@ -0,0 +1,229 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
* Interface defining the public JMAP client contract.
*
* Both the real `JMAPClient` (network-backed) and `DemoJMAPClient`
* (in-memory/browser-only) implement this interface so that stores
* and UI code never need to know which one is active.
*/
export interface IJMAPClient {
// ── Connection lifecycle ──────────────────────────────────────
connect(): Promise<void>;
disconnect(): void;
reconnect(): Promise<void>;
ping(): Promise<void>;
// ── Session / auth accessors ──────────────────────────────────
getServerUrl(): string;
getAuthHeader(): string;
updateAccessToken(token: string): void;
getAccountId(): string;
getUsername(): string;
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getEventSourceUrl(): string | null;
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
supportsVacationResponse(): boolean;
supportsContacts(): boolean;
supportsCalendars(): boolean;
supportsSieve(): boolean;
supportsFiles(): boolean;
// ── Push / state ──────────────────────────────────────────────
setupPushNotifications(): boolean;
closePushNotifications(): void;
onConnectionChange(callback: (connected: boolean) => void): void;
onStateChange(callback: (change: StateChange) => void): void;
getLastStates(): AccountStates;
setLastStates(states: AccountStates): void;
// ── Quota ─────────────────────────────────────────────────────
getQuota(): Promise<{ used: number; total: number } | null>;
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
deleteMailbox(mailboxId: string): Promise<void>;
// ── Emails ────────────────────────────────────────────────────
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
advancedSearchEmails(
filter: Record<string, unknown>,
accountId?: string,
limit?: number,
position?: number,
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
// ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
createDraft(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
fromName?: string,
): Promise<string>;
sendEmail(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
): Promise<void>;
sendImipReply(opts: {
organizerEmail: string;
organizerName?: string;
attendeeEmail: string;
attendeeName?: string;
uid: string;
summary?: string;
dtStart?: string;
dtEnd?: string;
timeZone?: string;
isAllDay?: boolean;
sequence?: number;
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
identityId?: string;
}): Promise<void>;
sendImipInvitation(event: CalendarEvent): Promise<void>;
sendImipCancellation(event: CalendarEvent): Promise<void>;
// ── Blobs ─────────────────────────────────────────────────────
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
getIdentities(): Promise<Identity[]>;
createIdentity(
name: string,
email: string,
replyTo?: EmailAddress[] | null,
bcc?: EmailAddress[] | null,
htmlSignature?: string,
textSignature?: string,
): Promise<Identity>;
updateIdentity(
identityId: string,
updates: {
name?: string;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
htmlSignature?: string;
textSignature?: string;
},
): Promise<void>;
deleteIdentity(identityId: string): Promise<void>;
// ── Vacation ──────────────────────────────────────────────────
getVacationResponse(): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard>;
updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void>;
deleteContact(contactId: string, targetAccountId?: string): Promise<void>;
searchContacts(query: string): Promise<ContactCard[]>;
// ── Calendars ─────────────────────────────────────────────────
getCalendarsAccountId(): string;
getCalendars(): Promise<Calendar[]>;
getAllCalendars(): Promise<Calendar[]>;
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
updateCalendarEvent(
eventId: string,
updates: Partial<CalendarEvent>,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void>;
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
getSieveScripts(): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
deleteSieveScript(scriptId: string): Promise<void>;
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
// ── Files (WebDAV / FileNode) ─────────────────────────────────
getFilesAccountId(): string;
probeFileNodeSupport(): Promise<boolean>;
listFileNodes(parentId: string | null): Promise<FileNode[]>;
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
}
+70 -18
View File
@@ -1,5 +1,6 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
// JMAP protocol types - these are intentionally flexible due to server variations
@@ -99,7 +100,7 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
return emailCount === limit;
}
export class JMAPClient {
export class JMAPClient implements IJMAPClient {
private serverUrl: string;
private username: string;
private password: string;
@@ -762,6 +763,56 @@ export class JMAPClient {
]);
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
// Query all email IDs that have the old keyword
const allIds: string[] = [];
let position = 0;
const batchSize = 100;
// eslint-disable-next-line no-constant-condition
while (true) {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: oldKeyword },
limit: batchSize,
position,
}, "0"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const ids: string[] = queryResult?.ids || [];
allIds.push(...ids);
if (ids.length < batchSize) break;
position += ids.length;
}
if (allIds.length === 0) return 0;
// Batch update: remove old keyword, add new keyword using per-property patches
const updateBatchSize = 50;
for (let i = 0; i < allIds.length; i += updateBatchSize) {
const batch = allIds.slice(i, i + updateBatchSize);
const update: Record<string, Record<string, boolean | null>> = {};
for (const id of batch) {
update[id] = {
[`keywords/${oldKeyword}`]: null,
[`keywords/${newKeyword}`]: true,
};
}
await this.request([
["Email/set", {
accountId: this.accountId,
update,
}, "0"],
]);
}
return allIds.length;
}
async deleteEmail(emailId: string): Promise<void> {
await this.request([
["Email/set", {
@@ -1358,34 +1409,35 @@ export class JMAPClient {
}));
}
// Destroy old draft before creating replacement to avoid duplicates
const methodCalls: JMAPMethodCall[] = [];
// Use a single Email/set call with both destroy and create for atomicity
const setArgs: Record<string, unknown> = {
accountId: this.accountId,
create: { [emailId]: emailData },
};
if (draftId) {
methodCalls.push(["Email/set", {
accountId: this.accountId, destroy: [draftId],
}, "0"]);
methodCalls.push(["Email/set", {
accountId: this.accountId, create: { [emailId]: emailData },
}, "1"]);
} else {
methodCalls.push(["Email/set", {
accountId: this.accountId, create: { [emailId]: emailData },
}, "0"]);
setArgs.destroy = [draftId];
}
const methodCalls: JMAPMethodCall[] = [
["Email/set", setArgs, "0"],
];
const response = await this.request(methodCalls);
const responseIndex = draftId ? 1 : 0;
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") {
const result = response.methodResponses[responseIndex][1];
if (response.methodResponses?.[0]?.[0] === "Email/set") {
const result = response.methodResponses[0][1];
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
if (result.notCreated) {
const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
}
if (draftId && result.notDestroyed) {
console.warn('Failed to destroy old draft:', result.notDestroyed);
}
if (result.created?.[emailId]) {
return result.created[emailId].id;
}
+23 -3
View File
@@ -171,9 +171,28 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
// From SubjectAlternativeName
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
if (sanExt?.parsedValue) {
const san = sanExt.parsedValue as pkijs.GeneralNames;
for (const name of san.names) {
if (sanExt) {
let names: pkijs.GeneralName[] | undefined;
// parsedValue may be a GeneralNames with .names, or a raw ASN.1 object
const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined;
if (pv?.names) {
names = pv.names;
} else if (sanExt.extnValue) {
// Manually parse the extension value as a SEQUENCE OF GeneralName
try {
const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
if (sanAsn1.offset !== -1) {
const gn = new pkijs.GeneralNames({ schema: sanAsn1.result });
names = gn.names;
}
} catch {
// Malformed SAN — skip gracefully
}
}
if (names) {
for (const name of names) {
// type 1 = rfc822Name
if (name.type === 1 && typeof name.value === 'string') {
if (!emails.includes(name.value)) {
@@ -182,6 +201,7 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
}
}
}
}
return emails;
}
+182 -3
View File
@@ -7,21 +7,200 @@
*
* Native Web Crypto calls are passed through to the real implementation;
* liner only intercepts algorithms that the browser doesn't natively support.
*
* Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
* handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use
* legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We
* extend CryptoEngine to handle those via RFC 7292 Appendix B key
* derivation + webcrypto-liner's DES-EDE3-CBC support.
*/
import * as asn1js from 'asn1js';
import * as pkijs from 'pkijs';
// webcrypto-liner exports a Crypto constructor at runtime that extends native
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
// expose the type alias, so we import the module dynamically and cast.
// Import the ES module build directly — the package's "browser" field points
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
// eslint-disable-next-line @typescript-eslint/no-require-imports
const liner = require('webcrypto-liner') as {
const liner = require('webcrypto-liner/build/index.es.js') as {
Crypto: { new (): Crypto };
setCrypto: (subtle: SubtleCrypto) => void;
nativeCrypto: Crypto | Record<string, never>;
};
let linerEngine: pkijs.CryptoEngine | null = null;
// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC
const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC
const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC
const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC
const LEGACY_PBE_OIDS = new Set([
PBE_SHA1_3DES_3KEY,
PBE_SHA1_3DES_2KEY,
PBE_SHA1_RC2_128,
PBE_SHA1_RC2_40,
]);
/** Algorithm config for each legacy PBE OID. */
function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } {
switch (oid) {
case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' };
case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' };
case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' };
case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' };
default: throw new Error(`Unsupported legacy PBE OID: ${oid}`);
}
}
/**
* PKCS#12 key derivation RFC 7292, Appendix B.
*
* @param password BMP-encoded password (with trailing 0x00 0x00)
* @param salt raw salt bytes
* @param iterations PBKDF iteration count
* @param id 1 = key material, 2 = IV, 3 = MAC key
* @param needed number of bytes to derive
*/
async function pkcs12KDF(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
id: number,
needed: number,
): Promise<Uint8Array> {
const v = 64; // SHA-1 block size
const u = 20; // SHA-1 output size
// Step 1: diversifier D = v bytes of 'id'
const D = new Uint8Array(v);
D.fill(id);
// Step 2: fill S from salt, padded/repeated to v-byte boundary
const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v);
const S = new Uint8Array(sLen);
for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length];
// Step 3: fill P from password, padded/repeated to v-byte boundary
const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v);
const P = new Uint8Array(pLen);
for (let i = 0; i < pLen; i++) P[i] = password[i % password.length];
// I = S || P
const I = new Uint8Array(sLen + pLen);
I.set(S, 0);
I.set(P, sLen);
const c = Math.ceil(needed / u);
const result = new Uint8Array(c * u);
for (let i = 0; i < c; i++) {
// Aj = Hash^iterations(D || I)
const buf = new Uint8Array(v + I.length);
buf.set(D, 0);
buf.set(I, v);
let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf));
for (let j = 1; j < iterations; j++) {
A = new Uint8Array(await crypto.subtle.digest('SHA-1', A));
}
result.set(A, i * u);
if (i + 1 < c) {
// Build B by repeating A to fill v bytes
const B = new Uint8Array(v);
for (let j = 0; j < v; j++) B[j] = A[j % u];
// I[j] = (I[j] + B + 1) mod 2^v for each v-byte block
for (let j = 0; j < I.length; j += v) {
let carry = 1;
for (let k = v - 1; k >= 0; k--) {
const sum = I[j + k] + B[k] + carry;
I[j + k] = sum & 0xff;
carry = sum >> 8;
}
}
}
}
return result.slice(0, needed);
}
/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */
function passwordToBMP(password: ArrayBuffer): Uint8Array {
const passView = new Uint8Array(password);
// If already BMP-encoded (even length, every odd byte is 0x00 for ASCII),
// or empty, use as-is. Otherwise convert char codes to big-endian UCS-2.
// pkijs passes the password as a raw ArrayBuffer of char codes.
const bmp = new Uint8Array(passView.length * 2 + 2);
for (let i = 0; i < passView.length; i++) {
bmp[i * 2] = 0;
bmp[i * 2 + 1] = passView[i];
}
// trailing 0x00 0x00
bmp[bmp.length - 2] = 0;
bmp[bmp.length - 1] = 0;
return bmp;
}
/**
* Extended CryptoEngine that handles legacy PKCS#12 PBE algorithms.
* Falls through to the base CryptoEngine for everything else.
*/
class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
async decryptEncryptedContentInfo(
parameters: Parameters<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
): Promise<ArrayBuffer> {
const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
if (!LEGACY_PBE_OIDS.has(oid)) {
// Delegate to base CryptoEngine (handles PBES2)
return super.decryptEncryptedContentInfo(parameters);
}
const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
if (!algParams) {
throw new Error('Missing PBE algorithm parameters');
}
// Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER }
const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
if (paramAsn1.offset === -1) {
throw new Error('Invalid PBE parameters ASN.1');
}
const seq = paramAsn1.result as asn1js.Sequence;
const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView);
const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec;
const { keyLen, ivLen, algName } = pbeConfig(oid);
const bmpPassword = passwordToBMP(parameters.password);
// Derive key (id=1) and IV (id=2) using PKCS#12 KDF
const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
// Import key via webcrypto-liner (supports DES-EDE3-CBC)
const cryptoKey = await this.importKey(
'raw',
new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength) as unknown as BufferSource,
{ name: algName, length: keyLen * 8 } as Algorithm,
false,
['decrypt'],
);
// Decrypt
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
return this.decrypt(
{ name: algName, iv: ivBytes } as Algorithm,
cryptoKey,
ciphertext,
);
}
}
let linerEngine: Pkcs12CryptoEngine | null = null;
let linerCryptoInstance: Crypto | null = null;
function ensureLiner() {
@@ -39,7 +218,7 @@ function ensureLiner() {
linerCryptoInstance = new liner.Crypto();
}
if (!linerEngine) {
linerEngine = new pkijs.CryptoEngine({
linerEngine = new Pkcs12CryptoEngine({
crypto: linerCryptoInstance,
subtle: linerCryptoInstance.subtle,
name: 'webcrypto-liner',
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Webseite",
"imprint": "Impressum",
"privacy_policy": "Datenschutz",
"try_demo": "Demo testen",
"demo_description": "Erkunden Sie mit Beispieldaten — kein Konto nötig",
"demo_launching": "Demo wird gestartet...",
"demo_login_button": "Demo starten",
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
"demo_no_signup": "Keine Registrierung nötig — erkunden Sie frei mit Beispieldaten",
"oauth_completing": "Anmeldung wird abgeschlossen...",
"oauth_error": {
"title": "Authentifizierung fehlgeschlagen",
@@ -104,6 +110,9 @@
},
"clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv",
"demo_banner": "Demo-Modus",
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
"tags": "Tags",
"mail": "E-Mail",
"nav_label": "Navigation",
@@ -847,6 +856,9 @@
"account": {
"title": "Konto",
"description": "Zeigen Sie Ihre Kontoinformationen an",
"name_label": "Anzeigename",
"account_type_label": "Kontotyp",
"demo_account": "Demokonto",
"email": {
"label": "E-Mail-Adresse",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Kontakte",
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
"no_category": "Ohne Kategorie",
"category_added": "Kontakt zu {name} hinzugefügt",
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
"empty_state": "Keine Kontakte",
"empty_state_title": "Keine Kontakte",
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
@@ -1613,7 +1628,8 @@
"level_low": "Niedrig",
"categories": "Kategorien",
"categories_placeholder": "z. B. Familie, Freunde, Kollegen",
"categories_hint": "Mit Kommas trennen",
"categories_hint": "Tippen zum Suchen oder Hinzufügen",
"category_add": "Hinzufügen",
"note": "Notizen",
"note_placeholder": "Notiz hinzufügen...",
"gender": "Geschlecht",
@@ -2001,7 +2017,17 @@
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
"got_it": "Verstanden",
"settings": "Einstellungen",
"dismiss": "Schließen"
"dismiss": "Schließen",
"start_tour": "Tour starten"
},
"demo_welcome": {
"title": "Willkommen bei Bulwark Mail",
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client — direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
"feature_email": "E-Mails lesen & verfassen",
"feature_organize": "Tags, Sterne & Ordner",
"feature_shortcuts": "Tastenkürzel",
"feature_privacy": "100 % private Demo",
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
},
"files": {
"title": "Dateien",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
"incorrect_passphrase": "Falsche Passphrase"
},
"tour": {
"step_counter": "Schritt {current} von {total}",
"skip": "Tour überspringen",
"back": "Zurück",
"next": "Weiter",
"finish": "Fertig",
"take_a_tour": "Machen Sie eine Tour durch die Oberfläche",
"restart_title": "Einführungstour",
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
"restart_button": "Tour neu starten",
"sidebar_title": "Ihre Postfächer",
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
"compose_title": "E-Mail verfassen",
"compose_desc": "Klicken Sie hier, um eine neue E-Mail zu schreiben. Sie können Empfänger, Anhänge und Textformatierung hinzufügen.",
"search_title": "E-Mails durchsuchen",
"search_desc": "Suchen Sie nach Absender, Betreff oder Inhalt. Klicken Sie auf das Filtersymbol für erweiterte Optionen wie Datumsbereich, Anhänge und markierte Nachrichten.",
"email_list_title": "Ihre E-Mail-Liste",
"email_list_desc": "E-Mails erscheinen hier. Klicken Sie auf eine, um sie rechts zu lesen. Verwenden Sie die Checkbox, um mehrere auszuwählen und sie dann zu verschieben, löschen oder taggen.",
"email_viewer_title": "Lesebereich",
"email_viewer_desc": "Die ausgewählte E-Mail wird hier geöffnet. Antworten, weiterleiten, archivieren oder löschen Sie mit den Schaltflächen. Sie können auch E-Mails markieren oder Farbtags hinzufügen.",
"keywords_title": "Farbtags",
"keywords_desc": "Organisieren Sie Ihre E-Mails mit farbcodierten Tags. Ziehen Sie eine E-Mail auf ein Tag oder klicken Sie mit der rechten Maustaste.",
"calendar_title": "Kalender",
"calendar_desc": "Wechseln Sie zum Kalender, um Ihre Termine zu verwalten. Erstellen Sie Ereignisse, setzen Sie Erinnerungen und wählen Sie verschiedene Ansichten.",
"contacts_title": "Kontakte",
"contacts_desc": "Hier finden Sie Ihr Adressbuch. Importieren Sie Kontakte, erstellen Sie Gruppen und sehen Sie Details.",
"settings_title": "Einstellungen",
"settings_desc": "Passen Sie alles an: Design, Dichte, Signaturen, Filter, Tastaturkürzel, Kalender-Standards und mehr.",
"shortcuts_title": "Tastaturkürzel",
"shortcuts_desc": "Für Power-User. Drücken Sie jederzeit ?, um alle verfügbaren Kürzel anzuzeigen.",
"calendar_view_title": "Ihr Kalender",
"calendar_view_desc": "Hier ist Ihr Kalender mit Beispielterminen. Wechseln Sie zwischen Tag-, Wochen-, Monats- und Agendaansicht.",
"contacts_list_title": "Ihre Kontakte",
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
"files_title": "Dateispeicher",
"settings_tabs_title": "Einstellungsmenü",
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
"demo_banner_title": "Demo-Steuerung",
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
"quota_title": "Speichernutzung",
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
}
}
+91 -3
View File
@@ -40,6 +40,12 @@
"website": "Website",
"imprint": "Imprint",
"privacy_policy": "Privacy Policy",
"try_demo": "Try Demo",
"demo_description": "Explore with sample data — no account needed",
"demo_launching": "Launching demo...",
"demo_login_button": "Launch Demo",
"demo_tagline": "Experience a full-featured email client. No account required.",
"demo_no_signup": "No signup needed — explore freely with sample data",
"oauth_completing": "Completing sign in...",
"oauth_error": {
"title": "Authentication Failed",
@@ -104,6 +110,9 @@
},
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active",
"demo_banner": "Demo Mode",
"demo_reset": "Reset",
"demo_tour": "Tour",
"tags": "Tags",
"mail": "Mail",
"nav_label": "Navigation",
@@ -682,7 +691,9 @@
"delete": "Delete keyword",
"save": "Save",
"add": "Add",
"cancel": "Cancel"
"cancel": "Cancel",
"migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails"
},
"language_region": {
"title": "Language & Region",
@@ -797,6 +808,17 @@
"close": "Close",
"invalid_email": "Please enter a valid email address",
"already_added": "This sender is already trusted"
},
"hover_actions": {
"label": "Quick Hover Actions",
"description": "Choose which quick actions appear when hovering over an email in the list",
"delete": "Delete",
"star": "Star / Unstar",
"mark_read": "Mark Read / Unread",
"archive": "Archive",
"tag": "Tag",
"spam": "Mark as Spam",
"none_selected": "No actions selected"
}
},
"composer": {
@@ -847,6 +869,9 @@
"account": {
"title": "Account",
"description": "View your account information",
"name_label": "Display Name",
"account_type_label": "Account Type",
"demo_account": "Demo Account",
"email": {
"label": "Email Address",
"value": "{email}"
@@ -1470,6 +1495,9 @@
"title": "Contacts",
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"no_category": "No Category",
"category_added": "Contact added to {name}",
"category_added_plural": "{count} contacts added to {name}",
"empty_state": "No contacts yet",
"empty_state_title": "No contacts yet",
"empty_state_subtitle": "Create your first contact or import from a vCard file",
@@ -1613,7 +1641,8 @@
"level_low": "Low",
"categories": "Categories",
"categories_placeholder": "e.g., Family, Friends, Colleagues",
"categories_hint": "Separate with commas",
"categories_hint": "Type to search or add categories",
"category_add": "Add",
"note": "Notes",
"note_placeholder": "Add a note...",
"gender": "Gender",
@@ -2001,7 +2030,17 @@
"tip_settings": "Customize your experience in Settings",
"got_it": "Got it",
"settings": "Settings",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"start_tour": "Start Tour"
},
"demo_welcome": {
"title": "Welcome to Bulwark Mail",
"description": "Explore a fully-featured webmail client — right in your browser. All data stays on your device, so feel free to test everything.",
"feature_email": "Read & compose email",
"feature_organize": "Tags, stars & folders",
"feature_shortcuts": "Keyboard shortcuts",
"feature_privacy": "100% private demo",
"hint": "Click any email on the left to get started, or take the tour below."
},
"files": {
"title": "Files",
@@ -2181,5 +2220,54 @@
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
"incorrect_passphrase": "Incorrect passphrase"
},
"tour": {
"step_counter": "Step {current} of {total}",
"skip": "Skip tour",
"back": "Back",
"next": "Next",
"finish": "Finish",
"take_a_tour": "Take a tour of the interface",
"restart_title": "Introductory tour",
"restart_desc": "Replay the guided walkthrough of the interface",
"restart_button": "Restart tour",
"sidebar_title": "Your mailboxes",
"sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.",
"compose_title": "Compose an email",
"compose_desc": "Click here to write a new email. You can add recipients, attachments, and use rich text formatting.",
"search_title": "Search your mail",
"search_desc": "Search by sender, subject, or content. Click the filter icon for advanced options like date range, attachments, and starred messages.",
"email_list_title": "Your email list",
"email_list_desc": "Emails appear here. Click one to read it on the right. Use the checkbox to select multiple, then bulk-move, delete, or tag them.",
"email_viewer_title": "Reading pane",
"email_viewer_desc": "The selected email opens here. Reply, forward, archive, or delete with the toolbar buttons. You can also star emails or add color tags.",
"keywords_title": "Color tags",
"keywords_desc": "Organize your email with color-coded tags. Drag an email onto a tag to label it, or right-click an email to assign tags.",
"calendar_title": "Calendar",
"calendar_desc": "Switch to the calendar to manage your events. Create events, set reminders, and view day, week, or month layouts.",
"contacts_title": "Contacts",
"contacts_desc": "Your address book lives here. Import contacts, create groups, and click any contact to see their full details.",
"settings_title": "Settings",
"settings_desc": "Customize everything: theme, density, signatures, filters, keyboard shortcuts, calendar defaults, and more.",
"shortcuts_title": "Keyboard shortcuts",
"shortcuts_desc": "Power users love this. Press ? anytime to see all available shortcuts. You can navigate, compose, and manage emails without touching a mouse.",
"compose_open_title": "The composer",
"compose_open_desc": "This is the email composer. Add recipients, write your message, attach files, and use rich text formatting. You can also save drafts and use templates.",
"calendar_view_title": "Your calendar",
"calendar_view_desc": "Here's your calendar with sample events. You can switch between day, week, month, and agenda views using the toolbar.",
"create_event_title": "Create an event",
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
"event_modal_title": "Event details",
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done — or close it and move on.",
"contacts_list_title": "Your contacts",
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
"settings_tabs_title": "Settings menu",
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
"files_title": "File storage",
"files_desc": "Your file browser lets you upload, organize, and share files — like a personal cloud drive built into your mail.",
"demo_banner_title": "Demo controls",
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
"quota_title": "Storage usage",
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Sitio web",
"imprint": "Aviso legal",
"privacy_policy": "Política de privacidad",
"try_demo": "Probar demo",
"demo_description": "Explora con datos de ejemplo — sin cuenta necesaria",
"demo_launching": "Iniciando demo...",
"demo_login_button": "Iniciar demo",
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
"demo_no_signup": "Sin registro — explora libremente con datos de ejemplo",
"oauth_completing": "Completando inicio de sesión...",
"oauth_error": {
"title": "Error de autenticación",
@@ -104,6 +110,9 @@
},
"clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa",
"demo_banner": "Modo demo",
"demo_reset": "Restablecer",
"demo_tour": "Tour",
"tags": "Etiquetas",
"mail": "Correo",
"nav_label": "Navegación",
@@ -847,6 +856,9 @@
"account": {
"title": "Cuenta",
"description": "Vea la información de su cuenta",
"name_label": "Nombre para mostrar",
"account_type_label": "Tipo de cuenta",
"demo_account": "Cuenta de demostración",
"email": {
"label": "Dirección de Correo",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Contactos",
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
"no_category": "Sin categoría",
"category_added": "Contacto añadido a {name}",
"category_added_plural": "{count} contactos añadidos a {name}",
"empty_state": "No hay contactos",
"empty_state_title": "Sin contactos",
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
@@ -1613,7 +1628,8 @@
"level_low": "Bajo",
"categories": "Categorías",
"categories_placeholder": "p. ej., Familia, Amigos, Colegas",
"categories_hint": "Separar con comas",
"categories_hint": "Escriba para buscar o añadir categorías",
"category_add": "Añadir",
"note": "Notas",
"note_placeholder": "Agregar una nota...",
"gender": "Género",
@@ -2001,7 +2017,17 @@
"tip_settings": "Personaliza tu experiencia en Ajustes",
"got_it": "Entendido",
"settings": "Ajustes",
"dismiss": "Cerrar"
"dismiss": "Cerrar",
"start_tour": "Iniciar tour"
},
"demo_welcome": {
"title": "Bienvenido a Bulwark Mail",
"description": "Explora un cliente de correo web completo — directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
"feature_email": "Leer y redactar correos",
"feature_organize": "Etiquetas, estrellas y carpetas",
"feature_shortcuts": "Atajos de teclado",
"feature_privacy": "Demo 100 % privada",
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
},
"files": {
"title": "Archivos",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
"incorrect_passphrase": "Contraseña incorrecta"
},
"tour": {
"step_counter": "Paso {current} de {total}",
"skip": "Saltar tour",
"back": "Atrás",
"next": "Siguiente",
"finish": "Finalizar",
"take_a_tour": "Haz un recorrido por la interfaz",
"restart_title": "Tour introductorio",
"restart_desc": "Repetir el recorrido guiado por la interfaz",
"restart_button": "Reiniciar tour",
"sidebar_title": "Tus buzones",
"sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.",
"compose_title": "Redactar un correo",
"compose_desc": "Haz clic aquí para escribir un nuevo correo. Puedes añadir destinatarios, archivos adjuntos y formato de texto enriquecido.",
"search_title": "Buscar en tu correo",
"search_desc": "Busca por remitente, asunto o contenido. Haz clic en el icono de filtro para opciones avanzadas como rango de fechas, adjuntos y mensajes destacados.",
"email_list_title": "Tu lista de correos",
"email_list_desc": "Los correos aparecen aquí. Haz clic en uno para leerlo a la derecha. Usa la casilla para seleccionar varios y moverlos, eliminarlos o etiquetarlos.",
"email_viewer_title": "Panel de lectura",
"email_viewer_desc": "El correo seleccionado se abre aquí. Responde, reenvía, archiva o elimina con los botones de la barra. También puedes destacar correos o añadir etiquetas de color.",
"keywords_title": "Etiquetas de color",
"keywords_desc": "Organiza tu correo con etiquetas de colores. Arrastra un correo sobre una etiqueta o haz clic derecho para asignarlas.",
"calendar_title": "Calendario",
"calendar_desc": "Cambia al calendario para gestionar tus eventos. Crea eventos, configura recordatorios y elige diferentes vistas.",
"contacts_title": "Contactos",
"contacts_desc": "Tu libreta de direcciones está aquí. Importa contactos, crea grupos y consulta los detalles.",
"settings_title": "Ajustes",
"settings_desc": "Personaliza todo: tema, densidad, firmas, filtros, atajos de teclado, valores predeterminados del calendario y más.",
"shortcuts_title": "Atajos de teclado",
"shortcuts_desc": "Para usuarios avanzados. Pulsa ? en cualquier momento para ver todos los atajos disponibles.",
"calendar_view_title": "Tu calendario",
"calendar_view_desc": "Aquí está tu calendario con eventos de ejemplo. Cambia entre vistas de día, semana, mes y agenda.",
"contacts_list_title": "Tus contactos",
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
"files_title": "Almacenamiento de archivos",
"settings_tabs_title": "Menú de ajustes",
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
"demo_banner_title": "Controles de demo",
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
"quota_title": "Uso de almacenamiento",
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Site web",
"imprint": "Mentions légales",
"privacy_policy": "Politique de confidentialité",
"try_demo": "Essayer la démo",
"demo_description": "Explorez avec des données d'exemple — aucun compte nécessaire",
"demo_launching": "Lancement de la démo...",
"demo_login_button": "Lancer la démo",
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
"demo_no_signup": "Aucune inscription — explorez librement avec des données d'exemple",
"oauth_completing": "Connexion en cours...",
"oauth_error": {
"title": "Échec de l'authentification",
@@ -104,6 +110,9 @@
},
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé",
"demo_banner": "Mode démo",
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
"tags": "Étiquettes",
"mail": "Messagerie",
"nav_label": "Navigation",
@@ -847,6 +856,9 @@
"account": {
"title": "Compte",
"description": "Consultez les informations de votre compte",
"name_label": "Nom d'affichage",
"account_type_label": "Type de compte",
"demo_account": "Compte de démonstration",
"email": {
"label": "Adresse email",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Contacts",
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
"no_category": "Sans catégorie",
"category_added": "Contact ajouté à {name}",
"category_added_plural": "{count} contacts ajoutés à {name}",
"empty_state": "Aucun contact",
"empty_state_title": "Aucun contact",
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
@@ -1613,7 +1628,8 @@
"level_low": "Faible",
"categories": "Catégories",
"categories_placeholder": "p. ex., Famille, Amis, Collègues",
"categories_hint": "Séparer par des virgules",
"categories_hint": "Tapez pour rechercher ou ajouter",
"category_add": "Ajouter",
"note": "Notes",
"note_placeholder": "Ajouter une note...",
"gender": "Genre",
@@ -2001,7 +2017,17 @@
"tip_settings": "Personnalisez votre experience dans les Parametres",
"got_it": "Compris",
"settings": "Paramètres",
"dismiss": "Fermer"
"dismiss": "Fermer",
"start_tour": "Démarrer la visite"
},
"demo_welcome": {
"title": "Bienvenue sur Bulwark Mail",
"description": "Explorez un client webmail complet — directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
"feature_email": "Lire et rédiger des e-mails",
"feature_organize": "Tags, étoiles et dossiers",
"feature_shortcuts": "Raccourcis clavier",
"feature_privacy": "Démo 100 % privée",
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
},
"files": {
"title": "Fichiers",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
"incorrect_passphrase": "Phrase secrète incorrecte"
},
"tour": {
"step_counter": "Étape {current} sur {total}",
"skip": "Passer la visite",
"back": "Retour",
"next": "Suivant",
"finish": "Terminer",
"take_a_tour": "Faire une visite de l'interface",
"restart_title": "Visite d'introduction",
"restart_desc": "Rejouer la visite guidée de l'interface",
"restart_button": "Relancer la visite",
"sidebar_title": "Vos boîtes mail",
"sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.",
"compose_title": "Rédiger un email",
"compose_desc": "Cliquez ici pour écrire un nouvel email. Ajoutez des destinataires, des pièces jointes et utilisez la mise en forme enrichie.",
"search_title": "Rechercher vos emails",
"search_desc": "Recherchez par expéditeur, objet ou contenu. Cliquez sur l'icône de filtre pour les options avancées comme la plage de dates, les pièces jointes et les messages suivis.",
"email_list_title": "Votre liste d'emails",
"email_list_desc": "Les emails apparaissent ici. Cliquez sur un email pour le lire à droite. Utilisez la case à cocher pour en sélectionner plusieurs, puis déplacez, supprimez ou étiquetez-les.",
"email_viewer_title": "Panneau de lecture",
"email_viewer_desc": "L'email sélectionné s'ouvre ici. Répondez, transférez, archivez ou supprimez avec les boutons de la barre d'outils. Vous pouvez aussi marquer les emails ou ajouter des étiquettes de couleur.",
"keywords_title": "Étiquettes de couleur",
"keywords_desc": "Organisez vos emails avec des étiquettes colorées. Glissez un email sur une étiquette pour le marquer, ou faites un clic droit pour assigner des étiquettes.",
"calendar_title": "Calendrier",
"calendar_desc": "Accédez au calendrier pour gérer vos événements. Créez des événements, définissez des rappels et consultez les vues jour, semaine ou mois.",
"contacts_title": "Contacts",
"contacts_desc": "Votre carnet d'adresses se trouve ici. Importez des contacts, créez des groupes et cliquez sur un contact pour voir ses détails.",
"settings_title": "Paramètres",
"settings_desc": "Personnalisez tout : thème, densité, signatures, filtres, raccourcis clavier, paramètres du calendrier et plus encore.",
"shortcuts_title": "Raccourcis clavier",
"shortcuts_desc": "Les utilisateurs avancés adorent ça. Appuyez sur ? à tout moment pour voir tous les raccourcis disponibles. Naviguez, rédigez et gérez vos emails sans toucher à la souris.",
"calendar_view_title": "Votre calendrier",
"calendar_view_desc": "Voici votre calendrier avec des événements exemples. Basculez entre les vues jour, semaine, mois et agenda avec la barre d'outils.",
"contacts_list_title": "Vos contacts",
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
"files_title": "Stockage de fichiers",
"settings_tabs_title": "Menu des paramètres",
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers — comme un cloud personnel intégré à votre messagerie.",
"demo_banner_title": "Contrôles de démo",
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
"quota_title": "Utilisation du stockage",
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Sito web",
"imprint": "Note legali",
"privacy_policy": "Informativa sulla privacy",
"try_demo": "Prova la demo",
"demo_description": "Esplora con dati di esempio — nessun account necessario",
"demo_launching": "Avvio demo...",
"demo_login_button": "Avvia demo",
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
"demo_no_signup": "Nessuna registrazione — esplora liberamente con dati di esempio",
"oauth_completing": "Completamento dell'accesso...",
"oauth_error": {
"title": "Autenticazione non riuscita",
@@ -104,6 +110,9 @@
},
"clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo",
"demo_banner": "Modalità demo",
"demo_reset": "Reimposta",
"demo_tour": "Tour",
"tags": "Etichette",
"mail": "Posta",
"nav_label": "Navigazione",
@@ -847,6 +856,9 @@
"account": {
"title": "Account",
"description": "Visualizza le informazioni del tuo account",
"name_label": "Nome visualizzato",
"account_type_label": "Tipo di account",
"demo_account": "Account dimostrativo",
"email": {
"label": "Indirizzo email",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Contatti",
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
"no_category": "Senza categoria",
"category_added": "Contatto aggiunto a {name}",
"category_added_plural": "{count} contatti aggiunti a {name}",
"empty_state": "Nessun contatto",
"empty_state_title": "Nessun contatto",
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
@@ -1613,7 +1628,8 @@
"level_low": "Basso",
"categories": "Categorie",
"categories_placeholder": "es., Famiglia, Amici, Colleghi",
"categories_hint": "Separare con virgole",
"categories_hint": "Digita per cercare o aggiungere",
"category_add": "Aggiungi",
"note": "Note",
"note_placeholder": "Aggiungi una nota...",
"gender": "Genere",
@@ -2001,7 +2017,17 @@
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
"got_it": "Ho capito",
"settings": "Impostazioni",
"dismiss": "Chiudi"
"dismiss": "Chiudi",
"start_tour": "Inizia il tour"
},
"demo_welcome": {
"title": "Benvenuto su Bulwark Mail",
"description": "Esplora un client webmail completo — direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
"feature_email": "Leggere e scrivere email",
"feature_organize": "Tag, stelle e cartelle",
"feature_shortcuts": "Scorciatoie da tastiera",
"feature_privacy": "Demo 100% privata",
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
},
"files": {
"title": "File",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
"incorrect_passphrase": "Passphrase errata"
},
"tour": {
"step_counter": "Passo {current} di {total}",
"skip": "Salta il tour",
"back": "Indietro",
"next": "Avanti",
"finish": "Fine",
"take_a_tour": "Fai un tour dell'interfaccia",
"restart_title": "Tour introduttivo",
"restart_desc": "Rivedi la guida dell'interfaccia",
"restart_button": "Riavvia il tour",
"sidebar_title": "Le tue caselle di posta",
"sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.",
"compose_title": "Scrivi un'email",
"compose_desc": "Clicca qui per scrivere una nuova email. Puoi aggiungere destinatari, allegati e usare la formattazione RTF.",
"search_title": "Cerca nella posta",
"search_desc": "Cerca per mittente, oggetto o contenuto. Clicca sull'icona del filtro per opzioni avanzate come intervallo di date, allegati e messaggi speciali.",
"email_list_title": "La tua lista email",
"email_list_desc": "Le email appaiono qui. Clicca su una per leggerla a destra. Usa la casella di controllo per selezionarne più di una, poi sposta, elimina o etichetta in blocco.",
"email_viewer_title": "Pannello di lettura",
"email_viewer_desc": "L'email selezionata si apre qui. Rispondi, inoltra, archivia o elimina con i pulsanti della barra degli strumenti. Puoi anche contrassegnare le email o aggiungere etichette colorate.",
"keywords_title": "Etichette colorate",
"keywords_desc": "Organizza le tue email con etichette colorate. Trascina un'email su un'etichetta per contrassegnarla, o fai clic destro per assegnare etichette.",
"calendar_title": "Calendario",
"calendar_desc": "Passa al calendario per gestire i tuoi eventi. Crea eventi, imposta promemoria e visualizza le viste giorno, settimana o mese.",
"contacts_title": "Contatti",
"contacts_desc": "La tua rubrica si trova qui. Importa contatti, crea gruppi e clicca su un contatto per vedere i suoi dettagli completi.",
"settings_title": "Impostazioni",
"settings_desc": "Personalizza tutto: tema, densità, firme, filtri, scorciatoie da tastiera, impostazioni del calendario e altro ancora.",
"shortcuts_title": "Scorciatoie da tastiera",
"shortcuts_desc": "Gli utenti esperti adorano questo. Premi ? in qualsiasi momento per vedere tutte le scorciatoie disponibili. Puoi navigare, comporre e gestire le email senza toccare il mouse.",
"calendar_view_title": "Il tuo calendario",
"calendar_view_desc": "Ecco il tuo calendario con eventi di esempio. Puoi passare tra le viste giorno, settimana, mese e agenda usando la barra degli strumenti.",
"contacts_list_title": "I tuoi contatti",
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
"files_title": "Archiviazione file",
"settings_tabs_title": "Menu impostazioni",
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file — come un cloud personale integrato nella tua posta.",
"demo_banner_title": "Controlli demo",
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
"quota_title": "Utilizzo dello spazio",
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "ウェブサイト",
"imprint": "サイト運営者情報",
"privacy_policy": "プライバシーポリシー",
"try_demo": "デモを試す",
"demo_description": "サンプルデータで探索 — アカウント不要",
"demo_launching": "デモを起動中...",
"demo_login_button": "デモを開始",
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
"oauth_completing": "サインイン処理中...",
"oauth_error": {
"title": "認証に失敗しました",
@@ -104,6 +110,9 @@
},
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です",
"demo_banner": "デモモード",
"demo_reset": "リセット",
"demo_tour": "ツアー",
"tags": "タグ",
"mail": "メール",
"nav_label": "ナビゲーション",
@@ -847,6 +856,9 @@
"account": {
"title": "アカウント",
"description": "アカウント情報を表示",
"name_label": "表示名",
"account_type_label": "アカウントタイプ",
"demo_account": "デモアカウント",
"email": {
"label": "メールアドレス",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "連絡先",
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
"no_category": "カテゴリなし",
"category_added": "{name} に連絡先を追加しました",
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
"empty_state": "連絡先がありません",
"empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
@@ -1613,7 +1628,8 @@
"level_low": "低",
"categories": "カテゴリー",
"categories_placeholder": "例:家族、友人、同僚",
"categories_hint": "カンマで区切ってください",
"categories_hint": "検索または追加するには入力",
"category_add": "追加",
"note": "メモ",
"note_placeholder": "メモを追加...",
"gender": "性別",
@@ -2001,7 +2017,17 @@
"tip_settings": "設定でカスタマイズできます",
"got_it": "了解",
"settings": "設定",
"dismiss": "閉じる"
"dismiss": "閉じる",
"start_tour": "ツアーを開始"
},
"demo_welcome": {
"title": "Bulwark Mail へようこそ",
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
"feature_email": "メールの読み書き",
"feature_organize": "タグ・スター・フォルダ",
"feature_shortcuts": "キーボードショートカット",
"feature_privacy": "100% プライベートなデモ",
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
},
"files": {
"title": "ファイル",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
"incorrect_passphrase": "パスフレーズが正しくありません"
},
"tour": {
"step_counter": "ステップ {current} / {total}",
"skip": "ツアーをスキップ",
"back": "戻る",
"next": "次へ",
"finish": "完了",
"take_a_tour": "インターフェースのツアーを見る",
"restart_title": "紹介ツアー",
"restart_desc": "インターフェースのガイドツアーを再生する",
"restart_button": "ツアーを再開",
"sidebar_title": "メールボックス",
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
"compose_title": "メールを作成",
"compose_desc": "ここをクリックして新しいメールを作成します。宛先、添付ファイルの追加やリッチテキスト書式が使えます。",
"search_title": "メールを検索",
"search_desc": "送信者、件名、内容で検索できます。フィルターアイコンをクリックすると、日付範囲、添付ファイル、スター付きメッセージなどの詳細オプションが使えます。",
"email_list_title": "メール一覧",
"email_list_desc": "メールがここに表示されます。クリックすると右側で読めます。チェックボックスで複数選択し、一括で移動、削除、タグ付けができます。",
"email_viewer_title": "閲覧パネル",
"email_viewer_desc": "選択したメールがここに開きます。ツールバーのボタンで返信、転送、アーカイブ、削除ができます。メールにスターやカラータグも付けられます。",
"keywords_title": "カラータグ",
"keywords_desc": "色分けされたタグでメールを整理できます。メールをタグにドラッグしてラベル付けするか、右クリックでタグを割り当てられます。",
"calendar_title": "カレンダー",
"calendar_desc": "カレンダーに切り替えてイベントを管理できます。イベントの作成、リマインダーの設定、日・週・月表示の切り替えができます。",
"contacts_title": "連絡先",
"contacts_desc": "アドレス帳がここにあります。連絡先のインポート、グループの作成、連絡先をクリックして詳細を確認できます。",
"settings_title": "設定",
"settings_desc": "すべてをカスタマイズできます:テーマ、表示密度、署名、フィルター、キーボードショートカット、カレンダー設定など。",
"shortcuts_title": "キーボードショートカット",
"shortcuts_desc": "パワーユーザー向けの機能です。いつでも ? を押すと利用可能なすべてのショートカットが表示されます。マウスを使わずにナビゲーション、作成、メール管理ができます。",
"calendar_view_title": "カレンダー表示",
"calendar_view_desc": "サンプルイベント付きのカレンダーです。ツールバーで日、週、月、アジェンダビューを切り替えられます。",
"contacts_list_title": "連絡先一覧",
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
"files_title": "ファイルストレージ",
"settings_tabs_title": "設定メニュー",
"settings_tabs_desc": "すべての設定カテゴリがここにあります。外観のカスタマイズ、IDの管理、メールフィルターの設定、カレンダーの構成など、多数の項目を調整できます。",
"files_desc": "ファイルブラウザでファイルのアップロード、整理、共有ができます。メールに統合されたパーソナルクラウドのようなものです。",
"demo_banner_title": "デモコントロール",
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
"quota_title": "ストレージ使用量",
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Website",
"imprint": "Colofon",
"privacy_policy": "Privacybeleid",
"try_demo": "Demo proberen",
"demo_description": "Verken met voorbeeldgegevens — geen account nodig",
"demo_launching": "Demo starten...",
"demo_login_button": "Demo starten",
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
"demo_no_signup": "Geen registratie nodig — verken vrij met voorbeeldgegevens",
"oauth_completing": "Aanmelding voltooien...",
"oauth_error": {
"title": "Authenticatie mislukt",
@@ -104,6 +110,9 @@
},
"clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief",
"demo_banner": "Demomodus",
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
"tags": "Labels",
"mail": "E-mail",
"nav_label": "Navigatie",
@@ -847,6 +856,9 @@
"account": {
"title": "Account",
"description": "Bekijk je accountinformatie",
"name_label": "Weergavenaam",
"account_type_label": "Accounttype",
"demo_account": "Demoaccount",
"email": {
"label": "E-mailadres",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Contacten",
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
"no_category": "Geen categorie",
"category_added": "Contact toegevoegd aan {name}",
"category_added_plural": "{count} contacten toegevoegd aan {name}",
"empty_state": "Geen contacten",
"empty_state_title": "Geen contacten",
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
@@ -1613,7 +1628,8 @@
"level_low": "Laag",
"categories": "Categorieën",
"categories_placeholder": "bijv. Familie, Vrienden, Collega's",
"categories_hint": "Scheiden met komma's",
"categories_hint": "Typ om te zoeken of toe te voegen",
"category_add": "Toevoegen",
"note": "Notities",
"note_placeholder": "Notitie toevoegen...",
"gender": "Geslacht",
@@ -2001,7 +2017,17 @@
"tip_settings": "Pas uw ervaring aan in Instellingen",
"got_it": "Begrepen",
"settings": "Instellingen",
"dismiss": "Sluiten"
"dismiss": "Sluiten",
"start_tour": "Tour starten"
},
"demo_welcome": {
"title": "Welkom bij Bulwark Mail",
"description": "Ontdek een volwaardige webmail-client — rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
"feature_email": "E-mails lezen en schrijven",
"feature_organize": "Tags, sterren en mappen",
"feature_shortcuts": "Sneltoetsen",
"feature_privacy": "100% privédemo",
"hint": "Klik links op een e-mail om te beginnen, of start de tour."
},
"files": {
"title": "Bestanden",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
"incorrect_passphrase": "Onjuiste wachtwoordzin"
},
"tour": {
"step_counter": "Stap {current} van {total}",
"skip": "Tour overslaan",
"back": "Terug",
"next": "Volgende",
"finish": "Voltooien",
"take_a_tour": "Maak een rondleiding door de interface",
"restart_title": "Introductietour",
"restart_desc": "Bekijk de rondleiding door de interface opnieuw",
"restart_button": "Tour herstarten",
"sidebar_title": "Uw mailboxen",
"sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.",
"compose_title": "E-mail schrijven",
"compose_desc": "Klik hier om een nieuwe e-mail te schrijven. U kunt ontvangers, bijlagen toevoegen en rijke tekstopmaak gebruiken.",
"search_title": "Zoek in uw mail",
"search_desc": "Zoek op afzender, onderwerp of inhoud. Klik op het filtericoon voor geavanceerde opties zoals datumbereik, bijlagen en favoriete berichten.",
"email_list_title": "Uw e-maillijst",
"email_list_desc": "E-mails verschijnen hier. Klik op een e-mail om deze rechts te lezen. Gebruik het selectievakje om meerdere te selecteren, verplaats, verwijder of label ze vervolgens.",
"email_viewer_title": "Leesvenster",
"email_viewer_desc": "De geselecteerde e-mail opent hier. Beantwoord, doorstuur, archiveer of verwijder met de werkbalkknopen. U kunt ook e-mails markeren of kleurlabels toevoegen.",
"keywords_title": "Kleurlabels",
"keywords_desc": "Organiseer uw e-mail met kleurgecodeerde labels. Sleep een e-mail naar een label om het te markeren, of klik met de rechtermuisknop om labels toe te wijzen.",
"calendar_title": "Agenda",
"calendar_desc": "Schakel naar de agenda om uw evenementen te beheren. Maak evenementen aan, stel herinneringen in en bekijk dag-, week- of maandweergaven.",
"contacts_title": "Contacten",
"contacts_desc": "Uw adresboek bevindt zich hier. Importeer contacten, maak groepen aan en klik op een contact om de volledige details te bekijken.",
"settings_title": "Instellingen",
"settings_desc": "Pas alles aan: thema, dichtheid, handtekeningen, filters, sneltoetsen, agendainstellingen en meer.",
"shortcuts_title": "Sneltoetsen",
"shortcuts_desc": "Ervaren gebruikers zijn hier dol op. Druk op ? om alle beschikbare sneltoetsen te bekijken. Navigeer, schrijf en beheer e-mails zonder de muis aan te raken.",
"calendar_view_title": "Uw agenda",
"calendar_view_desc": "Hier is uw agenda met voorbeeldevenementen. Schakel tussen dag-, week-, maand- en agendaweergave via de werkbalk.",
"contacts_list_title": "Uw contacten",
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
"files_title": "Bestandsopslag",
"settings_tabs_title": "Instellingenmenu",
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen — als een persoonlijke cloud geïntegreerd in uw mail.",
"demo_banner_title": "Demo-bediening",
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
"quota_title": "Opslaggebruik",
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
}
}
+71 -2
View File
@@ -40,6 +40,12 @@
"website": "Site",
"imprint": "Informações legais",
"privacy_policy": "Política de privacidade",
"try_demo": "Experimentar demo",
"demo_description": "Explore com dados de exemplo — sem conta necessária",
"demo_launching": "Iniciando demo...",
"demo_login_button": "Iniciar demo",
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
"demo_no_signup": "Sem registo — explore livremente com dados de exemplo",
"oauth_completing": "Concluindo login...",
"oauth_error": {
"title": "Falha na autenticação",
@@ -104,6 +110,9 @@
},
"clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa",
"demo_banner": "Modo de demonstração",
"demo_reset": "Repor",
"demo_tour": "Tour",
"tags": "Etiquetas",
"mail": "E-mail",
"nav_label": "Navegação",
@@ -847,6 +856,9 @@
"account": {
"title": "Conta",
"description": "Visualize as informações da sua conta",
"name_label": "Nome de exibição",
"account_type_label": "Tipo de conta",
"demo_account": "Conta de demonstração",
"email": {
"label": "Endereço de E-mail",
"value": "{email}"
@@ -1470,6 +1482,9 @@
"title": "Contatos",
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
"no_category": "Sem categoria",
"category_added": "Contato adicionado a {name}",
"category_added_plural": "{count} contatos adicionados a {name}",
"empty_state": "Nenhum contato",
"empty_state_title": "Sem contatos",
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
@@ -1613,7 +1628,8 @@
"level_low": "Baixo",
"categories": "Categorias",
"categories_placeholder": "ex., Família, Amigos, Colegas",
"categories_hint": "Separar com vírgulas",
"categories_hint": "Digite para pesquisar ou adicionar",
"category_add": "Adicionar",
"note": "Notas",
"note_placeholder": "Adicionar uma nota...",
"gender": "Gênero",
@@ -2001,7 +2017,17 @@
"tip_settings": "Personalize sua experiência nas Configurações",
"got_it": "Entendi",
"settings": "Configurações",
"dismiss": "Fechar"
"dismiss": "Fechar",
"start_tour": "Iniciar tour"
},
"demo_welcome": {
"title": "Bem-vindo ao Bulwark Mail",
"description": "Explore um cliente de webmail completo — diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
"feature_email": "Ler e escrever e-mails",
"feature_organize": "Tags, estrelas e pastas",
"feature_shortcuts": "Atalhos de teclado",
"feature_privacy": "Demo 100% privada",
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
},
"files": {
"title": "Ficheiros",
@@ -2181,5 +2207,48 @@
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
"incorrect_passphrase": "Frase secreta incorreta"
},
"tour": {
"step_counter": "Passo {current} de {total}",
"skip": "Pular tour",
"back": "Voltar",
"next": "Próximo",
"finish": "Finalizar",
"take_a_tour": "Faça um tour pela interface",
"restart_title": "Tour introdutório",
"restart_desc": "Rever o tour guiado da interface",
"restart_button": "Reiniciar tour",
"sidebar_title": "Suas caixas de correio",
"sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.",
"compose_title": "Escrever um e-mail",
"compose_desc": "Clique aqui para escrever um novo e-mail. Você pode adicionar destinatários, anexos e usar formatação de texto enriquecido.",
"search_title": "Pesquisar sua caixa",
"search_desc": "Pesquise por remetente, assunto ou conteúdo. Clique no ícone de filtro para opções avançadas como intervalo de datas, anexos e mensagens com estrela.",
"email_list_title": "Sua lista de e-mails",
"email_list_desc": "Os e-mails aparecem aqui. Clique em um para lê-lo à direita. Use a caixa de seleção para selecionar vários, depois mova, exclua ou etiquete em massa.",
"email_viewer_title": "Painel de leitura",
"email_viewer_desc": "O e-mail selecionado abre aqui. Responda, encaminhe, arquive ou exclua com os botões da barra de ferramentas. Você também pode marcar e-mails com estrela ou adicionar etiquetas coloridas.",
"keywords_title": "Etiquetas coloridas",
"keywords_desc": "Organize seus e-mails com etiquetas coloridas. Arraste um e-mail para uma etiqueta para marcá-lo, ou clique com o botão direito para atribuir etiquetas.",
"calendar_title": "Calendário",
"calendar_desc": "Mude para o calendário para gerenciar seus eventos. Crie eventos, defina lembretes e visualize os layouts de dia, semana ou mês.",
"contacts_title": "Contatos",
"contacts_desc": "Seu livro de endereços fica aqui. Importe contatos, crie grupos e clique em qualquer contato para ver seus detalhes completos.",
"settings_title": "Configurações",
"settings_desc": "Personalize tudo: tema, densidade, assinaturas, filtros, atalhos de teclado, padrões do calendário e mais.",
"shortcuts_title": "Atalhos de teclado",
"shortcuts_desc": "Usuários avançados adoram isso. Pressione ? a qualquer momento para ver todos os atalhos disponíveis. Navegue, escreva e gerencie e-mails sem tocar no mouse.",
"calendar_view_title": "Seu calendário",
"calendar_view_desc": "Aqui está seu calendário com eventos de exemplo. Você pode alternar entre as visualizações de dia, semana, mês e agenda usando a barra de ferramentas.",
"contacts_list_title": "Seus contatos",
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
"files_title": "Armazenamento de ficheiros",
"settings_tabs_title": "Menu de configurações",
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros — como uma nuvem pessoal integrada no seu e-mail.",
"demo_banner_title": "Controlos de demonstração",
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
"quota_title": "Utilização do armazenamento",
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.4.5",
"version": "1.4.6",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+102 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
@@ -21,7 +22,7 @@ interface AuthState {
error: string | null;
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
client: IJMAPClient | null;
identities: Identity[];
primaryIdentity: Identity | null;
authMode: 'basic' | 'oauth';
@@ -30,9 +31,11 @@ interface AuthState {
tokenExpiresAt: number | null;
connectionLost: boolean;
activeAccountId: string | null;
isDemoMode: boolean;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
loginDemo: () => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
logout: () => Promise<void>;
logoutAll: () => void;
@@ -140,7 +143,7 @@ function markSessionExpired(): void {
saveRedirectAfterLogin();
}
function initializeFeatureStores(client: JMAPClient): void {
function initializeFeatureStores(client: IJMAPClient): void {
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
@@ -242,6 +245,7 @@ export const useAuthStore = create<AuthState>()(
tokenExpiresAt: null,
connectionLost: false,
activeAccountId: null,
isDemoMode: false,
login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
@@ -355,6 +359,68 @@ export const useAuthStore = create<AuthState>()(
}
},
loginDemo: async () => {
set({ isLoading: true, error: null });
try {
// Clear all store data before re-initializing with fresh demo data
clearAllStores();
const { DemoJMAPClient } = await import('@/lib/demo/demo-client');
const client = new DemoJMAPClient();
await client.connect();
const username = client.getUsername();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
// Register a demo account entry so the account-switcher shows
// proper avatar/name instead of a "?" placeholder.
const accountStore = useAccountStore.getState();
const demoAccountId = accountStore.addAccount({
label: primaryIdentity?.name || 'Demo User',
serverUrl: 'https://demo.example.com',
username,
authMode: 'basic',
rememberMe: false,
displayName: primaryIdentity?.name || 'Demo User',
email: primaryIdentity?.email || username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
isDefault: true,
});
accountStore.setActiveAccount(demoAccountId);
set({
isAuthenticated: true,
isLoading: false,
serverUrl: 'demo.example.com',
username,
client,
identities,
primaryIdentity,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: demoAccountId,
isDemoMode: true,
});
return true;
} catch (error) {
debug.error('Demo login error:', error);
set({
isLoading: false,
error: 'generic',
isAuthenticated: false,
client: null,
});
return false;
}
},
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
set({ isLoading: true, error: null });
@@ -512,12 +578,39 @@ export const useAuthStore = create<AuthState>()(
logout: async () => {
const state = get();
const wasDemoMode = state.isDemoMode;
const wasOAuth = state.authMode === 'oauth';
const accountId = state.activeAccountId;
const accountStore = useAccountStore.getState();
const account = accountId ? accountStore.getAccountById(accountId) : null;
const slot = account?.cookieSlot ?? 0;
// Demo mode: simple cleanup, no network calls
if (wasDemoMode) {
set({ client: null });
state.client?.disconnect();
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: null,
isDemoMode: false,
});
localStorage.removeItem('auth-storage');
clearAllStores();
redirectToLogin();
return;
}
clearRefreshTimer(accountId ?? undefined);
// Null out the client BEFORE disconnecting so the page doesn't fire
@@ -902,6 +995,13 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState();
const accounts = accountStore.accounts;
// If the only account is the demo account, re-initialize demo mode
// instead of trying to restore a server session (which doesn't exist).
if (accounts.length === 1 && accounts[0].serverUrl === 'https://demo.example.com') {
await get().loginDemo();
return;
}
// Multi-account restoration: restore all registered accounts
if (accounts.length > 0) {
// Null out client so the page doesn't fire data-loading effects
+16 -16
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
@@ -37,17 +37,17 @@ interface CalendarStore {
dateRange: { start: string; end: string } | null;
setSupported: (supported: boolean) => void;
fetchCalendars: (client: JMAPClient) => Promise<void>;
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>;
fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void;
@@ -56,10 +56,10 @@ interface CalendarStore {
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>;
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
isSubscriptionCalendar: (calendarId: string) => boolean;
}
+15 -15
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) {
@@ -47,11 +47,11 @@ interface ContactStore {
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
fetchContacts: (client: JMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
addLocalContact: (contact: ContactCard) => void;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
@@ -68,21 +68,21 @@ interface ContactStore {
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
getGroupMembers: (groupId: string) => ContactCard[];
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
createGroup: (client: IJMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: IJMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: IJMAPClient | null, groupId: string) => Promise<void>;
toggleContactSelection: (id: string) => void;
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
selectAllContacts: (ids: string[]) => void;
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
export const useContactStore = create<ContactStore>()(
+31 -31
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
@@ -48,7 +48,7 @@ interface EmailStore {
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
selectKeyword: (keyword: string | null) => void;
fetchTagCounts: (client: JMAPClient) => Promise<void>;
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
@@ -56,54 +56,54 @@ interface EmailStore {
clearSelection: () => void;
// JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>;
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
advancedSearch: (client: JMAPClient) => Promise<void>;
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
// Batch operations
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: IJMAPClient) => Promise<void>;
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mailbox management
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Mock data for demo
loadMockData: () => void;
@@ -1033,7 +1033,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
// Find inbox (batch operations don't preserve original mailboxes)
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
export interface FileResource {
@@ -47,7 +47,7 @@ interface FileState {
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
client: JMAPClient | null;
client: IJMAPClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
@@ -55,7 +55,7 @@ interface FileState {
lastAction: UndoAction | null;
// Actions
initClient: (client: JMAPClient) => void;
initClient: (client: IJMAPClient) => void;
checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
@@ -178,7 +178,7 @@ export const useFileStore = create<FileState>((set, get) => ({
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: JMAPClient) => {
initClient: (client: IJMAPClient) => {
set({ client });
},
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { parseScript } from '@/lib/sieve/parser';
import { generateScript } from '@/lib/sieve/generator';
@@ -17,9 +17,9 @@ interface FilterStore {
rawScript: string;
setSupported: (supported: boolean) => void;
fetchFilters: (client: JMAPClient) => Promise<void>;
saveFilters: (client: JMAPClient) => Promise<void>;
validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
fetchFilters: (client: IJMAPClient) => Promise<void>;
saveFilters: (client: IJMAPClient) => Promise<void>;
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
deleteRule: (ruleId: string) => void;
+31
View File
@@ -33,6 +33,17 @@ export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
{ id: 'delete', labelKey: 'delete' },
{ id: 'star', labelKey: 'star' },
{ id: 'markRead', labelKey: 'mark_read' },
{ id: 'archive', labelKey: 'archive' },
{ id: 'tag', labelKey: 'tag' },
{ id: 'spam', labelKey: 'spam' },
];
export interface KeywordDefinition {
id: string; // Used as JMAP keyword suffix: $label:<id>
label: string; // Display name
@@ -45,6 +56,7 @@ export interface SidebarApp {
url: string;
icon: string; // Lucide icon name (e.g. 'Globe', 'Rss')
openMode: 'tab' | 'inline'; // Open in new tab or embed inline
showOnMobile: boolean;
}
// Available color palette for keywords
@@ -96,6 +108,7 @@ interface SettingsState {
attachmentPosition: AttachmentPosition;
emailAlwaysLightMode: boolean; // Always render email content in light mode
archiveMode: ArchiveMode; // How to organize archived emails: single folder, by year, or by year+month
hoverActions: HoverAction[]; // Quick actions shown on hover in mail list
// Composer
autoSaveDraftInterval: number; // milliseconds
@@ -106,6 +119,9 @@ interface SettingsState {
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
// Calendar
showTimeInMonthView: boolean;
// Calendar Notifications
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
@@ -153,6 +169,7 @@ interface SettingsState {
// Keywords
addKeyword: (keyword: KeywordDefinition) => void;
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void;
renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => void;
removeKeyword: (id: string) => void;
reorderKeywords: (keywords: KeywordDefinition[]) => void;
getKeywordById: (id: string) => KeywordDefinition | undefined;
@@ -191,6 +208,7 @@ const DEFAULT_SETTINGS = {
attachmentPosition: 'beside-sender' as AttachmentPosition,
emailAlwaysLightMode: false,
archiveMode: 'single' as ArchiveMode,
hoverActions: ['delete', 'star', 'markRead', 'archive'] as HoverAction[],
// Composer
autoSaveDraftInterval: 60000, // 1 minute
@@ -201,6 +219,9 @@ const DEFAULT_SETTINGS = {
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
// Calendar
showTimeInMonthView: false,
// Calendar Notifications
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
@@ -276,6 +297,7 @@ export const useSettingsStore = create<SettingsState>()(
mailAttachmentAction: state.mailAttachmentAction,
attachmentPosition: state.attachmentPosition,
archiveMode: state.archiveMode,
hoverActions: state.hoverActions,
trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
@@ -284,6 +306,7 @@ export const useSettingsStore = create<SettingsState>()(
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
showTimeInMonthView: state.showTimeInMonthView,
toolbarPosition: state.toolbarPosition,
senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons,
@@ -381,6 +404,14 @@ export const useSettingsStore = create<SettingsState>()(
});
},
renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => {
set({
emailKeywords: get().emailKeywords.map(k =>
k.id === oldId ? newKeyword : k
),
});
},
removeKeyword: (id: string) => {
set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) });
},
+3 -3
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface VacationStore {
isEnabled: boolean;
@@ -13,8 +13,8 @@ interface VacationStore {
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
updateVacationResponse: (client: JMAPClient, updates: {
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
updateVacationResponse: (client: IJMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
toDate?: string | null;