/** * ============================================================ * Gmail API v1 — 읽기 전용(read-only) 메시지 수집 (Project Astra, Phase 1) * * Calendar/Tasks/Sheets 와 *같은 Google OAuth 토큰*을 공유한다 * (`getFreshAccessToken`). scope 에 `gmail.readonly` 가 포함돼야 함(oauth.ts). * 삭제/답장/전달 등 쓰기 기능은 일절 없다(읽기 전용 — 데이터 무결성·보안). * * 외부 라이브러리 없음 — REST + native fetch. tasksApi.ts 와 동일한 셰이프: * (context, ...) -> getFreshAccessToken -> fetch -> { ok, data } | { ok:false, error } * ============================================================ */ import * as vscode from 'vscode'; import { getFreshAccessToken } from '../calendar/calendarApi'; const API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; export interface ParsedMessage { messageId: string; threadId: string; from: string; to: string; subject: string; /** epoch ms */ date: number; snippet: string; bodyText: string; permalink: string; labels: string[]; } type ApiResult = { ok: true; data: T } | { ok: false; error: string }; function authError(status: number, msg: string): boolean { return status === 401 || status === 403 || /insufficient|scope|disabled|enable/i.test(msg); } const REAUTH_HINT = 'Gmail API 권한 부족 — "Astra: Google Calendar OAuth 연결" 명령을 다시 실행해 gmail.readonly 스코프 동의가 필요합니다. (Google Cloud Console 에서 Gmail API 활성화도 확인)'; /** * 최근 메시지 id 목록을 조회. q 는 Gmail 검색 문법(예: `newer_than:7d`). * maxResults 까지 pageToken 으로 페이지네이션한다. */ export async function listMessageIds( context: vscode.ExtensionContext, opts: { query?: string; maxResults?: number } = {}, ): Promise>> { const tok = await getFreshAccessToken(context); if (!tok.ok) return { ok: false, error: tok.error }; const target = Math.max(1, Math.min(2000, opts.maxResults ?? 200)); const out: Array<{ id: string; threadId: string }> = []; let pageToken: string | undefined; try { while (out.length < target) { const params = new URLSearchParams({ maxResults: String(Math.min(100, target - out.length)) }); if (opts.query) params.set('q', opts.query); if (pageToken) params.set('pageToken', pageToken); const res = await fetch(`${API_BASE}/messages?${params.toString()}`, { headers: { Authorization: `Bearer ${tok.accessToken}` }, signal: AbortSignal.timeout(20000), }); const json: any = await res.json().catch(() => ({})); if (!res.ok) { const msg: string = json?.error?.message || `HTTP ${res.status}`; return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; } const items: any[] = Array.isArray(json.messages) ? json.messages : []; for (const m of items) { if (m?.id) out.push({ id: String(m.id), threadId: String(m.threadId || '') }); } pageToken = json.nextPageToken; if (!pageToken || items.length === 0) break; } return { ok: true, data: out.slice(0, target) }; } catch (e: any) { return { ok: false, error: e?.message || String(e) }; } } /** 단일 메시지 전체(format=full)를 조회해 파싱. */ export async function getMessage( context: vscode.ExtensionContext, id: string, ): Promise> { const tok = await getFreshAccessToken(context); if (!tok.ok) return { ok: false, error: tok.error }; try { const res = await fetch(`${API_BASE}/messages/${encodeURIComponent(id)}?format=full`, { headers: { Authorization: `Bearer ${tok.accessToken}` }, signal: AbortSignal.timeout(20000), }); const json: any = await res.json().catch(() => ({})); if (!res.ok) { const msg: string = json?.error?.message || `HTTP ${res.status}`; return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; } return { ok: true, data: parseMessage(json) }; } catch (e: any) { return { ok: false, error: e?.message || String(e) }; } } // ─── 파싱 헬퍼 ─────────────────────────────────────────────────────────────── function header(headers: any[], name: string): string { const h = (headers || []).find((x) => String(x?.name || '').toLowerCase() === name.toLowerCase()); return h ? String(h.value || '') : ''; } function decodeBase64Url(data: string): string { try { return Buffer.from(String(data).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8'); } catch { return ''; } } /** payload 트리를 순회하며 text/plain 본문을 추출. 없으면 text/html 을 태그 제거해 사용. */ function extractBody(payload: any): string { if (!payload) return ''; const plain = findPart(payload, 'text/plain'); if (plain) return plain; const html = findPart(payload, 'text/html'); if (html) return html.replace(//gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+\n/g, '\n'); return ''; } function findPart(node: any, mime: string): string { if (!node) return ''; if (node.mimeType === mime && node.body?.data) return decodeBase64Url(node.body.data); const parts: any[] = Array.isArray(node.parts) ? node.parts : []; for (const p of parts) { const found = findPart(p, mime); if (found) return found; } return ''; } function parseMessage(msg: any): ParsedMessage { const headers: any[] = msg?.payload?.headers || []; const internal = Number(msg?.internalDate); const dateHeader = Date.parse(header(headers, 'Date')); const date = Number.isFinite(internal) && internal > 0 ? internal : (Number.isFinite(dateHeader) ? dateHeader : Date.now()); return { messageId: String(msg?.id || ''), threadId: String(msg?.threadId || ''), from: header(headers, 'From'), to: header(headers, 'To'), subject: header(headers, 'Subject'), date, snippet: String(msg?.snippet || ''), bodyText: extractBody(msg?.payload).trim(), permalink: `https://mail.google.com/mail/u/0/#all/${String(msg?.id || '')}`, labels: Array.isArray(msg?.labelIds) ? msg.labelIds.map((x: any) => String(x)) : [], }; }