mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
direct gmail initial commit
This commit is contained in:
parent
af618155e1
commit
e3eac3cfdd
9 changed files with 318 additions and 75 deletions
|
|
@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { fetchThreadSnapshot } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -482,6 +483,16 @@ export function setupIpcHandlers() {
|
|||
'workspace:remove': async (_event, args) => {
|
||||
return workspace.remove(args.path, args.opts);
|
||||
},
|
||||
'gmail:getThread': async (_event, args) => {
|
||||
try {
|
||||
return { thread: await fetchThreadSnapshot(args.threadId) };
|
||||
} catch (error) {
|
||||
return {
|
||||
thread: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,6 +54,137 @@ function avatarColor(from: string): string {
|
|||
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
|
||||
}
|
||||
|
||||
function extractThreadId(config: Pick<blocks.EmailBlock, 'threadId' | 'threadUrl'>): string | null {
|
||||
if (config.threadId) return config.threadId
|
||||
if (!config.threadUrl) return null
|
||||
const url = config.threadUrl.trim()
|
||||
const hashId = url.match(/#(?:all|inbox|sent|important|starred|search\/[^/]+)\/([^/?#]+)/)
|
||||
if (hashId?.[1]) return decodeURIComponent(hashId[1])
|
||||
const queryId = url.match(/[?&](?:th|threadId)=([^&]+)/)
|
||||
if (queryId?.[1]) return decodeURIComponent(queryId[1])
|
||||
const tailId = url.match(/\/([a-f0-9]{12,})\/?$/i)
|
||||
return tailId?.[1] || null
|
||||
}
|
||||
|
||||
function parseSyncedGmailThread(markdown: string, threadId: string): blocks.EmailBlock | null {
|
||||
const subject = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
||||
const chunks = markdown
|
||||
.split(/\n---\n/g)
|
||||
.map(chunk => chunk.trim())
|
||||
.filter(chunk => /^### From:/m.test(chunk))
|
||||
|
||||
const messages = chunks.map((chunk) => {
|
||||
const from = chunk.match(/^### From:\s*(.+)$/m)?.[1]?.trim()
|
||||
const date = chunk.match(/^\*\*Date:\*\*\s*(.+)$/m)?.[1]?.trim()
|
||||
const body = chunk
|
||||
.replace(/^### From:\s*.+$/m, '')
|
||||
.replace(/^\*\*Date:\*\*\s*.+$/m, '')
|
||||
.replace(/\n\*\*Attachments:\*\*[\s\S]*$/m, '')
|
||||
.trim()
|
||||
return { from, date, body }
|
||||
}).filter(message => message.from || message.body)
|
||||
|
||||
const latest = messages[messages.length - 1]
|
||||
if (!latest) return null
|
||||
|
||||
const earlier = messages.slice(0, -1)
|
||||
const pastSummary = earlier
|
||||
.map((message) => {
|
||||
const date = message.date ? ` (${message.date})` : ''
|
||||
const body = message.body.replace(/\s+/g, ' ').slice(0, 500).trim()
|
||||
return `${message.from || 'Unknown'}${date}: ${body}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
return {
|
||||
threadId,
|
||||
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
|
||||
subject,
|
||||
from: latest.from,
|
||||
date: latest.date,
|
||||
latest_email: latest.body,
|
||||
past_summary: pastSummary || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeHydratedEmail(base: blocks.EmailBlock, hydrated: blocks.EmailBlock | null): blocks.EmailBlock {
|
||||
if (!hydrated) return base
|
||||
return {
|
||||
...hydrated,
|
||||
...base,
|
||||
threadId: base.threadId || hydrated.threadId,
|
||||
threadUrl: base.threadUrl || hydrated.threadUrl,
|
||||
subject: hydrated.subject || base.subject,
|
||||
from: hydrated.from || base.from,
|
||||
to: hydrated.to || base.to,
|
||||
date: hydrated.date || base.date,
|
||||
latest_email: hydrated.latest_email || base.latest_email,
|
||||
past_summary: hydrated.past_summary || base.past_summary,
|
||||
summary: base.summary || hydrated.summary,
|
||||
}
|
||||
}
|
||||
|
||||
function useHydratedEmail(config: blocks.EmailBlock): { email: blocks.EmailBlock; loading: boolean; error: string | null } {
|
||||
const [email, setEmail] = useState(config)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const threadId = extractThreadId(config)
|
||||
const configKey = JSON.stringify(config)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const baseConfig = blocks.EmailBlockSchema.parse(JSON.parse(configKey))
|
||||
|
||||
async function load() {
|
||||
setEmail(baseConfig)
|
||||
setError(null)
|
||||
if (!threadId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
let hydrated: blocks.EmailBlock | null = null
|
||||
let loadError: string | null = null
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:getThread', { threadId })
|
||||
if (result.thread) {
|
||||
hydrated = result.thread
|
||||
} else if (result.error) {
|
||||
loadError = result.error
|
||||
}
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `gmail_sync/${threadId}.md`,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
hydrated = parseSyncedGmailThread(result.data, threadId)
|
||||
} catch (err) {
|
||||
loadError ||= err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setEmail(mergeHydratedEmail(baseConfig, hydrated))
|
||||
setError(hydrated ? null : loadError)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => { cancelled = true }
|
||||
}, [configKey, threadId])
|
||||
|
||||
return { email, loading, error }
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingEmailDraft?: { prompt: string }
|
||||
|
|
@ -88,8 +219,9 @@ function EmailExpandedBody({
|
|||
let prompt = draftBody
|
||||
? `Help me refine this draft response to an email`
|
||||
: `Help me draft a response to this email`
|
||||
if (config.threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
|
||||
const threadId = extractThreadId(config)
|
||||
if (threadId) {
|
||||
prompt += `. Read the full thread at gmail_sync/${threadId}.md for context`
|
||||
}
|
||||
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
|
||||
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
|
||||
|
|
@ -113,9 +245,8 @@ function EmailExpandedBody({
|
|||
})
|
||||
}, [draftBody])
|
||||
|
||||
const gmailUrl = config.threadId
|
||||
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
|
||||
: null
|
||||
const threadId = extractThreadId(config)
|
||||
const gmailUrl = config.threadUrl || (threadId ? `https://mail.google.com/mail/u/0/#all/${threadId}` : null)
|
||||
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
|
|
@ -138,7 +269,7 @@ function EmailExpandedBody({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="email-gmail-exp-body">{config.latest_email}</div>
|
||||
<div className="email-gmail-exp-body">{config.latest_email || 'Loading latest message...'}</div>
|
||||
|
||||
{config.past_summary && (
|
||||
<div className="email-gmail-exp-history">
|
||||
|
|
@ -224,6 +355,69 @@ function EmailExpandedBody({
|
|||
|
||||
// --- Multi-email inbox block (language-emails) ---
|
||||
|
||||
function EmailInboxRow({
|
||||
email,
|
||||
expanded,
|
||||
onToggle,
|
||||
resolvedTheme,
|
||||
}: {
|
||||
email: blocks.EmailBlock
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
resolvedTheme: string
|
||||
}) {
|
||||
const { email: hydratedEmail, loading, error } = useHydratedEmail(email)
|
||||
const senderName = hydratedEmail.from ? extractName(hydratedEmail.from) : 'Unknown'
|
||||
const initial = hydratedEmail.from ? getInitial(hydratedEmail.from) : '?'
|
||||
const color = hydratedEmail.from ? avatarColor(hydratedEmail.from) : '#5f6368'
|
||||
const snippet = hydratedEmail.summary
|
||||
|| (hydratedEmail.latest_email ? hydratedEmail.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
|
||||
|| (loading ? 'Loading latest Gmail thread...' : error || '')
|
||||
|
||||
return (
|
||||
<div className={`email-inbox-row${expanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{hydratedEmail.date && <span className="email-inbox-date">{formatEmailDate(hydratedEmail.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{hydratedEmail.subject && <span className="email-inbox-subject">{hydratedEmail.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{hydratedEmail.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${expanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={hydratedEmail}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailsBlockView({ node, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
|
|
@ -258,53 +452,14 @@ function EmailsBlockView({ node, deleteNode }: {
|
|||
<div className="email-inbox-list">
|
||||
{config.emails.map((email, i) => {
|
||||
const isExpanded = expandedIndex === i
|
||||
const senderName = email.from ? extractName(email.from) : 'Unknown'
|
||||
const initial = email.from ? getInitial(email.from) : '?'
|
||||
const color = email.from ? avatarColor(email.from) : '#5f6368'
|
||||
const snippet = email.summary
|
||||
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
|
||||
|
||||
return (
|
||||
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{email.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={email}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EmailInboxRow
|
||||
key={email.threadId || email.threadUrl || i}
|
||||
email={email}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setExpandedIndex(isExpanded ? null : i)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -387,11 +542,13 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
)
|
||||
}
|
||||
|
||||
const senderName = config.from ? extractName(config.from) : 'Unknown'
|
||||
const initial = config.from ? getInitial(config.from) : '?'
|
||||
const color = config.from ? avatarColor(config.from) : '#5f6368'
|
||||
const snippet = config.summary
|
||||
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
|
||||
const { email, loading, error } = useHydratedEmail(config)
|
||||
const senderName = email.from ? extractName(email.from) : 'Unknown'
|
||||
const initial = email.from ? getInitial(email.from) : '?'
|
||||
const color = email.from ? avatarColor(email.from) : '#5f6368'
|
||||
const snippet = email.summary
|
||||
|| (email.latest_email ? email.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
|
||||
|| (loading ? 'Loading latest Gmail thread...' : error || '')
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
|
|
@ -407,11 +564,11 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
<div className="email-gmail-content">
|
||||
<div className="email-gmail-top-row">
|
||||
<span className="email-gmail-sender">{senderName}</span>
|
||||
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
|
||||
{email.date && <span className="email-gmail-date">{formatEmailDate(email.date)}</span>}
|
||||
</div>
|
||||
<div className="email-gmail-bottom-row">
|
||||
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
{email.subject && <span className="email-gmail-subject">{email.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{email.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
|
||||
|
|
@ -419,7 +576,7 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
|
||||
{expanded && (
|
||||
<EmailExpandedBody
|
||||
config={config}
|
||||
config={email}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -324,7 +324,8 @@ live:
|
|||
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
|
||||
|
||||
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
|
||||
full digest from scratch.
|
||||
full digest from scratch. In each email entry, store only \`threadId\` or \`threadUrl\`,
|
||||
optional \`summary\`, and optional \`draft_response\`; the block hydrates Gmail metadata.
|
||||
|
||||
With an event payload (event run): integrate the new thread into the existing digest —
|
||||
add it if new, update its entry if the threadId is already shown — and don't re-list
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
|||
// on-disk `templateVersion` against this constant — if older or missing, the
|
||||
// existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with the
|
||||
// new template. v2 is the live-note rewrite (single objective, no `track:`).
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 2;
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 3;
|
||||
|
||||
const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
||||
objective:
|
||||
|
|
@ -24,7 +24,7 @@ const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
|||
|
||||
2. **Calendar** — today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\` → \`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`.
|
||||
|
||||
3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
|
||||
3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful for why it matters. Do **not** copy \`subject\`, \`from\`, \`date\`, or \`latest_email\` from Gmail into the block — the email block hydrates the latest thread from Gmail directly. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`, and use the filename/Thread ID to set \`threadId\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
|
||||
|
||||
4. **What you missed** — a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
|
||||
|
||||
|
|
|
|||
|
|
@ -163,11 +163,11 @@ If there are events, include them:
|
|||
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
|
||||
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
|
||||
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful. Do not copy sender, subject, date, or latest body into the block; the renderer hydrates those from Gmail. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`emails
|
||||
{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"Security alert","subject":"New sign-in from Chrome","from":"Google <no-reply@accounts.google.com>","date":"2026-04-01T09:15:00+05:30","latest_email":"A new sign-in to your account was detected."}]}
|
||||
{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation needs acknowledgement.","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"FYI security alert."}]}
|
||||
\\\`\\\`\\\`
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -221,4 +221,4 @@ When you see a target region associated with your task (during a scheduled run),
|
|||
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
|
||||
- Do NOT include the target tags themselves in your response
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,19 +160,17 @@ Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\
|
|||
|
||||
## \`email\` — single email or thread digest (JSON)
|
||||
|
||||
Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply.
|
||||
Use for: surfacing one important Gmail thread. Prefer storing only a Gmail thread reference plus an optional draft reply; the renderer hydrates sender, subject, date, and latest body from Gmail.
|
||||
|
||||
\`\`\`email
|
||||
{
|
||||
"subject": "Q3 launch readiness",
|
||||
"from": "sarah@acme.com",
|
||||
"date": "2026-04-19T16:42:00Z",
|
||||
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
|
||||
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
|
||||
"threadId": "18d5a3b2c1e4f567",
|
||||
"summary": "Needs a reply on the Q3 launch blocker.",
|
||||
"draft_response": "Thanks for flagging. I'll check with infra and get back to you today."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
|
||||
Required: \`threadId\` or \`threadUrl\`. Optional: \`summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both"). Legacy fields \`subject\`, \`from\`, \`to\`, \`date\`, \`latest_email\`, and \`past_summary\` are supported for older notes, but don't emit them for Gmail threads.
|
||||
|
||||
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ interface SyncedThread {
|
|||
markdown: string;
|
||||
}
|
||||
|
||||
export interface GmailThreadSnapshot {
|
||||
threadId: string;
|
||||
threadUrl: string;
|
||||
summary?: string;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
date?: string;
|
||||
latest_email?: string;
|
||||
past_summary?: string;
|
||||
}
|
||||
|
||||
function summarizeGmailSync(threads: SyncedThread[]): string {
|
||||
const lines: string[] = [
|
||||
`# Gmail sync update`,
|
||||
|
|
@ -124,6 +136,59 @@ function getBody(payload: gmail.Schema$MessagePart): string {
|
|||
return body;
|
||||
}
|
||||
|
||||
function normalizeBody(body: string): string {
|
||||
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
|
||||
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
|
||||
}
|
||||
|
||||
export async function fetchThreadSnapshot(threadId: string): Promise<GmailThreadSnapshot | null> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) {
|
||||
throw new Error('Gmail is not connected.');
|
||||
}
|
||||
|
||||
const gmailClient = google.gmail({ version: 'v1', auth });
|
||||
const res = await gmailClient.users.threads.get({ userId: 'me', id: threadId });
|
||||
const messages = res.data.messages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
const parsed = messages.map((msg) => {
|
||||
const headers = msg.payload?.headers || [];
|
||||
return {
|
||||
from: headerValue(headers, 'From') || 'Unknown',
|
||||
to: headerValue(headers, 'To'),
|
||||
date: headerValue(headers, 'Date'),
|
||||
subject: headerValue(headers, 'Subject') || '(No Subject)',
|
||||
body: msg.payload ? normalizeBody(getBody(msg.payload)) : '',
|
||||
};
|
||||
});
|
||||
|
||||
const latest = parsed[parsed.length - 1]!;
|
||||
const earlier = parsed.slice(0, -1);
|
||||
const earlierSummary = earlier
|
||||
.map((msg) => {
|
||||
const date = msg.date ? ` (${msg.date})` : '';
|
||||
const body = msg.body.replace(/\s+/g, ' ').slice(0, 500).trim();
|
||||
return `${msg.from}${date}: ${body}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
threadId,
|
||||
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
|
||||
subject: latest.subject || parsed[0]?.subject,
|
||||
from: latest.from,
|
||||
to: latest.to,
|
||||
date: latest.date,
|
||||
latest_email: latest.body,
|
||||
past_summary: earlierSummary || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
|
||||
const filename = part.filename;
|
||||
const attId = part.body?.attachmentId;
|
||||
|
|
|
|||
|
|
@ -88,12 +88,13 @@ export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
|
|||
|
||||
export const EmailBlockSchema = z.object({
|
||||
threadId: z.string().optional(),
|
||||
threadUrl: z.string().url().optional(),
|
||||
summary: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
latest_email: z.string(),
|
||||
latest_email: z.string().optional(),
|
||||
past_summary: z.string().optional(),
|
||||
draft_response: z.string().optional(),
|
||||
response_mode: z.enum(['inline', 'assistant', 'both']).optional(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { RowboatApiConfig } from './rowboat-account.js';
|
|||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema } from './blocks.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -123,6 +124,15 @@ const ipcSchemas = {
|
|||
req: WorkspaceChangeEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'gmail:getThread': {
|
||||
req: z.object({
|
||||
threadId: z.string().min(1),
|
||||
}),
|
||||
res: z.object({
|
||||
thread: EmailBlockSchema.nullable(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'mcp:listTools': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue