Compare commits

..
38 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
Linus Rath 705b942800 Merge dev into main - version 1.4.5 2026-03-20 18:10:08 +01:00
Linus Rath d68b81e6b8 chore: bump version to 1.4.5 2026-03-20 18:10:00 +01:00
Linus Rath 616e4d018d fix: expand recurring events in CalendarEvent/query (closes #65)
Add expandRecurrences: true to CalendarEvent/query when a date range
filter is provided, so the JMAP server returns individual occurrences
of recurring events instead of only the master event.
2026-03-20 17:53:30 +01:00
Linus Rath 68e141b787 feat: add mobile visibility toggle for sidebar apps and update related components 2026-03-20 17:44:26 +01:00
Linus Rath 1e6f5e2c8c fix: correct JSX syntax in CalendarToolbar component 2026-03-20 17:25:01 +01:00
Linus Rath 9495b34430 feat: add prev/next navigation buttons and date label to desktop calendar toolbar
Closes #59
2026-03-20 17:23:22 +01:00
Linus Rath 65fc489b9c feat: add pending event preview functionality to calendar views and event modal 2026-03-20 17:21:44 +01:00
Linus Rath bd686c092c fix: validate event start field when fetching calendar events 2026-03-20 17:11:36 +01:00
Linus Rath 6cff98ddb8 feat: implement pagination for fetching contacts and add maxObjectsInGet capability 2026-03-20 16:47:27 +01:00
Linus Rath dcc35335f5 feat: add setting to show event start time in month view 2026-03-20 16:42:07 +01:00
Linus Rath 8a54ae2456 feat: add NotFound component to handle 404 errors and redirect unauthenticated users 2026-03-20 16:31:57 +01:00
Linus Rath e26654a005 fix: enhance account switching logic and clear stores on account change 2026-03-20 16:29:32 +01:00
Linus Rath c1c06c68bb fix: improve draft handling in email composer and enhance session cookie verification logic 2026-03-20 15:45:25 +01:00
Linus Rath 74cf642182 Merge branch 'dev' 2026-03-20 00:16:41 +01:00
Linus Rath c5b1731a63 feat: add attachment position setting in email settings
- Introduced a new setting for attachment position in email settings, allowing users to choose between displaying attachments beside the sender or below the header.
- Updated the settings store to include the new attachment position type and default value.
- Added translations for the new setting in multiple languages (de, en, es, fr, it, ja, nl, pt).
2026-03-19 21:14:18 +01:00
Linus Rath 40cf164df3 fix: update calendar agenda view to auto-scroll to today's events and ensure today's date is included in the groups 2026-03-19 19:25:34 +01:00
Linus Rath ff56245db8 fix: improve account restoration logic and handle stale accounts in auth store 2026-03-19 19:21:08 +01:00
Linus Rath 9b4de4d152 fix: remove claude directory from .dockerignore and .gitignore 2026-03-19 19:06:31 +01:00
Linus Rath 0c9e60db8b fix: update flatted to 3.4.2 2026-03-19 19:01:40 +01:00
Linus Rath def8ee89fa Merge branch 'dev' 2026-03-19 18:57:57 +01:00
Linus Rath e7e07a38d7 fix: use native ARM runners instead of QEMU for Docker builds 2026-03-19 18:57:17 +01:00
Linus Rath a009e5ae32 fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe 2026-03-19 18:50:59 +01:00
86 changed files with 6428 additions and 583 deletions
-2
View File
@@ -5,9 +5,7 @@ node_modules
.env* .env*
!.env.example !.env.example
!.env.dev.example !.env.dev.example
.claude/
scripts/ scripts/
TODO.md TODO.md
CLAUDE.md
*.md *.md
!README.md !README.md
+79 -17
View File
@@ -20,9 +20,20 @@ on:
tags: ["v*.*.*"] tags: ["v*.*.*"]
workflow_dispatch: workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs: jobs:
build-and-push: build:
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -31,9 +42,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -48,21 +56,75 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: ${{ env.IMAGE_NAME }}
ghcr.io/${{ github.repository }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=sha,prefix= type=sha,prefix=
- name: Build and push - name: Create manifest list and push
uses: docker/build-push-action@v6 working-directory: /tmp/digests
with: run: |
context: . docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
platforms: linux/amd64,linux/arm64 $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
push: true
tags: ${{ steps.meta.outputs.tags }} - name: Inspect image
labels: ${{ steps.meta.outputs.labels }} run: |
cache-from: type=gha docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
cache-to: type=gha,mode=max
-3
View File
@@ -42,9 +42,6 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# claude code
.claude/
# settings sync data # settings sync data
/data/ /data/
+42
View File
@@ -1,5 +1,47 @@
# Changelog # 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
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
- **Calendar**: Add pending event preview functionality to calendar views and event modal
- **Calendar**: Add setting to show event start time in month view
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
- **Email**: Add attachment position setting in email settings
- **Layout**: Add mobile visibility toggle for sidebar apps
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
### Fixes
- **Auth**: Enhance account switching logic and clear stores on account change
- **Auth**: Improve account restoration logic and handle stale accounts
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
- **Calendar**: Validate event start field when fetching calendar events
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
- **Calendar**: Correct JSX syntax in CalendarToolbar component
- **Dependencies**: Update flatted to 3.4.2
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
## 1.4.4 (2026-03-19) ## 1.4.4 (2026-03-19)
### Features ### Features
+1 -1
View File
@@ -1 +1 @@
1.4.4 1.4.6
+23 -10
View File
@@ -7,7 +7,7 @@ import { Plus } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO, startOfDay, format, parseISO,
} from "date-fns"; } from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { isCalendarViewMode } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store";
@@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
@@ -82,6 +82,7 @@ export default function CalendarPage() {
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null); const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null); const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null); const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
// Sidebar resize state // Sidebar resize state
@@ -156,12 +157,16 @@ export default function CalendarPage() {
start: format(d, "yyyy-MM-dd'T'00:00:00"), start: format(d, "yyyy-MM-dd'T'00:00:00"),
end: format(d, "yyyy-MM-dd'T'23:59:59"), end: format(d, "yyyy-MM-dd'T'23:59:59"),
}; };
case "agenda": case "agenda": {
// Agenda always starts from today at the earliest
const today = startOfDay(new Date());
const agendaStart = d >= today ? d : today;
return { return {
start: format(d, "yyyy-MM-dd'T'00:00:00"), start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"),
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"), end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
}; };
} }
}
}, [selectedDate, normalizedViewMode, firstDayOfWeek]); }, [selectedDate, normalizedViewMode, firstDayOfWeek]);
useEffect(() => { useEffect(() => {
@@ -264,12 +269,13 @@ export default function CalendarPage() {
}, [closeDetail, openEditModal]); }, [closeDetail, openEditModal]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event // Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return; if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event); setDetailEvent(event);
setDetailAnchorRect(anchorRect); setDetailAnchorRect(anchorRect);
}, [showEventModal, editEvent]); }, [isMobile, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => { const handleHoverLeave = useCallback(() => {
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
@@ -611,7 +617,7 @@ export default function CalendarPage() {
const visibleEvents = useMemo(() => const visibleEvents = useMemo(() =>
events.filter((e) => { events.filter((e) => {
if (!e.calendarIds) return false; if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds); const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id)); return calIds.some((id) => selectedCalendarIds.includes(id));
}), }),
@@ -644,6 +650,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
/> />
); );
case "week": case "week":
@@ -660,6 +667,7 @@ export default function CalendarPage() {
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
/> />
); );
case "day": case "day":
@@ -674,6 +682,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
/> />
); );
case "agenda": case "agenda":
@@ -704,7 +713,7 @@ export default function CalendarPage() {
}; };
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */} {/* Left Navigation Rail */}
{!isMobile && ( {!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -767,7 +776,7 @@ export default function CalendarPage() {
)} )}
{!inlineApp && ( {!inlineApp && (
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
<CalendarToolbar <CalendarToolbar
selectedDate={selectedDate} selectedDate={selectedDate}
viewMode={normalizedViewMode} viewMode={normalizedViewMode}
@@ -786,6 +795,7 @@ export default function CalendarPage() {
<div <div
className="flex flex-1 overflow-hidden relative" className="flex flex-1 overflow-hidden relative"
data-tour="calendar-view"
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
@@ -804,7 +814,8 @@ export default function CalendarPage() {
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={false} isMobile={false}
/> />
@@ -827,6 +838,7 @@ export default function CalendarPage() {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
{isMobile && ( {isMobile && (
<div className="shrink-0">
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -834,6 +846,7 @@ export default function CalendarPage() {
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null} activeAppId={inlineApp?.id ?? null}
/> />
</div>
)} )}
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
+46 -2
View File
@@ -126,9 +126,24 @@ export default function ContactsPage() {
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; 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 // Contacts to display based on active category
const displayedContacts = useMemo(() => { const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals; 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) { if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId; const bookId = activeCategory.addressBookId;
return individuals.filter(c => { return individuals.filter(c => {
@@ -146,6 +161,7 @@ export default function ContactsPage() {
// Label for the current category // Label for the current category
const categoryLabel = useMemo(() => { const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all"); if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) { if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId); const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all"); return book?.name || t("tabs.all");
@@ -182,6 +198,31 @@ export default function ContactsPage() {
} }
}, [client, moveContactToAddressBook, t]); }, [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[]) => { const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts( return importContacts(
supportsSync && client ? client : null, supportsSync && client ? client : null,
@@ -430,7 +471,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -438,6 +479,7 @@ export default function ContactsPage() {
<ContactForm <ContactForm
contact={selectedContact} contact={selectedContact}
addressBooks={addressBooks} addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancel} onCancel={handleCancel}
/> />
@@ -544,7 +586,7 @@ export default function ContactsPage() {
}; };
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */} {/* Navigation Rail - desktop only */}
{!isMobile && ( {!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -590,6 +632,7 @@ export default function ContactsPage() {
onEditGroup={handleEditGroupFromSidebar} onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
/> />
</div> </div>
<ResizeHandle <ResizeHandle
@@ -606,6 +649,7 @@ export default function ContactsPage() {
{/* Panel 2: Contact list */} {/* Panel 2: Contact list */}
<div <div
data-tour="contacts-list"
className={cn( className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0", "border-r border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "", isMobile ? "w-full" : "",
+3
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider"; import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider"; import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -26,7 +27,9 @@ export default async function LocaleLayout({
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<ThemeProvider> <ThemeProvider>
<CalendarAlertProvider> <CalendarAlertProvider>
<TourProvider>
{children} {children}
</TourProvider>
</CalendarAlertProvider> </CalendarAlertProvider>
</ThemeProvider> </ThemeProvider>
</IntlProvider> </IntlProvider>
+191 -4
View File
@@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils"; 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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
@@ -30,9 +30,9 @@ export default function LoginPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account"; 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 { 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -54,6 +54,7 @@ export default function LoginPage() {
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null); const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false); const [oauthLoading, setOauthLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -206,7 +207,7 @@ export default function LoginPage() {
); );
} }
if (!serverUrl) { if (!serverUrl && !demoMode) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30"> <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"> <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 currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon; 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 ( 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"> <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 */} {/* Theme toggle - top right, dropdown style */}
@@ -747,6 +906,34 @@ export default function LoginPage() {
</Button> </Button>
</div> </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>
</div> </div>
+5 -4
View File
@@ -816,13 +816,13 @@ export default function Home() {
}; };
}, []); }, []);
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => { const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
if (!client) return; if (!client) return;
try { try {
const { mailAttachmentAction } = useSettingsStore.getState(); const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type }); setPreviewAttachment({ blobId, name, type });
return; return;
} }
@@ -1174,6 +1174,7 @@ export default function Home() {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -1579,8 +1580,8 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev} onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
currentUserEmail={client?.["username"]} currentUserEmail={client?.getUsername()}
currentUserName={client?.["username"]?.split("@")[0]} currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
mailboxes={mailboxes} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
+1 -1
View File
@@ -352,7 +352,7 @@ export default function SettingsPage() {
</div> </div>
{/* Tabs */} {/* 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"> <div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
<div key={group.group}> <div key={group.group}>
+1
View File
@@ -35,5 +35,6 @@ export async function GET() {
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
demoMode: process.env.DEMO_MODE === 'true',
}); });
} }
+26 -35
View File
@@ -1,10 +1,21 @@
import v8 from 'node:v8';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
// Health check thresholds const MEMORY_WARNING_THRESHOLD = 0.85;
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage const MEMORY_CRITICAL_THRESHOLD = 0.95;
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
if (denominator <= 0) {
return 0;
}
return (heapUsed / denominator) * 100;
}
interface HealthStatus { interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy'; status: 'healthy' | 'degraded' | 'unhealthy';
@@ -14,6 +25,7 @@ interface HealthStatus {
memory?: { memory?: {
heapUsed: number; heapUsed: number;
heapTotal: number; heapTotal: number;
heapSizeLimit: number;
rss: number; rss: number;
external: number; external: number;
heapUsagePercent: number; heapUsagePercent: number;
@@ -27,14 +39,9 @@ interface HealthStatus {
/** /**
* Health check endpoint for container orchestration * Health check endpoint for container orchestration
* *
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable) * GET /api/health - Liveness probe for container orchestration
* GET /api/health?detailed=true - Detailed diagnostics with memory stats * GET /api/health?detailed=true - Diagnostics with advisory memory warnings
* HEAD /api/health - Lightweight health check (status code only) * HEAD /api/health - Lightweight liveness probe (status code only)
*
* Health status based on Node.js heap usage:
* - Healthy (200): < 85% heap usage
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
* - Unhealthy (503): > 95% heap usage
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
try { try {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const memUsage = process.memoryUsage(); const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
// Determine health status based on memory usage
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
const warnings: string[] = []; const warnings: string[] = [];
let httpStatus = 200;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
status = 'unhealthy'; status = 'degraded';
httpStatus = 503; warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) { } else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
status = 'degraded'; status = 'degraded';
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`); warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
} }
// Build response
const response: HealthStatus = { const response: HealthStatus = {
status, status: detailed ? status : 'healthy',
timestamp, timestamp,
}; };
if (status === 'unhealthy') {
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
}
// Add detailed information if requested
if (detailed) { if (detailed) {
response.uptime = process.uptime(); response.uptime = process.uptime();
response.version = process.env.npm_package_version || '0.1.0'; response.version = process.env.npm_package_version || '0.1.0';
response.memory = { response.memory = {
heapUsed: memUsage.heapUsed, heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal, heapTotal: memUsage.heapTotal,
heapSizeLimit,
rss: memUsage.rss, rss: memUsage.rss,
external: memUsage.external, external: memUsage.external,
heapUsagePercent: Number(heapUsagePercent.toFixed(2)), heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
} }
} }
logger.info('Health check', { status, detailed });
return NextResponse.json(response, { return NextResponse.json(response, {
status: httpStatus, status: 200,
headers: { headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate', 'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
*/ */
export async function HEAD() { export async function HEAD() {
try { try {
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
return new Response(null, { status: 503 });
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} catch { } catch {
return new Response(null, { status: 503 }); return new Response(null, { status: 503 });
+20 -9
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto'; import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
function isEnabled(): boolean { function isEnabled(): boolean {
@@ -10,19 +10,30 @@ function isEnabled(): boolean {
} }
/** /**
* Verify identity against the session cookie if available. * Verify identity against session cookies across all account slots.
* Returns true if no session cookie exists (can't verify) or if identity matches. * With multi-account, the requesting account may be on any slot (0-4).
* Returns false if session cookie exists but identity doesn't match. * Returns true if any slot matches OR if no session cookies exist at all.
*/ */
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> { async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value; let hasAnyCookie = false;
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
const session = decryptSession(sessionToken); for (let slot = 0; slot <= 4; slot++) {
if (!session) return true; // Invalid session cookie, skip verification const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
return session.username === username && session.serverUrl === serverUrl; const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
return true; // Found a matching slot
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
return false;
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
+6
View File
@@ -20,6 +20,8 @@
--color-accent-foreground: #1e40af; --color-accent-foreground: #1e40af;
--color-destructive: #ef4444; --color-destructive: #ef4444;
--color-destructive-foreground: #ffffff; --color-destructive-foreground: #ffffff;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
/* Settings variables */ /* Settings variables */
--font-size-base: 16px; --font-size-base: 16px;
@@ -50,6 +52,8 @@
--color-accent-foreground: #dbeafe; --color-accent-foreground: #dbeafe;
--color-destructive: #ef4444; --color-destructive: #ef4444;
--color-destructive-foreground: #fafafa; --color-destructive-foreground: #fafafa;
--color-popover: #1c1c1c;
--color-popover-foreground: #fafafa;
} }
@theme inline { @theme inline {
@@ -68,6 +72,8 @@
--color-accent-foreground: var(--color-accent-foreground); --color-accent-foreground: var(--color-accent-foreground);
--color-destructive: var(--color-destructive); --color-destructive: var(--color-destructive);
--color-destructive-foreground: var(--color-destructive-foreground); --color-destructive-foreground: var(--color-destructive-foreground);
--color-popover: var(--color-popover);
--color-popover-foreground: var(--color-popover-foreground);
} }
* { * {
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
window.location.href = "/login";
}
}, [isAuthenticated]);
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
);
}
+42 -13
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow } from "date-fns"; import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react"; import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
@@ -27,6 +27,7 @@ interface DayGroup {
} }
export function CalendarAgendaView({ export function CalendarAgendaView({
selectedDate,
events, events,
calendars, calendars,
onSelectEvent, onSelectEvent,
@@ -43,6 +44,9 @@ export function CalendarAgendaView({
return map; return map;
}, [calendars]); }, [calendars]);
const todayRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) => const sorted = [...events].sort((a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime() new Date(a.start).getTime() - new Date(b.start).getTime()
@@ -69,10 +73,38 @@ export function CalendarAgendaView({
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
// Always include today's date in the groups so the view has a "Today" anchor
const todayKey = format(new Date(), "yyyy-MM-dd");
if (!groupMap.has(todayKey)) {
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
groupMap.set(todayKey, todayGroup);
groups.push(todayGroup);
}
groups.sort((a, b) => a.date.getTime() - b.date.getTime()); groups.sort((a, b) => a.date.getTime() - b.date.getTime());
return groups; return groups;
}, [events]); }, [events]);
// Auto-scroll to today's section on mount and when selectedDate changes to today
const scrollToToday = useCallback(() => {
if (todayRef.current) {
todayRef.current.scrollIntoView({ block: "start" });
}
}, []);
useEffect(() => {
// Scroll to today on mount
const frame = requestAnimationFrame(scrollToToday);
return () => cancelAnimationFrame(frame);
}, [scrollToToday]);
useEffect(() => {
// Scroll to today when selectedDate changes to today
if (isToday(selectedDate)) {
scrollToToday();
}
}, [selectedDate, scrollToToday]);
const formatDateHeader = (date: Date): string => { const formatDateHeader = (date: Date): string => {
if (isToday(date)) return t("events.today_header"); if (isToday(date)) return t("events.today_header");
if (isTomorrow(date)) return t("events.tomorrow_header"); if (isTomorrow(date)) return t("events.tomorrow_header");
@@ -86,19 +118,10 @@ export function CalendarAgendaView({
return format(date, "HH:mm"); return format(date, "HH:mm");
}; };
if (grouped.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground"> <div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">{t("events.no_events")}</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
{grouped.map((group) => ( {grouped.map((group) => (
<div key={group.dateKey}> <div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border"> <div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
<span className={cn( <span className={cn(
"text-sm font-medium", "text-sm font-medium",
@@ -111,6 +134,11 @@ export function CalendarAgendaView({
</span> </span>
</div> </div>
{group.events.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
{t("events.no_events")}
</div>
) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{group.events.map((ev) => { {group.events.map((ev) => {
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
@@ -176,6 +204,7 @@ export function CalendarAgendaView({
); );
})} })}
</div> </div>
)}
</div> </div>
))} ))}
</div> </div>
+32 -1
View File
@@ -2,13 +2,14 @@
import { useMemo, useEffect, useRef, useState } from "react"; import { useMemo, useEffect, useRef, useState } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, parseISO } from "date-fns"; import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card"; import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarDayViewProps { interface CalendarDayViewProps {
selectedDate: Date; selectedDate: Date;
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
} }
const HOUR_HEIGHT = 64; const HOUR_HEIGHT = 64;
@@ -35,6 +37,7 @@ export function CalendarDayView({
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
pendingPreview,
}: CalendarDayViewProps) { }: CalendarDayViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -274,6 +277,34 @@ export function CalendarDayView({
</div> </div>
</div> </div>
)} )}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div> </div>
</div> </div>
</div> </div>
+37 -2
View File
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface CalendarMonthViewProps { interface CalendarMonthViewProps {
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
} }
export function CalendarMonthView({ export function CalendarMonthView({
@@ -38,6 +40,7 @@ export function CalendarMonthView({
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile, isMobile,
pendingPreview,
}: CalendarMonthViewProps) { }: CalendarMonthViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -194,7 +197,6 @@ export function CalendarMonthView({
</span> </span>
</div> </div>
{isMobile ? ( {isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap"> <div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => { {dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
@@ -211,14 +213,47 @@ export function CalendarMonthView({
{dayEvents.length > 3 && ( {dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" /> <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)} )}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
/>
)}
</div> </div>
)
) : null} ) : null}
</div> </div>
); );
})} })}
</div> </div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
</div>
);
})()}
{!isMobile && segments.length > 0 && ( {!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}> <div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{segments.map((segment) => { {segments.map((segment) => {
@@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client"; import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps { interface CalendarSidebarPanelProps {
calendars: Calendar[]; calendars: Calendar[];
@@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void; onSubscribe?: () => void;
client?: JMAPClient | null; client?: IJMAPClient | null;
} }
export function CalendarSidebarPanel({ export function CalendarSidebarPanel({
+15 -1
View File
@@ -136,6 +136,20 @@ export function CalendarToolbar({
{t("views.today")} {t("views.today")}
</Button> </Button>
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && ( {isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<Button <Button
@@ -312,7 +326,7 @@ export function CalendarToolbar({
)} )}
{!isMobile && ( {!isMobile && (
<Button size="sm" onClick={onCreateEvent}> <Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
{t("events.create")} {t("events.create")}
</Button> </Button>
@@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarWeekViewProps { interface CalendarWeekViewProps {
selectedDate: Date; selectedDate: Date;
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
} }
const HOUR_HEIGHT = 60; const HOUR_HEIGHT = 60;
@@ -41,6 +43,7 @@ export function CalendarWeekView({
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
pendingPreview,
}: CalendarWeekViewProps) { }: CalendarWeekViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -366,6 +369,34 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
)} )}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div> </div>
); );
})} })}
+4
View File
@@ -70,6 +70,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = parseISO(event.start);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || ""; const calendarName = calendar?.name || "";
@@ -156,6 +157,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
> >
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span> <span className="truncate font-medium">{event.title || t("events.no_title")}</span>
</div> </div>
</button> </button>
+23 -1
View File
@@ -20,6 +20,14 @@ import {
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview {
start: Date;
end: Date;
title: string;
allDay: boolean;
calendarId: string;
}
interface EventModalProps { interface EventModalProps {
event?: CalendarEvent | null; event?: CalendarEvent | null;
calendars: Calendar[]; calendars: Calendar[];
@@ -30,6 +38,7 @@ interface EventModalProps {
onDuplicate?: (data: Partial<CalendarEvent>) => void; onDuplicate?: (data: Partial<CalendarEvent>) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void; onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[]; currentUserEmails?: string[];
isMobile?: boolean; isMobile?: boolean;
} }
@@ -105,6 +114,7 @@ export function EventModal({
onDuplicate, onDuplicate,
onRsvp, onRsvp,
onClose, onClose,
onPreviewChange,
currentUserEmails = [], currentUserEmails = [],
isMobile = false, isMobile = false,
}: EventModalProps) { }: EventModalProps) {
@@ -219,6 +229,18 @@ export function EventModal({
}); });
const [sendInvitations, setSendInvitations] = useState(true); const [sendInvitations, setSendInvitations] = useState(true);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const s = new Date(startStr);
const e = new Date(endStr);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
return () => onPreviewChange(null);
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
if (!event?.participants) return null; if (!event?.participants) return null;
return getStatusCounts(event); return getStatusCounts(event);
@@ -707,7 +729,7 @@ export function EventModal({
} }
return ( 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"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")} {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 { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; 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 { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalImportModalProps { interface ICalImportModalProps {
calendars: Calendar[]; calendars: Calendar[];
client: JMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
} }
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react"; 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 { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps { interface ICalSubscriptionModalProps {
client: JMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
} }
+148 -8
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; 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 { 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"; import { Button } from "@/components/ui/button";
@@ -48,6 +48,7 @@ interface AddressEntry {
interface ContactFormProps { interface ContactFormProps {
contact?: ContactCard | null; contact?: ContactCard | null;
addressBooks?: AddressBook[]; addressBooks?: AddressBook[];
allKeywords?: string[];
onSave: (data: Partial<ContactCard>) => Promise<void>; onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => 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 t = useTranslations("contacts.form");
const isEditing = !!contact; const isEditing = !!contact;
@@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
{/* Categories */} {/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital"> <FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<div> <CategoryComboBox
<Input keywordsStr={keywordsStr}
value={keywordsStr} onChange={setKeywordsStr}
onChange={(e) => setKeywordsStr(e.target.value)} allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")} 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> </FormSection>
{/* Gender */} {/* Gender */}
@@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
</form> </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) ? Array.from(selectedContactIds)
: [contact.id]; : [contact.id];
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids)); e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
e.dataTransfer.setData("text/plain", name || email || contact.id); 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 type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; 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 { interface ContactsSidebarProps {
groups: ContactCard[]; groups: ContactCard[];
@@ -24,6 +24,7 @@ interface ContactsSidebarProps {
onEditGroup?: (groupId: string) => void; onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
className?: string; className?: string;
} }
@@ -56,6 +57,7 @@ export function ContactsSidebar({
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
@@ -146,6 +148,11 @@ export function ContactsSidebar({
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
}, [individuals]); }, [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 // Resolve actual group member counts against living contacts
const memberCountByGroup = useMemo(() => { const memberCountByGroup = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
@@ -311,7 +318,6 @@ export function ContactsSidebar({
)} )}
{/* Categories section (from contact keywords) */} {/* Categories section (from contact keywords) */}
{allKeywords.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<button <button
onClick={() => toggleSection("categories")} onClick={() => toggleSection("categories")}
@@ -327,30 +333,41 @@ export function ContactsSidebar({
</span> </span>
</button> </button>
{!collapsed.categories && allKeywords.map(([keyword, count]) => { {!collapsed.categories && (
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; <>
return ( {/* No Category item */}
<button <button
key={keyword} onClick={() => onSelectCategory("uncategorized")}
onClick={() => onSelectCategory({ keyword })}
className={cn( className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", "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" ? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted" : "text-foreground/80 hover:bg-muted"
)} )}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }} style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
> >
<Tag className="w-3.5 h-3.5 flex-shrink-0" /> <Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate">{keyword}</span> <span className="truncate italic">{t("no_category")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
{count} {uncategorizedCount}
</span> </span>
</button> </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 */} {/* Shared accounts with address books */}
{sharedBookGroups.map((group) => ( {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({ function AddressBookItem({
book, book,
isActive, isActive,
+1
View File
@@ -886,6 +886,7 @@ export function EmailComposer({
return ( return (
<div <div
className={cn("flex flex-col h-full bg-background relative", className)} className={cn("flex flex-col h-full bg-background relative", className)}
data-tour="composer"
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} 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 { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils"; import { getEmailColorTag } from "@/lib/thread-utils";
interface EmailListItemProps { interface EmailListItemProps {
@@ -21,9 +22,15 @@ interface EmailListItemProps {
selected?: boolean; selected?: boolean;
onClick?: () => void; onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => 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 t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
@@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
{...dragHandlers} {...dragHandlers}
{...longPressHandlers} {...longPressHandlers}
className={cn( 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 // Apply color tag as background, with selected and unread states
colorTag ? colorTag : ( colorTag ? colorTag : (
selected selected
@@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)} )}
</div> </div>
</div> </div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
</div> </div>
); );
} }
+7 -1
View File
@@ -358,7 +358,7 @@ export function EmailList({
)} )}
{/* Email List */} {/* 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 */} {/* Loading overlay */}
{isLoading && emails.length > 0 && ( {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"> <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)} onEmailSelect={(email) => onEmailSelect?.(email)}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation} 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> </div>
); );
+435 -88
View File
@@ -66,6 +66,7 @@ import {
Moon, Moon,
HelpCircle, HelpCircle,
EditIcon, EditIcon,
PlayCircle,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime'; 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 { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner"; import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment } from "@/lib/calendar-invitation"; import { findCalendarAttachment } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover"; import { RecipientPopover } from "./recipient-popover";
@@ -105,7 +107,7 @@ interface EmailViewerProps {
onToggleStar?: () => void; onToggleStar?: () => void;
onMarkAsRead?: (emailId: string, read: boolean) => void; onMarkAsRead?: (emailId: string, read: boolean) => void;
onSetColorTag?: (emailId: string, color: string | null) => void; onSetColorTag?: (emailId: string, color: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
onQuickReply?: (body: string) => Promise<void>; onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void; onUndoSpam?: () => void;
@@ -149,6 +151,42 @@ const getFileIcon = (name?: string, type?: string) => {
return File; return File;
}; };
const MIME_TYPE_LABELS: Record<string, string> = {
'application/pdf': 'Document.pdf',
'application/zip': 'Archive.zip',
'application/x-zip-compressed': 'Archive.zip',
'application/gzip': 'Archive.gz',
'application/x-rar-compressed': 'Archive.rar',
'application/x-7z-compressed': 'Archive.7z',
'application/msword': 'Document.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Document.docx',
'application/vnd.ms-excel': 'Spreadsheet.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Spreadsheet.xlsx',
'application/vnd.ms-powerpoint': 'Presentation.ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Presentation.pptx',
'text/plain': 'Text.txt',
'text/html': 'Document.html',
'text/csv': 'Data.csv',
'application/json': 'Data.json',
'application/xml': 'Data.xml',
'application/octet-stream': 'Attachment',
'message/rfc822': 'Email.eml',
};
const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: string): string => {
if (name) return name;
if (mimeType) {
const label = MIME_TYPE_LABELS[mimeType.toLowerCase()];
if (label) return label;
const sub = mimeType.split('/')[1];
if (sub) {
const clean = sub.replace(/^x-/, '').replace(/^vnd\./, '');
return `Attachment.${clean}`;
}
}
return 'Attachment';
};
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null; if (!keywords) return null;
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
@@ -835,8 +873,11 @@ export function EmailViewer({
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tSmime = useTranslations('smime'); const tSmime = useTranslations('smime');
const tFiles = useTranslations('files'); const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -861,9 +902,12 @@ export function EmailViewer({
// Tablet list visibility // Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities, client } = useAuthStore(); const { identities, client, isDemoMode } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false); const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({}); const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
@@ -2394,6 +2438,44 @@ export function EmailViewer({
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment]); }, [mailAttachmentAction, onDownloadAttachment]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
return;
}
if (attachment.tnefData) {
const buffer = attachment.tnefData.buffer.slice(
attachment.tnefData.byteOffset,
attachment.tnefData.byteOffset + attachment.tnefData.byteLength,
) as ArrayBuffer;
const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000);
return;
}
if (!attachment.decryptedAttachment) return;
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment]);
// Iframe for rendering HTML emails true-to-life // Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -2607,6 +2689,52 @@ export function EmailViewer({
} }
if (!email) { 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 ( 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={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"> <div className="text-center p-8">
@@ -2913,6 +3041,21 @@ export function EmailViewer({
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
</Button> </Button>
{/* Dark/light mode toggle for HTML emails */}
{effectiveEmailContent.isHtml && (
<Button
variant="ghost"
size="sm"
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
data-overflow-item
data-overflow-priority="11"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isDark ? 'View in light mode' : 'View in dark mode'}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
)}
{/* More menu — click-based */} {/* More menu — click-based */}
<div ref={moreMenuRef} className="relative"> <div ref={moreMenuRef} className="relative">
<Button <Button
@@ -3095,6 +3238,16 @@ export function EmailViewer({
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
{t('view_source')} {t('view_source')}
</button> </button>
{/* Overflow: dark/light mode toggle */}
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
{/* Export email */} {/* Export email */}
<button <button
@@ -3131,6 +3284,7 @@ export function EmailViewer({
return ( return (
<div <div
key={email.id} 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)} 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 */} {/* Mobile More menu sidebar overlay */}
@@ -3268,6 +3422,15 @@ export function EmailViewer({
<Code className="w-5 h-5" /> <Code className="w-5 h-5" />
{t('view_source')} {t('view_source')}
</button> </button>
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }} onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
@@ -3341,7 +3504,7 @@ export function EmailViewer({
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight break-words min-w-0"> <h1 className="text-lg lg:text-xl font-bold text-foreground tracking-tight break-words min-w-0">
{email.subject || t('no_subject')} {email.subject || t('no_subject')}
</h1> </h1>
{/* Star inline with subject (top toolbar mode) */} {/* Star inline with subject (top toolbar mode) */}
@@ -3365,19 +3528,24 @@ export function EmailViewer({
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> <span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null; ) : null;
})()} })()}
</div>
<div className="flex items-center gap-2 lg:gap-3 mt-1 lg:mt-1.5 text-xs lg:text-sm text-muted-foreground">
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
<Clock className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{isImportant && ( {isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap"> <span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')} {t('important')}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* Date/time on the right of subject row */}
<div className="flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -3408,13 +3576,14 @@ export function EmailViewer({
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
size="lg" size="lg"
className="shadow-sm w-12 h-12 group-hover:ring-2 group-hover:ring-primary/30 transition-all" className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/> />
</button> </button>
<div className="flex-1 min-w-0 flex gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Sender line with email and badges */} {/* Row 1: Sender name + badges */}
<div className="flex items-start justify-between gap-4"> <div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
@@ -3425,10 +3594,6 @@ export function EmailViewer({
{sender?.name || sender?.email || t('unknown_sender')} {sender?.name || sender?.email || t('unknown_sender')}
</button> </button>
<EmailIdentityBadge email={email} identities={identities} /> <EmailIdentityBadge email={email} identities={identities} />
</div>
{sender?.email && (
<div className="text-sm text-muted-foreground mt-0.5 flex items-center min-w-0">
<span className="truncate">{sender.email}</span>
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner <UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe} listUnsubscribe={listHeaders.listUnsubscribe}
@@ -3442,72 +3607,52 @@ export function EmailViewer({
/> />
)} )}
</div> </div>
)} {/* Email address under name */}
</div> {sender?.email && sender?.name && (
{/* Date and size on the right */} <div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
<div className="text-right flex-shrink-0">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</div>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/70 mt-0.5">
{formatFileSize(email.size)}
</div>
)}
{effectiveEmailContent.isHtml && (
<button
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
className="inline-flex items-center rounded-full p-1 mt-1 text-muted-foreground/70 hover:text-foreground transition-colors hover:bg-muted"
title={isDark ? 'View in light mode' : 'View in dark mode'}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
)} )}
</div> </div>
</div> </div>
{/* Recipient section - separate line */} {/* Row 2: Recipients + Show details */}
<div className="mt-2 space-y-1"> <div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && ( {email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <>
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span> <span>{t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
{email.to.length > 2 && ( {email.to.length > 2 && (
<button <button
onClick={() => setShowFullHeaders(!showFullHeaders)} onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline text-sm" className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
> >
{t('more_count', { count: email.to.length - 2 })} {t('more_count', { count: email.to.length - 2 })}
</button> </button>
)} )}
</div> </>
)} )}
{email.cc && email.cc.length > 0 && ( {email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <>
<span className="text-muted-foreground">CC:</span> <span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && ( {email.cc.length > 2 && (
<span className="text-muted-foreground text-sm">+{email.cc.length - 2}</span> <span className="text-muted-foreground">+{email.cc.length - 2}</span>
)} )}
</div> </>
)} )}
{email.bcc && email.bcc.length > 0 && ( {email.bcc && email.bcc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <>
<span className="text-muted-foreground">{t('bcc')}:</span> <span className="text-muted-foreground/50">|</span>
<span>{t('bcc')}:</span>
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)} {renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
{email.bcc.length > 2 && ( {email.bcc.length > 2 && (
<span className="text-muted-foreground text-sm">+{email.bcc.length - 2}</span> <span className="text-muted-foreground">+{email.bcc.length - 2}</span>
)} )}
</div> </>
)} )}
</div>
{/* Details toggle - stays in place when expanded */}
<button <button
onClick={() => setShowFullHeaders(!showFullHeaders)} onClick={() => setShowFullHeaders(!showFullHeaders)}
className="mt-3 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors" className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
> >
{showFullHeaders ? ( {showFullHeaders ? (
<> <>
@@ -3521,6 +3666,7 @@ export function EmailViewer({
</> </>
)} )}
</button> </button>
</div>
{/* Expandable Details */} {/* Expandable Details */}
{showFullHeaders && ( {showFullHeaders && (
@@ -3894,45 +4040,247 @@ export function EmailViewer({
)} )}
</div> </div>
{/* Attachments on the right (beside-sender mode) */}
{attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<div
key={attachment.id}
className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-foreground truncate max-w-[140px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={() => handleEffectiveAttachmentDownload(attachment)}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
</div>
</div>
);
})}
{effectiveAttachments.length > 2 && (
<button
onClick={() => setShowAllBesideAttachments(!showAllBesideAttachments)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
>
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{/* Floating popup for remaining attachments */}
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBesideAttachments(false)} />
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<div
key={attachment.id}
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
</div>
</div> </div>
</div> </div>
{/* === ATTACHMENTS (integrated into header) === */} {/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{effectiveAttachments.length > 0 && ( {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
<div className="bg-background border-b border-border px-4 lg:px-6 py-3"> <div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
<div className="flex items-start gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{effectiveAttachments.map((attachment) => { {effectiveAttachments.map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( return (
<button <div
key={attachment.id} key={attachment.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-muted/60 hover:bg-accent rounded-lg transition-colors group border border-border/50" className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
title={`${opensPreview ? tFiles('preview') : t('download')} ${attachment.name || 'Unnamed'} (${formatFileSize(attachment.size)})`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
> >
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex flex-col items-start min-w-0">
<span className="text-sm text-foreground truncate max-w-[200px]"> <span className="text-sm text-foreground truncate max-w-[200px]">
{attachment.name || "Unnamed"} {getAttachmentDisplayName(attachment.name, attachment.type)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatFileSize(attachment.size)} {formatFileSize(attachment.size)}
</span> </span>
</div> <div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
{opensPreview ? ( <button
<Eye className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" /> className="p-1 hover:bg-accent rounded transition-colors"
) : ( title={t('download')}
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" /> onClick={() => handleEffectiveAttachmentDownload(attachment)}
)} >
<Download className="w-4 h-4 text-foreground" />
</button> </button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
</div>
</div>
); );
})} })}
</div> </div>
</div> </div>
)} )}
{/* Mobile/Tablet Attachments */}
{effectiveAttachments.length > 0 && (
<div className="lg:hidden bg-background border-b border-border px-4 py-2">
<div className="relative flex items-center gap-1.5 flex-wrap">
{effectiveAttachments.slice(0, 2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<div
key={attachment.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
>
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground truncate max-w-[200px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={() => handleEffectiveAttachmentDownload(attachment)}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
</div>
</div>
);
})}
{effectiveAttachments.length > 2 && (
<button
onClick={() => setShowAllMobileAttachments(!showAllMobileAttachments)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
>
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{effectiveAttachments.slice(2).map((attachment) => {
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<div
key={attachment.id}
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
>
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
>
<Download className="w-3.5 h-3.5 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
>
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
)}
{/* Mobile/Tablet Sender Info - scrolls with content */} {/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}> <div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
@@ -3949,8 +4297,8 @@ export function EmailViewer({
/> />
</button> </button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Mobile 2-line layout */} {/* Row 1: Sender name + badges */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<button <button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)} onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left" className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
@@ -3958,11 +4306,6 @@ export function EmailViewer({
{sender?.name || sender?.email || t('unknown_sender')} {sender?.name || sender?.email || t('unknown_sender')}
</button> </button>
<EmailIdentityBadge email={email} identities={identities} /> <EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{sender?.email && sender?.name && (
<>
<span className="truncate">{sender.email}</span>
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner <UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe} listUnsubscribe={listHeaders.listUnsubscribe}
@@ -3975,26 +4318,30 @@ export function EmailViewer({
}} }}
/> />
)} )}
<span>·</span> </div>
</> {/* Email address under name */}
{sender?.email && sender?.name && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{sender.email}</div>
)} )}
{/* Row 2: Recipients */}
<div className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{email.to && email.to.length > 0 && ( {email.to && email.to.length > 0 && (
<> <>
<span> {t('recipient_to_prefix')}</span> <span> {t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
</> </>
)} )}
</div>
{/* CC line (mobile - only if present) */}
{email.cc && email.cc.length > 0 && ( {email.cc && email.cc.length > 0 && (
<div className="mt-1 flex items-center gap-1 text-sm"> <>
<span className="text-muted-foreground">CC:</span> <span className="text-muted-foreground/50">|</span>
<span>CC:</span>
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
{email.cc.length > 2 && ( {email.cc.length > 2 && (
<span className="text-muted-foreground">+{email.cc.length - 2}</span> <span>+{email.cc.length - 2}</span>
)}
</>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
+50 -3
View File
@@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface ThreadListItemProps { interface ThreadListItemProps {
@@ -25,6 +26,12 @@ interface ThreadListItemProps {
onEmailSelect: (email: Email) => void; onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => 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 { interface SingleEmailItemProps {
@@ -34,10 +41,16 @@ interface SingleEmailItemProps {
onContextMenu?: (e: React.MouseEvent, email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean; showPreview: boolean;
colorTag: string | null; 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>( 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 isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0]; const sender = email.from?.[0];
@@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{...dragHandlers} {...dragHandlers}
{...longPressHandlers} {...longPressHandlers}
className={cn( 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 : ( resolvedColorTag ? resolvedColorTag : (
selected selected
? "bg-accent" ? "bg-accent"
@@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
</div> </div>
</div> </div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
</div> </div>
); );
} }
@@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onEmailSelect, onEmailSelect,
onContextMenu, onContextMenu,
onOpenConversation, onOpenConversation,
onToggleStar,
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
}, ref) { }, ref) {
const t = useTranslations('threads'); const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
@@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
showPreview={showPreview} showPreview={showPreview}
colorTag={colorTag} 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} {...dragHandlers}
{...threadLongPressHandlers} {...threadLongPressHandlers}
className={cn( 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 : ( colorTag ? colorTag : (
isSelected isSelected
? "bg-accent" ? "bg-accent"
@@ -493,6 +529,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
</div> </div>
</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> </div>
{isExpanded && !isMobile && ( {isExpanded && !isMobile && (
+10
View File
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts"; import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTour } from "@/components/tour/tour-provider";
interface KeyboardShortcutsModalProps { interface KeyboardShortcutsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) { export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations(); const t = useTranslations();
const { startTour } = useTour();
const modalRef = useFocusTrap({ const modalRef = useFocusTrap({
isActive: isOpen, isActive: isOpen,
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")} {t("shortcuts.tip")}
</p> </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> </div>
</div> </div>
+27 -11
View File
@@ -217,8 +217,8 @@ export function NavigationRail({
); );
})} })}
{/* Custom sidebar apps */} {/* Custom sidebar apps (per-app mobile visibility) */}
{sidebarApps.map((app) => { {sidebarApps.filter((app) => app.showOnMobile).map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
const isActive = activeAppId === app.id; const isActive = activeAppId === app.id;
return ( return (
@@ -252,16 +252,27 @@ export function NavigationRail({
); );
})} })}
{/* Manage apps button */} {/* Settings */}
{onManageApps && ( <Link
<button href="/settings"
onClick={onManageApps} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground" className={cn(
> "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
<Plus className="w-5 h-5" /> "transition-colors duration-150",
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span> isSettingsActive
</button> ? "text-primary"
: "text-muted-foreground hover:text-foreground"
)} )}
aria-current={isSettingsActive ? "page" : undefined}
>
<div className="relative">
<Settings className="w-5 h-5" />
{isSettingsActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
</Link>
</nav> </nav>
); );
} }
@@ -292,6 +303,7 @@ export function NavigationRail({
key={item.id} key={item.id}
href={item.href} href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour={`nav-${item.id}`}
className={cn( className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150", "relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed collapsed
@@ -390,6 +402,7 @@ export function NavigationRail({
<Link <Link
href="/settings" href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour="nav-settings"
className={cn( className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors", "flex items-center justify-center w-10 h-10 rounded-md transition-colors",
isSettingsActive isSettingsActive
@@ -407,6 +420,7 @@ export function NavigationRail({
{onShowShortcuts && ( {onShowShortcuts && (
<button <button
onClick={onShowShortcuts} 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" 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")} title={t("keyboard_shortcuts")}
> >
@@ -415,7 +429,9 @@ export function NavigationRail({
)} )}
{quota && quota.total > 0 && ( {quota && quota.total > 0 && (
<div data-tour="storage-quota">
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} /> <StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
</div>
)} )}
{isPushConnected != null && ( {isPushConnected != null && (
+2
View File
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
url: string; url: string;
icon: string; icon: string;
openMode: 'tab' | 'inline'; openMode: 'tab' | 'inline';
showOnMobile: boolean;
} }
function SidebarAppForm({ function SidebarAppForm({
@@ -37,6 +38,7 @@ function SidebarAppForm({
url: app?.url || '', url: app?.url || '',
icon: app?.icon || 'Globe', icon: app?.icon || 'Globe',
openMode: app?.openMode || 'tab', openMode: app?.openMode || 'tab',
showOnMobile: app?.showOnMobile ?? false,
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
+77 -7
View File
@@ -24,6 +24,10 @@ import {
Settings, Settings,
X, X,
Tag, Tag,
RotateCcw,
FlaskConical,
PlayCircle,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
@@ -40,6 +44,7 @@ import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { AccountSwitcher } from "./account-switcher"; import { AccountSwitcher } from "./account-switcher";
import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps { interface SidebarProps {
mailboxes: Mailbox[]; 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() { function VacationBanner() {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const router = useRouter(); const router = useRouter();
@@ -454,12 +521,12 @@ export function Sidebar({
)} )}
> >
{/* Header */} {/* 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onSidebarClose} 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")} aria-label={t("close")}
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
@@ -480,7 +547,7 @@ export function Sidebar({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleSidebarCollapsed} 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")} title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
> >
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />} {isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
@@ -491,11 +558,14 @@ export function Sidebar({
)} )}
</div> </div>
{/* Demo Banner */}
{!isCollapsed && <DemoBanner />}
{/* Vacation Banner */} {/* Vacation Banner */}
{!isCollapsed && <VacationBanner />} {!isCollapsed && <VacationBanner />}
{/* Mailbox List */} {/* Mailbox List */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1"> <div className="py-1">
{mailboxes.length === 0 ? ( {mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground"> <div className="px-4 py-2 text-sm text-muted-foreground">
@@ -582,7 +652,7 @@ export function Sidebar({
</div> </div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( {((tagsExpanded && !isCollapsed) || isCollapsed) && (
<div className="relative"> <div className="relative" data-tour="keyword-tags">
{emailKeywords.map((kw) => { {emailKeywords.map((kw) => {
const isSelected = selectedKeyword === kw.id; const isSelected = selectedKeyword === kw.id;
return ( return (
@@ -606,11 +676,11 @@ export function Sidebar({
{/* Compose Button */} {/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}> <div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? ( {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" /> <PenSquare className="w-5 h-5" />
</Button> </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" /> <PenSquare className="w-4 h-4 mr-2" />
{t("compose")} {t("compose")}
</Button> </Button>
+19 -1
View File
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
export function AccountSettings() { export function AccountSettings() {
const t = useTranslations('settings.account'); const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore(); const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
const { quota } = useEmailStore(); const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <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 */} {/* Email Address */}
<SettingItem label={t('email.label')}> <SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span> <span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
@@ -49,6 +57,16 @@ export function AccountSettings() {
</div> </div>
</SettingItem> </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> </SettingsSection>
); );
} }
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
import { LanguageSwitcher } from '@/components/ui/language-switcher'; import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils'; 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 }> = { const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() { export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
onChange={(checked) => updateSetting('animationsEnabled', checked)} onChange={(checked) => updateSetting('animationsEnabled', checked)}
/> />
</SettingItem> </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> </SettingsSection>
); );
} }
+11
View File
@@ -14,6 +14,7 @@ export function CalendarSettings() {
const { const {
timeFormat, timeFormat,
firstDayOfWeek, firstDayOfWeek,
showTimeInMonthView,
calendarNotificationsEnabled, calendarNotificationsEnabled,
calendarNotificationSound, calendarNotificationSound,
calendarInvitationParsingEnabled, calendarInvitationParsingEnabled,
@@ -57,6 +58,16 @@ export function CalendarSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem
label={t('show_time_in_month_view')}
description={t('show_time_in_month_view_desc')}
>
<ToggleSwitch
checked={showTimeInMonthView}
onChange={(checked) => updateSetting('showTimeInMonthView', checked)}
/>
</SettingItem>
<SettingItem <SettingItem
label={t('notifications_enabled')} label={t('notifications_enabled')}
description={t('notifications_enabled_desc')} description={t('notifications_enabled_desc')}
+49 -1
View File
@@ -3,9 +3,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store'; 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 { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
@@ -24,8 +26,10 @@ export function EmailSettings() {
emailsPerPage, emailsPerPage,
externalContentPolicy, externalContentPolicy,
mailAttachmentAction, mailAttachmentAction,
attachmentPosition,
emailAlwaysLightMode, emailAlwaysLightMode,
archiveMode, archiveMode,
hoverActions,
trustedSenders, trustedSenders,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -184,6 +188,39 @@ export function EmailSettings() {
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} /> <ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem> </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')}> <SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select <Select
value={mailAttachmentAction} value={mailAttachmentAction}
@@ -195,6 +232,17 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('attachment_position.label')} description={t('attachment_position.description')}>
<Select
value={attachmentPosition}
onChange={(value) => updateSetting('attachmentPosition', value as 'beside-sender' | 'below-header')}
options={[
{ value: 'beside-sender', label: t('attachment_position.beside-sender') },
{ value: 'below-header', label: t('attachment_position.below-header') },
]}
/>
</SettingItem>
{/* Emails Per Page */} {/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}> <SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select <Select
+41 -8
View File
@@ -3,8 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; 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 { 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"; import { cn } from "@/lib/utils";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
@@ -91,16 +93,14 @@ function KeywordEditForm({
const [color, setColor] = useState(initial?.color || "blue"); const [color, setColor] = useState(initial?.color || "blue");
const isEditing = !!initial; const isEditing = !!initial;
const normalizedId = isEditing const normalizedId = label
? initial.id
: label
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]/g, "-") .replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-") .replace(/-+/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 isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => { const handleSave = () => {
@@ -159,10 +159,13 @@ function KeywordEditForm({
export function KeywordSettings() { export function KeywordSettings() {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } = const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
useSettingsStore(); useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
@@ -171,8 +174,32 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}; };
const handleEdit = (keyword: KeywordDefinition) => { const handleEdit = async (keyword: KeywordDefinition) => {
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color }); 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); setEditingId(null);
}; };
@@ -187,6 +214,12 @@ export function KeywordSettings() {
return ( return (
<SettingsSection title={t("title")} description={t("description")}> <SettingsSection title={t("title")} description={t("description")}>
<div className="space-y-2"> <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) => {emailKeywords.map((keyword) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
url: string; url: string;
icon: string; icon: string;
openMode: "tab" | "inline"; openMode: "tab" | "inline";
showOnMobile: boolean;
} }
function AppForm({ function AppForm({
@@ -37,6 +38,7 @@ function AppForm({
url: app?.url || "", url: app?.url || "",
icon: app?.icon || "Globe", icon: app?.icon || "Globe",
openMode: app?.openMode || "tab", openMode: app?.openMode || "tab",
showOnMobile: app?.showOnMobile ?? false,
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
@@ -144,6 +146,25 @@ function AppForm({
</div> </div>
</div> </div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t("show_on_mobile")}</label>
<button
type="button"
onClick={() => setFormData({ ...formData, showOnMobile: !formData.showOnMobile })}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
formData.showOnMobile ? "bg-primary" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
formData.showOnMobile ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}> <Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{t("cancel")} {t("cancel")}
+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 { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; 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 { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
const ONBOARDING_KEY = "onboarding_completed"; const ONBOARDING_KEY = "onboarding_completed";
export function WelcomeBanner() { export function WelcomeBanner() {
const t = useTranslations("welcome"); const t = useTranslations("welcome");
const router = useRouter(); const router = useRouter();
const { startTour } = useTour();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
</button> </button>
</div> </div>
<div className="mt-2.5 flex justify-end gap-2"> <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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
+7 -4
View File
@@ -28,15 +28,18 @@ export default [
}, },
plugins: { plugins: {
"@typescript-eslint": tseslint, "@typescript-eslint": tseslint,
"react": reactPlugin, react: reactPlugin,
"react-hooks": reactHooksPlugin, "react-hooks": reactHooksPlugin,
}, },
rules: { rules: {
...tseslint.configs.recommended.rules, ...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", { "@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_", argsIgnorePattern: "^_",
varsIgnorePattern: "^_" varsIgnorePattern: "^_",
}], },
],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "off", "@typescript-eslint/no-empty-object-type": "off",
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
+4
View File
@@ -22,6 +22,7 @@ interface ConfigData {
loginImprintUrl: string; loginImprintUrl: string;
loginPrivacyPolicyUrl: string; loginPrivacyPolicyUrl: string;
loginWebsiteUrl: string; loginWebsiteUrl: string;
demoMode: boolean;
} }
interface AppConfig extends ConfigData { interface AppConfig extends ConfigData {
@@ -91,6 +92,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache?.loginImprintUrl || '', loginImprintUrl: configCache?.loginImprintUrl || '',
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '', loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
loginWebsiteUrl: configCache?.loginWebsiteUrl || '', loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
demoMode: configCache?.demoMode || false,
isLoading: !configCache, isLoading: !configCache,
error: null, error: null,
}); });
@@ -118,6 +120,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: configCache.loginImprintUrl, loginImprintUrl: configCache.loginImprintUrl,
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl, loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
loginWebsiteUrl: configCache.loginWebsiteUrl, loginWebsiteUrl: configCache.loginWebsiteUrl,
demoMode: configCache.demoMode,
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@@ -146,6 +149,7 @@ export function useConfig(): AppConfig {
loginImprintUrl: data.loginImprintUrl, loginImprintUrl: data.loginImprintUrl,
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl, loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
loginWebsiteUrl: data.loginWebsiteUrl, loginWebsiteUrl: data.loginWebsiteUrl,
demoMode: data.demoMode,
isLoading: false, isLoading: false,
error: null, error: null,
}); });
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const loggerError = vi.fn();
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({
status: init?.status ?? 200,
headers: init?.headers,
json: async () => data,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: {
error: loggerError,
},
}));
describe('health route', () => {
beforeEach(() => {
vi.restoreAllMocks();
loggerError.mockReset();
});
it('returns healthy for the basic liveness probe even when heap usage is high', async () => {
vi.spyOn(process, 'memoryUsage').mockReturnValue({
rss: 120_000_000,
heapTotal: 45_000_000,
heapUsed: 43_000_000,
external: 8_000_000,
arrayBuffers: 1_000_000,
});
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toMatchObject({
status: 'healthy',
});
expect(payload.warnings).toBeUndefined();
});
it('returns degraded diagnostics in detailed mode without failing the probe', async () => {
vi.spyOn(process, 'memoryUsage').mockReturnValue({
rss: 120_000_000,
heapTotal: 4_100_000_000,
heapUsed: 4_000_000_000,
external: 8_000_000,
arrayBuffers: 1_000_000,
});
vi.spyOn(process, 'uptime').mockReturnValue(123.45);
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.status).toBe('degraded');
expect(payload.memory).toMatchObject({
heapUsed: 4_000_000_000,
heapTotal: 4_100_000_000,
rss: 120_000_000,
external: 8_000_000,
});
expect(payload.memory.heapSizeLimit).toBeGreaterThan(0);
expect(payload.warnings).toEqual([
expect.stringContaining('V8 heap usage is high'),
]);
});
it('keeps HEAD as a stable liveness probe', async () => {
const { HEAD } = await import('@/app/api/health/route');
const response = await HEAD();
expect(response.status).toBe(200);
});
it('returns 503 when collecting health diagnostics throws', async () => {
vi.spyOn(process, 'memoryUsage').mockImplementation(() => {
throw new Error('boom');
});
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload).toMatchObject({
status: 'unhealthy',
reason: 'boom',
});
expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' });
});
});
+14
View File
@@ -8,6 +8,7 @@ import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store'; import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store'; import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store'; import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store'; import { useVacationStore } from '@/stores/vacation-store';
@@ -97,6 +98,19 @@ export function clearAllStores(): void {
error: null, error: null,
searchQuery: '', searchQuery: '',
quota: null, quota: null,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
selectedEmailIds: new Set<string>(),
hasMoreEmails: false,
totalEmails: 0,
expandedThreadIds: new Set<string>(),
threadEmailsCache: new Map(),
isLoadingThread: null,
selectedKeyword: null,
tagCounts: {},
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
}); });
useIdentityStore.getState().clearIdentities(); useIdentityStore.getState().clearIdentities();
useContactStore.getState().clearContacts(); useContactStore.getState().clearContacts();
+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>;
}
+130 -43
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 { 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 { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
// JMAP protocol types - these are intentionally flexible due to server variations // 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; return emailCount === limit;
} }
export class JMAPClient { export class JMAPClient implements IJMAPClient {
private serverUrl: string; private serverUrl: string;
private username: string; private username: string;
private password: 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> { async deleteEmail(emailId: string): Promise<void> {
await this.request([ await this.request([
["Email/set", { ["Email/set", {
@@ -1358,34 +1409,35 @@ export class JMAPClient {
})); }));
} }
// Destroy old draft before creating replacement to avoid duplicates // Use a single Email/set call with both destroy and create for atomicity
const methodCalls: JMAPMethodCall[] = []; const setArgs: Record<string, unknown> = {
accountId: this.accountId,
create: { [emailId]: emailData },
};
if (draftId) { if (draftId) {
methodCalls.push(["Email/set", { setArgs.destroy = [draftId];
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"]);
} }
const methodCalls: JMAPMethodCall[] = [
["Email/set", setArgs, "0"],
];
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
const responseIndex = draftId ? 1 : 0;
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") { if (response.methodResponses?.[0]?.[0] === "Email/set") {
const result = response.methodResponses[responseIndex][1]; const result = response.methodResponses[0][1];
if (result.notCreated || result.notUpdated) { if (result.notCreated) {
const errors = result.notCreated || result.notUpdated; const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string }; const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError); console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft'); 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]) { if (result.created?.[emailId]) {
return result.created[emailId].id; return result.created[emailId].id;
} }
@@ -2066,6 +2118,11 @@ export class JMAPClient {
return coreCapability?.maxCallsInRequest || 50; return coreCapability?.maxCallsInRequest || 50;
} }
getMaxObjectsInGet(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
return coreCapability?.maxObjectsInGet || 500;
}
getEventSourceUrl(): string | null { getEventSourceUrl(): string | null {
if (!this.session) return null; if (!this.session) return null;
@@ -2428,26 +2485,62 @@ export class JMAPClient {
} }
} }
async getContacts(addressBookId?: string): Promise<ContactCard[]> { private async fetchPaginatedContacts(
try { accountId: string,
const accountId = this.getContactsAccountId(); filter?: Record<string, unknown>,
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 }; ): Promise<ContactCard[]> {
if (addressBookId) { const batchSize = this.getMaxObjectsInGet();
queryArgs.filter = { inAddressBook: addressBookId }; const allIds: string[] = [];
let position = 0;
// Paginate ContactCard/query to collect all IDs
for (;;) {
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
if (filter) {
queryArgs.filter = filter;
} }
const response = await this.request([ const response = await this.request([
["ContactCard/query", queryArgs, "0"], ["ContactCard/query", queryArgs, "q"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"],
], this.contactUsing()); ], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { const queryResult = response.methodResponses?.[0];
return (response.methodResponses[1][1].list || []) as ContactCard[]; if (queryResult?.[0] !== "ContactCard/query") break;
const ids: string[] = queryResult[1].ids || [];
allIds.push(...ids);
const total: number = queryResult[1].total ?? -1;
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
break;
} }
return []; position += ids.length;
}
if (allIds.length === 0) return [];
// Batch ContactCard/get to respect maxObjectsInGet
const allContacts: ContactCard[] = [];
for (let i = 0; i < allIds.length; i += batchSize) {
const chunk = allIds.slice(i, i + batchSize);
const response = await this.request([
["ContactCard/get", { accountId, ids: chunk }, "g"],
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
allContacts.push(...list);
}
}
return allContacts;
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
return await this.fetchPaginatedContacts(accountId, filter);
} catch (error) { } catch (error) {
console.error('Failed to get contacts:', error); console.error('Failed to get contacts:', error);
return []; return [];
@@ -2465,16 +2558,7 @@ export class JMAPClient {
const account = this.accounts[accountId]; const account = this.accounts[accountId];
try { try {
const response = await this.request([ const rawContacts = await this.fetchPaginatedContacts(accountId);
["ContactCard/query", { accountId, limit: 1000 }, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"],
], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
const contacts = rawContacts.map((contact) => ({ const contacts = rawContacts.map((contact) => ({
...contact, ...contact,
id: isPrimary ? contact.id : `${accountId}:${contact.id}`, id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
@@ -2487,7 +2571,6 @@ export class JMAPClient {
isShared: !isPrimary, isShared: !isPrimary,
})); }));
allContacts.push(...contacts); allContacts.push(...contacts);
}
} catch (error) { } catch (error) {
console.error(`Failed to fetch contacts for account ${accountId}:`, error); console.error(`Failed to fetch contacts for account ${accountId}:`, error);
} }
@@ -2888,6 +2971,10 @@ export class JMAPClient {
filter, filter,
limit: limit || 1000, limit: limit || 1000,
}; };
// Expand recurring events into individual occurrences when a date range is provided
if (filter.after || filter.before) {
queryArgs.expandRecurrences = true;
}
if (sort) { if (sort) {
queryArgs.sort = sort; queryArgs.sort = sort;
} }
+23 -3
View File
@@ -171,9 +171,28 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
// From SubjectAlternativeName // From SubjectAlternativeName
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN); const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
if (sanExt?.parsedValue) { if (sanExt) {
const san = sanExt.parsedValue as pkijs.GeneralNames; let names: pkijs.GeneralName[] | undefined;
for (const name of san.names) {
// 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 // type 1 = rfc822Name
if (name.type === 1 && typeof name.value === 'string') { if (name.type === 1 && typeof name.value === 'string') {
if (!emails.includes(name.value)) { if (!emails.includes(name.value)) {
@@ -182,6 +201,7 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
} }
} }
} }
}
return emails; return emails;
} }
+182 -3
View File
@@ -7,21 +7,200 @@
* *
* Native Web Crypto calls are passed through to the real implementation; * Native Web Crypto calls are passed through to the real implementation;
* liner only intercepts algorithms that the browser doesn't natively support. * 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'; import * as pkijs from 'pkijs';
// webcrypto-liner exports a Crypto constructor at runtime that extends native // webcrypto-liner exports a Crypto constructor at runtime that extends native
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only // Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
// expose the type alias, so we import the module dynamically and cast. // 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 // 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 }; Crypto: { new (): Crypto };
setCrypto: (subtle: SubtleCrypto) => void; setCrypto: (subtle: SubtleCrypto) => void;
nativeCrypto: Crypto | Record<string, never>; 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; let linerCryptoInstance: Crypto | null = null;
function ensureLiner() { function ensureLiner() {
@@ -39,7 +218,7 @@ function ensureLiner() {
linerCryptoInstance = new liner.Crypto(); linerCryptoInstance = new liner.Crypto();
} }
if (!linerEngine) { if (!linerEngine) {
linerEngine = new pkijs.CryptoEngine({ linerEngine = new Pkcs12CryptoEngine({
crypto: linerCryptoInstance, crypto: linerCryptoInstance,
subtle: linerCryptoInstance.subtle, subtle: linerCryptoInstance.subtle,
name: 'webcrypto-liner', name: 'webcrypto-liner',
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Webseite", "website": "Webseite",
"imprint": "Impressum", "imprint": "Impressum",
"privacy_policy": "Datenschutz", "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_completing": "Anmeldung wird abgeschlossen...",
"oauth_error": { "oauth_error": {
"title": "Authentifizierung fehlgeschlagen", "title": "Authentifizierung fehlgeschlagen",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Suche löschen", "clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv", "vacation_active": "Abwesenheitsnotiz ist aktiv",
"demo_banner": "Demo-Modus",
"demo_reset": "Zurücksetzen",
"demo_tour": "Tour",
"tags": "Tags", "tags": "Tags",
"mail": "E-Mail", "mail": "E-Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
@@ -210,6 +219,7 @@
"attachments": "Anhänge", "attachments": "Anhänge",
"important": "Wichtig", "important": "Wichtig",
"download": "Herunterladen", "download": "Herunterladen",
"download_all": "Alle herunterladen",
"from": "Von", "from": "Von",
"to": "An", "to": "An",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Zurück", "previous": "Zurück",
"next": "Weiter", "next": "Weiter",
"send": "Senden" "send": "Senden",
"more": "mehr"
}, },
"email_composer": { "email_composer": {
"new_message": "Neue Nachricht", "new_message": "Neue Nachricht",
@@ -753,6 +764,12 @@
"preview": "Wenn möglich in Vorschau öffnen", "preview": "Wenn möglich in Vorschau öffnen",
"download": "Sofort herunterladen" "download": "Sofort herunterladen"
}, },
"attachment_position": {
"label": "Anhangsposition",
"description": "Wo Anhänge im E-Mail-Header angezeigt werden",
"beside-sender": "Neben dem Absender",
"below-header": "Unter dem Header"
},
"emails_per_page": { "emails_per_page": {
"25": "25 E-Mails", "25": "25 E-Mails",
"50": "50 E-Mails", "50": "50 E-Mails",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Konto", "title": "Konto",
"description": "Zeigen Sie Ihre Kontoinformationen an", "description": "Zeigen Sie Ihre Kontoinformationen an",
"name_label": "Anzeigename",
"account_type_label": "Kontotyp",
"demo_account": "Demokonto",
"email": { "email": {
"label": "E-Mail-Adresse", "label": "E-Mail-Adresse",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Kontakte", "title": "Kontakte",
"search_placeholder": "Kontakte suchen...", "search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt", "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": "Keine Kontakte",
"empty_state_title": "Keine Kontakte", "empty_state_title": "Keine Kontakte",
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
@@ -1605,7 +1628,8 @@
"level_low": "Niedrig", "level_low": "Niedrig",
"categories": "Kategorien", "categories": "Kategorien",
"categories_placeholder": "z. B. Familie, Freunde, Kollegen", "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": "Notizen",
"note_placeholder": "Notiz hinzufügen...", "note_placeholder": "Notiz hinzufügen...",
"gender": "Geschlecht", "gender": "Geschlecht",
@@ -1947,6 +1971,12 @@
"deleted": "Abonnement entfernt", "deleted": "Abonnement entfernt",
"delete_error": "Abonnement konnte nicht entfernt werden", "delete_error": "Abonnement konnte nicht entfernt werden",
"last_refreshed": "Zuletzt aktualisiert: {time}" "last_refreshed": "Zuletzt aktualisiert: {time}"
},
"tasks": {
"no_tasks": "Keine Aufgaben",
"no_title": "(Kein Titel)",
"mark_complete": "Als erledigt markieren",
"mark_incomplete": "Als unerledigt markieren"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an", "tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
"got_it": "Verstanden", "got_it": "Verstanden",
"settings": "Einstellungen", "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": { "files": {
"title": "Dateien", "title": "Dateien",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei", "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", "export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
"incorrect_passphrase": "Falsche Passphrase" "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."
} }
} }
+106 -4
View File
@@ -40,6 +40,12 @@
"website": "Website", "website": "Website",
"imprint": "Imprint", "imprint": "Imprint",
"privacy_policy": "Privacy Policy", "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_completing": "Completing sign in...",
"oauth_error": { "oauth_error": {
"title": "Authentication Failed", "title": "Authentication Failed",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Clear search", "clear_search": "Clear search",
"vacation_active": "Vacation responder is active", "vacation_active": "Vacation responder is active",
"demo_banner": "Demo Mode",
"demo_reset": "Reset",
"demo_tour": "Tour",
"tags": "Tags", "tags": "Tags",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation", "nav_label": "Navigation",
@@ -210,6 +219,7 @@
"attachments": "Attachments", "attachments": "Attachments",
"important": "Important", "important": "Important",
"download": "Download", "download": "Download",
"download_all": "Download all",
"from": "From", "from": "From",
"to": "To", "to": "To",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
"event_status_tentative": "Tentative", "event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled" "event_status_cancelled": "Cancelled"
}, },
"send": "Send" "send": "Send",
"more": "more"
}, },
"email_composer": { "email_composer": {
"new_message": "New Message", "new_message": "New Message",
@@ -680,7 +691,9 @@
"delete": "Delete keyword", "delete": "Delete keyword",
"save": "Save", "save": "Save",
"add": "Add", "add": "Add",
"cancel": "Cancel" "cancel": "Cancel",
"migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails"
}, },
"language_region": { "language_region": {
"title": "Language & Region", "title": "Language & Region",
@@ -753,6 +766,12 @@
"preview": "Preview when possible", "preview": "Preview when possible",
"download": "Download immediately" "download": "Download immediately"
}, },
"attachment_position": {
"label": "Attachment Position",
"description": "Where to display attachments in the email header",
"beside-sender": "Next to sender",
"below-header": "Below header"
},
"emails_per_page": { "emails_per_page": {
"25": "25 emails", "25": "25 emails",
"50": "50 emails", "50": "50 emails",
@@ -789,6 +808,17 @@
"close": "Close", "close": "Close",
"invalid_email": "Please enter a valid email address", "invalid_email": "Please enter a valid email address",
"already_added": "This sender is already trusted" "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": { "composer": {
@@ -839,6 +869,9 @@
"account": { "account": {
"title": "Account", "title": "Account",
"description": "View your account information", "description": "View your account information",
"name_label": "Display Name",
"account_type_label": "Account Type",
"demo_account": "Demo Account",
"email": { "email": {
"label": "Email Address", "label": "Email Address",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1495,9 @@
"title": "Contacts", "title": "Contacts",
"search_placeholder": "Search contacts...", "search_placeholder": "Search contacts...",
"create_new": "New Contact", "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": "No contacts yet",
"empty_state_title": "No contacts yet", "empty_state_title": "No contacts yet",
"empty_state_subtitle": "Create your first contact or import from a vCard file", "empty_state_subtitle": "Create your first contact or import from a vCard file",
@@ -1605,7 +1641,8 @@
"level_low": "Low", "level_low": "Low",
"categories": "Categories", "categories": "Categories",
"categories_placeholder": "e.g., Family, Friends, Colleagues", "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": "Notes",
"note_placeholder": "Add a note...", "note_placeholder": "Add a note...",
"gender": "Gender", "gender": "Gender",
@@ -1947,6 +1984,12 @@
"deleted": "Subscription removed", "deleted": "Subscription removed",
"delete_error": "Failed to remove subscription", "delete_error": "Failed to remove subscription",
"last_refreshed": "Last updated: {time}" "last_refreshed": "Last updated: {time}"
},
"tasks": {
"no_tasks": "No tasks",
"no_title": "(No title)",
"mark_complete": "Mark as complete",
"mark_incomplete": "Mark as incomplete"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2030,17 @@
"tip_settings": "Customize your experience in Settings", "tip_settings": "Customize your experience in Settings",
"got_it": "Got it", "got_it": "Got it",
"settings": "Settings", "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": { "files": {
"title": "Files", "title": "Files",
@@ -2167,5 +2220,54 @@
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file", "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", "export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
"incorrect_passphrase": "Incorrect passphrase" "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."
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Sitio web", "website": "Sitio web",
"imprint": "Aviso legal", "imprint": "Aviso legal",
"privacy_policy": "Política de privacidad", "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_completing": "Completando inicio de sesión...",
"oauth_error": { "oauth_error": {
"title": "Error de autenticación", "title": "Error de autenticación",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Limpiar búsqueda", "clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa", "vacation_active": "Respuesta automática activa",
"demo_banner": "Modo demo",
"demo_reset": "Restablecer",
"demo_tour": "Tour",
"tags": "Etiquetas", "tags": "Etiquetas",
"mail": "Correo", "mail": "Correo",
"nav_label": "Navegación", "nav_label": "Navegación",
@@ -210,6 +219,7 @@
"attachments": "Archivos adjuntos", "attachments": "Archivos adjuntos",
"important": "Importante", "important": "Importante",
"download": "Descargar", "download": "Descargar",
"download_all": "Descargar todo",
"from": "De", "from": "De",
"to": "Para", "to": "Para",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Siguiente", "next": "Siguiente",
"send": "Enviar" "send": "Enviar",
"more": "más"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuevo Mensaje", "new_message": "Nuevo Mensaje",
@@ -753,6 +764,12 @@
"preview": "Mostrar vista previa cuando sea posible", "preview": "Mostrar vista previa cuando sea posible",
"download": "Descargar inmediatamente" "download": "Descargar inmediatamente"
}, },
"attachment_position": {
"label": "Posición del adjunto",
"description": "Dónde mostrar los adjuntos en el encabezado del correo",
"beside-sender": "Junto al remitente",
"below-header": "Debajo del encabezado"
},
"emails_per_page": { "emails_per_page": {
"25": "25 correos", "25": "25 correos",
"50": "50 correos", "50": "50 correos",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Cuenta", "title": "Cuenta",
"description": "Vea la información de su 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": { "email": {
"label": "Dirección de Correo", "label": "Dirección de Correo",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Contactos", "title": "Contactos",
"search_placeholder": "Buscar contactos...", "search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto", "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": "No hay contactos",
"empty_state_title": "Sin contactos", "empty_state_title": "Sin contactos",
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", "empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
@@ -1605,7 +1628,8 @@
"level_low": "Bajo", "level_low": "Bajo",
"categories": "Categorías", "categories": "Categorías",
"categories_placeholder": "p. ej., Familia, Amigos, Colegas", "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": "Notas",
"note_placeholder": "Agregar una nota...", "note_placeholder": "Agregar una nota...",
"gender": "Género", "gender": "Género",
@@ -1947,6 +1971,12 @@
"deleted": "Suscripción eliminada", "deleted": "Suscripción eliminada",
"delete_error": "No se pudo eliminar la suscripción", "delete_error": "No se pudo eliminar la suscripción",
"last_refreshed": "Última actualización: {time}" "last_refreshed": "Última actualización: {time}"
},
"tasks": {
"no_tasks": "Sin tareas",
"no_title": "(Sin título)",
"mark_complete": "Marcar como completada",
"mark_incomplete": "Marcar como incompleta"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Personaliza tu experiencia en Ajustes", "tip_settings": "Personaliza tu experiencia en Ajustes",
"got_it": "Entendido", "got_it": "Entendido",
"settings": "Ajustes", "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": { "files": {
"title": "Archivos", "title": "Archivos",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado", "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", "export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
"incorrect_passphrase": "Contraseña incorrecta" "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."
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Site web", "website": "Site web",
"imprint": "Mentions légales", "imprint": "Mentions légales",
"privacy_policy": "Politique de confidentialité", "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_completing": "Connexion en cours...",
"oauth_error": { "oauth_error": {
"title": "Échec de l'authentification", "title": "Échec de l'authentification",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Effacer la recherche", "clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé", "vacation_active": "Répondeur d'absence activé",
"demo_banner": "Mode démo",
"demo_reset": "Réinitialiser",
"demo_tour": "Visite",
"tags": "Étiquettes", "tags": "Étiquettes",
"mail": "Messagerie", "mail": "Messagerie",
"nav_label": "Navigation", "nav_label": "Navigation",
@@ -210,6 +219,7 @@
"attachments": "Pièces jointes", "attachments": "Pièces jointes",
"important": "Important", "important": "Important",
"download": "Télécharger", "download": "Télécharger",
"download_all": "Tout télécharger",
"from": "De", "from": "De",
"to": "À", "to": "À",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Précédent", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
"send": "Envoyer" "send": "Envoyer",
"more": "plus"
}, },
"email_composer": { "email_composer": {
"new_message": "Nouveau message", "new_message": "Nouveau message",
@@ -753,6 +764,12 @@
"preview": "Aperçu si possible", "preview": "Aperçu si possible",
"download": "Télécharger immédiatement" "download": "Télécharger immédiatement"
}, },
"attachment_position": {
"label": "Position des pièces jointes",
"description": "Où afficher les pièces jointes dans l'en-tête de l'email",
"beside-sender": "À côté de l'expéditeur",
"below-header": "Sous l'en-tête"
},
"emails_per_page": { "emails_per_page": {
"25": "25 emails", "25": "25 emails",
"50": "50 emails", "50": "50 emails",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Compte", "title": "Compte",
"description": "Consultez les informations de votre 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": { "email": {
"label": "Adresse email", "label": "Adresse email",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Contacts", "title": "Contacts",
"search_placeholder": "Rechercher des contacts...", "search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact", "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": "Aucun contact",
"empty_state_title": "Aucun contact", "empty_state_title": "Aucun contact",
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
@@ -1605,7 +1628,8 @@
"level_low": "Faible", "level_low": "Faible",
"categories": "Catégories", "categories": "Catégories",
"categories_placeholder": "p. ex., Famille, Amis, Collègues", "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": "Notes",
"note_placeholder": "Ajouter une note...", "note_placeholder": "Ajouter une note...",
"gender": "Genre", "gender": "Genre",
@@ -1947,6 +1971,12 @@
"deleted": "Abonnement supprimé", "deleted": "Abonnement supprimé",
"delete_error": "Impossible de supprimer l'abonnement", "delete_error": "Impossible de supprimer l'abonnement",
"last_refreshed": "Dernière mise à jour : {time}" "last_refreshed": "Dernière mise à jour : {time}"
},
"tasks": {
"no_tasks": "Aucune tâche",
"no_title": "(Sans titre)",
"mark_complete": "Marquer comme terminée",
"mark_incomplete": "Marquer comme non terminée"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Personnalisez votre experience dans les Parametres", "tip_settings": "Personnalisez votre experience dans les Parametres",
"got_it": "Compris", "got_it": "Compris",
"settings": "Paramètres", "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": { "files": {
"title": "Fichiers", "title": "Fichiers",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté", "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", "export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
"incorrect_passphrase": "Phrase secrète incorrecte" "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."
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Sito web", "website": "Sito web",
"imprint": "Note legali", "imprint": "Note legali",
"privacy_policy": "Informativa sulla privacy", "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_completing": "Completamento dell'accesso...",
"oauth_error": { "oauth_error": {
"title": "Autenticazione non riuscita", "title": "Autenticazione non riuscita",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Cancella ricerca", "clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo", "vacation_active": "Risponditore automatico attivo",
"demo_banner": "Modalità demo",
"demo_reset": "Reimposta",
"demo_tour": "Tour",
"tags": "Etichette", "tags": "Etichette",
"mail": "Posta", "mail": "Posta",
"nav_label": "Navigazione", "nav_label": "Navigazione",
@@ -210,6 +219,7 @@
"attachments": "Allegati", "attachments": "Allegati",
"important": "Importante", "important": "Importante",
"download": "Scarica", "download": "Scarica",
"download_all": "Scarica tutto",
"from": "Da", "from": "Da",
"to": "A", "to": "A",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
"send": "Invia" "send": "Invia",
"more": "altri"
}, },
"email_composer": { "email_composer": {
"new_message": "Nuovo messaggio", "new_message": "Nuovo messaggio",
@@ -753,6 +764,12 @@
"preview": "Anteprima quando possibile", "preview": "Anteprima quando possibile",
"download": "Scarica immediatamente" "download": "Scarica immediatamente"
}, },
"attachment_position": {
"label": "Posizione degli allegati",
"description": "Dove visualizzare gli allegati nell'intestazione dell'email",
"beside-sender": "Accanto al mittente",
"below-header": "Sotto l'intestazione"
},
"emails_per_page": { "emails_per_page": {
"25": "25 messaggi", "25": "25 messaggi",
"50": "50 messaggi", "50": "50 messaggi",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Account", "title": "Account",
"description": "Visualizza le informazioni del tuo account", "description": "Visualizza le informazioni del tuo account",
"name_label": "Nome visualizzato",
"account_type_label": "Tipo di account",
"demo_account": "Account dimostrativo",
"email": { "email": {
"label": "Indirizzo email", "label": "Indirizzo email",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Contatti", "title": "Contatti",
"search_placeholder": "Cerca contatti...", "search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto", "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": "Nessun contatto",
"empty_state_title": "Nessun contatto", "empty_state_title": "Nessun contatto",
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
@@ -1605,7 +1628,8 @@
"level_low": "Basso", "level_low": "Basso",
"categories": "Categorie", "categories": "Categorie",
"categories_placeholder": "es., Famiglia, Amici, Colleghi", "categories_placeholder": "es., Famiglia, Amici, Colleghi",
"categories_hint": "Separare con virgole", "categories_hint": "Digita per cercare o aggiungere",
"category_add": "Aggiungi",
"note": "Note", "note": "Note",
"note_placeholder": "Aggiungi una nota...", "note_placeholder": "Aggiungi una nota...",
"gender": "Genere", "gender": "Genere",
@@ -1947,6 +1971,12 @@
"deleted": "Abbonamento rimosso", "deleted": "Abbonamento rimosso",
"delete_error": "Impossibile rimuovere l'abbonamento", "delete_error": "Impossibile rimuovere l'abbonamento",
"last_refreshed": "Ultimo aggiornamento: {time}" "last_refreshed": "Ultimo aggiornamento: {time}"
},
"tasks": {
"no_tasks": "Nessuna attività",
"no_title": "(Senza titolo)",
"mark_complete": "Segna come completata",
"mark_incomplete": "Segna come non completata"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni", "tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
"got_it": "Ho capito", "got_it": "Ho capito",
"settings": "Impostazioni", "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": { "files": {
"title": "File", "title": "File",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato", "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", "export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
"incorrect_passphrase": "Passphrase errata" "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."
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "ウェブサイト", "website": "ウェブサイト",
"imprint": "サイト運営者情報", "imprint": "サイト運営者情報",
"privacy_policy": "プライバシーポリシー", "privacy_policy": "プライバシーポリシー",
"try_demo": "デモを試す",
"demo_description": "サンプルデータで探索 — アカウント不要",
"demo_launching": "デモを起動中...",
"demo_login_button": "デモを開始",
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
"oauth_completing": "サインイン処理中...", "oauth_completing": "サインイン処理中...",
"oauth_error": { "oauth_error": {
"title": "認証に失敗しました", "title": "認証に失敗しました",
@@ -104,6 +110,9 @@
}, },
"clear_search": "検索をクリア", "clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です", "vacation_active": "不在応答が有効です",
"demo_banner": "デモモード",
"demo_reset": "リセット",
"demo_tour": "ツアー",
"tags": "タグ", "tags": "タグ",
"mail": "メール", "mail": "メール",
"nav_label": "ナビゲーション", "nav_label": "ナビゲーション",
@@ -210,6 +219,7 @@
"attachments": "添付ファイル", "attachments": "添付ファイル",
"important": "重要", "important": "重要",
"download": "ダウンロード", "download": "ダウンロード",
"download_all": "すべてダウンロード",
"from": "送信者", "from": "送信者",
"to": "宛先", "to": "宛先",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "前へ", "previous": "前へ",
"next": "次へ", "next": "次へ",
"send": "送信" "send": "送信",
"more": "他"
}, },
"email_composer": { "email_composer": {
"new_message": "新規メッセージ", "new_message": "新規メッセージ",
@@ -753,6 +764,12 @@
"preview": "可能ならプレビューを開く", "preview": "可能ならプレビューを開く",
"download": "すぐにダウンロード" "download": "すぐにダウンロード"
}, },
"attachment_position": {
"label": "添付ファイルの位置",
"description": "メールヘッダー内での添付ファイルの表示位置",
"beside-sender": "送信者の横",
"below-header": "ヘッダーの下"
},
"emails_per_page": { "emails_per_page": {
"25": "25件", "25": "25件",
"50": "50件", "50": "50件",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "アカウント", "title": "アカウント",
"description": "アカウント情報を表示", "description": "アカウント情報を表示",
"name_label": "表示名",
"account_type_label": "アカウントタイプ",
"demo_account": "デモアカウント",
"email": { "email": {
"label": "メールアドレス", "label": "メールアドレス",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "連絡先", "title": "連絡先",
"search_placeholder": "連絡先を検索...", "search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先", "create_new": "新しい連絡先",
"no_category": "カテゴリなし",
"category_added": "{name} に連絡先を追加しました",
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
"empty_state": "連絡先がありません", "empty_state": "連絡先がありません",
"empty_state_title": "連絡先がありません", "empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
@@ -1605,7 +1628,8 @@
"level_low": "低", "level_low": "低",
"categories": "カテゴリー", "categories": "カテゴリー",
"categories_placeholder": "例:家族、友人、同僚", "categories_placeholder": "例:家族、友人、同僚",
"categories_hint": "カンマで区切ってください", "categories_hint": "検索または追加するには入力",
"category_add": "追加",
"note": "メモ", "note": "メモ",
"note_placeholder": "メモを追加...", "note_placeholder": "メモを追加...",
"gender": "性別", "gender": "性別",
@@ -1947,6 +1971,12 @@
"deleted": "購読を解除しました", "deleted": "購読を解除しました",
"delete_error": "購読の解除に失敗しました", "delete_error": "購読の解除に失敗しました",
"last_refreshed": "最終更新: {time}" "last_refreshed": "最終更新: {time}"
},
"tasks": {
"no_tasks": "タスクなし",
"no_title": "(タイトルなし)",
"mark_complete": "完了にする",
"mark_incomplete": "未完了にする"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "設定でカスタマイズできます", "tip_settings": "設定でカスタマイズできます",
"got_it": "了解", "got_it": "了解",
"settings": "設定", "settings": "設定",
"dismiss": "閉じる" "dismiss": "閉じる",
"start_tour": "ツアーを開始"
},
"demo_welcome": {
"title": "Bulwark Mail へようこそ",
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
"feature_email": "メールの読み書き",
"feature_organize": "タグ・スター・フォルダ",
"feature_shortcuts": "キーボードショートカット",
"feature_privacy": "100% プライベートなデモ",
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
}, },
"files": { "files": {
"title": "ファイル", "title": "ファイル",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください", "export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください", "export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
"incorrect_passphrase": "パスフレーズが正しくありません" "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": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Website", "website": "Website",
"imprint": "Colofon", "imprint": "Colofon",
"privacy_policy": "Privacybeleid", "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_completing": "Aanmelding voltooien...",
"oauth_error": { "oauth_error": {
"title": "Authenticatie mislukt", "title": "Authenticatie mislukt",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Zoekopdracht wissen", "clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief", "vacation_active": "Afwezigheidsmelder is actief",
"demo_banner": "Demomodus",
"demo_reset": "Resetten",
"demo_tour": "Rondleiding",
"tags": "Labels", "tags": "Labels",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navigatie", "nav_label": "Navigatie",
@@ -210,6 +219,7 @@
"attachments": "Bijlagen", "attachments": "Bijlagen",
"important": "Belangrijk", "important": "Belangrijk",
"download": "Downloaden", "download": "Downloaden",
"download_all": "Alles downloaden",
"from": "Van", "from": "Van",
"to": "Aan", "to": "Aan",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Vorige", "previous": "Vorige",
"next": "Volgende", "next": "Volgende",
"send": "Verzenden" "send": "Verzenden",
"more": "meer"
}, },
"email_composer": { "email_composer": {
"new_message": "Nieuw bericht", "new_message": "Nieuw bericht",
@@ -753,6 +764,12 @@
"preview": "Voorbeeld tonen indien mogelijk", "preview": "Voorbeeld tonen indien mogelijk",
"download": "Direct downloaden" "download": "Direct downloaden"
}, },
"attachment_position": {
"label": "Positie van bijlagen",
"description": "Waar bijlagen in de e-mailkop worden weergegeven",
"beside-sender": "Naast de afzender",
"below-header": "Onder de kop"
},
"emails_per_page": { "emails_per_page": {
"25": "25 e-mails", "25": "25 e-mails",
"50": "50 e-mails", "50": "50 e-mails",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Account", "title": "Account",
"description": "Bekijk je accountinformatie", "description": "Bekijk je accountinformatie",
"name_label": "Weergavenaam",
"account_type_label": "Accounttype",
"demo_account": "Demoaccount",
"email": { "email": {
"label": "E-mailadres", "label": "E-mailadres",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Contacten", "title": "Contacten",
"search_placeholder": "Contacten zoeken...", "search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact", "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": "Geen contacten",
"empty_state_title": "Geen contacten", "empty_state_title": "Geen contacten",
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
@@ -1605,7 +1628,8 @@
"level_low": "Laag", "level_low": "Laag",
"categories": "Categorieën", "categories": "Categorieën",
"categories_placeholder": "bijv. Familie, Vrienden, Collega's", "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": "Notities",
"note_placeholder": "Notitie toevoegen...", "note_placeholder": "Notitie toevoegen...",
"gender": "Geslacht", "gender": "Geslacht",
@@ -1947,6 +1971,12 @@
"deleted": "Abonnement verwijderd", "deleted": "Abonnement verwijderd",
"delete_error": "Kan abonnement niet verwijderen", "delete_error": "Kan abonnement niet verwijderen",
"last_refreshed": "Laatst bijgewerkt: {time}" "last_refreshed": "Laatst bijgewerkt: {time}"
},
"tasks": {
"no_tasks": "Geen taken",
"no_title": "(Geen titel)",
"mark_complete": "Markeren als voltooid",
"mark_incomplete": "Markeren als onvoltooid"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Pas uw ervaring aan in Instellingen", "tip_settings": "Pas uw ervaring aan in Instellingen",
"got_it": "Begrepen", "got_it": "Begrepen",
"settings": "Instellingen", "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": { "files": {
"title": "Bestanden", "title": "Bestanden",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen", "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", "export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
"incorrect_passphrase": "Onjuiste wachtwoordzin" "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."
} }
} }
+86 -3
View File
@@ -40,6 +40,12 @@
"website": "Site", "website": "Site",
"imprint": "Informações legais", "imprint": "Informações legais",
"privacy_policy": "Política de privacidade", "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_completing": "Concluindo login...",
"oauth_error": { "oauth_error": {
"title": "Falha na autenticação", "title": "Falha na autenticação",
@@ -104,6 +110,9 @@
}, },
"clear_search": "Limpar busca", "clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa", "vacation_active": "Resposta automática ativa",
"demo_banner": "Modo de demonstração",
"demo_reset": "Repor",
"demo_tour": "Tour",
"tags": "Etiquetas", "tags": "Etiquetas",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navegação", "nav_label": "Navegação",
@@ -210,6 +219,7 @@
"attachments": "Anexos", "attachments": "Anexos",
"important": "Importante", "important": "Importante",
"download": "Baixar", "download": "Baixar",
"download_all": "Baixar tudo",
"from": "De", "from": "De",
"to": "Para", "to": "Para",
"cc": "CC", "cc": "CC",
@@ -401,7 +411,8 @@
}, },
"previous": "Anterior", "previous": "Anterior",
"next": "Próximo", "next": "Próximo",
"send": "Enviar" "send": "Enviar",
"more": "mais"
}, },
"email_composer": { "email_composer": {
"new_message": "Nova Mensagem", "new_message": "Nova Mensagem",
@@ -753,6 +764,12 @@
"preview": "Visualizar quando possível", "preview": "Visualizar quando possível",
"download": "Baixar imediatamente" "download": "Baixar imediatamente"
}, },
"attachment_position": {
"label": "Posição dos anexos",
"description": "Onde exibir os anexos no cabeçalho do e-mail",
"beside-sender": "Ao lado do remetente",
"below-header": "Abaixo do cabeçalho"
},
"emails_per_page": { "emails_per_page": {
"25": "25 e-mails", "25": "25 e-mails",
"50": "50 e-mails", "50": "50 e-mails",
@@ -839,6 +856,9 @@
"account": { "account": {
"title": "Conta", "title": "Conta",
"description": "Visualize as informações da sua 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": { "email": {
"label": "Endereço de E-mail", "label": "Endereço de E-mail",
"value": "{email}" "value": "{email}"
@@ -1462,6 +1482,9 @@
"title": "Contatos", "title": "Contatos",
"search_placeholder": "Pesquisar contatos...", "search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato", "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": "Nenhum contato",
"empty_state_title": "Sem contatos", "empty_state_title": "Sem contatos",
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
@@ -1605,7 +1628,8 @@
"level_low": "Baixo", "level_low": "Baixo",
"categories": "Categorias", "categories": "Categorias",
"categories_placeholder": "ex., Família, Amigos, Colegas", "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": "Notas",
"note_placeholder": "Adicionar uma nota...", "note_placeholder": "Adicionar uma nota...",
"gender": "Gênero", "gender": "Gênero",
@@ -1947,6 +1971,12 @@
"deleted": "Assinatura removida", "deleted": "Assinatura removida",
"delete_error": "Falha ao remover assinatura", "delete_error": "Falha ao remover assinatura",
"last_refreshed": "Última atualização: {time}" "last_refreshed": "Última atualização: {time}"
},
"tasks": {
"no_tasks": "Sem tarefas",
"no_title": "(Sem título)",
"mark_complete": "Marcar como concluída",
"mark_incomplete": "Marcar como não concluída"
} }
}, },
"advanced_search": { "advanced_search": {
@@ -1987,7 +2017,17 @@
"tip_settings": "Personalize sua experiência nas Configurações", "tip_settings": "Personalize sua experiência nas Configurações",
"got_it": "Entendi", "got_it": "Entendi",
"settings": "Configurações", "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": { "files": {
"title": "Ficheiros", "title": "Ficheiros",
@@ -2167,5 +2207,48 @@
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado", "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", "export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
"incorrect_passphrase": "Frase secreta incorreta" "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."
} }
} }
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.3", "version": "1.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.3", "version": "1.4.4",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
@@ -5480,9 +5480,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.4", "version": "1.4.6",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -77,6 +77,7 @@
}, },
"overrides": { "overrides": {
"elliptic": "^6.6.1", "elliptic": "^6.6.1",
"flatted": "^3.4.2",
"undici": "^7.24.0" "undici": "^7.24.0"
} }
} }
+232 -37
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client'; import { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store'; import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store'; import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store'; import { useVacationStore } from './vacation-store';
@@ -21,7 +22,7 @@ interface AuthState {
error: string | null; error: string | null;
serverUrl: string | null; serverUrl: string | null;
username: string | null; username: string | null;
client: JMAPClient | null; client: IJMAPClient | null;
identities: Identity[]; identities: Identity[];
primaryIdentity: Identity | null; primaryIdentity: Identity | null;
authMode: 'basic' | 'oauth'; authMode: 'basic' | 'oauth';
@@ -30,11 +31,13 @@ interface AuthState {
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
connectionLost: boolean; connectionLost: boolean;
activeAccountId: string | null; activeAccountId: string | null;
isDemoMode: boolean;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<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>; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
loginDemo: () => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>; refreshAccessToken: () => Promise<string | null>;
logout: () => void; logout: () => Promise<void>;
logoutAll: () => void; logoutAll: () => void;
switchAccount: (accountId: string) => Promise<void>; switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
@@ -140,7 +143,7 @@ function markSessionExpired(): void {
saveRedirectAfterLogin(); saveRedirectAfterLogin();
} }
function initializeFeatureStores(client: JMAPClient): void { function initializeFeatureStores(client: IJMAPClient): void {
if (client.supportsContacts()) { if (client.supportsContacts()) {
const contactStore = useContactStore.getState(); const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true); contactStore.setSupportsSync(true);
@@ -242,6 +245,7 @@ export const useAuthStore = create<AuthState>()(
tokenExpiresAt: null, tokenExpiresAt: null,
connectionLost: false, connectionLost: false,
activeAccountId: null, activeAccountId: null,
isDemoMode: false,
login: async (serverUrl, username, password, totp, rememberMe) => { login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password; const effectivePassword = totp ? `${password}$${totp}` : password;
@@ -264,10 +268,12 @@ export const useAuthStore = create<AuthState>()(
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot()) ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
: accountStore.getNextCookieSlot(); : accountStore.getNextCookieSlot();
// Snapshot current account if switching away // Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId; const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) { if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId); snapshotAccount(prevAccountId);
clearAllStores();
} }
// Store client in multi-account map // Store client in multi-account map
@@ -353,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) => { loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
@@ -390,10 +458,12 @@ export const useAuthStore = create<AuthState>()(
// Register in account store // Register in account store
const accountId = generateAccountId(username, serverUrl); const accountId = generateAccountId(username, serverUrl);
// Snapshot current account if switching away // Snapshot current account if switching away and clear stores so
// the new account starts with a clean email/contact/calendar state.
const prevAccountId = get().activeAccountId; const prevAccountId = get().activeAccountId;
if (prevAccountId && prevAccountId !== accountId) { if (prevAccountId && prevAccountId !== accountId) {
snapshotAccount(prevAccountId); snapshotAccount(prevAccountId);
clearAllStores();
} }
clients.set(accountId, client); clients.set(accountId, client);
@@ -506,15 +576,47 @@ export const useAuthStore = create<AuthState>()(
return promise; return promise;
}, },
logout: () => { logout: async () => {
const state = get(); const state = get();
const wasDemoMode = state.isDemoMode;
const wasOAuth = state.authMode === 'oauth'; const wasOAuth = state.authMode === 'oauth';
const accountId = state.activeAccountId; const accountId = state.activeAccountId;
const accountStore = useAccountStore.getState(); const accountStore = useAccountStore.getState();
const account = accountId ? accountStore.getAccountById(accountId) : null; const account = accountId ? accountStore.getAccountById(accountId) : null;
const slot = account?.cookieSlot ?? 0; 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); clearRefreshTimer(accountId ?? undefined);
// Null out the client BEFORE disconnecting so the page doesn't fire
// data-loading effects with the stale disconnected client while
// stores are being cleared.
set({ client: null });
state.client?.disconnect(); state.client?.disconnect();
// Remove client from multi-account map // Remove client from multi-account map
@@ -536,11 +638,56 @@ export const useAuthStore = create<AuthState>()(
clearAllStores(); clearAllStores();
// Restore next account // Restore next account
const nextClient = clients.get(nextAccount.id); let nextClient = clients.get(nextAccount.id);
// If the client isn't in memory, try to restore it from the session
if (!nextClient) {
try {
if (nextAccount.authMode === 'oauth') {
const res = await fetch(`/api/auth/token?slot=${nextAccount.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
nextClient = JMAPClient.withBearer(nextAccount.serverUrl, access_token, nextAccount.username, () => refreshFn());
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
scheduleRefresh(expires_in, get().refreshAccessToken, nextAccount.id);
}
} else if (nextAccount.authMode === 'basic' && nextAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${nextAccount.cookieSlot}`);
if (res.ok) {
const { serverUrl: sUrl, username: uName, password: pwd } = await res.json();
nextClient = new JMAPClient(sUrl, uName, pwd);
nextClient.onConnectionChange((connected) => {
if (get().activeAccountId === nextAccount.id) {
set({ connectionLost: !connected });
}
accountStore.updateAccount(nextAccount.id, { isConnected: connected });
});
await nextClient.connect();
clients.set(nextAccount.id, nextClient);
}
}
} catch (err) {
debug.error(`Failed to restore next account ${nextAccount.id} during logout:`, err);
nextClient = undefined;
}
}
if (nextClient) { if (nextClient) {
const restored = restoreAccount(nextAccount.id); const restored = restoreAccount(nextAccount.id);
accountStore.setActiveAccount(nextAccount.id); accountStore.setActiveAccount(nextAccount.id);
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({ set({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
@@ -552,6 +699,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false, connectionLost: false,
error: null, error: null,
activeAccountId: nextAccount.id, activeAccountId: nextAccount.id,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
}); });
if (!restored) { if (!restored) {
@@ -560,13 +709,32 @@ export const useAuthStore = create<AuthState>()(
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username); const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
set({ identities, primaryIdentity }); set({ identities, primaryIdentity });
}).catch((err) => debug.error('Failed to load identities after switch:', err)); }).catch((err) => debug.error('Failed to load identities after switch:', err));
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
} }
} else {
// Could not restore the next account — remove it and do a full logout
debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`);
evictAccount(nextAccount.id);
accountStore.removeAccount(nextAccount.id);
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,
});
localStorage.removeItem('auth-storage');
clearAllStores();
redirectToLogin();
} }
} else { } else {
// No accounts remaining — full logout // No accounts remaining — full logout
@@ -691,7 +859,9 @@ export const useAuthStore = create<AuthState>()(
const targetAccount = accountStore.getAccountById(accountId); const targetAccount = accountStore.getAccountById(accountId);
if (!targetAccount) return; if (!targetAccount) return;
set({ isLoading: true }); // Null out the client immediately so the page doesn't fire data-loading
// effects with the old client while stores are being cleared.
set({ isLoading: true, client: null });
// Snapshot current account // Snapshot current account
if (state.activeAccountId) { if (state.activeAccountId) {
@@ -741,23 +911,39 @@ export const useAuthStore = create<AuthState>()(
} }
} catch (err) { } catch (err) {
debug.error(`Failed to restore client for ${accountId}:`, err); debug.error(`Failed to restore client for ${accountId}:`, err);
accountStore.updateAccount(accountId, {
isConnected: false,
hasError: true,
errorMessage: err instanceof Error ? err.message : 'Connection failed',
});
set({ isLoading: false });
return;
} }
} }
if (!targetClient) { if (!targetClient) {
accountStore.updateAccount(accountId, { // Cannot restore — remove the stale account and redirect to login
isConnected: false, evictAccount(accountId);
hasError: true, accountStore.removeAccount(accountId);
errorMessage: 'Unable to restore session', fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
// Restore the previous account if still available
if (state.activeAccountId && state.activeAccountId !== accountId) {
const prevClient = clients.get(state.activeAccountId);
const prevAccount = accountStore.getAccountById(state.activeAccountId);
if (prevClient && prevAccount) {
restoreAccount(state.activeAccountId);
accountStore.setActiveAccount(state.activeAccountId);
set({
isLoading: false,
serverUrl: prevAccount.serverUrl,
username: prevAccount.username,
client: prevClient,
authMode: prevAccount.authMode,
rememberMe: prevAccount.rememberMe,
connectionLost: false,
activeAccountId: state.activeAccountId,
}); });
return;
}
}
set({ isLoading: false }); set({ isLoading: false });
// Redirect to login so the user can re-authenticate
replaceWindowLocation(getLocaleLoginPath());
return; return;
} }
@@ -766,6 +952,10 @@ export const useAuthStore = create<AuthState>()(
accountStore.setActiveAccount(accountId); accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined }); accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
// Build identity state up front so the name updates atomically
const restoredIdentities = restored ? useIdentityStore.getState().identities : [];
const restoredPrimary = restoredIdentities[0] ?? null;
set({ set({
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
@@ -777,6 +967,8 @@ export const useAuthStore = create<AuthState>()(
connectionLost: false, connectionLost: false,
error: null, error: null,
activeAccountId: accountId, activeAccountId: accountId,
identities: restoredIdentities,
primaryIdentity: restoredPrimary,
}); });
if (!restored) { if (!restored) {
@@ -788,12 +980,6 @@ export const useAuthStore = create<AuthState>()(
} catch (err) { } catch (err) {
debug.error(`Failed to load data for ${accountId}:`, err); debug.error(`Failed to load data for ${accountId}:`, err);
} }
} else {
const identityState = useIdentityStore.getState();
set({
identities: identityState.identities,
primaryIdentity: identityState.identities[0] ?? null,
});
} }
// Sync settings // Sync settings
@@ -809,9 +995,18 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState(); const accountStore = useAccountStore.getState();
const accounts = accountStore.accounts; 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 // Multi-account restoration: restore all registered accounts
if (accounts.length > 0) { if (accounts.length > 0) {
set({ isLoading: true }); // Null out client so the page doesn't fire data-loading effects
// with a stale client reference while we're restoring accounts.
set({ isLoading: true, client: null });
// Determine which account to activate first // Determine which account to activate first
const defaultAccount = accountStore.getDefaultAccount(); const defaultAccount = accountStore.getDefaultAccount();
@@ -865,11 +1060,11 @@ export const useAuthStore = create<AuthState>()(
} }
} catch (err) { } catch (err) {
debug.error(`Failed to restore account ${account.id}:`, err); debug.error(`Failed to restore account ${account.id}:`, err);
accountStore.updateAccount(account.id, { // Remove unrestorable accounts so the user is prompted to log in
isConnected: false, // again rather than seeing a stale error entry forever.
hasError: true, evictAccount(account.id);
errorMessage: err instanceof Error ? err.message : 'Restore failed', accountStore.removeAccount(account.id);
}); fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'DELETE' }).catch(() => {});
} }
} }
+20 -17
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; 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 type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { debug } from '@/lib/debug'; import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { normalizeAllDayDuration } from '@/lib/calendar-utils';
@@ -37,17 +37,17 @@ interface CalendarStore {
dateRange: { start: string; end: string } | null; dateRange: { start: string; end: string } | null;
setSupported: (supported: boolean) => void; setSupported: (supported: boolean) => void;
fetchCalendars: (client: JMAPClient) => Promise<void>; fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>; fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>; createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>; updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>; deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>; rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>; importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>; updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>; createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>; removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>; clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
setSelectedDate: (date: Date) => void; setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void; setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void; toggleCalendarVisibility: (calendarId: string) => void;
@@ -56,10 +56,10 @@ interface CalendarStore {
// iCal subscriptions // iCal subscriptions
icalSubscriptions: ICalSubscription[]; icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>; addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>; refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>; refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
isSubscriptionCalendar: (calendarId: string) => boolean; isSubscriptionCalendar: (calendarId: string) => boolean;
} }
@@ -110,10 +110,12 @@ export const useCalendarStore = create<CalendarStore>()(
fetchEvents: async (client, start, end) => { fetchEvents: async (client, start, end) => {
set({ isLoadingEvents: true, error: null }); set({ isLoadingEvents: true, error: null });
try { try {
const events = await client.queryAllCalendarEvents({ const rawEvents = await client.queryAllCalendarEvents({
after: start, after: start,
before: end, before: end,
}); });
// Filter out malformed events missing required 'start' field
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
set({ events, isLoadingEvents: false, dateRange: { start, end } }); set({ events, isLoadingEvents: false, dateRange: { start, end } });
} catch (error) { } catch (error) {
debug.error('Failed to fetch events:', error); debug.error('Failed to fetch events:', error);
@@ -655,6 +657,7 @@ export const useCalendarStore = create<CalendarStore>()(
return { return {
...mergedState, ...mergedState,
selectedDate: new Date(),
viewMode: getSafeCalendarViewMode(mergedState.viewMode), viewMode: getSafeCalendarViewMode(mergedState.viewMode),
}; };
}, },
+15 -15
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types'; 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 { export function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) { if (contact.name?.components) {
@@ -47,11 +47,11 @@ interface ContactStore {
lastSelectedContactId: string | null; lastSelectedContactId: string | null;
activeTab: 'all' | 'groups'; activeTab: 'all' | 'groups';
fetchContacts: (client: JMAPClient) => Promise<void>; fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>; fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>; createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>; updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: JMAPClient, id: string) => Promise<void>; deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
addLocalContact: (contact: ContactCard) => void; addLocalContact: (contact: ContactCard) => void;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void; updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
@@ -68,21 +68,21 @@ interface ContactStore {
getGroups: () => ContactCard[]; getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[]; getIndividuals: () => ContactCard[];
getGroupMembers: (groupId: string) => ContactCard[]; getGroupMembers: (groupId: string) => ContactCard[];
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>; createGroup: (client: IJMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>; updateGroup: (client: IJMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>; addMembersToGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>; removeMembersFromGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>; deleteGroup: (client: IJMAPClient | null, groupId: string) => Promise<void>;
toggleContactSelection: (id: string) => void; toggleContactSelection: (id: string) => void;
selectRangeContacts: (targetId: string, sortedIds: string[]) => void; selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
selectAllContacts: (ids: string[]) => void; selectAllContacts: (ids: string[]) => void;
clearSelection: () => void; clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>; bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>; bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => 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>()( export const useContactStore = create<ContactStore>()(
+36 -34
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types"; 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 { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
@@ -48,7 +48,7 @@ interface EmailStore {
setSearchQuery: (query: string) => void; setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void; setQuota: (quota: { used: number; total: number } | null) => void;
selectKeyword: (keyword: string | null) => void; selectKeyword: (keyword: string | null) => void;
fetchTagCounts: (client: JMAPClient) => Promise<void>; fetchTagCounts: (client: IJMAPClient) => Promise<void>;
toggleEmailSelection: (emailId: string) => void; toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void; selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null; lastSelectedEmailId: string | null;
@@ -56,54 +56,54 @@ interface EmailStore {
clearSelection: () => void; clearSelection: () => void;
// JMAP operations // JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>; fetchMailboxes: (client: IJMAPClient) => Promise<void>;
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>; fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: JMAPClient) => Promise<void>; loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>; fetchQuota: (client: IJMAPClient) => 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>; 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: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>; searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: JMAPClient) => Promise<void>; advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void; setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void; clearSearchFilters: () => void;
toggleAdvancedSearch: () => void; toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>; toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
// Batch operations // Batch operations
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>; batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: JMAPClient) => Promise<void>; batchDelete: (client: IJMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>; batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Spam operations // Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>; spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>; markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>; undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>; batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>; batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers // Push notification handlers
setPushConnected: (connected: boolean) => void; setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>; handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>; refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void; handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void; clearNewEmailNotification: () => void;
// Thread expansion actions // Thread expansion actions
toggleThreadExpansion: (threadId: string) => void; toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>; fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void; collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void; updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mailbox management // Mailbox management
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>; createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>; renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>; deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>; setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>; emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Mock data for demo // Mock data for demo
loadMockData: () => void; loadMockData: () => void;
@@ -239,15 +239,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try { try {
const mailboxes = await client.getAllMailboxes(); const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected // Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox; const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) { const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts) // Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) { if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false }); set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else { } else {
set({ mailboxes, isLoading: false }); set({ mailboxes, selectedMailbox: '', isLoading: false });
} }
} else { } else {
set({ mailboxes, isLoading: false }); set({ mailboxes, isLoading: false });
@@ -1031,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(); const { mailboxes, selectedMailbox } = get();
// Find inbox (batch operations don't preserve original mailboxes) // Find inbox (batch operations don't preserve original mailboxes)
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; 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'; import type { FileNode } from '@/lib/jmap/types';
export interface FileResource { export interface FileResource {
@@ -47,7 +47,7 @@ interface FileState {
supportsFiles: boolean | null; supportsFiles: boolean | null;
selectedResources: Set<string>; selectedResources: Set<string>;
uploadProgress: UploadProgress | null; uploadProgress: UploadProgress | null;
client: JMAPClient | null; client: IJMAPClient | null;
clipboard: ClipboardState | null; clipboard: ClipboardState | null;
uploadAbortController: AbortController | null; uploadAbortController: AbortController | null;
favorites: string[]; favorites: string[];
@@ -55,7 +55,7 @@ interface FileState {
lastAction: UndoAction | null; lastAction: UndoAction | null;
// Actions // Actions
initClient: (client: JMAPClient) => void; initClient: (client: IJMAPClient) => void;
checkSupport: () => Promise<boolean>; checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>; navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: 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 []; } try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(), })(),
initClient: (client: JMAPClient) => { initClient: (client: IJMAPClient) => {
set({ client }); set({ client });
}, },
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; 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 type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { parseScript } from '@/lib/sieve/parser'; import { parseScript } from '@/lib/sieve/parser';
import { generateScript } from '@/lib/sieve/generator'; import { generateScript } from '@/lib/sieve/generator';
@@ -17,9 +17,9 @@ interface FilterStore {
rawScript: string; rawScript: string;
setSupported: (supported: boolean) => void; setSupported: (supported: boolean) => void;
fetchFilters: (client: JMAPClient) => Promise<void>; fetchFilters: (client: IJMAPClient) => Promise<void>;
saveFilters: (client: JMAPClient) => Promise<void>; saveFilters: (client: IJMAPClient) => Promise<void>;
validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>; validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void; addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void; updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
deleteRule: (ruleId: string) => void; deleteRule: (ruleId: string) => void;
+35
View File
@@ -29,9 +29,21 @@ export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download'; export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject'; export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month'; 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 { export interface KeywordDefinition {
id: string; // Used as JMAP keyword suffix: $label:<id> id: string; // Used as JMAP keyword suffix: $label:<id>
label: string; // Display name label: string; // Display name
@@ -44,6 +56,7 @@ export interface SidebarApp {
url: string; url: string;
icon: string; // Lucide icon name (e.g. 'Globe', 'Rss') icon: string; // Lucide icon name (e.g. 'Globe', 'Rss')
openMode: 'tab' | 'inline'; // Open in new tab or embed inline openMode: 'tab' | 'inline'; // Open in new tab or embed inline
showOnMobile: boolean;
} }
// Available color palette for keywords // Available color palette for keywords
@@ -92,8 +105,10 @@ interface SettingsState {
emailsPerPage: number; emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction; mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition;
emailAlwaysLightMode: boolean; // Always render email content in light mode emailAlwaysLightMode: boolean; // Always render email content in light mode
archiveMode: ArchiveMode; // How to organize archived emails: single folder, by year, or by year+month 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 // Composer
autoSaveDraftInterval: number; // milliseconds autoSaveDraftInterval: number; // milliseconds
@@ -104,6 +119,9 @@ interface SettingsState {
sessionTimeout: number; // minutes (0 = never) sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content trustedSenders: string[]; // Email addresses that can load external content
// Calendar
showTimeInMonthView: boolean;
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean; calendarNotificationSound: boolean;
@@ -151,6 +169,7 @@ interface SettingsState {
// Keywords // Keywords
addKeyword: (keyword: KeywordDefinition) => void; addKeyword: (keyword: KeywordDefinition) => void;
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void; updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void;
renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => void;
removeKeyword: (id: string) => void; removeKeyword: (id: string) => void;
reorderKeywords: (keywords: KeywordDefinition[]) => void; reorderKeywords: (keywords: KeywordDefinition[]) => void;
getKeywordById: (id: string) => KeywordDefinition | undefined; getKeywordById: (id: string) => KeywordDefinition | undefined;
@@ -186,8 +205,10 @@ const DEFAULT_SETTINGS = {
emailsPerPage: 50, emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy, externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction, mailAttachmentAction: 'preview' as MailAttachmentAction,
attachmentPosition: 'beside-sender' as AttachmentPosition,
emailAlwaysLightMode: false, emailAlwaysLightMode: false,
archiveMode: 'single' as ArchiveMode, archiveMode: 'single' as ArchiveMode,
hoverActions: ['delete', 'star', 'markRead', 'archive'] as HoverAction[],
// Composer // Composer
autoSaveDraftInterval: 60000, // 1 minute autoSaveDraftInterval: 60000, // 1 minute
@@ -198,6 +219,9 @@ const DEFAULT_SETTINGS = {
sessionTimeout: 0, // Never sessionTimeout: 0, // Never
trustedSenders: [] as string[], trustedSenders: [] as string[],
// Calendar
showTimeInMonthView: false,
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true, calendarNotificationSound: true,
@@ -271,7 +295,9 @@ export const useSettingsStore = create<SettingsState>()(
emailsPerPage: state.emailsPerPage, emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy, externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction, mailAttachmentAction: state.mailAttachmentAction,
attachmentPosition: state.attachmentPosition,
archiveMode: state.archiveMode, archiveMode: state.archiveMode,
hoverActions: state.hoverActions,
trustedSenders: state.trustedSenders, trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval, autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation, sendConfirmation: state.sendConfirmation,
@@ -280,6 +306,7 @@ export const useSettingsStore = create<SettingsState>()(
calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound, calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
showTimeInMonthView: state.showTimeInMonthView,
toolbarPosition: state.toolbarPosition, toolbarPosition: state.toolbarPosition,
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons, folderIcons: state.folderIcons,
@@ -377,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) => { removeKeyword: (id: string) => {
set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) }); set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) });
}, },
+3 -3
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client'; import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface VacationStore { interface VacationStore {
isEnabled: boolean; isEnabled: boolean;
@@ -13,8 +13,8 @@ interface VacationStore {
error: string | null; error: string | null;
isSupported: boolean; isSupported: boolean;
fetchVacationResponse: (client: JMAPClient) => Promise<void>; fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
updateVacationResponse: (client: JMAPClient, updates: { updateVacationResponse: (client: IJMAPClient, updates: {
isEnabled?: boolean; isEnabled?: boolean;
fromDate?: string | null; fromDate?: string | null;
toDate?: string | null; toDate?: string | null;