+ Use your device's biometric authentication (fingerprint, face) or security key as an
+ alternative to your PIN.
+
+
+ {#if prfSupported}
+ {#if passkeyRegistered}
+
+
+
+ Passkey registered
+
+
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ Passkey registration requires a browser and authenticator that support the WebAuthn
+ PRF extension. Your current browser or device does not support this feature.
+
@@ -6581,6 +6628,11 @@ const stopVerticalResize = () => {
{/if}
+
+{#if $selectedFolder === 'INBOX'}
+
+{/if}
+
diff --git a/src/svelte/Settings.svelte b/src/svelte/Settings.svelte
index 8f1d025..9e81758 100644
--- a/src/svelte/Settings.svelte
+++ b/src/svelte/Settings.svelte
@@ -6,7 +6,11 @@
import { getDatabaseInfo, CURRENT_SCHEMA_VERSION, db } from '../utils/db';
import { cacheManager } from '../utils/cache-manager';
import { unregisterServiceWorker } from '../utils/sw-cache.js';
+ import AppLockSettings from './AppLockSettings.svelte';
+ import MailtoSettings from './components/MailtoSettings.svelte';
import { forceDeleteAllDatabases } from '../utils/db-recovery.js';
+ import { closeDatabase, terminateDbWorker } from '../utils/db-worker-client.js';
+ import { deactivateDemoMode } from '../utils/demo-mode.js';
import { refreshSyncWorkerPgpKeys } from '../utils/sync-worker-client.js';
import { initPerfObservers } from '../utils/perf-logger.ts';
import { mailService, clearPgpKeyCache, invalidatePgpCachedBodies } from '../stores/mailService';
@@ -1088,9 +1092,27 @@
resettingStorage = true;
try {
+ try {
+ await closeDatabase();
+ } catch {
+ /* ignore */
+ }
+ try {
+ terminateDbWorker();
+ } catch {
+ /* ignore */
+ }
await unregisterServiceWorker();
await forceDeleteAllDatabases();
- Local.clear();
+ // Set a flag for the fallback-recovery.js to delete IndexedDB on next page load
+ // (the database may still be blocked by open connections on this page)
+ localStorage.setItem('webmail_pending_idb_cleanup', '1');
+ // Clear ALL localStorage (not just webmail_ prefixed keys)
+ localStorage.clear();
+ localStorage.setItem('webmail_pending_idb_cleanup', '1');
+ sessionStorage.clear();
+ // Also deactivate demo mode (clears fe_demo_mode key)
+ deactivateDemoMode();
setSuccess('Service worker reset and local data cleared. Redirecting to login...');
toasts?.show?.('Service worker reset and local data cleared. Redirecting to login...', 'success');
setTimeout(() => {
@@ -1386,6 +1408,12 @@
{/if}
{#if section === 'privacy'}
+
+
+
+
+
+
PGP encryption
diff --git a/src/svelte/components/EmailIframe.svelte b/src/svelte/components/EmailIframe.svelte
index 0c77696..b05cd4f 100644
--- a/src/svelte/components/EmailIframe.svelte
+++ b/src/svelte/components/EmailIframe.svelte
@@ -123,6 +123,12 @@
// Handle postMessage events from iframe
function handleMessage(event: MessageEvent) {
+ // Validate origin: only accept messages from same origin or null (srcdoc iframes)
+ // srcdoc iframes have a null origin, so we accept that as well as our own origin
+ if (event.origin !== 'null' && event.origin !== window.location.origin) {
+ return;
+ }
+
// Only accept messages from our own iframe to prevent cross-contamination
// when multiple EmailIframe instances exist (e.g., conversation view)
if (iframeRef && event.source !== iframeRef.contentWindow) {
diff --git a/src/svelte/components/MailtoPrompt.svelte b/src/svelte/components/MailtoPrompt.svelte
new file mode 100644
index 0000000..7e71210
--- /dev/null
+++ b/src/svelte/components/MailtoPrompt.svelte
@@ -0,0 +1,63 @@
+
+
+{#if visible}
+
+
+
+
+ Set Forward Email as your default email app?
+
+
+
+
+
+
+
+{/if}
diff --git a/src/svelte/components/MailtoSettings.svelte b/src/svelte/components/MailtoSettings.svelte
new file mode 100644
index 0000000..8cb6c33
--- /dev/null
+++ b/src/svelte/components/MailtoSettings.svelte
@@ -0,0 +1,98 @@
+
+
+{#if visible}
+
+
+
+
+ Default Email App
+
+
+ Set Forward Email as your default email application for mailto: links.
+
+
+
+
+ {#if status === 'registered'}
+
+
+ Forward Email is set as your default email app.
+
+ {:else if status === 'declined'}
+
+
+ Registration was previously declined. You may need to update your browser settings.
+
+ {:else}
+
+
+ Status unknown. Click below to register or re-register.
+
+ {/if}
+
+
+
+
+
+ When registered, clicking mailto: links on any website will open Forward Email to compose a new message.
+
+
+
+{/if}
diff --git a/src/utils/bootstrap-ready.js b/src/utils/bootstrap-ready.js
index 9770164..208a7a1 100644
--- a/src/utils/bootstrap-ready.js
+++ b/src/utils/bootstrap-ready.js
@@ -10,3 +10,18 @@ export function markBootstrapReady() {
resolveReady = null;
}
}
+
+// Separate gate that resolves after app lock is dismissed and credentials
+// are available. Mailbox (and other components that make API calls) must
+// await this before issuing requests.
+let resolveAppReady = null;
+export const appReady = new Promise((resolve) => {
+ resolveAppReady = resolve;
+});
+
+export function markAppReady() {
+ if (resolveAppReady) {
+ resolveAppReady();
+ resolveAppReady = null;
+ }
+}
diff --git a/src/utils/crypto-store.js b/src/utils/crypto-store.js
new file mode 100644
index 0000000..695864a
--- /dev/null
+++ b/src/utils/crypto-store.js
@@ -0,0 +1,960 @@
+/**
+ * Crypto Store - Client-Side Encryption Layer
+ *
+ * Provides transparent encryption for all data stored in IndexedDB and
+ * sensitive values in localStorage using libsodium (XChaCha20-Poly1305).
+ *
+ * Architecture:
+ * - Envelope encryption: a random Data Encryption Key (DEK) encrypts all data
+ * - The DEK is itself encrypted by a Key Encryption Key (KEK) derived from
+ * the user's PIN (via Argon2id) or passkey (via WebAuthn PRF)
+ * - The encrypted DEK + salt are stored in localStorage (the "vault")
+ * - On unlock the KEK is re-derived, the DEK decrypted, and held in memory
+ * - On lock the DEK is wiped from memory
+ *
+ * What gets encrypted (non-indexed fields):
+ * - Email bodies, subjects, snippets, from addresses
+ * - Attachment metadata, draft content
+ * - PGP keys and passphrases
+ * - Account credentials in localStorage
+ *
+ * What stays in plaintext (indexed fields required for queries):
+ * - Primary keys, compound index keys
+ * - Message UIDs, folder paths, timestamps, flags
+ */
+
+const VAULT_KEY = 'webmail_crypto_vault';
+const LOCK_PREFS_KEY = 'webmail_lock_prefs';
+const ENCRYPTED_PREFIX = '\x00ENC\x01';
+
+// Fields that MUST remain unencrypted because they are used as Dexie indexes.
+// Derived from the schema in db.worker.ts.
+const INDEX_FIELDS_BY_TABLE = {
+ accounts: new Set(['id', 'email', 'createdAt', 'updatedAt']),
+ folders: new Set(['account', 'path', 'parentPath', 'unread_count', 'specialUse', 'updatedAt']),
+ messages: new Set([
+ 'account',
+ 'id',
+ 'folder',
+ 'date',
+ 'flags',
+ 'is_unread',
+ 'is_unread_index',
+ 'has_attachment',
+ 'modseq',
+ 'updatedAt',
+ 'bodyIndexed',
+ 'labels',
+ ]),
+ messageBodies: new Set([
+ 'account',
+ 'id',
+ 'folder',
+ 'updatedAt',
+ 'sanitizedAt',
+ 'trackingPixelCount',
+ 'blockedRemoteImageCount',
+ ]),
+ drafts: new Set(['account', 'id', 'folder', 'updatedAt']),
+ searchIndex: new Set(['account', 'key', 'updatedAt']),
+ indexMeta: new Set(['account', 'key', 'updatedAt']),
+ meta: new Set(['key', 'updatedAt']),
+ syncManifests: new Set([
+ 'account',
+ 'folder',
+ 'lastUID',
+ 'lastSyncAt',
+ 'pagesFetched',
+ 'messagesFetched',
+ 'hasBodiesPass',
+ 'updatedAt',
+ ]),
+ labels: new Set(['account', 'id', 'name', 'color', 'createdAt', 'updatedAt']),
+ settings: new Set(['account', 'updatedAt']),
+ settingsLabels: new Set(['account', 'updatedAt']),
+ outbox: new Set([
+ 'account',
+ 'id',
+ 'status',
+ 'retryCount',
+ 'nextRetryAt',
+ 'sendAt',
+ 'createdAt',
+ 'updatedAt',
+ ]),
+};
+
+// Sensitive localStorage keys that should be encrypted when lock is enabled
+const SENSITIVE_LOCAL_KEYS = new Set([
+ 'api_key',
+ 'alias_auth',
+ 'authToken',
+ // PGP keys are stored as pgp_keys_{email} and pgp_passphrases_{email}
+]);
+
+const isSensitiveLocalKey = (key) => {
+ if (SENSITIVE_LOCAL_KEYS.has(key)) return true;
+ if (key.startsWith('pgp_keys_')) return true;
+ if (key.startsWith('pgp_passphrases_')) return true;
+ return false;
+};
+
+const SESSION_UNLOCKED_KEY = 'webmail_lock_session_unlocked';
+
+let _sodium = null;
+let _dek = null; // Data Encryption Key - held in memory only while unlocked
+let _initialized = false;
+let _enabled = false;
+
+/**
+ * Load and initialize libsodium-wrappers.
+ * Cached after first call.
+ */
+async function getSodium() {
+ if (_sodium) return _sodium;
+ const mod = await import('libsodium-wrappers');
+ const sodium = mod.default || mod;
+ await sodium.ready;
+ _sodium = sodium;
+ return sodium;
+}
+
+/**
+ * Check whether the crypto vault has been set up (i.e. the user has
+ * configured a PIN or passkey at least once).
+ */
+function isVaultConfigured() {
+ try {
+ const raw = localStorage.getItem(VAULT_KEY);
+ if (!raw) return false;
+ const vault = JSON.parse(raw);
+ return Boolean(vault && vault.encryptedDek);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check whether app lock is enabled in user preferences.
+ */
+function isLockEnabled() {
+ try {
+ const raw = localStorage.getItem(LOCK_PREFS_KEY);
+ if (!raw) return false;
+ const prefs = JSON.parse(raw);
+ return prefs.enabled === true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get lock preferences (timeout, lock-on-minimize, etc.)
+ */
+function getLockPrefs() {
+ try {
+ const raw = localStorage.getItem(LOCK_PREFS_KEY);
+ if (!raw) {
+ return {
+ enabled: false,
+ timeoutMs: 5 * 60 * 1000, // default 5 minutes
+ lockOnMinimize: false,
+ pinLength: 6,
+ hasPasskey: false,
+ };
+ }
+ return JSON.parse(raw);
+ } catch {
+ return {
+ enabled: false,
+ timeoutMs: 5 * 60 * 1000,
+ lockOnMinimize: false,
+ pinLength: 6,
+ hasPasskey: false,
+ };
+ }
+}
+
+/**
+ * Save lock preferences.
+ */
+function setLockPrefs(prefs) {
+ try {
+ localStorage.setItem(LOCK_PREFS_KEY, JSON.stringify(prefs));
+ } catch (err) {
+ console.error('[crypto-store] Failed to save lock prefs:', err);
+ }
+}
+
+// =========================================================================
+// Key Derivation
+// =========================================================================
+
+/**
+ * Derive a 256-bit Key Encryption Key from a PIN using Argon2id.
+ *
+ * @param {string} pin - The user's PIN
+ * @param {Uint8Array} salt - 16-byte salt (stored in the vault)
+ * @returns {Promise} 32-byte KEK
+ */
+// Argon2id salt length (matches libsodium crypto_pwhash_SALTBYTES)
+const ARGON2_SALT_BYTES = 16;
+
+async function deriveKekFromPin(pin, salt) {
+ const { argon2id } = await import('hash-wasm');
+ if (!pin || typeof pin !== 'string') {
+ throw new Error('PIN is required');
+ }
+ if (!salt || salt.length !== ARGON2_SALT_BYTES) {
+ throw new Error('Invalid salt');
+ }
+ // Argon2id with moderate parameters (matches libsodium OPSLIMIT_MODERATE / MEMLIMIT_MODERATE)
+ const hashHex = await argon2id({
+ password: pin,
+ salt,
+ parallelism: 1,
+ iterations: 3, // OPSLIMIT_MODERATE
+ memorySize: 262144, // MEMLIMIT_MODERATE = 256 MiB in KiB
+ hashLength: 32, // crypto_secretbox_KEYBYTES
+ outputType: 'hex',
+ });
+ // Convert hex string to Uint8Array
+ const kek = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) {
+ kek[i] = Number.parseInt(hashHex.slice(i * 2, i * 2 + 2), 16);
+ }
+ return kek;
+}
+
+/**
+ * Derive a 256-bit KEK from a WebAuthn PRF output.
+ *
+ * The PRF extension returns a raw secret; we feed it through HKDF
+ * (implemented via crypto_generichash / BLAKE2b) to produce a
+ * fixed-length key.
+ *
+ * @param {Uint8Array} prfOutput - Raw PRF secret from WebAuthn
+ * @returns {Promise} 32-byte KEK
+ */
+async function deriveKekFromPrf(prfOutput) {
+ const sodium = await getSodium();
+ if (!prfOutput || prfOutput.length < 16) {
+ throw new Error('PRF output too short');
+ }
+ // BLAKE2b-256 keyed hash acts as a KDF
+ return sodium.crypto_generichash(
+ sodium.crypto_secretbox_KEYBYTES,
+ prfOutput,
+ // Use a fixed context string as the key for domain separation
+ sodium.from_string('ForwardEmail-CryptoStore-KEK-v1'),
+ );
+}
+
+// =========================================================================
+// Vault Management
+// =========================================================================
+
+/**
+ * Create a new vault: generate a random DEK, encrypt it with the KEK,
+ * and store the result in localStorage.
+ *
+ * @param {Uint8Array} kek - 32-byte Key Encryption Key
+ * @returns {Promise}
+ */
+async function createVault(kek) {
+ const sodium = await getSodium();
+
+ // Generate random DEK
+ const dek = sodium.crypto_secretbox_keygen();
+
+ // Encrypt DEK with KEK
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+ const encryptedDek = sodium.crypto_secretbox_easy(dek, nonce, kek);
+
+ // Generate a new salt for future PIN derivations
+ const salt = sodium.randombytes_buf(ARGON2_SALT_BYTES);
+
+ const vault = {
+ encryptedDek: sodium.to_base64(encryptedDek),
+ nonce: sodium.to_base64(nonce),
+ salt: sodium.to_base64(salt),
+ version: 1,
+ createdAt: Date.now(),
+ };
+
+ localStorage.setItem(VAULT_KEY, JSON.stringify(vault));
+
+ // Hold DEK in memory
+ _dek = dek;
+ _enabled = true;
+ _initialized = true;
+ try {
+ sessionStorage.setItem(SESSION_UNLOCKED_KEY, '1');
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Open an existing vault by decrypting the DEK with the provided KEK.
+ *
+ * @param {Uint8Array} kek - 32-byte Key Encryption Key
+ * @returns {Promise} true if unlock succeeded
+ */
+async function openVault(kek) {
+ const sodium = await getSodium();
+ const raw = localStorage.getItem(VAULT_KEY);
+ if (!raw) throw new Error('No vault found');
+
+ const vault = JSON.parse(raw);
+ if (!vault.encryptedDek || !vault.nonce) {
+ throw new Error('Corrupt vault');
+ }
+
+ const encryptedDek = sodium.from_base64(vault.encryptedDek);
+ const nonce = sodium.from_base64(vault.nonce);
+
+ try {
+ const dek = sodium.crypto_secretbox_open_easy(encryptedDek, nonce, kek);
+ _dek = dek;
+ _enabled = true;
+ _initialized = true;
+ // Mark this tab session as unlocked so in-app page reloads
+ // don't re-prompt (cleared on tab close by sessionStorage).
+ try {
+ sessionStorage.setItem(SESSION_UNLOCKED_KEY, '1');
+ } catch {
+ // ignore
+ }
+ // Restore decrypted credentials to sessionStorage so Local.get()
+ // returns plaintext values for auth headers.
+ restoreSessionCredentials();
+ return true;
+ } catch {
+ // Wrong PIN / passkey — decryption failed
+ return false;
+ }
+}
+
+/**
+ * Get the salt stored in the vault (needed for PIN derivation).
+ */
+function getVaultSalt() {
+ try {
+ const raw = localStorage.getItem(VAULT_KEY);
+ if (!raw) return null;
+ const vault = JSON.parse(raw);
+ if (!vault.salt) return null;
+ // Lazy-load sodium for from_base64
+ // Since this is sync and sodium may not be loaded yet, return the base64
+ return vault.salt;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get the vault salt as a Uint8Array (async, loads sodium if needed).
+ */
+async function getVaultSaltBytes() {
+ const sodium = await getSodium();
+ const b64 = getVaultSalt();
+ if (!b64) return null;
+ return sodium.from_base64(b64);
+}
+
+/**
+ * Lock the app: wipe the DEK from memory.
+ */
+function lock() {
+ if (_dek) {
+ // Best-effort memory wipe
+ try {
+ _dek.fill(0);
+ } catch {
+ // Uint8Array.fill may throw in some edge cases
+ }
+ _dek = null;
+ }
+ // Clear session unlock flag so the next page load shows the lock screen
+ try {
+ sessionStorage.removeItem(SESSION_UNLOCKED_KEY);
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Check if the store is currently unlocked (DEK in memory).
+ */
+function isUnlocked() {
+ return _dek !== null && _enabled;
+}
+
+/**
+ * Check if this tab session was previously unlocked (survives page reloads
+ * within the same tab but cleared on tab close). Used by bootstrap to
+ * avoid re-prompting the lock screen on SPA-style page reloads where the
+ * in-memory DEK is lost but the user already authenticated.
+ */
+function wasUnlockedThisSession() {
+ try {
+ return sessionStorage.getItem(SESSION_UNLOCKED_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if encryption is enabled and initialized.
+ */
+function isEnabled() {
+ return _enabled && _initialized;
+}
+
+// =========================================================================
+// Setup Flows
+// =========================================================================
+
+/**
+ * Set up app lock with a PIN.
+ * Creates the vault and encrypts the DEK.
+ *
+ * @param {string} pin - User's chosen PIN
+ * @returns {Promise}
+ */
+async function setupWithPin(pin) {
+ const sodium = await getSodium();
+ const salt = sodium.randombytes_buf(ARGON2_SALT_BYTES);
+
+ // Temporarily store salt for createVault
+ const tempVault = {
+ salt: sodium.to_base64(salt),
+ version: 1,
+ createdAt: Date.now(),
+ };
+ localStorage.setItem(VAULT_KEY, JSON.stringify(tempVault));
+
+ const kek = await deriveKekFromPin(pin, salt);
+ await createVault(kek);
+
+ // Re-encrypt the vault with the correct salt
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+ const encryptedDek = sodium.crypto_secretbox_easy(_dek, nonce, kek);
+
+ const vault = {
+ encryptedDek: sodium.to_base64(encryptedDek),
+ nonce: sodium.to_base64(nonce),
+ salt: sodium.to_base64(salt),
+ version: 1,
+ createdAt: Date.now(),
+ };
+ localStorage.setItem(VAULT_KEY, JSON.stringify(vault));
+
+ // Encrypt existing sensitive localStorage values
+ await encryptExistingLocalStorage();
+}
+
+/**
+ * Set up or add passkey unlock (WebAuthn PRF).
+ *
+ * If a vault already exists and is unlocked (DEK in memory), the existing
+ * DEK is re-wrapped with the passkey-derived KEK and stored as a second
+ * envelope (`passkeyEncryptedDek`) alongside the PIN envelope. This
+ * allows both PIN and passkey to unlock the same underlying DEK.
+ *
+ * If no vault exists yet, a fresh vault is created (passkey-only mode).
+ *
+ * @param {Uint8Array} prfOutput - PRF secret from WebAuthn authentication
+ * @returns {Promise}
+ */
+async function setupWithPasskey(prfOutput) {
+ const sodium = await getSodium();
+ const kek = await deriveKekFromPrf(prfOutput);
+
+ if (_dek && isVaultConfigured()) {
+ // Vault already exists and is unlocked — add passkey as additional unlock
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+ const encryptedDek = sodium.crypto_secretbox_easy(_dek, nonce, kek);
+
+ const raw = localStorage.getItem(VAULT_KEY);
+ const vault = JSON.parse(raw);
+ vault.passkeyEncryptedDek = sodium.to_base64(encryptedDek);
+ vault.passkeyNonce = sodium.to_base64(nonce);
+ localStorage.setItem(VAULT_KEY, JSON.stringify(vault));
+ } else {
+ // No vault yet — create one from scratch with passkey
+ await createVault(kek);
+ await encryptExistingLocalStorage();
+ }
+}
+
+/**
+ * Unlock with PIN.
+ *
+ * @param {string} pin - User's PIN
+ * @returns {Promise} true if unlock succeeded
+ */
+async function unlockWithPin(pin) {
+ const salt = await getVaultSaltBytes();
+ if (!salt) throw new Error('No vault salt found');
+ const kek = await deriveKekFromPin(pin, salt);
+ return openVault(kek);
+}
+
+/**
+ * Unlock with passkey PRF output.
+ *
+ * Checks for a passkey-specific envelope (`passkeyEncryptedDek`) first.
+ * Falls back to the main envelope for passkey-only vaults.
+ *
+ * @param {Uint8Array} prfOutput - PRF secret from WebAuthn authentication
+ * @returns {Promise} true if unlock succeeded
+ */
+async function unlockWithPasskey(prfOutput) {
+ const sodium = await getSodium();
+ const kek = await deriveKekFromPrf(prfOutput);
+
+ const raw = localStorage.getItem(VAULT_KEY);
+ if (!raw) throw new Error('No vault found');
+ const vault = JSON.parse(raw);
+
+ // Try passkey-specific envelope first (dual-unlock vault)
+ if (vault.passkeyEncryptedDek && vault.passkeyNonce) {
+ try {
+ const encDek = sodium.from_base64(vault.passkeyEncryptedDek);
+ const nonce = sodium.from_base64(vault.passkeyNonce);
+ const dek = sodium.crypto_secretbox_open_easy(encDek, nonce, kek);
+ if (dek) {
+ _dek = dek;
+ _enabled = true;
+ _initialized = true;
+ return true;
+ }
+ } catch {
+ // Decryption failed — wrong PRF or corrupted envelope
+ return false;
+ }
+ }
+
+ // Fallback: try the main envelope (passkey-only vault without separate envelope)
+ return openVault(kek);
+}
+
+/**
+ * Change the PIN (re-encrypt the DEK with a new KEK).
+ *
+ * @param {string} oldPin - Current PIN
+ * @param {string} newPin - New PIN
+ * @returns {Promise} true if change succeeded
+ */
+async function changePin(oldPin, newPin) {
+ const sodium = await getSodium();
+
+ // Verify old PIN
+ const oldSalt = await getVaultSaltBytes();
+ if (!oldSalt) throw new Error('No vault found');
+ const oldKek = await deriveKekFromPin(oldPin, oldSalt);
+ const unlocked = await openVault(oldKek);
+ if (!unlocked) return false;
+
+ // Re-encrypt with new PIN
+ const newSalt = sodium.randombytes_buf(ARGON2_SALT_BYTES);
+ const newKek = await deriveKekFromPin(newPin, newSalt);
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+ const encryptedDek = sodium.crypto_secretbox_easy(_dek, nonce, newKek);
+
+ const vault = {
+ encryptedDek: sodium.to_base64(encryptedDek),
+ nonce: sodium.to_base64(nonce),
+ salt: sodium.to_base64(newSalt),
+ version: 1,
+ createdAt: Date.now(),
+ };
+ localStorage.setItem(VAULT_KEY, JSON.stringify(vault));
+ return true;
+}
+
+/**
+ * Remove the passkey envelope from the vault without affecting PIN unlock.
+ * Called when the user removes their passkey from settings.
+ */
+function removePasskeyEnvelope() {
+ try {
+ const raw = localStorage.getItem(VAULT_KEY);
+ if (!raw) return;
+ const vault = JSON.parse(raw);
+ delete vault.passkeyEncryptedDek;
+ delete vault.passkeyNonce;
+ localStorage.setItem(VAULT_KEY, JSON.stringify(vault));
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Disable app lock entirely. Decrypts all data and removes the vault.
+ *
+ * @returns {Promise}
+ */
+async function disableLock() {
+ if (_dek) {
+ await decryptExistingLocalStorage();
+ }
+ localStorage.removeItem(VAULT_KEY);
+ lock();
+ _enabled = false;
+ _initialized = false;
+ setLockPrefs({ ...getLockPrefs(), enabled: false });
+}
+
+// =========================================================================
+// Data Encryption / Decryption
+// =========================================================================
+
+/**
+ * Encrypt a value using the DEK.
+ * Returns a base64 string prefixed with ENCRYPTED_PREFIX.
+ *
+ * @param {*} value - Any JSON-serializable value
+ * @returns {string} Encrypted string
+ */
+function encryptValue(value) {
+ if (!_dek) throw new Error('Store is locked');
+ if (value === null || value === undefined) return value;
+
+ const sodium = _sodium;
+ if (!sodium) throw new Error('Sodium not initialized');
+
+ const plaintext = typeof value === 'string' ? value : JSON.stringify(value);
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+ const ciphertext = sodium.crypto_secretbox_easy(sodium.from_string(plaintext), nonce, _dek);
+
+ // Pack nonce + ciphertext and base64-encode
+ const packed = new Uint8Array(nonce.length + ciphertext.length);
+ packed.set(nonce, 0);
+ packed.set(ciphertext, nonce.length);
+
+ return ENCRYPTED_PREFIX + sodium.to_base64(packed);
+}
+
+/**
+ * Decrypt a value that was encrypted with encryptValue.
+ *
+ * @param {string} encrypted - Encrypted string (with ENCRYPTED_PREFIX)
+ * @returns {*} Decrypted value (parsed from JSON if applicable)
+ */
+function decryptValue(encrypted) {
+ if (!_dek) throw new Error('Store is locked');
+ if (encrypted === null || encrypted === undefined) return encrypted;
+ if (typeof encrypted !== 'string') return encrypted;
+ if (!encrypted.startsWith(ENCRYPTED_PREFIX)) return encrypted;
+
+ const sodium = _sodium;
+ if (!sodium) throw new Error('Sodium not initialized');
+
+ const b64 = encrypted.slice(ENCRYPTED_PREFIX.length);
+ const packed = sodium.from_base64(b64);
+
+ const nonceLen = sodium.crypto_secretbox_NONCEBYTES;
+ if (packed.length < nonceLen + 1) {
+ throw new Error('Encrypted data too short');
+ }
+
+ const nonce = packed.slice(0, nonceLen);
+ const ciphertext = packed.slice(nonceLen);
+
+ const plaintext = sodium.to_string(sodium.crypto_secretbox_open_easy(ciphertext, nonce, _dek));
+
+ // Try to parse as JSON; if it fails, return the raw string
+ try {
+ return JSON.parse(plaintext);
+ } catch {
+ return plaintext;
+ }
+}
+
+/**
+ * Check if a value is encrypted.
+ */
+function isEncrypted(value) {
+ return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
+}
+
+// =========================================================================
+// IndexedDB Record Encryption
+// =========================================================================
+
+/**
+ * Encrypt non-indexed fields of a database record.
+ *
+ * @param {string} table - Table name
+ * @param {Object} record - The record to encrypt
+ * @returns {Object} Record with sensitive fields encrypted
+ */
+function encryptRecord(table, record) {
+ if (!_enabled || !_dek || !record) return record;
+
+ const indexFields = INDEX_FIELDS_BY_TABLE[table];
+ if (!indexFields) return record; // Unknown table, don't encrypt
+
+ const encrypted = {};
+ for (const [key, value] of Object.entries(record)) {
+ if (indexFields.has(key) || value === null || value === undefined) {
+ encrypted[key] = value;
+ } else {
+ encrypted[key] = encryptValue(value);
+ }
+ }
+ return encrypted;
+}
+
+/**
+ * Decrypt non-indexed fields of a database record.
+ *
+ * @param {string} table - Table name
+ * @param {Object} record - The record to decrypt
+ * @returns {Object} Record with sensitive fields decrypted
+ */
+function decryptRecord(table, record) {
+ if (!_enabled || !_dek || !record) return record;
+
+ const indexFields = INDEX_FIELDS_BY_TABLE[table];
+ if (!indexFields) return record;
+
+ const decrypted = {};
+ for (const [key, value] of Object.entries(record)) {
+ if (indexFields.has(key) || !isEncrypted(value)) {
+ decrypted[key] = value;
+ } else {
+ try {
+ decrypted[key] = decryptValue(value);
+ } catch (err) {
+ console.error(`[crypto-store] Failed to decrypt field ${table}.${key}:`, err);
+ decrypted[key] = value; // Return encrypted value on failure
+ }
+ }
+ }
+ return decrypted;
+}
+
+/**
+ * Encrypt an array of records.
+ */
+function encryptRecords(table, records) {
+ if (!_enabled || !_dek || !Array.isArray(records)) return records;
+ return records.map((r) => encryptRecord(table, r));
+}
+
+/**
+ * Decrypt an array of records.
+ */
+function decryptRecords(table, records) {
+ if (!_enabled || !_dek || !Array.isArray(records)) return records;
+ return records.map((r) => decryptRecord(table, r));
+}
+
+// =========================================================================
+// localStorage Encryption
+// =========================================================================
+
+/**
+ * Encrypt all existing sensitive localStorage values.
+ * Called once during setup.
+ */
+async function encryptExistingLocalStorage() {
+ if (!_dek) return;
+ const prefix = 'webmail_';
+ for (let i = 0; i < localStorage.length; i++) {
+ const fullKey = localStorage.key(i);
+ if (!fullKey || !fullKey.startsWith(prefix)) continue;
+ const key = fullKey.slice(prefix.length);
+ if (!isSensitiveLocalKey(key)) continue;
+
+ const value = localStorage.getItem(fullKey);
+ if (value && !isEncrypted(value)) {
+ try {
+ localStorage.setItem(fullKey, encryptValue(value));
+ } catch (err) {
+ console.error(`[crypto-store] Failed to encrypt localStorage key ${key}:`, err);
+ }
+ }
+ }
+}
+
+/**
+ * Decrypt all sensitive localStorage values back to plaintext.
+ * Called when disabling lock.
+ */
+async function decryptExistingLocalStorage() {
+ if (!_dek) return;
+ const prefix = 'webmail_';
+ for (let i = 0; i < localStorage.length; i++) {
+ const fullKey = localStorage.key(i);
+ if (!fullKey || !fullKey.startsWith(prefix)) continue;
+ const key = fullKey.slice(prefix.length);
+ if (!isSensitiveLocalKey(key)) continue;
+
+ const value = localStorage.getItem(fullKey);
+ if (value && isEncrypted(value)) {
+ try {
+ const decrypted = decryptValue(value);
+ localStorage.setItem(
+ fullKey,
+ typeof decrypted === 'string' ? decrypted : JSON.stringify(decrypted),
+ );
+ } catch (err) {
+ console.error(`[crypto-store] Failed to decrypt localStorage key ${key}:`, err);
+ }
+ }
+ }
+}
+
+/**
+ * Read a sensitive localStorage value, decrypting if necessary.
+ */
+function readSensitiveLocal(key) {
+ const prefix = 'webmail_';
+ const value = localStorage.getItem(`${prefix}${key}`);
+ if (!value) return value;
+ if (isEncrypted(value) && _dek) {
+ try {
+ return decryptValue(value);
+ } catch {
+ return null; // Cannot decrypt — locked or wrong key
+ }
+ }
+ return value;
+}
+
+/**
+ * Write a sensitive localStorage value, encrypting if lock is enabled.
+ */
+function writeSensitiveLocal(key, value) {
+ const prefix = 'webmail_';
+ if (_enabled && _dek && isSensitiveLocalKey(key)) {
+ localStorage.setItem(`${prefix}${key}`, encryptValue(value));
+ } else {
+ localStorage.setItem(`${prefix}${key}`, value);
+ }
+}
+
+/**
+ * After unlocking the vault, restore decrypted tab-scoped credentials
+ * (alias_auth, api_key, authToken) into sessionStorage so that
+ * Local.get() — which checks sessionStorage first — returns plaintext.
+ *
+ * Without this, Local.get() falls through to encrypted localStorage values
+ * on fresh sessions and sends garbage auth headers (→ 401).
+ */
+function restoreSessionCredentials() {
+ if (!_dek) return;
+ const prefix = 'webmail_';
+ const tabScopedSensitive = ['alias_auth', 'api_key', 'authToken'];
+ for (const key of tabScopedSensitive) {
+ const fullKey = `${prefix}${key}`;
+ const localValue = localStorage.getItem(fullKey);
+ if (localValue && isEncrypted(localValue)) {
+ try {
+ const decrypted = decryptValue(localValue);
+ const plain = typeof decrypted === 'string' ? decrypted : JSON.stringify(decrypted);
+ sessionStorage.setItem(fullKey, plain);
+ } catch {
+ // Cannot decrypt — ignore, auth will fail gracefully
+ }
+ }
+ }
+}
+
+// =========================================================================
+// Re-encryption (for migrating existing unencrypted data)
+// =========================================================================
+
+/**
+ * Re-encrypt all existing IndexedDB data.
+ * This is called after initial setup to encrypt data that was previously
+ * stored in plaintext. It must be called from the db worker context
+ * or via a message to the db worker.
+ *
+ * Returns a function that the db worker can call for each table.
+ */
+function createReEncryptor() {
+ if (!_enabled || !_dek) {
+ return null;
+ }
+
+ return {
+ shouldEncrypt: (table, record) => {
+ if (!record) return false;
+ const indexFields = INDEX_FIELDS_BY_TABLE[table];
+ if (!indexFields) return false;
+ // Check if any non-index field is not yet encrypted
+ for (const [key, value] of Object.entries(record)) {
+ if (!indexFields.has(key) && value !== null && value !== undefined && !isEncrypted(value)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ encrypt: (table, record) => encryptRecord(table, record),
+ };
+}
+
+// =========================================================================
+// Exports
+// =========================================================================
+
+export {
+ // Initialization & status
+ isVaultConfigured,
+ isLockEnabled,
+ isUnlocked,
+ wasUnlockedThisSession,
+ isEnabled,
+ getSodium,
+
+ // Lock preferences
+ getLockPrefs,
+ setLockPrefs,
+
+ // Setup flows
+ setupWithPin,
+ setupWithPasskey,
+
+ // Unlock flows
+ unlockWithPin,
+ unlockWithPasskey,
+
+ // Lock & key management
+ lock,
+ changePin,
+ disableLock,
+ removePasskeyEnvelope,
+
+ // Vault info
+ getVaultSalt,
+ getVaultSaltBytes,
+
+ // Data encryption (for db worker integration)
+ encryptRecord,
+ decryptRecord,
+ encryptRecords,
+ decryptRecords,
+ encryptValue,
+ decryptValue,
+ isEncrypted,
+
+ // localStorage encryption
+ readSensitiveLocal,
+ writeSensitiveLocal,
+ isSensitiveLocalKey,
+ restoreSessionCredentials,
+
+ // Re-encryption
+ createReEncryptor,
+
+ // Key derivation (exposed for passkey-auth module)
+ deriveKekFromPin,
+ deriveKekFromPrf,
+ openVault,
+};
diff --git a/src/utils/demo-data.js b/src/utils/demo-data.js
new file mode 100644
index 0000000..4f3efca
--- /dev/null
+++ b/src/utils/demo-data.js
@@ -0,0 +1,614 @@
+/**
+ * Forward Email – Demo Account Data Generator
+ *
+ * Provides realistic fake email, contact, and calendar data for the
+ * demo account experience. All data is generated deterministically
+ * so the demo feels consistent across page reloads.
+ *
+ * No real API calls are made — everything is served from memory.
+ */
+
+// ── Demo Account Constants ────────────────────────────────────────────────
+export const DEMO_EMAIL = 'demo@forwardemail.net';
+export const DEMO_ALIAS_AUTH = 'demo@forwardemail.net:demo-password-not-real';
+export const DEMO_STORAGE_KEY = 'fe_demo_mode';
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+// Use deterministic IDs so demo data is consistent across calls
+let _idCounter = 0;
+function nextId(prefix = 'demo') {
+ _idCounter += 1;
+ return `${prefix}-${_idCounter}`;
+}
+
+// Reset counter before each generator call for deterministic output
+function resetIds() {
+ _idCounter = 0;
+}
+
+function daysAgo(n) {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ return d.toISOString();
+}
+
+function hoursAgo(n) {
+ const d = new Date();
+ d.setHours(d.getHours() - n);
+ return d.toISOString();
+}
+
+function minutesAgo(n) {
+ const d = new Date();
+ d.setMinutes(d.getMinutes() - n);
+ return d.toISOString();
+}
+
+// ── Fake Folders ──────────────────────────────────────────────────────────
+
+export function generateFolders() {
+ return [
+ {
+ id: 'folder-inbox',
+ path: 'INBOX',
+ name: 'Inbox',
+ delimiter: '/',
+ specialUse: '\\Inbox',
+ messages: 12,
+ unseen: 3,
+ },
+ {
+ id: 'folder-drafts',
+ path: 'Drafts',
+ name: 'Drafts',
+ delimiter: '/',
+ specialUse: '\\Drafts',
+ messages: 1,
+ unseen: 0,
+ },
+ {
+ id: 'folder-sent',
+ path: 'Sent',
+ name: 'Sent',
+ delimiter: '/',
+ specialUse: '\\Sent',
+ messages: 5,
+ unseen: 0,
+ },
+ {
+ id: 'folder-spam',
+ path: 'Spam',
+ name: 'Spam',
+ delimiter: '/',
+ specialUse: '\\Junk',
+ messages: 2,
+ unseen: 1,
+ },
+ {
+ id: 'folder-trash',
+ path: 'Trash',
+ name: 'Trash',
+ delimiter: '/',
+ specialUse: '\\Trash',
+ messages: 1,
+ unseen: 0,
+ },
+ {
+ id: 'folder-archive',
+ path: 'Archive',
+ name: 'Archive',
+ delimiter: '/',
+ specialUse: '\\Archive',
+ messages: 3,
+ unseen: 0,
+ },
+ ];
+}
+
+// ── Fake Messages ─────────────────────────────────────────────────────────
+
+export function generateMessages(folder = 'INBOX', page = 1) {
+ resetIds();
+ const allMessages = {
+ INBOX: [
+ {
+ id: nextId(),
+ uid: 1001,
+ mailbox: 'INBOX',
+ subject: 'Welcome to Forward Email!',
+ from: { name: 'Forward Email Team', address: 'team@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: minutesAgo(5),
+ intro:
+ 'Thanks for trying out Forward Email webmail. This is a demo account with sample data...',
+ text: 'Thanks for trying out Forward Email webmail.\n\nThis is a demo account with sample data to help you explore the interface. Feel free to click around and explore all the features!\n\nNote: Sending emails and other write operations are disabled in demo mode.\n\nTo get started with your own account, visit https://forwardemail.net\n\nBest regards,\nThe Forward Email Team',
+ html: '
Thanks for trying out Forward Email webmail.
This is a demo account with sample data to help you explore the interface. Feel free to click around and explore all the features!
Note: Sending emails and other write operations are disabled in demo mode.
',
+ flags: [],
+ size: 2048,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1002,
+ mailbox: 'INBOX',
+ subject: 'Your weekly privacy report',
+ from: { name: 'Privacy Monitor', address: 'privacy@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: hoursAgo(2),
+ intro: 'Your email privacy score this week is 98/100. No tracking pixels were detected...',
+ text: 'Your email privacy score this week is 98/100.\n\nNo tracking pixels were detected in your incoming emails this week. Forward Email automatically strips tracking pixels and protects your privacy.\n\nPrivacy Summary:\n- Emails received: 47\n- Tracking pixels blocked: 12\n- External images proxied: 23\n- Encrypted emails: 8\n\nKeep up the great work protecting your privacy!',
+ html: '
Your Weekly Privacy Report
Your email privacy score this week is 98/100.
No tracking pixels were detected in your incoming emails this week. Forward Email automatically strips tracking pixels and protects your privacy.
Privacy Summary
Emails received: 47
Tracking pixels blocked: 12
External images proxied: 23
Encrypted emails: 8
Keep up the great work protecting your privacy!
',
+ flags: [],
+ size: 3200,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1003,
+ mailbox: 'INBOX',
+ subject: 'Meeting tomorrow at 2pm',
+ from: { name: 'Alice Johnson', address: 'alice@example.com' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: hoursAgo(6),
+ intro: 'Hi! Just a reminder about our meeting tomorrow at 2pm. We will be discussing...',
+ text: "Hi!\n\nJust a reminder about our meeting tomorrow at 2pm. We will be discussing the Q4 roadmap and feature priorities.\n\nPlease bring your notes from last week's brainstorming session.\n\nSee you there!\nAlice",
+ html: "
Hi!
Just a reminder about our meeting tomorrow at 2pm. We will be discussing the Q4 roadmap and feature priorities.
Please bring your notes from last week's brainstorming session.
See you there! Alice
",
+ flags: [],
+ size: 1500,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1004,
+ mailbox: 'INBOX',
+ subject: 'Invoice #2024-0892',
+ from: { name: 'Billing Department', address: 'billing@example-corp.com' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(1),
+ intro: 'Please find attached your invoice for November 2024. Total amount due: $49.99...',
+ text: 'Please find attached your invoice for November 2024.\n\nTotal amount due: $49.99\nDue date: December 15, 2024\n\nPayment methods accepted: Credit card, bank transfer, or PayPal.\n\nThank you for your business!\nBilling Department',
+ html: '
Please find attached your invoice for November 2024.
Total amount due: $49.99 Due date: December 15, 2024
Payment methods accepted: Credit card, bank transfer, or PayPal.
Thank you for your business! Billing Department
',
+ flags: ['\\Seen'],
+ size: 4500,
+ attachments: [
+ { filename: 'invoice-2024-0892.pdf', contentType: 'application/pdf', size: 45000 },
+ ],
+ },
+ {
+ id: nextId(),
+ uid: 1005,
+ mailbox: 'INBOX',
+ subject: 'Re: Project update',
+ from: { name: 'Bob Smith', address: 'bob@example.org' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(1),
+ intro:
+ 'Great progress on the frontend! The new dashboard looks amazing. I have a few suggestions...',
+ text: 'Great progress on the frontend! The new dashboard looks amazing.\n\nI have a few suggestions:\n1. Add dark mode support\n2. Improve the mobile layout\n3. Add keyboard shortcuts\n\nLet me know what you think.\n\nBob',
+ html: '
Great progress on the frontend! The new dashboard looks amazing.
I have a few suggestions:
Add dark mode support
Improve the mobile layout
Add keyboard shortcuts
Let me know what you think.
Bob
',
+ flags: ['\\Seen'],
+ size: 1800,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1006,
+ mailbox: 'INBOX',
+ subject: 'Open source contribution accepted!',
+ from: { name: 'GitHub', address: 'noreply@github.com' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(2),
+ intro:
+ 'Your pull request #347 has been merged into main. Thank you for your contribution...',
+ text: 'Your pull request #347 has been merged into main.\n\nThank you for your contribution to forwardemail/forwardemail.net!\n\nChanges merged:\n- Fixed email parsing edge case\n- Added unit tests for MIME boundary detection\n- Updated documentation\n\nKeep up the great work!',
+ html: '
Your pull request #347 has been merged into main.
Thank you for your contribution to forwardemail/forwardemail.net!
Changes merged:
Fixed email parsing edge case
Added unit tests for MIME boundary detection
Updated documentation
Keep up the great work!
',
+ flags: ['\\Seen'],
+ size: 2200,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1007,
+ mailbox: 'INBOX',
+ subject: 'Weekend hiking trip',
+ from: { name: 'Carol Davis', address: 'carol@example.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(2),
+ intro:
+ 'Hey! Are you still up for the hiking trip this weekend? The weather forecast looks great...',
+ text: 'Hey!\n\nAre you still up for the hiking trip this weekend? The weather forecast looks great for Saturday.\n\nTrail: Mountain View Loop\nMeeting point: Trailhead parking lot\nTime: 8:00 AM\n\nBring water and snacks. I will bring the trail map.\n\nLet me know!\nCarol',
+ html: '
Hey!
Are you still up for the hiking trip this weekend? The weather forecast looks great for Saturday.
Trail: Mountain View Loop Meeting point: Trailhead parking lot Time: 8:00 AM
Bring water and snacks. I will bring the trail map.
Let me know! Carol
',
+ flags: ['\\Seen'],
+ size: 1600,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1008,
+ mailbox: 'INBOX',
+ subject: 'Security alert: New sign-in detected',
+ from: { name: 'Forward Email Security', address: 'security@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(3),
+ intro: 'A new sign-in to your account was detected from a new device...',
+ text: 'A new sign-in to your account was detected.\n\nDevice: Chrome on macOS\nLocation: San Francisco, CA\nTime: November 12, 2024 at 3:45 PM PST\n\nIf this was you, no action is needed.\nIf you did not sign in, please change your password immediately.',
+ html: '
A new sign-in to your account was detected.
Device: Chrome on macOS Location: San Francisco, CA Time: November 12, 2024 at 3:45 PM PST
If this was you, no action is needed. If you did not sign in, please change your password immediately.
',
+ flags: ['\\Seen'],
+ size: 1900,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1009,
+ mailbox: 'INBOX',
+ subject: 'Newsletter: Privacy tips for 2024',
+ from: { name: 'Privacy Weekly', address: 'newsletter@privacyweekly.example' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(4),
+ intro:
+ 'This week we cover the top 10 privacy tools for 2024, including email encryption...',
+ text: 'This week we cover the top 10 privacy tools for 2024.\n\n1. Forward Email - Privacy-focused email forwarding\n2. Signal - Encrypted messaging\n3. Tor Browser - Anonymous browsing\n4. ProtonVPN - Secure VPN\n5. Bitwarden - Password manager\n\nRead more at our website.',
+ html: '
Privacy Tips for 2024
This week we cover the top 10 privacy tools for 2024.
Forward Email - Privacy-focused email forwarding
Signal - Encrypted messaging
Tor Browser - Anonymous browsing
ProtonVPN - Secure VPN
Bitwarden - Password manager
Read more at our website.
',
+ flags: ['\\Seen'],
+ size: 5600,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1010,
+ mailbox: 'INBOX',
+ subject: 'Lunch next week?',
+ from: { name: 'Dave Wilson', address: 'dave@example.com' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(5),
+ intro: 'Hey, it has been a while! Want to grab lunch next Tuesday or Wednesday?',
+ text: 'Hey, it has been a while!\n\nWant to grab lunch next Tuesday or Wednesday? I know a great new Thai place downtown.\n\nLet me know what works for you.\n\nDave',
+ html: '
Hey, it has been a while!
Want to grab lunch next Tuesday or Wednesday? I know a great new Thai place downtown.
Let me know what works for you.
Dave
',
+ flags: ['\\Seen'],
+ size: 1200,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1011,
+ mailbox: 'INBOX',
+ subject: 'Your DNS records are configured correctly',
+ from: { name: 'Forward Email', address: 'support@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(7),
+ intro:
+ 'Great news! Your DNS records for example.com have been verified and are working correctly...',
+ text: 'Great news! Your DNS records for example.com have been verified and are working correctly.\n\nMX records: OK\nSPF record: OK\nDKIM record: OK\nDMARC record: OK\n\nYour email forwarding is fully operational.',
+ html: '
Great news! Your DNS records for example.com have been verified and are working correctly.
MX records: OK
SPF record: OK
DKIM record: OK
DMARC record: OK
Your email forwarding is fully operational.
',
+ flags: ['\\Seen'],
+ size: 1700,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 1012,
+ mailbox: 'INBOX',
+ subject: 'Book recommendation: Permanent Record',
+ from: { name: 'Eve Martinez', address: 'eve@example.org' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(10),
+ intro: 'Just finished reading Permanent Record by Edward Snowden. Highly recommend it...',
+ text: 'Just finished reading Permanent Record by Edward Snowden. Highly recommend it if you are interested in privacy and surveillance.\n\nIt really changed my perspective on digital privacy.\n\nEve',
+ html: '
Just finished reading Permanent Record by Edward Snowden. Highly recommend it if you are interested in privacy and surveillance.
It really changed my perspective on digital privacy.
Eve
',
+ flags: ['\\Seen'],
+ size: 1100,
+ attachments: [],
+ },
+ ],
+ Sent: [
+ {
+ id: nextId(),
+ uid: 2001,
+ mailbox: 'Sent',
+ subject: 'Re: Meeting tomorrow at 2pm',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [{ name: 'Alice Johnson', address: 'alice@example.com' }],
+ date: hoursAgo(5),
+ intro: 'Sounds good! I will be there with my notes. See you at 2pm.',
+ text: 'Sounds good! I will be there with my notes. See you at 2pm.\n\nBest,\nDemo User',
+ html: '
Sounds good! I will be there with my notes. See you at 2pm.
Best, Demo User
',
+ flags: ['\\Seen'],
+ size: 800,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 2002,
+ mailbox: 'Sent',
+ subject: 'Re: Project update',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [{ name: 'Bob Smith', address: 'bob@example.org' }],
+ date: daysAgo(1),
+ intro: 'Thanks for the feedback! I will work on dark mode this week.',
+ text: 'Thanks for the feedback! I will work on dark mode this week.\n\nThe keyboard shortcuts are a great idea too.\n\nBest,\nDemo User',
+ html: '
Thanks for the feedback! I will work on dark mode this week.
The keyboard shortcuts are a great idea too.
Best, Demo User
',
+ flags: ['\\Seen'],
+ size: 900,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 2003,
+ mailbox: 'Sent',
+ subject: 'Re: Weekend hiking trip',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [{ name: 'Carol Davis', address: 'carol@example.net' }],
+ date: daysAgo(2),
+ intro: 'Count me in! I will bring extra water bottles.',
+ text: 'Count me in! I will bring extra water bottles.\n\nSee you Saturday at 8am!\n\nBest,\nDemo User',
+ html: '
Count me in! I will bring extra water bottles.
See you Saturday at 8am!
Best, Demo User
',
+ flags: ['\\Seen'],
+ size: 750,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 2004,
+ mailbox: 'Sent',
+ subject: 'Re: Lunch next week?',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [{ name: 'Dave Wilson', address: 'dave@example.com' }],
+ date: daysAgo(4),
+ intro: 'Tuesday works great for me! Let us meet at noon.',
+ text: 'Tuesday works great for me! Let us meet at noon.\n\nSend me the address of the Thai place.\n\nBest,\nDemo User',
+ html: '
Tuesday works great for me! Let us meet at noon.
Send me the address of the Thai place.
Best, Demo User
',
+ flags: ['\\Seen'],
+ size: 800,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 2005,
+ mailbox: 'Sent',
+ subject: 'Re: Book recommendation: Permanent Record',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [{ name: 'Eve Martinez', address: 'eve@example.org' }],
+ date: daysAgo(9),
+ intro: 'Thanks for the recommendation! I just ordered it.',
+ text: 'Thanks for the recommendation! I just ordered it.\n\nI have been meaning to read more about digital privacy.\n\nBest,\nDemo User',
+ html: '
Thanks for the recommendation! I just ordered it.
I have been meaning to read more about digital privacy.
Best, Demo User
',
+ flags: ['\\Seen'],
+ size: 850,
+ attachments: [],
+ },
+ ],
+ Drafts: [
+ {
+ id: nextId(),
+ uid: 3001,
+ mailbox: 'Drafts',
+ subject: 'Blog post draft: Why email privacy matters',
+ from: { name: 'Demo User', address: DEMO_EMAIL },
+ to: [],
+ date: daysAgo(1),
+ intro: "In today's digital age, email privacy is more important than ever...",
+ text: "In today's digital age, email privacy is more important than ever.\n\n[Draft in progress...]",
+ html: "
In today's digital age, email privacy is more important than ever.
[Draft in progress...]
",
+ flags: ['\\Seen', '\\Draft'],
+ size: 600,
+ attachments: [],
+ },
+ ],
+ Spam: [
+ {
+ id: nextId(),
+ uid: 4001,
+ mailbox: 'Spam',
+ subject: 'You have won $1,000,000!!!',
+ from: { name: 'Prize Center', address: 'winner@spam-example.com' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(1),
+ intro: 'Congratulations! You have been selected as our lucky winner...',
+ text: 'Congratulations! You have been selected as our lucky winner. Click here to claim your prize.',
+ html: '
Congratulations! You have been selected as our lucky winner. Click here to claim your prize.
',
+ flags: [],
+ size: 900,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 4002,
+ mailbox: 'Spam',
+ subject: 'Limited time offer - 90% off',
+ from: { name: 'Deals Store', address: 'deals@spam-example.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(3),
+ intro: 'Unbelievable deals await! Shop now before it is too late...',
+ text: 'Unbelievable deals await! Shop now before it is too late.',
+ html: '
Unbelievable deals await! Shop now before it is too late.
',
+ flags: ['\\Seen', '\\Deleted'],
+ size: 3200,
+ attachments: [],
+ },
+ ],
+ Archive: [
+ {
+ id: nextId(),
+ uid: 6001,
+ mailbox: 'Archive',
+ subject: 'Account setup complete',
+ from: { name: 'Forward Email', address: 'noreply@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(30),
+ intro: 'Your Forward Email account has been set up successfully...',
+ text: 'Your Forward Email account has been set up successfully.\n\nYou can now receive and send emails using your custom domain.',
+ html: '
Your Forward Email account has been set up successfully.
You can now receive and send emails using your custom domain.
',
+ flags: ['\\Seen'],
+ size: 1400,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 6002,
+ mailbox: 'Archive',
+ subject: 'Welcome to Forward Email',
+ from: { name: 'Forward Email Team', address: 'team@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(31),
+ intro: 'Welcome! We are excited to have you on board...',
+ text: 'Welcome! We are excited to have you on board.\n\nForward Email is the 100% open-source and privacy-focused email service.',
+ html: '
Welcome! We are excited to have you on board.
Forward Email is the 100% open-source and privacy-focused email service.
',
+ flags: ['\\Seen'],
+ size: 1500,
+ attachments: [],
+ },
+ {
+ id: nextId(),
+ uid: 6003,
+ mailbox: 'Archive',
+ subject: 'DNS verification reminder',
+ from: { name: 'Forward Email', address: 'support@forwardemail.net' },
+ to: [{ name: 'Demo User', address: DEMO_EMAIL }],
+ date: daysAgo(28),
+ intro: 'Reminder: Please verify your DNS records to complete setup...',
+ text: 'Reminder: Please verify your DNS records to complete setup.\n\nVisit your dashboard to check the status.',
+ html: '
Reminder: Please verify your DNS records to complete setup.
Visit your dashboard to check the status.
',
+ flags: ['\\Seen'],
+ size: 1300,
+ attachments: [],
+ },
+ ],
+ };
+
+ const msgs = allMessages[folder] || [];
+ const pageSize = 20;
+ const start = (page - 1) * pageSize;
+ return msgs.slice(start, start + pageSize);
+}
+
+// ── Fake Contacts ─────────────────────────────────────────────────────────
+
+export function generateContacts() {
+ return [
+ {
+ id: nextId(),
+ fn: 'Alice Johnson',
+ email: 'alice@example.com',
+ tel: '+1-555-0101',
+ org: 'Acme Corp',
+ updated: daysAgo(2),
+ },
+ {
+ id: nextId(),
+ fn: 'Bob Smith',
+ email: 'bob@example.org',
+ tel: '+1-555-0102',
+ org: 'Tech Solutions',
+ updated: daysAgo(5),
+ },
+ {
+ id: nextId(),
+ fn: 'Carol Davis',
+ email: 'carol@example.net',
+ tel: '+1-555-0103',
+ org: '',
+ updated: daysAgo(8),
+ },
+ {
+ id: nextId(),
+ fn: 'Dave Wilson',
+ email: 'dave@example.com',
+ tel: '+1-555-0104',
+ org: 'Design Studio',
+ updated: daysAgo(10),
+ },
+ {
+ id: nextId(),
+ fn: 'Eve Martinez',
+ email: 'eve@example.org',
+ tel: '+1-555-0105',
+ org: 'Privacy First Inc',
+ updated: daysAgo(15),
+ },
+ {
+ id: nextId(),
+ fn: 'Forward Email Team',
+ email: 'team@forwardemail.net',
+ tel: '',
+ org: 'Forward Email',
+ updated: daysAgo(30),
+ },
+ ];
+}
+
+// ── Fake Calendar Events ──────────────────────────────────────────────────
+
+export function generateCalendarEvents() {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(14, 0, 0, 0);
+
+ const nextWeek = new Date();
+ nextWeek.setDate(nextWeek.getDate() + 7);
+ nextWeek.setHours(12, 0, 0, 0);
+
+ return [
+ {
+ id: nextId(),
+ summary: 'Team Meeting',
+ description: 'Discuss Q4 roadmap and feature priorities',
+ start: tomorrow.toISOString(),
+ end: new Date(tomorrow.getTime() + 3600000).toISOString(),
+ location: 'Conference Room A',
+ attendees: ['alice@example.com', DEMO_EMAIL],
+ },
+ {
+ id: nextId(),
+ summary: 'Lunch with Dave',
+ description: 'Thai restaurant downtown',
+ start: nextWeek.toISOString(),
+ end: new Date(nextWeek.getTime() + 3600000).toISOString(),
+ location: 'Thai Palace, 123 Main St',
+ attendees: ['dave@example.com', DEMO_EMAIL],
+ },
+ ];
+}
+
+// ── Fake Account Info ─────────────────────────────────────────────────────
+
+export function generateAccountInfo() {
+ return {
+ id: 'demo-account-id',
+ email: DEMO_EMAIL,
+ plan: 'enhanced-protection',
+ storage_used: 15728640, // ~15 MB
+ storage_limit: 10737418240, // 10 GB
+ created_at: daysAgo(60),
+ locale: 'en',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/New_York',
+ };
+}
+
+// ── Fake Labels ───────────────────────────────────────────────────────────
+
+export function generateLabels() {
+ return [
+ { id: nextId(), name: 'Important', color: '#ef4444' },
+ { id: nextId(), name: 'Work', color: '#3b82f6' },
+ { id: nextId(), name: 'Personal', color: '#22c55e' },
+ { id: nextId(), name: 'Finance', color: '#f59e0b' },
+ ];
+}
diff --git a/src/utils/demo-mode.js b/src/utils/demo-mode.js
new file mode 100644
index 0000000..d865a87
--- /dev/null
+++ b/src/utils/demo-mode.js
@@ -0,0 +1,287 @@
+/**
+ * Forward Email – Demo Mode Manager
+ *
+ * Provides a complete sandboxed demo experience. When demo mode is active:
+ * 1. All API requests are intercepted and served from fake data
+ * 2. Write operations (send, move, delete, etc.) show a toast notification
+ * linking to https://forwardemail.net for sign-up
+ * 3. The user can exit demo mode at any time
+ *
+ * Demo mode is activated via the "Try Demo" button on the Login page and
+ * persisted in localStorage so it survives page reloads within the same session.
+ */
+
+import {
+ DEMO_EMAIL,
+ DEMO_STORAGE_KEY,
+ generateFolders,
+ generateMessages,
+ generateContacts,
+ generateCalendarEvents,
+ generateAccountInfo,
+ generateLabels,
+} from './demo-data';
+import { Local, Accounts } from './storage';
+
+// ── State ─────────────────────────────────────────────────────────────────
+
+let _active = false;
+let _toasts = null;
+
+const SIGN_UP_URL = 'https://forwardemail.net';
+const BLOCKED_MSG = 'Action not available in demo account. Sign up at https://forwardemail.net';
+
+// Actions that are read-only and should return fake data
+const READ_ACTIONS = new Set([
+ 'Folders',
+ 'FolderGet',
+ 'MessageList',
+ 'Message',
+ 'Contacts',
+ 'Calendars',
+ 'Calendar',
+ 'CalendarEvents',
+ 'Labels',
+ 'Account',
+]);
+
+// Write actions that are silently blocked (no toast) — background ops like mark-as-read
+const SILENT_WRITE_ACTIONS = new Set(['MessageUpdate']);
+
+// Actions that are write operations and should be blocked with toast
+const WRITE_ACTIONS = new Set([
+ 'Emails',
+ 'EmailCancel',
+ 'FolderCreate',
+ 'FolderUpdate',
+ 'FolderDelete',
+ 'MessageDelete',
+ 'ContactsCreate',
+ 'ContactsUpdate',
+ 'ContactsDelete',
+ 'CalendarUpdate',
+ 'CalendarEventCreate',
+ 'CalendarEventUpdate',
+ 'CalendarEventDelete',
+ 'LabelsCreate',
+ 'LabelsUpdate',
+ 'AccountUpdate',
+]);
+
+// ── Public API ────────────────────────────────────────────────────────────
+
+/**
+ * Check if demo mode is currently active.
+ */
+export function isDemoMode() {
+ if (_active) return true;
+ try {
+ return localStorage.getItem(DEMO_STORAGE_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Activate demo mode. Sets up fake credentials in storage so the
+ * rest of the app thinks a real user is logged in.
+ */
+export function activateDemoMode() {
+ try {
+ localStorage.setItem(DEMO_STORAGE_KEY, '1');
+ } catch {
+ // localStorage unavailable
+ }
+
+ _active = true;
+}
+
+/**
+ * Deactivate demo mode and clean up all demo state.
+ */
+export function deactivateDemoMode() {
+ _active = false;
+ try {
+ localStorage.removeItem(DEMO_STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Set the toast host reference so we can show notifications.
+ * Called from main.ts after the toast host is created.
+ */
+export function setDemoToasts(toasts) {
+ _toasts = toasts;
+}
+
+/**
+ * Show the "not available in demo" toast with a sign-up action button.
+ * If the user clicks the action, we log them out and open the sign-up page.
+ */
+export function showDemoBlockedToast(actionLabel) {
+ if (!_toasts) {
+ console.warn('[demo] Toast host not available');
+ return;
+ }
+
+ const label = actionLabel
+ ? `"${actionLabel}" is not available in demo mode. Sign up at forwardemail.net`
+ : BLOCKED_MSG;
+
+ _toasts.show(label, 'warning', {
+ duration: 15000,
+ action: {
+ label: 'Sign Up',
+ callback: () => {
+ exitDemoAndRedirect();
+ },
+ },
+ });
+}
+
+/**
+ * Exit demo mode, clear credentials, and redirect to sign-up page.
+ */
+export function exitDemoAndRedirect() {
+ deactivateDemoMode();
+
+ // Clear demo credentials from storage
+ try {
+ Accounts.remove(DEMO_EMAIL);
+ Local.remove('email');
+ Local.remove('alias_auth');
+ Local.remove('api_token');
+ } catch {
+ // Best effort cleanup
+ }
+
+ // Clear tab-scoped session keys
+ try {
+ sessionStorage.clear();
+ } catch {
+ // ignore
+ }
+
+ // Open sign-up page
+ window.open(SIGN_UP_URL, '_blank', 'noopener,noreferrer');
+
+ // Navigate to login
+ window.location.hash = '#/login';
+ window.location.reload();
+}
+
+/**
+ * Intercept a Remote.request() call in demo mode.
+ * Returns { handled: true, result: ... } if we handled it,
+ * or { handled: false } if the real API should be called.
+ */
+export function interceptDemoRequest(action, params = {}, options = {}) {
+ if (!isDemoMode()) return { handled: false };
+
+ // Extract message ID from pathOverride if present (e.g. /v1/messages/demo-1?folder=INBOX)
+ if (action === 'Message' && options?.pathOverride) {
+ const match = options.pathOverride.match(/\/v1\/messages\/([^?]+)/);
+ if (match) params = { ...params, id: decodeURIComponent(match[1]) };
+ const folderMatch = options.pathOverride.match(/folder=([^&]+)/);
+ if (folderMatch) params = { ...params, folder: decodeURIComponent(folderMatch[1]) };
+ }
+
+ // Handle read actions with fake data
+ if (READ_ACTIONS.has(action)) {
+ return { handled: true, result: getDemoData(action, params) };
+ }
+
+ // Silently block background write actions (no toast, no error)
+ if (SILENT_WRITE_ACTIONS.has(action)) {
+ return { handled: true, result: { ok: true, demo: true } };
+ }
+
+ // Block write actions with toast
+ if (WRITE_ACTIONS.has(action)) {
+ const friendlyName = getFriendlyActionName(action);
+ showDemoBlockedToast(friendlyName);
+ // Return handled with a special demo marker so Remote.request can
+ // return without making a real API call. The toast is the feedback.
+ return { handled: true, result: { ok: false, demo: true, blocked: true } };
+ }
+
+ // Unknown action — let it through (it will likely fail with fake auth,
+ // which is fine since the user is in demo mode)
+ return { handled: false };
+}
+
+// ── Data Generators ───────────────────────────────────────────────────────
+
+function getDemoData(action, params) {
+ switch (action) {
+ case 'Folders':
+ return generateFolders();
+
+ case 'FolderGet': {
+ const folders = generateFolders();
+ const id = params?.id || params?.path;
+ return folders.find((f) => f.id === id || f.path === id) || folders[0];
+ }
+
+ case 'MessageList':
+ case 'Message': {
+ const folder = params?.folder || params?.mailbox || params?.path || 'INBOX';
+ const page = Number(params?.page) || 1;
+ const messages = generateMessages(folder, page);
+ if (action === 'Message' && params?.id) {
+ return messages.find((m) => m.id === params.id) || messages[0] || null;
+ }
+
+ // Return in the format expected by mailboxStore.ts:
+ // source === 'main' path reads: res?.Result?.List || res?.Result || res || []
+ // So we return the array directly so `res || []` gives the array.
+ return messages;
+ }
+
+ case 'Contacts':
+ return generateContacts();
+
+ case 'Calendars':
+ case 'Calendar':
+ return [
+ { id: 'demo-calendar', name: 'Personal', color: '#3b82f6', description: 'Demo calendar' },
+ ];
+
+ case 'CalendarEvents':
+ return generateCalendarEvents();
+
+ case 'Labels':
+ return generateLabels();
+
+ case 'Account':
+ return generateAccountInfo();
+
+ default:
+ return null;
+ }
+}
+
+function getFriendlyActionName(action) {
+ const names = {
+ Emails: 'Send email',
+ EmailCancel: 'Cancel email',
+ FolderCreate: 'Create folder',
+ FolderUpdate: 'Update folder',
+ FolderDelete: 'Delete folder',
+ MessageUpdate: 'Update message',
+ MessageDelete: 'Delete message',
+ ContactsCreate: 'Create contact',
+ ContactsUpdate: 'Update contact',
+ ContactsDelete: 'Delete contact',
+ CalendarUpdate: 'Update calendar',
+ CalendarEventCreate: 'Create event',
+ CalendarEventUpdate: 'Update event',
+ CalendarEventDelete: 'Delete event',
+ LabelsCreate: 'Create label',
+ LabelsUpdate: 'Update label',
+ AccountUpdate: 'Update account',
+ };
+ return names[action] || action;
+}
diff --git a/src/utils/draft-service.js b/src/utils/draft-service.js
index bb52ed9..86c1a3c 100644
--- a/src/utils/draft-service.js
+++ b/src/utils/draft-service.js
@@ -3,6 +3,7 @@ import { Local } from './storage';
import { Remote } from './remote';
import { sendSyncTask } from './sync-worker-client';
import { getEffectiveSettingValue } from '../stores/settingsStore';
+import { isDemoMode } from './demo-mode';
const AUTOSAVE_INTERVAL = 30000; // 30 seconds
const AUTOSAVE_DEBOUNCE = 3000; // 3 seconds after last keystroke (matches Gmail)
@@ -63,6 +64,11 @@ export async function saveDraft(draftData, options = {}) {
await db.drafts.put(draft);
+ // In demo mode, save locally but skip server sync
+ if (isDemoMode()) {
+ return { ...draft, syncStatus: 'local' };
+ }
+
if (sync && typeof navigator !== 'undefined' && navigator.onLine) {
try {
const synced = await syncDraftToServer(draft);
diff --git a/src/utils/favicon-badge.js b/src/utils/favicon-badge.js
new file mode 100644
index 0000000..c69bc9b
--- /dev/null
+++ b/src/utils/favicon-badge.js
@@ -0,0 +1,232 @@
+/**
+ * Forward Email – Favicon Badge
+ *
+ * Canvas-based badge overlay on the existing favicon to display unread
+ * message count in the browser tab.
+ *
+ * Works by:
+ * 1. Loading the original favicon into an off-screen canvas
+ * 2. Drawing a red circle with the count number on top
+ * 3. Replacing the favicon href with the canvas data URL
+ *
+ * When the count is 0, the original favicon is restored.
+ *
+ * Hardening:
+ * - Count is bounds-checked (0–99999).
+ * - Canvas operations are wrapped in try/catch for CSP restrictions.
+ * - Original favicon href is cached to allow clean restoration.
+ * - Only runs in browser context (no-op in SSR/Tauri).
+ */
+
+import { isTauri } from './platform.js';
+
+// ── State ──────────────────────────────────────────────────────────────────
+
+let originalFaviconHref = null;
+let faviconLinkElement = null;
+let cachedFaviconImage = null;
+let currentCount = 0;
+
+// Reusable off-screen canvas and context to avoid per-update allocation
+let _canvas = null;
+let _ctx = null;
+
+function getCanvas() {
+ if (!_canvas) {
+ _canvas = document.createElement('canvas');
+ _ctx = _canvas.getContext('2d');
+ }
+ return { canvas: _canvas, ctx: _ctx };
+}
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const BADGE_BG_COLOR = '#ef4444'; // red-500
+const BADGE_TEXT_COLOR = '#ffffff';
+const CANVAS_SIZE = 64; // Favicon rendered at 64x64 for clarity
+const BADGE_RADIUS_RATIO = 0.28; // Badge circle radius relative to canvas
+const BADGE_FONT_SIZE_RATIO = 0.3; // Font size relative to canvas
+const BADGE_OFFSET_X = 0.72; // Badge center X position (right side)
+const BADGE_OFFSET_Y = 0.28; // Badge center Y position (top side)
+const MAX_BADGE_COUNT = 99999;
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function getFaviconLink() {
+ if (faviconLinkElement) return faviconLinkElement;
+
+ // Look for existing favicon link
+ faviconLinkElement =
+ document.querySelector('link[rel="icon"]') ||
+ document.querySelector('link[rel="shortcut icon"]');
+
+ if (!faviconLinkElement) {
+ // Create one if it doesn't exist
+ faviconLinkElement = document.createElement('link');
+ faviconLinkElement.rel = 'icon';
+ faviconLinkElement.type = 'image/png';
+ document.head.appendChild(faviconLinkElement);
+ }
+
+ // Cache the original href for restoration
+ if (!originalFaviconHref && faviconLinkElement.href) {
+ originalFaviconHref = faviconLinkElement.href;
+ }
+
+ return faviconLinkElement;
+}
+
+function loadFaviconImage() {
+ return new Promise((resolve, reject) => {
+ if (cachedFaviconImage) {
+ resolve(cachedFaviconImage);
+ return;
+ }
+
+ const link = getFaviconLink();
+ const src = originalFaviconHref || link.href;
+
+ if (!src) {
+ reject(new Error('No favicon source'));
+ return;
+ }
+
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ cachedFaviconImage = img;
+ resolve(img);
+ };
+ img.onerror = () => reject(new Error('Failed to load favicon'));
+ img.src = src;
+ });
+}
+
+function formatBadgeText(count) {
+ if (count <= 0) return '';
+ if (count > 999) return '999+';
+ return String(count);
+}
+
+function drawBadge(canvas, ctx, img, count) {
+ const size = CANVAS_SIZE;
+ canvas.width = size;
+ canvas.height = size;
+
+ // Clear and draw original favicon
+ ctx.clearRect(0, 0, size, size);
+ ctx.drawImage(img, 0, 0, size, size);
+
+ if (count <= 0) return;
+
+ const text = formatBadgeText(count);
+ const badgeRadius = size * BADGE_RADIUS_RATIO;
+ const centerX = size * BADGE_OFFSET_X;
+ const centerY = size * BADGE_OFFSET_Y;
+
+ // Adjust badge size for longer text
+ const extraWidth = text.length > 2 ? (text.length - 2) * (size * 0.08) : 0;
+
+ // Draw badge background (pill shape for long text, circle for short)
+ ctx.beginPath();
+ if (extraWidth > 0) {
+ // Pill shape
+ const left = centerX - badgeRadius - extraWidth / 2;
+ const right = centerX + badgeRadius + extraWidth / 2;
+ const top = centerY - badgeRadius;
+ const bottom = centerY + badgeRadius;
+ ctx.moveTo(left + badgeRadius, top);
+ ctx.lineTo(right - badgeRadius, top);
+ ctx.arc(right - badgeRadius, centerY, badgeRadius, -Math.PI / 2, Math.PI / 2);
+ ctx.lineTo(left + badgeRadius, bottom);
+ ctx.arc(left + badgeRadius, centerY, badgeRadius, Math.PI / 2, -Math.PI / 2);
+ } else {
+ ctx.arc(centerX, centerY, badgeRadius, 0, Math.PI * 2);
+ }
+ ctx.fillStyle = BADGE_BG_COLOR;
+ ctx.fill();
+
+ // Draw white border
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = size * 0.03;
+ ctx.stroke();
+
+ // Draw text
+ const fontSize = Math.round(size * BADGE_FONT_SIZE_RATIO);
+ ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = BADGE_TEXT_COLOR;
+ ctx.fillText(text, centerX, centerY + 1); // +1 for visual centering
+}
+
+// ── Public API ─────────────────────────────────────────────────────────────
+
+/**
+ * Update the favicon badge with the given unread count.
+ * Pass 0 to clear the badge and restore the original favicon.
+ *
+ * @param {number} count - Unread message count (0 to clear)
+ */
+export async function updateFaviconBadge(count) {
+ // No-op outside browser or in Tauri (Tauri uses native badge)
+ if (typeof document === 'undefined') return;
+ if (isTauri) return;
+
+ const n =
+ typeof count === 'number' ? Math.max(0, Math.min(Math.round(count), MAX_BADGE_COUNT)) : 0;
+
+ // Skip if count hasn't changed
+ if (n === currentCount) return;
+ currentCount = n;
+
+ try {
+ const link = getFaviconLink();
+
+ if (n === 0) {
+ // Restore original favicon
+ if (originalFaviconHref) {
+ link.href = originalFaviconHref;
+ }
+ return;
+ }
+
+ const img = await loadFaviconImage();
+ const { canvas, ctx } = getCanvas();
+ if (!ctx) return;
+
+ drawBadge(canvas, ctx, img, n);
+
+ // Update favicon
+ link.href = canvas.toDataURL('image/png');
+ } catch (err) {
+ // Canvas operations may fail due to CSP or CORS
+ console.warn('[favicon-badge] Failed to update badge:', err);
+ }
+}
+
+/**
+ * Clear the favicon badge and restore the original favicon.
+ */
+export async function clearFaviconBadge() {
+ return updateFaviconBadge(0);
+}
+
+/**
+ * Get the current badge count.
+ */
+export function getFaviconBadgeCount() {
+ return currentCount;
+}
+
+/**
+ * Reset the cached favicon image (e.g., after theme change).
+ */
+export function resetFaviconCache() {
+ cachedFaviconImage = null;
+ originalFaviconHref = null;
+ faviconLinkElement = null;
+ _canvas = null;
+ _ctx = null;
+ currentCount = -1; // Force re-render on next update
+}
diff --git a/src/utils/iframe-srcdoc.ts b/src/utils/iframe-srcdoc.ts
index a36c2fe..0cc4249 100644
--- a/src/utils/iframe-srcdoc.ts
+++ b/src/utils/iframe-srcdoc.ts
@@ -14,6 +14,11 @@
*/
export function buildIframeSrcdoc(emailHtml: string, isDarkMode: boolean = false): string {
const bodyClass = isDarkMode ? 'fe-iframe-dark' : 'fe-iframe-light';
+ const scriptContent = getEmbeddedScript();
+ // The iframe is sandboxed (sandbox="allow-scripts") so 'unsafe-inline' here
+ // only applies within the isolated srcdoc context — not the parent page.
+ // Using a hash is fragile because the browser hashes the full text node
+ // between including template-literal whitespace.
return `
@@ -32,7 +37,7 @@ export function buildIframeSrcdoc(emailHtml: string, isDarkMode: boolean = false
${emailHtml}