├── src ├── vite-env.d.ts ├── main.tsx ├── components │ ├── label-list.tsx │ ├── contact-item.tsx │ ├── thread-list.tsx │ ├── thread-item.tsx │ ├── email-message.tsx │ └── thread-view.tsx ├── utils │ ├── email.ts │ ├── openai.ts │ └── date.ts ├── styles │ └── globals.css └── App.tsx ├── postcss.config.cjs ├── drizzle.config.ts ├── .gitignore ├── electron ├── database │ ├── db.ts │ ├── schema.ts │ ├── thread.ts │ └── message.ts ├── gmail │ ├── types.ts │ ├── auth.ts │ ├── api.ts │ ├── decoder.ts │ └── client.ts ├── renderer.d.ts ├── preload.ts └── main.ts ├── animation-delay.plugin.cjs ├── index.html ├── prettier.config.cjs ├── unpreflight.plugin.cjs ├── tailwind.config.cjs ├── tsconfig.json ├── package.json └── vite.config.mjs /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import App from "./App"; 5 | 6 | import "./styles/globals.css"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./electron/database/schema.ts", 5 | out: "./electron/database/migrations", 6 | driver: "better-sqlite", 7 | dbCredentials: { 8 | url: "./db.sqlite", 9 | }, 10 | verbose: true, 11 | strict: true, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # DEPENDENCIES 2 | node_modules/ 3 | /.pnp 4 | .pnp.js 5 | yarn.lock 6 | 7 | # BUILD 8 | dist/ 9 | dist-electron/ 10 | 11 | # ENV FILES 12 | .env 13 | 14 | # TYPESCRIPT 15 | *.tsbuildinfo 16 | 17 | # MAC 18 | ._* 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # LOCAL FILES 23 | credentials.json 24 | token.json 25 | inbox.json 26 | db.sqlite 27 | -------------------------------------------------------------------------------- /electron/database/db.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import Database from "better-sqlite3"; 3 | import { drizzle } from "drizzle-orm/better-sqlite3"; 4 | 5 | import * as schema from "./schema"; 6 | 7 | const DB_PATH = path.join(process.cwd(), "db.sqlite"); 8 | const sqlite = new Database(DB_PATH); 9 | 10 | export const db = drizzle(sqlite, { schema }); 11 | -------------------------------------------------------------------------------- /animation-delay.plugin.cjs: -------------------------------------------------------------------------------- 1 | const plugin = require("tailwindcss/plugin"); 2 | 3 | module.exports = plugin(function ({ matchUtilities, theme }) { 4 | matchUtilities( 5 | { 6 | "animation-delay": (value) => { 7 | return { 8 | "animation-delay": value, 9 | }; 10 | }, 11 | }, 12 | { 13 | values: theme("transitionDelay"), 14 | }, 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Email Client 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/label-list.tsx: -------------------------------------------------------------------------------- 1 | import { gmail_v1 } from "googleapis"; 2 | 3 | type LabelListProps = { 4 | labels: gmail_v1.Schema$Label[] | null; 5 | onLabelClick: (label: gmail_v1.Schema$Label) => void; 6 | }; 7 | 8 | export function LabelList({ labels, onLabelClick }: LabelListProps) { 9 | return ( 10 |
11 | {labels?.map((label, i) => ( 12 |
onLabelClick(label)}> 13 | {label.name} 14 |
15 | ))} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /electron/gmail/types.ts: -------------------------------------------------------------------------------- 1 | export type EmailMessage = { 2 | historyId: string | null; 3 | id: string | null; 4 | internalDate: string | null; 5 | labelIds: string[] | null; 6 | decodedPayload: DecodedPayload; 7 | snippet: string | null; 8 | threadId: string | null; 9 | }; 10 | 11 | export type EmailThread = { 12 | historyId: string | null; 13 | id: string | null; 14 | messages: EmailMessage[]; 15 | }; 16 | 17 | export type DecodedPayload = { 18 | html: string | null; 19 | text: string | null; 20 | headers: Record; 21 | }; 22 | -------------------------------------------------------------------------------- /electron/renderer.d.ts: -------------------------------------------------------------------------------- 1 | export interface IGmailAPI { 2 | listInbox: () => Promise; 3 | getThread: (id: string) => Promise; 4 | modifyThread: ( 5 | id: string, 6 | options: import("googleapis").gmail_v1.Schema$ModifyThreadRequest, 7 | ) => Promise; 8 | sync: () => Promise; 9 | } 10 | 11 | export interface IBrowserAPI { 12 | openUrl: (url: string) => Promise; 13 | } 14 | 15 | declare global { 16 | interface Window { 17 | gmail: IGmailAPI; 18 | browser: IBrowserAPI; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/contact-item.tsx: -------------------------------------------------------------------------------- 1 | import { getNameAndEmail } from "@/utils/email"; 2 | 3 | type ContactItemProps = { 4 | label?: string; 5 | contact: string; 6 | }; 7 | 8 | export function ContactItem({ label, contact }: ContactItemProps) { 9 | const { name, email } = getNameAndEmail(contact); 10 | const nameText = name ?? email ?? contact; 11 | 12 | if (!label) { 13 | return ( 14 |

15 | {nameText} 16 |

17 | ); 18 | } 19 | return ( 20 |

21 | {label}{" "} 22 | 23 | {nameText} 24 | 25 |

26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig*/ 2 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/ 3 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindPluginConfig*/ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindPluginConfig } */ 6 | const config = { 7 | printWidth: 100, 8 | singleQuote: false, 9 | tabWidth: 2, 10 | semi: true, 11 | trailingComma: "all", 12 | plugins: ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 13 | importOrder: ["", "", "^@/(.*)$", "^[./]"], 14 | }; 15 | 16 | module.exports = config; 17 | -------------------------------------------------------------------------------- /unpreflight.plugin.cjs: -------------------------------------------------------------------------------- 1 | const plugin = require("tailwindcss/plugin"); 2 | const fs = require("fs"); 3 | const postcss = require("postcss"); 4 | 5 | module.exports = plugin(function ({ addBase, addUtilities }) { 6 | const preflightStyles = postcss.parse( 7 | fs.readFileSync(require.resolve("tailwindcss/lib/css/preflight.css"), "utf8"), 8 | ); 9 | 10 | preflightStyles.walkRules((rule) => { 11 | rule.selectors = rule.selectors.map((selector) => `${selector}:where(:not(.unpreflight *))`); 12 | rule.selector = rule.selectors.join(","); 13 | }); 14 | 15 | addBase(preflightStyles.nodes); 16 | addUtilities({ 17 | ".unpreflight": { 18 | all: "revert", 19 | }, 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/email.ts: -------------------------------------------------------------------------------- 1 | import linkifyHtml from "linkify-html"; 2 | 3 | export function convertTextToHtml(text: string) { 4 | const textWithBr = text.replaceAll("\n", "
"); 5 | const textWithLinks = linkifyHtml(textWithBr); 6 | return textWithLinks; 7 | } 8 | 9 | function removeQuotes(str: string | undefined) { 10 | const regex = /^"(.*)"$/; 11 | return str?.replace(regex, "$1"); 12 | } 13 | 14 | export function getNameAndEmail(emailStr: string) { 15 | const regex = /^(?:(?.*)\s)?\<(?.*)\>$/; 16 | const groups = emailStr.match(regex)?.groups; 17 | 18 | const name = removeQuotes(groups?.["name"]); 19 | const email = groups?.["email"]; 20 | 21 | return { name, email }; 22 | } 23 | -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import { gmail_v1 } from "googleapis"; 3 | 4 | import { IBrowserAPI, IGmailAPI } from "./renderer"; 5 | 6 | const gmailApi: IGmailAPI = { 7 | listInbox: () => ipcRenderer.invoke("gmail/list-inbox"), 8 | getThread: (id: string) => ipcRenderer.invoke("gmail/get-thread", id), 9 | modifyThread: (id: string, options: gmail_v1.Schema$ModifyThreadRequest) => 10 | ipcRenderer.invoke("gmail/modify-thread", id, options), 11 | sync: () => ipcRenderer.invoke("gmail/sync"), 12 | }; 13 | contextBridge.exposeInMainWorld("gmail", gmailApi); 14 | 15 | const browserApi: IBrowserAPI = { 16 | openUrl: (url: string) => ipcRenderer.invoke("browser/open-url", url), 17 | }; 18 | contextBridge.exposeInMainWorld("browser", browserApi); 19 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | bg: "rgb(var(--color-bg))", 8 | "bg-2": "rgb(var(--color-bg-2))", 9 | ui: "rgb(var(--color-ui))", 10 | "ui-2": "rgb(var(--color-ui-2))", 11 | "ui-3": "rgb(var(--color-ui-3))", 12 | tx: "rgb(var(--color-tx))", 13 | "tx-2": "rgb(var(--color-tx-2))", 14 | "tx-3": "rgb(var(--color-tx-3))", 15 | }, 16 | }, 17 | }, 18 | corePlugins: { 19 | preflight: false, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/typography"), 23 | require("./unpreflight.plugin.cjs"), 24 | require("./animation-delay.plugin.cjs"), 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | // "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "moduleDetection": "force", 11 | 12 | /* Strictness */ 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | 16 | /* If transpiling with TypeScript: */ 17 | "moduleResolution": "NodeNext", 18 | "module": "NodeNext", 19 | "outDir": "dist", 20 | "sourceMap": true, 21 | 22 | /* If your code runs in the DOM: */ 23 | "lib": ["es2022", "dom", "dom.iterable"], 24 | 25 | "jsx": "react-jsx", 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/electron/*": ["./electron/*"], 29 | "@/*": ["./src/*"] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/thread-list.tsx: -------------------------------------------------------------------------------- 1 | import { EmailThread } from "@/electron/gmail/types"; 2 | import { ThreadItem } from "./thread-item"; 3 | 4 | type ThreadListProps = { 5 | threads: EmailThread[] | null; 6 | selectedThreadId: string | null; 7 | onThreadClick: (threadId: string | null) => void; 8 | }; 9 | 10 | export function ThreadList({ 11 | threads, 12 | selectedThreadId, 13 | onThreadClick: consumerOnThreadClick, 14 | }: ThreadListProps) { 15 | if (threads === null) { 16 | return ( 17 |
18 |

Failed to fetch emails

19 |
20 | ); 21 | } 22 | 23 | return ( 24 |
25 | {threads.map((thread, i) => ( 26 | { 30 | consumerOnThreadClick(thread.id); 31 | }} 32 | isSelected={thread.id === selectedThreadId} 33 | /> 34 | ))} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | /* white */ 8 | --color-bg: 255 255 255; 9 | /* zinc-100 */ 10 | --color-bg-2: 244 244 245; 11 | 12 | /* zinc-200 */ 13 | --color-ui: 228 228 231; 14 | /* zinc-300 */ 15 | --color-ui-2: 212 212 216; 16 | /* zinc-400 */ 17 | --color-ui-3: 161 161 170; 18 | 19 | /* zinc-950 */ 20 | --color-tx: 9 9 11; 21 | /* zinc-500 */ 22 | --color-tx-2: 113 113 122; 23 | /* zinc-400 */ 24 | --color-tx-3: 161 161 170; 25 | } 26 | 27 | .dark { 28 | /* zinc-950 */ 29 | --color-bg: 9 9 11; 30 | /* zinc-900 */ 31 | --color-bg-2: 24 24 27; 32 | 33 | /* zinc-800 */ 34 | --color-ui: 39 39 42; 35 | /* zinc-700 */ 36 | --color-ui-2: 63 63 70; 37 | /* zinc-600 */ 38 | --color-ui-3: 82 82 91; 39 | 40 | /* zinc-200 */ 41 | --color-tx: 228 228 231; 42 | /* zinc-300 */ 43 | --color-tx-2: 212 212 216; 44 | /* zinc-400 */ 45 | --color-tx-3: 161 161 170; 46 | } 47 | } 48 | 49 | @layer base { 50 | body { 51 | @apply bg-bg text-tx; 52 | font-feature-settings: 53 | "rlig" 1, 54 | "calt" 1; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-client", 3 | "version": "1.0.0", 4 | "main": "dist-electron/main.js", 5 | "scripts": { 6 | "clean": "rm -rf node_modules dist", 7 | "dev": "vite", 8 | "rebuild": "electron-rebuild -f -w better-sqlite3", 9 | "db:push": "pnpm rebuild && drizzle-kit push:sqlite && pnpm run rebuild" 10 | }, 11 | "dependencies": { 12 | "@google-cloud/local-auth": "^3.0.0", 13 | "better-sqlite3": "^9.2.2", 14 | "dompurify": "^3.0.6", 15 | "drizzle-orm": "^0.29.1", 16 | "googleapis": "^129.0.0", 17 | "html-entities": "^2.4.0", 18 | "linkify-html": "^4.1.3", 19 | "openai": "^4.20.1", 20 | "react-markdown": "^9.0.1", 21 | "react-resizable-panels": "^0.0.63", 22 | "tailwind-merge": "^2.1.0" 23 | }, 24 | "devDependencies": { 25 | "@electron/rebuild": "^3.4.1", 26 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 27 | "@tailwindcss/typography": "^0.5.10", 28 | "@types/better-sqlite3": "^7.6.8", 29 | "@types/dompurify": "^3.0.5", 30 | "@types/node": "^20.10.3", 31 | "@types/react": "^18.2.42", 32 | "@types/react-dom": "^18.2.17", 33 | "@vitejs/plugin-react": "^4.2.1", 34 | "autoprefixer": "^10.4.16", 35 | "drizzle-kit": "^0.20.6", 36 | "electron": "^28.0.0", 37 | "postcss": "^8.4.32", 38 | "prettier": "^3.1.0", 39 | "prettier-plugin-tailwindcss": "^0.5.7", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "tailwindcss": "^3.3.6", 43 | "ts-node": "^10.9.1", 44 | "typescript": "^5.3.2", 45 | "vite": "^5.0.5", 46 | "vite-plugin-electron": "^0.15.4", 47 | "vite-plugin-electron-renderer": "^0.14.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /electron/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 | 4 | export const threads = sqliteTable("threads", { 5 | id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), 6 | serverId: text("serverId").unique(), 7 | historyId: text("historyId"), 8 | latestMessageDate: text("latestMessageDate"), 9 | }); 10 | 11 | export const threadsRelations = relations(threads, ({ many }) => ({ 12 | messages: many(messages), 13 | })); 14 | 15 | export const messages = sqliteTable("messages", { 16 | id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), 17 | threadId: integer("threadId") 18 | .notNull() 19 | .references(() => threads.id, { onDelete: "cascade" }), 20 | serverId: text("serverId").unique(), 21 | historyId: text("historyId"), 22 | internalDate: text("internalDate"), 23 | from: text("from"), 24 | to: text("to"), 25 | subject: text("subject"), 26 | snippet: text("snippet"), 27 | isUnread: integer("isUnread", { mode: "boolean" }), 28 | }); 29 | 30 | export const messagesRelations = relations(messages, ({ one }) => ({ 31 | thread: one(threads, { fields: [messages.threadId], references: [threads.id] }), 32 | messageContents: one(messageContents, { 33 | fields: [messages.id], 34 | references: [messageContents.messageId], 35 | }), 36 | })); 37 | 38 | export const messageContents = sqliteTable("messageContents", { 39 | id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), 40 | messageId: integer("messageId") 41 | .notNull() 42 | .references(() => messages.id, { 43 | onDelete: "cascade", 44 | }), 45 | bodyHtml: text("bodyHtml"), 46 | bodyText: text("bodyText"), 47 | }); 48 | -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import electron, { app, BrowserWindow, ipcMain, shell } from "electron"; 3 | 4 | import * as client from "./gmail/client"; 5 | 6 | let win: BrowserWindow | null = null; 7 | 8 | function createWindow() { 9 | win = new BrowserWindow({ 10 | width: 800, 11 | height: 600, 12 | webPreferences: { 13 | preload: path.join(__dirname, "preload.js"), 14 | }, 15 | }); 16 | 17 | if (process.env.VITE_DEV_SERVER_URL) { 18 | win.loadURL(process.env.VITE_DEV_SERVER_URL); 19 | win.webContents.openDevTools({ mode: "detach" }); 20 | } else { 21 | // TODO: fix this 22 | win.loadFile(path.join(__dirname, "../index.html")); 23 | } 24 | 25 | // open all external URL links in user browser 26 | win.webContents.on("will-navigate", (event) => { 27 | if (event.initiator?.url === event.url) { 28 | return; 29 | } 30 | event.preventDefault(); 31 | shell.openExternal(event.url); 32 | }); 33 | } 34 | 35 | app.whenReady().then(() => { 36 | ipcMain.handle("gmail/list-inbox", function ipcListInbox() { 37 | return client.listThreads(); 38 | }); 39 | ipcMain.handle("gmail/get-thread", function ipcGetThread(_, id) { 40 | return client.getThread(id); 41 | }); 42 | ipcMain.handle("gmail/modify-thread", function ipcModifyThread(_, id, options) { 43 | return client.modifyThread(id, options); 44 | }); 45 | ipcMain.handle("gmail/sync", function ipcSync() { 46 | return client.sync(); 47 | }); 48 | ipcMain.handle("browser/open-url", function ipcOpenUrl(_, url) { 49 | return electron.shell.openExternal(url); 50 | }); 51 | 52 | createWindow(); 53 | 54 | app.on("activate", () => { 55 | if (BrowserWindow.getAllWindows().length === 0) createWindow(); 56 | }); 57 | }); 58 | 59 | app.on("window-all-closed", () => { 60 | if (process.platform !== "darwin") app.quit(); 61 | }); 62 | -------------------------------------------------------------------------------- /src/utils/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | import { EmailMessage, EmailThread } from "@/electron/gmail/types"; 4 | 5 | const openai = new OpenAI({ 6 | apiKey: import.meta.env.VITE_OPENAI_API_KEY, 7 | dangerouslyAllowBrowser: true, 8 | }); 9 | 10 | export function summarizeMessage(message: EmailMessage) { 11 | return openai.chat.completions.create({ 12 | messages: [ 13 | { 14 | role: "system", 15 | content: 16 | "You are an assistant who helps users by summarizing their HTML emails. Ignore any extraneous information, for example, some text in headers and footers.", 17 | }, 18 | { 19 | role: "user", 20 | content: message.decodedPayload.text ?? message.decodedPayload.html ?? "", 21 | }, 22 | ], 23 | model: "gpt-3.5-turbo", 24 | }); 25 | } 26 | 27 | export function summarizeThread(thread: EmailThread) { 28 | const subject = thread.messages.at(0)?.decodedPayload.headers["Subject"]; 29 | const emailMessages = thread.messages.map(({ decodedPayload }) => { 30 | const from = decodedPayload.headers["From"]; 31 | const message = decodedPayload.text ?? decodedPayload.html ?? ""; 32 | return JSON.stringify({ 33 | from, 34 | message, 35 | }); 36 | }); 37 | const prompt = JSON.stringify({ subject, emailMessages }); 38 | 39 | return openai.chat.completions.create({ 40 | messages: [ 41 | { 42 | role: "system", 43 | content: 44 | "You are an assistant who helps users by summarizing their emails given to you in JSON form. You may reference the subject and sender of the email, but only if it adds meaning to the summarization. Ignore any extraneous information. For example, unimportant disclamers in email headers and footers are not necessary in a summary.", 45 | }, 46 | { 47 | role: "user", 48 | content: prompt, 49 | }, 50 | ], 51 | model: "gpt-3.5-turbo", 52 | stream: true, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /electron/gmail/auth.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "node:path"; 3 | import { authenticate } from "@google-cloud/local-auth"; 4 | import { Auth, gmail_v1, google } from "googleapis"; 5 | 6 | const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]; 7 | const TOKEN_PATH = path.join(process.cwd(), "token.json"); 8 | const CREDENTIALS_PATH = path.join(process.cwd(), "credentials.json"); 9 | 10 | let _gmailClient: gmail_v1.Gmail; 11 | 12 | async function loadSavedCredentialsIfExist() { 13 | try { 14 | const content = await fs.readFile(TOKEN_PATH, { encoding: "utf-8" }); 15 | const credentials = JSON.parse(content); 16 | const refreshClient = new Auth.UserRefreshClient(); 17 | refreshClient.fromJSON(credentials); 18 | return refreshClient; 19 | } catch (err) { 20 | return null; 21 | } 22 | } 23 | 24 | async function saveCredentials(client: Auth.OAuth2Client) { 25 | const content = await fs.readFile(CREDENTIALS_PATH, { encoding: "utf-8" }); 26 | const keys = JSON.parse(content); 27 | const key = keys.installed || keys.web; 28 | const payload = JSON.stringify({ 29 | type: "authorized_user", 30 | client_id: key.client_id, 31 | client_secret: key.client_secret, 32 | refresh_token: client.credentials.refresh_token, 33 | }); 34 | await fs.writeFile(TOKEN_PATH, payload); 35 | } 36 | 37 | async function authorize() { 38 | let client: Auth.OAuth2Client | null = await loadSavedCredentialsIfExist(); 39 | if (client) { 40 | return client; 41 | } 42 | client = await authenticate({ 43 | scopes: SCOPES, 44 | keyfilePath: CREDENTIALS_PATH, 45 | }); 46 | if (client.credentials) { 47 | await saveCredentials(client); 48 | } 49 | return client; 50 | } 51 | 52 | function createGmailClient(auth: Auth.OAuth2Client) { 53 | return google.gmail({ version: "v1", auth }); 54 | } 55 | 56 | export async function getGmailClient() { 57 | if (!_gmailClient) { 58 | _gmailClient = await authorize().then(createGmailClient); 59 | } 60 | return _gmailClient; 61 | } 62 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { rmSync } from "node:fs"; 2 | import path from "path"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig } from "vite"; 5 | import electron from "vite-plugin-electron"; 6 | import renderer from "vite-plugin-electron-renderer"; 7 | 8 | import pkg from "./package.json"; 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig(({ command }) => { 12 | rmSync("dist-electron", { recursive: true, force: true }); 13 | 14 | const isServe = command === "serve"; 15 | const isBuild = command === "build"; 16 | const sourcemap = isServe; 17 | 18 | return { 19 | plugins: [ 20 | react(), 21 | electron([ 22 | { 23 | // Main-Process entry file of the Electron App. 24 | entry: "electron/main.ts", 25 | onstart(options) { 26 | options.startup(); 27 | }, 28 | vite: { 29 | build: { 30 | sourcemap, 31 | minify: isBuild, 32 | outDir: "dist-electron/", 33 | rollupOptions: { 34 | external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}), 35 | }, 36 | }, 37 | }, 38 | }, 39 | { 40 | entry: "electron/preload.ts", 41 | onstart(options) { 42 | // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, 43 | // instead of restarting the entire Electron App. 44 | options.reload(); 45 | }, 46 | vite: { 47 | build: { 48 | sourcemap: sourcemap ? "inline" : undefined, 49 | minify: isBuild, 50 | outDir: "dist-electron/", 51 | rollupOptions: { 52 | external: Object.keys("dependencies" in pkg ? pkg.dependencies : {}), 53 | }, 54 | }, 55 | }, 56 | }, 57 | ]), 58 | 59 | // Use Node.js API in the Renderer-process 60 | renderer(), 61 | ], 62 | resolve: { 63 | alias: { 64 | "@/electron": path.resolve(__dirname, "./electron"), 65 | "@": path.resolve(__dirname, "./src"), 66 | }, 67 | }, 68 | }; 69 | }); 70 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function areDatesOnSameDay(first: Date, second: Date) { 2 | return ( 3 | first.getFullYear() === second.getFullYear() && 4 | first.getMonth() === second.getMonth() && 5 | first.getDate() === second.getDate() 6 | ); 7 | } 8 | 9 | export function isDateToday(date: Date | null | undefined) { 10 | if (!date) { 11 | return false; 12 | } 13 | return areDatesOnSameDay(date, new Date()); 14 | } 15 | 16 | export function isDateYesterday(date: Date | null | undefined) { 17 | if (!date) { 18 | return false; 19 | } 20 | return areDatesOnSameDay(date, new Date(Date.now() - 24 * 60 * 60 * 1000)); 21 | } 22 | 23 | type FormatDateOptions = 24 | | Intl.DateTimeFormatOptions 25 | | (Omit & { 26 | dateStyle: "relative"; 27 | relativeDateStyleFallback: Intl.DateTimeFormatOptions["dateStyle"]; 28 | }); 29 | 30 | function capitalizeFirstLetter(string: string) { 31 | return string.charAt(0).toUpperCase() + string.slice(1); 32 | } 33 | 34 | export function formatDate(date: Date | null | undefined, options?: FormatDateOptions) { 35 | if (!date) { 36 | return null; 37 | } 38 | 39 | switch (options?.dateStyle) { 40 | case "relative": { 41 | const { dateStyle: _, relativeDateStyleFallback, ...rest } = options; 42 | 43 | const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 44 | const formattedDate = date.toLocaleString(undefined, { ...rest, dateStyle: "medium" }); 45 | const formattedOnlyDate = date.toLocaleDateString(undefined, { dateStyle: "medium" }); 46 | 47 | if (isDateToday(date)) { 48 | const todayString = rtf.format(0, "day"); 49 | return formattedDate.replace(formattedOnlyDate, capitalizeFirstLetter(todayString)); 50 | } else if (isDateYesterday(date)) { 51 | const yesterdayString = rtf.format(-1, "day"); 52 | return formattedDate.replace(formattedOnlyDate, capitalizeFirstLetter(yesterdayString)); 53 | } else { 54 | return date.toLocaleString(undefined, { ...rest, dateStyle: relativeDateStyleFallback }); 55 | } 56 | } 57 | default: { 58 | return date.toLocaleString(undefined, { ...options, dateStyle: options?.dateStyle }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /electron/database/thread.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | 3 | import { EmailThread } from "../gmail/types"; 4 | import { db } from "./db"; 5 | import { insertMessage, updateMessageLabels } from "./message"; 6 | import { threads as threadsTable } from "./schema"; 7 | 8 | export type Thread = typeof threadsTable.$inferSelect; 9 | 10 | export function getAllThreads() { 11 | return db.query.threads.findMany({ 12 | with: { messages: true }, 13 | orderBy: sql`cast(${threadsTable.latestMessageDate} as integer) desc`, 14 | }); 15 | } 16 | 17 | export function getThreadByServerId(serverId: string) { 18 | return db.query.threads.findFirst({ 19 | where: eq(threadsTable.serverId, serverId), 20 | }); 21 | } 22 | 23 | export function getThreadWithFullMessages(serverId: string) { 24 | return db.query.threads.findFirst({ 25 | where: eq(threadsTable.serverId, serverId), 26 | with: { 27 | messages: { 28 | with: { messageContents: true }, 29 | }, 30 | }, 31 | }); 32 | } 33 | 34 | export function insertThread(thread: EmailThread) { 35 | return db.transaction(async (tx) => { 36 | const insertedThread = await db 37 | .insert(threadsTable) 38 | .values({ 39 | serverId: thread.id, 40 | historyId: thread.historyId, 41 | latestMessageDate: thread.messages.at(-1)?.internalDate, 42 | }) 43 | .returning({ id: threadsTable.id }); 44 | 45 | const threadId = insertedThread[0]?.id; 46 | 47 | if (!threadId) { 48 | tx.rollback(); 49 | // TODO: logging here? 50 | return; 51 | } 52 | 53 | for (const message of thread.messages) { 54 | await insertMessage(message, threadId); 55 | } 56 | }); 57 | } 58 | 59 | export function updateThread(thread: EmailThread) { 60 | return db.transaction(async (tx) => { 61 | if (!thread.id || !thread.historyId) { 62 | // TODO: logging here? 63 | return; 64 | } 65 | 66 | await db 67 | .update(threadsTable) 68 | .set({ 69 | historyId: thread.historyId, 70 | latestMessageDate: thread.messages.at(-1)?.internalDate, 71 | }) 72 | .where(eq(threadsTable.serverId, thread.id)); 73 | 74 | for (const message of thread.messages) { 75 | if (!message.id || !message.historyId) { 76 | // TODO: logging here? 77 | tx.rollback(); 78 | return; 79 | } 80 | 81 | await updateMessageLabels(message.id, message.historyId, message.labelIds ?? []); 82 | } 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/components/thread-item.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | import { EmailThread } from "@/electron/gmail/types"; 4 | import { formatDate, isDateToday } from "@/utils/date"; 5 | import { getNameAndEmail } from "@/utils/email"; 6 | 7 | type ThreadItemProps = { 8 | thread: EmailThread; 9 | isSelected: boolean; 10 | onThreadClick: () => void; 11 | }; 12 | 13 | export function ThreadItem({ thread, isSelected, onThreadClick }: ThreadItemProps) { 14 | const senders = thread.messages.map((message) => message.decodedPayload.headers["From"]); 15 | const numMessages = thread.messages.length; 16 | 17 | const firstMessage = thread.messages.at(0); 18 | const lastMessage = thread.messages.at(-1); 19 | const subject = firstMessage?.decodedPayload.headers["Subject"]; 20 | const snippet = lastMessage?.snippet; 21 | 22 | const isUnread = thread.messages.some((message) => message.labelIds?.includes("UNREAD")); 23 | 24 | const date = lastMessage?.internalDate ? new Date(parseInt(lastMessage.internalDate)) : null; 25 | const dateString = formatDate(date, { 26 | dateStyle: isDateToday(date) ? undefined : "relative", 27 | timeStyle: isDateToday(date) ? "short" : undefined, 28 | relativeDateStyleFallback: "short", 29 | }); 30 | 31 | const uniqueSenders = [...new Set(senders)]; 32 | const sendersText = uniqueSenders 33 | .map((from) => (from ? getNameAndEmail(from).name ?? from : null)) 34 | .join(", "); 35 | 36 | return ( 37 |
44 | {isSelected &&
} 45 |
46 | {isUnread && ( 47 |
53 | )} 54 |
55 |
56 |
57 |

{sendersText}

58 | {numMessages > 1 && ( 59 | 60 | {numMessages} 61 | 62 | )} 63 | 64 | {dateString} 65 | 66 |
67 |

{subject ?? "(No subject)"}

68 |

{snippet}

69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /electron/database/message.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | 3 | import { EmailMessage } from "../gmail/types"; 4 | import { db } from "./db"; 5 | import { messageContents as messageContentsTable, messages as messagesTable } from "./schema"; 6 | 7 | export type Message = typeof messagesTable.$inferSelect; 8 | 9 | export function getAllMessages() { 10 | return db.select().from(messagesTable).all(); 11 | } 12 | 13 | export function getMostRecentMessage() { 14 | return db.query.messages.findFirst({ 15 | orderBy: sql`cast(${messagesTable.historyId} as integer) desc`, 16 | }); 17 | } 18 | 19 | function getLabels(labelIds: string[]) { 20 | return { 21 | isUnread: labelIds.includes("UNREAD"), 22 | }; 23 | } 24 | 25 | export function insertMessage(message: EmailMessage, threadId: number) { 26 | return db.transaction(async (tx) => { 27 | const insertedMessage = await db 28 | .insert(messagesTable) 29 | .values({ 30 | threadId, 31 | historyId: message.historyId, 32 | serverId: message.id, 33 | internalDate: message.internalDate, 34 | from: message.decodedPayload.headers["From"], 35 | to: message.decodedPayload.headers["To"], 36 | subject: message.decodedPayload.headers["Subject"], 37 | snippet: message.snippet, 38 | ...getLabels(message.labelIds ?? []), 39 | }) 40 | .returning({ id: messagesTable.id }); 41 | 42 | const messageId = insertedMessage[0]?.id; 43 | if (!messageId) { 44 | tx.rollback(); 45 | return; 46 | } 47 | 48 | await db.insert(messageContentsTable).values({ 49 | messageId, 50 | bodyHtml: message.decodedPayload.html, 51 | bodyText: message.decodedPayload.text, 52 | }); 53 | }); 54 | } 55 | 56 | export function updateMessageLabels(serverId: string, historyId: string, labelIds: string[]) { 57 | return db 58 | .update(messagesTable) 59 | .set({ historyId, ...getLabels(labelIds) }) 60 | .where(eq(messagesTable.serverId, serverId)); 61 | } 62 | 63 | export function addMessageLabels(serverId: string, historyId: string, labelIds: string[]) { 64 | const labels = getLabels(labelIds); 65 | 66 | const filteredLabels: Partial = {}; 67 | for (const [key, value] of Object.entries(labels)) { 68 | if (value) { 69 | filteredLabels[key as keyof typeof labels] = true; 70 | } 71 | } 72 | 73 | return db 74 | .update(messagesTable) 75 | .set({ historyId, ...filteredLabels }) 76 | .where(eq(messagesTable.serverId, serverId)); 77 | } 78 | 79 | export function removeMessageLabels(serverId: string, historyId: string, labelIds: string[]) { 80 | const labels = getLabels(labelIds); 81 | 82 | const filteredLabels: Partial = {}; 83 | for (const [key, value] of Object.entries(labels)) { 84 | if (!value) { 85 | filteredLabels[key as keyof typeof labels] = false; 86 | } 87 | } 88 | 89 | return db 90 | .update(messagesTable) 91 | .set({ historyId, ...filteredLabels }) 92 | .where(eq(messagesTable.serverId, serverId)); 93 | } 94 | -------------------------------------------------------------------------------- /electron/gmail/api.ts: -------------------------------------------------------------------------------- 1 | import { gmail_v1 } from "googleapis"; 2 | 3 | import { getGmailClient } from "./auth"; 4 | import { decodeEmailMessage, decodeEmailThread } from "./decoder"; 5 | import { EmailThread } from "./types"; 6 | 7 | export async function getThread(threadId: string) { 8 | const gmail = await getGmailClient(); 9 | return await gmail.users.threads 10 | .get({ 11 | id: threadId, 12 | userId: "me", 13 | format: "full", 14 | }) 15 | .then((res) => decodeEmailThread(res.data)) 16 | .catch((err) => { 17 | console.error("Failed to get thread", err); 18 | return null; 19 | }); 20 | } 21 | 22 | export async function getMessage(messageId: string) { 23 | const gmail = await getGmailClient(); 24 | return await gmail.users.messages 25 | .get({ 26 | id: messageId, 27 | userId: "me", 28 | format: "full", 29 | }) 30 | .then((res) => decodeEmailMessage(res.data)) 31 | .catch((err) => { 32 | console.error("Failed to get message", err); 33 | return null; 34 | }); 35 | } 36 | 37 | export async function listInboxThreads(maxThreads: number = 100): Promise { 38 | const gmail = await getGmailClient(); 39 | let nextPageToken: string | null = null; 40 | const threads: EmailThread[] = []; 41 | 42 | while (threads.length < maxThreads) { 43 | const res = await gmail.users.threads 44 | .list({ 45 | labelIds: ["INBOX"], 46 | pageToken: nextPageToken ?? undefined, 47 | maxResults: 20, 48 | userId: "me", 49 | }) 50 | .catch((err) => { 51 | console.error("Failed to list threads", err); 52 | return null; 53 | }); 54 | 55 | const resThreads = res?.data.threads ?? []; 56 | const decodedThreads = await Promise.all( 57 | resThreads.map(async (thread) => { 58 | if (thread.id) { 59 | return await getThread(thread.id); 60 | } 61 | }), 62 | ).then((threads) => threads.filter((thread): thread is EmailThread => thread !== undefined)); 63 | threads.push(...decodedThreads); 64 | 65 | nextPageToken = (res?.data.nextPageToken ?? null) as string | null; 66 | 67 | if (nextPageToken === null) { 68 | break; 69 | } 70 | } 71 | 72 | return threads; 73 | } 74 | 75 | export async function getUpdates(startHistoryId: string) { 76 | const gmail = await getGmailClient(); 77 | 78 | // TODO: only returns one page of updates 79 | const res = await gmail.users.history 80 | .list({ 81 | startHistoryId, 82 | userId: "me", 83 | }) 84 | .catch((err) => { 85 | console.error("Failed to get updates", err); 86 | return null; 87 | }); 88 | 89 | const history = res?.data.history ?? null; 90 | 91 | return history; 92 | } 93 | 94 | export async function modifyThread(id: string, options: gmail_v1.Schema$ModifyThreadRequest) { 95 | const gmail = await getGmailClient(); 96 | 97 | return await gmail.users.threads 98 | .modify({ 99 | id, 100 | userId: "me", 101 | requestBody: options, 102 | }) 103 | .then(() => getThread(id)) 104 | .catch((err) => { 105 | console.error("Failed to modify thread", err); 106 | return null; 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /electron/gmail/decoder.ts: -------------------------------------------------------------------------------- 1 | import { gmail_v1 } from "googleapis"; 2 | import { decode } from "html-entities"; 3 | 4 | import { DecodedPayload, EmailMessage, EmailThread } from "./types"; 5 | 6 | function decodeBase64(base64: string) { 7 | const text = atob(base64); 8 | const bytes = new Uint8Array(text.length); 9 | for (let i = 0; i < text.length; ++i) { 10 | bytes[i] = text.charCodeAt(i); 11 | } 12 | const decoder = new TextDecoder(); 13 | return decoder.decode(bytes); 14 | } 15 | 16 | function decodeBody(body: string) { 17 | // transform from other base64 variants 18 | const transformedBody = body.replaceAll("-", "+").replaceAll("_", "/"); 19 | try { 20 | return decodeBase64(transformedBody); 21 | } catch (err) { 22 | console.error(err); 23 | return null; 24 | } 25 | } 26 | 27 | function decodeHtmlEntities(str: string) { 28 | return decode(str); 29 | } 30 | 31 | function flattenParts( 32 | parts: gmail_v1.Schema$MessagePart[] | undefined, 33 | flattened: gmail_v1.Schema$MessagePart[], 34 | ) { 35 | if (parts === undefined) { 36 | return; 37 | } 38 | 39 | for (let i = 0; i < parts.length; ++i) { 40 | const part = parts[i]; 41 | if (!part) { 42 | continue; 43 | } 44 | 45 | if (part.parts) { 46 | flattenParts(part.parts, flattened); 47 | } else { 48 | flattened.push(part); 49 | } 50 | } 51 | } 52 | 53 | export function decodePayload(payload: gmail_v1.Schema$MessagePart) { 54 | const decodedPayload: DecodedPayload = { html: null, text: null, headers: {} }; 55 | payload.headers?.forEach(({ name, value }) => { 56 | if (!name || !value) { 57 | return; 58 | } 59 | decodedPayload.headers[name] = value; 60 | }); 61 | 62 | const flattened: gmail_v1.Schema$MessagePart[] = []; 63 | flattenParts([payload], flattened); 64 | 65 | const html = flattened.find((part) => part.mimeType === "text/html"); 66 | const text = flattened.find((part) => part.mimeType === "text/plain"); 67 | if (typeof html?.body?.data === "string") { 68 | const decodedHtml = decodeBody(html.body.data); 69 | decodedPayload.html = decodedHtml; 70 | } 71 | if (typeof text?.body?.data === "string") { 72 | const decodedText = decodeBody(text.body.data); 73 | decodedPayload.text = decodedText; 74 | } 75 | 76 | return decodedPayload; 77 | } 78 | 79 | export function decodeEmailMessage(message: gmail_v1.Schema$Message) { 80 | const { historyId, id, internalDate, labelIds, payload, snippet, threadId } = message; 81 | const decodedMessage: EmailMessage = { 82 | historyId: historyId ?? null, 83 | id: id ?? null, 84 | internalDate: internalDate ?? null, 85 | labelIds: labelIds ?? null, 86 | decodedPayload: payload ? decodePayload(payload) : { html: null, text: null, headers: {} }, 87 | snippet: snippet ? decodeHtmlEntities(snippet) : null, 88 | threadId: threadId ?? null, 89 | }; 90 | return decodedMessage; 91 | } 92 | 93 | export function decodeEmailThread(thread: gmail_v1.Schema$Thread) { 94 | const { historyId, id, messages } = thread; 95 | const decodedThread: EmailThread = { 96 | historyId: historyId ?? null, 97 | id: id ?? null, 98 | messages: messages?.map(decodeEmailMessage) ?? [], 99 | }; 100 | return decodedThread; 101 | } 102 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 3 | 4 | import { EmailThread } from "@/electron/gmail/types"; 5 | import { ThreadList } from "./components/thread-list"; 6 | import { ThreadView } from "./components/thread-view"; 7 | 8 | function App() { 9 | const [threads, setThreads] = useState(null); 10 | const [thread, setThread] = useState(null); 11 | 12 | const loadInbox = useCallback(async (abortSignal?: AbortSignal) => { 13 | const inbox = await window.gmail.listInbox(); 14 | if (!abortSignal?.aborted && inbox !== null) { 15 | setThreads(inbox); 16 | } 17 | }, []); 18 | 19 | // TODO: no abortsignal here 20 | const sync = useCallback(async () => { 21 | const didSync = await window.gmail.sync(); 22 | if (didSync) { 23 | loadInbox(); 24 | } 25 | }, [loadInbox]); 26 | 27 | useEffect(() => { 28 | const abortController = new AbortController(); 29 | 30 | loadInbox(abortController.signal); 31 | sync(); 32 | const syncInterval = setInterval(sync, 10 * 1000); 33 | 34 | return () => { 35 | abortController.abort(); 36 | clearInterval(syncInterval); 37 | }; 38 | }, [loadInbox, sync]); 39 | 40 | async function onThreadClick(threadId: string | null) { 41 | if (!threadId || thread?.id === threadId) { 42 | return; 43 | } 44 | 45 | // TODO: cache these retrieved threads? 46 | const fullThread = await window.gmail.getThread(threadId); 47 | 48 | const isUnread = fullThread?.messages.some((message) => message.labelIds?.includes("UNREAD")); 49 | if (!fullThread?.id || !isUnread) { 50 | setThread(fullThread); 51 | return; 52 | } 53 | 54 | const optimisticThread = { 55 | ...fullThread, 56 | messages: fullThread.messages.map((message) => ({ 57 | ...message, 58 | labelIds: message.labelIds?.filter((label) => label !== "UNREAD") ?? null, 59 | })), 60 | }; 61 | 62 | // TODO: make these sync automatically??? 63 | setThread(optimisticThread); 64 | setThreads( 65 | (threads) => 66 | threads?.map((t) => (t.id === optimisticThread.id ? optimisticThread : t)) ?? null, 67 | ); 68 | 69 | const updatedThread = await window.gmail.modifyThread(fullThread.id, { 70 | removeLabelIds: ["UNREAD"], 71 | }); 72 | const updatedOrFallbackThread = updatedThread ?? fullThread; 73 | 74 | setThread(updatedOrFallbackThread); 75 | setThreads( 76 | (threads) => 77 | threads?.map((t) => (t.id === updatedOrFallbackThread.id ? updatedOrFallbackThread : t)) ?? 78 | null, 79 | ); 80 | } 81 | 82 | return ( 83 |
84 | 85 | 86 | 91 | 92 | 93 |
94 | 95 | 96 | 97 | 98 | 99 |
100 | ); 101 | } 102 | 103 | export default App; 104 | -------------------------------------------------------------------------------- /src/components/email-message.tsx: -------------------------------------------------------------------------------- 1 | import DOMPurify from "dompurify"; 2 | import { useState } from "react"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | import { EmailMessage as EmailMessageType } from "@/electron/gmail/types"; 6 | import { formatDate } from "@/utils/date"; 7 | import { convertTextToHtml } from "@/utils/email"; 8 | import { ContactItem } from "./contact-item"; 9 | 10 | type EmailMessageProps = { 11 | message: EmailMessageType; 12 | isCollapsible: boolean; 13 | }; 14 | 15 | export function EmailMessage({ message, isCollapsible }: EmailMessageProps) { 16 | const [isCollapsed, setIsCollapsed] = useState(isCollapsible); 17 | 18 | return ( 19 |
setIsCollapsed(false) : undefined} 22 | > 23 | setIsCollapsed(true) : undefined} 27 | /> 28 | 29 |
30 | ); 31 | } 32 | 33 | type EmailHeadersProps = { 34 | message: EmailMessageType; 35 | isCollapsed: boolean | null; 36 | onClick: (() => void) | undefined; 37 | }; 38 | 39 | function EmailHeaders({ message, isCollapsed, onClick }: EmailHeadersProps) { 40 | const headers = message.decodedPayload.headers; 41 | const from = headers["From"]; 42 | const to = headers["Delivered-To"] ?? headers["To"]; 43 | const cc = headers["Cc"]; 44 | const replyTo = headers["Reply-To"]; 45 | 46 | const date = message.internalDate ? new Date(parseInt(message.internalDate)) : null; 47 | const dateString = formatDate(date, { 48 | dateStyle: "relative", 49 | timeStyle: "short", 50 | relativeDateStyleFallback: "short", 51 | }); 52 | 53 | return ( 54 |
61 |
62 | {from && } 63 | {!isCollapsed && ( 64 | <> 65 | {to && } 66 | {cc && } 67 | {replyTo && } 68 | 69 | )} 70 |
71 |
{dateString}
72 |
73 | ); 74 | } 75 | 76 | type EmailPreviewProps = { 77 | message: EmailMessageType; 78 | isCollapsed: boolean; 79 | }; 80 | 81 | export function EmailPreview({ message, isCollapsed }: EmailPreviewProps) { 82 | const { html, text } = message.decodedPayload; 83 | const snippet = message.snippet; 84 | 85 | if (isCollapsed) { 86 | return

{snippet}

; 87 | } 88 | 89 | let htmlToRender: string | null = null; 90 | if (html !== null) { 91 | htmlToRender = html; 92 | } else if (text !== null) { 93 | htmlToRender = convertTextToHtml(text); 94 | } else { 95 | htmlToRender = "Email could not be decoded"; 96 | } 97 | 98 | return ( 99 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/thread-view.tsx: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { useState } from "react"; 3 | import Markdown from "react-markdown"; 4 | 5 | import { EmailThread } from "@/electron/gmail/types"; 6 | import { summarizeThread } from "@/utils/openai"; 7 | import { EmailMessage } from "./email-message"; 8 | 9 | type ThreadViewProps = { 10 | thread: EmailThread | null; 11 | }; 12 | 13 | export function ThreadView({ thread }: ThreadViewProps) { 14 | const [summary, setSummary] = useState(null); 15 | const [isLoading, setIsLoading] = useState(false); 16 | 17 | if (!thread) { 18 | return ( 19 |
20 |

No email selected

21 |
22 | ); 23 | } 24 | 25 | const firstMessage = thread.messages.at(0); 26 | if (!firstMessage) { 27 | return ( 28 |
29 |

Empty email thread

30 |
31 | ); 32 | } 33 | 34 | const subject = firstMessage.decodedPayload.headers["Subject"]; 35 | 36 | return ( 37 |
38 |

{subject ?? "(No subject)"}

39 | { 43 | setIsLoading(true); 44 | const stream = await summarizeThread(thread).catch((err) => { 45 | if (err instanceof OpenAI.APIError) { 46 | setSummary(`OpenAI error: ${err.message}`); 47 | } else { 48 | setSummary(`Unexpected error: ${err}`); 49 | } 50 | return null; 51 | }); 52 | setIsLoading(false); 53 | 54 | if (stream !== null) { 55 | for await (const chunk of stream) { 56 | const content = chunk.choices[0]?.delta?.content ?? ""; 57 | setSummary((prev) => (prev ?? "") + content); 58 | } 59 | } 60 | }} 61 | /> 62 |
63 | {thread.messages?.map((message, i) => { 64 | const isLast = i === thread.messages.length - 1; 65 | return ; 66 | })} 67 |
68 |
69 | ); 70 | } 71 | 72 | type GenerationBannerProps = { 73 | summary: string | null; 74 | isLoading: boolean; 75 | onGenerate: () => void; 76 | }; 77 | 78 | function GenerationBanner({ summary, isLoading, onGenerate }: GenerationBannerProps) { 79 | return ( 80 |
81 | {isLoading && summary === null && ( 82 |
83 |

Generating...

84 |
85 |
86 |
87 |
88 |
89 |
90 | )} 91 | {!isLoading && summary === null && ( 92 |
93 |

Get an AI-powered summary of this email thread?

94 | 101 |
102 | )} 103 | {summary !== null && {summary}} 104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /electron/gmail/client.ts: -------------------------------------------------------------------------------- 1 | import { gmail_v1 } from "googleapis"; 2 | 3 | import { getMostRecentMessage, insertMessage, updateMessageLabels } from "../database/message"; 4 | import { 5 | getAllThreads, 6 | getThreadByServerId, 7 | getThreadWithFullMessages, 8 | insertThread, 9 | updateThread, 10 | } from "../database/thread"; 11 | import * as api from "./api"; 12 | import { EmailThread } from "./types"; 13 | 14 | async function fullSync() { 15 | // TODO: make this a generator function 16 | const threads = await api.listInboxThreads(20); 17 | for (const thread of threads) { 18 | await insertThread(thread); 19 | } 20 | } 21 | 22 | async function partialSync(historyId: string) { 23 | const updates = await api.getUpdates(historyId); 24 | 25 | if (!updates) { 26 | return false; 27 | } 28 | 29 | for (const update of updates) { 30 | for (const messageAdded of update.messagesAdded ?? []) { 31 | const messageServerId = messageAdded.message?.id; 32 | const threadServerId = messageAdded.message?.threadId; 33 | if (!messageServerId || !threadServerId) { 34 | console.warn("New message has a null id or threadId"); 35 | continue; 36 | } 37 | 38 | const dbThread = await getThreadByServerId(threadServerId); 39 | if (!dbThread) { 40 | const gmailThread = await api.getThread(threadServerId); 41 | if (gmailThread) { 42 | await insertThread(gmailThread); 43 | } 44 | continue; 45 | } 46 | 47 | const message = await api.getMessage(messageServerId); 48 | if (!message) { 49 | console.error("Failed to get message", messageServerId); 50 | continue; 51 | } 52 | 53 | await insertMessage(message, dbThread.id); 54 | 55 | // TODO: hack to update thread historyId and latestMessageDate 56 | await updateThread({ 57 | id: threadServerId, 58 | historyId: message.historyId, 59 | messages: [message], 60 | }); 61 | } 62 | 63 | const labelUpdates = [...(update.labelsAdded ?? []), ...(update.labelsRemoved ?? [])]; 64 | for (const labelUpdate of labelUpdates) { 65 | const messageServerId = labelUpdate.message?.id; 66 | if (!messageServerId) { 67 | console.warn("New message has a null id"); 68 | continue; 69 | } 70 | 71 | const message = await api.getMessage(messageServerId); 72 | if (!message) { 73 | console.error("Failed to get message", messageServerId); 74 | continue; 75 | } 76 | 77 | if (!message.historyId) { 78 | console.warn("New message has a null historyId", messageServerId); 79 | continue; 80 | } 81 | 82 | await updateMessageLabels(messageServerId, message.historyId, message.labelIds ?? []); 83 | } 84 | } 85 | 86 | return true; 87 | } 88 | 89 | export async function sync(): Promise { 90 | // TODO: sync after returning original threads 91 | // TODO: use a different heuristic than the most recent message 92 | // this is fine for now because all our syncing operations are idempotent 93 | const mostRecentMessage = await getMostRecentMessage(); 94 | 95 | if (mostRecentMessage?.historyId) { 96 | return await partialSync(mostRecentMessage.historyId).catch((err) => { 97 | console.error("Partial sync failed", err); 98 | return false; 99 | }); 100 | } 101 | 102 | await fullSync().catch((err) => { 103 | console.error("Full sync failed", err); 104 | }); 105 | 106 | return true; 107 | } 108 | 109 | export async function listThreads(): Promise { 110 | const threads = await getAllThreads().catch((err) => { 111 | console.error("Failed to get all threads", err); 112 | return null; 113 | }); 114 | 115 | if (!threads) { 116 | return null; 117 | } 118 | 119 | const savedThreads: EmailThread[] = threads.map((thread) => ({ 120 | id: thread.serverId, 121 | historyId: thread.historyId, 122 | messages: thread.messages.map((message) => ({ 123 | id: message.serverId, 124 | historyId: message.historyId, 125 | internalDate: message.internalDate, 126 | labelIds: [message.isUnread && "UNREAD"].filter( 127 | (label): label is string => typeof label === "string", 128 | ), 129 | decodedPayload: { 130 | html: null, 131 | text: null, 132 | headers: { 133 | From: message.from, 134 | To: message.to, 135 | Subject: message.subject, 136 | }, 137 | }, 138 | snippet: message.snippet, 139 | threadId: thread.serverId, 140 | })), 141 | })); 142 | 143 | return savedThreads; 144 | } 145 | 146 | export async function getThread(threadId: string): Promise { 147 | const thread = await getThreadWithFullMessages(threadId).catch((err) => { 148 | console.error("Failed to get thread", err); 149 | return null; 150 | }); 151 | 152 | if (!thread) { 153 | return null; 154 | } 155 | 156 | // TODO: dedupe this code 157 | return { 158 | id: thread.serverId, 159 | historyId: thread.historyId, 160 | messages: thread.messages.map((message) => ({ 161 | id: message.serverId, 162 | historyId: message.historyId, 163 | internalDate: message.internalDate, 164 | labelIds: [message.isUnread && "UNREAD"].filter( 165 | (label): label is string => typeof label === "string", 166 | ), 167 | decodedPayload: { 168 | html: message.messageContents.bodyHtml, 169 | text: message.messageContents.bodyText, 170 | headers: { 171 | From: message.from, 172 | To: message.to, 173 | Subject: message.subject, 174 | }, 175 | }, 176 | snippet: message.snippet, 177 | threadId: thread.serverId, 178 | })), 179 | }; 180 | } 181 | 182 | export async function modifyThread(threadId: string, options: gmail_v1.Schema$ModifyThreadRequest) { 183 | const thread = await api.modifyThread(threadId, options).catch((err) => { 184 | console.error("Failed to modify thread", err); 185 | return null; 186 | }); 187 | 188 | if (!thread) { 189 | return null; 190 | } 191 | 192 | const isUpdated = await updateThread(thread) 193 | .then(() => true) 194 | .catch((err) => { 195 | console.error("Failed to update thread", err); 196 | return false; 197 | }); 198 | 199 | if (!isUpdated) { 200 | return null; 201 | } 202 | 203 | return thread; 204 | } 205 | --------------------------------------------------------------------------------