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:
@@ -1,5 +1,14 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.206 (2026-06-05)
|
||||
### 📧 Project Astra — 이메일 자산화 (Phase 1+2)
|
||||
- **Gmail 읽기전용 수집** (`/email-sync [일수]`) — OAuth 에 `gmail.readonly` 스코프 추가(공유 토큰), 본문/메타/스레드를 로컬 인덱스(`{brainPath}/memory/email_index.json`)에 저장. 본문은 로컬을 벗어나지 않으며 합성은 로컬 LLM only(프라이버시).
|
||||
- **RAG 'email' 소스** — 수집된 메일이 기존 검색 파이프라인에 자동 합류, 답변에 **원문 메일 링크** 출처 제공. 기존 grounding(확인불가/citation) 그대로 적용.
|
||||
- **하이브리드 검색** — TF-IDF + 임베딩(설정 시) 블렌드, brain 과 동일 공식.
|
||||
- **미회신 추적** (`/email-status [일수]`) — 스레드의 마지막이 내 답장이 아닌 건을 추출(노이즈 카테고리 제외, 🔔 요청 추정).
|
||||
- **백그라운드 자동 동기화** — `g1nation.email.autoSync` 켜면 주기 수집(설정 변경 시 재시작, unref). 슬래시 명령과 동일 코어(`emailSync.ts`) 공유.
|
||||
- 신규: [features/email/](src/features/email/) (gmailApi·emailStore·emailSync·autoSync·handlers) + retrieval 'email' 소스 통합.
|
||||
|
||||
## v2.2.205 (2026-06-05)
|
||||
### 🧹 백엔드 분리 준비 — Bridge 타깃 토글(로컬/NAS) + /research 제거
|
||||
- **Datacollect Bridge 타깃 설정** 추가 — Astra Settings 패널에서 `로컬/NAS` 전환 + NAS URL/토큰(`x-bridge-token`). 기본 `로컬` = 현행 동작 그대로. ([bridgeClient.ts](src/features/datacollect/bridgeClient.ts) · [settings-panel](media/settings-panel.html) · [settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts))
|
||||
|
||||
+27
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.205",
|
||||
"version": "2.2.206",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -230,6 +230,32 @@
|
||||
"default": "",
|
||||
"markdownDescription": "`/benchmark` 등 Datacollect slash 명령 결과물(markdown)을 저장할 폴더. **비워두면** Bridge 기본 위치(Bridge의 `WIKI_RAW_PATH` 환경변수)에 저장됩니다 — 코드/설정 어디에도 절대경로가 박히지 않습니다. 특정 폴더로 저장하려면 절대경로를 입력하세요. Astra Settings 패널의 'Datacollect' 섹션에서도 편집 가능."
|
||||
},
|
||||
"g1nation.email.syncDays": {
|
||||
"type": "number",
|
||||
"default": 7,
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"markdownDescription": "[Project Astra] `/email-sync` 가 기본으로 수집할 최근 일수. 명령에서 `/email-sync 30` 처럼 그때그때 덮어쓸 수 있습니다. 수집된 메일은 로컬 인덱스(`{brainPath}/memory/email_index.json`)에 저장되어 채팅 답변의 근거로 쓰입니다(읽기 전용)."
|
||||
},
|
||||
"g1nation.email.syncMaxMessages": {
|
||||
"type": "number",
|
||||
"default": 200,
|
||||
"minimum": 1,
|
||||
"maximum": 2000,
|
||||
"markdownDescription": "[Project Astra] `/email-sync` 1회 실행 시 가져올 최대 메일 수. 메일 본문은 로컬을 벗어나지 않으며, 합성은 로컬 LLM 만 사용합니다."
|
||||
},
|
||||
"g1nation.email.autoSync": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "[Project Astra] 켜면 백그라운드에서 주기적으로 Gmail 을 자동 수집합니다(`/email-sync` 와 동일 동작). 끄면 수동 `/email-sync` 만. 기본 off."
|
||||
},
|
||||
"g1nation.email.autoSyncIntervalMinutes": {
|
||||
"type": "number",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 1440,
|
||||
"markdownDescription": "[Project Astra] 자동 동기화 간격(분). `g1nation.email.autoSync` 가 켜져 있을 때만 적용. 최소 5분."
|
||||
},
|
||||
"g1nation.datacollectCrawlDepth": {
|
||||
"type": "number",
|
||||
"default": 1,
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as path from 'path';
|
||||
import './features/teamops/handlers';
|
||||
import './features/system/handlers';
|
||||
import './features/datacollect/handlers';
|
||||
import './features/email/handlers'; // Project Astra — /email-sync
|
||||
import { startEmailAutoSync } from './features/email/autoSync';
|
||||
// axios removed in favor of native fetch
|
||||
import {
|
||||
_getBrainDir,
|
||||
@@ -119,6 +121,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
|
||||
context.subscriptions.push({ dispose: () => lifecycle.dispose() });
|
||||
|
||||
// Project Astra — 이메일 백그라운드 자동 동기화 (g1nation.email.autoSync 켜져 있을 때만).
|
||||
startEmailAutoSync(context);
|
||||
|
||||
// React to engine URL changes — re-target the SDK and reset state.
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
|
||||
@@ -19,12 +19,14 @@ import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
|
||||
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
|
||||
// Calendar/Sheets/Tasks/Gmail 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 모두 동작.
|
||||
// 옛 사용자(이전 스코프로 연결)는 새 기능(Gmail 등) 사용 시 권한 부족 에러 → 재연결 필요.
|
||||
// gmail.readonly 는 읽기 전용 — 삭제/답장/전달 불가(데이터 무결성·보안).
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/tasks',
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'openid',
|
||||
'email',
|
||||
].join(' ');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Email Auto-Sync Scheduler (Project Astra, Phase 2)
|
||||
*
|
||||
* g1nation.email.autoSync 가 켜져 있으면 주기적으로 syncEmails 를 호출한다.
|
||||
* - 기동 직후 곧장 돌리지 않고 60s 뒤 첫 실행(활성화 블로킹 방지) → 이후 간격 반복.
|
||||
* - 매 tick 마다 enabled 를 재확인 → 설정에서 끄면 다음 tick 부터 멈춤.
|
||||
* - 설정(autoSync / 간격) 변경 시 타이머 재시작(onDidChangeConfiguration).
|
||||
* - 동시 실행 가드(running) + timer.unref() 로 프로세스 종료를 막지 않음.
|
||||
*
|
||||
* 슬래시 명령과 *같은* syncEmails 코어를 쓴다 — 수집 동작 단일 출처.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
import { syncEmails } from './emailSync';
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let kickoff: ReturnType<typeof setTimeout> | null = null;
|
||||
let configListener: vscode.Disposable | null = null;
|
||||
let running = false;
|
||||
|
||||
function readSettings() {
|
||||
const c = vscode.workspace.getConfiguration('g1nation');
|
||||
return {
|
||||
enabled: c.get<boolean>('email.autoSync', false),
|
||||
intervalMin: Math.max(5, c.get<number>('email.autoSyncIntervalMinutes', 30) ?? 30),
|
||||
days: c.get<number>('email.syncDays', 7) ?? 7,
|
||||
maxResults: Math.max(1, Math.min(2000, c.get<number>('email.syncMaxMessages', 200) ?? 200)),
|
||||
};
|
||||
}
|
||||
|
||||
async function tick(context: vscode.ExtensionContext): Promise<void> {
|
||||
const s = readSettings();
|
||||
if (!s.enabled || running) return; // 끄면 즉시 중단 / 겹치면 스킵
|
||||
running = true;
|
||||
try {
|
||||
const r = await syncEmails(context, { days: s.days, maxResults: s.maxResults });
|
||||
if (r.ok) logInfo('[email] auto-sync 완료', { added: r.added, total: r.total, embedded: r.embedded, failed: r.failed });
|
||||
else logError('[email] auto-sync 실패', { error: r.error });
|
||||
} catch (e: any) {
|
||||
logError('[email] auto-sync 예외', { error: e?.message || String(e) });
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimers(): void {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (kickoff) { clearTimeout(kickoff); kickoff = null; }
|
||||
}
|
||||
|
||||
function restartTimers(context: vscode.ExtensionContext): void {
|
||||
stopTimers();
|
||||
const s = readSettings();
|
||||
if (!s.enabled) { logInfo('[email] auto-sync 비활성(설정 off)'); return; }
|
||||
kickoff = setTimeout(() => { void tick(context); }, 60_000);
|
||||
timer = setInterval(() => { void tick(context); }, s.intervalMin * 60_000);
|
||||
if (typeof (kickoff as any).unref === 'function') (kickoff as any).unref();
|
||||
if (typeof (timer as any).unref === 'function') (timer as any).unref();
|
||||
logInfo(`[email] auto-sync 활성 — ${s.intervalMin}분 간격`);
|
||||
}
|
||||
|
||||
/** extension activate 에서 1회 호출. 설정 변경 감시 + 타이머 시작. */
|
||||
export function startEmailAutoSync(context: vscode.ExtensionContext): void {
|
||||
if (!configListener) {
|
||||
configListener = vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('g1nation.email.autoSync') || e.affectsConfiguration('g1nation.email.autoSyncIntervalMinutes')) {
|
||||
restartTimers(context);
|
||||
}
|
||||
});
|
||||
context.subscriptions.push(configListener, { dispose: stopTimers });
|
||||
}
|
||||
restartTimers(context);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Email Store — 수집된 이메일의 로컬 영속 저장소 (Project Astra, Phase 1)
|
||||
*
|
||||
* 저장 위치: {brainPath}/memory/email_index.json (LongTermMemory 와 동일 규약)
|
||||
* - 이메일 본문은 로컬을 절대 벗어나지 않는다(프라이버시 불변식). 합성은 로컬 LLM only.
|
||||
* - 각 레코드는 *수집 시점에* 토큰화돼 들어오므로(handlers.ts), 검색 때 재토큰화하지
|
||||
* 않는다 — brainIndex 의 mtime 캐시와 같은 정신.
|
||||
* - 모듈 레벨 캐시(path+mtime 키)로 매 질의마다 전체 파일을 다시 읽지 않는다.
|
||||
* (Phase 2 에서 brainIndex 수준의 증분 인덱스 + 임베딩으로 확장.)
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface EmailRecord {
|
||||
/** Gmail message id (불변 — 중복 제거 키). */
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
/** 수신/발신 시각 epoch ms. */
|
||||
date: number;
|
||||
snippet: string;
|
||||
/** 플레인 텍스트 본문(로컬 저장). */
|
||||
bodyText: string;
|
||||
/** 원문 메일로 점프하는 딥링크 (출처 추적용). */
|
||||
permalink: string;
|
||||
labels: string[];
|
||||
/** 검색용 토큰(본문+제목+발신자) — 수집 시 1회 계산. */
|
||||
tokens: string[];
|
||||
/** 제목 토큰(타이틀 가중치). */
|
||||
subjectTokens: string[];
|
||||
/** (Phase 2) 임베딩 벡터 — 수집 시 1회 계산. 모델 불일치 시 검색에서 무시. */
|
||||
embedding?: number[];
|
||||
/** 임베딩을 만든 모델명(모델 교체 시 무효화 판단). */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
interface EmailStore {
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
records: EmailRecord[];
|
||||
}
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
export function getEmailStorePath(brainPath: string): string {
|
||||
return path.join(brainPath, 'memory', 'email_index.json');
|
||||
}
|
||||
|
||||
// path -> { mtimeMs, records } : 같은 파일이 안 바뀌었으면 디스크 재독 생략.
|
||||
const _cache = new Map<string, { mtimeMs: number; records: EmailRecord[] }>();
|
||||
|
||||
/** 저장된 이메일 레코드 로드. 파일이 없거나 깨졌으면 빈 배열. mtime 캐시 적용. */
|
||||
export function loadEmailRecords(brainPath: string): EmailRecord[] {
|
||||
const filePath = getEmailStorePath(brainPath);
|
||||
let mtimeMs = 0;
|
||||
try { mtimeMs = fs.statSync(filePath).mtimeMs; } catch { return []; }
|
||||
const cached = _cache.get(filePath);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.records;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const store = JSON.parse(raw) as EmailStore;
|
||||
const records = Array.isArray(store?.records) ? store.records : [];
|
||||
_cache.set(filePath, { mtimeMs, records });
|
||||
return records;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 레코드 저장(원자적 쓰기). 디렉터리 자동 생성, 캐시 갱신. */
|
||||
export function saveEmailRecords(brainPath: string, records: EmailRecord[]): void {
|
||||
const filePath = getEmailStorePath(brainPath);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const store: EmailStore = { version: STORE_VERSION, updatedAt: Date.now(), records };
|
||||
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(store), 'utf-8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
try {
|
||||
_cache.set(filePath, { mtimeMs: fs.statSync(filePath).mtimeMs, records });
|
||||
} catch { /* 캐시 갱신 실패는 무해 — 다음 로드가 디스크에서 다시 읽음 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 레코드를 messageId 기준으로 머지(중복은 새 값으로 덮어씀) 후 저장.
|
||||
* 최신순 정렬, 상한(maxRetained) 초과 시 오래된 것부터 제거.
|
||||
* 반환: { total, added }.
|
||||
*/
|
||||
export function upsertEmailRecords(
|
||||
brainPath: string,
|
||||
incoming: EmailRecord[],
|
||||
maxRetained = 50000,
|
||||
): { total: number; added: number } {
|
||||
const existing = loadEmailRecords(brainPath);
|
||||
const byId = new Map<string, EmailRecord>();
|
||||
for (const r of existing) byId.set(r.messageId, r);
|
||||
let added = 0;
|
||||
for (const r of incoming) {
|
||||
if (!byId.has(r.messageId)) added++;
|
||||
byId.set(r.messageId, r);
|
||||
}
|
||||
let merged = Array.from(byId.values()).sort((a, b) => b.date - a.date);
|
||||
if (merged.length > maxRetained) merged = merged.slice(0, maxRetained);
|
||||
saveEmailRecords(brainPath, merged);
|
||||
return { total: merged.length, added };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Email Sync Core — 수집 로직 단일 출처 (Project Astra, Phase 2)
|
||||
*
|
||||
* 슬래시 명령(/email-sync)과 백그라운드 자동 동기화(autoSync.ts)가 *같은* 이 함수를
|
||||
* 호출한다 — 수집 동작이 한 곳에만 있어 유지보수가 쉽다.
|
||||
* onProgress 콜백으로 UI 스트리밍(수동) / 무음(백그라운드)을 모두 지원.
|
||||
*
|
||||
* 프라이버시 불변식: 메일 본문은 로컬 인덱스에만 저장. 합성은 로컬 LLM only.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
import { tokenize } from '../../retrieval/scoring';
|
||||
import { embedTexts } from '../../retrieval/embeddings';
|
||||
import { listMessageIds, getMessage } from './gmailApi';
|
||||
import { upsertEmailRecords, loadEmailRecords, type EmailRecord } from './emailStore';
|
||||
|
||||
/** getMessage 동시 호출 수 — Gmail rate limit 과 속도의 균형. */
|
||||
const FETCH_CONCURRENCY = 6;
|
||||
|
||||
export interface SyncResult {
|
||||
ok: boolean;
|
||||
/** 인덱스 총 메일 수(머지 후). */
|
||||
total: number;
|
||||
/** 신규 추가 수. */
|
||||
added: number;
|
||||
/** 본문 fetch 실패 수. */
|
||||
failed: number;
|
||||
/** 임베딩된 수(0 = 임베딩 비활성/실패). */
|
||||
embedded: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function syncEmails(
|
||||
context: vscode.ExtensionContext,
|
||||
opts: { days: number; maxResults: number; onProgress?: (msg: string) => void },
|
||||
): Promise<SyncResult> {
|
||||
const log = opts.onProgress || (() => { /* silent */ });
|
||||
const base: SyncResult = { ok: false, total: 0, added: 0, failed: 0, embedded: 0 };
|
||||
|
||||
const brainPath = (getConfig().localBrainPath || '').trim();
|
||||
if (!brainPath) return { ...base, error: '활성 브레인 경로가 설정돼 있지 않습니다.' };
|
||||
|
||||
const list = await listMessageIds(context, { query: `newer_than:${opts.days}d`, maxResults: opts.maxResults });
|
||||
if (!list.ok) return { ...base, error: list.error };
|
||||
if (list.data.length === 0) {
|
||||
return { ok: true, total: loadEmailRecords(brainPath).length, added: 0, failed: 0, embedded: 0 };
|
||||
}
|
||||
|
||||
log(`본문 가져오는 중… (${list.data.length}건)`);
|
||||
const records: EmailRecord[] = [];
|
||||
let fetched = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < list.data.length; i += FETCH_CONCURRENCY) {
|
||||
const batch = list.data.slice(i, i + FETCH_CONCURRENCY);
|
||||
const results = await Promise.all(batch.map((m) => getMessage(context, m.id)));
|
||||
for (const r of results) {
|
||||
if (!r.ok) { failed++; continue; }
|
||||
const m = r.data;
|
||||
const searchable = `${m.subject}\n${m.from}\n${m.bodyText || m.snippet}`;
|
||||
records.push({
|
||||
messageId: m.messageId,
|
||||
threadId: m.threadId,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
subject: m.subject,
|
||||
date: m.date,
|
||||
snippet: m.snippet,
|
||||
bodyText: m.bodyText,
|
||||
permalink: m.permalink,
|
||||
labels: m.labels,
|
||||
tokens: tokenize(searchable),
|
||||
subjectTokens: tokenize(m.subject),
|
||||
});
|
||||
}
|
||||
fetched += batch.length;
|
||||
if (fetched % 30 === 0 || fetched >= list.data.length) {
|
||||
log(` · ${Math.min(fetched, list.data.length)}/${list.data.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return { ...base, failed, error: `본문을 하나도 가져오지 못했습니다 (실패 ${failed}건).` };
|
||||
}
|
||||
|
||||
// 임베딩(best-effort) — 모델 설정 시 수집과 동시에 벡터화.
|
||||
let embedded = 0;
|
||||
const acfg = getConfig();
|
||||
if (acfg.embeddingModel) {
|
||||
log(`임베딩 생성 중… (모델: ${acfg.embeddingModel})`);
|
||||
try {
|
||||
const texts = records.map((r) => `${r.subject}\n${r.bodyText || r.snippet}`);
|
||||
const vectors = await embedTexts(texts, { baseUrl: acfg.ollamaUrl, model: acfg.embeddingModel });
|
||||
if (vectors.length === records.length) {
|
||||
records.forEach((r, idx) => { r.embedding = vectors[idx]; r.embeddingModel = acfg.embeddingModel; });
|
||||
embedded = vectors.length;
|
||||
}
|
||||
} catch { /* 키워드 검색으로 graceful fallback */ }
|
||||
}
|
||||
|
||||
const { total, added } = upsertEmailRecords(brainPath, records);
|
||||
return { ok: true, total, added, failed, embedded };
|
||||
}
|
||||
@@ -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(/ /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)) : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Email handlers — /email-sync (Project Astra, Phase 1)
|
||||
*
|
||||
* Gmail 을 읽기 전용으로 수집해 {brainPath}/memory/email_index.json 에 저장한다.
|
||||
* 저장된 메일은 RetrievalOrchestrator 의 'email' 소스로 자동 검색되므로(별도 QA
|
||||
* 명령 불필요), 이후 일반 채팅 질문이 메일 근거+원문 링크로 답하게 된다.
|
||||
*
|
||||
* import 만으로 등록되도록 module scope 에서 registerSlashCommand 호출 — 배럴
|
||||
* (extension.ts)에서 `import './features/email/handlers'` 한 줄 추가.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
|
||||
import { getConfig } from '../../config';
|
||||
import { syncEmails } from './emailSync';
|
||||
import { loadEmailRecords, getEmailStorePath, type EmailRecord } from './emailStore';
|
||||
|
||||
async function runEmailSync(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /email-sync 실행 불가.\n'); return true; }
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const argDays = parseInt(arg.trim().split(/\s+/)[0] || '', 10);
|
||||
const days = Number.isFinite(argDays) && argDays > 0 ? Math.min(365, argDays) : (cfg.get<number>('email.syncDays', 7) ?? 7);
|
||||
const maxResults = Math.max(1, Math.min(2000, cfg.get<number>('email.syncMaxMessages', 200) ?? 200));
|
||||
|
||||
const brainPath = (getConfig().localBrainPath || '').trim();
|
||||
if (!brainPath) {
|
||||
chunk(view, '\n❌ 활성 브레인 경로가 설정돼 있지 않습니다. Astra Settings 에서 Brain 을 먼저 지정하세요.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📧 **이메일 수집 (읽기 전용)** — 최근 ${days}일, 최대 ${maxResults}건\n · 저장 위치: \`${getEmailStorePath(brainPath)}\`\n`);
|
||||
const r = await syncEmails(context, { days, maxResults, onProgress: (m) => chunk(view, ` ${m}\n`) });
|
||||
if (!r.ok) { chunk(view, `\n❌ 수집 실패: ${r.error}\n`); return true; }
|
||||
|
||||
chunk(view, `\n✅ 수집 완료 — 신규 ${r.added}건 / 인덱스 총 ${r.total}건${r.embedded ? ` · 임베딩 ${r.embedded}건` : ''}${r.failed ? ` · 실패 ${r.failed}건` : ''}\n`);
|
||||
chunk(view, '이제 일반 채팅으로 메일 내용을 물어보면 근거 메일과 원문 링크로 답합니다. (예: "A 프로젝트 계약 조건 어떻게 바뀌었지?")\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /email-status — 미회신/놓친 요청 추적 (Scenario 2) ──────────────────────
|
||||
// 수집된 메일을 스레드로 묶어, 마지막 메시지가 '내가 보낸 것(SENT)'이 아닌 스레드를
|
||||
// '미회신'으로 추출한다. 프로모션/소셜/업데이트 카테고리는 노이즈로 제외.
|
||||
|
||||
const NOISE_LABELS = ['CATEGORY_PROMOTIONS', 'CATEGORY_SOCIAL', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS', 'SPAM', 'TRASH'];
|
||||
const REQUEST_HINT = /요청|문의|부탁|검토|회신|답장|확인\s*(?:부탁|요망)|please|could you|kindly|반려|승인|\?\s*$/i;
|
||||
|
||||
async function runEmailStatus(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /email-status 실행 불가.\n'); return true; }
|
||||
const brainPath = (getConfig().localBrainPath || '').trim();
|
||||
if (!brainPath) { chunk(view, '\n❌ 활성 브레인 경로 미설정.\n'); return true; }
|
||||
|
||||
const argDays = parseInt(arg.trim().split(/\s+/)[0] || '', 10);
|
||||
const days = Number.isFinite(argDays) && argDays > 0 ? Math.min(90, argDays) : 7;
|
||||
|
||||
const all = loadEmailRecords(brainPath);
|
||||
if (all.length === 0) {
|
||||
chunk(view, '\nℹ️ 수집된 메일이 없습니다. 먼저 `/email-sync` 를 실행하세요.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const recent = all.filter((r) => r.date >= since);
|
||||
|
||||
// 스레드별 최신 메시지
|
||||
const latestByThread = new Map<string, EmailRecord>();
|
||||
for (const r of recent) {
|
||||
const key = r.threadId || r.messageId;
|
||||
const cur = latestByThread.get(key);
|
||||
if (!cur || r.date > cur.date) latestByThread.set(key, r);
|
||||
}
|
||||
|
||||
const unanswered = Array.from(latestByThread.values())
|
||||
.filter((r) => !r.labels.includes('SENT')) // 내가 마지막에 답하지 않음
|
||||
.filter((r) => !r.labels.some((l) => NOISE_LABELS.includes(l))) // 프로모션/소셜 등 제외
|
||||
.sort((a, b) => a.date - b.date); // 오래된 미회신 먼저
|
||||
|
||||
chunk(view, `\n📭 **미회신 / 놓친 요청 — 최근 ${days}일** (${unanswered.length}건)\n`);
|
||||
if (unanswered.length === 0) {
|
||||
chunk(view, '\n✅ 미회신 스레드가 없습니다. 깔끔하네요.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
const MAX = 20;
|
||||
const today = Date.now();
|
||||
for (const r of unanswered.slice(0, MAX)) {
|
||||
const ageDays = Math.floor((today - r.date) / (24 * 60 * 60 * 1000));
|
||||
const dateStr = new Date(r.date).toISOString().slice(0, 10);
|
||||
const isRequest = REQUEST_HINT.test(r.subject) || REQUEST_HINT.test(r.snippet);
|
||||
const flag = isRequest ? '🔔 ' : '';
|
||||
const fromShort = r.from.replace(/\s*<[^>]+>/, '').trim() || r.from;
|
||||
chunk(view, `- ${flag}**${r.subject || '(제목 없음)'}** — ${fromShort} · ${dateStr} (${ageDays}일 경과)\n ↳ ${r.permalink}\n`);
|
||||
}
|
||||
if (unanswered.length > MAX) chunk(view, `\n_…+${unanswered.length - MAX}건 더 (\`/email-status ${days}\` 범위를 좁혀 보세요)_\n`);
|
||||
chunk(view, '\n🔔 = 회신·검토 요청으로 보이는 메일. 자세한 내용은 채팅으로 물어보세요.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
registerSlashCommand({
|
||||
name: '/email-sync',
|
||||
description: 'Gmail 읽기전용 수집 → 로컬 인덱스 (이후 채팅이 메일 근거로 답변). 사용법: /email-sync [일수]',
|
||||
handler: runEmailSync,
|
||||
});
|
||||
registerSlashCommand({
|
||||
name: '/email-status',
|
||||
description: '미회신/놓친 요청 추적 — 마지막이 내 답장이 아닌 스레드 추출. 사용법: /email-status [일수]',
|
||||
handler: runEmailStatus,
|
||||
});
|
||||
@@ -119,7 +119,8 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
|
||||
'procedural-memory': '📋 Procedural Memory (반복 절차)',
|
||||
'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)',
|
||||
'project-scan': '🔍 Project Scan',
|
||||
'recent-knowledge': '📄 Recent Project Knowledge'
|
||||
'recent-knowledge': '📄 Recent Project Knowledge',
|
||||
'email': '📧 이메일 (수집된 메일 근거 — 원문 링크 포함)'
|
||||
};
|
||||
|
||||
// Group by source
|
||||
|
||||
+96
-1
@@ -26,6 +26,7 @@ import { extractLessonEssence } from './lessonHelpers';
|
||||
import { cosineSimilarity } from './embeddings';
|
||||
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
|
||||
import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel';
|
||||
import { loadEmailRecords } from '../features/email/emailStore';
|
||||
|
||||
export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring';
|
||||
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
@@ -83,6 +84,8 @@ interface RetrievalOptions {
|
||||
embeddingModel?: string;
|
||||
/** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */
|
||||
embeddingBlendAlpha?: number;
|
||||
/** Project Astra — email 소스에서 가져올 최대 메일 수. 기본 6. 0 이면 스킵. */
|
||||
emailLimit?: number;
|
||||
/**
|
||||
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중.
|
||||
* undefined 면 actionability re-rank 안 함 (legacy 동작).
|
||||
@@ -151,6 +154,19 @@ export class RetrievalOrchestrator {
|
||||
allChunks.push(...memoryChunks);
|
||||
fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`);
|
||||
|
||||
// ── ①-b Email Search (Project Astra) — 수집된 메일에서 근거 검색 ──
|
||||
// 인덱스가 비어 있으면(미수집) 즉시 [] 반환하므로 비용 0.
|
||||
const emailChunks = this.searchEmailIndex(
|
||||
expandedTokens,
|
||||
options.brain,
|
||||
options.emailLimit ?? 6,
|
||||
options.queryEmbedding,
|
||||
options.embeddingModel,
|
||||
options.embeddingBlendAlpha,
|
||||
);
|
||||
allChunks.push(...emailChunks);
|
||||
if (emailChunks.length > 0) fusionLog.push(`Email search: ${emailChunks.length} chunks found`);
|
||||
|
||||
// ── ②-b Medium-Term Memory (recent sessions) ──
|
||||
const mediumChunks = this.scoreRecentSessions(
|
||||
expandedTokens,
|
||||
@@ -343,6 +359,84 @@ export class RetrievalOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Email Search (Project Astra) ───
|
||||
|
||||
/**
|
||||
* 수집된 이메일 인덱스({brainPath}/memory/email_index.json)에서 TF-IDF 로 근거 메일을
|
||||
* 찾는다. 브레인 파일 검색과 *동일한* scoreTfIdfPreTokenized 를 써서 일관성 유지.
|
||||
* 각 청크는 messageId/permalink 메타를 실어 답변이 원문 메일로 점프할 수 있게 한다.
|
||||
* Phase 1 은 키워드(TF-IDF)만 — 임베딩 블렌드는 Phase 2.
|
||||
*/
|
||||
private searchEmailIndex(
|
||||
expandedTokens: string[],
|
||||
brain: BrainProfile,
|
||||
limit: number,
|
||||
queryEmbedding?: number[],
|
||||
embeddingModel?: string,
|
||||
embeddingBlendAlpha?: number,
|
||||
): RetrievalChunk[] {
|
||||
if (limit <= 0) return [];
|
||||
try {
|
||||
const records = loadEmailRecords(brain.localBrainPath);
|
||||
if (records.length === 0) return [];
|
||||
const scored = scoreTfIdfPreTokenized(
|
||||
expandedTokens,
|
||||
records.map((r) => ({
|
||||
tokens: r.tokens,
|
||||
titleTokens: r.subjectTokens,
|
||||
lastModified: r.date,
|
||||
conflictCount: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
// (Phase 2) 하이브리드 블렌드 — 브레인 검색과 동일 공식. 같은 모델로 임베딩된
|
||||
// 레코드만 코사인 가산, 없으면 순수 TF-IDF 유지(graceful fallback).
|
||||
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
|
||||
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
|
||||
const maxTfidf = scored.reduce((m, s) => s.score > m ? s.score : m, 0) || 1;
|
||||
for (const s of scored) {
|
||||
const rec = records[s.index];
|
||||
if (!rec.embedding || rec.embeddingModel !== embeddingModel) continue;
|
||||
const cos = cosineSimilarity(queryEmbedding, rec.embedding);
|
||||
const tfidfNorm = s.score / maxTfidf;
|
||||
s.score = (1 - alpha) * tfidfNorm + alpha * Math.max(0, cos);
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
|
||||
const out: RetrievalChunk[] = [];
|
||||
for (const s of ranked) {
|
||||
const r = records[s.index];
|
||||
const dateStr = new Date(r.date).toISOString().slice(0, 10);
|
||||
const title = `메일: "${r.subject || '(제목 없음)'}" (${dateStr}, from ${r.from})`;
|
||||
const body = summarizeText(r.bodyText || r.snippet || '', 700);
|
||||
const content = `${body}${r.permalink ? `\n[원문 링크] ${r.permalink}` : ''}`;
|
||||
out.push({
|
||||
id: `email-${r.messageId}`,
|
||||
source: 'email' as const,
|
||||
title,
|
||||
content,
|
||||
score: s.score,
|
||||
tokenEstimate: estimateTokens(content),
|
||||
metadata: {
|
||||
category: 'email',
|
||||
lastUpdated: r.date,
|
||||
queryCoverage: s.queryCoverage,
|
||||
emailMessageId: r.messageId,
|
||||
emailThreadId: r.threadId,
|
||||
emailFrom: r.from,
|
||||
emailSubject: r.subject,
|
||||
emailDate: r.date,
|
||||
emailPermalink: r.permalink,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory Layer Search ───
|
||||
|
||||
private searchMemoryLayers(
|
||||
@@ -513,7 +607,8 @@ export class RetrievalOrchestrator {
|
||||
'medium-term-memory': 0.78, // recent sessions: useful when the user references "last time / yesterday"
|
||||
'episodic-memory': 0.7,
|
||||
'project-scan': 0.6,
|
||||
'recent-knowledge': 0.75
|
||||
'recent-knowledge': 0.75,
|
||||
'email': 0.9 // 메일은 직접 근거 — 브레인 노트와 동급으로 취급
|
||||
};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
|
||||
+11
-1
@@ -16,7 +16,8 @@ export type RetrievalSource =
|
||||
| 'procedural-memory' // Procedural Memory
|
||||
| 'episodic-memory' // Episodic Memory
|
||||
| 'project-scan' // Local Project Path scan
|
||||
| 'recent-knowledge'; // Recent Project Knowledge record
|
||||
| 'recent-knowledge' // Recent Project Knowledge record
|
||||
| 'email'; // Project Astra — 수집된 Gmail/이메일
|
||||
|
||||
export type ConflictSeverity = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
|
||||
@@ -43,6 +44,15 @@ export interface RetrievalChunk {
|
||||
isLesson?: boolean;
|
||||
/** 'lesson' | 'playbook' | 'qa-finding' when isLesson is true. */
|
||||
lessonKind?: string;
|
||||
|
||||
// --- Email (Project Astra) — 출처 추적: 원문 메일로 점프 ---
|
||||
emailMessageId?: string;
|
||||
emailThreadId?: string;
|
||||
emailFrom?: string;
|
||||
emailSubject?: string;
|
||||
emailDate?: number;
|
||||
/** 원문 메일 딥링크. */
|
||||
emailPermalink?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user