mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
Gmail send, archive and delete (#573)
* added send, archive and delete
* fix scopes
* added replyall, cc, bcc etc
* - Added scope-aware Gmail status via gmail:getConnectionStatus, so the email empty state can
distinguish “not connected” from “connected but missing new Gmail scope.”
- Hardened Gmail send header construction against CR/LF header injection.
- Switched MIME parts from invalid UTF-8 7bit bodies to base64-encoded UTF-8 parts.
- Made forward send as a new message instead of attaching it to the original thread, and included
forwarded message content.
- Changed archive/delete UI behavior to remove the thread only after Gmail confirms success.
---------
Co-authored-by: Ramnique Singh <30795890+ramnique@users.noreply.github.com>
This commit is contained in:
parent
a59c42e22b
commit
84aa980894
8 changed files with 927 additions and 79 deletions
|
|
@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||||
import { API_URL } from '@x/core/dist/config/env.js';
|
import { API_URL } from '@x/core/dist/config/env.js';
|
||||||
|
|
@ -493,6 +493,24 @@ export function setupIpcHandlers() {
|
||||||
triggerGmailSync();
|
triggerGmailSync();
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
'gmail:sendReply': async (_event, args) => {
|
||||||
|
return sendThreadReply(args);
|
||||||
|
},
|
||||||
|
'gmail:getConnectionStatus': async () => {
|
||||||
|
return getGmailConnectionStatus();
|
||||||
|
},
|
||||||
|
'gmail:getAccountEmail': async () => {
|
||||||
|
return { email: await getAccountEmail() };
|
||||||
|
},
|
||||||
|
'gmail:archiveThread': async (_event, args) => {
|
||||||
|
return archiveThread(args.threadId);
|
||||||
|
},
|
||||||
|
'gmail:trashThread': async (_event, args) => {
|
||||||
|
return trashThread(args.threadId);
|
||||||
|
},
|
||||||
|
'gmail:markThreadRead': async (_event, args) => {
|
||||||
|
return markThreadRead(args.threadId);
|
||||||
|
},
|
||||||
'gmail:saveMessageHeight': async (_event, args) => {
|
'gmail:saveMessageHeight': async (_event, args) => {
|
||||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||||
return {};
|
return {};
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,10 @@
|
||||||
color: var(--gm-text-faint);
|
color: var(--gm-text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gmail-row-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.gmail-row {
|
.gmail-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
|
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
|
||||||
|
|
@ -249,6 +253,51 @@
|
||||||
transition: background 120ms ease;
|
transition: background 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gmail-row-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-row-shell:hover .gmail-row-actions {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-row-shell:hover .gmail-row-date {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-row-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-row-action:hover {
|
||||||
|
background: var(--gm-bg-pill-hover);
|
||||||
|
color: var(--gm-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-row-action-danger:hover {
|
||||||
|
color: #e8453c;
|
||||||
|
}
|
||||||
|
|
||||||
.gmail-row:hover {
|
.gmail-row:hover {
|
||||||
background: var(--gm-bg-row-hover);
|
background: var(--gm-bg-row-hover);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
@ -694,6 +743,126 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gmail-compose-label {
|
||||||
|
flex: none;
|
||||||
|
min-width: 28px;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-compose-subject-input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gm-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipient (To / Cc / Bcc) rows with editable chips */
|
||||||
|
.gmail-recipient-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-bottom: 1px solid var(--gm-border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-label {
|
||||||
|
flex: none;
|
||||||
|
min-width: 28px;
|
||||||
|
padding-top: 5px;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-field {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 4px 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--gm-bg-pill);
|
||||||
|
color: var(--gm-text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-chip-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-chip-remove:hover {
|
||||||
|
background: var(--gm-bg-pill-hover);
|
||||||
|
color: var(--gm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-input {
|
||||||
|
flex: 1 1 80px;
|
||||||
|
min-width: 80px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gm-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-trailing {
|
||||||
|
flex: none;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-toggles {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-toggles button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-recipient-toggles button:hover {
|
||||||
|
color: var(--gm-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.gmail-compose-toolbar {
|
.gmail-compose-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
|
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
|
||||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
@ -12,6 +12,12 @@ import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
|
|
||||||
type GmailThread = blocks.GmailThread
|
type GmailThread = blocks.GmailThread
|
||||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||||
|
type GmailConnectionStatus = {
|
||||||
|
connected: boolean
|
||||||
|
hasRequiredScope: boolean
|
||||||
|
missingScopes: string[]
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function formatInboxTime(value?: string): string {
|
function formatInboxTime(value?: string): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
|
|
@ -80,6 +86,112 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
|
||||||
return thread.messages[thread.messages.length - 1]
|
return thread.messages[thread.messages.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split a raw header recipient string (e.g. `"Jo Bloggs" <jo@x.com>, b@y.com`) into
|
||||||
|
// individual address tokens, respecting commas inside quotes/angle brackets.
|
||||||
|
function splitAddresses(raw?: string): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
const tokens: string[] = []
|
||||||
|
let buf = ''
|
||||||
|
let inQuote = false
|
||||||
|
let depth = 0
|
||||||
|
for (const ch of raw) {
|
||||||
|
if (ch === '"') inQuote = !inQuote
|
||||||
|
else if (ch === '<') depth += 1
|
||||||
|
else if (ch === '>') depth = Math.max(0, depth - 1)
|
||||||
|
if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) {
|
||||||
|
const token = buf.trim()
|
||||||
|
if (token) tokens.push(token)
|
||||||
|
buf = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf += ch
|
||||||
|
}
|
||||||
|
const last = buf.trim()
|
||||||
|
if (last) tokens.push(last)
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display label for a recipient chip: the display name if present, else the bare address.
|
||||||
|
function recipientLabel(token: string): string {
|
||||||
|
const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/)
|
||||||
|
if (named?.[1]?.trim()) return named[1].trim()
|
||||||
|
return extractAddress(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`.
|
||||||
|
function dedupeRecipients(tokens: string[], exclude: Set<string>): string[] {
|
||||||
|
const seen = new Set<string>(exclude)
|
||||||
|
const out: string[] = []
|
||||||
|
for (const token of tokens) {
|
||||||
|
const addr = extractAddress(token).toLowerCase()
|
||||||
|
if (!addr || seen.has(addr)) continue
|
||||||
|
seen.add(addr)
|
||||||
|
out.push(token)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me".
|
||||||
|
function buildRecipients(
|
||||||
|
mode: ComposeMode,
|
||||||
|
thread: GmailThread,
|
||||||
|
selfEmail: string,
|
||||||
|
): { to: string[]; cc: string[] } {
|
||||||
|
if (mode === 'forward') return { to: [], cc: [] }
|
||||||
|
|
||||||
|
const latest = latestMessage(thread)
|
||||||
|
const self = selfEmail.toLowerCase()
|
||||||
|
const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : ''
|
||||||
|
const iAmSender = Boolean(self) && fromAddr === self
|
||||||
|
|
||||||
|
// If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender.
|
||||||
|
const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : [])
|
||||||
|
const ccPool = iAmSender
|
||||||
|
? splitAddresses(latest?.cc)
|
||||||
|
: [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)]
|
||||||
|
|
||||||
|
const selfSet = new Set<string>(self ? [self] : [])
|
||||||
|
const to = dedupeRecipients(rawTo, selfSet)
|
||||||
|
if (iAmSender && to.length === 0 && self && rawTo.some((token) => extractAddress(token).toLowerCase() === self)) {
|
||||||
|
to.push(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reply') return { to, cc: [] }
|
||||||
|
|
||||||
|
const ccExclude = new Set<string>(selfSet)
|
||||||
|
for (const token of to) ccExclude.add(extractAddress(token).toLowerCase())
|
||||||
|
const cc = dedupeRecipients(ccPool, ccExclude)
|
||||||
|
return { to, cc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes.
|
||||||
|
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
||||||
|
const raw = (rawSubject || '').trim()
|
||||||
|
if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim()
|
||||||
|
return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForwardedContent(thread: GmailThread): string {
|
||||||
|
const message = latestMessage(thread)
|
||||||
|
if (!message) return ''
|
||||||
|
const rows = [
|
||||||
|
'---------- Forwarded message ---------',
|
||||||
|
message.from ? `From: ${message.from}` : null,
|
||||||
|
message.date ? `Date: ${formatFullDate(message.date)}` : null,
|
||||||
|
message.subject || thread.subject ? `Subject: ${message.subject || thread.subject}` : null,
|
||||||
|
message.to ? `To: ${message.to}` : null,
|
||||||
|
message.cc ? `Cc: ${message.cc}` : null,
|
||||||
|
].filter((line): line is string => Boolean(line))
|
||||||
|
const body = (message.body || snippet(message.bodyHtml)).trim()
|
||||||
|
return [
|
||||||
|
'<p></p>',
|
||||||
|
'<blockquote>',
|
||||||
|
...rows.map((line) => `<p>${escapeHtml(line)}</p>`),
|
||||||
|
body ? `<p>${escapeHtml(body).replace(/\n/g, '<br />')}</p>` : '',
|
||||||
|
'</blockquote>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
const PREFETCH_HOVER_MS = 180
|
const PREFETCH_HOVER_MS = 180
|
||||||
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
|
||||||
|
|
||||||
|
|
@ -374,7 +486,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComposeMode = 'reply' | 'forward'
|
type ComposeMode = 'reply' | 'replyAll' | 'forward'
|
||||||
|
|
||||||
function ComposeToolbarButton({
|
function ComposeToolbarButton({
|
||||||
editor,
|
editor,
|
||||||
|
|
@ -475,20 +587,110 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecipientField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
autoFocus,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string[]
|
||||||
|
onChange: (next: string[]) => void
|
||||||
|
autoFocus?: boolean
|
||||||
|
trailing?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) inputRef.current?.focus()
|
||||||
|
}, [autoFocus])
|
||||||
|
|
||||||
|
const commit = (raw: string) => {
|
||||||
|
const additions = splitAddresses(raw)
|
||||||
|
if (additions.length === 0) return
|
||||||
|
onChange(dedupeRecipients([...value, ...additions], new Set()))
|
||||||
|
setDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
|
||||||
|
if (draft.trim()) {
|
||||||
|
event.preventDefault()
|
||||||
|
commit(draft)
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||||
|
onChange(value.slice(0, -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gmail-recipient-row">
|
||||||
|
<span className="gmail-recipient-label">{label}</span>
|
||||||
|
<div className="gmail-recipient-field">
|
||||||
|
{value.map((token, index) => (
|
||||||
|
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||||
|
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gmail-recipient-chip-remove"
|
||||||
|
aria-label={`Remove ${extractAddress(token)}`}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => onChange(value.filter((_, idx) => idx !== index))}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="gmail-recipient-input"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={() => { if (draft.trim()) commit(draft) }}
|
||||||
|
onPaste={(event) => {
|
||||||
|
const text = event.clipboardData.getData('text')
|
||||||
|
if (text && /[,;\n]/.test(text)) {
|
||||||
|
event.preventDefault()
|
||||||
|
commit(text)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ComposeBox({
|
function ComposeBox({
|
||||||
mode,
|
mode,
|
||||||
thread,
|
thread,
|
||||||
|
selfEmail,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
mode: ComposeMode
|
mode: ComposeMode
|
||||||
thread: GmailThread
|
thread: GmailThread
|
||||||
|
selfEmail: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const latest = latestMessage(thread)
|
const latest = latestMessage(thread)
|
||||||
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
|
const initialRecipients = useMemo(
|
||||||
|
() => buildRecipients(mode, thread, selfEmail),
|
||||||
|
[mode, thread, selfEmail],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [toList, setToList] = useState<string[]>(initialRecipients.to)
|
||||||
|
const [ccList, setCcList] = useState<string[]>(initialRecipients.cc)
|
||||||
|
const [bccList, setBccList] = useState<string[]>([])
|
||||||
|
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
|
||||||
|
const [showBcc, setShowBcc] = useState<boolean>(false)
|
||||||
|
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
|
||||||
|
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||||
|
|
||||||
const initialContent = useMemo(() => {
|
const initialContent = useMemo(() => {
|
||||||
if (mode !== 'reply') return ''
|
if (mode === 'forward') return buildForwardedContent(thread)
|
||||||
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||||
const source = thread.gmail_draft || thread.draft_response
|
const source = thread.gmail_draft || thread.draft_response
|
||||||
if (!source) return ''
|
if (!source) return ''
|
||||||
|
|
@ -496,14 +698,14 @@ function ComposeBox({
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||||
.join('')
|
.join('')
|
||||||
}, [mode, thread.gmail_draft, thread.draft_response])
|
}, [mode, thread])
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ link: false }),
|
StarterKit.configure({ link: false }),
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',
|
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
|
|
@ -555,52 +757,65 @@ function ComposeBox({
|
||||||
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
|
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
const sendInGmail = async () => {
|
const sendInGmail = async () => {
|
||||||
if (!editor) {
|
if (!editor || sending) return
|
||||||
window.open(thread.threadUrl, '_blank')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const html = editor.getHTML()
|
const html = editor.getHTML()
|
||||||
const text = editor.getText().trim()
|
const text = editor.getText().trim()
|
||||||
|
if (!text) {
|
||||||
|
toast('Draft is empty.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let copied = false
|
if (toList.length === 0) {
|
||||||
if (text) {
|
toast('Add at least one recipient.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build References chain from all known message ids (newest last).
|
||||||
|
const messageIds = thread.messages
|
||||||
|
.map((m) => m.messageIdHeader)
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
const references = messageIds.join(' ')
|
||||||
|
const inReplyTo = latest?.messageIdHeader
|
||||||
|
const isForward = mode === 'forward'
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||||
await navigator.clipboard.write([
|
threadId: isForward ? undefined : thread.threadId,
|
||||||
new ClipboardItem({
|
to: toList.join(', '),
|
||||||
'text/html': new Blob([html], { type: 'text/html' }),
|
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||||
'text/plain': new Blob([text], { type: 'text/plain' }),
|
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||||
}),
|
subject: subject.trim() || composeSubject(mode, thread.subject),
|
||||||
])
|
bodyHtml: html,
|
||||||
copied = true
|
bodyText: text,
|
||||||
} else if (navigator.clipboard?.writeText) {
|
inReplyTo: isForward ? undefined : inReplyTo,
|
||||||
await navigator.clipboard.writeText(text)
|
references: isForward ? undefined : references || undefined,
|
||||||
copied = true
|
})
|
||||||
|
if (result.error) {
|
||||||
|
toast(`Send failed: ${result.error}`, 'error')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
toast('Sent.', 'success')
|
||||||
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Gmail] clipboard write failed:', err)
|
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||||
}
|
} finally {
|
||||||
}
|
setSending(false)
|
||||||
|
|
||||||
window.open(thread.threadUrl, '_blank')
|
|
||||||
if (copied) {
|
|
||||||
toast('Draft copied — open the reply in Gmail and paste.', 'info')
|
|
||||||
} else if (text) {
|
|
||||||
toast('Could not copy draft. Open Gmail and paste manually.', 'error')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refineWithCopilot = () => {
|
const refineWithCopilot = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
const currentDraft = editor.getText().trim()
|
const currentDraft = editor.getText().trim()
|
||||||
const subject = thread.subject || '(No subject)'
|
const threadSubject = thread.subject || '(No subject)'
|
||||||
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`)
|
lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`)
|
lines.push(`**Mode:** ${modeLabel}`)
|
||||||
lines.push(`**Subject:** ${subject}`)
|
lines.push(`**Subject:** ${threadSubject}`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`)
|
lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
@ -625,17 +840,32 @@ function ComposeBox({
|
||||||
return (
|
return (
|
||||||
<div className="gmail-compose-card">
|
<div className="gmail-compose-card">
|
||||||
<div className="gmail-compose-header">
|
<div className="gmail-compose-header">
|
||||||
<span>{mode === 'reply' ? 'Reply' : 'Forward'}</span>
|
<span>{modeLabel}</span>
|
||||||
<button type="button" onClick={onClose} aria-label="Close compose">x</button>
|
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="gmail-compose-line">
|
<RecipientField
|
||||||
<span>{mode === 'reply' ? 'To' : 'Recipients'}</span>
|
label="To"
|
||||||
<input value={to} placeholder="Recipients" readOnly={mode === 'reply'} />
|
value={toList}
|
||||||
|
onChange={setToList}
|
||||||
|
autoFocus={mode === 'forward'}
|
||||||
|
trailing={
|
||||||
|
<div className="gmail-recipient-toggles">
|
||||||
|
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||||||
|
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
|
||||||
|
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
|
||||||
{mode === 'forward' && (
|
{mode === 'forward' && (
|
||||||
<div className="gmail-compose-line">
|
<div className="gmail-compose-line">
|
||||||
<span>Subject</span>
|
<span className="gmail-compose-label">Subject</span>
|
||||||
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
|
<input
|
||||||
|
className="gmail-compose-subject-input"
|
||||||
|
value={subject}
|
||||||
|
onChange={(event) => setSubject(event.target.value)}
|
||||||
|
placeholder="Subject"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} className="gmail-compose-editor" />
|
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||||||
|
|
@ -666,10 +896,11 @@ function ComposeBox({
|
||||||
type="button"
|
type="button"
|
||||||
className="gmail-send-button"
|
className="gmail-send-button"
|
||||||
onClick={() => { void sendInGmail() }}
|
onClick={() => { void sendInGmail() }}
|
||||||
title="Copy draft and open this thread in Gmail"
|
disabled={sending}
|
||||||
|
title="Send this reply via Gmail"
|
||||||
>
|
>
|
||||||
<Send size={15} />
|
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
|
||||||
Send
|
{sending ? 'Sending…' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -698,10 +929,25 @@ function ThreadDetail({
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
|
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
|
||||||
|
const [selfEmail, setSelfEmail] = useState<string>('')
|
||||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
|
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
|
||||||
() => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : [])
|
() => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : [])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The connected Gmail address, so reply-all can exclude "me".
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
window.ipc.invoke('gmail:getAccountEmail', {})
|
||||||
|
.then((res) => { if (!cancelled && res?.email) setSelfEmail(res.email) })
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canReplyAll = useMemo(() => {
|
||||||
|
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
|
||||||
|
return cc.length > 0 || to.length > 1
|
||||||
|
}, [thread, selfEmail])
|
||||||
|
|
||||||
const toggleExpand = useCallback((index: number) => {
|
const toggleExpand = useCallback((index: number) => {
|
||||||
setExpandedIndices((prev) => {
|
setExpandedIndices((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
|
|
@ -771,6 +1017,12 @@ function ThreadDetail({
|
||||||
<Reply size={16} />
|
<Reply size={16} />
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
|
{canReplyAll && (
|
||||||
|
<button type="button" onClick={() => setComposeMode('replyAll')}>
|
||||||
|
<ReplyAll size={16} />
|
||||||
|
Reply all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={() => setComposeMode('forward')}>
|
<button type="button" onClick={() => setComposeMode('forward')}>
|
||||||
<Forward size={16} />
|
<Forward size={16} />
|
||||||
Forward
|
Forward
|
||||||
|
|
@ -779,8 +1031,10 @@ function ThreadDetail({
|
||||||
|
|
||||||
{composeMode && (
|
{composeMode && (
|
||||||
<ComposeBox
|
<ComposeBox
|
||||||
|
key={composeMode}
|
||||||
mode={composeMode}
|
mode={composeMode}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
selfEmail={selfEmail}
|
||||||
onClose={() => setComposeMode(null)}
|
onClose={() => setComposeMode(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -844,17 +1098,24 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
// Gmail sync uses the native Google OAuth connection.
|
// Gmail sync uses the native Google OAuth connection.
|
||||||
const [emailConnected, setEmailConnected] = useState<boolean | null>(null)
|
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
try {
|
||||||
const oauthState = await window.ipc.invoke('oauth:getState', null)
|
const status = await window.ipc.invoke('gmail:getConnectionStatus', {})
|
||||||
if (!cancelled) setEmailConnected(oauthState.config?.google?.connected ?? false)
|
if (!cancelled) setEmailConnection(status)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setEmailConnected(false)
|
if (!cancelled) {
|
||||||
|
setEmailConnection({
|
||||||
|
connected: false,
|
||||||
|
hasRequiredScope: false,
|
||||||
|
missingScopes: [],
|
||||||
|
email: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void check()
|
void check()
|
||||||
|
|
@ -873,18 +1134,81 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
else setOther(updater)
|
else setOther(updater)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleThread = useCallback((threadId: string) => {
|
const updateThreadInState = useCallback((threadId: string, updater: (t: GmailThread) => GmailThread) => {
|
||||||
|
const mapSection = (prev: SectionState): SectionState => ({
|
||||||
|
...prev,
|
||||||
|
threads: prev.threads.map((t) => (t.threadId === threadId ? updater(t) : t)),
|
||||||
|
})
|
||||||
|
setImportant(mapSection)
|
||||||
|
setOther(mapSection)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeThreadFromState = useCallback((threadId: string) => {
|
||||||
|
const filterSection = (prev: SectionState): SectionState => ({
|
||||||
|
...prev,
|
||||||
|
threads: prev.threads.filter((t) => t.threadId !== threadId),
|
||||||
|
})
|
||||||
|
setImportant(filterSection)
|
||||||
|
setOther(filterSection)
|
||||||
|
setSelectedThreadId((current) => (current === threadId ? null : current))
|
||||||
|
setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markThreadReadAction = useCallback(async (threadId: string) => {
|
||||||
|
updateThreadInState(threadId, (t) => ({
|
||||||
|
...t,
|
||||||
|
unread: false,
|
||||||
|
messages: t.messages.map((m) => ({ ...m, unread: false })),
|
||||||
|
}))
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId })
|
||||||
|
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Gmail] mark-read failed:', err)
|
||||||
|
}
|
||||||
|
}, [updateThreadInState])
|
||||||
|
|
||||||
|
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
||||||
|
if (result.ok) {
|
||||||
|
removeThreadFromState(threadId)
|
||||||
|
} else if (result.error) {
|
||||||
|
toast(`Archive failed: ${result.error}`, 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||||
|
}
|
||||||
|
}, [removeThreadFromState])
|
||||||
|
|
||||||
|
const trashThreadAction = useCallback(async (threadId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
|
||||||
|
if (result.ok) {
|
||||||
|
removeThreadFromState(threadId)
|
||||||
|
} else if (result.error) {
|
||||||
|
toast(`Delete failed: ${result.error}`, 'error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||||
|
}
|
||||||
|
}, [removeThreadFromState])
|
||||||
|
|
||||||
|
const toggleThread = useCallback((thread: GmailThread) => {
|
||||||
setSelectedThreadId((current) => {
|
setSelectedThreadId((current) => {
|
||||||
const next = current === threadId ? null : threadId
|
const next = current === thread.threadId ? null : thread.threadId
|
||||||
if (next) {
|
if (next) {
|
||||||
setOpenedThreadIds((prev) => {
|
setOpenedThreadIds((prev) => {
|
||||||
const without = prev.filter((id) => id !== next)
|
const without = prev.filter((id) => id !== next)
|
||||||
return [...without, next].slice(-MAX_KEPT_OPEN)
|
return [...without, next].slice(-MAX_KEPT_OPEN)
|
||||||
})
|
})
|
||||||
|
if (thread.unread) {
|
||||||
|
void markThreadReadAction(thread.threadId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [markThreadReadAction])
|
||||||
|
|
||||||
const prefetchedRef = useRef<Set<string>>(new Set())
|
const prefetchedRef = useRef<Set<string>>(new Set())
|
||||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
@ -1131,20 +1455,28 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
|
|
||||||
const hasAny = important.threads.length > 0 || other.threads.length > 0
|
const hasAny = important.threads.length > 0 || other.threads.length > 0
|
||||||
const initialLoading = !hasAny && refreshing
|
const initialLoading = !hasAny && refreshing
|
||||||
|
const needsEmailConnect = emailConnection?.connected === false
|
||||||
|
const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope
|
||||||
|
|
||||||
const renderRow = (thread: GmailThread) => {
|
const renderRow = (thread: GmailThread) => {
|
||||||
const latest = latestMessage(thread)
|
const latest = latestMessage(thread)
|
||||||
const isSelected = thread.threadId === selectedThreadId
|
const isSelected = thread.threadId === selectedThreadId
|
||||||
const isUnread = thread.unread === true
|
const isUnread = thread.unread === true
|
||||||
const isMounted = openedThreadIds.includes(thread.threadId)
|
const isMounted = openedThreadIds.includes(thread.threadId)
|
||||||
|
const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={thread.threadId} className="gmail-row-group">
|
<div key={thread.threadId} className="gmail-row-group">
|
||||||
|
<div
|
||||||
|
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
|
||||||
|
onMouseEnter={() => scheduleHoverPrefetch(thread)}
|
||||||
|
onMouseLeave={cancelHoverPrefetch}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
|
||||||
onClick={() => toggleThread(thread.threadId)}
|
onClick={() => toggleThread(thread)}
|
||||||
onMouseEnter={() => scheduleHoverPrefetch(thread)}
|
|
||||||
onMouseLeave={cancelHoverPrefetch}
|
|
||||||
>
|
>
|
||||||
<span className="gmail-row-dot" aria-hidden />
|
<span className="gmail-row-dot" aria-hidden />
|
||||||
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
|
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
|
||||||
|
|
@ -1154,6 +1486,38 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
</span>
|
</span>
|
||||||
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
|
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
|
||||||
|
{isUnread && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gmail-row-action"
|
||||||
|
title="Mark as read"
|
||||||
|
aria-label="Mark as read"
|
||||||
|
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId) }}
|
||||||
|
>
|
||||||
|
<CheckCheck size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gmail-row-action"
|
||||||
|
title="Archive"
|
||||||
|
aria-label="Archive"
|
||||||
|
onClick={(e) => { stop(e); void archiveThreadAction(thread.threadId) }}
|
||||||
|
>
|
||||||
|
<Archive size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gmail-row-action gmail-row-action-danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={(e) => { stop(e); void trashThreadAction(thread.threadId) }}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<ThreadDetail
|
<ThreadDetail
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
|
@ -1223,17 +1587,21 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : emailConnected === false ? (
|
) : needsEmailConnect || needsEmailReconnect ? (
|
||||||
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
|
||||||
<Mail size={28} className="opacity-50" />
|
<Mail size={28} className="opacity-50" />
|
||||||
<p>Connect your email to see your inbox here.</p>
|
<p>
|
||||||
|
{needsEmailReconnect
|
||||||
|
? 'Reconnect your email to enable Gmail sync and actions.'
|
||||||
|
: 'Connect your email to see your inbox here.'}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Mail size={15} />
|
<Mail size={15} />
|
||||||
Connect your email
|
{needsEmailReconnect ? 'Reconnect your email' : 'Connect your email'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,8 @@ const providerConfigs: ProviderConfig = {
|
||||||
mode: 'static',
|
mode: 'static',
|
||||||
},
|
},
|
||||||
scopes: [
|
scopes: [
|
||||||
'https://www.googleapis.com/auth/gmail.readonly',
|
'https://www.googleapis.com/auth/gmail.modify',
|
||||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||||
'https://www.googleapis.com/auth/drive.readonly',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'fireflies-ai': {
|
'fireflies-ai': {
|
||||||
|
|
@ -119,4 +118,3 @@ export async function getProviderConfig(providerName: string): Promise<ProviderC
|
||||||
export function getAvailableProviders(): string[] {
|
export function getAvailableProviders(): string[] {
|
||||||
return Object.keys(providerConfigs);
|
return Object.keys(providerConfigs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,18 +188,41 @@ export class GoogleClientFactory {
|
||||||
* Check if credentials are available and have required scopes
|
* Check if credentials are available and have required scopes
|
||||||
*/
|
*/
|
||||||
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
||||||
|
const status = await this.getCredentialStatus(requiredScopes);
|
||||||
|
return status.hasRequiredScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCredentialStatus(requiredScopes: string | string[]): Promise<{
|
||||||
|
connected: boolean;
|
||||||
|
hasRequiredScopes: boolean;
|
||||||
|
missingScopes: string[];
|
||||||
|
}> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
return false;
|
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
hasRequiredScopes: false,
|
||||||
|
missingScopes: scopesArray,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if required scope(s) are present
|
|
||||||
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
|
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
|
||||||
|
const granted = new Set(tokens.scopes ?? []);
|
||||||
|
const missingScopes = scopesArray.filter(scope => !granted.has(scope));
|
||||||
if (!tokens.scopes || tokens.scopes.length === 0) {
|
if (!tokens.scopes || tokens.scopes.length === 0) {
|
||||||
return false;
|
return {
|
||||||
|
connected: true,
|
||||||
|
hasRequiredScopes: false,
|
||||||
|
missingScopes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return scopesArray.every(scope => tokens.scopes!.includes(scope));
|
return {
|
||||||
|
connected: true,
|
||||||
|
hasRequiredScopes: missingScopes.length === 0,
|
||||||
|
missingScopes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
||||||
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify';
|
||||||
const MAX_THREADS_IN_DIGEST = 10;
|
const MAX_THREADS_IN_DIGEST = 10;
|
||||||
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
|
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
const nhm = new NodeHtmlMarkdown();
|
const nhm = new NodeHtmlMarkdown();
|
||||||
|
|
@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteCachedSnapshot(threadId: string): void {
|
||||||
|
try {
|
||||||
|
fs.rmSync(cachePath(threadId), { force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Gmail cache] delete failed for ${threadId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGmailClientOrThrow() {
|
||||||
|
const auth = await GoogleClientFactory.getClient();
|
||||||
|
if (!auth) throw new Error('Gmail is not connected.');
|
||||||
|
return google.gmail({ version: 'v1', auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadActionResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveThread(threadId: string): Promise<ThreadActionResult> {
|
||||||
|
try {
|
||||||
|
const gmailClient = await getGmailClientOrThrow();
|
||||||
|
await gmailClient.users.threads.modify({
|
||||||
|
userId: 'me',
|
||||||
|
id: threadId,
|
||||||
|
requestBody: { removeLabelIds: ['INBOX'] },
|
||||||
|
});
|
||||||
|
deleteCachedSnapshot(threadId);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashThread(threadId: string): Promise<ThreadActionResult> {
|
||||||
|
try {
|
||||||
|
const gmailClient = await getGmailClientOrThrow();
|
||||||
|
await gmailClient.users.threads.trash({ userId: 'me', id: threadId });
|
||||||
|
deleteCachedSnapshot(threadId);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
|
||||||
|
try {
|
||||||
|
const gmailClient = await getGmailClientOrThrow();
|
||||||
|
await gmailClient.users.threads.modify({
|
||||||
|
userId: 'me',
|
||||||
|
id: threadId,
|
||||||
|
requestBody: { removeLabelIds: ['UNREAD'] },
|
||||||
|
});
|
||||||
|
// Update local cache: clear unread on all messages in the thread.
|
||||||
|
const cached = readCachedSnapshot(threadId);
|
||||||
|
if (cached) {
|
||||||
|
for (const m of cached.snapshot.messages) m.unread = false;
|
||||||
|
cached.snapshot.unread = false;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncedThread {
|
interface SyncedThread {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
|
@ -114,6 +184,7 @@ export interface GmailThreadSnapshot {
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
savedPath: string;
|
savedPath: string;
|
||||||
}>;
|
}>;
|
||||||
|
messageIdHeader?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1158,6 +1229,162 @@ async function performSync() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Send Reply ---
|
||||||
|
|
||||||
|
export interface SendReplyOptions {
|
||||||
|
threadId?: string;
|
||||||
|
to: string;
|
||||||
|
cc?: string;
|
||||||
|
bcc?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
bodyText: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
references?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendReplyResult {
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailConnectionStatus {
|
||||||
|
connected: boolean;
|
||||||
|
hasRequiredScope: boolean;
|
||||||
|
missingScopes: string[];
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connected Gmail address (cached). Used by the composer to exclude "me" from reply-all. */
|
||||||
|
export async function getAccountEmail(): Promise<string | null> {
|
||||||
|
const auth = await GoogleClientFactory.getClient();
|
||||||
|
if (!auth) return null;
|
||||||
|
return getUserEmail(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
|
||||||
|
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
|
||||||
|
let email: string | null = null;
|
||||||
|
if (status.connected) {
|
||||||
|
try {
|
||||||
|
email = await getAccountEmail();
|
||||||
|
} catch {
|
||||||
|
email = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
connected: status.connected,
|
||||||
|
hasRequiredScope: status.hasRequiredScopes,
|
||||||
|
missingScopes: status.missingScopes,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSafeHeaderValue(name: string, value: string): string {
|
||||||
|
if (/[\r\n]/.test(value)) {
|
||||||
|
throw new Error(`${name} cannot contain line breaks.`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeRfc2047(text: string): string {
|
||||||
|
requireSafeHeaderValue('Subject', text);
|
||||||
|
// Only encode if non-ASCII chars present.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/^[\x00-\x7F]*$/.test(text)) return text;
|
||||||
|
return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMimeBase64(text: string): string {
|
||||||
|
return Buffer.from(text, 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.match(/.{1,76}/g)
|
||||||
|
?.join('\r\n') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
|
||||||
|
try {
|
||||||
|
const auth = await GoogleClientFactory.getClient();
|
||||||
|
if (!auth) return { error: 'Gmail is not connected.' };
|
||||||
|
|
||||||
|
const gmailClient = google.gmail({ version: 'v1', auth });
|
||||||
|
const userEmail = await getUserEmail(auth);
|
||||||
|
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
|
||||||
|
|
||||||
|
const safeTo = requireSafeHeaderValue('To', opts.to);
|
||||||
|
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
|
||||||
|
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
|
||||||
|
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
|
||||||
|
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
|
||||||
|
|
||||||
|
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const headers: string[] = [];
|
||||||
|
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
|
||||||
|
headers.push(`To: ${safeTo}`);
|
||||||
|
if (safeCc) headers.push(`Cc: ${safeCc}`);
|
||||||
|
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
|
||||||
|
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
|
||||||
|
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
|
||||||
|
if (safeReferences) headers.push(`References: ${safeReferences}`);
|
||||||
|
headers.push('MIME-Version: 1.0');
|
||||||
|
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`--${boundary}`);
|
||||||
|
parts.push('Content-Type: text/plain; charset="UTF-8"');
|
||||||
|
parts.push('Content-Transfer-Encoding: base64');
|
||||||
|
parts.push('');
|
||||||
|
parts.push(encodeMimeBase64(opts.bodyText));
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`--${boundary}`);
|
||||||
|
parts.push('Content-Type: text/html; charset="UTF-8"');
|
||||||
|
parts.push('Content-Transfer-Encoding: base64');
|
||||||
|
parts.push('');
|
||||||
|
parts.push(encodeMimeBase64(opts.bodyHtml));
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`--${boundary}--`);
|
||||||
|
|
||||||
|
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
|
||||||
|
const raw = Buffer.from(message, 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
|
||||||
|
const requestBody: gmail.Schema$Message = { raw };
|
||||||
|
if (opts.threadId) requestBody.threadId = opts.threadId;
|
||||||
|
|
||||||
|
const res = await gmailClient.users.messages.send({
|
||||||
|
userId: 'me',
|
||||||
|
requestBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.threadId) {
|
||||||
|
// Clean up any Gmail-side drafts in this thread.
|
||||||
|
try {
|
||||||
|
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
|
||||||
|
const matching = (drafts.data.drafts || []).filter(
|
||||||
|
(d) => d.message?.threadId === opts.threadId && d.id
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
matching.map((d) =>
|
||||||
|
gmailClient.users.drafts.delete({ userId: 'me', id: d.id! })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake the sync loop so the cache picks up the new message.
|
||||||
|
triggerSync();
|
||||||
|
|
||||||
|
return { messageId: res.data.id || undefined };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
console.log("Starting Gmail Sync (TS)...");
|
console.log("Starting Gmail Sync (TS)...");
|
||||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export const GmailThreadMessageSchema = z.object({
|
||||||
unread: z.boolean().optional(),
|
unread: z.boolean().optional(),
|
||||||
bodyHeight: z.number().int().positive().optional(),
|
bodyHeight: z.number().int().positive().optional(),
|
||||||
attachments: z.array(GmailAttachmentSchema).optional(),
|
attachments: z.array(GmailAttachmentSchema).optional(),
|
||||||
|
messageIdHeader: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
|
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,50 @@ const ipcSchemas = {
|
||||||
req: z.object({}),
|
req: z.object({}),
|
||||||
res: z.object({}),
|
res: z.object({}),
|
||||||
},
|
},
|
||||||
|
'gmail:sendReply': {
|
||||||
|
req: z.object({
|
||||||
|
threadId: z.string().min(1).optional(),
|
||||||
|
to: z.string().min(1),
|
||||||
|
cc: z.string().optional(),
|
||||||
|
bcc: z.string().optional(),
|
||||||
|
subject: z.string(),
|
||||||
|
bodyHtml: z.string(),
|
||||||
|
bodyText: z.string(),
|
||||||
|
inReplyTo: z.string().optional(),
|
||||||
|
references: z.string().optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
messageId: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'gmail:getConnectionStatus': {
|
||||||
|
req: z.object({}),
|
||||||
|
res: z.object({
|
||||||
|
connected: z.boolean(),
|
||||||
|
hasRequiredScope: z.boolean(),
|
||||||
|
missingScopes: z.array(z.string()),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'gmail:getAccountEmail': {
|
||||||
|
req: z.object({}),
|
||||||
|
res: z.object({
|
||||||
|
email: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'gmail:archiveThread': {
|
||||||
|
req: z.object({ threadId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||||
|
},
|
||||||
|
'gmail:trashThread': {
|
||||||
|
req: z.object({ threadId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||||
|
},
|
||||||
|
'gmail:markThreadRead': {
|
||||||
|
req: z.object({ threadId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||||
|
},
|
||||||
'gmail:saveMessageHeight': {
|
'gmail:saveMessageHeight': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
threadId: z.string().min(1),
|
threadId: z.string().min(1),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue