7e96e56381
- Gmail 읽기전용 수집(/email-sync) — gmail.readonly 스코프(공유 토큰),
본문/메타/스레드를 로컬 인덱스에 저장. 본문 로컬 only(프라이버시).
- RAG 'email' 소스 — 검색 파이프라인 자동 합류 + 원문 메일 링크 출처.
- 하이브리드(TF-IDF+임베딩) 검색, brain 과 동일 공식.
- /email-status — 미회신/놓친 요청 추적(스레드 SENT 라벨 휴리스틱).
- 백그라운드 자동 동기화(g1nation.email.autoSync) — 슬래시와 동일 코어 공유.
신규 features/email/{gmailApi,emailStore,emailSync,autoSync,handlers}.ts
+ retrieval 'email' 소스 통합. 타입체크·407 테스트 통과.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
/**
|
|
* ============================================================
|
|
* 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<T> = { 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<ApiResult<Array<{ id: string; threadId: string }>>> {
|
|
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<ApiResult<ParsedMessage>> {
|
|
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(/<style[\s\S]*?<\/style>/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)) : [],
|
|
};
|
|
}
|