From 8cdb2bec7fc241f7e16db9920ecdb595f525675e Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:31:08 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20customizable=20appearance=20?= =?UTF-8?q?=E2=80=94=20theme=20presets=20+=20accent=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CSS custom property override layer that re-skins Tailwind's hardcoded gray/blue colors globally without touching any component files. - 5 theme presets: Default, Midnight, Nord, Solarized, Rose - 8 accent color swatches + custom hex color picker - All settings persist via electron-store and apply instantly - useAppearance hook applies CSS variables on theme/config change - Expanded Appearance section in Settings with preset swatches and accent picker --- src/main/ipc/settings.ipc.ts | 40 +++++ src/preload/index.ts | 17 ++ src/renderer/App.tsx | 4 + src/renderer/components/SettingsPanel.tsx | 207 +++++++++++++++++----- src/renderer/hooks/useAppearance.ts | 80 +++++++++ src/renderer/store/index.ts | 12 ++ src/renderer/styles/index.css | 197 ++++++++++++++++++-- src/shared/theme-presets.ts | 191 ++++++++++++++++++++ src/shared/types.ts | 11 ++ 9 files changed, 703 insertions(+), 56 deletions(-) create mode 100644 src/renderer/hooks/useAppearance.ts create mode 100644 src/shared/theme-presets.ts diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 8b5a691a..b3cb2706 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -1,6 +1,7 @@ import { ipcMain, nativeTheme, BrowserWindow, shell, dialog } from "electron"; import Store from "electron-store"; import { + type AppearanceConfig, type Config, type EAConfig, type IpcResponse, @@ -657,6 +658,45 @@ export function registerSettingsIpc(): void { }, ); + // Get appearance config + ipcMain.handle("appearance:get", async (): Promise> => { + try { + const config = getConfig(); + return { + success: true, + data: config.appearance ?? { themePreset: "default", accentColor: null }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + // Set appearance config — saves to store, broadcasts to renderer + ipcMain.handle( + "appearance:set", + async (_, appearance: AppearanceConfig): Promise> => { + try { + const currentConfig = getConfig(); + getStore().set("config", { ...currentConfig, appearance }); + + // Broadcast to all renderer windows + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send("appearance:changed", appearance); + } + + return { success: true, data: undefined }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + // Test OpenClaw connection by running `openclaw health` ipcMain.handle("settings:test-openclaw-connection", async (): Promise> => { const { execFile } = await import("node:child_process"); diff --git a/src/preload/index.ts b/src/preload/index.ts index 0fc45a85..6cad769c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,7 @@ const api = { saveCredentials: (clientId: string, clientSecret: string): Promise => ipcRenderer.invoke("gmail:save-credentials", { clientId, clientSecret }), startOAuth: (): Promise => ipcRenderer.invoke("gmail:start-oauth"), + abortOAuth: (): Promise => ipcRenderer.invoke("gmail:abort-oauth"), }, // Analysis operations @@ -557,6 +558,22 @@ const api = { }, }, + // Appearance customization + appearance: { + get: (): Promise => ipcRenderer.invoke("appearance:get"), + set: (config: Record): Promise => + ipcRenderer.invoke("appearance:set", config), + onChange: (callback: (data: Record) => void): void => { + ipcRenderer.on( + "appearance:changed", + (_: Electron.IpcRendererEvent, data: Record) => callback(data), + ); + }, + removeAllListeners: (): void => { + ipcRenderer.removeAllListeners("appearance:changed"); + }, + }, + // Auth events (token expiry, extension re-auth) auth: { onTokenExpired: ( diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 687c591a..b7b4c9d4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -27,6 +27,7 @@ import { DraftEditLearnedToast } from "./components/DraftEditLearnedToast"; import { AnalysisOverrideLearnedToast } from "./components/AnalysisOverrideLearnedToast"; import { SnoozeMenu } from "./components/SnoozeMenu"; import { FindBar } from "./components/FindBar"; +import { useAppearance } from "./hooks/useAppearance"; import { registerBundledExtensions } from "./extensions"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { @@ -665,6 +666,9 @@ export default function App() { syncProgress, } = useAppStore(); + // Apply theme CSS variables (presets, accent, vibrancy, font scale) + useAppearance(); + // Initialize keyboard shortcuts useKeyboardShortcuts({ onToggleShortcutHelp: () => setShowShortcuts((prev) => !prev), diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index e755601f..08230883 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -17,8 +17,10 @@ import { type McpServerConfig, type ModelConfig, type ModelTier, + type AppearanceConfig, type CliToolConfig, } from "../../shared/types"; +import { THEME_PRESET_LIST, ACCENT_SWATCHES } from "../../shared/theme-presets"; import { useAppStore, type Account, type SettingsTab } from "../store"; import { reconfigurePostHog, trackEvent } from "../services/posthog"; import { SplitConfigEditor } from "./SplitConfigEditor"; @@ -51,6 +53,8 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setUndoSendDelay, currentAccountId, highlightMemoryIds, + appearance, + setAppearance, } = useAppStore(); const [isAddingAccount, setIsAddingAccount] = useState(false); const [addAccountPhase, setAddAccountPhase] = useState("Connecting..."); @@ -353,6 +357,13 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { } }; + // Update a single appearance field — merges with current config, persists, and applies + const updateAppearance = async (patch: Partial) => { + const updated = { ...appearance, ...patch }; + setAppearance(updated); + await window.api.appearance.set(updated); + }; + const handleDensityChange = async (density: InboxDensity) => { setInboxDensity(density); await window.api.settings.set({ inboxDensity: density }); @@ -852,48 +863,148 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { Configure how Exo generates draft replies.

- {/* Appearance / Theme Toggle */} -
-
-

Appearance

-

- Choose your preferred color theme. + {/* Appearance */} +

+ {/* Light / Dark / System toggle */} +
+

Mode

+
+ {(["light", "dark", "system"] as const).map((mode) => ( + + ))} +
+
+ + {/* Theme presets */} +
+

Theme

+

+ Color palettes for surfaces and accents.

+
+ {THEME_PRESET_LIST.map((preset) => ( + + ))} +
-
- - - + + {/* Accent color picker */} +
+

+ Accent Color +

+

+ Override the theme accent with a custom color. +

+
+ {/* "Auto" = use preset default */} + + + {/* Preset accent swatches */} + {ACCENT_SWATCHES.map((swatch) => ( +
@@ -1404,7 +1515,21 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { {accountError && (
- {accountError} +

{accountError}

+ {accountError.includes("Access denied") && ( +

+ Add your email as a test user in{" "} + + Google Cloud Console → Audience + + , then try again. +

+ )}
)} diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts new file mode 100644 index 00000000..0edb3a6f --- /dev/null +++ b/src/renderer/hooks/useAppearance.ts @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { useAppStore } from "../store"; +import { THEME_PRESETS } from "../../shared/theme-presets"; +import type { AppearanceConfig } from "../../shared/types"; +import type { ThemeColors } from "../../shared/theme-presets"; + +// Convert "#2563eb" → "37 99 235" +function hexToRgbTriplet(hex: string): string { + const h = hex.replace("#", ""); + const n = parseInt(h, 16); + return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`; +} + +// Lighten or darken an RGB triplet by a factor (-1 to 1) +function adjustBrightness(triplet: string, factor: number): string { + const [r, g, b] = triplet.split(" ").map(Number); + const adjust = (c: number) => + Math.min(255, Math.max(0, Math.round(factor > 0 ? c + (255 - c) * factor : c * (1 + factor)))); + return `${adjust(r)} ${adjust(g)} ${adjust(b)}`; +} + +// Apply the full set of CSS variables to +function applyThemeVariables(appearance: AppearanceConfig, isDark: boolean): void { + const preset = THEME_PRESETS[appearance.themePreset] ?? THEME_PRESETS.default; + const colors: ThemeColors = isDark ? preset.dark : preset.light; + const root = document.documentElement; + + // Surface & text colors from preset + root.style.setProperty("--bg-base", colors.bgBase); + root.style.setProperty("--bg-surface", colors.bgSurface); + root.style.setProperty("--bg-elevated", colors.bgElevated); + root.style.setProperty("--border-default", colors.borderDefault); + root.style.setProperty("--text-primary", colors.textPrimary); + root.style.setProperty("--text-secondary", colors.textSecondary); + + // Accent — custom color overrides the preset + if (appearance.accentColor) { + const rgb = hexToRgbTriplet(appearance.accentColor); + root.style.setProperty("--accent", rgb); + root.style.setProperty("--accent-hover", adjustBrightness(rgb, isDark ? 0.25 : -0.15)); + root.style.setProperty("--accent-soft", adjustBrightness(rgb, isDark ? -0.6 : 0.7)); + } else { + root.style.setProperty("--accent", colors.accent); + root.style.setProperty("--accent-hover", colors.accentHover); + root.style.setProperty("--accent-soft", colors.accentSoft); + } +} + +/** + * Reads appearance config from the store, applies CSS variables to , + * and listens for changes from the main process. + */ +export function useAppearance(): void { + const appearance = useAppStore((s) => s.appearance); + const setAppearance = useAppStore((s) => s.setAppearance); + const resolvedTheme = useAppStore((s) => s.resolvedTheme); + + // Fetch persisted config on mount + useEffect(() => { + window.api.appearance.get().then((result: { success: boolean; data?: AppearanceConfig }) => { + if (result.success && result.data) { + setAppearance(result.data); + } + }); + + // Listen for changes broadcast from main process (e.g. from another window) + window.api.appearance.onChange((data: Record) => { + setAppearance(data as AppearanceConfig); + }); + + return () => { + window.api.appearance.removeAllListeners(); + }; + }, [setAppearance]); + + // Re-apply CSS variables whenever appearance config or resolved theme changes + useEffect(() => { + applyThemeVariables(appearance, resolvedTheme === "dark"); + }, [appearance, resolvedTheme]); +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7d29c875..2bde956c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { clearPendingLabelUpdates } from "../hooks-bridge"; import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads"; import type { + AppearanceConfig, DashboardEmail, ComposeMode, OutboxStats, @@ -248,6 +249,9 @@ interface AppState { themePreference: ThemePreference; resolvedTheme: "light" | "dark"; + // Appearance customization + appearance: AppearanceConfig; + // Inbox density state inboxDensity: InboxDensity; @@ -420,6 +424,7 @@ interface AppState { // Theme actions setThemePreference: (preference: ThemePreference) => void; setResolvedTheme: (theme: "light" | "dark") => void; + setAppearance: (config: AppearanceConfig) => void; // Inbox density actions setInboxDensity: (density: InboxDensity) => void; @@ -599,6 +604,12 @@ export const useAppStore = create((set, get) => ({ themePreference: "system", resolvedTheme: "light", + // Appearance defaults + appearance: { + themePreset: "default", + accentColor: null, + }, + // Inbox density state inboxDensity: "compact", @@ -1040,6 +1051,7 @@ export const useAppStore = create((set, get) => ({ // Theme actions setThemePreference: (preference) => set({ themePreference: preference }), setResolvedTheme: (theme) => set({ resolvedTheme: theme }), + setAppearance: (config) => set({ appearance: config }), // Inbox density actions setInboxDensity: (density) => set({ inboxDensity: density }), diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 8c18eb11..f5160038 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -2,32 +2,199 @@ @tailwind components; @tailwind utilities; -/* Custom scrollbar styles */ -::-webkit-scrollbar { - width: 8px; - height: 8px; +/* ============================================================ + Theme variable system + + CSS custom properties set by useAppearance hook at runtime. + Default values here match the current Tailwind gray/blue palette + so the app looks identical without any appearance config. + ============================================================ */ + +@layer base { + :root { + /* Surface colors (space-separated RGB for use with rgb() / rgba()) */ + --bg-base: 243 244 246; + --bg-surface: 255 255 255; + --bg-elevated: 255 255 255; + --border-default: 229 231 235; + + /* Text */ + --text-primary: 17 24 39; + --text-secondary: 75 85 99; + + /* Accent (interactive elements) */ + --accent: 37 99 235; + --accent-hover: 29 78 216; + --accent-soft: 219 234 254; + } + + .dark { + --bg-base: 17 24 39; + --bg-surface: 31 41 55; + --bg-elevated: 55 65 81; + --border-default: 55 65 81; + --text-primary: 243 244 246; + --text-secondary: 156 163 175; + --accent: 59 130 246; + --accent-hover: 96 165 250; + --accent-soft: 30 58 138; + } } -::-webkit-scrollbar-track { - background: transparent; + +/* --- Surface color overrides --- + Re-skin Tailwind's hardcoded grays so theme presets work + without touching any component files. */ + +/* bg-gray-100 (light base) */ +.bg-gray-100 { + background-color: rgb(var(--bg-base)) !important; } -::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; +/* dark:bg-gray-900 (dark base) */ +.dark .bg-gray-900 { + background-color: rgb(var(--bg-base)) !important; +} + +/* bg-white (light surface — sidebar, cards, titlebar) */ +.bg-white { + background-color: rgb(var(--bg-surface)) !important; } +/* dark:bg-gray-800 (dark surface) */ +.dark .bg-gray-800 { + background-color: rgb(var(--bg-surface)) !important; +} + +/* dark:bg-gray-700 (dark elevated — dropdowns, inputs) */ +.dark .bg-gray-700 { + background-color: rgb(var(--bg-elevated)) !important; +} + +/* border overrides */ +.border-gray-200 { + border-color: rgb(var(--border-default)) !important; +} +.dark .border-gray-700, +.dark .border-gray-600 { + border-color: rgb(var(--border-default)) !important; +} +.divide-gray-200 > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(var(--border-default)) !important; +} +.dark .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(var(--border-default)) !important; +} + +/* --- Accent color overrides --- + Re-skin Tailwind's hardcoded blues for interactive elements. */ + +/* Primary accent backgrounds */ +.bg-blue-600 { + background-color: rgb(var(--accent)) !important; +} +.dark .bg-blue-500 { + background-color: rgb(var(--accent)) !important; +} +.hover\:bg-blue-700:hover { + background-color: rgb(var(--accent-hover)) !important; +} +.dark .hover\:bg-blue-600:hover, +.dark .dark\:hover\:bg-blue-600:hover { + background-color: rgb(var(--accent-hover)) !important; +} + +/* Soft accent backgrounds */ +.bg-blue-100 { + background-color: rgb(var(--accent-soft) / 0.3) !important; +} +.bg-blue-50 { + background-color: rgb(var(--accent-soft) / 0.15) !important; +} +.dark .bg-blue-900\/30, +.dark .bg-blue-900\/40, +.dark .bg-blue-900\/60, +.dark .bg-blue-900\/20 { + background-color: rgb(var(--accent-soft) / 0.3) !important; +} +.hover\:bg-blue-100:hover { + background-color: rgb(var(--accent-soft) / 0.4) !important; +} +.dark .hover\:bg-blue-900\/40:hover, +.dark .dark\:hover\:bg-blue-900\/40:hover { + background-color: rgb(var(--accent-soft) / 0.4) !important; +} +.dark .hover\:bg-blue-900\/50:hover, +.dark .dark\:hover\:bg-blue-900\/50:hover { + background-color: rgb(var(--accent-soft) / 0.5) !important; +} + +/* Accent text */ +.text-blue-600 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-400, +.dark .dark\:text-blue-400 { + color: rgb(var(--accent-hover)) !important; +} +.text-blue-700 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-300, +.dark .dark\:text-blue-300 { + color: rgb(var(--accent-hover)) !important; +} +.text-blue-800 { + color: rgb(var(--accent)) !important; +} +.dark .text-blue-200, +.dark .dark\:text-blue-200 { + color: rgb(var(--accent-hover)) !important; +} +.hover\:text-blue-500:hover, +.hover\:text-blue-800:hover { + color: rgb(var(--accent-hover)) !important; +} + +/* Accent borders & rings */ +.border-blue-500, +.border-blue-600 { + border-color: rgb(var(--accent)) !important; +} +.dark .border-blue-500, +.dark .dark\:border-blue-500 { + border-color: rgb(var(--accent)) !important; +} +.ring-blue-500, +.focus\:ring-blue-500:focus { + --tw-ring-color: rgb(var(--accent)) !important; +} + +/* Checkbox/toggle accent */ +.peer-checked\:bg-blue-600 { + background-color: rgb(var(--accent)) !important; +} + +/* Scrollbar themed colors */ +::-webkit-scrollbar-thumb { + background: rgb(var(--text-secondary) / 0.3) !important; +} ::-webkit-scrollbar-thumb:hover { - background: #94a3b8; + background: rgb(var(--text-secondary) / 0.5) !important; } -/* Dark mode scrollbar */ -.dark ::-webkit-scrollbar-thumb { - background: #4b5563; +/* Scrollbar base (colors handled by theme overrides above) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; } -.dark ::-webkit-scrollbar-thumb:hover { - background: #6b7280; +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: 4px; } /* Titlebar drag region */ diff --git a/src/shared/theme-presets.ts b/src/shared/theme-presets.ts new file mode 100644 index 00000000..40234820 --- /dev/null +++ b/src/shared/theme-presets.ts @@ -0,0 +1,191 @@ +import type { ThemePresetId } from "./types"; + +// All color values are space-separated RGB triplets for use with rgb() / rgba() +export interface ThemeColors { + bgBase: string; + bgSurface: string; + bgElevated: string; + borderDefault: string; + textPrimary: string; + textSecondary: string; + accent: string; + accentHover: string; + accentSoft: string; +} + +export interface ThemePreset { + id: ThemePresetId; + name: string; + light: ThemeColors; + dark: ThemeColors; + + // Preview colors for the settings UI swatch + preview: { surface: string; accent: string }; +} + +// Default — matches the current Tailwind gray/blue palette exactly +const DEFAULT_PRESET: ThemePreset = { + id: "default", + name: "Default", + preview: { surface: "#f3f4f6", accent: "#2563eb" }, + light: { + bgBase: "243 244 246", + bgSurface: "255 255 255", + bgElevated: "255 255 255", + borderDefault: "229 231 235", + textPrimary: "17 24 39", + textSecondary: "75 85 99", + accent: "37 99 235", + accentHover: "29 78 216", + accentSoft: "219 234 254", + }, + dark: { + bgBase: "17 24 39", + bgSurface: "31 41 55", + bgElevated: "55 65 81", + borderDefault: "55 65 81", + textPrimary: "243 244 246", + textSecondary: "156 163 175", + accent: "59 130 246", + accentHover: "96 165 250", + accentSoft: "30 58 138", + }, +}; + +// Midnight — deep navy surfaces, violet accent +const MIDNIGHT_PRESET: ThemePreset = { + id: "midnight", + name: "Midnight", + preview: { surface: "#0f172a", accent: "#8b5cf6" }, + light: { + bgBase: "241 245 249", + bgSurface: "248 250 252", + bgElevated: "255 255 255", + borderDefault: "226 232 240", + textPrimary: "15 23 42", + textSecondary: "71 85 105", + accent: "139 92 246", + accentHover: "124 58 237", + accentSoft: "237 233 254", + }, + dark: { + bgBase: "15 23 42", + bgSurface: "30 41 59", + bgElevated: "51 65 85", + borderDefault: "51 65 85", + textPrimary: "241 245 249", + textSecondary: "148 163 184", + accent: "139 92 246", + accentHover: "167 139 250", + accentSoft: "76 29 149", + }, +}; + +// Nord — polar night surfaces, frost blue accent +const NORD_PRESET: ThemePreset = { + id: "nord", + name: "Nord", + preview: { surface: "#2e3440", accent: "#88c0d0" }, + light: { + bgBase: "236 239 244", + bgSurface: "242 244 248", + bgElevated: "255 255 255", + borderDefault: "216 222 233", + textPrimary: "46 52 64", + textSecondary: "76 86 106", + accent: "94 129 172", + accentHover: "76 108 148", + accentSoft: "222 233 244", + }, + dark: { + bgBase: "46 52 64", + bgSurface: "59 66 82", + bgElevated: "67 76 94", + borderDefault: "67 76 94", + textPrimary: "236 239 244", + textSecondary: "216 222 233", + accent: "136 192 208", + accentHover: "143 188 187", + accentSoft: "59 80 97", + }, +}; + +// Solarized — warm base tones, yellow accent +const SOLARIZED_PRESET: ThemePreset = { + id: "solarized", + name: "Solarized", + preview: { surface: "#002b36", accent: "#b58900" }, + light: { + bgBase: "238 232 213", + bgSurface: "253 246 227", + bgElevated: "255 255 255", + borderDefault: "220 213 194", + textPrimary: "0 43 54", + textSecondary: "88 110 117", + accent: "181 137 0", + accentHover: "152 115 0", + accentSoft: "248 237 196", + }, + dark: { + bgBase: "0 43 54", + bgSurface: "7 54 66", + bgElevated: "29 78 89", + borderDefault: "29 78 89", + textPrimary: "238 232 213", + textSecondary: "147 161 161", + accent: "181 137 0", + accentHover: "209 166 41", + accentSoft: "60 52 12", + }, +}; + +// Rose — warm surfaces, pink accent +const ROSE_PRESET: ThemePreset = { + id: "rose", + name: "Rose", + preview: { surface: "#1c1017", accent: "#f43f5e" }, + light: { + bgBase: "255 241 242", + bgSurface: "255 255 255", + bgElevated: "255 255 255", + borderDefault: "252 231 233", + textPrimary: "28 16 23", + textSecondary: "113 63 79", + accent: "244 63 94", + accentHover: "225 29 72", + accentSoft: "255 228 230", + }, + dark: { + bgBase: "28 16 23", + bgSurface: "44 24 36", + bgElevated: "64 36 52", + borderDefault: "64 36 52", + textPrimary: "255 241 242", + textSecondary: "194 153 168", + accent: "251 113 133", + accentHover: "253 164 175", + accentSoft: "100 20 44", + }, +}; + +export const THEME_PRESETS: Record = { + default: DEFAULT_PRESET, + midnight: MIDNIGHT_PRESET, + nord: NORD_PRESET, + solarized: SOLARIZED_PRESET, + rose: ROSE_PRESET, +}; + +export const THEME_PRESET_LIST: ThemePreset[] = Object.values(THEME_PRESETS); + +// Accent color swatches for the picker UI +export const ACCENT_SWATCHES = [ + { name: "Blue", hex: "#2563eb", rgb: "37 99 235" }, + { name: "Violet", hex: "#7c3aed", rgb: "124 58 237" }, + { name: "Pink", hex: "#db2777", rgb: "219 39 119" }, + { name: "Rose", hex: "#f43f5e", rgb: "244 63 94" }, + { name: "Orange", hex: "#ea580c", rgb: "234 88 12" }, + { name: "Green", hex: "#16a34a", rgb: "22 163 74" }, + { name: "Teal", hex: "#0d9488", rgb: "13 148 136" }, + { name: "Cyan", hex: "#0891b2", rgb: "8 145 178" }, +] as const; diff --git a/src/shared/types.ts b/src/shared/types.ts index 5a21e9fe..18454c7b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -358,6 +358,16 @@ export function resolveModelId(tier: ModelTier): string { return MODEL_TIER_IDS[tier]; } +// Appearance customization +export const ThemePresetSchema = z.enum(["default", "midnight", "nord", "solarized", "rose"]); +export type ThemePresetId = z.infer; + +export const AppearanceConfigSchema = z.object({ + themePreset: ThemePresetSchema.default("default"), + accentColor: z.string().nullable().default(null), +}); +export type AppearanceConfig = z.infer; + // Config schema export const ConfigSchema = z.object({ maxEmails: z.number().default(50), @@ -405,6 +415,7 @@ export const ConfigSchema = z.object({ gatewayToken: z.string().default(""), }) .optional(), + appearance: AppearanceConfigSchema.optional(), configVersion: z.number().optional(), }); From b9187180df6e9eb5ff43cdcd9b85fe09241aa399 Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:45:46 -0300 Subject: [PATCH 2/8] feat: add Gmail send-as alias support - Fetch and cache send-as aliases per account (1h TTL) - From selector in compose UI (only shown with 2+ aliases) - Smart reply default: auto-selects alias the email was sent to - Forward `from` through outbox, scheduled send, and local drafts - DB migration v2: send_as_aliases table + from_address columns --- src/main/db/index.ts | 111 ++++++++++++++++++-- src/main/db/schema.ts | 14 +++ src/main/ipc/compose.ipc.ts | 43 ++++++++ src/main/ipc/scheduled-send.ipc.ts | 1 + src/main/services/gmail-client.ts | 54 ++++++---- src/main/services/outbox-service.ts | 3 + src/main/services/scheduled-send-service.ts | 1 + src/preload/index.ts | 6 ++ src/renderer/components/EmailDetail.tsx | 13 +++ src/renderer/components/FromSelector.tsx | 35 ++++++ src/renderer/hooks/useComposeForm.ts | 51 ++++++++- src/shared/types.ts | 9 ++ 12 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 src/renderer/components/FromSelector.tsx diff --git a/src/main/db/index.ts b/src/main/db/index.ts index a47a2a14..1f572350 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -393,6 +393,29 @@ const NUMBERED_MIGRATIONS: Migration[] = [ `); }, }, + { + version: 2, + name: "add_send_as_aliases_and_from_address", + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS send_as_aliases ( + email TEXT NOT NULL, + account_id TEXT NOT NULL, + display_name TEXT, + is_default INTEGER DEFAULT 0, + reply_to_address TEXT, + verification_status TEXT, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (email, account_id), + FOREIGN KEY (account_id) REFERENCES accounts(id) + ); + CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id); + ALTER TABLE local_drafts ADD COLUMN from_address TEXT; + ALTER TABLE outbox ADD COLUMN from_address TEXT; + ALTER TABLE scheduled_messages ADD COLUMN from_address TEXT; + `); + }, + }, ]; function runNumberedMigrations(db: DatabaseInstance): void { @@ -2074,6 +2097,7 @@ export function removeAccount(accountId: string): void { db.prepare("DELETE FROM calendar_sync_state WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM memories WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM agent_audit_log WHERE account_id = ?").run(accountId); + db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM emails WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId); }); @@ -2086,6 +2110,65 @@ export function setPrimaryAccount(accountId: string): void { db.prepare("UPDATE accounts SET is_primary = 1 WHERE id = ?").run(accountId); } +// ============================================ +// Send-as alias operations +// ============================================ + +import type { SendAsAlias } from "../../shared/types"; + +export function upsertSendAsAliases(accountId: string, aliases: SendAsAlias[]): void { + const db = getDatabase(); + const now = Date.now(); + + const run = db.transaction(() => { + // Clear stale aliases for this account, then insert fresh + db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId); + + const stmt = db.prepare(` + INSERT INTO send_as_aliases (email, account_id, display_name, is_default, reply_to_address, verification_status, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + for (const alias of aliases) { + stmt.run( + alias.email, + accountId, + alias.displayName || null, + alias.isDefault ? 1 : 0, + alias.replyToAddress || null, + "accepted", + now, + ); + } + }); + run(); +} + +export function getSendAsAliases(accountId: string): SendAsAlias[] { + const db = getDatabase(); + const rows = db + .prepare( + `SELECT email, display_name as displayName, is_default as isDefault, reply_to_address as replyToAddress + FROM send_as_aliases WHERE account_id = ? ORDER BY is_default DESC, email ASC`, + ) + .all(accountId) as Array>; + + return rows.map((row) => ({ + email: row.email as string, + displayName: (row.displayName as string | null) ?? undefined, + isDefault: Boolean(row.isDefault), + replyToAddress: (row.replyToAddress as string | null) ?? undefined, + })); +} + +export function getSendAsAliasFetchedAt(accountId: string): number | null { + const db = getDatabase(); + const row = db + .prepare("SELECT MAX(fetched_at) as fetchedAt FROM send_as_aliases WHERE account_id = ?") + .get(accountId) as { fetchedAt: number | null } | undefined; + return row?.fetchedAt ?? null; +} + // ============================================ // Sender profile operations // ============================================ @@ -2685,10 +2768,10 @@ export function saveLocalDraft(draft: LocalDraft): void { INSERT OR REPLACE INTO local_drafts ( id, account_id, gmail_draft_id, thread_id, in_reply_to, to_addresses, cc_addresses, bcc_addresses, subject, - body_html, body_text, is_reply, is_forward, + body_html, body_text, from_address, is_reply, is_forward, created_at, updated_at, synced_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( draft.id, @@ -2702,6 +2785,7 @@ export function saveLocalDraft(draft: LocalDraft): void { draft.subject, draft.bodyHtml, draft.bodyText || null, + draft.fromAddress || null, draft.isReply ? 1 : 0, draft.isForward ? 1 : 0, draft.createdAt, @@ -2718,6 +2802,7 @@ export function getLocalDraft(draftId: string): LocalDraft | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, + from_address as fromAddress, is_reply as isReply, is_forward as isForward, created_at as createdAt, updated_at as updatedAt, synced_at as syncedAt @@ -2737,6 +2822,7 @@ export function getLocalDrafts(accountId?: string): LocalDraft[] { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, + from_address as fromAddress, is_reply as isReply, is_forward as isForward, created_at as createdAt, updated_at as updatedAt, synced_at as syncedAt @@ -2781,6 +2867,7 @@ function rowToLocalDraft(row: Record): LocalDraft { subject: row.subject as string, bodyHtml: row.bodyHtml as string, bodyText: (row.bodyText as string | null) ?? undefined, + fromAddress: (row.fromAddress as string | null) ?? undefined, isReply: Boolean(row.isReply), isForward: Boolean(row.isForward), createdAt: row.createdAt as number, @@ -3156,6 +3243,7 @@ export type OutboxItem = { accountId: string; type: OutboxType; threadId?: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -3194,9 +3282,9 @@ export function insertOutboxMessage( INSERT INTO outbox ( id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses, subject, body_html, body_text, in_reply_to, references_header, - attachments, status, retry_count, created_at, updated_at + attachments, from_address, status, retry_count, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?) `); const now = Date.now(); stmt.run( @@ -3213,6 +3301,7 @@ export function insertOutboxMessage( item.inReplyTo || null, item.references || null, item.attachments ? JSON.stringify(item.attachments) : null, + item.from || null, item.createdAt, now, ); @@ -3249,6 +3338,7 @@ export function getPendingOutbox(accountId?: string, limit: number = 10): Outbox to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, + from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3271,6 +3361,7 @@ export function getOutboxItems(accountId?: string): OutboxItem[] { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, + from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3293,6 +3384,7 @@ export function getOutboxItem(id: string): OutboxItem | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, + from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3596,6 +3688,7 @@ export type ScheduledMessageRow = { accountId: string; type: "send" | "reply"; threadId?: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -3620,9 +3713,9 @@ export function insertScheduledMessage( INSERT INTO scheduled_messages ( id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses, subject, body_html, body_text, in_reply_to, references_header, - scheduled_at, status, created_at, updated_at + from_address, scheduled_at, status, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) `); const now = Date.now(); stmt.run( @@ -3638,6 +3731,7 @@ export function insertScheduledMessage( item.bodyText || null, item.inReplyTo || null, item.references || null, + item.from || null, item.scheduledAt, item.createdAt, now, @@ -3652,6 +3746,7 @@ export function getDueScheduledMessages(limit: number = 10): ScheduledMessageRow to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, + from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3670,6 +3765,7 @@ export function getScheduledMessages(accountId?: string): ScheduledMessageRow[] to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, + from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3692,6 +3788,7 @@ export function getScheduledMessage(id: string): ScheduledMessageRow | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, + from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3772,6 +3869,7 @@ function rowToScheduledMessage(row: Record): ScheduledMessageRo accountId: row.accountId as string, type: row.type as "send" | "reply", threadId: (row.threadId as string | null) ?? undefined, + from: (row.fromAddress as string | null) ?? undefined, to: JSON.parse(row.toAddresses as string) as string[], cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined, bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined, @@ -4045,6 +4143,7 @@ function rowToOutboxItem(row: Record): OutboxItem { accountId: row.accountId as string, type: row.type as OutboxType, threadId: (row.threadId as string | null) ?? undefined, + from: (row.fromAddress as string | null) ?? undefined, to: JSON.parse(row.toAddresses as string) as string[], cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined, bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined, diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 89c51cb7..8a92eb97 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -8,6 +8,19 @@ CREATE TABLE IF NOT EXISTS accounts ( added_at INTEGER NOT NULL ); +-- Gmail send-as aliases per account (cached from Gmail settings API) +CREATE TABLE IF NOT EXISTS send_as_aliases ( + email TEXT NOT NULL, + account_id TEXT NOT NULL, + display_name TEXT, + is_default INTEGER DEFAULT 0, + reply_to_address TEXT, + verification_status TEXT, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (email, account_id), + FOREIGN KEY (account_id) REFERENCES accounts(id) +); + -- Sync state for each account (for incremental sync) CREATE TABLE IF NOT EXISTS sync_state ( account_id TEXT PRIMARY KEY, @@ -355,6 +368,7 @@ CREATE INDEX IF NOT EXISTS idx_memories_account ON memories(account_id); CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope, scope_value); CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id); CREATE INDEX IF NOT EXISTS idx_emails_in_reply_to ON emails(in_reply_to); +CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id); `; // FTS5 full-text search schema (separate because SQLite can't IF NOT EXISTS for virtual tables) diff --git a/src/main/ipc/compose.ipc.ts b/src/main/ipc/compose.ipc.ts index bb5b0d4e..4a27a864 100644 --- a/src/main/ipc/compose.ipc.ts +++ b/src/main/ipc/compose.ipc.ts @@ -13,6 +13,9 @@ import { getArchiveReadyForThread, getEmailsByThread, updateEmailLabelIds, + getSendAsAliases, + getSendAsAliasFetchedAt, + upsertSendAsAliases, } from "../db"; import { networkMonitor } from "../services/network-monitor"; import { outboxService } from "../services/outbox-service"; @@ -27,6 +30,7 @@ import type { ReplyInfo, SendMessageOptions, SendMessageResult, + SendAsAlias, } from "../../shared/types"; import { formatAddressesWithNames, extractThreadNames } from "../utils/address-formatting"; import { createLogger } from "../services/logger"; @@ -54,6 +58,7 @@ function queueToOutbox(options: SendMessageOptions & { accountId: string }): Sen accountId: options.accountId, type: options.threadId ? "reply" : "send", threadId: options.threadId, + from: options.from, to: formattedTo, cc: formattedCc, bcc: formattedBcc, @@ -847,4 +852,42 @@ export function registerComposeIpc(): void { } }, ); + + // Fetch send-as aliases for an account (cached with 1h TTL) + const SEND_AS_CACHE_TTL_MS = 60 * 60 * 1000; + ipcMain.handle( + "compose:get-send-as-aliases", + async (_, { accountId }: { accountId: string }): Promise> => { + try { + // Check cache freshness + const fetchedAt = getSendAsAliasFetchedAt(accountId); + if (fetchedAt && Date.now() - fetchedAt < SEND_AS_CACHE_TTL_MS) { + return { success: true, data: getSendAsAliases(accountId) }; + } + + // Fetch fresh from Gmail API + const syncService = getEmailSyncService(); + const client = syncService.getClientForAccount(accountId); + if (!client) { + // Offline or no client — return cached if available + const cached = getSendAsAliases(accountId); + return { success: true, data: cached }; + } + + const aliases = await client.fetchSendAsAliases(); + upsertSendAsAliases(accountId, aliases); + return { success: true, data: aliases }; + } catch (error) { + // Fall back to cache on API error + const cached = getSendAsAliases(accountId); + if (cached.length > 0) { + return { success: true, data: cached }; + } + return { + success: false, + error: error instanceof Error ? error.message : "Failed to fetch send-as aliases", + }; + } + }, + ); } diff --git a/src/main/ipc/scheduled-send.ipc.ts b/src/main/ipc/scheduled-send.ipc.ts index 32434c5f..71ac335a 100644 --- a/src/main/ipc/scheduled-send.ipc.ts +++ b/src/main/ipc/scheduled-send.ipc.ts @@ -102,6 +102,7 @@ export function registerScheduledSendIpc(): void { accountId: options.accountId, type: options.threadId ? "reply" : "send", threadId: options.threadId, + from: options.from, to: formatAddressesWithNames(options.to, recipientNames), cc: options.cc ? formatAddressesWithNames(options.cc, recipientNames) : undefined, bcc: options.bcc ? formatAddressesWithNames(options.bcc, recipientNames) : undefined, diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 4ad11ee2..8c9129cd 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -16,6 +16,7 @@ import type { SendMessageOptions, ComposeMessageOptions, AttachmentMeta, + SendAsAlias, } from "../../shared/types"; import { getAccounts } from "../db"; import { getDataDir } from "../data-dir"; @@ -1502,32 +1503,47 @@ export class GmailClient { * Falls back to the Google People API (own profile) if send-as has no name, * which is common for Google Workspace accounts. */ + /** + * Fetch all verified send-as aliases from Gmail settings. + * Only returns aliases with accepted verification status (or primary). + */ + async fetchSendAsAliases(): Promise { + const gmail = this.gmail!; + const response = await gmail.users.settings.sendAs.list({ userId: "me" }); + const rawAliases = response.data.sendAs || []; + + // Only include verified aliases (primary is always verified) + return rawAliases + .filter((s) => s.isPrimary || s.verificationStatus === "accepted") + .map((s) => ({ + email: s.sendAsEmail!, + displayName: s.displayName?.trim() || undefined, + isDefault: Boolean(s.isDefault), + replyToAddress: s.replyToAddress || undefined, + })); + } + async fetchDisplayName(): Promise { try { - const gmail = this.gmail!; - - // Try send-as settings first (the name shown on outgoing mail) - const sendAsResponse = await gmail.users.settings.sendAs.list({ userId: "me" }); - const primarySendAs = sendAsResponse.data.sendAs?.find((s) => s.isPrimary); - const sendAsName = primarySendAs?.displayName?.trim() || null; - if (sendAsName) { - log.info(`[GmailClient] Display name from send-as: "${sendAsName}"`); - return sendAsName; + // Reuse fetchSendAsAliases to avoid duplicate API call + const aliases = await this.fetchSendAsAliases(); + + // Prefer the default alias's display name + const defaultAlias = aliases.find((a) => a.isDefault); + if (defaultAlias?.displayName) { + log.info(`[GmailClient] Display name from send-as: "${defaultAlias.displayName}"`); + return defaultAlias.displayName; } - // Fallback: check all send-as aliases for one matching this account's email. - // Use getProfile() instead of getAccountInfo() since the account may not - // be in the DB yet during OAuth registration. + // Fallback: find alias matching this account's email const accountEmail = this.getAccountInfo()?.email || (await this.getProfile()).emailAddress; if (accountEmail) { - const matchingAlias = sendAsResponse.data.sendAs?.find( - (s) => - s.sendAsEmail?.toLowerCase() === accountEmail.toLowerCase() && s.displayName?.trim(), + const matching = aliases.find( + (a) => a.email.toLowerCase() === accountEmail.toLowerCase() && a.displayName, ); - if (matchingAlias?.displayName) { - const name = matchingAlias.displayName.trim(); - log.info(`[GmailClient] Display name from send-as alias match: "${name}"`); - return name; + if (matching?.displayName) { + log.info(`[GmailClient] Display name from send-as alias match: "${matching.displayName}"`); + return matching.displayName; } } diff --git a/src/main/services/outbox-service.ts b/src/main/services/outbox-service.ts index 7fa310b0..3729e791 100644 --- a/src/main/services/outbox-service.ts +++ b/src/main/services/outbox-service.ts @@ -22,6 +22,7 @@ export type OutboxMessage = { accountId: string; type: "send" | "reply"; threadId?: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -81,6 +82,7 @@ class OutboxService extends EventEmitter { accountId: message.accountId, type: message.type, threadId: message.threadId, + from: message.from, to: message.to, cc: message.cc, bcc: message.bcc, @@ -346,6 +348,7 @@ class OutboxService extends EventEmitter { try { const result = await client.sendMessage({ + from: item.from, to: item.to, cc: item.cc, bcc: item.bcc, diff --git a/src/main/services/scheduled-send-service.ts b/src/main/services/scheduled-send-service.ts index dc10e456..4391a112 100644 --- a/src/main/services/scheduled-send-service.ts +++ b/src/main/services/scheduled-send-service.ts @@ -99,6 +99,7 @@ class ScheduledSendService extends EventEmitter { try { const result = await client.sendMessage({ + from: item.from, to: item.to, cc: item.cc, bcc: item.bcc, diff --git a/src/preload/index.ts b/src/preload/index.ts index 6cad769c..714655ca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,7 @@ const api = { // Send a new message send: (options: { accountId: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -90,18 +91,23 @@ const api = { recipientNames?: Record; }): Promise => ipcRenderer.invoke("compose:send", options), + getSendAsAliases: (accountId: string): Promise => + ipcRenderer.invoke("compose:get-send-as-aliases", { accountId }), + // Local drafts (stored in SQLite) saveLocalDraft: (draft: { accountId: string; gmailDraftId?: string; threadId?: string; inReplyTo?: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; subject: string; bodyHtml: string; bodyText?: string; + fromAddress?: string; isReply?: boolean; isForward?: boolean; }): Promise => ipcRenderer.invoke("compose:save-local-draft", draft), diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index a62881e3..512e0bc6 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -32,6 +32,7 @@ import { useComposeForm } from "../hooks/useComposeForm"; import { THREAD_NAV_EVENT } from "../hooks/useKeyboardShortcuts"; import type { ComposeFormState } from "../hooks/useComposeForm"; import { ComposeToolbar } from "./ComposeToolbar"; +import { FromSelector } from "./FromSelector"; import { trackEvent } from "../services/posthog"; import { draftBodyToHtml } from "../../shared/draft-utils"; import { AnalysisPrioritySection } from "./AnalysisPrioritySection"; @@ -1593,6 +1594,11 @@ function InlineReply({
{showAddressFields && ( <> +
+ {/* From selector (only shown when account has multiple send-as aliases) */} + + {/* To field with Cc/Bcc toggle */}
diff --git a/src/renderer/components/FromSelector.tsx b/src/renderer/components/FromSelector.tsx new file mode 100644 index 00000000..94533f11 --- /dev/null +++ b/src/renderer/components/FromSelector.tsx @@ -0,0 +1,35 @@ +import type { SendAsAlias } from "../../shared/types"; + +interface FromSelectorProps { + aliases: SendAsAlias[]; + selected: string | undefined; + onChange: (email: string) => void; +} + +/** + * Compact dropdown for selecting which send-as address to use. + * Only renders when the account has 2+ aliases. + */ +export function FromSelector({ aliases, selected, onChange }: FromSelectorProps) { + if (aliases.length < 2) return null; + + // Determine what's shown — default to the default alias + const current = selected || aliases.find((a) => a.isDefault)?.email || aliases[0].email; + + return ( +
+ From + +
+ ); +} diff --git a/src/renderer/hooks/useComposeForm.ts b/src/renderer/hooks/useComposeForm.ts index 9b75546e..163d8a7f 100644 --- a/src/renderer/hooks/useComposeForm.ts +++ b/src/renderer/hooks/useComposeForm.ts @@ -2,7 +2,13 @@ import { useState, useCallback, useEffect } from "react"; import { useAppStore } from "../store"; import { useSignature } from "./useSignature"; import type { ComposeAttachmentItem } from "../components/AttachmentList"; -import type { ReplyInfo, IpcResponse, ContactSuggestion, ComposeMode } from "../../shared/types"; +import type { + ReplyInfo, + IpcResponse, + ContactSuggestion, + ComposeMode, + SendAsAlias, +} from "../../shared/types"; /** Extract bare email from a potentially formatted "Name " address. */ function extractBareEmail(addr: string): string { @@ -25,6 +31,7 @@ function buildNameMapFromAddresses(addresses: string[]): Map { // Shared send options shape (subset of the IPC API) export interface ComposeSendOptions { accountId: string; + from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -124,6 +131,41 @@ export function useComposeForm({ } }, []); + // --- Send-as aliases --- + const [sendAsAliases, setSendAsAliases] = useState([]); + const [from, setFrom] = useState(undefined); + + // Fetch aliases on mount + useEffect(() => { + (window.api.compose.getSendAsAliases(accountId) as Promise>) + .then((result) => { + if (result.success && result.data.length > 0) { + setSendAsAliases(result.data); + + // Smart reply default: if replying, auto-select the alias that the original email was sent to + if (replyInfo) { + const allRecipients = [...(replyInfo.to || []), ...(replyInfo.cc || [])].map((addr) => + extractBareEmail(addr).toLowerCase(), + ); + const matchingAlias = result.data.find((a) => + allRecipients.includes(a.email.toLowerCase()), + ); + if (matchingAlias) { + setFrom(matchingAlias.email); + return; + } + } + + // Default to the default alias + const defaultAlias = result.data.find((a) => a.isDefault); + if (defaultAlias) setFrom(defaultAlias.email); + } + }) + .catch(() => { + // Silently fail — compose still works without aliases + }); + }, [accountId]); + // --- Send state --- const [isSending, setIsSending] = useState(false); const [isScheduling, setIsScheduling] = useState(false); @@ -250,6 +292,7 @@ export function useComposeForm({ return { accountId, + from, to, cc: cc.length > 0 ? cc : undefined, bcc: bcc.length > 0 ? bcc : undefined, @@ -265,6 +308,7 @@ export function useComposeForm({ }; }, [ accountId, + from, to, cc, bcc, @@ -408,6 +452,11 @@ export function useComposeForm({ handleRecipientDragStart, handleMentionAddToCc, + // Send-as aliases + sendAsAliases, + from, + setFrom, + // Content state subject, setSubject, diff --git a/src/shared/types.ts b/src/shared/types.ts index 18454c7b..713a2a79 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -498,6 +498,14 @@ export type ComposeAttachment = { size?: number; }; +// Gmail send-as alias (cached from Gmail settings) +export type SendAsAlias = { + email: string; + displayName?: string; + isDefault: boolean; + replyToAddress?: string; +}; + // Options for composing a message (used internally) export type ComposeMessageOptions = { from?: string; @@ -540,6 +548,7 @@ export const LocalDraftSchema = z.object({ subject: z.string(), bodyHtml: z.string(), bodyText: z.string().optional(), + fromAddress: z.string().optional(), isReply: z.boolean().default(false), isForward: z.boolean().default(false), createdAt: z.number(), From 0626d57cfa981043c0dbc1fe73711c0791334672 Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:58:51 -0300 Subject: [PATCH 3/8] fix: migration crash on fresh DB + guard getSendAsAliases - Migration v2 ALTER TABLE now checks if table exists first (fresh DBs don't have tables yet when numbered migrations run) - Added from_address column to base SCHEMA for fresh installs - Guard getSendAsAliases call in case preload API isn't available --- src/main/db/index.ts | 12 +++++++++--- src/main/db/schema.ts | 3 +++ src/renderer/hooks/useComposeForm.ts | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 1f572350..d3e66559 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -410,10 +410,16 @@ const NUMBERED_MIGRATIONS: Migration[] = [ FOREIGN KEY (account_id) REFERENCES accounts(id) ); CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id); - ALTER TABLE local_drafts ADD COLUMN from_address TEXT; - ALTER TABLE outbox ADD COLUMN from_address TEXT; - ALTER TABLE scheduled_messages ADD COLUMN from_address TEXT; `); + + // ALTER TABLE only for existing databases — fresh DBs get the column from SCHEMA + const tables = ["local_drafts", "outbox", "scheduled_messages"]; + for (const table of tables) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (cols.length > 0 && !cols.some((c) => c.name === "from_address")) { + db.exec(`ALTER TABLE ${table} ADD COLUMN from_address TEXT`); + } + } }, }, ]; diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 8a92eb97..d5c3a69c 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -144,6 +144,7 @@ CREATE TABLE IF NOT EXISTS local_drafts ( gmail_draft_id TEXT, thread_id TEXT, in_reply_to TEXT, + from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, @@ -187,6 +188,7 @@ CREATE TABLE IF NOT EXISTS scheduled_messages ( account_id TEXT NOT NULL, type TEXT NOT NULL, thread_id TEXT, + from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, @@ -210,6 +212,7 @@ CREATE TABLE IF NOT EXISTS outbox ( account_id TEXT NOT NULL, type TEXT NOT NULL, thread_id TEXT, + from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, diff --git a/src/renderer/hooks/useComposeForm.ts b/src/renderer/hooks/useComposeForm.ts index 163d8a7f..b67fbbb3 100644 --- a/src/renderer/hooks/useComposeForm.ts +++ b/src/renderer/hooks/useComposeForm.ts @@ -137,6 +137,8 @@ export function useComposeForm({ // Fetch aliases on mount useEffect(() => { + if (typeof window.api.compose.getSendAsAliases !== "function") return; + (window.api.compose.getSendAsAliases(accountId) as Promise>) .then((result) => { if (result.success && result.data.length > 0) { From 616a2a4f714dbdbf1591d31ce6d06f52eba27401 Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:05:28 -0300 Subject: [PATCH 4/8] style: fix prettier formatting --- src/main/services/gmail-client.ts | 4 +++- src/renderer/components/EmailDetail.tsx | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 8c9129cd..16c5f3fe 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -1542,7 +1542,9 @@ export class GmailClient { (a) => a.email.toLowerCase() === accountEmail.toLowerCase() && a.displayName, ); if (matching?.displayName) { - log.info(`[GmailClient] Display name from send-as alias match: "${matching.displayName}"`); + log.info( + `[GmailClient] Display name from send-as alias match: "${matching.displayName}"`, + ); return matching.displayName; } } diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index 512e0bc6..ec0f05e5 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -2045,11 +2045,7 @@ function NewEmailCompose({
{/* From selector (only shown when account has multiple send-as aliases) */} - + {/* To field with Cc/Bcc toggle */}
From 046732ad232ec6096410f26774363adbecdf6920 Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:12:09 -0300 Subject: [PATCH 5/8] fix: validate appearance config at IPC boundary - Use AppearanceConfigSchema.safeParse() instead of type assertion on onChange listener and get() response - Add .catch() on appearance.get() to handle rejections - Validate input in appearance:set handler before writing to store --- src/main/ipc/settings.ipc.ts | 9 ++++++++- src/renderer/hooks/useAppearance.ts | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index b3cb2706..4ad678b3 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -1,6 +1,7 @@ import { ipcMain, nativeTheme, BrowserWindow, shell, dialog } from "electron"; import Store from "electron-store"; import { + AppearanceConfigSchema, type AppearanceConfig, type Config, type EAConfig, @@ -677,8 +678,14 @@ export function registerSettingsIpc(): void { // Set appearance config — saves to store, broadcasts to renderer ipcMain.handle( "appearance:set", - async (_, appearance: AppearanceConfig): Promise> => { + async (_, rawAppearance: unknown): Promise> => { try { + const parsed = AppearanceConfigSchema.safeParse(rawAppearance); + if (!parsed.success) { + return { success: false, error: `Invalid appearance config: ${parsed.error.message}` }; + } + const appearance = parsed.data; + const currentConfig = getConfig(); getStore().set("config", { ...currentConfig, appearance }); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 0edb3a6f..5d285f8b 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useAppStore } from "../store"; import { THEME_PRESETS } from "../../shared/theme-presets"; -import type { AppearanceConfig } from "../../shared/types"; +import { AppearanceConfigSchema, type AppearanceConfig } from "../../shared/types"; import type { ThemeColors } from "../../shared/theme-presets"; // Convert "#2563eb" → "37 99 235" @@ -57,15 +57,22 @@ export function useAppearance(): void { // Fetch persisted config on mount useEffect(() => { - window.api.appearance.get().then((result: { success: boolean; data?: AppearanceConfig }) => { - if (result.success && result.data) { - setAppearance(result.data); - } - }); + window.api.appearance + .get() + .then((result: { success: boolean; data?: unknown }) => { + if (result.success && result.data) { + const parsed = AppearanceConfigSchema.safeParse(result.data); + if (parsed.success) setAppearance(parsed.data); + } + }) + .catch(() => { + // Appearance fetch failed — keep defaults + }); // Listen for changes broadcast from main process (e.g. from another window) window.api.appearance.onChange((data: Record) => { - setAppearance(data as AppearanceConfig); + const parsed = AppearanceConfigSchema.safeParse(data); + if (parsed.success) setAppearance(parsed.data); }); return () => { From 93923da2a6974c99fe362fb9260855157cf12457 Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:32:32 -0300 Subject: [PATCH 6/8] fix: validate hex input in hexToRgbTriplet --- src/renderer/hooks/useAppearance.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 5d285f8b..32136340 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -4,10 +4,11 @@ import { THEME_PRESETS } from "../../shared/theme-presets"; import { AppearanceConfigSchema, type AppearanceConfig } from "../../shared/types"; import type { ThemeColors } from "../../shared/theme-presets"; -// Convert "#2563eb" → "37 99 235" +// Convert "#2563eb" → "37 99 235", fallback to blue if invalid function hexToRgbTriplet(hex: string): string { - const h = hex.replace("#", ""); - const n = parseInt(h, 16); + const h = hex.replace(/^#/, ""); + const valid = /^[0-9a-fA-F]{6}$/.test(h); + const n = parseInt(valid ? h : "2563eb", 16); return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`; } From ed1e04c8c17aabb8d1e14b58d11b78061a203aac Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:17:10 -0300 Subject: [PATCH 7/8] remove unrelated send-as alias changes from appearance PR --- src/main/db/index.ts | 117 +------------------- src/main/db/schema.ts | 17 --- src/main/ipc/compose.ipc.ts | 43 ------- src/main/ipc/scheduled-send.ipc.ts | 1 - src/main/services/gmail-client.ts | 56 ++++------ src/main/services/outbox-service.ts | 3 - src/main/services/scheduled-send-service.ts | 1 - src/preload/index.ts | 7 -- src/renderer/components/EmailDetail.tsx | 9 -- src/renderer/components/FromSelector.tsx | 35 ------ src/renderer/hooks/useComposeForm.ts | 53 +-------- src/shared/types.ts | 9 -- 12 files changed, 26 insertions(+), 325 deletions(-) delete mode 100644 src/renderer/components/FromSelector.tsx diff --git a/src/main/db/index.ts b/src/main/db/index.ts index d3e66559..a47a2a14 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -393,35 +393,6 @@ const NUMBERED_MIGRATIONS: Migration[] = [ `); }, }, - { - version: 2, - name: "add_send_as_aliases_and_from_address", - up: (db) => { - db.exec(` - CREATE TABLE IF NOT EXISTS send_as_aliases ( - email TEXT NOT NULL, - account_id TEXT NOT NULL, - display_name TEXT, - is_default INTEGER DEFAULT 0, - reply_to_address TEXT, - verification_status TEXT, - fetched_at INTEGER NOT NULL, - PRIMARY KEY (email, account_id), - FOREIGN KEY (account_id) REFERENCES accounts(id) - ); - CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id); - `); - - // ALTER TABLE only for existing databases — fresh DBs get the column from SCHEMA - const tables = ["local_drafts", "outbox", "scheduled_messages"]; - for (const table of tables) { - const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; - if (cols.length > 0 && !cols.some((c) => c.name === "from_address")) { - db.exec(`ALTER TABLE ${table} ADD COLUMN from_address TEXT`); - } - } - }, - }, ]; function runNumberedMigrations(db: DatabaseInstance): void { @@ -2103,7 +2074,6 @@ export function removeAccount(accountId: string): void { db.prepare("DELETE FROM calendar_sync_state WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM memories WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM agent_audit_log WHERE account_id = ?").run(accountId); - db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM emails WHERE account_id = ?").run(accountId); db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId); }); @@ -2116,65 +2086,6 @@ export function setPrimaryAccount(accountId: string): void { db.prepare("UPDATE accounts SET is_primary = 1 WHERE id = ?").run(accountId); } -// ============================================ -// Send-as alias operations -// ============================================ - -import type { SendAsAlias } from "../../shared/types"; - -export function upsertSendAsAliases(accountId: string, aliases: SendAsAlias[]): void { - const db = getDatabase(); - const now = Date.now(); - - const run = db.transaction(() => { - // Clear stale aliases for this account, then insert fresh - db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId); - - const stmt = db.prepare(` - INSERT INTO send_as_aliases (email, account_id, display_name, is_default, reply_to_address, verification_status, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - for (const alias of aliases) { - stmt.run( - alias.email, - accountId, - alias.displayName || null, - alias.isDefault ? 1 : 0, - alias.replyToAddress || null, - "accepted", - now, - ); - } - }); - run(); -} - -export function getSendAsAliases(accountId: string): SendAsAlias[] { - const db = getDatabase(); - const rows = db - .prepare( - `SELECT email, display_name as displayName, is_default as isDefault, reply_to_address as replyToAddress - FROM send_as_aliases WHERE account_id = ? ORDER BY is_default DESC, email ASC`, - ) - .all(accountId) as Array>; - - return rows.map((row) => ({ - email: row.email as string, - displayName: (row.displayName as string | null) ?? undefined, - isDefault: Boolean(row.isDefault), - replyToAddress: (row.replyToAddress as string | null) ?? undefined, - })); -} - -export function getSendAsAliasFetchedAt(accountId: string): number | null { - const db = getDatabase(); - const row = db - .prepare("SELECT MAX(fetched_at) as fetchedAt FROM send_as_aliases WHERE account_id = ?") - .get(accountId) as { fetchedAt: number | null } | undefined; - return row?.fetchedAt ?? null; -} - // ============================================ // Sender profile operations // ============================================ @@ -2774,10 +2685,10 @@ export function saveLocalDraft(draft: LocalDraft): void { INSERT OR REPLACE INTO local_drafts ( id, account_id, gmail_draft_id, thread_id, in_reply_to, to_addresses, cc_addresses, bcc_addresses, subject, - body_html, body_text, from_address, is_reply, is_forward, + body_html, body_text, is_reply, is_forward, created_at, updated_at, synced_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( draft.id, @@ -2791,7 +2702,6 @@ export function saveLocalDraft(draft: LocalDraft): void { draft.subject, draft.bodyHtml, draft.bodyText || null, - draft.fromAddress || null, draft.isReply ? 1 : 0, draft.isForward ? 1 : 0, draft.createdAt, @@ -2808,7 +2718,6 @@ export function getLocalDraft(draftId: string): LocalDraft | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, - from_address as fromAddress, is_reply as isReply, is_forward as isForward, created_at as createdAt, updated_at as updatedAt, synced_at as syncedAt @@ -2828,7 +2737,6 @@ export function getLocalDrafts(accountId?: string): LocalDraft[] { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, - from_address as fromAddress, is_reply as isReply, is_forward as isForward, created_at as createdAt, updated_at as updatedAt, synced_at as syncedAt @@ -2873,7 +2781,6 @@ function rowToLocalDraft(row: Record): LocalDraft { subject: row.subject as string, bodyHtml: row.bodyHtml as string, bodyText: (row.bodyText as string | null) ?? undefined, - fromAddress: (row.fromAddress as string | null) ?? undefined, isReply: Boolean(row.isReply), isForward: Boolean(row.isForward), createdAt: row.createdAt as number, @@ -3249,7 +3156,6 @@ export type OutboxItem = { accountId: string; type: OutboxType; threadId?: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -3288,9 +3194,9 @@ export function insertOutboxMessage( INSERT INTO outbox ( id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses, subject, body_html, body_text, in_reply_to, references_header, - attachments, from_address, status, retry_count, created_at, updated_at + attachments, status, retry_count, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?) `); const now = Date.now(); stmt.run( @@ -3307,7 +3213,6 @@ export function insertOutboxMessage( item.inReplyTo || null, item.references || null, item.attachments ? JSON.stringify(item.attachments) : null, - item.from || null, item.createdAt, now, ); @@ -3344,7 +3249,6 @@ export function getPendingOutbox(accountId?: string, limit: number = 10): Outbox to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, - from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3367,7 +3271,6 @@ export function getOutboxItems(accountId?: string): OutboxItem[] { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, - from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3390,7 +3293,6 @@ export function getOutboxItem(id: string): OutboxItem | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, attachments, - from_address as fromAddress, status, error_message as errorMessage, retry_count as retryCount, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM outbox @@ -3694,7 +3596,6 @@ export type ScheduledMessageRow = { accountId: string; type: "send" | "reply"; threadId?: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -3719,9 +3620,9 @@ export function insertScheduledMessage( INSERT INTO scheduled_messages ( id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses, subject, body_html, body_text, in_reply_to, references_header, - from_address, scheduled_at, status, created_at, updated_at + scheduled_at, status, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) `); const now = Date.now(); stmt.run( @@ -3737,7 +3638,6 @@ export function insertScheduledMessage( item.bodyText || null, item.inReplyTo || null, item.references || null, - item.from || null, item.scheduledAt, item.createdAt, now, @@ -3752,7 +3652,6 @@ export function getDueScheduledMessages(limit: number = 10): ScheduledMessageRow to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, - from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3771,7 +3670,6 @@ export function getScheduledMessages(accountId?: string): ScheduledMessageRow[] to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, - from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3794,7 +3692,6 @@ export function getScheduledMessage(id: string): ScheduledMessageRow | null { to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses, subject, body_html as bodyHtml, body_text as bodyText, in_reply_to as inReplyTo, references_header as referencesHeader, - from_address as fromAddress, scheduled_at as scheduledAt, status, error_message as errorMessage, created_at as createdAt, updated_at as updatedAt, sent_at as sentAt FROM scheduled_messages @@ -3875,7 +3772,6 @@ function rowToScheduledMessage(row: Record): ScheduledMessageRo accountId: row.accountId as string, type: row.type as "send" | "reply", threadId: (row.threadId as string | null) ?? undefined, - from: (row.fromAddress as string | null) ?? undefined, to: JSON.parse(row.toAddresses as string) as string[], cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined, bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined, @@ -4149,7 +4045,6 @@ function rowToOutboxItem(row: Record): OutboxItem { accountId: row.accountId as string, type: row.type as OutboxType, threadId: (row.threadId as string | null) ?? undefined, - from: (row.fromAddress as string | null) ?? undefined, to: JSON.parse(row.toAddresses as string) as string[], cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined, bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined, diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index d5c3a69c..89c51cb7 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -8,19 +8,6 @@ CREATE TABLE IF NOT EXISTS accounts ( added_at INTEGER NOT NULL ); --- Gmail send-as aliases per account (cached from Gmail settings API) -CREATE TABLE IF NOT EXISTS send_as_aliases ( - email TEXT NOT NULL, - account_id TEXT NOT NULL, - display_name TEXT, - is_default INTEGER DEFAULT 0, - reply_to_address TEXT, - verification_status TEXT, - fetched_at INTEGER NOT NULL, - PRIMARY KEY (email, account_id), - FOREIGN KEY (account_id) REFERENCES accounts(id) -); - -- Sync state for each account (for incremental sync) CREATE TABLE IF NOT EXISTS sync_state ( account_id TEXT PRIMARY KEY, @@ -144,7 +131,6 @@ CREATE TABLE IF NOT EXISTS local_drafts ( gmail_draft_id TEXT, thread_id TEXT, in_reply_to TEXT, - from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, @@ -188,7 +174,6 @@ CREATE TABLE IF NOT EXISTS scheduled_messages ( account_id TEXT NOT NULL, type TEXT NOT NULL, thread_id TEXT, - from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, @@ -212,7 +197,6 @@ CREATE TABLE IF NOT EXISTS outbox ( account_id TEXT NOT NULL, type TEXT NOT NULL, thread_id TEXT, - from_address TEXT, to_addresses TEXT NOT NULL, cc_addresses TEXT, bcc_addresses TEXT, @@ -371,7 +355,6 @@ CREATE INDEX IF NOT EXISTS idx_memories_account ON memories(account_id); CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope, scope_value); CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id); CREATE INDEX IF NOT EXISTS idx_emails_in_reply_to ON emails(in_reply_to); -CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id); `; // FTS5 full-text search schema (separate because SQLite can't IF NOT EXISTS for virtual tables) diff --git a/src/main/ipc/compose.ipc.ts b/src/main/ipc/compose.ipc.ts index 4a27a864..bb5b0d4e 100644 --- a/src/main/ipc/compose.ipc.ts +++ b/src/main/ipc/compose.ipc.ts @@ -13,9 +13,6 @@ import { getArchiveReadyForThread, getEmailsByThread, updateEmailLabelIds, - getSendAsAliases, - getSendAsAliasFetchedAt, - upsertSendAsAliases, } from "../db"; import { networkMonitor } from "../services/network-monitor"; import { outboxService } from "../services/outbox-service"; @@ -30,7 +27,6 @@ import type { ReplyInfo, SendMessageOptions, SendMessageResult, - SendAsAlias, } from "../../shared/types"; import { formatAddressesWithNames, extractThreadNames } from "../utils/address-formatting"; import { createLogger } from "../services/logger"; @@ -58,7 +54,6 @@ function queueToOutbox(options: SendMessageOptions & { accountId: string }): Sen accountId: options.accountId, type: options.threadId ? "reply" : "send", threadId: options.threadId, - from: options.from, to: formattedTo, cc: formattedCc, bcc: formattedBcc, @@ -852,42 +847,4 @@ export function registerComposeIpc(): void { } }, ); - - // Fetch send-as aliases for an account (cached with 1h TTL) - const SEND_AS_CACHE_TTL_MS = 60 * 60 * 1000; - ipcMain.handle( - "compose:get-send-as-aliases", - async (_, { accountId }: { accountId: string }): Promise> => { - try { - // Check cache freshness - const fetchedAt = getSendAsAliasFetchedAt(accountId); - if (fetchedAt && Date.now() - fetchedAt < SEND_AS_CACHE_TTL_MS) { - return { success: true, data: getSendAsAliases(accountId) }; - } - - // Fetch fresh from Gmail API - const syncService = getEmailSyncService(); - const client = syncService.getClientForAccount(accountId); - if (!client) { - // Offline or no client — return cached if available - const cached = getSendAsAliases(accountId); - return { success: true, data: cached }; - } - - const aliases = await client.fetchSendAsAliases(); - upsertSendAsAliases(accountId, aliases); - return { success: true, data: aliases }; - } catch (error) { - // Fall back to cache on API error - const cached = getSendAsAliases(accountId); - if (cached.length > 0) { - return { success: true, data: cached }; - } - return { - success: false, - error: error instanceof Error ? error.message : "Failed to fetch send-as aliases", - }; - } - }, - ); } diff --git a/src/main/ipc/scheduled-send.ipc.ts b/src/main/ipc/scheduled-send.ipc.ts index 71ac335a..32434c5f 100644 --- a/src/main/ipc/scheduled-send.ipc.ts +++ b/src/main/ipc/scheduled-send.ipc.ts @@ -102,7 +102,6 @@ export function registerScheduledSendIpc(): void { accountId: options.accountId, type: options.threadId ? "reply" : "send", threadId: options.threadId, - from: options.from, to: formatAddressesWithNames(options.to, recipientNames), cc: options.cc ? formatAddressesWithNames(options.cc, recipientNames) : undefined, bcc: options.bcc ? formatAddressesWithNames(options.bcc, recipientNames) : undefined, diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 16c5f3fe..4ad11ee2 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -16,7 +16,6 @@ import type { SendMessageOptions, ComposeMessageOptions, AttachmentMeta, - SendAsAlias, } from "../../shared/types"; import { getAccounts } from "../db"; import { getDataDir } from "../data-dir"; @@ -1503,49 +1502,32 @@ export class GmailClient { * Falls back to the Google People API (own profile) if send-as has no name, * which is common for Google Workspace accounts. */ - /** - * Fetch all verified send-as aliases from Gmail settings. - * Only returns aliases with accepted verification status (or primary). - */ - async fetchSendAsAliases(): Promise { - const gmail = this.gmail!; - const response = await gmail.users.settings.sendAs.list({ userId: "me" }); - const rawAliases = response.data.sendAs || []; - - // Only include verified aliases (primary is always verified) - return rawAliases - .filter((s) => s.isPrimary || s.verificationStatus === "accepted") - .map((s) => ({ - email: s.sendAsEmail!, - displayName: s.displayName?.trim() || undefined, - isDefault: Boolean(s.isDefault), - replyToAddress: s.replyToAddress || undefined, - })); - } - async fetchDisplayName(): Promise { try { - // Reuse fetchSendAsAliases to avoid duplicate API call - const aliases = await this.fetchSendAsAliases(); - - // Prefer the default alias's display name - const defaultAlias = aliases.find((a) => a.isDefault); - if (defaultAlias?.displayName) { - log.info(`[GmailClient] Display name from send-as: "${defaultAlias.displayName}"`); - return defaultAlias.displayName; + const gmail = this.gmail!; + + // Try send-as settings first (the name shown on outgoing mail) + const sendAsResponse = await gmail.users.settings.sendAs.list({ userId: "me" }); + const primarySendAs = sendAsResponse.data.sendAs?.find((s) => s.isPrimary); + const sendAsName = primarySendAs?.displayName?.trim() || null; + if (sendAsName) { + log.info(`[GmailClient] Display name from send-as: "${sendAsName}"`); + return sendAsName; } - // Fallback: find alias matching this account's email + // Fallback: check all send-as aliases for one matching this account's email. + // Use getProfile() instead of getAccountInfo() since the account may not + // be in the DB yet during OAuth registration. const accountEmail = this.getAccountInfo()?.email || (await this.getProfile()).emailAddress; if (accountEmail) { - const matching = aliases.find( - (a) => a.email.toLowerCase() === accountEmail.toLowerCase() && a.displayName, + const matchingAlias = sendAsResponse.data.sendAs?.find( + (s) => + s.sendAsEmail?.toLowerCase() === accountEmail.toLowerCase() && s.displayName?.trim(), ); - if (matching?.displayName) { - log.info( - `[GmailClient] Display name from send-as alias match: "${matching.displayName}"`, - ); - return matching.displayName; + if (matchingAlias?.displayName) { + const name = matchingAlias.displayName.trim(); + log.info(`[GmailClient] Display name from send-as alias match: "${name}"`); + return name; } } diff --git a/src/main/services/outbox-service.ts b/src/main/services/outbox-service.ts index 3729e791..7fa310b0 100644 --- a/src/main/services/outbox-service.ts +++ b/src/main/services/outbox-service.ts @@ -22,7 +22,6 @@ export type OutboxMessage = { accountId: string; type: "send" | "reply"; threadId?: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -82,7 +81,6 @@ class OutboxService extends EventEmitter { accountId: message.accountId, type: message.type, threadId: message.threadId, - from: message.from, to: message.to, cc: message.cc, bcc: message.bcc, @@ -348,7 +346,6 @@ class OutboxService extends EventEmitter { try { const result = await client.sendMessage({ - from: item.from, to: item.to, cc: item.cc, bcc: item.bcc, diff --git a/src/main/services/scheduled-send-service.ts b/src/main/services/scheduled-send-service.ts index 4391a112..dc10e456 100644 --- a/src/main/services/scheduled-send-service.ts +++ b/src/main/services/scheduled-send-service.ts @@ -99,7 +99,6 @@ class ScheduledSendService extends EventEmitter { try { const result = await client.sendMessage({ - from: item.from, to: item.to, cc: item.cc, bcc: item.bcc, diff --git a/src/preload/index.ts b/src/preload/index.ts index 714655ca..5cbc71ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,7 +25,6 @@ const api = { saveCredentials: (clientId: string, clientSecret: string): Promise => ipcRenderer.invoke("gmail:save-credentials", { clientId, clientSecret }), startOAuth: (): Promise => ipcRenderer.invoke("gmail:start-oauth"), - abortOAuth: (): Promise => ipcRenderer.invoke("gmail:abort-oauth"), }, // Analysis operations @@ -71,7 +70,6 @@ const api = { // Send a new message send: (options: { accountId: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -91,23 +89,18 @@ const api = { recipientNames?: Record; }): Promise => ipcRenderer.invoke("compose:send", options), - getSendAsAliases: (accountId: string): Promise => - ipcRenderer.invoke("compose:get-send-as-aliases", { accountId }), - // Local drafts (stored in SQLite) saveLocalDraft: (draft: { accountId: string; gmailDraftId?: string; threadId?: string; inReplyTo?: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; subject: string; bodyHtml: string; bodyText?: string; - fromAddress?: string; isReply?: boolean; isForward?: boolean; }): Promise => ipcRenderer.invoke("compose:save-local-draft", draft), diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index ec0f05e5..a62881e3 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -32,7 +32,6 @@ import { useComposeForm } from "../hooks/useComposeForm"; import { THREAD_NAV_EVENT } from "../hooks/useKeyboardShortcuts"; import type { ComposeFormState } from "../hooks/useComposeForm"; import { ComposeToolbar } from "./ComposeToolbar"; -import { FromSelector } from "./FromSelector"; import { trackEvent } from "../services/posthog"; import { draftBodyToHtml } from "../../shared/draft-utils"; import { AnalysisPrioritySection } from "./AnalysisPrioritySection"; @@ -1594,11 +1593,6 @@ function InlineReply({
{showAddressFields && ( <> -
- {/* From selector (only shown when account has multiple send-as aliases) */} - - {/* To field with Cc/Bcc toggle */}
diff --git a/src/renderer/components/FromSelector.tsx b/src/renderer/components/FromSelector.tsx deleted file mode 100644 index 94533f11..00000000 --- a/src/renderer/components/FromSelector.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { SendAsAlias } from "../../shared/types"; - -interface FromSelectorProps { - aliases: SendAsAlias[]; - selected: string | undefined; - onChange: (email: string) => void; -} - -/** - * Compact dropdown for selecting which send-as address to use. - * Only renders when the account has 2+ aliases. - */ -export function FromSelector({ aliases, selected, onChange }: FromSelectorProps) { - if (aliases.length < 2) return null; - - // Determine what's shown — default to the default alias - const current = selected || aliases.find((a) => a.isDefault)?.email || aliases[0].email; - - return ( -
- From - -
- ); -} diff --git a/src/renderer/hooks/useComposeForm.ts b/src/renderer/hooks/useComposeForm.ts index b67fbbb3..9b75546e 100644 --- a/src/renderer/hooks/useComposeForm.ts +++ b/src/renderer/hooks/useComposeForm.ts @@ -2,13 +2,7 @@ import { useState, useCallback, useEffect } from "react"; import { useAppStore } from "../store"; import { useSignature } from "./useSignature"; import type { ComposeAttachmentItem } from "../components/AttachmentList"; -import type { - ReplyInfo, - IpcResponse, - ContactSuggestion, - ComposeMode, - SendAsAlias, -} from "../../shared/types"; +import type { ReplyInfo, IpcResponse, ContactSuggestion, ComposeMode } from "../../shared/types"; /** Extract bare email from a potentially formatted "Name " address. */ function extractBareEmail(addr: string): string { @@ -31,7 +25,6 @@ function buildNameMapFromAddresses(addresses: string[]): Map { // Shared send options shape (subset of the IPC API) export interface ComposeSendOptions { accountId: string; - from?: string; to: string[]; cc?: string[]; bcc?: string[]; @@ -131,43 +124,6 @@ export function useComposeForm({ } }, []); - // --- Send-as aliases --- - const [sendAsAliases, setSendAsAliases] = useState([]); - const [from, setFrom] = useState(undefined); - - // Fetch aliases on mount - useEffect(() => { - if (typeof window.api.compose.getSendAsAliases !== "function") return; - - (window.api.compose.getSendAsAliases(accountId) as Promise>) - .then((result) => { - if (result.success && result.data.length > 0) { - setSendAsAliases(result.data); - - // Smart reply default: if replying, auto-select the alias that the original email was sent to - if (replyInfo) { - const allRecipients = [...(replyInfo.to || []), ...(replyInfo.cc || [])].map((addr) => - extractBareEmail(addr).toLowerCase(), - ); - const matchingAlias = result.data.find((a) => - allRecipients.includes(a.email.toLowerCase()), - ); - if (matchingAlias) { - setFrom(matchingAlias.email); - return; - } - } - - // Default to the default alias - const defaultAlias = result.data.find((a) => a.isDefault); - if (defaultAlias) setFrom(defaultAlias.email); - } - }) - .catch(() => { - // Silently fail — compose still works without aliases - }); - }, [accountId]); - // --- Send state --- const [isSending, setIsSending] = useState(false); const [isScheduling, setIsScheduling] = useState(false); @@ -294,7 +250,6 @@ export function useComposeForm({ return { accountId, - from, to, cc: cc.length > 0 ? cc : undefined, bcc: bcc.length > 0 ? bcc : undefined, @@ -310,7 +265,6 @@ export function useComposeForm({ }; }, [ accountId, - from, to, cc, bcc, @@ -454,11 +408,6 @@ export function useComposeForm({ handleRecipientDragStart, handleMentionAddToCc, - // Send-as aliases - sendAsAliases, - from, - setFrom, - // Content state subject, setSubject, diff --git a/src/shared/types.ts b/src/shared/types.ts index 713a2a79..18454c7b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -498,14 +498,6 @@ export type ComposeAttachment = { size?: number; }; -// Gmail send-as alias (cached from Gmail settings) -export type SendAsAlias = { - email: string; - displayName?: string; - isDefault: boolean; - replyToAddress?: string; -}; - // Options for composing a message (used internally) export type ComposeMessageOptions = { from?: string; @@ -548,7 +540,6 @@ export const LocalDraftSchema = z.object({ subject: z.string(), bodyHtml: z.string(), bodyText: z.string().optional(), - fromAddress: z.string().optional(), isReply: z.boolean().default(false), isForward: z.boolean().default(false), createdAt: z.number(), From 4a2e0dcbe71a0dfb9da5a5724d024066564a1e0c Mon Sep 17 00:00:00 2001 From: Fabio Roma <94720877+itsfabioroma@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:28:18 -0300 Subject: [PATCH 8/8] fix: dark-mode bg-gray-100 override, color picker IPC flood, accentColor validation - bg-gray-100 in dark mode now maps to --bg-elevated (fixes 47 elements) - color picker uses onInput for live preview, onChange for IPC persist - accentColor schema validates hex format - use schema defaults instead of hardcoded fallback - merge duplicate scrollbar-thumb CSS rules --- src/main/ipc/settings.ipc.ts | 2 +- src/renderer/components/SettingsPanel.tsx | 12 +++++++++++- src/renderer/styles/index.css | 20 ++++++++++---------- src/shared/types.ts | 6 +++++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 4ad678b3..9dc07bf1 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -665,7 +665,7 @@ export function registerSettingsIpc(): void { const config = getConfig(); return { success: true, - data: config.appearance ?? { themePreset: "default", accentColor: null }, + data: config.appearance ?? AppearanceConfigSchema.parse({}), }; } catch (error) { return { diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 08230883..ec0dcb4c 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -357,13 +357,18 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { } }; - // Update a single appearance field — merges with current config, persists, and applies + // Update appearance — applies locally + persists via IPC const updateAppearance = async (patch: Partial) => { const updated = { ...appearance, ...patch }; setAppearance(updated); await window.api.appearance.set(updated); }; + // Live preview only (no IPC) — used during color picker drag + const previewAppearance = (patch: Partial) => { + setAppearance({ ...appearance, ...patch }); + }; + const handleDensityChange = async (density: InboxDensity) => { setInboxDensity(density); await window.api.settings.set({ inboxDensity: density }); @@ -981,6 +986,11 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { + previewAppearance({ + accentColor: (e.target as HTMLInputElement).value, + }) + } onChange={(e) => updateAppearance({ accentColor: e.target.value })} className="absolute inset-0 opacity-0 cursor-pointer" /> diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index f5160038..bedba9d9 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -46,10 +46,13 @@ Re-skin Tailwind's hardcoded grays so theme presets work without touching any component files. */ -/* bg-gray-100 (light base) */ +/* bg-gray-100 → base in light, elevated in dark (matches dark:bg-gray-700 intent) */ .bg-gray-100 { background-color: rgb(var(--bg-base)) !important; } +.dark .bg-gray-100 { + background-color: rgb(var(--bg-elevated)) !important; +} /* dark:bg-gray-900 (dark base) */ .dark .bg-gray-900 { @@ -175,15 +178,7 @@ background-color: rgb(var(--accent)) !important; } -/* Scrollbar themed colors */ -::-webkit-scrollbar-thumb { - background: rgb(var(--text-secondary) / 0.3) !important; -} -::-webkit-scrollbar-thumb:hover { - background: rgb(var(--text-secondary) / 0.5) !important; -} - -/* Scrollbar base (colors handled by theme overrides above) */ +/* Scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; @@ -195,6 +190,11 @@ ::-webkit-scrollbar-thumb { border-radius: 4px; + background: rgb(var(--text-secondary) / 0.3) !important; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--text-secondary) / 0.5) !important; } /* Titlebar drag region */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 18454c7b..120f8cb5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -364,7 +364,11 @@ export type ThemePresetId = z.infer; export const AppearanceConfigSchema = z.object({ themePreset: ThemePresetSchema.default("default"), - accentColor: z.string().nullable().default(null), + accentColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .nullable() + .default(null), }); export type AppearanceConfig = z.infer;