Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105194a8b9 | ||
|
|
8dbb538c98 | ||
|
|
e435356c53 | ||
|
|
6f9982540c | ||
|
|
d0d6632b24 | ||
|
|
4b7009dfc2 | ||
|
|
55a408e810 | ||
|
|
d5dddba6df | ||
|
|
d1a0667c79 | ||
|
|
e700e4fd04 | ||
|
|
cf993c1036 | ||
|
|
5fdf226ebe | ||
|
|
fae15f073e | ||
|
|
c646c87030 | ||
|
|
b4a76bc4d1 | ||
|
|
dfe886636b | ||
|
|
f499e87d2a | ||
|
|
32fe871b70 | ||
|
|
aab19379e2 | ||
|
|
b46a1a69e8 | ||
|
|
ea424cad7e | ||
|
|
3f444a8912 | ||
|
|
8b0e2052cf | ||
|
|
c99934a92c | ||
|
|
ce2731cd9d | ||
|
|
f9f8af2f11 | ||
|
|
d8e2a10806 |
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.6 (2026-05-15)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285)
|
||||
- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288)
|
||||
- **Compose**: Raise HTML identity signature length cap to 50,000 characters
|
||||
- **Compose**: Allow `<img>` tags in HTML identity signatures for inline logos and banners
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291)
|
||||
- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284)
|
||||
- **UI**: Standardize punctuation in tooltips and inline comments across locales
|
||||
|
||||
### i18n
|
||||
|
||||
- Add Danish localization
|
||||
- Clean up Danish locale wiring and sort the language picker alphabetically (#286)
|
||||
|
||||
## 1.6.5 (2026-05-13)
|
||||
|
||||
### Features
|
||||
|
||||
- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page
|
||||
- **Protocol**: Account picker for protocol links when multiple accounts are connected
|
||||
- **Protocol**: Import-or-subscribe choice for detected webcal calendars
|
||||
- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab
|
||||
- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275)
|
||||
- **Admin**: Honor admin-uploaded favicon in root metadata (#274)
|
||||
- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271)
|
||||
- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265)
|
||||
|
||||
## 1.6.4 (2026-05-11)
|
||||
|
||||
### Web Setup Wizard
|
||||
|
||||
+26
-34
@@ -10,14 +10,17 @@
|
||||
|
||||
# Contributing to Bulwark Webmail
|
||||
|
||||
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
|
||||
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
|
||||
|
||||
## Join our Community
|
||||
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
|
||||
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
## Join the Community
|
||||
|
||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
||||
|
||||
- **Get support** - real-time help with development hurdles
|
||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
||||
- **Collaborate** - meet the team and other contributors
|
||||
|
||||
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
|
||||
|
||||
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl** for internationalization. Please follow these guidelines:
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
|
||||
|
||||
### Key Rules
|
||||
### Rules
|
||||
|
||||
1. **Never hardcode user-facing text** - Always use translations:
|
||||
1. **Never hardcode user-facing text** - always use translations:
|
||||
|
||||
```tsx
|
||||
const t = useTranslations("namespace");
|
||||
return <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
|
||||
|
||||
3. **Namespace organization**:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `settings.*` - Settings page
|
||||
- `login.*` - login page
|
||||
- `sidebar.*` - sidebar navigation
|
||||
- `email_list.*` - email list
|
||||
- `email_viewer.*` - email viewer
|
||||
- `email_composer.*` - composer
|
||||
- `settings.*` - settings page
|
||||
- `notifications.*` - toasts and alerts
|
||||
- `common.*` - shared strings
|
||||
|
||||
4. **Adding new strings**:
|
||||
- Add to **both** English and French translation files
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone
|
||||
4. **Locale-aware navigation**:
|
||||
|
||||
5. **Locale-aware navigation**:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
@@ -203,16 +200,11 @@ webmail/
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
- **Block external content** by default - privacy is the point
|
||||
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve Bulwark Webmail!
|
||||
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@
|
||||
|
||||
## Internationalization
|
||||
|
||||
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,6 +38,7 @@ import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
@@ -56,6 +58,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -68,9 +72,10 @@ function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const isMobile = useIsMobile();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
@@ -96,6 +101,10 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
|
||||
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
@@ -156,10 +165,10 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !supportsCalendar) {
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -167,6 +176,84 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const getWebcalProtocolAccounts = useCallback(() => {
|
||||
const connectedClients = useAuthStore.getState().getAllConnectedClients();
|
||||
return useAccountStore.getState().accounts.filter((account) => {
|
||||
if (!account.isConnected) return false;
|
||||
return connectedClients.get(account.id)?.supportsCalendars() === true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) {
|
||||
await switchAccount(accountId);
|
||||
}
|
||||
setPendingWebcalAccountChoice(null);
|
||||
setPendingSubscription({
|
||||
url: pending.subscriptionUrl,
|
||||
name: pending.suggestedName,
|
||||
});
|
||||
setShowWebcalActionChoice(true);
|
||||
} finally {
|
||||
setIsProtocolAccountSwitching(false);
|
||||
}
|
||||
}, [switchAccount]);
|
||||
|
||||
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
|
||||
const protocolAccounts = getWebcalProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingWebcalAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocolAccounts.length === 0 && !supportsCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
if (accountId) {
|
||||
void openWebcalForAccount(pending, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSubscription({
|
||||
url: pending.subscriptionUrl,
|
||||
name: pending.suggestedName,
|
||||
});
|
||||
setShowWebcalActionChoice(true);
|
||||
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
|
||||
|
||||
const closeWebcalActionChoice = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setPendingSubscription(null);
|
||||
}, []);
|
||||
|
||||
const handleImportWebcal = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setShowImportModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSubscribeWebcal = useCallback(() => {
|
||||
setShowWebcalActionChoice(false);
|
||||
setShowSubscriptionModal(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const openPendingWebcal = () => {
|
||||
const pending = consumePendingWebcal();
|
||||
if (!pending) return;
|
||||
|
||||
handleWebcalProtocolRequest(pending);
|
||||
};
|
||||
|
||||
openPendingWebcal();
|
||||
return subscribeToPendingWebcal(openPendingWebcal);
|
||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
@@ -955,7 +1042,54 @@ export default function CalendarPage() {
|
||||
});
|
||||
}, [events, selectedCalendarIds, visibleEvents]);
|
||||
|
||||
if (!isAuthenticated || !supportsCalendar) return null;
|
||||
const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
|
||||
<ProtocolAccountPicker
|
||||
kind="webcal"
|
||||
operation={pendingWebcalAccountChoice}
|
||||
accounts={getWebcalProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
|
||||
onCancel={() => setPendingWebcalAccountChoice(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={tWebcalAction("title")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||
<span className="text-left">
|
||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
|
||||
const renderView = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
@@ -1378,14 +1512,23 @@ export default function CalendarPage() {
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
initialName={pendingSubscription?.name}
|
||||
onClose={() => {
|
||||
setShowSubscriptionModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1402,6 +1545,8 @@ export default function CalendarPage() {
|
||||
})()}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{renderWebcalAccountPicker()}
|
||||
{renderWebcalActionChoice()}
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
{children}
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
|
||||
+104
-3
@@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||
@@ -60,6 +61,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
|
||||
@@ -74,6 +78,7 @@ export default function Home() {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
@@ -89,8 +94,10 @@ export default function Home() {
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
@@ -308,6 +315,13 @@ export default function Home() {
|
||||
[],
|
||||
);
|
||||
|
||||
const getMailtoProtocolAccounts = useCallback(() => {
|
||||
const connectedClients = useAuthStore.getState().getAllConnectedClients();
|
||||
return useAccountStore.getState().accounts.filter((account) =>
|
||||
account.isConnected && connectedClients.has(account.id)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
@@ -651,6 +665,74 @@ export default function Home() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: ParsedMailto) => {
|
||||
const body = useSettingsStore.getState().plainTextMode
|
||||
? pending.body
|
||||
: plainTextToComposerBody(pending.body);
|
||||
|
||||
if (showComposer) {
|
||||
suppressComposerStateSaveSessionRef.current = composerSessionId;
|
||||
}
|
||||
setComposerSessionId((id) => id + 1);
|
||||
setPendingDraft({
|
||||
to: pending.to.join(", "),
|
||||
cc: pending.cc.join(", "),
|
||||
bcc: pending.bcc.join(", "),
|
||||
subject: pending.subject,
|
||||
body,
|
||||
showCc: pending.cc.length > 0,
|
||||
showBcc: pending.bcc.length > 0,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: "",
|
||||
mode: "compose",
|
||||
draftId: null,
|
||||
});
|
||||
setComposerMode("compose");
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView, showComposer]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) {
|
||||
await switchAccount(accountId);
|
||||
}
|
||||
setPendingMailtoAccountChoice(null);
|
||||
openMailtoDraft(pending);
|
||||
} finally {
|
||||
setIsProtocolAccountSwitching(false);
|
||||
}
|
||||
}, [openMailtoDraft, switchAccount]);
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
if (accountId) {
|
||||
void openMailtoForAccount(pending, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
openMailtoDraft(pending);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const openPendingMailto = () => {
|
||||
const pending = consumePendingMailto();
|
||||
if (pending) handleMailtoProtocolRequest(pending);
|
||||
};
|
||||
|
||||
openPendingMailto();
|
||||
return subscribeToPendingMailto(openPendingMailto);
|
||||
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
||||
|
||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
|
||||
// populates mailboxes before this effect first runs, so on the post-login
|
||||
@@ -1659,7 +1741,9 @@ export default function Home() {
|
||||
|
||||
// Append signature from the sending identity (fall back to primary
|
||||
// when the reply-from lives on the same identity but a different alias).
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity);
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
|
||||
separator: useSettingsStore.getState().signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
|
||||
@@ -2371,7 +2455,13 @@ export default function Home() {
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
onSaveState={(data) => setPendingDraft(data)}
|
||||
onSaveState={(data) => {
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
await handleEmailSend(data);
|
||||
setPendingDraft(null);
|
||||
@@ -2526,6 +2616,17 @@ export default function Home() {
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{pendingMailtoAccountChoice && (
|
||||
<ProtocolAccountPicker
|
||||
kind="mailto"
|
||||
operation={pendingMailtoAccountChoice}
|
||||
accounts={getMailtoProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
|
||||
onCancel={() => setPendingMailtoAccountChoice(null)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
@@ -63,6 +64,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
@@ -98,6 +100,7 @@ type Tab =
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'protocol_handlers'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
@@ -133,6 +136,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
@@ -211,6 +215,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
calendar: ['calendar.settings', 'calendar.management'],
|
||||
contacts: ['settings.contacts', 'contacts'],
|
||||
files: ['settings.files'],
|
||||
protocol_handlers: ['protocol_handlers'],
|
||||
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
@@ -240,6 +245,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
files: 'attachments cloud drive storage upload',
|
||||
protocol_handlers: 'mailto webcal links default app protocol handler',
|
||||
sidebar_apps: 'apps webview iframe',
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
themes: 'custom theme css skin appearance',
|
||||
@@ -560,6 +566,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
@@ -583,7 +590,7 @@ export default function SettingsPage() {
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
|
||||
// Advanced
|
||||
@@ -666,6 +673,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
|
||||
+23
-11
@@ -27,11 +27,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// /admin lives outside the [locale] tree, so links back to the webmail
|
||||
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
||||
// basePath to <Link>/router APIs - for these we prepend it manually so
|
||||
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
||||
const prefix = getPathPrefix();
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
@@ -274,39 +282,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href="/files"
|
||||
href={`${prefix}/files`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
href={`${prefix}/settings`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
href={`${prefix}/`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
@@ -419,7 +429,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
@@ -427,21 +437,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href="/files"
|
||||
href={`${prefix}/files`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
@@ -454,7 +466,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
href={`${prefix}/settings`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
|
||||
@@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
||||
// just authenticated to JMAP with these credentials, and the cookie we
|
||||
// write here is only ever consumed for requests on behalf of this same
|
||||
// user — a bogus auth header would just yield 401s downstream, not
|
||||
// user - a bogus auth header would just yield 401s downstream, not
|
||||
// privilege escalation. For untrusted custom endpoints we still verify
|
||||
// upstream as before.
|
||||
const normalizedServerUrl = upstreamTrusted
|
||||
|
||||
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Willkommen bei Bulwark Webmail!',
|
||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
||||
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: {
|
||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
||||
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Sprint planning - next week priorities',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
hasAttachment: false,
|
||||
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
|
||||
{
|
||||
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
||||
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||
subject: 'Design review feedback',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
hasAttachment: false,
|
||||
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
|
||||
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
hasAttachment: false,
|
||||
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
||||
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -728,8 +728,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -743,6 +743,12 @@ const addressBooks = [
|
||||
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
||||
];
|
||||
|
||||
// Profile photos served straight from randomuser.me's CDN; the API at
|
||||
// https://randomuser.me/api/ also returns these portrait URLs, but for a
|
||||
// fixed mock dataset we link them directly to keep things offline-friendly.
|
||||
// See https://randomuser.me/documentation#howto
|
||||
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const contacts = [
|
||||
// --- Personal address book ---
|
||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
@@ -752,6 +758,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
||||
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -760,6 +767,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -768,6 +776,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -775,6 +784,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
||||
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -783,6 +793,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
||||
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -791,6 +802,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
||||
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -798,6 +810,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
||||
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -806,6 +819,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
||||
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -814,6 +828,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
||||
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -822,6 +837,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
||||
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
@@ -831,6 +847,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -839,6 +856,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
||||
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -847,6 +865,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
||||
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||
@@ -855,6 +874,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'García Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
||||
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -863,6 +883,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -871,6 +892,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
||||
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -879,6 +901,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
||||
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -887,6 +910,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
||||
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -895,6 +919,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
||||
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||
@@ -904,6 +929,7 @@ const contacts = [
|
||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Groups ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -976,7 +1002,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -986,7 +1012,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1024,7 +1050,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1054,7 +1080,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1066,7 +1092,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
||||
@@ -1084,7 +1110,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p5: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
||||
@@ -1093,7 +1119,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p4: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
},
|
||||
}),
|
||||
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
||||
@@ -1109,7 +1135,7 @@ const calendarEvents = [
|
||||
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
||||
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1192,7 +1218,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== Birthday calendar (cal-5) =====
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
|
||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
|
||||
showWithoutTime: true,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
description: 'Don\'t forget to bring Kuchen!',
|
||||
@@ -1220,7 +1246,7 @@ const calendarEvents = [
|
||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
||||
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let authHeader: string | null = null;
|
||||
if (parsed.username || parsed.password) {
|
||||
const username = decodeURIComponent(parsed.username);
|
||||
const password = decodeURIComponent(parsed.password);
|
||||
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
}
|
||||
|
||||
return { cleanUrl: parsed.toString(), authHeader };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isPublicHttpUrl(url))) {
|
||||
const extracted = extractBasicAuth(url);
|
||||
if (!extracted) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { cleanUrl, authHeader } = extracted;
|
||||
|
||||
if (!(await isPublicHttpUrl(cleanUrl))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
};
|
||||
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
@@ -47,14 +47,14 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/setup/branding — wizard branding upload.
|
||||
* POST /api/setup/branding - wizard branding upload.
|
||||
*
|
||||
* Multipart form fields:
|
||||
* file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||
* slot — which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||
*
|
||||
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
||||
* instead of admin session — admin auth doesn't exist yet during bootstrap.
|
||||
* instead of admin session - admin auth doesn't exist yet during bootstrap.
|
||||
* Files land in the same directory; the public read endpoint at
|
||||
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||
* assets after setup.
|
||||
@@ -126,7 +126,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/setup/branding — remove an uploaded asset and clear the
|
||||
* DELETE /api/setup/branding - remove an uploaded asset and clear the
|
||||
* config override so the slot falls back to the system default.
|
||||
*
|
||||
* Body: { slot: string }
|
||||
|
||||
+8
-1
@@ -171,8 +171,15 @@ body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans Thai",
|
||||
"Leelawadee UI",
|
||||
Tahoma,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<defs>
|
||||
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||
<style>
|
||||
.icon-bg { fill: #ffffff; }
|
||||
.icon-mark { fill: rgb(219,45,84); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-bg { fill: #18181b; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||
<rect width="1000" height="1000" class="icon-bg"/>
|
||||
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
+4
-2
@@ -4,6 +4,7 @@ import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -17,7 +18,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
await configManager.ensureLoaded();
|
||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
@@ -30,7 +32,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
icons: { icon: faviconUrl },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type WebAppProtocolHandler = {
|
||||
protocol: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
launch_handler?: {
|
||||
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
|
||||
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
|
||||
};
|
||||
};
|
||||
|
||||
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||
// against the document origin, not the manifest's location, and Next.js does
|
||||
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
export default function manifest(): ExtendedManifest {
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
@@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
],
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "navigate-new"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
|
||||
|
||||
export default async function MailtoProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
|
||||
|
||||
export default async function WebcalProtocolPage() {
|
||||
const t = await getTranslations("protocol_handlers");
|
||||
|
||||
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
|
||||
}
|
||||
+9
-9
@@ -229,7 +229,7 @@ export default function SetupWizardPage() {
|
||||
} catch (e) {
|
||||
const msg = humanError(e);
|
||||
setError(msg);
|
||||
// Session expired mid-flow — kick the user back to the
|
||||
// Session expired mid-flow - kick the user back to the
|
||||
// welcome step so they can re-enter the token without
|
||||
// having to refresh.
|
||||
if (/wizard session required/i.test(msg)) {
|
||||
@@ -241,7 +241,7 @@ export default function SetupWizardPage() {
|
||||
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
|
||||
onFinish={() => {
|
||||
setCompleted(true);
|
||||
// Hard navigation after a beat — gives the user a moment
|
||||
// Hard navigation after a beat - gives the user a moment
|
||||
// to see the success screen and works around any router
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
@@ -536,7 +536,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
const data = await res.json();
|
||||
let entry: { status: ProbeStatus; message: string; url: string };
|
||||
if (data.status === 'jmap_detected') {
|
||||
entry = { status: 'jmap_detected', message: 'Connected — this looks like a JMAP server.', url: config.jmapServerUrl };
|
||||
entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl };
|
||||
} else if (data.status === 'reachable_no_jmap') {
|
||||
entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl };
|
||||
} else if (data.status === 'invalid_url') {
|
||||
@@ -618,7 +618,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
}
|
||||
if (!result) return;
|
||||
|
||||
// Hard-fail on these — no "are you sure" since they can't be right.
|
||||
// Hard-fail on these - no "are you sure" since they can't be right.
|
||||
if (result.status === 'invalid_url' || result.status === 'unreachable') {
|
||||
return;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
This URL uses plain HTTP.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
||||
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production — terminate TLS on the mail server or a reverse proxy in front of it.
|
||||
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production - terminate TLS on the mail server or a reverse proxy in front of it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -720,7 +720,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-foreground">I'm sure this is the right URL — continue anyway.</span>
|
||||
<span className="text-sm text-foreground">I'm sure this is the right URL - continue anyway.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1105,7 +1105,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<StepHeader
|
||||
title="Branding"
|
||||
subtitle="All fields optional. Upload a file or paste a URL — defaults are used for anything you skip."
|
||||
subtitle="All fields optional. Upload a file or paste a URL - defaults are used for anything you skip."
|
||||
/>
|
||||
<Field label="Company / organization name">
|
||||
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
||||
@@ -1174,7 +1174,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
* One branding asset slot: shows a thumbnail preview if a value is set,
|
||||
* a file picker (uploads to /api/setup/branding), and a URL field for
|
||||
* operators who'd rather paste a link. Upload and URL are mutually
|
||||
* compatible — the URL field always reflects the persisted value.
|
||||
* compatible - the URL field always reflects the persisted value.
|
||||
*/
|
||||
function BrandingAsset({
|
||||
label,
|
||||
@@ -1522,7 +1522,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
{value || <span className="text-muted-foreground italic">—</span>}
|
||||
{value || <span className="text-muted-foreground italic">-</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
const tCal = useTranslations("calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("file");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
|
||||
const [urlInput, setUrlInput] = useState(initialUrl || "");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
editSubscription?: ICalSubscription;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
|
||||
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
const [name, setName] = useState(editSubscription?.name || "");
|
||||
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
|
||||
const [name, setName] = useState(editSubscription?.name || initialName || "");
|
||||
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
@@ -55,7 +56,7 @@ export interface ComposerDraftData {
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: EmailComposerProps['replyTo'];
|
||||
draftId: string | null;
|
||||
/** When set, overrides the header From: — sent through the selected identity's envelope. */
|
||||
/** When set, overrides the header From: - sent through the selected identity's envelope. */
|
||||
fromOverrideEmail?: string;
|
||||
fromOverrideName?: string;
|
||||
fromOverrideEnabled?: boolean;
|
||||
@@ -116,6 +117,39 @@ type ComposerAttachment = {
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
type SignatureIdentityLike = {
|
||||
htmlSignature?: string;
|
||||
textSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// Render the embedded signature for "above quote" mode. Bracketed with
|
||||
// `data-signature-block` marker paragraphs so we can swap the inner content
|
||||
// when the user switches identity without losing the surrounding draft or
|
||||
// quoted message. The markers are preserved through TipTap by the
|
||||
// StyledParagraph extension.
|
||||
function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureIdentityLike,
|
||||
options: { embed: boolean; separator: boolean }
|
||||
): string {
|
||||
if (!options.embed) return '';
|
||||
const startMarker = options.separator
|
||||
? `<p data-signature-block="separator">-- </p>`
|
||||
: `<p data-signature-block="start"></p>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${startMarker}<p>${escaped}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
@@ -136,6 +170,7 @@ export function EmailComposer({
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
|
||||
@@ -204,10 +239,11 @@ export function EmailComposer({
|
||||
|
||||
// When "above quote" is configured, splice signature between the user's
|
||||
// drafting area and the quoted content so it reads naturally as a
|
||||
// closing for the reply body. Send-time append is skipped — see
|
||||
// closing for the reply body. Send-time append is skipped - see
|
||||
// shouldEmbedSignatureAboveQuote.
|
||||
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||
? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}`
|
||||
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
||||
: '';
|
||||
|
||||
if (mode === 'forward') {
|
||||
@@ -225,21 +261,10 @@ export function EmailComposer({
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
// When "above quote" is configured, splice signature between the user's
|
||||
// drafting area and the quoted content so it reads naturally as a closing
|
||||
// for the reply body. Send-time append is skipped — see
|
||||
// shouldEmbedSignatureAboveQuote.
|
||||
const buildEmbeddedSignatureHtml = (): string => {
|
||||
if (!shouldEmbedSignatureAboveQuote) return '';
|
||||
if (initialSignatureIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (initialSignatureIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${initialSignatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const signatureBlock = buildEmbeddedSignatureHtml();
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
@@ -335,6 +360,69 @@ export function EmailComposer({
|
||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
const endEl = doc.querySelector('[data-signature-block="end"]');
|
||||
|
||||
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
if (!newSignature) return;
|
||||
|
||||
// Build a temporary container holding the replacement nodes so we can
|
||||
// splice them in without re-serializing/parsing twice.
|
||||
const replacementHost = doc.createElement('div');
|
||||
replacementHost.innerHTML = newSignature;
|
||||
const replacementNodes = Array.from(replacementHost.childNodes);
|
||||
|
||||
const parent = startEl.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const toRemove: Node[] = [];
|
||||
let cursor: Node | null = startEl;
|
||||
while (cursor) {
|
||||
toRemove.push(cursor);
|
||||
if (cursor === removeUntil) break;
|
||||
const next: Node | null = cursor.nextSibling;
|
||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
toRemove.forEach((node) => parent.removeChild(node));
|
||||
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
||||
|
||||
const nextHtml = doc.body.innerHTML;
|
||||
if (nextHtml !== currentHtml) {
|
||||
editor.commands.setContent(nextHtml, { emitUpdate: true });
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
@@ -1018,7 +1106,7 @@ export function EmailComposer({
|
||||
: undefined;
|
||||
// When the user has typed a From override, that becomes the header From
|
||||
// (and MIME-builder From in the S/MIME path). The identity still drives
|
||||
// the SMTP envelope MAIL FROM — set explicitly so it doesn't mistakenly
|
||||
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
|
||||
// default to the override address.
|
||||
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
||||
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
||||
@@ -1038,11 +1126,12 @@ export function EmailComposer({
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (signatureAlreadyInBody) return '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (signatureIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -1053,9 +1142,10 @@ export function EmailComposer({
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
@@ -1094,7 +1184,7 @@ export function EmailComposer({
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME — disable one to send.');
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
@@ -1628,6 +1718,7 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1638,13 +1729,13 @@ export function EmailComposer({
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
{'-- \n'}{getPlainTextSignature(signatureIdentity)}
|
||||
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
|
||||
</div>
|
||||
) : null
|
||||
) : composerSignatureHtml ? (
|
||||
<div
|
||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
// Pasted email content (signatures, replies, quoted text) commonly carries
|
||||
// inline styles on block elements. StarterKit's default Paragraph/Heading
|
||||
// drop unknown attributes; extend them to round-trip `style` and `class` so
|
||||
// signature formatting survives the editor.
|
||||
const styledBlockAttributes = {
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
"data-signature-block": {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs["data-signature-block"]
|
||||
? { "data-signature-block": attrs["data-signature-block"] }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const StyledHeading = Heading.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -131,17 +179,23 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
StyledHeading.configure({ levels: [1, 2] }),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -239,6 +293,12 @@ export function RichTextEditor({
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
// Expose the editor instance once it's ready so parents can target
|
||||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||||
useEffect(() => {
|
||||
if (editor) onEditorReadyRef.current?.(editor);
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
|
||||
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
maxLength={50000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
rows={5}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
|
||||
}
|
||||
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={account.label}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -610,7 +611,6 @@ export function NavigationRail({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
@@ -618,15 +618,20 @@ export function NavigationRail({
|
||||
if (!isActive) switchAccount(account.id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||
>
|
||||
{initials}
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-2 h-2 text-primary-foreground" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
User,
|
||||
Users,
|
||||
Palmtree,
|
||||
Settings,
|
||||
X,
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
NotebookPen,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -88,6 +93,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
|
||||
if (role === "archive" || lowerName.includes("archive")) return Archive;
|
||||
if (role === "shared" || lowerName.includes("shared")) return Users;
|
||||
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
|
||||
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
|
||||
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
|
||||
|
||||
if (hasChildren) {
|
||||
@@ -104,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
|
||||
trash: "text-muted-foreground",
|
||||
junk: "text-red-600/80 dark:text-red-400/80",
|
||||
archive: "text-amber-600/80 dark:text-amber-400/80",
|
||||
shared: "text-cyan-600/80 dark:text-cyan-400/80",
|
||||
important: "text-orange-600/80 dark:text-orange-400/80",
|
||||
memos: "text-yellow-600/80 dark:text-yellow-400/80",
|
||||
scheduled: "text-sky-600/80 dark:text-sky-400/80",
|
||||
snoozed: "text-slate-500/80 dark:text-slate-400/80",
|
||||
};
|
||||
|
||||
function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
@@ -114,6 +129,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
|
||||
if (role === "archive" || lowerName.includes("archive")) return "archive";
|
||||
if (role === "shared" || lowerName.includes("shared")) return "shared";
|
||||
if (role === "important" || lowerName.includes("important")) return "important";
|
||||
if (role === "memos" || lowerName.includes("memo")) return "memos";
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/mailto";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
async function focusExistingClient() {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
worker?.postMessage({ type: "focus-existing-mailto-client" });
|
||||
} catch {
|
||||
// Focusing is a progressive enhancement; the composer handoff still works.
|
||||
}
|
||||
}
|
||||
|
||||
interface MailtoProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function handleMailto() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
const openMode = useSettingsStore.getState().protocolOpenMode;
|
||||
const parsed = raw ? parseMailto(raw) : null;
|
||||
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab && openMode === "new-tab") {
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
} else if (!isFallbackAppTab) {
|
||||
const delivered = await requestOpenMailtoInExistingClient(parsed);
|
||||
if (cancelled) return;
|
||||
|
||||
if (delivered) {
|
||||
void focusExistingClient();
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
savePendingMailto(parsed);
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/`);
|
||||
}
|
||||
|
||||
void handleMailto();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import type { AccountEntry } from "@/stores/account-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
type ProtocolAccountPickerProps = {
|
||||
accounts: AccountEntry[];
|
||||
activeAccountId: string | null;
|
||||
isSwitching?: boolean;
|
||||
onSelect: (accountId: string) => void;
|
||||
onCancel: () => void;
|
||||
} & (
|
||||
| { kind: "mailto"; operation?: ParsedMailto }
|
||||
| { kind: "webcal"; operation?: ParsedWebcal }
|
||||
);
|
||||
|
||||
function getHost(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProtocolAccountPicker({
|
||||
kind,
|
||||
accounts,
|
||||
activeAccountId,
|
||||
isSwitching = false,
|
||||
onSelect,
|
||||
onCancel,
|
||||
operation,
|
||||
}: ProtocolAccountPickerProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const tCommon = useTranslations("common");
|
||||
const details = operation
|
||||
? kind === "mailto"
|
||||
? [
|
||||
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
|
||||
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
|
||||
]
|
||||
: [
|
||||
{ label: t("detail_calendar"), value: operation.suggestedName },
|
||||
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("select_account_title")}
|
||||
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
<div className="border-b border-border bg-muted/40 px-5 py-3">
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
let host = account.serverUrl;
|
||||
try {
|
||||
host = new URL(account.serverUrl).hostname;
|
||||
} catch {
|
||||
// Keep the configured value when it is not an absolute URL.
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
type="button"
|
||||
disabled={isSwitching}
|
||||
onClick={() => onSelect(account.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isSwitching && "cursor-wait opacity-70"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{account.displayName || account.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{t("active_account")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
||||
{isSwitching ? (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("switching_account")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSwitching}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import {
|
||||
listenForMailtoRequests,
|
||||
notifyPendingMailto,
|
||||
notifyPendingWebcal,
|
||||
requestOpenMailtoInExistingClient,
|
||||
savePendingMailto,
|
||||
savePendingWebcal,
|
||||
} from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type LaunchParams = { targetURL?: string };
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
launchQueue?: {
|
||||
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getProtocolLaunch(targetURL: string):
|
||||
| { kind: "mailto"; raw: string }
|
||||
| { kind: "webcal"; raw: string }
|
||||
| null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(targetURL, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
const raw = url.searchParams.get("url");
|
||||
if (!raw) return null;
|
||||
|
||||
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
|
||||
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
}
|
||||
|
||||
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
|
||||
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProtocolLaunchHandlerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/")) return;
|
||||
|
||||
return listenForMailtoRequests((pending) => {
|
||||
savePendingMailto(pending);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
}, () => ({
|
||||
path: pathname,
|
||||
standalone: isStandaloneDisplayMode(),
|
||||
focusNotificationTitle: t("focus_notification_title"),
|
||||
focusNotificationBody: t("focus_notification_body"),
|
||||
}));
|
||||
}, [pathname, router, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.launchQueue) return;
|
||||
|
||||
window.launchQueue.setConsumer((launchParams) => {
|
||||
if (!launchParams.targetURL) return;
|
||||
|
||||
const launch = getProtocolLaunch(launchParams.targetURL);
|
||||
if (!launch) return;
|
||||
|
||||
if (launch.kind === "mailto") {
|
||||
const parsed = parseMailto(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("mailto", launch.raw)) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
|
||||
if (delivered) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseWebcal(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("webcal", launch.raw)) return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
notifyPendingWebcal();
|
||||
if (pathname !== "/calendar") router.push("/calendar");
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/webcal";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
interface WebcalProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
|
||||
if (raw) {
|
||||
const parsed = parseWebcal(raw);
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab
|
||||
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
|
||||
&& shouldOpenFallbackAppTab()
|
||||
&& openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
import koMessages from '@/locales/ko/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fr: frMessages,
|
||||
it: itMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
|
||||
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
initializeTheme();
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault();
|
||||
const { resolvedTheme, setTheme } = useThemeStore.getState();
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
|
||||
{tTour('restart_button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
|
||||
<ToggleSwitch
|
||||
checked={showOnboardingOnNewDevices}
|
||||
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
isSupportedSubAddressDelimiter,
|
||||
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
@@ -28,20 +24,10 @@ export function ComposingSettings() {
|
||||
attachmentReminderKeywords,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
@@ -62,6 +48,13 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('signature_separator.label')} description={t('signature_separator.description')}>
|
||||
<ToggleSwitch
|
||||
checked={signatureSeparatorEnabled}
|
||||
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
@@ -160,24 +153,6 @@ export function ComposingSettings() {
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
|
||||
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
|
||||
trash: Trash,
|
||||
junk: ShieldAlert,
|
||||
archive: Archive,
|
||||
shared: Users,
|
||||
important: AlertTriangle,
|
||||
memos: NotebookPen,
|
||||
scheduled: CalendarClock,
|
||||
snoozed: BellOff,
|
||||
};
|
||||
|
||||
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { ProtocolOpenMode } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection, SettingItem, Select } from "./settings-section";
|
||||
|
||||
type Protocol = "mailto" | "webcal";
|
||||
|
||||
function canRegisterProtocolHandler(): boolean {
|
||||
return typeof navigator !== "undefined"
|
||||
&& "registerProtocolHandler" in navigator
|
||||
&& typeof window !== "undefined"
|
||||
&& window.isSecureContext;
|
||||
}
|
||||
|
||||
function getProtocolHandlerUrl(protocol: Protocol) {
|
||||
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
|
||||
}
|
||||
|
||||
function registerProtocolHandler(protocol: Protocol) {
|
||||
navigator.registerProtocolHandler(
|
||||
protocol,
|
||||
getProtocolHandlerUrl(protocol),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProtocolHandlerSettingsProps {
|
||||
supportsCalendar: boolean;
|
||||
}
|
||||
|
||||
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const [supported, setSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(canRegisterProtocolHandler());
|
||||
}, []);
|
||||
|
||||
const handleOpenModeChange = async (value: string) => {
|
||||
const openMode = value as ProtocolOpenMode;
|
||||
|
||||
if (openMode === "active-session"
|
||||
&& typeof window !== "undefined"
|
||||
&& "Notification" in window
|
||||
&& Notification.permission === "default") {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
|
||||
updateSetting("protocolOpenMode", openMode);
|
||||
};
|
||||
|
||||
const handleRegister = (protocol: Protocol) => {
|
||||
try {
|
||||
registerProtocolHandler(protocol);
|
||||
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
|
||||
} catch {
|
||||
toast.error(t("registration_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderRegistrationControl = (protocol: Protocol) => {
|
||||
return (
|
||||
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
|
||||
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
{!supported && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("unsupported")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
|
||||
{renderRegistrationControl("mailto")}
|
||||
</SettingItem>
|
||||
|
||||
{supportsCalendar && (
|
||||
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
|
||||
{renderRegistrationControl("webcal")}
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
|
||||
<Select
|
||||
value={protocolOpenMode}
|
||||
onChange={handleOpenModeChange}
|
||||
options={[
|
||||
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
|
||||
{ value: "active-session", label: t("protocol_open_mode_active_session") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state.
|
||||
try {
|
||||
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
|
||||
if (legacy && !tourCompleted) {
|
||||
updateSetting("tourCompleted", true);
|
||||
}
|
||||
} catch { /* */ }
|
||||
}, [tourCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tourCompleted) {
|
||||
setHasCompletedTour(false);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
return;
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}
|
||||
setHasCompletedTour(true);
|
||||
}, [tourCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
let resumeStep = 0;
|
||||
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
updateSetting("tourCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
updateSetting("tourCompleted", false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
|
||||
@@ -142,9 +142,13 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||
disableImages?: boolean;
|
||||
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
|
||||
disableFavicon?: boolean;
|
||||
/** Background color used when no image source resolves. Overrides the hash-based default. */
|
||||
fallbackColor?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
@@ -226,7 +230,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
|
||||
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
|
||||
const showFavicon =
|
||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
|
||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
@@ -257,7 +261,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
|
||||
title={name || email}
|
||||
>
|
||||
{imgSrc ? (
|
||||
|
||||
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Denmark – Red with a white Nordic cross */
|
||||
export function FlagDK(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
|
||||
<path fill="#C8102E" d="M0,0H37V28H0Z" />
|
||||
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
cs: FlagCS,
|
||||
da: FlagDK,
|
||||
de: FlagDE,
|
||||
en: FlagGB,
|
||||
es: FlagES,
|
||||
fr: FlagFR,
|
||||
it: FlagIT,
|
||||
ja: FlagJP,
|
||||
ko: FlagKR,
|
||||
es: FlagES,
|
||||
it: FlagIT,
|
||||
de: FlagDE,
|
||||
lv: FlagLV,
|
||||
nl: FlagNL,
|
||||
pl: FlagPL,
|
||||
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
cs: FlagCS,
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state so the user isn't shown
|
||||
// the banner again on this device after the upgrade.
|
||||
try {
|
||||
if (!localStorage.getItem(ONBOARDING_KEY)) {
|
||||
setVisible(true);
|
||||
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
|
||||
if (legacy && !onboardingCompleted) {
|
||||
updateSetting("onboardingCompleted", true);
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [onboardingCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboardingCompleted) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
setVisible(false);
|
||||
}, [onboardingCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
updateSetting("onboardingCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
@@ -45,6 +45,7 @@ export default [
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
@@ -66,7 +66,7 @@ export function useAttachmentDrag(
|
||||
urlRef.current = url;
|
||||
// Mark as owned so we revoke on unmount. Callers that hand back a
|
||||
// shared URL (e.g. a cached thumbnail blob URL) can return the same
|
||||
// string each time — we still revoke once on unmount.
|
||||
// string each time - we still revoke once on unmount.
|
||||
ownedRef.current = true;
|
||||
}
|
||||
return url;
|
||||
@@ -101,7 +101,7 @@ export function useAttachmentDrag(
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Keep the blob URL around briefly — Chromium asynchronously fetches the
|
||||
// Keep the blob URL around briefly - Chromium asynchronously fetches the
|
||||
// blob: URL after dragend fires, so revoking immediately races the OS.
|
||||
if (urlRef.current && ownedRef.current) {
|
||||
const url = urlRef.current;
|
||||
|
||||
+5
-2
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
case 'da':
|
||||
messages = (await import('../locales/da/common.json')).default;
|
||||
break;
|
||||
case 'de':
|
||||
messages = (await import('../locales/de/common.json')).default;
|
||||
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'es':
|
||||
messages = (await import('../locales/es/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
case 'it':
|
||||
messages = (await import('../locales/it/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(plainTextToComposerBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("escapes HTML before building composer paragraphs", () => {
|
||||
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
|
||||
"<p><script>alert('x') & "q"</script></p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes line endings and preserves single line breaks", () => {
|
||||
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
|
||||
"<p>line1<br>line2<br>line3</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits paragraphs on blank lines", () => {
|
||||
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
|
||||
"<p>first</p><p>second<br>third</p>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
it('should allow img with https src', () => {
|
||||
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
|
||||
expect(clean).toContain('alt="Logo"');
|
||||
expect(clean).toContain('width="120"');
|
||||
expect(clean).toContain('height="40"');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/png;base64 src', () => {
|
||||
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
|
||||
const signature = `<img src="${dataUri}" alt="Logo">`;
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('data:image/png;base64,');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/jpeg, gif, webp', () => {
|
||||
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
|
||||
for (const src of cases) {
|
||||
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain(src);
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip img with http: src (https only)', () => {
|
||||
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('http://insecure.example.com');
|
||||
expect(clean).not.toContain('<img');
|
||||
expect(clean).toContain('John');
|
||||
});
|
||||
|
||||
it('should strip img with javascript: src', () => {
|
||||
const signature = '<img src="javascript:alert(1)" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('javascript:');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
|
||||
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:image/svg');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with non-image data: URI', () => {
|
||||
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:text/html');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip event handlers on img', () => {
|
||||
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('onerror');
|
||||
expect(clean).not.toContain('onload');
|
||||
expect(clean).toContain('https://cdn.example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should remove video and audio tags', () => {
|
||||
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
|
||||
});
|
||||
|
||||
it('should be stricter than email sanitization', () => {
|
||||
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
|
||||
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video>';
|
||||
const emailClean = sanitizeEmailHtml(html);
|
||||
const signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
// Email allows table
|
||||
expect(emailClean).toContain('<table>');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
// Signature blocks table and video
|
||||
expect(signatureClean).not.toContain('<table');
|
||||
expect(signatureClean).not.toContain('<video');
|
||||
expect(signatureClean).toContain('Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMailto } from "../protocol-handlers/mailto";
|
||||
import { listenForMailtoRequests } from "../protocol-handlers/session";
|
||||
import { parseWebcal } from "../protocol-handlers/webcal";
|
||||
|
||||
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
|
||||
|
||||
function installServiceWorkerMock() {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const worker = { postMessage: vi.fn() };
|
||||
const serviceWorker = {
|
||||
ready: Promise.resolve({ active: worker }),
|
||||
controller: worker,
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatch(data: unknown) {
|
||||
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServiceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
describe("protocol handlers", () => {
|
||||
describe("parseMailto", () => {
|
||||
it("parses a single path recipient", () => {
|
||||
expect(parseMailto("mailto:alice@example.com")).toEqual({
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple recipients with subject and body", () => {
|
||||
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
|
||||
to: ["alice@example.com", "bob@example.com"],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses to, cc, and bcc query recipients", () => {
|
||||
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
|
||||
to: ["alice@example.com"],
|
||||
cc: ["bob@example.com"],
|
||||
bcc: ["eve@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes subject and body values", () => {
|
||||
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
|
||||
subject: "Hello World",
|
||||
body: "line1\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal plus signs in query values", () => {
|
||||
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
|
||||
to: ["user+tag@example.com"],
|
||||
subject: "C++",
|
||||
body: "a+b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-mailto URLs", () => {
|
||||
expect(parseMailto("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty mailto URL", () => {
|
||||
expect(parseMailto("mailto:")).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes control characters and caps recipients", () => {
|
||||
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
|
||||
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
|
||||
expect(parsed?.to).toHaveLength(200);
|
||||
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebcal", () => {
|
||||
it("normalizes webcal to https", () => {
|
||||
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("normalizes webcals to https", () => {
|
||||
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests a name from the path", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
|
||||
});
|
||||
|
||||
it("falls back to hostname for suggested name", () => {
|
||||
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("prefers a name query parameter", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForMailtoRequests", () => {
|
||||
const mailtoValue = {
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
};
|
||||
|
||||
it("accepts legacy service-worker mailto messages without a client id", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
vi.spyOn(window, "focus").mockImplementation(() => undefined);
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
|
||||
|
||||
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores service-worker mailto messages for another client", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
|
||||
|
||||
expect(onMailto).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
||||
|
||||
// randomuser.me serves stable portrait URLs at
|
||||
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
|
||||
// See https://randomuser.me/documentation#howto - we use these directly
|
||||
// rather than hitting the JSON API so the demo works offline.
|
||||
const portrait = (gender: 'men' | 'women', n: number): string =>
|
||||
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||
|
||||
const photo = (gender: 'men' | 'women', n: number) => ({
|
||||
photo1: { kind: 'photo' as const, uri: portrait(gender, n), mediaType: 'image/jpeg' },
|
||||
});
|
||||
|
||||
export function createDemoAddressBooks(): AddressBook[] {
|
||||
return [
|
||||
{
|
||||
@@ -34,6 +45,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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 } } },
|
||||
media: photo('women', 44),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-2',
|
||||
@@ -50,6 +62,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
},
|
||||
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
|
||||
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
|
||||
media: photo('men', 32),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-3',
|
||||
@@ -60,6 +73,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
|
||||
organizations: { o1: { name: 'DesignCo' } },
|
||||
titles: { t1: { name: 'UX Designer', kind: 'title' } },
|
||||
media: photo('women', 68),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-4',
|
||||
@@ -69,6 +83,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('men', 15),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-5',
|
||||
@@ -89,6 +104,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
},
|
||||
},
|
||||
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
|
||||
media: photo('women', 22),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-6',
|
||||
@@ -97,6 +113,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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 } } },
|
||||
media: photo('men', 67),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-7',
|
||||
@@ -123,6 +140,58 @@ export function createDemoContacts(): ContactCard[] {
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
|
||||
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
|
||||
media: photo('women', 85),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-16',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sofia' }, { kind: 'surname', value: 'Russo' }] },
|
||||
emails: { e1: { address: 'sofia.russo@example.com', contexts: { private: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+39-340-555-0111', features: { cell: true }, contexts: { private: true } } },
|
||||
notes: { n1: { note: 'Mom' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
|
||||
media: photo('women', 3),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-17',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
|
||||
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
|
||||
notes: { n1: { note: 'Sister - lives in Kraków' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
|
||||
media: photo('women', 47),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-18',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
|
||||
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
|
||||
notes: { n1: { note: 'College friend - book club organiser' } },
|
||||
media: photo('men', 96),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-19',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Bennett' }] },
|
||||
emails: { e1: { address: 'olivia.bennett@example.com', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Northwind Studio' } },
|
||||
titles: { t1: { name: 'Product Designer', kind: 'title' } },
|
||||
media: photo('women', 91),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-20',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Daniel' }, { kind: 'surname', value: 'Cooper' }] },
|
||||
emails: { e1: { address: 'daniel.cooper@example.com', pref: 1 } },
|
||||
organizations: { o1: { name: 'Freelance' } },
|
||||
titles: { t1: { name: 'Illustrator', kind: 'title' } },
|
||||
media: photo('men', 76),
|
||||
},
|
||||
|
||||
// ── Work address book ──────────────────────────────────────
|
||||
@@ -135,6 +204,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('men', 41),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-10',
|
||||
@@ -144,6 +214,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('women', 12),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-11',
|
||||
@@ -153,6 +224,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('men', 52),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-12',
|
||||
@@ -162,6 +234,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('women', 77),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-13',
|
||||
@@ -171,6 +244,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('men', 89),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-14',
|
||||
@@ -180,6 +254,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('women', 55),
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-15',
|
||||
@@ -189,6 +264,7 @@ export function createDemoContacts(): ContactCard[] {
|
||||
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' } },
|
||||
media: photo('men', 8),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+590
-110
@@ -1,6 +1,35 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { demoDate } from '../demo-utils';
|
||||
|
||||
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
|
||||
|
||||
// Helper to keep the fixtures short - auto-assigns a partId/blobId per body.
|
||||
let bodyCounter = 0;
|
||||
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
|
||||
const partId = String(++bodyCounter);
|
||||
const blobId = `blob-${partId}`;
|
||||
return {
|
||||
part: { partId, blobId, size: value.length, type },
|
||||
values: { [partId]: { value } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build text+html parts in one shot. */
|
||||
function bodies(text: string, html: string) {
|
||||
const t = body(text, 'text/plain');
|
||||
const h = body(html, 'text/html');
|
||||
return {
|
||||
textBody: [t.part],
|
||||
htmlBody: [h.part],
|
||||
bodyValues: { ...t.values, ...h.values },
|
||||
};
|
||||
}
|
||||
|
||||
function textOnly(text: string) {
|
||||
const t = body(text, 'text/plain');
|
||||
return { textBody: [t.part], bodyValues: t.values };
|
||||
}
|
||||
|
||||
export function createDemoEmails(): Email[] {
|
||||
return [
|
||||
// ── Inbox ───────────────────────────────────────────────────
|
||||
@@ -12,19 +41,61 @@ export function createDemoEmails(): Email[] {
|
||||
size: 4200,
|
||||
receivedAt: demoDate(0, -2),
|
||||
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
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>' },
|
||||
},
|
||||
...bodies(
|
||||
'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!',
|
||||
'<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>',
|
||||
},
|
||||
|
||||
// Mom - personal message, unread
|
||||
{
|
||||
id: 'demo-email-mom',
|
||||
threadId: 'demo-thread-mom',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 1900,
|
||||
receivedAt: demoDate(0, -4, -12),
|
||||
from: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'when are you coming home?',
|
||||
sentAt: demoDate(0, -4, -12),
|
||||
preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom",
|
||||
),
|
||||
messageId: '<5a8c-mom@example.com>',
|
||||
},
|
||||
|
||||
// GitHub - PR review request
|
||||
{
|
||||
id: 'demo-email-gh-pr',
|
||||
threadId: 'demo-thread-gh-pr',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 6400,
|
||||
receivedAt: demoDate(0, -3, -5),
|
||||
from: [{ name: 'Alice Johnson (via GitHub)', email: 'notifications@github.com' }],
|
||||
replyTo: [{ name: 'reply', email: 'reply+abc123@reply.github.com' }],
|
||||
to: [USER],
|
||||
subject: '[acme/api-gateway] Add token-bucket rate limiter (#1284)',
|
||||
sentAt: demoDate(0, -3, -5),
|
||||
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
'@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 −47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.',
|
||||
'<table style="font-family:-apple-system,sans-serif"><tr><td><strong>@demo-user</strong> requested your review on this pull request.</td></tr><tr><td style="padding-top:12px">Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in <code>rate-limit.toml</code>.</td></tr><tr><td style="padding-top:12px;color:#666">Three files changed, <span style="color:#16a34a">+312</span> <span style="color:#dc2626">−47</span></td></tr><tr><td style="padding-top:16px"><a href="https://github.com/acme/api-gateway/pull/1284" style="background:#1f2328;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px">View on GitHub</a></td></tr></table>',
|
||||
),
|
||||
messageId: '<acme/api-gateway/pull/1284@github.com>',
|
||||
},
|
||||
|
||||
// Hacker Newsletter - newsletter, read
|
||||
{
|
||||
id: 'demo-email-2',
|
||||
threadId: 'demo-thread-2',
|
||||
@@ -33,20 +104,19 @@ export function createDemoEmails(): Email[] {
|
||||
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',
|
||||
to: [USER],
|
||||
subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
|
||||
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>' },
|
||||
...bodies(
|
||||
'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.',
|
||||
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
|
||||
),
|
||||
messageId: '<weekly-218@techdigest.example>',
|
||||
},
|
||||
messageId: '<weekly-42@techdigest.example>',
|
||||
},
|
||||
// Thread: Project discussion (3 emails in same thread)
|
||||
|
||||
// Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages)
|
||||
{
|
||||
id: 'demo-email-3a',
|
||||
threadId: 'demo-thread-3',
|
||||
@@ -55,17 +125,15 @@ export function createDemoEmails(): Email[] {
|
||||
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' }],
|
||||
to: [USER, { 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>' },
|
||||
},
|
||||
...bodies(
|
||||
'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',
|
||||
'<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>',
|
||||
},
|
||||
{
|
||||
@@ -76,15 +144,14 @@ export function createDemoEmails(): Email[] {
|
||||
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' }],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, USER],
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>'],
|
||||
@@ -97,20 +164,41 @@ export function createDemoEmails(): Email[] {
|
||||
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' }],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, USER],
|
||||
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...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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
|
||||
|
||||
// Stripe receipt
|
||||
{
|
||||
id: 'demo-email-stripe',
|
||||
threadId: 'demo-thread-stripe',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 11200,
|
||||
receivedAt: demoDate(-1, -1, -22),
|
||||
from: [{ name: 'Stripe', email: 'receipts@stripe.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your receipt from Linear Inc. [#2451-9928]',
|
||||
sentAt: demoDate(-1, -1, -22),
|
||||
preview: 'Receipt from Linear Inc. for $16.00. Thanks for your business.',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
'Receipt from Linear Inc.\nAmount paid: $16.00\nDate paid: yesterday\nPayment method: Visa •••• 4242\n\nDescription: Linear Standard (monthly)\n\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.\n\nQuestions? Contact support@linear.app.',
|
||||
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif"><div style="text-align:center;padding:24px 0"><div style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase">Receipt</div><div style="font-size:32px;font-weight:700;margin-top:4px">$16.00</div><div style="color:#666;margin-top:4px">Linear Inc.</div></div><table style="width:100%;border-top:1px solid #eee;border-bottom:1px solid #eee"><tr><td style="padding:10px 0;color:#666">Amount</td><td style="padding:10px 0;text-align:right">$16.00</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Payment method</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4">Visa •••• 4242</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Receipt number</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4;font-family:monospace">2451-9928</td></tr></table><p style="color:#666;font-size:13px;margin-top:24px">Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.</p></div>',
|
||||
),
|
||||
messageId: '<receipt-2451-9928@stripe.com>',
|
||||
},
|
||||
|
||||
// Email with attachments - invoice
|
||||
{
|
||||
id: 'demo-email-4',
|
||||
threadId: 'demo-thread-4',
|
||||
@@ -119,22 +207,22 @@ export function createDemoEmails(): Email[] {
|
||||
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',
|
||||
to: [USER],
|
||||
subject: 'Invoice #2024-089 & landing-page prototype v3',
|
||||
sentAt: demoDate(0, -6),
|
||||
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\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
|
||||
|
||||
// Carlos - starred, social
|
||||
{
|
||||
id: 'demo-email-5',
|
||||
threadId: 'demo-thread-5',
|
||||
@@ -143,18 +231,286 @@ export function createDemoEmails(): Email[] {
|
||||
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',
|
||||
to: [USER],
|
||||
subject: 'Friday dinner - moved to 7:30 (sorry!)',
|
||||
sentAt: demoDate(-2, -1),
|
||||
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
|
||||
preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
|
||||
),
|
||||
messageId: '<dinner-reminder@example.com>',
|
||||
},
|
||||
|
||||
// Linear - issue assigned
|
||||
{
|
||||
id: 'demo-email-linear',
|
||||
threadId: 'demo-thread-linear',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 5400,
|
||||
receivedAt: demoDate(0, -7, -15),
|
||||
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
|
||||
to: [USER],
|
||||
subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"',
|
||||
sentAt: demoDate(0, -7, -15),
|
||||
preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...',
|
||||
hasAttachment: false,
|
||||
...bodies(
|
||||
"Priya Sharma assigned BUL-2031 to you.\n\nTitle: Compose: drag-and-drop attachments duplicated on slow networks\nPriority: Medium\n\nRepro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn't get debounced and both attempts complete, so the attachment shows up twice in the draft.\n\nOpen in Linear: https://linear.app/bulwark/issue/BUL-2031",
|
||||
'<table style="font-family:-apple-system,sans-serif;max-width:520px"><tr><td><div style="font-size:11px;color:#888;letter-spacing:0.08em;text-transform:uppercase">Linear · BUL-2031</div><div style="font-size:18px;font-weight:600;margin-top:6px">Compose: drag-and-drop attachments duplicated on slow networks</div><div style="margin-top:8px;color:#666"><strong>Priya Sharma</strong> assigned this issue to you · Priority Medium</div></td></tr><tr><td style="padding-top:16px;color:#444">Repro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn\'t get debounced and both attempts complete, so the attachment shows up twice in the draft.</td></tr><tr><td style="padding-top:16px"><a href="https://linear.app/bulwark/issue/BUL-2031" style="background:#5e6ad2;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px;font-size:13px">Open in Linear</a></td></tr></table>',
|
||||
),
|
||||
messageId: '<BUL-2031-assign@linear.app>',
|
||||
},
|
||||
|
||||
// Anna - sister, photos
|
||||
{
|
||||
id: 'demo-email-anna',
|
||||
threadId: 'demo-thread-anna',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 4800000,
|
||||
receivedAt: demoDate(-1, -19),
|
||||
from: [{ name: 'Anna Kowalski', email: 'anna.kowalski@example.com' }],
|
||||
to: [USER],
|
||||
subject: 'photos from the wedding',
|
||||
sentAt: demoDate(-1, -19),
|
||||
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
"ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na",
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
|
||||
{ partId: 'att-4', blobId: 'demo-blob-att-4', size: 1600000, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' },
|
||||
{ partId: 'att-5', blobId: 'demo-blob-att-5', size: 1400000, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' },
|
||||
],
|
||||
messageId: '<wedding-photos@example.com>',
|
||||
},
|
||||
|
||||
// AWS billing
|
||||
{
|
||||
id: 'demo-email-aws',
|
||||
threadId: 'demo-thread-aws',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 9100,
|
||||
receivedAt: demoDate(-2, -3, -45),
|
||||
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your AWS bill is available - $127.43',
|
||||
sentAt: demoDate(-2, -3, -45),
|
||||
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.",
|
||||
),
|
||||
messageId: '<aws-bill-2024-11@amazon.com>',
|
||||
},
|
||||
|
||||
// 2FA code - system, unread
|
||||
{
|
||||
id: 'demo-email-2fa',
|
||||
threadId: 'demo-thread-2fa',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 1700,
|
||||
receivedAt: demoDate(0, -1, -8),
|
||||
from: [{ name: '1Password', email: 'noreply@1password.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your one-time verification code is 814-302',
|
||||
sentAt: demoDate(0, -1, -8),
|
||||
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.",
|
||||
),
|
||||
messageId: '<otp-814302@1password.com>',
|
||||
},
|
||||
|
||||
// LinkedIn - cold-ish
|
||||
{
|
||||
id: 'demo-email-linkedin',
|
||||
threadId: 'demo-thread-linkedin',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 8200,
|
||||
receivedAt: demoDate(-3, -11),
|
||||
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
|
||||
to: [USER],
|
||||
subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog',
|
||||
sentAt: demoDate(-3, -11),
|
||||
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.',
|
||||
),
|
||||
messageId: '<jobs-1107@linkedin.com>',
|
||||
},
|
||||
|
||||
// Book club - Marcus
|
||||
{
|
||||
id: 'demo-email-bookclub',
|
||||
threadId: 'demo-thread-bookclub',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 2400,
|
||||
receivedAt: demoDate(-1, -14),
|
||||
from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }],
|
||||
to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }],
|
||||
subject: 'book club thursday - picking the next one',
|
||||
sentAt: demoDate(-1, -14),
|
||||
preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Reminder: 7pm at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma's been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm",
|
||||
),
|
||||
messageId: '<bookclub-nov@example.com>',
|
||||
},
|
||||
|
||||
// DHL package
|
||||
{
|
||||
id: 'demo-email-dhl',
|
||||
threadId: 'demo-thread-dhl',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 5600,
|
||||
receivedAt: demoDate(0, -9, -30),
|
||||
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your package is out for delivery - arriving today',
|
||||
sentAt: demoDate(0, -9, -30),
|
||||
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Your package is on the truck.\n\nTracking: 1Z 999 AA1 0123 4567 84\nEstimated delivery window: today, 14:00–18:00\n\nIf no one is home, the driver will attempt redelivery tomorrow or leave it at the nearest pickup point.\n\nTrack live at dhl.com/track.',
|
||||
),
|
||||
messageId: '<delivery-1Z999AA1@dhl.com>',
|
||||
},
|
||||
|
||||
// Notion
|
||||
{
|
||||
id: 'demo-email-notion',
|
||||
threadId: 'demo-thread-notion',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 4100,
|
||||
receivedAt: demoDate(-2, -16),
|
||||
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
|
||||
to: [USER],
|
||||
subject: 'Olivia shared "Q1 2026 - design north star" with you',
|
||||
sentAt: demoDate(-2, -16),
|
||||
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star',
|
||||
),
|
||||
messageId: '<share-northwind-q1@mail.notion.so>',
|
||||
},
|
||||
|
||||
// Spotify wrap
|
||||
{
|
||||
id: 'demo-email-spotify',
|
||||
threadId: 'demo-thread-spotify',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 7400,
|
||||
receivedAt: demoDate(-4, -8),
|
||||
from: [{ name: 'Spotify', email: 'no-reply@spotify.com' }],
|
||||
to: [USER],
|
||||
subject: 'Your year in music is ready',
|
||||
sentAt: demoDate(-4, -8),
|
||||
preview: 'You spent 38,420 minutes listening this year. Your top artist was Big Thief, and your top genre was indie folk.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Your year, in music.\n\n38,420 minutes listened\nTop artist: Big Thief\nTop song: "Vampire Empire"\nTop genre: indie folk\nDiscover Weekly hit rate: 41%\n\nOpen Spotify to see your full Wrapped.',
|
||||
),
|
||||
messageId: '<wrapped-2025@spotify.com>',
|
||||
},
|
||||
|
||||
// Booking.com confirmation
|
||||
{
|
||||
id: 'demo-email-booking',
|
||||
threadId: 'demo-thread-booking',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 32100,
|
||||
receivedAt: demoDate(-5, -10),
|
||||
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
|
||||
to: [USER],
|
||||
subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 22–25)',
|
||||
sentAt: demoDate(-5, -10),
|
||||
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
'Your booking is confirmed.\n\nHotel Lago, Lake Como (Italy)\nCheck-in: Dec 22, after 15:00\nCheck-out: Dec 25, before 11:00\n\nRoom: Lake-view double, breakfast included\nTotal: €612 (paid)\n\nConfirmation number: 4892-7714-3320\n\nYour voucher is attached. Show it at reception.',
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-6', blobId: 'demo-blob-att-6', size: 31000, name: 'booking-voucher-4892-7714-3320.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<conf-4892-7714-3320@booking.com>',
|
||||
},
|
||||
|
||||
// Substack post
|
||||
{
|
||||
id: 'demo-email-substack',
|
||||
threadId: 'demo-thread-substack',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 22400,
|
||||
receivedAt: demoDate(-1, -12),
|
||||
from: [{ name: 'Robin Sloan', email: 'robin@substack.com' }],
|
||||
to: [USER],
|
||||
subject: 'a small newsletter about a small forge',
|
||||
sentAt: demoDate(-1, -12),
|
||||
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.",
|
||||
),
|
||||
messageId: '<nov-2025@robin.substack.com>',
|
||||
},
|
||||
|
||||
// Recruiter cold outreach
|
||||
{
|
||||
id: 'demo-email-recruiter',
|
||||
threadId: 'demo-thread-recruiter',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 3200,
|
||||
receivedAt: demoDate(0, -10),
|
||||
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||
to: [USER],
|
||||
subject: 'Senior role - Distributed Systems - €180-220k + equity',
|
||||
sentAt: demoDate(0, -10),
|
||||
preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners",
|
||||
),
|
||||
messageId: '<outreach-jh-2025-11@talent-partners.example>',
|
||||
},
|
||||
|
||||
// Dentist reminder
|
||||
{
|
||||
id: 'demo-email-dentist',
|
||||
threadId: 'demo-thread-dentist',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 2200,
|
||||
receivedAt: demoDate(-1, -2),
|
||||
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
|
||||
to: [USER],
|
||||
subject: 'Appointment reminder - Tuesday at 10:00',
|
||||
sentAt: demoDate(-1, -2),
|
||||
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hello,\n\nThis is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM with Dr. Smith.\n\nLocation: 123 Medical Plaza, Suite 4\n\nNeed to reschedule? Reply to this email or call (555) 010-7878.\n\nSee you Tuesday!\nDr. Smith's office",
|
||||
),
|
||||
messageId: '<appt-reminder-dr-smith@drsmith.example>',
|
||||
},
|
||||
|
||||
// ── Sent ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-6',
|
||||
@@ -163,16 +519,15 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 2100,
|
||||
receivedAt: demoDate(-1, -4),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
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...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>',
|
||||
},
|
||||
{
|
||||
@@ -182,18 +537,37 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true },
|
||||
size: 1800,
|
||||
receivedAt: demoDate(-4, -2),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-sent-mom',
|
||||
threadId: 'demo-thread-mom',
|
||||
mailboxIds: { 'demo-mailbox-sent': true },
|
||||
keywords: { $seen: true },
|
||||
size: 1400,
|
||||
receivedAt: demoDate(0, -2, -10),
|
||||
from: [USER],
|
||||
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
||||
subject: 'Re: when are you coming home?',
|
||||
sentAt: demoDate(0, -2, -10),
|
||||
preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.",
|
||||
),
|
||||
messageId: '<re-mom-1@example.com>',
|
||||
inReplyTo: ['<5a8c-mom@example.com>'],
|
||||
references: ['<5a8c-mom@example.com>'],
|
||||
},
|
||||
|
||||
// ── Drafts ──────────────────────────────────────────────────
|
||||
{
|
||||
@@ -203,18 +577,35 @@ export function createDemoEmails(): Email[] {
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: 900,
|
||||
receivedAt: demoDate(0, -1),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
from: [USER],
|
||||
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...',
|
||||
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- ' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-draft-recruiter',
|
||||
threadId: 'demo-thread-draft-recruiter',
|
||||
mailboxIds: { 'demo-mailbox-drafts': true },
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: 720,
|
||||
receivedAt: demoDate(0, -8),
|
||||
from: [USER],
|
||||
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||
subject: 'Re: Senior role - Distributed Systems',
|
||||
sentAt: demoDate(0, -8),
|
||||
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi Jennifer,\n\nThanks for reaching out. I'm not actively looking, but the role sounds interesting enough that I'd be open to a quick call. A few questions before we set something up:\n\n- ",
|
||||
),
|
||||
messageId: '<draft-recruiter@example.com>',
|
||||
},
|
||||
|
||||
// ── Trash ───────────────────────────────────────────────────
|
||||
{
|
||||
@@ -225,15 +616,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 15200,
|
||||
receivedAt: demoDate(-5, -3),
|
||||
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
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.' },
|
||||
},
|
||||
...textOnly(
|
||||
'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.',
|
||||
),
|
||||
messageId: '<promo-1@promostore.example>',
|
||||
},
|
||||
{
|
||||
@@ -244,15 +634,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 2300,
|
||||
receivedAt: demoDate(-7, 0),
|
||||
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
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.' },
|
||||
},
|
||||
...textOnly(
|
||||
'Your account password was successfully changed. If you did not make this change, please contact support immediately.',
|
||||
),
|
||||
messageId: '<notification-1@service.example>',
|
||||
},
|
||||
|
||||
@@ -265,15 +654,14 @@ export function createDemoEmails(): Email[] {
|
||||
size: 4500,
|
||||
receivedAt: demoDate(-2, -7),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
subject: '[Project] Sprint Planning Agenda',
|
||||
sentAt: demoDate(-2, -7),
|
||||
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>',
|
||||
},
|
||||
{
|
||||
@@ -284,17 +672,37 @@ export function createDemoEmails(): Email[] {
|
||||
size: 3200,
|
||||
receivedAt: demoDate(0, -8),
|
||||
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
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...',
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
"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>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-roadmap',
|
||||
threadId: 'demo-thread-roadmap',
|
||||
mailboxIds: { 'demo-mailbox-projects': true },
|
||||
keywords: {},
|
||||
size: 4900,
|
||||
receivedAt: demoDate(-1, -15),
|
||||
from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }],
|
||||
to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }],
|
||||
subject: '[Project] Q1 2026 roadmap - first cut',
|
||||
sentAt: demoDate(-1, -15),
|
||||
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
"Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM",
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<roadmap-q1-2026@company.example>',
|
||||
},
|
||||
|
||||
// ── Archive ─────────────────────────────────────────────────
|
||||
{
|
||||
@@ -304,18 +712,35 @@ export function createDemoEmails(): Email[] {
|
||||
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' }],
|
||||
from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }],
|
||||
to: [USER],
|
||||
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' },
|
||||
},
|
||||
...textOnly(
|
||||
'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,\nMaria - People Ops',
|
||||
),
|
||||
messageId: '<hr-policy-1@company.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-archive-support',
|
||||
threadId: 'demo-thread-archive-support',
|
||||
mailboxIds: { 'demo-mailbox-archive': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3400,
|
||||
receivedAt: demoDate(-21, -4),
|
||||
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
|
||||
to: [USER],
|
||||
subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases',
|
||||
sentAt: demoDate(-21, -4),
|
||||
preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support",
|
||||
),
|
||||
messageId: '<ticket-438201-resolved@fastmail.com>',
|
||||
},
|
||||
|
||||
// ── Receipts ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -325,17 +750,37 @@ export function createDemoEmails(): Email[] {
|
||||
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',
|
||||
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
|
||||
to: [USER],
|
||||
subject: 'Invoice #INV-2024-1042 - €49.99 (paid)',
|
||||
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.' },
|
||||
preview: 'Your payment of €49.99 has been processed successfully...',
|
||||
hasAttachment: true,
|
||||
...textOnly(
|
||||
'Payment Confirmation\n\nAmount: €49.99\nDate: 3 days ago\nInvoice: INV-2024-1042\nService: CX22 dedicated (Helsinki, monthly)\n\nThank you for your payment.',
|
||||
),
|
||||
attachments: [
|
||||
{ partId: 'att-8', blobId: 'demo-blob-att-8', size: 28000, name: 'INV-2024-1042.pdf', type: 'application/pdf' },
|
||||
],
|
||||
messageId: '<receipt-1@hetzner.com>',
|
||||
},
|
||||
messageId: '<receipt-1@cloudprovider.example>',
|
||||
{
|
||||
id: 'demo-email-receipts-domain',
|
||||
threadId: 'demo-thread-receipts-domain',
|
||||
mailboxIds: { 'demo-mailbox-receipts': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3100,
|
||||
receivedAt: demoDate(-9, -8),
|
||||
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
|
||||
to: [USER],
|
||||
subject: 'Renewal confirmation - example.com (1 year)',
|
||||
sentAt: demoDate(-9, -8),
|
||||
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun",
|
||||
),
|
||||
messageId: '<renewal-example.com@porkbun.com>',
|
||||
},
|
||||
|
||||
// ── Spam ────────────────────────────────────────────────────
|
||||
@@ -347,16 +792,51 @@ export function createDemoEmails(): Email[] {
|
||||
size: 8900,
|
||||
receivedAt: demoDate(-1, -9),
|
||||
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [USER],
|
||||
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]' },
|
||||
},
|
||||
...textOnly(
|
||||
'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>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-spam-phish',
|
||||
threadId: 'demo-thread-spam-phish',
|
||||
mailboxIds: { 'demo-mailbox-junk': true },
|
||||
keywords: {},
|
||||
size: 4600,
|
||||
receivedAt: demoDate(-2, -3),
|
||||
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
|
||||
to: [USER],
|
||||
subject: 'URGENT: Unusual activity on your account - verify within 24 hours',
|
||||
sentAt: demoDate(-2, -3),
|
||||
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
"We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]",
|
||||
),
|
||||
messageId: '<phish-1@secur1ty-bank.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-spam-crypto',
|
||||
threadId: 'demo-thread-spam-crypto',
|
||||
mailboxIds: { 'demo-mailbox-junk': true },
|
||||
keywords: {},
|
||||
size: 6800,
|
||||
receivedAt: demoDate(-3, -19),
|
||||
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
|
||||
to: [USER],
|
||||
subject: '🚀 The coin Elon won\'t tell you about - 1000x potential',
|
||||
sentAt: demoDate(-3, -19),
|
||||
preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...',
|
||||
hasAttachment: false,
|
||||
...textOnly(
|
||||
'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier. Limited spots. No experience needed.\n\n[Demo spam.]',
|
||||
),
|
||||
messageId: '<spam-crypto@cryptogrowth.example>',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@ 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 };
|
||||
|
||||
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
|
||||
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-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 22, unreadEmails: 13, totalThreads: 20, unreadThreads: 12, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 2, unreadEmails: 0, totalThreads: 2, 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 },
|
||||
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 3, totalThreads: 3, unreadThreads: 3, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 3, unreadEmails: 2, totalThreads: 3, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
} as const;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
|
||||
);
|
||||
}
|
||||
|
||||
export function plainTextToComposerBody(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature with stricter rules
|
||||
* Only allows basic formatting, no external resources
|
||||
* Allows basic formatting plus <img> for company logos
|
||||
*/
|
||||
export const SIGNATURE_SANITIZE_CONFIG = {
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class'],
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature for storage and display
|
||||
* Sanitize HTML signature for storage and display.
|
||||
* img src is restricted to https: or base64-embedded raster data: URIs
|
||||
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
|
||||
* bytes inside a data: URI. Images with a disallowed src are removed
|
||||
* entirely so they don't render as broken-image icons.
|
||||
* @param html - User-provided HTML signature
|
||||
* @returns Sanitized signature (no scripts, no external resources)
|
||||
*/
|
||||
export function sanitizeSignatureHtml(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName !== 'IMG') return;
|
||||
const src = node.getAttribute('src');
|
||||
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
try {
|
||||
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
|
||||
} finally {
|
||||
DOMPurify.removeAllHooks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
type SameSite = 'lax' | 'none' | 'strict';
|
||||
|
||||
export function getCookieOptions() {
|
||||
const sameSite = configManager.get<SameSite>('cookieSameSite', 'lax');
|
||||
const secure = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: (sameSite === 'none' || process.env.NODE_ENV === 'production');
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: COOKIE_SAME_SITE,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface ParsedMailto {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
const MAX_SUBJECT_LENGTH = 998;
|
||||
const MAX_BODY_LENGTH = 64 * 1024;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
return value.replace(CONTROL_CHARS, "");
|
||||
}
|
||||
|
||||
function stripBodyControlChars(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
return stripControlChars(value)
|
||||
.split(",")
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
type QueryParam = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function getQueryValue(searchParams: QueryParam[], key: string): string {
|
||||
const values: string[] = [];
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
for (const { key: paramKey, value } of searchParams) {
|
||||
if (paramKey.toLowerCase() === lowerKey) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function decodePathname(pathname: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(pathname || "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string | null {
|
||||
try {
|
||||
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
|
||||
// encoding, a literal '+' is part of the value and must not become space.
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuery(query: string): QueryParam[] | null {
|
||||
if (!query) return [];
|
||||
|
||||
const params: QueryParam[] = [];
|
||||
for (const part of query.split("&")) {
|
||||
if (!part) continue;
|
||||
const separatorIndex = part.indexOf("=");
|
||||
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
|
||||
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
|
||||
const key = decodeQueryPart(rawKey);
|
||||
const value = decodeQueryPart(rawValue);
|
||||
if (key === null || value === null) return null;
|
||||
params.push({ key, value });
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function parseMailto(raw: string): ParsedMailto | null {
|
||||
if (!raw.toLowerCase().startsWith("mailto:")) return null;
|
||||
|
||||
const addressAndQuery = raw.slice("mailto:".length);
|
||||
const queryIndex = addressAndQuery.indexOf("?");
|
||||
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
|
||||
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
|
||||
|
||||
const decodedPathname = decodePathname(rawPathname);
|
||||
if (decodedPathname === null) return null;
|
||||
const searchParams = parseQuery(rawQuery);
|
||||
if (searchParams === null) return null;
|
||||
|
||||
const to = [
|
||||
...splitRecipients(decodedPathname),
|
||||
...splitRecipients(getQueryValue(searchParams, "to")),
|
||||
].slice(0, MAX_RECIPIENTS);
|
||||
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
|
||||
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
|
||||
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
|
||||
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { ParsedMailto } from "./mailto";
|
||||
import type { ParsedWebcal } from "./webcal";
|
||||
|
||||
const MAILTO_KEY = "bulwark:pending-mailto";
|
||||
const WEBCAL_KEY = "bulwark:pending-webcal";
|
||||
const PROTOCOL_CHANNEL = "bulwark:protocol-handlers";
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
const MAILTO_REQUEST = "mailto-request";
|
||||
const MAILTO_CANDIDATE = "mailto-candidate";
|
||||
const MAILTO_ACK = "mailto-ack";
|
||||
const OPEN_MAILTO_IN_CLIENT = "open-mailto-in-client";
|
||||
const MAILTO_CLIENT_READY = "mailto-client-ready";
|
||||
const MAILTO_CLIENT_GONE = "mailto-client-gone";
|
||||
const PENDING_MAILTO_EVENT = "bulwark:pending-mailto";
|
||||
const PENDING_WEBCAL_EVENT = "bulwark:pending-webcal";
|
||||
|
||||
type PendingValue<T> = T & { createdAt: number };
|
||||
type PendingMailtoRequest = { type: typeof MAILTO_REQUEST; id: string; value: ParsedMailto; clientId?: string };
|
||||
type PendingMailtoCandidate = { type: typeof MAILTO_CANDIDATE; id: string; clientId: string; priority: number };
|
||||
type PendingMailtoAck = { type: typeof MAILTO_ACK; id: string };
|
||||
type OpenMailtoInClientRequest = {
|
||||
type: typeof OPEN_MAILTO_IN_CLIENT;
|
||||
id: string;
|
||||
value: ParsedMailto;
|
||||
clientId?: string;
|
||||
};
|
||||
type ProtocolClientInfo = {
|
||||
path: string;
|
||||
standalone: boolean;
|
||||
clientId?: string;
|
||||
focusNotificationTitle?: string;
|
||||
focusNotificationBody?: string;
|
||||
};
|
||||
|
||||
function savePending<T>(key: string, value: T) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({ ...value, createdAt: Date.now() }));
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened/private browser modes.
|
||||
}
|
||||
}
|
||||
|
||||
function consumePending<T>(key: string, validate: (value: unknown) => value is T): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingValue<unknown>;
|
||||
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
return validate(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPending<T>(key: string, validate: (value: unknown) => value is T): boolean {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return false;
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingValue<unknown>;
|
||||
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
|
||||
sessionStorage.removeItem(key);
|
||||
return false;
|
||||
}
|
||||
return validate(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isParsedMailto(value: unknown): value is ParsedMailto {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<ParsedMailto>;
|
||||
return Array.isArray(candidate.to)
|
||||
&& Array.isArray(candidate.cc)
|
||||
&& Array.isArray(candidate.bcc)
|
||||
&& typeof candidate.subject === "string"
|
||||
&& typeof candidate.body === "string";
|
||||
}
|
||||
|
||||
function isPendingMailtoRequest(value: unknown): value is PendingMailtoRequest {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoRequest>;
|
||||
return candidate.type === MAILTO_REQUEST
|
||||
&& typeof candidate.id === "string"
|
||||
&& isParsedMailto(candidate.value)
|
||||
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
|
||||
}
|
||||
|
||||
function isPendingMailtoAck(value: unknown, id: string): value is PendingMailtoAck {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoAck>;
|
||||
return candidate.type === MAILTO_ACK && candidate.id === id;
|
||||
}
|
||||
|
||||
function isPendingMailtoCandidate(value: unknown, id: string): value is PendingMailtoCandidate {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoCandidate>;
|
||||
return candidate.type === MAILTO_CANDIDATE
|
||||
&& candidate.id === id
|
||||
&& typeof candidate.clientId === "string"
|
||||
&& typeof candidate.priority === "number";
|
||||
}
|
||||
|
||||
function isOpenMailtoInClientRequest(value: unknown): value is OpenMailtoInClientRequest {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<OpenMailtoInClientRequest>;
|
||||
return candidate.type === OPEN_MAILTO_IN_CLIENT
|
||||
&& typeof candidate.id === "string"
|
||||
&& isParsedMailto(candidate.value)
|
||||
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
|
||||
}
|
||||
|
||||
function createRequestId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const BROWSER_CLIENT_ID = createRequestId();
|
||||
|
||||
function getMailtoClientPriority(info: ProtocolClientInfo): number {
|
||||
const isMailSection = info.path === "/" || info.path === "";
|
||||
if (info.standalone && isMailSection) return 0;
|
||||
if (isMailSection) return 1;
|
||||
if (info.standalone) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
|
||||
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
|
||||
}
|
||||
|
||||
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
|
||||
if (typeof navigator === "undefined"
|
||||
|| !("serviceWorker" in navigator)
|
||||
|| typeof MessageChannel === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<null>((resolve) => globalThis.setTimeout(() => resolve(null), timeoutMs)),
|
||||
]);
|
||||
if (!registration) return false;
|
||||
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
if (!worker) return false;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
channel.port1.close();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.port1.close();
|
||||
resolve(event.data?.delivered === true);
|
||||
};
|
||||
|
||||
worker.postMessage({
|
||||
type: OPEN_MAILTO_IN_CLIENT,
|
||||
id: createRequestId(),
|
||||
value,
|
||||
} satisfies OpenMailtoInClientRequest, [channel.port2]);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyServiceWorker(
|
||||
type: typeof MAILTO_CLIENT_READY | typeof MAILTO_CLIENT_GONE,
|
||||
info?: ProtocolClientInfo,
|
||||
) {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
worker?.postMessage({ type, ...info });
|
||||
})
|
||||
.catch(() => {
|
||||
// Service worker registration is optional for local/dev environments.
|
||||
});
|
||||
}
|
||||
|
||||
function isParsedWebcal(value: unknown): value is ParsedWebcal {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<ParsedWebcal>;
|
||||
return typeof candidate.originalUrl === "string"
|
||||
&& typeof candidate.subscriptionUrl === "string"
|
||||
&& typeof candidate.suggestedName === "string";
|
||||
}
|
||||
|
||||
export function savePendingMailto(value: ParsedMailto) {
|
||||
savePending(MAILTO_KEY, value);
|
||||
}
|
||||
|
||||
export function consumePendingMailto(): ParsedMailto | null {
|
||||
return consumePending(MAILTO_KEY, isParsedMailto);
|
||||
}
|
||||
|
||||
export function notifyPendingMailto() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(PENDING_MAILTO_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToPendingMailto(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(PENDING_MAILTO_EVENT, callback);
|
||||
return () => window.removeEventListener(PENDING_MAILTO_EVENT, callback);
|
||||
}
|
||||
|
||||
async function requestMailtoViaBroadcastChannel(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
|
||||
if (typeof BroadcastChannel === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const id = createRequestId();
|
||||
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
|
||||
const candidates: PendingMailtoCandidate[] = [];
|
||||
let selected = false;
|
||||
let selectionTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
const candidateWindowMs = Math.min(75, Math.max(25, Math.floor(timeoutMs / 3)));
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
|
||||
channel.close();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const selectCandidate = () => {
|
||||
if (selected) return;
|
||||
selected = true;
|
||||
|
||||
const best = candidates.sort((a, b) => a.priority - b.priority)[0];
|
||||
if (!best) {
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.close();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
channel.postMessage({
|
||||
type: OPEN_MAILTO_IN_CLIENT,
|
||||
id,
|
||||
clientId: best.clientId,
|
||||
value,
|
||||
} satisfies OpenMailtoInClientRequest);
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
if (isPendingMailtoCandidate(event.data, id)) {
|
||||
candidates.push(event.data);
|
||||
selectionTimer ??= globalThis.setTimeout(selectCandidate, candidateWindowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPendingMailtoAck(event.data, id)) {
|
||||
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.close();
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
channel.postMessage({ type: MAILTO_REQUEST, id, value } satisfies PendingMailtoRequest);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestOpenMailtoInExistingClient(value: ParsedMailto, timeoutMs = 300): Promise<boolean> {
|
||||
if (await requestMailtoViaServiceWorker(value, timeoutMs)) return true;
|
||||
return requestMailtoViaBroadcastChannel(value, timeoutMs);
|
||||
}
|
||||
|
||||
export function listenForMailtoRequests(
|
||||
onMailto: (value: ParsedMailto) => void,
|
||||
getClientInfo: () => ProtocolClientInfo = getDefaultProtocolClientInfo,
|
||||
): () => void {
|
||||
const cleanup: Array<() => void> = [];
|
||||
const clientInfo = getClientInfo();
|
||||
|
||||
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
||||
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
if (isPendingMailtoRequest(event.data)) {
|
||||
if (event.data.clientId !== undefined && event.data.clientId !== BROWSER_CLIENT_ID) return;
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
onMailto(event.data.value);
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
|
||||
notifyServiceWorker(MAILTO_CLIENT_READY, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
|
||||
cleanup.push(() => {
|
||||
notifyServiceWorker(MAILTO_CLIENT_GONE, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
|
||||
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel !== "undefined") {
|
||||
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
|
||||
channel.onmessage = (event) => {
|
||||
if (isPendingMailtoRequest(event.data)) {
|
||||
channel.postMessage({
|
||||
type: MAILTO_CANDIDATE,
|
||||
id: event.data.id,
|
||||
clientId: BROWSER_CLIENT_ID,
|
||||
priority: getMailtoClientPriority(getClientInfo()),
|
||||
} satisfies PendingMailtoCandidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenMailtoInClientRequest(event.data) || event.data.clientId !== BROWSER_CLIENT_ID) return;
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
onMailto(event.data.value);
|
||||
channel.postMessage({ type: MAILTO_ACK, id: event.data.id } satisfies PendingMailtoAck);
|
||||
};
|
||||
cleanup.push(() => channel.close());
|
||||
}
|
||||
|
||||
return () => cleanup.forEach((dispose) => dispose());
|
||||
}
|
||||
|
||||
export function savePendingWebcal(value: ParsedWebcal) {
|
||||
savePending(WEBCAL_KEY, value);
|
||||
}
|
||||
|
||||
export function consumePendingWebcal(): ParsedWebcal | null {
|
||||
return consumePending(WEBCAL_KEY, isParsedWebcal);
|
||||
}
|
||||
|
||||
export function notifyPendingWebcal() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(PENDING_WEBCAL_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToPendingWebcal(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(PENDING_WEBCAL_EVENT, callback);
|
||||
return () => window.removeEventListener(PENDING_WEBCAL_EVENT, callback);
|
||||
}
|
||||
|
||||
export function hasPendingWebcal(): boolean {
|
||||
return hasPending(WEBCAL_KEY, isParsedWebcal);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ParsedWebcal {
|
||||
originalUrl: string;
|
||||
subscriptionUrl: string;
|
||||
suggestedName: string;
|
||||
}
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim();
|
||||
}
|
||||
|
||||
function extensionlessName(value: string): string {
|
||||
return value.replace(/\.(ics|ical)$/i, "");
|
||||
}
|
||||
|
||||
function decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWebcal(raw: string): ParsedWebcal | null {
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol === "webcal:" || url.protocol === "webcals:") {
|
||||
url = new URL(raw.replace(/^webcals?:/i, "https:"));
|
||||
} else if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionUrl = url.toString();
|
||||
const queryName = stripControlChars(url.searchParams.get("name") || "");
|
||||
const pathSegment = stripControlChars(decodePathSegment(url.pathname.split("/").filter(Boolean).pop() || ""));
|
||||
const suggestedName = queryName || extensionlessName(pathSegment) || url.hostname;
|
||||
|
||||
return {
|
||||
originalUrl: raw,
|
||||
subscriptionUrl,
|
||||
suggestedName,
|
||||
};
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export interface ReplyFromResolution {
|
||||
/**
|
||||
* Override for the outgoing `From:` header. Populated when the incoming
|
||||
* message was delivered to an address on a domain the user owns (by
|
||||
* identity) but that isn't itself a configured identity — typical
|
||||
* identity) but that isn't itself a configured identity - typical
|
||||
* domain-catch-all deployments. When set, the composer should put this
|
||||
* address (and `overrideName`) in the message's From header while sending
|
||||
* through the chosen identity.
|
||||
|
||||
@@ -110,13 +110,18 @@ export function getPlainTextSignature(signature?: SignatureSource | null): strin
|
||||
return '';
|
||||
}
|
||||
|
||||
export function appendPlainTextSignature(body: string, signature?: SignatureSource | null): string {
|
||||
export function appendPlainTextSignature(
|
||||
body: string,
|
||||
signature?: SignatureSource | null,
|
||||
options: { separator?: boolean } = {},
|
||||
): string {
|
||||
const plainTextSignature = getPlainTextSignature(signature);
|
||||
if (!plainTextSignature) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return `${body}\n\n-- \n${plainTextSignature}`;
|
||||
const sep = options.separator === false ? '\n\n' : '\n\n-- \n';
|
||||
return `${body}${sep}${plainTextSignature}`;
|
||||
}
|
||||
|
||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"nav_label": "Navigace",
|
||||
"add_app": "Aplikace"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Výchozí aplikace",
|
||||
"description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v Bulwarku. Technicky se Bulwark registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.",
|
||||
"unsupported": "Tento prohlížeč nebo toto připojení nepodporuje ruční registraci obslužné aplikace protokolu. Nainstalovanou PWA můžete případně použít přes nastavení prohlížeče nebo systému.",
|
||||
"mailto_label": "E-mailové odkazy",
|
||||
"mailto_description": "Otevře odkazy mailto: v Bulwarku s předvyplněným editorem zprávy.",
|
||||
"protocol_open_mode_label": "Při otevírání odkazů protokolů",
|
||||
"protocol_open_mode_description": "Zvolte, zda má Bulwark otevírat odkazy mailto: a webcal: v nové kartě, nebo znovu použít otevřenou relaci. Volba aktivní relace vyžaduje oprávnění k oznámením, abyste mohli kliknout na záložní oznámení a přenést Bulwark do popředí, pokud prohlížeč blokuje fokus.",
|
||||
"protocol_open_mode_active_session": "Otevřít v aktivní relaci, pokud je to možné",
|
||||
"protocol_open_mode_new_tab": "Vždy otevřít novou kartu",
|
||||
"focus_notification_title": "Otevřít Bulwark",
|
||||
"focus_notification_body": "Odkaz byl otevřen v Bulwarku. Kliknutím přenesete okno do popředí.",
|
||||
"webcal_label": "Kalendářové odkazy",
|
||||
"webcal_description": "Otevře odkazy webcal: v Bulwarku s předvyplněným dialogem pro odběr kalendáře.",
|
||||
"register_mailto": "Registrovat e-mailovou aplikaci",
|
||||
"register_webcal": "Registrovat kalendářovou aplikaci",
|
||||
"mailto_registered": "Registrace obsluhy e-mailových odkazů byla vyžádána",
|
||||
"webcal_registered": "Registrace obsluhy kalendářových odkazů byla vyžádána",
|
||||
"registration_failed": "Registrace obslužné aplikace protokolu selhala",
|
||||
"opening_mailto": "Otevírá se editor...",
|
||||
"opening_webcal": "Otevírá se kalendář...",
|
||||
"browser_note": "Prohlížeč nebo operační systém vás může požádat o potvrzení a může vyžadovat, aby byl Bulwark nainstalovaný, než jej půjde vybrat jako výchozí aplikaci.",
|
||||
"select_account_title": "Vybrat účet",
|
||||
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
|
||||
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
|
||||
"select_account_note": "Tato volba platí jen pro tento odkaz protokolu.",
|
||||
"detail_to": "Komu",
|
||||
"detail_subject": "Předmět",
|
||||
"detail_no_subject": "Bez předmětu",
|
||||
"detail_calendar": "Kalendář",
|
||||
"detail_source": "Zdroj",
|
||||
"active_account": "Aktivní",
|
||||
"switching_account": "Přepínání účtu..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikace postranního panelu",
|
||||
"add_new": "Přidat aplikaci",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Přepsat",
|
||||
"toggle_on": "Zrušit přepsání",
|
||||
"toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu — mění se pouze viditelné záhlaví Od.",
|
||||
"toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu - mění se pouze viditelné záhlaví Od.",
|
||||
"name_label": "Jméno odesílatele",
|
||||
"name_placeholder": "Jméno",
|
||||
"email_label": "E-mailová adresa odesílatele",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "Soubory",
|
||||
"contacts": "Kontakty",
|
||||
"encryption": "Šifrování",
|
||||
"protocol_handlers": "Výchozí aplikace",
|
||||
"sidebar_apps": "Aplikace postranního panelu",
|
||||
"notifications": "Oznámení",
|
||||
"layout": "Vzhled",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "Před citovaným textem",
|
||||
"below_quote": "Za citovaným textem"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Oddělovač podpisu",
|
||||
"description": "Před podpis přidat standardní oddělovací řádek \"-- \" (RFC 3676). Vypněte, pokud chcete plynule přejít z textu zprávy do podpisu."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Oddělovač sub-adresy",
|
||||
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Soubor překračuje limit 10 MB",
|
||||
"invalid_format": "Neplatný formát souboru kalendáře"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Otevřít odkaz kalendáře",
|
||||
"description": "Jak chcete použít \"{name}\"?",
|
||||
"import_title": "Jednorázově importovat",
|
||||
"import_description": "Načíst události nyní a zkopírovat je do jednoho z vašich kalendářů.",
|
||||
"subscribe_title": "Odebírat",
|
||||
"subscribe_description": "Automaticky synchronizovat tento kalendář jako samostatný kalendář.",
|
||||
"cancel": "Zrušit"
|
||||
},
|
||||
"management": {
|
||||
"title": "Správa kalendáře",
|
||||
"description": "Vytvářejte, přejmenovávejte a přizpůsobujte si své kalendáře. Klikněte pravým tlačítkem na kalendář v postranním panelu pro rychlou změnu jeho barvy.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Úvodní průvodce",
|
||||
"restart_desc": "Přehrát průvodce rozhraním krok za krokem",
|
||||
"restart_button": "Spustit průvodce znovu",
|
||||
"show_on_new_devices_title": "Zobrazit na nových zařízeních",
|
||||
"show_on_new_devices_desc": "Přehrát uvítací banner a průvodce při prvním přihlášení na novém zařízení, i když jste je již dokončili jinde",
|
||||
"sidebar_title": "Vaše poštovní schránky",
|
||||
"sidebar_desc": "Toto je postranní panel se složkami. Kliknutím na libovolnou schránku zobrazíte její zprávy. Můžete vytvářet složky, přetahovat zprávy mezi nimi a okamžitě vidět počet nepřečtených e-mailů.",
|
||||
"compose_title": "Napsat zprávu",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Geteilt"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standard-Apps",
|
||||
"description": "Legen Sie fest, ob E-Mail- und Kalender-Links in Bulwark geöffnet werden. Technisch registriert sich Bulwark dafür als Protokoll-Handler für mailto: und webcal:.",
|
||||
"unsupported": "Dieser Browser oder diese Verbindung unterstützt die manuelle Registrierung von Protokoll-Handlern nicht. Möglicherweise können Sie die installierte PWA trotzdem über Browser- oder Systemeinstellungen verwenden.",
|
||||
"mailto_label": "E-Mail-Links",
|
||||
"mailto_description": "Öffnet mailto:-Links in Bulwark mit vorausgefülltem Editor.",
|
||||
"protocol_open_mode_label": "Beim Öffnen von Protokoll-Links",
|
||||
"protocol_open_mode_description": "Wähle, ob Bulwark mailto:- und webcal:-Links immer in einem neuen Tab öffnet oder eine offene Sitzung wiederverwendet. Für die aktive Sitzung benötigt Bulwark Benachrichtigungen, damit du das Fenster per Klick in den Vordergrund holen kannst, falls der Browser den Fokus blockiert.",
|
||||
"protocol_open_mode_active_session": "Wenn möglich in aktiver Sitzung öffnen",
|
||||
"protocol_open_mode_new_tab": "Immer neuen Tab öffnen",
|
||||
"focus_notification_title": "Bulwark öffnen",
|
||||
"focus_notification_body": "Der Link wurde in Bulwark geöffnet. Klicke hier, um das Fenster in den Vordergrund zu holen.",
|
||||
"webcal_label": "Kalender-Links",
|
||||
"webcal_description": "Öffnet webcal:-Links in Bulwark mit vorausgefülltem Kalender-Abo-Dialog.",
|
||||
"register_mailto": "Als E-Mail-App registrieren",
|
||||
"register_webcal": "Als Kalender-App registrieren",
|
||||
"mailto_registered": "Registrierung als E-Mail-Handler angefordert",
|
||||
"webcal_registered": "Registrierung als Kalender-Handler angefordert",
|
||||
"registration_failed": "Protokoll-Handler konnte nicht registriert werden",
|
||||
"opening_mailto": "Editor wird geöffnet...",
|
||||
"opening_webcal": "Kalender wird geöffnet...",
|
||||
"browser_note": "Ihr Browser oder Betriebssystem kann eine Bestätigung verlangen. Eventuell muss Bulwark installiert sein, bevor es als Standard-App ausgewählt werden kann.",
|
||||
"select_account_title": "Account auswählen",
|
||||
"select_mailto_account": "Wähle aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
|
||||
"select_webcal_account": "Wähle aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
|
||||
"select_account_note": "Diese Auswahl gilt nur für diesen Protokoll-Link.",
|
||||
"detail_to": "An",
|
||||
"detail_subject": "Betreff",
|
||||
"detail_no_subject": "Ohne Betreff",
|
||||
"detail_calendar": "Kalender",
|
||||
"detail_source": "Quelle",
|
||||
"active_account": "Aktiv",
|
||||
"switching_account": "Account wird gewechselt..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar-Apps",
|
||||
"add_new": "App hinzufügen",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Überschreiben",
|
||||
"toggle_on": "Überschreibung aufheben",
|
||||
"toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet — nur die sichtbare Absenderkopfzeile ändert sich.",
|
||||
"toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet - nur die sichtbare Absenderkopfzeile ändert sich.",
|
||||
"name_label": "Absendername",
|
||||
"name_placeholder": "Name",
|
||||
"email_label": "Absender-E-Mail-Adresse",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Verschlüsselung",
|
||||
"files": "Dateien",
|
||||
"contacts": "Kontakte",
|
||||
"protocol_handlers": "Standard-Apps",
|
||||
"sidebar_apps": "Sidebar-Apps",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"layout": "Layout",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "Vor zitiertem Text",
|
||||
"below_quote": "Nach zitiertem Text"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Signatur-Trenner",
|
||||
"description": "Der Signatur die Standard-Trennerzeile \"-- \" voranstellen (RFC 3676). Deaktivieren, wenn der Nachrichtentext direkt in die Signatur übergehen soll."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Adress-Trennzeichen",
|
||||
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Datei überschreitet das 10-MB-Limit",
|
||||
"invalid_format": "Ungültiges Kalenderdateiformat"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Kalender-Link öffnen",
|
||||
"description": "Wie möchten Sie \"{name}\" verwenden?",
|
||||
"import_title": "Einmal importieren",
|
||||
"import_description": "Termine jetzt abrufen und in einen Ihrer Kalender kopieren.",
|
||||
"subscribe_title": "Abonnieren",
|
||||
"subscribe_description": "Diesen Kalender automatisch als separaten Kalender synchronisieren.",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"management": {
|
||||
"title": "Kalenderverwaltung",
|
||||
"description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Einführungstour",
|
||||
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
|
||||
"restart_button": "Tour neu starten",
|
||||
"show_on_new_devices_title": "Auf neuen Geräten anzeigen",
|
||||
"show_on_new_devices_desc": "Zeige das Willkommensbanner und die Tour beim ersten Anmelden auf einem neuen Gerät erneut, auch wenn du sie bereits anderswo abgeschlossen hast",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Default apps",
|
||||
"description": "Choose whether email and calendar links open in Bulwark. Technically, Bulwark registers as a protocol handler for mailto: and webcal: links.",
|
||||
"unsupported": "This browser or connection does not support manual protocol-handler registration. You may still be able to use the installed PWA via browser or OS settings.",
|
||||
"mailto_label": "Email links",
|
||||
"mailto_description": "Open mailto: links in Bulwark with a prefilled composer.",
|
||||
"protocol_open_mode_label": "When opening protocol links",
|
||||
"protocol_open_mode_description": "Choose whether Bulwark opens mailto: and webcal: links in a new tab or reuses an open session. The active-session option needs notification permission so you can click a fallback notification to bring Bulwark to the front if the browser blocks focus.",
|
||||
"protocol_open_mode_active_session": "Open in active session if possible",
|
||||
"protocol_open_mode_new_tab": "Always open a new tab",
|
||||
"focus_notification_title": "Open Bulwark",
|
||||
"focus_notification_body": "The link was opened in Bulwark. Click to bring the window to the front.",
|
||||
"webcal_label": "Calendar links",
|
||||
"webcal_description": "Open webcal: links in Bulwark with a prefilled calendar subscription dialog.",
|
||||
"register_mailto": "Register email app",
|
||||
"register_webcal": "Register calendar app",
|
||||
"mailto_registered": "Email handler registration requested",
|
||||
"webcal_registered": "Calendar handler registration requested",
|
||||
"registration_failed": "Protocol handler registration failed",
|
||||
"opening_mailto": "Opening composer...",
|
||||
"opening_webcal": "Opening calendar...",
|
||||
"browser_note": "Your browser or operating system may ask you to confirm this and may require Bulwark to be installed before it can be selected as the default app.",
|
||||
"select_account_title": "Choose account",
|
||||
"select_mailto_account": "Choose which account should open this email link.",
|
||||
"select_webcal_account": "Choose which account should open this calendar link.",
|
||||
"select_account_note": "This only applies to this protocol link.",
|
||||
"detail_to": "To",
|
||||
"detail_subject": "Subject",
|
||||
"detail_no_subject": "No subject",
|
||||
"detail_calendar": "Calendar",
|
||||
"detail_source": "Source",
|
||||
"active_account": "Active",
|
||||
"switching_account": "Switching account..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar Apps",
|
||||
"add_new": "Add App",
|
||||
@@ -534,7 +568,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Override",
|
||||
"toggle_on": "Cancel override",
|
||||
"toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity — only the visible From header changes.",
|
||||
"toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.",
|
||||
"name_label": "From name",
|
||||
"name_placeholder": "Name",
|
||||
"email_label": "From email address",
|
||||
@@ -736,6 +770,7 @@
|
||||
"files": "Files",
|
||||
"contacts": "Contacts",
|
||||
"encryption": "Encryption",
|
||||
"protocol_handlers": "Default apps",
|
||||
"sidebar_apps": "Sidebar Apps",
|
||||
"notifications": "Notifications",
|
||||
"layout": "Layout",
|
||||
@@ -980,6 +1015,10 @@
|
||||
"above_quote": "Before quoted text",
|
||||
"below_quote": "After quoted text"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Signature Delimiter",
|
||||
"description": "Prefix the signature with the standard \"-- \" delimiter line (RFC 3676). Turn off if you'd rather flow straight from your message into the signature."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-Address Delimiter",
|
||||
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
|
||||
@@ -2441,6 +2480,15 @@
|
||||
"file_too_large": "File exceeds 10MB limit",
|
||||
"invalid_format": "Invalid calendar file format"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Open calendar link",
|
||||
"description": "How would you like to use \"{name}\"?",
|
||||
"import_title": "Import once",
|
||||
"import_description": "Fetch the events now and copy them into one of your calendars.",
|
||||
"subscribe_title": "Subscribe",
|
||||
"subscribe_description": "Keep this calendar synced automatically as a separate calendar.",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"management": {
|
||||
"title": "Calendar Management",
|
||||
"description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.",
|
||||
@@ -2819,6 +2867,8 @@
|
||||
"restart_title": "Introductory tour",
|
||||
"restart_desc": "Replay the guided walkthrough of the interface",
|
||||
"restart_button": "Restart tour",
|
||||
"show_on_new_devices_title": "Show on new devices",
|
||||
"show_on_new_devices_desc": "Replay the welcome banner and tour the first time you sign in on a new device, even if you've already completed them elsewhere",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartido"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplicaciones predeterminadas",
|
||||
"description": "Elige si los enlaces de correo y calendario se abren en Bulwark. Técnicamente, Bulwark se registra como controlador de protocolo para enlaces mailto: y webcal:.",
|
||||
"unsupported": "Este navegador o esta conexión no admite el registro manual de controladores de protocolo. Es posible que aún puedas usar la PWA instalada desde la configuración del navegador o del sistema.",
|
||||
"mailto_label": "Enlaces de correo",
|
||||
"mailto_description": "Abre enlaces mailto: en Bulwark con el redactor rellenado previamente.",
|
||||
"protocol_open_mode_label": "Al abrir enlaces de protocolo",
|
||||
"protocol_open_mode_description": "Elige si Bulwark abre los enlaces mailto: y webcal: en una nueva pestaña o reutiliza una sesión abierta. La opción de sesión activa necesita permiso de notificaciones para que puedas hacer clic en una notificación de respaldo y traer Bulwark al frente si el navegador bloquea el foco.",
|
||||
"protocol_open_mode_active_session": "Abrir en la sesión activa si es posible",
|
||||
"protocol_open_mode_new_tab": "Abrir siempre una nueva pestaña",
|
||||
"focus_notification_title": "Abrir Bulwark",
|
||||
"focus_notification_body": "El enlace se abrió en Bulwark. Haz clic para traer la ventana al frente.",
|
||||
"webcal_label": "Enlaces de calendario",
|
||||
"webcal_description": "Abre enlaces webcal: en Bulwark con un diálogo de suscripción al calendario rellenado previamente.",
|
||||
"register_mailto": "Registrar aplicación de correo",
|
||||
"register_webcal": "Registrar aplicación de calendario",
|
||||
"mailto_registered": "Registro del controlador de correo solicitado",
|
||||
"webcal_registered": "Registro del controlador de calendario solicitado",
|
||||
"registration_failed": "No se pudo registrar el controlador de protocolo",
|
||||
"opening_mailto": "Abriendo redactor...",
|
||||
"opening_webcal": "Abriendo calendario...",
|
||||
"browser_note": "Tu navegador o sistema operativo puede pedirte confirmación y puede requerir que Bulwark esté instalado antes de poder seleccionarlo como aplicación predeterminada.",
|
||||
"select_account_title": "Elegir cuenta",
|
||||
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
|
||||
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
|
||||
"select_account_note": "Esta selección solo se aplica a este enlace de protocolo.",
|
||||
"detail_to": "Para",
|
||||
"detail_subject": "Asunto",
|
||||
"detail_no_subject": "Sin asunto",
|
||||
"detail_calendar": "Calendario",
|
||||
"detail_source": "Fuente",
|
||||
"active_account": "Activa",
|
||||
"switching_account": "Cambiando de cuenta..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicaciones de la barra lateral",
|
||||
"add_new": "Añadir aplicación",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Anular",
|
||||
"toggle_on": "Cancelar anulación",
|
||||
"toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad — solo cambia el encabezado De visible.",
|
||||
"toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad - solo cambia el encabezado De visible.",
|
||||
"name_label": "Nombre del remitente",
|
||||
"name_placeholder": "Nombre",
|
||||
"email_label": "Dirección de correo del remitente",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Cifrado",
|
||||
"files": "Archivos",
|
||||
"contacts": "Contactos",
|
||||
"protocol_handlers": "Aplicaciones predeterminadas",
|
||||
"sidebar_apps": "Apps de barra lateral",
|
||||
"notifications": "Notificaciones",
|
||||
"layout": "Diseño",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Antes del texto citado",
|
||||
"below_quote": "Después del texto citado"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Delimitador de firma",
|
||||
"description": "Anteponer a la firma la línea delimitadora estándar \"-- \" (RFC 3676). Desactiva si prefieres pasar directamente del mensaje a la firma."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitador de sub-dirección",
|
||||
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "El archivo supera el límite de 10 MB",
|
||||
"invalid_format": "Formato de archivo de calendario no válido"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Abrir enlace de calendario",
|
||||
"description": "¿Cómo quieres usar \"{name}\"?",
|
||||
"import_title": "Importar una vez",
|
||||
"import_description": "Obtén los eventos ahora y cópialos en uno de tus calendarios.",
|
||||
"subscribe_title": "Suscribirse",
|
||||
"subscribe_description": "Mantén este calendario sincronizado automáticamente como un calendario separado.",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestión de calendarios",
|
||||
"description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Tour introductorio",
|
||||
"restart_desc": "Repetir el recorrido guiado por la interfaz",
|
||||
"restart_button": "Reiniciar tour",
|
||||
"show_on_new_devices_title": "Mostrar en dispositivos nuevos",
|
||||
"show_on_new_devices_desc": "Vuelve a mostrar el banner de bienvenida y el tour la primera vez que inicies sesión en un dispositivo nuevo, incluso si ya los completaste en otro lugar",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Partagé"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Applications par défaut",
|
||||
"description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans Bulwark. Techniquement, Bulwark s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.",
|
||||
"unsupported": "Ce navigateur ou cette connexion ne prend pas en charge l'enregistrement manuel des gestionnaires de protocole. Vous pourrez peut-être quand même utiliser la PWA installée via les paramètres du navigateur ou du système.",
|
||||
"mailto_label": "Liens e-mail",
|
||||
"mailto_description": "Ouvre les liens mailto: dans Bulwark avec un éditeur prérempli.",
|
||||
"protocol_open_mode_label": "À l’ouverture des liens de protocole",
|
||||
"protocol_open_mode_description": "Choisissez si Bulwark ouvre les liens mailto: et webcal: dans un nouvel onglet ou réutilise une session ouverte. L’option de session active nécessite l’autorisation des notifications afin que vous puissiez cliquer sur une notification de secours pour ramener Bulwark au premier plan si le navigateur bloque le focus.",
|
||||
"protocol_open_mode_active_session": "Ouvrir dans la session active si possible",
|
||||
"protocol_open_mode_new_tab": "Toujours ouvrir un nouvel onglet",
|
||||
"focus_notification_title": "Ouvrir Bulwark",
|
||||
"focus_notification_body": "Le lien a été ouvert dans Bulwark. Cliquez pour ramener la fenêtre au premier plan.",
|
||||
"webcal_label": "Liens de calendrier",
|
||||
"webcal_description": "Ouvre les liens webcal: dans Bulwark avec une boîte de dialogue d'abonnement au calendrier préremplie.",
|
||||
"register_mailto": "Enregistrer l'application e-mail",
|
||||
"register_webcal": "Enregistrer l'application de calendrier",
|
||||
"mailto_registered": "Enregistrement du gestionnaire d'e-mail demandé",
|
||||
"webcal_registered": "Enregistrement du gestionnaire de calendrier demandé",
|
||||
"registration_failed": "Échec de l'enregistrement du gestionnaire de protocole",
|
||||
"opening_mailto": "Ouverture de l'éditeur...",
|
||||
"opening_webcal": "Ouverture du calendrier...",
|
||||
"browser_note": "Votre navigateur ou système d'exploitation peut vous demander de confirmer et peut exiger que Bulwark soit installé avant de pouvoir le sélectionner comme application par défaut.",
|
||||
"select_account_title": "Choisir un compte",
|
||||
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
|
||||
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
|
||||
"select_account_note": "Cette sélection s'applique uniquement à ce lien de protocole.",
|
||||
"detail_to": "À",
|
||||
"detail_subject": "Objet",
|
||||
"detail_no_subject": "Sans objet",
|
||||
"detail_calendar": "Calendrier",
|
||||
"detail_source": "Source",
|
||||
"active_account": "Actif",
|
||||
"switching_account": "Changement de compte..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Applications de la barre latérale",
|
||||
"add_new": "Ajouter une application",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Remplacer",
|
||||
"toggle_on": "Annuler le remplacement",
|
||||
"toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité — seul l'en-tête De visible change.",
|
||||
"toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité - seul l'en-tête De visible change.",
|
||||
"name_label": "Nom de l'expéditeur",
|
||||
"name_placeholder": "Nom",
|
||||
"email_label": "Adresse e-mail de l'expéditeur",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Chiffrement",
|
||||
"files": "Fichiers",
|
||||
"contacts": "Contacts",
|
||||
"protocol_handlers": "Applications par défaut",
|
||||
"sidebar_apps": "Apps de la barre latérale",
|
||||
"notifications": "Notifications",
|
||||
"layout": "Mise en page",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Avant le texte cité",
|
||||
"below_quote": "Après le texte cité"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Délimiteur de signature",
|
||||
"description": "Préfixer la signature par la ligne de délimitation standard \"-- \" (RFC 3676). Désactivez si vous préférez enchaîner directement du message à la signature."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Délimiteur de sous-adresse",
|
||||
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
|
||||
@@ -2441,6 +2480,15 @@
|
||||
"file_too_large": "Le fichier dépasse la limite de 10 Mo",
|
||||
"invalid_format": "Format de fichier calendrier invalide"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Ouvrir le lien de calendrier",
|
||||
"description": "Comment souhaitez-vous utiliser \"{name}\" ?",
|
||||
"import_title": "Importer une fois",
|
||||
"import_description": "Récupérer les événements maintenant et les copier dans l'un de vos calendriers.",
|
||||
"subscribe_title": "S'abonner",
|
||||
"subscribe_description": "Synchroniser automatiquement ce calendrier comme calendrier séparé.",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestion des calendriers",
|
||||
"description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Visite d'introduction",
|
||||
"restart_desc": "Rejouer la visite guidée de l'interface",
|
||||
"restart_button": "Relancer la visite",
|
||||
"show_on_new_devices_title": "Afficher sur les nouveaux appareils",
|
||||
"show_on_new_devices_desc": "Rejouer la bannière d'accueil et la visite lors de votre première connexion sur un nouvel appareil, même si vous les avez déjà terminées ailleurs",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "App",
|
||||
"shared": "Condiviso"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "App predefinite",
|
||||
"description": "Scegli se i link e-mail e calendario devono aprirsi in Bulwark. Tecnicamente, Bulwark si registra come gestore di protocollo per i link mailto: e webcal:.",
|
||||
"unsupported": "Questo browser o questa connessione non supporta la registrazione manuale dei gestori di protocollo. Potresti comunque poter usare la PWA installata tramite le impostazioni del browser o del sistema.",
|
||||
"mailto_label": "Link e-mail",
|
||||
"mailto_description": "Apre i link mailto: in Bulwark con il compositore precompilato.",
|
||||
"protocol_open_mode_label": "All'apertura dei link di protocollo",
|
||||
"protocol_open_mode_description": "Scegli se Bulwark deve aprire i link mailto: e webcal: in una nuova scheda o riutilizzare una sessione aperta. L'opzione sessione attiva richiede l'autorizzazione alle notifiche, così puoi fare clic su una notifica di fallback per portare Bulwark in primo piano se il browser blocca il focus.",
|
||||
"protocol_open_mode_active_session": "Apri nella sessione attiva se possibile",
|
||||
"protocol_open_mode_new_tab": "Apri sempre una nuova scheda",
|
||||
"focus_notification_title": "Apri Bulwark",
|
||||
"focus_notification_body": "Il link è stato aperto in Bulwark. Fai clic per portare la finestra in primo piano.",
|
||||
"webcal_label": "Link calendario",
|
||||
"webcal_description": "Apre i link webcal: in Bulwark con una finestra di dialogo di sottoscrizione al calendario precompilata.",
|
||||
"register_mailto": "Registra app e-mail",
|
||||
"register_webcal": "Registra app calendario",
|
||||
"mailto_registered": "Registrazione del gestore e-mail richiesta",
|
||||
"webcal_registered": "Registrazione del gestore calendario richiesta",
|
||||
"registration_failed": "Registrazione del gestore di protocollo non riuscita",
|
||||
"opening_mailto": "Apertura compositore...",
|
||||
"opening_webcal": "Apertura calendario...",
|
||||
"browser_note": "Il browser o il sistema operativo potrebbe chiederti di confermare e potrebbe richiedere che Bulwark sia installato prima di poterlo selezionare come app predefinita.",
|
||||
"select_account_title": "Scegli account",
|
||||
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
|
||||
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
|
||||
"select_account_note": "Questa scelta si applica solo a questo link di protocollo.",
|
||||
"detail_to": "A",
|
||||
"detail_subject": "Oggetto",
|
||||
"detail_no_subject": "Senza oggetto",
|
||||
"detail_calendar": "Calendario",
|
||||
"detail_source": "Fonte",
|
||||
"active_account": "Attivo",
|
||||
"switching_account": "Cambio account..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "App della barra laterale",
|
||||
"add_new": "Aggiungi app",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Sovrascrivi",
|
||||
"toggle_on": "Annulla sovrascrittura",
|
||||
"toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità — cambia solo l'intestazione Da visibile.",
|
||||
"toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità - cambia solo l'intestazione Da visibile.",
|
||||
"name_label": "Nome mittente",
|
||||
"name_placeholder": "Nome",
|
||||
"email_label": "Indirizzo email del mittente",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Cifratura",
|
||||
"files": "File",
|
||||
"contacts": "Contatti",
|
||||
"protocol_handlers": "App predefinite",
|
||||
"sidebar_apps": "App nella barra laterale",
|
||||
"notifications": "Notifiche",
|
||||
"layout": "Layout",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Prima del testo citato",
|
||||
"below_quote": "Dopo il testo citato"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Delimitatore firma",
|
||||
"description": "Anteporre alla firma la riga di delimitazione standard \"-- \" (RFC 3676). Disattiva se preferisci passare direttamente dal messaggio alla firma."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitatore sub-indirizzo",
|
||||
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Il file supera il limite di 10 MB",
|
||||
"invalid_format": "Formato del file calendario non valido"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Apri link calendario",
|
||||
"description": "Come vuoi usare \"{name}\"?",
|
||||
"import_title": "Importa una volta",
|
||||
"import_description": "Recupera subito gli eventi e copiali in uno dei tuoi calendari.",
|
||||
"subscribe_title": "Abbonati",
|
||||
"subscribe_description": "Mantieni questo calendario sincronizzato automaticamente come calendario separato.",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestione calendari",
|
||||
"description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Tour introduttivo",
|
||||
"restart_desc": "Rivedi la guida dell'interfaccia",
|
||||
"restart_button": "Riavvia il tour",
|
||||
"show_on_new_devices_title": "Mostra sui nuovi dispositivi",
|
||||
"show_on_new_devices_desc": "Rivedi il banner di benvenuto e il tour al primo accesso su un nuovo dispositivo, anche se li hai già completati altrove",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "アプリ",
|
||||
"shared": "共有"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "既定のアプリ",
|
||||
"description": "メールとカレンダーのリンクを Bulwark で開くかどうかを選択します。技術的には、Bulwark は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
|
||||
"unsupported": "このブラウザーまたは接続では、プロトコル ハンドラーの手動登録がサポートされていません。インストール済みの PWA は、ブラウザーまたはシステム設定から使用できる場合があります。",
|
||||
"mailto_label": "メールリンク",
|
||||
"mailto_description": "mailto: リンクを、入力済みの作成画面で Bulwark に開きます。",
|
||||
"protocol_open_mode_label": "プロトコルリンクを開くとき",
|
||||
"protocol_open_mode_description": "Bulwark が mailto: と webcal: のリンクを新しいタブで開くか、開いているセッションを再利用するかを選択します。アクティブなセッションのオプションでは通知の許可が必要です。ブラウザーがフォーカスをブロックした場合に、代替通知をクリックして Bulwark を前面に表示できます。",
|
||||
"protocol_open_mode_active_session": "可能な場合はアクティブなセッションで開く",
|
||||
"protocol_open_mode_new_tab": "常に新しいタブを開く",
|
||||
"focus_notification_title": "Bulwark を開く",
|
||||
"focus_notification_body": "リンクは Bulwark で開かれました。クリックするとウィンドウを前面に表示します。",
|
||||
"webcal_label": "カレンダーリンク",
|
||||
"webcal_description": "webcal: リンクを、入力済みのカレンダー購読ダイアログで Bulwark に開きます。",
|
||||
"register_mailto": "メールアプリを登録",
|
||||
"register_webcal": "カレンダーアプリを登録",
|
||||
"mailto_registered": "メール ハンドラーの登録を要求しました",
|
||||
"webcal_registered": "カレンダー ハンドラーの登録を要求しました",
|
||||
"registration_failed": "プロトコル ハンドラーの登録に失敗しました",
|
||||
"opening_mailto": "作成画面を開いています...",
|
||||
"opening_webcal": "カレンダーを開いています...",
|
||||
"browser_note": "ブラウザーまたはオペレーティング システムから確認を求められる場合があります。また、既定のアプリとして選択する前に Bulwark のインストールが必要な場合があります。",
|
||||
"select_account_title": "アカウントを選択",
|
||||
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
|
||||
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
|
||||
"select_account_note": "この選択は、このプロトコル リンクにのみ適用されます。",
|
||||
"detail_to": "宛先",
|
||||
"detail_subject": "件名",
|
||||
"detail_no_subject": "件名なし",
|
||||
"detail_calendar": "カレンダー",
|
||||
"detail_source": "ソース",
|
||||
"active_account": "アクティブ",
|
||||
"switching_account": "アカウントを切り替えています..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "サイドバーアプリ",
|
||||
"add_new": "アプリを追加",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "上書き",
|
||||
"toggle_on": "上書きを取り消す",
|
||||
"toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます — 表示される差出人ヘッダーのみが変更されます。",
|
||||
"toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます - 表示される差出人ヘッダーのみが変更されます。",
|
||||
"name_label": "差出人名",
|
||||
"name_placeholder": "名前",
|
||||
"email_label": "差出人メールアドレス",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "暗号化",
|
||||
"files": "ファイル",
|
||||
"contacts": "連絡先",
|
||||
"protocol_handlers": "既定のアプリ",
|
||||
"sidebar_apps": "サイドバーアプリ",
|
||||
"notifications": "通知",
|
||||
"layout": "レイアウト",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "引用テキストの前",
|
||||
"below_quote": "引用テキストの後"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "署名区切り",
|
||||
"description": "署名の前に標準の区切り行「-- 」(RFC 3676)を付けます。本文から署名へ直接続けたい場合はオフにしてください。"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "サブアドレス区切り文字",
|
||||
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "ファイルサイズが10MBを超えています",
|
||||
"invalid_format": "無効なカレンダーファイル形式"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "カレンダーリンクを開く",
|
||||
"description": "\"{name}\"をどのように使用しますか?",
|
||||
"import_title": "一度だけインポート",
|
||||
"import_description": "今すぐ予定を取得し、いずれかのカレンダーにコピーします。",
|
||||
"subscribe_title": "購読",
|
||||
"subscribe_description": "このカレンダーを別のカレンダーとして自動的に同期します。",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"management": {
|
||||
"title": "カレンダー管理",
|
||||
"description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "紹介ツアー",
|
||||
"restart_desc": "インターフェースのガイドツアーを再生する",
|
||||
"restart_button": "ツアーを再開",
|
||||
"show_on_new_devices_title": "新しいデバイスで表示",
|
||||
"show_on_new_devices_desc": "他のデバイスで完了済みでも、新しいデバイスで初めてサインインしたときにウェルカムバナーとツアーを再表示します",
|
||||
"sidebar_title": "メールボックス",
|
||||
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
|
||||
"compose_title": "メールを作成",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "앱",
|
||||
"shared": "공유됨"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "기본 앱",
|
||||
"description": "이메일 및 캘린더 링크를 Bulwark에서 열지 선택하세요. 기술적으로 Bulwark는 mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
|
||||
"unsupported": "이 브라우저 또는 연결은 수동 프로토콜 핸들러 등록을 지원하지 않습니다. 설치된 PWA는 브라우저 또는 시스템 설정을 통해 사용할 수 있을 수 있습니다.",
|
||||
"mailto_label": "이메일 링크",
|
||||
"mailto_description": "mailto: 링크를 Bulwark의 미리 채워진 작성 창에서 엽니다.",
|
||||
"protocol_open_mode_label": "프로토콜 링크를 열 때",
|
||||
"protocol_open_mode_description": "Bulwark가 mailto: 및 webcal: 링크를 새 탭에서 열지, 열린 세션을 재사용할지 선택하세요. 활성 세션 옵션은 브라우저가 포커스를 차단할 때 Bulwark를 앞으로 가져오기 위한 대체 알림을 클릭할 수 있도록 알림 권한이 필요합니다.",
|
||||
"protocol_open_mode_active_session": "가능하면 활성 세션에서 열기",
|
||||
"protocol_open_mode_new_tab": "항상 새 탭 열기",
|
||||
"focus_notification_title": "Bulwark 열기",
|
||||
"focus_notification_body": "링크가 Bulwark에서 열렸습니다. 창을 앞으로 가져오려면 클릭하세요.",
|
||||
"webcal_label": "캘린더 링크",
|
||||
"webcal_description": "webcal: 링크를 Bulwark의 미리 채워진 캘린더 구독 대화상자에서 엽니다.",
|
||||
"register_mailto": "이메일 앱 등록",
|
||||
"register_webcal": "캘린더 앱 등록",
|
||||
"mailto_registered": "이메일 핸들러 등록을 요청했습니다",
|
||||
"webcal_registered": "캘린더 핸들러 등록을 요청했습니다",
|
||||
"registration_failed": "프로토콜 핸들러 등록에 실패했습니다",
|
||||
"opening_mailto": "작성 창을 여는 중...",
|
||||
"opening_webcal": "캘린더를 여는 중...",
|
||||
"browser_note": "브라우저 또는 운영 체제에서 확인을 요청할 수 있으며, 기본 앱으로 선택하기 전에 Bulwark 설치가 필요할 수 있습니다.",
|
||||
"select_account_title": "계정 선택",
|
||||
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
|
||||
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
|
||||
"select_account_note": "이 선택은 이 프로토콜 링크에만 적용됩니다.",
|
||||
"detail_to": "받는 사람",
|
||||
"detail_subject": "제목",
|
||||
"detail_no_subject": "제목 없음",
|
||||
"detail_calendar": "캘린더",
|
||||
"detail_source": "출처",
|
||||
"active_account": "활성",
|
||||
"switching_account": "계정 전환 중..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "사이드바 앱",
|
||||
"add_new": "앱 추가",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "재정의",
|
||||
"toggle_on": "재정의 취소",
|
||||
"toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 — 표시되는 보낸 사람 헤더만 변경됩니다.",
|
||||
"toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 - 표시되는 보낸 사람 헤더만 변경됩니다.",
|
||||
"name_label": "보낸 사람 이름",
|
||||
"name_placeholder": "이름",
|
||||
"email_label": "보낸 사람 이메일 주소",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "파일",
|
||||
"contacts": "연락처",
|
||||
"encryption": "암호화",
|
||||
"protocol_handlers": "기본 앱",
|
||||
"sidebar_apps": "사이드바 앱",
|
||||
"notifications": "알림",
|
||||
"layout": "레이아웃",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "인용 텍스트 앞",
|
||||
"below_quote": "인용 텍스트 뒤"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "서명 구분선",
|
||||
"description": "서명 앞에 표준 구분선 \"-- \" (RFC 3676)을 추가합니다. 본문에서 바로 서명으로 이어지길 원하면 해제하세요."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "서브 주소 구분자",
|
||||
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "파일이 10MB 제한을 넘었어요",
|
||||
"invalid_format": "캘린더 파일 형식이 올바르지 않아요"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "캘린더 링크 열기",
|
||||
"description": "\"{name}\"을 어떻게 사용할까요?",
|
||||
"import_title": "한 번 가져오기",
|
||||
"import_description": "지금 일정을 가져와 내 캘린더 중 하나에 복사합니다.",
|
||||
"subscribe_title": "구독",
|
||||
"subscribe_description": "이 캘린더를 별도의 캘린더로 자동 동기화합니다.",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"management": {
|
||||
"title": "캘린더 관리",
|
||||
"description": "캘린더를 만들고 이름을 바꾸거나 색상을 꾸며보세요. 사이드바에서 캘린더를 우클릭하면 색상을 빠르게 바꿀 수 있어요.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "소개 투어",
|
||||
"restart_desc": "인터페이스를 설명해 주는 투어를 다시 시작해요",
|
||||
"restart_button": "투어 다시 시작",
|
||||
"show_on_new_devices_title": "새 기기에서 표시",
|
||||
"show_on_new_devices_desc": "다른 곳에서 이미 완료했더라도 새 기기에 처음 로그인할 때 환영 배너와 투어를 다시 표시해요",
|
||||
"sidebar_title": "편지함",
|
||||
"sidebar_desc": "여기는 폴더 사이드바예요. 폴더를 클릭하면 그 안의 메일을 볼 수 있어요. 폴더를 만들거나, 메일을 드래그해서 옮길 수 있고 안 읽은 메일 개수도 한눈에 확인돼요.",
|
||||
"compose_title": "메일 쓰기",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Lietotnes",
|
||||
"shared": "Koplietots"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Noklusējuma lietotnes",
|
||||
"description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt Bulwark. Tehniski Bulwark reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.",
|
||||
"unsupported": "Šī pārlūkprogramma vai savienojums neatbalsta manuālu protokola apstrādātāja reģistrāciju. Iespējams, instalēto PWA joprojām var izmantot pārlūkprogrammas vai sistēmas iestatījumos.",
|
||||
"mailto_label": "E-pasta saites",
|
||||
"mailto_description": "Atver mailto: saites Bulwark ar iepriekš aizpildītu ziņojuma redaktoru.",
|
||||
"protocol_open_mode_label": "Atverot protokola saites",
|
||||
"protocol_open_mode_description": "Izvēlieties, vai Bulwark atver mailto: un webcal: saites jaunā cilnē vai atkārtoti izmanto atvērtu sesiju. Aktīvās sesijas opcijai nepieciešama paziņojumu atļauja, lai jūs varētu noklikšķināt uz rezerves paziņojuma un izcelt Bulwark priekšplānā, ja pārlūkprogramma bloķē fokusu.",
|
||||
"protocol_open_mode_active_session": "Ja iespējams, atvērt aktīvajā sesijā",
|
||||
"protocol_open_mode_new_tab": "Vienmēr atvērt jaunu cilni",
|
||||
"focus_notification_title": "Atvērt Bulwark",
|
||||
"focus_notification_body": "Saite tika atvērta Bulwark. Noklikšķiniet, lai izceltu logu priekšplānā.",
|
||||
"webcal_label": "Kalendāra saites",
|
||||
"webcal_description": "Atver webcal: saites Bulwark ar iepriekš aizpildītu kalendāra abonēšanas dialogu.",
|
||||
"register_mailto": "Reģistrēt e-pasta lietotni",
|
||||
"register_webcal": "Reģistrēt kalendāra lietotni",
|
||||
"mailto_registered": "E-pasta apstrādātāja reģistrācija pieprasīta",
|
||||
"webcal_registered": "Kalendāra apstrādātāja reģistrācija pieprasīta",
|
||||
"registration_failed": "Protokola apstrādātāja reģistrācija neizdevās",
|
||||
"opening_mailto": "Tiek atvērts redaktors...",
|
||||
"opening_webcal": "Tiek atvērts kalendārs...",
|
||||
"browser_note": "Pārlūkprogramma vai operētājsistēma var lūgt apstiprinājumu un var prasīt, lai Bulwark būtu instalēts, pirms to var izvēlēties kā noklusējuma lietotni.",
|
||||
"select_account_title": "Izvēlieties kontu",
|
||||
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
|
||||
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
|
||||
"select_account_note": "Šī izvēle attiecas tikai uz šo protokola saiti.",
|
||||
"detail_to": "Kam",
|
||||
"detail_subject": "Temats",
|
||||
"detail_no_subject": "Bez temata",
|
||||
"detail_calendar": "Kalendārs",
|
||||
"detail_source": "Avots",
|
||||
"active_account": "Aktīvs",
|
||||
"switching_account": "Notiek konta pārslēgšana..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sānu joslas lietotnes",
|
||||
"add_new": "Pievienot lietotni",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Pārrakstīt",
|
||||
"toggle_on": "Atcelt pārrakstīšanu",
|
||||
"toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti — mainās tikai redzamais No galvenes ieraksts.",
|
||||
"toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti - mainās tikai redzamais No galvenes ieraksts.",
|
||||
"name_label": "Sūtītāja vārds",
|
||||
"name_placeholder": "Vārds",
|
||||
"email_label": "Sūtītāja e-pasta adrese",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "Faili",
|
||||
"contacts": "Kontakti",
|
||||
"encryption": "Šifrēšana",
|
||||
"protocol_handlers": "Noklusējuma lietotnes",
|
||||
"sidebar_apps": "Sānu joslas lietotnes",
|
||||
"notifications": "Paziņojumi",
|
||||
"layout": "Izkārtojums",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Pirms citētā teksta",
|
||||
"below_quote": "Pēc citētā teksta"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Paraksta atdalītājs",
|
||||
"description": "Pirms paraksta pievienot standarta atdalītāju \"-- \" (RFC 3676). Izslēdziet, ja vēlaties pāriet no ziņojuma tieši uz parakstu."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Apakšadreses atdalītājs",
|
||||
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
|
||||
@@ -2426,6 +2465,15 @@
|
||||
"file_too_large": "Fails pārsniedz 10 MB limitu",
|
||||
"invalid_format": "Nederīgs kalendāra faila formāts"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Atvērt kalendāra saiti",
|
||||
"description": "Kā vēlaties izmantot \"{name}\"?",
|
||||
"import_title": "Importēt vienreiz",
|
||||
"import_description": "Ielādēt notikumus tagad un kopēt tos vienā no jūsu kalendāriem.",
|
||||
"subscribe_title": "Abonēt",
|
||||
"subscribe_description": "Automātiski sinhronizēt šo kalendāru kā atsevišķu kalendāru.",
|
||||
"cancel": "Atcelt"
|
||||
},
|
||||
"management": {
|
||||
"title": "Kalendāru pārvaldība",
|
||||
"description": "Izveidojiet, pārdēvējiet un konfigurējiet kalendārus. Ar labo klikšķi varat mainīt kalendāra krāsu.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Iepazīšanās ekskursija",
|
||||
"restart_desc": "Atkārtot soli pa solim pamācību par saskarni",
|
||||
"restart_button": "Restartēt ekskursiju",
|
||||
"show_on_new_devices_title": "Rādīt jaunās ierīcēs",
|
||||
"show_on_new_devices_desc": "Atkārtot sveiciena reklāmkarogu un ekskursiju, pirmoreiz pierakstoties jaunā ierīcē, pat ja esat tos jau pabeidzis citur",
|
||||
"sidebar_title": "Jūsu pastkastes",
|
||||
"sidebar_desc": "Šī ir sānu josla ar mapēm. Noklikšķiniet uz jebkuras pastkastes, lai skatītu vēstules. Varat izveidot mapes un pārvietot vēstules.",
|
||||
"compose_title": "Rakstīt vēstuli",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Gedeeld"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standaardapps",
|
||||
"description": "Kies of e-mail- en kalenderlinks in Bulwark worden geopend. Technisch registreert Bulwark zich als protocolhandler voor mailto:- en webcal:-links.",
|
||||
"unsupported": "Deze browser of verbinding ondersteunt geen handmatige registratie van protocolhandlers. Mogelijk kun je de geïnstalleerde PWA nog gebruiken via de browser- of systeeminstellingen.",
|
||||
"mailto_label": "E-maillinks",
|
||||
"mailto_description": "Opent mailto:-links in Bulwark met een vooraf ingevulde opsteller.",
|
||||
"protocol_open_mode_label": "Bij het openen van protocollinks",
|
||||
"protocol_open_mode_description": "Kies of Bulwark mailto:- en webcal:-links in een nieuw tabblad opent of een geopende sessie hergebruikt. Voor de optie actieve sessie is toestemming voor meldingen nodig, zodat je op een fallbackmelding kunt klikken om Bulwark naar voren te halen als de browser focus blokkeert.",
|
||||
"protocol_open_mode_active_session": "Indien mogelijk openen in actieve sessie",
|
||||
"protocol_open_mode_new_tab": "Altijd een nieuw tabblad openen",
|
||||
"focus_notification_title": "Bulwark openen",
|
||||
"focus_notification_body": "De link is geopend in Bulwark. Klik om het venster naar voren te halen.",
|
||||
"webcal_label": "Kalenderlinks",
|
||||
"webcal_description": "Opent webcal:-links in Bulwark met een vooraf ingevuld dialoogvenster voor kalenderabonnementen.",
|
||||
"register_mailto": "E-mailapp registreren",
|
||||
"register_webcal": "Kalenderapp registreren",
|
||||
"mailto_registered": "Registratie van e-mailhandler aangevraagd",
|
||||
"webcal_registered": "Registratie van kalenderhandler aangevraagd",
|
||||
"registration_failed": "Registratie van protocolhandler mislukt",
|
||||
"opening_mailto": "Opsteller wordt geopend...",
|
||||
"opening_webcal": "Kalender wordt geopend...",
|
||||
"browser_note": "Je browser of besturingssysteem kan om bevestiging vragen en kan vereisen dat Bulwark is geïnstalleerd voordat het als standaardapp kan worden geselecteerd.",
|
||||
"select_account_title": "Account kiezen",
|
||||
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
|
||||
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
|
||||
"select_account_note": "Deze keuze geldt alleen voor deze protocol-link.",
|
||||
"detail_to": "Aan",
|
||||
"detail_subject": "Onderwerp",
|
||||
"detail_no_subject": "Geen onderwerp",
|
||||
"detail_calendar": "Agenda",
|
||||
"detail_source": "Bron",
|
||||
"active_account": "Actief",
|
||||
"switching_account": "Account wisselen..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Zijbalk-apps",
|
||||
"add_new": "App toevoegen",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Overschrijven",
|
||||
"toggle_on": "Overschrijven annuleren",
|
||||
"toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden — alleen de zichtbare Van-koptekst verandert.",
|
||||
"toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden - alleen de zichtbare Van-koptekst verandert.",
|
||||
"name_label": "Afzendernaam",
|
||||
"name_placeholder": "Naam",
|
||||
"email_label": "E-mailadres afzender",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Versleuteling",
|
||||
"files": "Bestanden",
|
||||
"contacts": "Contacten",
|
||||
"protocol_handlers": "Standaardapps",
|
||||
"sidebar_apps": "Zijbalk-apps",
|
||||
"notifications": "Meldingen",
|
||||
"layout": "Indeling",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Voor geciteerde tekst",
|
||||
"below_quote": "Na geciteerde tekst"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Handtekening-scheider",
|
||||
"description": "De handtekening voorafgaan met de standaard scheidingsregel \"-- \" (RFC 3676). Zet uit als je liever direct van het bericht in de handtekening overgaat."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Sub-adres scheidingsteken",
|
||||
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Bestand overschrijdt de limiet van 10 MB",
|
||||
"invalid_format": "Ongeldig agendabestandsformaat"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Agendalink openen",
|
||||
"description": "Hoe wilt u \"{name}\" gebruiken?",
|
||||
"import_title": "Eenmalig importeren",
|
||||
"import_description": "Haal de afspraken nu op en kopieer ze naar een van uw agenda's.",
|
||||
"subscribe_title": "Abonneren",
|
||||
"subscribe_description": "Houd deze agenda automatisch gesynchroniseerd als aparte agenda.",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"management": {
|
||||
"title": "Agendabeheer",
|
||||
"description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Introductietour",
|
||||
"restart_desc": "Bekijk de rondleiding door de interface opnieuw",
|
||||
"restart_button": "Tour herstarten",
|
||||
"show_on_new_devices_title": "Tonen op nieuwe apparaten",
|
||||
"show_on_new_devices_desc": "Herhaal de welkomstbanner en de rondleiding wanneer je voor het eerst inlogt op een nieuw apparaat, zelfs als je ze elders al hebt voltooid",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Aplikacje",
|
||||
"shared": "Udostępnione"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplikacje domyślne",
|
||||
"description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w Bulwark. Technicznie Bulwark rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.",
|
||||
"unsupported": "Ta przeglądarka lub to połączenie nie obsługuje ręcznej rejestracji obsługi protokołu. Nadal możesz mieć możliwość użycia zainstalowanej aplikacji PWA w ustawieniach przeglądarki lub systemu.",
|
||||
"mailto_label": "Linki e-mail",
|
||||
"mailto_description": "Otwiera linki mailto: w Bulwark z wstępnie wypełnionym edytorem wiadomości.",
|
||||
"protocol_open_mode_label": "Podczas otwierania linków protokołu",
|
||||
"protocol_open_mode_description": "Wybierz, czy Bulwark ma otwierać linki mailto: i webcal: w nowej karcie, czy ponownie używać otwartej sesji. Opcja aktywnej sesji wymaga uprawnienia do powiadomień, aby można było kliknąć powiadomienie awaryjne i przenieść Bulwark na pierwszy plan, jeśli przeglądarka blokuje fokus.",
|
||||
"protocol_open_mode_active_session": "Jeśli to możliwe, otwórz w aktywnej sesji",
|
||||
"protocol_open_mode_new_tab": "Zawsze otwieraj nową kartę",
|
||||
"focus_notification_title": "Otwórz Bulwark",
|
||||
"focus_notification_body": "Link został otwarty w Bulwark. Kliknij, aby przenieść okno na pierwszy plan.",
|
||||
"webcal_label": "Linki kalendarza",
|
||||
"webcal_description": "Otwiera linki webcal: w Bulwark z wstępnie wypełnionym oknem subskrypcji kalendarza.",
|
||||
"register_mailto": "Zarejestruj aplikację e-mail",
|
||||
"register_webcal": "Zarejestruj aplikację kalendarza",
|
||||
"mailto_registered": "Zażądano rejestracji obsługi e-mail",
|
||||
"webcal_registered": "Zażądano rejestracji obsługi kalendarza",
|
||||
"registration_failed": "Rejestracja obsługi protokołu nie powiodła się",
|
||||
"opening_mailto": "Otwieranie edytora...",
|
||||
"opening_webcal": "Otwieranie kalendarza...",
|
||||
"browser_note": "Przeglądarka lub system operacyjny może poprosić o potwierdzenie i może wymagać zainstalowania Bulwark, zanim będzie można wybrać go jako aplikację domyślną.",
|
||||
"select_account_title": "Wybierz konto",
|
||||
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
|
||||
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
|
||||
"select_account_note": "Ten wybór dotyczy tylko tego linku protokołu.",
|
||||
"detail_to": "Do",
|
||||
"detail_subject": "Temat",
|
||||
"detail_no_subject": "Bez tematu",
|
||||
"detail_calendar": "Kalendarz",
|
||||
"detail_source": "Źródło",
|
||||
"active_account": "Aktywne",
|
||||
"switching_account": "Przełączanie konta..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikacje paska bocznego",
|
||||
"add_new": "Dodaj aplikację",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Zastąp",
|
||||
"toggle_on": "Anuluj zastąpienie",
|
||||
"toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość — zmienia się tylko widoczny nagłówek Od.",
|
||||
"toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość - zmienia się tylko widoczny nagłówek Od.",
|
||||
"name_label": "Nazwa nadawcy",
|
||||
"name_placeholder": "Nazwa",
|
||||
"email_label": "Adres e-mail nadawcy",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "Pliki",
|
||||
"contacts": "Kontakty",
|
||||
"encryption": "Szyfrowanie",
|
||||
"protocol_handlers": "Aplikacje domyślne",
|
||||
"sidebar_apps": "Aplikacje paska bocznego",
|
||||
"notifications": "Powiadomienia",
|
||||
"layout": "Układ",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "Przed cytowanym tekstem",
|
||||
"below_quote": "Po cytowanym tekście"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Separator podpisu",
|
||||
"description": "Poprzedź podpis standardową linią separatora \"-- \" (RFC 3676). Wyłącz, jeśli chcesz przejść bezpośrednio z treści wiadomości do podpisu."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Separator sub-adresu",
|
||||
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Plik przekracza limit 10 MB",
|
||||
"invalid_format": "Nieprawidłowy format pliku kalendarza"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Otwórz link kalendarza",
|
||||
"description": "Jak chcesz użyć \"{name}\"?",
|
||||
"import_title": "Importuj jednorazowo",
|
||||
"import_description": "Pobierz wydarzenia teraz i skopiuj je do jednego ze swoich kalendarzy.",
|
||||
"subscribe_title": "Subskrybuj",
|
||||
"subscribe_description": "Automatycznie synchronizuj ten kalendarz jako oddzielny kalendarz.",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"management": {
|
||||
"title": "Zarządzanie kalendarzem",
|
||||
"description": "Twórz, zmieniaj nazwy i dostosowuj swoje kalendarze. Kliknij prawym przyciskiem kalendarz na pasku bocznym, aby szybko zmienić jego kolor.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Przewodnik wprowadzający",
|
||||
"restart_desc": "Odtwórz przewodnik po interfejsie krok po kroku",
|
||||
"restart_button": "Uruchom przewodnik ponownie",
|
||||
"show_on_new_devices_title": "Pokaż na nowych urządzeniach",
|
||||
"show_on_new_devices_desc": "Wyświetl ponownie baner powitalny i przewodnik przy pierwszym logowaniu na nowym urządzeniu, nawet jeśli zostały już ukończone w innym miejscu",
|
||||
"sidebar_title": "Twoje skrzynki pocztowe",
|
||||
"sidebar_desc": "To jest pasek boczny z folderami. Kliknij dowolną skrzynkę, aby zobaczyć jej wiadomości. Możesz tworzyć foldery, przeciągać między nimi wiadomości i od razu widzieć liczbę nieprzeczytanych.",
|
||||
"compose_title": "Napisz wiadomość",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartilhado"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Aplicativos padrão",
|
||||
"description": "Escolha se links de e-mail e calendário devem abrir no Bulwark. Tecnicamente, o Bulwark se registra como manipulador de protocolo para links mailto: e webcal:.",
|
||||
"unsupported": "Este navegador ou esta conexão não oferece suporte ao registro manual de manipuladores de protocolo. Talvez você ainda consiga usar o PWA instalado pelas configurações do navegador ou do sistema.",
|
||||
"mailto_label": "Links de e-mail",
|
||||
"mailto_description": "Abre links mailto: no Bulwark com o editor preenchido previamente.",
|
||||
"protocol_open_mode_label": "Ao abrir links de protocolo",
|
||||
"protocol_open_mode_description": "Escolha se o Bulwark abre links mailto: e webcal: em uma nova guia ou reutiliza uma sessão aberta. A opção de sessão ativa precisa da permissão de notificações para que você possa clicar em uma notificação alternativa e trazer o Bulwark para a frente se o navegador bloquear o foco.",
|
||||
"protocol_open_mode_active_session": "Abrir na sessão ativa se possível",
|
||||
"protocol_open_mode_new_tab": "Sempre abrir uma nova guia",
|
||||
"focus_notification_title": "Abrir Bulwark",
|
||||
"focus_notification_body": "O link foi aberto no Bulwark. Clique para trazer a janela para a frente.",
|
||||
"webcal_label": "Links de calendário",
|
||||
"webcal_description": "Abre links webcal: no Bulwark com uma janela de assinatura de calendário preenchida previamente.",
|
||||
"register_mailto": "Registrar aplicativo de e-mail",
|
||||
"register_webcal": "Registrar aplicativo de calendário",
|
||||
"mailto_registered": "Registro do manipulador de e-mail solicitado",
|
||||
"webcal_registered": "Registro do manipulador de calendário solicitado",
|
||||
"registration_failed": "Falha ao registrar manipulador de protocolo",
|
||||
"opening_mailto": "Abrindo editor...",
|
||||
"opening_webcal": "Abrindo calendário...",
|
||||
"browser_note": "Seu navegador ou sistema operacional pode pedir confirmação e pode exigir que o Bulwark esteja instalado antes de poder ser selecionado como aplicativo padrão.",
|
||||
"select_account_title": "Escolher conta",
|
||||
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
|
||||
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
|
||||
"select_account_note": "Esta escolha se aplica apenas a este link de protocolo.",
|
||||
"detail_to": "Para",
|
||||
"detail_subject": "Assunto",
|
||||
"detail_no_subject": "Sem assunto",
|
||||
"detail_calendar": "Calendário",
|
||||
"detail_source": "Fonte",
|
||||
"active_account": "Ativa",
|
||||
"switching_account": "Alternando conta..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Apps da barra lateral",
|
||||
"add_new": "Adicionar app",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Substituir",
|
||||
"toggle_on": "Cancelar substituição",
|
||||
"toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade — apenas o cabeçalho De visível muda.",
|
||||
"toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade - apenas o cabeçalho De visível muda.",
|
||||
"name_label": "Nome do remetente",
|
||||
"name_placeholder": "Nome",
|
||||
"email_label": "Endereço de email do remetente",
|
||||
@@ -733,6 +767,7 @@
|
||||
"encryption": "Criptografia",
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contatos",
|
||||
"protocol_handlers": "Aplicativos padrão",
|
||||
"sidebar_apps": "Apps da barra lateral",
|
||||
"notifications": "Notificações",
|
||||
"layout": "Layout",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Antes do texto citado",
|
||||
"below_quote": "Depois do texto citado"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Delimitador de assinatura",
|
||||
"description": "Anteceder a assinatura com a linha delimitadora padrão \"-- \" (RFC 3676). Desative se preferir passar direto da mensagem para a assinatura."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Delimitador de sub-endereço",
|
||||
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
|
||||
@@ -2441,6 +2480,15 @@
|
||||
"file_too_large": "Arquivo excede o limite de 10 MB",
|
||||
"invalid_format": "Formato de arquivo de calendário inválido"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Abrir link de calendário",
|
||||
"description": "Como você gostaria de usar \"{name}\"?",
|
||||
"import_title": "Importar uma vez",
|
||||
"import_description": "Busque os eventos agora e copie-os para um dos seus calendários.",
|
||||
"subscribe_title": "Assinar",
|
||||
"subscribe_description": "Mantenha este calendário sincronizado automaticamente como um calendário separado.",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gerenciamento de calendários",
|
||||
"description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Tour introdutório",
|
||||
"restart_desc": "Rever o tour guiado da interface",
|
||||
"restart_button": "Reiniciar tour",
|
||||
"show_on_new_devices_title": "Mostrar em novos dispositivos",
|
||||
"show_on_new_devices_desc": "Reproduzir o banner de boas-vindas e o tour no primeiro login num novo dispositivo, mesmo que já os tenhas concluído noutro lado",
|
||||
"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",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "Приложения",
|
||||
"shared": "Общие"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Приложения по умолчанию",
|
||||
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в Bulwark. Технически Bulwark регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
|
||||
"unsupported": "Этот браузер или это соединение не поддерживает ручную регистрацию обработчиков протоколов. Возможно, установленное PWA всё же можно использовать через настройки браузера или системы.",
|
||||
"mailto_label": "Ссылки электронной почты",
|
||||
"mailto_description": "Открывает ссылки mailto: в Bulwark с предварительно заполненным редактором письма.",
|
||||
"protocol_open_mode_label": "При открытии ссылок протоколов",
|
||||
"protocol_open_mode_description": "Выберите, будет ли Bulwark открывать ссылки mailto: и webcal: в новой вкладке или повторно использовать открытую сессию. Для варианта с активной сессией нужно разрешение на уведомления, чтобы можно было нажать на резервное уведомление и вывести Bulwark на передний план, если браузер блокирует фокус.",
|
||||
"protocol_open_mode_active_session": "Открывать в активной сессии, если возможно",
|
||||
"protocol_open_mode_new_tab": "Всегда открывать новую вкладку",
|
||||
"focus_notification_title": "Открыть Bulwark",
|
||||
"focus_notification_body": "Ссылка была открыта в Bulwark. Нажмите, чтобы вывести окно на передний план.",
|
||||
"webcal_label": "Ссылки календаря",
|
||||
"webcal_description": "Открывает ссылки webcal: в Bulwark с предварительно заполненным диалогом подписки на календарь.",
|
||||
"register_mailto": "Зарегистрировать почтовое приложение",
|
||||
"register_webcal": "Зарегистрировать приложение календаря",
|
||||
"mailto_registered": "Запрошена регистрация обработчика электронной почты",
|
||||
"webcal_registered": "Запрошена регистрация обработчика календаря",
|
||||
"registration_failed": "Не удалось зарегистрировать обработчик протокола",
|
||||
"opening_mailto": "Открытие редактора...",
|
||||
"opening_webcal": "Открытие календаря...",
|
||||
"browser_note": "Браузер или операционная система может запросить подтверждение и может потребовать, чтобы Bulwark был установлен, прежде чем его можно будет выбрать приложением по умолчанию.",
|
||||
"select_account_title": "Выберите аккаунт",
|
||||
"select_mailto_account": "Выберите, в каком аккаунте открыть эту ссылку электронной почты.",
|
||||
"select_webcal_account": "Выберите, в каком аккаунте открыть эту ссылку календаря.",
|
||||
"select_account_note": "Этот выбор применяется только к этой ссылке протокола.",
|
||||
"detail_to": "Кому",
|
||||
"detail_subject": "Тема",
|
||||
"detail_no_subject": "Без темы",
|
||||
"detail_calendar": "Календарь",
|
||||
"detail_source": "Источник",
|
||||
"active_account": "Активен",
|
||||
"switching_account": "Переключение аккаунта..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Приложения боковой панели",
|
||||
"add_new": "Добавить приложение",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Переопределить",
|
||||
"toggle_on": "Отменить переопределение",
|
||||
"toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись — меняется только видимый заголовок От.",
|
||||
"toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись - меняется только видимый заголовок От.",
|
||||
"name_label": "Имя отправителя",
|
||||
"name_placeholder": "Имя",
|
||||
"email_label": "Email отправителя",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "Файлы",
|
||||
"contacts": "Контакты",
|
||||
"encryption": "Шифрование",
|
||||
"protocol_handlers": "Приложения по умолчанию",
|
||||
"sidebar_apps": "Приложения боковой панели",
|
||||
"notifications": "Уведомления",
|
||||
"layout": "Макет",
|
||||
@@ -972,6 +1007,10 @@
|
||||
"above_quote": "Перед цитируемым текстом",
|
||||
"below_quote": "После цитируемого текста"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Разделитель подписи",
|
||||
"description": "Добавлять перед подписью стандартную строку-разделитель \"-- \" (RFC 3676). Отключите, если хотите переходить от текста сразу к подписи."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Разделитель суб-адресов",
|
||||
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Файл превышает лимит 10 МБ",
|
||||
"invalid_format": "Неверный формат файла календаря"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Открыть ссылку календаря",
|
||||
"description": "Как вы хотите использовать \"{name}\"?",
|
||||
"import_title": "Импортировать один раз",
|
||||
"import_description": "Загрузить события сейчас и скопировать их в один из ваших календарей.",
|
||||
"subscribe_title": "Подписаться",
|
||||
"subscribe_description": "Автоматически синхронизировать этот календарь как отдельный календарь.",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"management": {
|
||||
"title": "Управление календарями",
|
||||
"description": "Создавайте, переименовывайте и настраивайте свои календари. Щёлкните правой кнопкой мыши на календаре в боковой панели для быстрой смены цвета.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Ознакомительный тур",
|
||||
"restart_desc": "Повторить пошаговое руководство по интерфейсу",
|
||||
"restart_button": "Перезапустить тур",
|
||||
"show_on_new_devices_title": "Показывать на новых устройствах",
|
||||
"show_on_new_devices_desc": "Повторить приветственный баннер и тур при первом входе на новом устройстве, даже если вы уже завершили их в другом месте",
|
||||
"sidebar_title": "Ваши почтовые ящики",
|
||||
"sidebar_desc": "Это боковая панель с папками. Нажмите на любой почтовый ящик для просмотра писем. Вы можете создавать папки, перетаскивать письма между ними и видеть количество непрочитанных.",
|
||||
"compose_title": "Написать письмо",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"nav_label": "Gezinme",
|
||||
"add_app": "Uygulamalar"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Varsayılan uygulamalar",
|
||||
"description": "E-posta ve takvim bağlantılarının Bulwark'ta açılıp açılmayacağını seçin. Teknik olarak Bulwark, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.",
|
||||
"unsupported": "Bu tarayıcı veya bağlantı manuel protokol işleyicisi kaydını desteklemiyor. Yüklü PWA'yı yine de tarayıcı veya işletim sistemi ayarlarından kullanabilirsiniz.",
|
||||
"mailto_label": "E-posta bağlantıları",
|
||||
"mailto_description": "mailto: bağlantılarını Bulwark'ta önceden doldurulmuş düzenleyiciyle açın.",
|
||||
"protocol_open_mode_label": "Protokol bağlantıları açılırken",
|
||||
"protocol_open_mode_description": "Bulwark'ın mailto: ve webcal: bağlantılarını yeni bir sekmede açmasını mı yoksa açık bir oturumu yeniden kullanmasını mı istediğinizi seçin. Etkin oturum seçeneği, tarayıcı odağı engellerse Bulwark'ı öne getirmek için yedek bildirime tıklayabilmeniz amacıyla bildirim izni gerektirir.",
|
||||
"protocol_open_mode_active_session": "Mümkünse etkin oturumda aç",
|
||||
"protocol_open_mode_new_tab": "Her zaman yeni sekme aç",
|
||||
"focus_notification_title": "Bulwark'ı aç",
|
||||
"focus_notification_body": "Bağlantı Bulwark'ta açıldı. Pencereyi öne getirmek için tıklayın.",
|
||||
"webcal_label": "Takvim bağlantıları",
|
||||
"webcal_description": "webcal: bağlantılarını Bulwark'ta önceden doldurulmuş takvim aboneliği penceresiyle açın.",
|
||||
"register_mailto": "E-posta uygulaması olarak kaydet",
|
||||
"register_webcal": "Takvim uygulaması olarak kaydet",
|
||||
"mailto_registered": "E-posta işleyicisi kaydı istendi",
|
||||
"webcal_registered": "Takvim işleyicisi kaydı istendi",
|
||||
"registration_failed": "Protokol işleyicisi kaydı başarısız oldu",
|
||||
"opening_mailto": "Düzenleyici açılıyor...",
|
||||
"opening_webcal": "Takvim açılıyor...",
|
||||
"browser_note": "Tarayıcınız veya işletim sisteminiz bunu onaylamanızı isteyebilir ve Bulwark'ın varsayılan uygulama olarak seçilebilmesi için yüklenmiş olmasını gerektirebilir.",
|
||||
"select_account_title": "Hesap seç",
|
||||
"select_mailto_account": "Bu e-posta bağlantısını hangi hesabın açacağını seçin.",
|
||||
"select_webcal_account": "Bu takvim bağlantısını hangi hesabın açacağını seçin.",
|
||||
"select_account_note": "Bu seçim yalnızca bu protokol bağlantısı için geçerlidir.",
|
||||
"detail_to": "Kime",
|
||||
"detail_subject": "Konu",
|
||||
"detail_no_subject": "Konu yok",
|
||||
"detail_calendar": "Takvim",
|
||||
"detail_source": "Kaynak",
|
||||
"active_account": "Etkin",
|
||||
"switching_account": "Hesap değiştiriliyor..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Kenar Çubuğu Uygulamaları",
|
||||
"add_new": "Uygulama Ekle",
|
||||
@@ -534,7 +568,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Geçersiz kıl",
|
||||
"toggle_on": "Geçersiz kılmayı iptal et",
|
||||
"toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir — yalnızca görünür Kimden başlığı değişir.",
|
||||
"toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir - yalnızca görünür Kimden başlığı değişir.",
|
||||
"name_label": "Gönderen adı",
|
||||
"name_placeholder": "Ad",
|
||||
"email_label": "Gönderen e-posta adresi",
|
||||
@@ -734,6 +768,7 @@
|
||||
"contacts": "Kişiler",
|
||||
"encryption": "Şifreleme",
|
||||
"sidebar_apps": "Kenar Çubuğu Uygulamaları",
|
||||
"protocol_handlers": "Varsayılan uygulamalar",
|
||||
"notifications": "Bildirimler",
|
||||
"layout": "Düzen",
|
||||
"reading": "Okuma",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "Alıntılanan metinden önce",
|
||||
"below_quote": "Alıntılanan metinden sonra"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "İmza ayırıcı",
|
||||
"description": "İmzayı standart \"-- \" ayırıcı satırıyla başlat (RFC 3676). Mesajdan doğrudan imzaya geçmek istiyorsanız kapatın."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Alt Adres Ayırıcı",
|
||||
"description": "Kullanıcı adını alt adres etiketinden ayıran karakter. Posta sunucunuzun kullandığı ayırıcıyı seçin (ör. kullanici{delimiter}etiket@domain.com).",
|
||||
@@ -2441,6 +2480,15 @@
|
||||
"file_too_large": "Dosya 10 MB sınırını aşıyor",
|
||||
"invalid_format": "Geçersiz takvim dosyası biçimi"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Takvim bağlantısını aç",
|
||||
"description": "\"{name}\" öğesini nasıl kullanmak istersiniz?",
|
||||
"import_title": "Bir kez içe aktar",
|
||||
"import_description": "Etkinlikleri şimdi alıp takvimlerinizden birine kopyalayın.",
|
||||
"subscribe_title": "Abone ol",
|
||||
"subscribe_description": "Bu takvimi ayrı bir takvim olarak otomatik eşitlenmiş tutun.",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"management": {
|
||||
"title": "Takvim Yönetimi",
|
||||
"description": "Takvimlerinizi oluşturun, yeniden adlandırın ve özelleştirin. Rengini hızlıca değiştirmek için kenar çubuğundaki bir takvime sağ tıklayın.",
|
||||
@@ -2819,6 +2867,8 @@
|
||||
"restart_title": "Tanıtım turu",
|
||||
"restart_desc": "Arayüzün rehberli gezintisini tekrar oynat",
|
||||
"restart_button": "Turu yeniden başlat",
|
||||
"show_on_new_devices_title": "Yeni cihazlarda göster",
|
||||
"show_on_new_devices_desc": "Başka bir yerde tamamlamış olsanız bile, yeni bir cihazda ilk oturum açtığınızda karşılama afişini ve turu yeniden gösterin",
|
||||
"sidebar_title": "Posta kutularınız",
|
||||
"sidebar_desc": "Bu sizin klasör kenar çubuğunuzdur. E-postalarını görüntülemek için herhangi bir posta kutusuna tıklayın. Klasörler oluşturabilir, e-postaları aralarında sürükleyebilir ve okunmamış sayılarını bir bakışta görebilirsiniz.",
|
||||
"compose_title": "E-posta oluştur",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "програми",
|
||||
"shared": "Спільні"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Програми за замовчуванням",
|
||||
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в Bulwark. Технічно Bulwark реєструється як обробник протоколу для посилань mailto: і webcal:.",
|
||||
"unsupported": "Цей браузер або це з'єднання не підтримує ручну реєстрацію обробників протоколів. Можливо, встановлену PWA все одно можна використати через налаштування браузера або системи.",
|
||||
"mailto_label": "Посилання електронної пошти",
|
||||
"mailto_description": "Відкриває посилання mailto: у Bulwark із попередньо заповненим редактором листа.",
|
||||
"protocol_open_mode_label": "Під час відкриття посилань протоколів",
|
||||
"protocol_open_mode_description": "Виберіть, чи Bulwark має відкривати посилання mailto: і webcal: у новій вкладці, чи повторно використовувати відкритий сеанс. Для варіанта активного сеансу потрібен дозвіл на сповіщення, щоб ви могли натиснути резервне сповіщення й вивести Bulwark на передній план, якщо браузер блокує фокус.",
|
||||
"protocol_open_mode_active_session": "Якщо можливо, відкривати в активному сеансі",
|
||||
"protocol_open_mode_new_tab": "Завжди відкривати нову вкладку",
|
||||
"focus_notification_title": "Відкрити Bulwark",
|
||||
"focus_notification_body": "Посилання було відкрито в Bulwark. Натисніть, щоб вивести вікно на передній план.",
|
||||
"webcal_label": "Посилання календаря",
|
||||
"webcal_description": "Відкриває посилання webcal: у Bulwark із попередньо заповненим діалогом підписки на календар.",
|
||||
"register_mailto": "Зареєструвати поштову програму",
|
||||
"register_webcal": "Зареєструвати програму календаря",
|
||||
"mailto_registered": "Реєстрацію обробника електронної пошти запитано",
|
||||
"webcal_registered": "Реєстрацію обробника календаря запитано",
|
||||
"registration_failed": "Не вдалося зареєструвати обробник протоколу",
|
||||
"opening_mailto": "Відкриття редактора...",
|
||||
"opening_webcal": "Відкриття календаря...",
|
||||
"browser_note": "Браузер або операційна система може попросити підтвердження та може вимагати, щоб Bulwark був встановлений, перш ніж його можна буде вибрати програмою за замовчуванням.",
|
||||
"select_account_title": "Виберіть акаунт",
|
||||
"select_mailto_account": "Виберіть акаунт, у якому слід відкрити це посилання електронної пошти.",
|
||||
"select_webcal_account": "Виберіть акаунт, у якому слід відкрити це посилання календаря.",
|
||||
"select_account_note": "Цей вибір застосовується лише до цього посилання протоколу.",
|
||||
"detail_to": "Кому",
|
||||
"detail_subject": "Тема",
|
||||
"detail_no_subject": "Без теми",
|
||||
"detail_calendar": "Календар",
|
||||
"detail_source": "Джерело",
|
||||
"active_account": "Активний",
|
||||
"switching_account": "Перемикання акаунта..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Програми бічної панелі",
|
||||
"add_new": "Додати додаток",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "Замінити",
|
||||
"toggle_on": "Скасувати заміну",
|
||||
"toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність — змінюється лише видимий заголовок Від.",
|
||||
"toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність - змінюється лише видимий заголовок Від.",
|
||||
"name_label": "Ім'я відправника",
|
||||
"name_placeholder": "Ім'я",
|
||||
"email_label": "Електронна адреса відправника",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "Файли",
|
||||
"contacts": "Контакти",
|
||||
"encryption": "Шифрування",
|
||||
"protocol_handlers": "Програми за замовчуванням",
|
||||
"sidebar_apps": "Програми бічної панелі",
|
||||
"notifications": "Сповіщення",
|
||||
"layout": "Макет",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "Перед цитованим текстом",
|
||||
"below_quote": "Після цитованого тексту"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "Розділювач підпису",
|
||||
"description": "Додавати перед підписом стандартний рядок-розділювач \"-- \" (RFC 3676). Вимкніть, якщо хочете переходити з повідомлення відразу до підпису."
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "Розділювач під-адреси",
|
||||
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "Файл перевищує обмеження в 10 Мб",
|
||||
"invalid_format": "Недійсний формат файлу календаря"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "Відкрити посилання календаря",
|
||||
"description": "Як ви хочете використати \"{name}\"?",
|
||||
"import_title": "Імпортувати один раз",
|
||||
"import_description": "Завантажити події зараз і скопіювати їх до одного з ваших календарів.",
|
||||
"subscribe_title": "Підписатися",
|
||||
"subscribe_description": "Автоматично синхронізувати цей календар як окремий календар.",
|
||||
"cancel": "Скасувати"
|
||||
},
|
||||
"management": {
|
||||
"title": "Управління календарем",
|
||||
"description": "Створюйте, перейменовуйте та налаштовуйте свої календарі. Клацніть правою кнопкою миші календар на бічній панелі, щоб швидко змінити його колір.",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "Ознайомчий тур",
|
||||
"restart_desc": "Повторіть покрокове керівництво по інтерфейсу",
|
||||
"restart_button": "Перезапустити тур",
|
||||
"show_on_new_devices_title": "Показувати на нових пристроях",
|
||||
"show_on_new_devices_desc": "Повторіть привітальний банер і тур під час першого входу на новому пристрої, навіть якщо ви вже завершили їх в іншому місці",
|
||||
"sidebar_title": "Ваші поштові скриньки",
|
||||
"sidebar_desc": "Це бічна панель вашої папки. Натисніть будь-яку поштову скриньку, щоб переглянути її електронні листи. Ви можете створювати папки, перетягувати електронні листи між ними та миттєво переглядати кількість непрочитаних.",
|
||||
"compose_title": "Створіть електронний лист",
|
||||
|
||||
+51
-1
@@ -134,6 +134,40 @@
|
||||
"add_app": "应用",
|
||||
"shared": "共享"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "默认应用",
|
||||
"description": "选择是否在 Bulwark 中打开电子邮件和日历链接。从技术上讲,Bulwark 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
|
||||
"unsupported": "此浏览器或连接不支持手动注册协议处理程序。你仍可尝试通过浏览器或系统设置使用已安装的 PWA。",
|
||||
"mailto_label": "电子邮件链接",
|
||||
"mailto_description": "在 Bulwark 中打开 mailto: 链接,并预先填好撰写窗口。",
|
||||
"protocol_open_mode_label": "打开协议链接时",
|
||||
"protocol_open_mode_description": "选择 Bulwark 是在新标签页中打开 mailto: 和 webcal: 链接,还是复用已打开的会话。活动会话选项需要通知权限,这样当浏览器阻止聚焦时,你可以点击备用通知将 Bulwark 窗口带到前台。",
|
||||
"protocol_open_mode_active_session": "尽可能在活动会话中打开",
|
||||
"protocol_open_mode_new_tab": "始终打开新标签页",
|
||||
"focus_notification_title": "打开 Bulwark",
|
||||
"focus_notification_body": "链接已在 Bulwark 中打开。点击可将窗口带到前台。",
|
||||
"webcal_label": "日历链接",
|
||||
"webcal_description": "在 Bulwark 中打开 webcal: 链接,并预先填好日历订阅对话框。",
|
||||
"register_mailto": "注册电子邮件应用",
|
||||
"register_webcal": "注册日历应用",
|
||||
"mailto_registered": "已请求注册电子邮件处理程序",
|
||||
"webcal_registered": "已请求注册日历处理程序",
|
||||
"registration_failed": "协议处理程序注册失败",
|
||||
"opening_mailto": "正在打开撰写窗口...",
|
||||
"opening_webcal": "正在打开日历...",
|
||||
"browser_note": "你的浏览器或操作系统可能会要求确认,并且可能需要先安装 Bulwark,才能将其选为默认应用。",
|
||||
"select_account_title": "选择账户",
|
||||
"select_mailto_account": "选择用于打开此电子邮件链接的账户。",
|
||||
"select_webcal_account": "选择用于打开此日历链接的账户。",
|
||||
"select_account_note": "此选择仅适用于此协议链接。",
|
||||
"detail_to": "收件人",
|
||||
"detail_subject": "主题",
|
||||
"detail_no_subject": "无主题",
|
||||
"detail_calendar": "日历",
|
||||
"detail_source": "来源",
|
||||
"active_account": "活动",
|
||||
"switching_account": "正在切换账户..."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "侧边栏应用",
|
||||
"add_new": "添加应用",
|
||||
@@ -531,7 +565,7 @@
|
||||
"from_override": {
|
||||
"toggle_off": "覆盖",
|
||||
"toggle_on": "取消覆盖",
|
||||
"toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 — 仅可见的发件人标题发生变化。",
|
||||
"toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 - 仅可见的发件人标题发生变化。",
|
||||
"name_label": "发件人姓名",
|
||||
"name_placeholder": "姓名",
|
||||
"email_label": "发件人电子邮件地址",
|
||||
@@ -733,6 +767,7 @@
|
||||
"files": "文件",
|
||||
"contacts": "联系人",
|
||||
"encryption": "加密",
|
||||
"protocol_handlers": "默认应用",
|
||||
"sidebar_apps": "侧边栏应用",
|
||||
"notifications": "通知",
|
||||
"layout": "布局",
|
||||
@@ -977,6 +1012,10 @@
|
||||
"above_quote": "引用文本之前",
|
||||
"below_quote": "引用文本之后"
|
||||
},
|
||||
"signature_separator": {
|
||||
"label": "签名分隔符",
|
||||
"description": "在签名前加入标准的 “-- ” 分隔行(RFC 3676)。如果希望正文直接连到签名,请关闭。"
|
||||
},
|
||||
"sub_address_delimiter": {
|
||||
"label": "子地址分隔符",
|
||||
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
|
||||
@@ -2427,6 +2466,15 @@
|
||||
"file_too_large": "文件超过 10MB 限制",
|
||||
"invalid_format": "日历文件格式无效"
|
||||
},
|
||||
"webcal_action": {
|
||||
"title": "打开日历链接",
|
||||
"description": "您想如何使用 \"{name}\"?",
|
||||
"import_title": "导入一次",
|
||||
"import_description": "立即获取事件并复制到您的某个日历中。",
|
||||
"subscribe_title": "订阅",
|
||||
"subscribe_description": "将此日历作为单独的日历自动同步。",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"management": {
|
||||
"title": "日历管理",
|
||||
"description": "创建、重命名和自定义您的日历。右键单击侧栏中的日历可快速更改其颜色。",
|
||||
@@ -2796,6 +2844,8 @@
|
||||
"restart_title": "新手导览",
|
||||
"restart_desc": "重新查看界面功能引导",
|
||||
"restart_button": "重新开始导览",
|
||||
"show_on_new_devices_title": "在新设备上显示",
|
||||
"show_on_new_devices_desc": "首次在新设备登录时重新显示欢迎横幅和导览,即使您已在其他设备完成",
|
||||
"sidebar_title": "邮箱文件夹",
|
||||
"sidebar_desc": "这里是邮箱文件夹列表。点击任意文件夹即可查看邮件。你可以创建文件夹、拖动邮件进行整理,并快速查看未读邮件数量。",
|
||||
"compose_title": "写邮件",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.6",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.6",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function proxy(request: NextRequest) {
|
||||
pathname === "/api/health" ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/branding/") ||
|
||||
// Public read endpoint — serves wizard-uploaded branding assets so
|
||||
// Public read endpoint - serves wizard-uploaded branding assets so
|
||||
// image previews work during the wizard. No auth on the GET route.
|
||||
pathname.startsWith("/api/admin/branding/") ||
|
||||
/\.[^/]+$/.test(pathname);
|
||||
@@ -106,9 +106,9 @@ export async function proxy(request: NextRequest) {
|
||||
`media-src 'self' blob:`,
|
||||
].join("; ");
|
||||
|
||||
// Skip intl middleware for /admin and /setup routes - they have their
|
||||
// own layout outside the [locale] tree.
|
||||
// Skip intl middleware for routes outside the localized app tree.
|
||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
||||
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
|
||||
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
|
||||
|
||||
// When localePrefix is 'always', paths that already have a locale prefix
|
||||
@@ -120,7 +120,7 @@ export async function proxy(request: NextRequest) {
|
||||
);
|
||||
|
||||
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
|
||||
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !hasLocalePrefix) {
|
||||
try {
|
||||
intlResponse = intlMiddleware(request);
|
||||
} catch (error) {
|
||||
|
||||
+157
@@ -23,6 +23,7 @@ function getBasePath() {
|
||||
}
|
||||
|
||||
const BASE_PATH = getBasePath();
|
||||
const MAILTO_CLIENTS = new Map();
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
@@ -43,6 +44,48 @@ self.addEventListener("notificationclick", (event) => {
|
||||
event.waitUntil(handleNotificationClick(event));
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type === "mailto-client-ready") {
|
||||
if (event.source && event.source.id) {
|
||||
MAILTO_CLIENTS.set(event.source.id, {
|
||||
path: typeof data.path === "string" ? data.path : "",
|
||||
standalone: data.standalone === true,
|
||||
clientId: typeof data.clientId === "string" ? data.clientId : "",
|
||||
focusNotificationTitle: typeof data.focusNotificationTitle === "string" ? data.focusNotificationTitle : "",
|
||||
focusNotificationBody: typeof data.focusNotificationBody === "string" ? data.focusNotificationBody : "",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "mailto-client-gone") {
|
||||
if (event.source && event.source.id) {
|
||||
const current = MAILTO_CLIENTS.get(event.source.id);
|
||||
if (!current
|
||||
|| (typeof data.clientId === "string" && current.clientId === data.clientId)
|
||||
|| (typeof data.clientId !== "string" && typeof data.path === "string" && current.path === data.path)) {
|
||||
MAILTO_CLIENTS.delete(event.source.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "open-mailto-in-client") {
|
||||
event.waitUntil(handleOpenMailtoInClient(event));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "focus-existing-mailto-client") {
|
||||
event.waitUntil(focusExistingWindowClient(event.source && event.source.id, true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type !== "focus-existing-client") return;
|
||||
|
||||
event.waitUntil(focusExistingWindowClient(event.source && event.source.id));
|
||||
});
|
||||
|
||||
async function handlePush(event) {
|
||||
let payload = null;
|
||||
try {
|
||||
@@ -123,6 +166,11 @@ async function handlePush(event) {
|
||||
async function handleNotificationClick(event) {
|
||||
const data = event.notification.data || {};
|
||||
const tag = event.notification.tag || "";
|
||||
|
||||
if (data.kind === "protocol-mailto-focus") {
|
||||
return handleMailtoFocusNotificationClick();
|
||||
}
|
||||
|
||||
const targetUrl = buildClickUrl(data);
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
@@ -160,6 +208,115 @@ async function handleNotificationClick(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function focusExistingWindowClient(sourceClientId, requireMailtoReady) {
|
||||
const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady);
|
||||
const client = entry && entry.client;
|
||||
if (client && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMailtoFocusNotificationClick() {
|
||||
const entry = await findReusableWindowClientEntry(null, true);
|
||||
const client = entry && entry.client;
|
||||
if (client && "focus" in client) {
|
||||
try {
|
||||
return await client.focus();
|
||||
} catch (_) {
|
||||
// Fall through to opening a new app window if activation is still blocked.
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(`${BASE_PATH}/`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenMailtoInClient(event) {
|
||||
const data = event.data || {};
|
||||
const responsePort = event.ports && event.ports[0];
|
||||
const entry = await findReusableWindowClientEntry(event.source && event.source.id, true);
|
||||
const client = entry && entry.client;
|
||||
const state = entry && entry.state;
|
||||
|
||||
if (!client || !state || !state.clientId) {
|
||||
responsePort && responsePort.postMessage({ delivered: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
client.postMessage({ type: "mailto-request", id: data.id, clientId: state.clientId, value: data.value });
|
||||
} catch (_) {
|
||||
responsePort && responsePort.postMessage({ delivered: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if ("focus" in client) {
|
||||
try {
|
||||
await client.focus();
|
||||
} catch (_) {
|
||||
// Delivery succeeded; focusing can still be blocked by browser policy.
|
||||
await showMailtoFocusNotification(state);
|
||||
}
|
||||
}
|
||||
|
||||
responsePort && responsePort.postMessage({ delivered: true });
|
||||
}
|
||||
|
||||
async function showMailtoFocusNotification(state) {
|
||||
try {
|
||||
await self.registration.showNotification(state.focusNotificationTitle || "Bulwark", {
|
||||
body: state.focusNotificationBody || "The request was opened in Bulwark. Click to bring it to the front.",
|
||||
tag: "bulwark-mailto-focus",
|
||||
icon: `${BASE_PATH}/icon-192x192.png`,
|
||||
badge: `${BASE_PATH}/icon-192x192.png`,
|
||||
data: { kind: "protocol-mailto-focus" },
|
||||
renotify: true,
|
||||
});
|
||||
} catch (_) {
|
||||
// Notification permission may be missing; the mailto request was still delivered.
|
||||
}
|
||||
}
|
||||
|
||||
async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady) {
|
||||
const scopedPath = BASE_PATH ? `${BASE_PATH}/` : "/";
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const candidates = [];
|
||||
|
||||
for (const client of allClients) {
|
||||
if (client.id === sourceClientId) continue;
|
||||
const state = MAILTO_CLIENTS.get(client.id);
|
||||
if (requireMailtoReady && !state) continue;
|
||||
|
||||
try {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin !== self.location.origin) continue;
|
||||
if (!url.pathname.startsWith(scopedPath)) continue;
|
||||
if (url.pathname.includes("/protocol/")) continue;
|
||||
|
||||
candidates.push({ client, state, score: getReusableClientScore(state) });
|
||||
} catch (_) {
|
||||
// Detached clients can disappear while iterating.
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function getReusableClientScore(state) {
|
||||
if (!state) return 4;
|
||||
|
||||
const isMailSection = state.path === "/" || state.path === "";
|
||||
if (state.standalone && isMailSection) return 0;
|
||||
if (isMailSection) return 1;
|
||||
if (state.standalone) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function buildClickUrl(data) {
|
||||
if (!data) return `${BASE_PATH}/`;
|
||||
if (data.kind === "email" && data.emailId) {
|
||||
|
||||
@@ -770,7 +770,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
const accountStore = useAccountStore.getState();
|
||||
const slot = accountStore.getNextCookieSlot();
|
||||
|
||||
// SSO token exchange and config fetch are independent — fire both
|
||||
// SSO token exchange and config fetch are independent - fire both
|
||||
// up front and let them resolve in parallel.
|
||||
const [ssoRes, config] = await Promise.all([
|
||||
apiFetch('/api/auth/sso/complete', {
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
||||
export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
||||
|
||||
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
||||
export type HoverActionsMode = 'inline' | 'floating';
|
||||
@@ -145,6 +146,7 @@ interface SettingsState {
|
||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
||||
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
@@ -175,6 +177,9 @@ interface SettingsState {
|
||||
emailNotificationSound: boolean;
|
||||
notificationSoundChoice: NotificationSoundChoice;
|
||||
|
||||
// Protocol Handlers
|
||||
protocolOpenMode: ProtocolOpenMode;
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
@@ -220,6 +225,11 @@ interface SettingsState {
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// Onboarding
|
||||
onboardingCompleted: boolean; // Welcome banner dismissed
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -298,6 +308,7 @@ const DEFAULT_SETTINGS = {
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
signaturePosition: 'below_quote' as SignaturePosition,
|
||||
signatureSeparatorEnabled: true,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
@@ -328,6 +339,9 @@ const DEFAULT_SETTINGS = {
|
||||
emailNotificationSound: true,
|
||||
notificationSoundChoice: 'default' as NotificationSoundChoice,
|
||||
|
||||
// Protocol Handlers
|
||||
protocolOpenMode: 'new-tab' as ProtocolOpenMode,
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
@@ -395,6 +409,11 @@ const DEFAULT_SETTINGS = {
|
||||
sidebarApps: [] as SidebarApp[],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
// Onboarding
|
||||
onboardingCompleted: false,
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
@@ -470,10 +489,12 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
plainTextMode: state.plainTextMode,
|
||||
subAddressDelimiter: state.subAddressDelimiter,
|
||||
signaturePosition: state.signaturePosition,
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
notificationSoundChoice: state.notificationSoundChoice,
|
||||
protocolOpenMode: state.protocolOpenMode,
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
calendarNotificationSound: state.calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||
@@ -501,6 +522,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
|
||||
sidebarApps: state.sidebarApps,
|
||||
keepAppsLoaded: state.keepAppsLoaded,
|
||||
onboardingCompleted: state.onboardingCompleted,
|
||||
tourCompleted: state.tourCompleted,
|
||||
showOnboardingOnNewDevices: state.showOnboardingOnNewDevices,
|
||||
debugMode: state.debugMode,
|
||||
debugCategories: state.debugCategories,
|
||||
settingsSyncDisabled: state.settingsSyncDisabled,
|
||||
@@ -520,6 +544,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof settings.protocolOpenMode !== 'string' && typeof settings.protocolMailtoOpenMode === 'string') {
|
||||
settings.protocolOpenMode = settings.protocolMailtoOpenMode;
|
||||
}
|
||||
|
||||
// Apply settings
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (key in DEFAULT_SETTINGS) {
|
||||
@@ -694,13 +722,17 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 2,
|
||||
version: 3,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as Record<string, unknown>;
|
||||
if (version < 2 && state.listDensity) {
|
||||
state.density = state.listDensity;
|
||||
delete state.listDensity;
|
||||
}
|
||||
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
|
||||
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
||||
}
|
||||
delete state.protocolMailtoOpenMode;
|
||||
return state as unknown as SettingsState;
|
||||
},
|
||||
onRehydrateStorage: () => {
|
||||
@@ -805,6 +837,13 @@ if (typeof window !== 'undefined') {
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status === 403) {
|
||||
// Identity mismatch — current session cookies don't match the
|
||||
// username/serverUrl we're syncing for (common in dev mock mode where
|
||||
// no stalwart-context cookie is written, or when rememberMe is off).
|
||||
// Retrying won't help for this session; disable to stop the noise.
|
||||
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status >= 500 && retries > 0) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
|
||||
|
||||
Reference in New Issue
Block a user