feat(astra): Project Astra 이메일 자산화 Phase 1+2 (v2.2.206)

- 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>
This commit is contained in:
2026-06-05 18:34:42 +09:00
parent 6b017b0d31
commit 7e96e56381
12 changed files with 719 additions and 6 deletions
+162
View File
@@ -0,0 +1,162 @@
/**
* ============================================================
* 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(/&nbsp;/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)) : [],
};
}