# backend/app/connectors/mail/real_gmail.py — Gmail API sync 커넥터 (phase-13, phase-16) # OAuth2 액세스 토큰으로 Gmail REST(messages.list + messages.get) 증분 호출 + messages.send 발송. import base64 from datetime import UTC, datetime from email.message import EmailMessage import httpx from sqlmodel import Session, select from ...config import get_settings from ...models import Email, ExternalLink from ..base import BaseConnector, NormalizedRecord, RawRecord from ..oauth import valid_access_token from ..ratelimit import RateLimiter from .normalize import ( folder_dict, mail_account_id_for, normalize_email, replace_account_folders, upsert_email, ) API = "https://gmail.googleapis.com/gmail/v1" # Gmail 카테고리 라벨 → 사이드바 폴더(slug, 한글명, 아이콘, 정렬). _GMAIL_CATEGORIES = [ ("cat:promotions", "프로모션", "ticket", 10), ("cat:social", "소셜", "users", 11), ("cat:updates", "업데이트", "bell", 12), ] def _epoch_to_dt(ms): return datetime.fromtimestamp(int(ms) / 1000, tz=UTC) if ms else None class GmailConnector(BaseConnector): domain = "mail" entity_type = "email" _rl = RateLimiter(rate=4, per=1.0) # Gmail per-user QPS 보수적 제한 def _labels_map(self, c, headers) -> dict[str, str]: """Gmail 사용자 라벨 id → 이름(메시지의 사용자 라벨 해석용).""" self._rl.acquire() r = c.get(f"{API}/users/me/labels", headers=headers) self._rl.handle_response(r) r.raise_for_status() return { L["id"]: L["name"] for L in r.json().get("labels", []) if L.get("type") == "user" } def _sync_folders(self, session, macct: str, label_map: dict[str, str]) -> None: """Gmail 라벨 구조 → AccountFolder 미러(받은편지함/카테고리/별표/라벨/스팸/휴지통 등).""" folders: list[dict] = [folder_dict("inbox", "받은편지함", "inbox", 0)] for slug, name, icon, sort in _GMAIL_CATEGORIES: folders.append(folder_dict(slug, name, icon, sort, kind="category")) folders.append(folder_dict("starred", "별표", "star", 40)) # 사용자 라벨(빈 라벨은 폴더 엔드포인트에서 동기화분 0 이면 숨김). for lid, name in sorted(label_map.items(), key=lambda kv: kv[1].lower()): folders.append( folder_dict(f"label:{name}", name, "hash", 20, kind="label", provider_id=lid) ) folders.append(folder_dict("sent", "보낸편지함", "send", 50)) folders.append(folder_dict("drafts", "임시보관함", "pen", 55)) folders.append(folder_dict("spam", "스팸", "alert", 60)) folders.append(folder_dict("trash", "휴지통", "trash", 65)) replace_account_folders(session, macct, folders) def fetch(self, session: Session, *, full: bool = False): st = self._state_row(session) token = valid_access_token(session, self.account) # token_expired 면 예외 → sync error headers = {"Authorization": f"Bearer {token}"} cfg = get_settings() macct = mail_account_id_for(self.account.external_account_id) with httpx.Client(timeout=cfg.connector_http_timeout) as c: label_map = self._labels_map(c, headers) self._sync_folders(session, macct, label_map) # 증분: history API 로 '변경된' 메시지만 재조회 → 신규+읽음/라벨까지 반영. if not full and st.page_token: changed, deleted, new_hist = self._history_changes(c, headers, st.page_token) if new_hist is not None: self._purge(session, deleted) for mid in changed: rec = self._get_raw(c, headers, mid, label_map) if rec: yield rec st.page_token = new_hist st.cursor = str(int(datetime.now(UTC).timestamp())) session.add(st) session.commit() return # new_hist None = historyId 만료 → 아래 윈도우 폴백으로 재동기화. # full / 최초 / 만료: 받은편지함(+휴지통) 윈도우 수집 후 현재 historyId 캡처. # 스팸은 가져오지 않는다(유저 요청 — 이미 스팸 분류된 건 수집 제외). scopes = [("in:inbox", cfg.sync_max_messages)] if full: scopes.append(("in:trash", cfg.sync_page_size)) for base_q, cap in scopes: if full: q = base_q elif st.cursor: q = f"{base_q} after:{st.cursor}" else: q = f"{base_q} newer_than:7d" for mid in self._list_ids(c, headers, q, cap, cfg): rec = self._get_raw(c, headers, mid, label_map) if rec: yield rec st.page_token = self._current_history_id(c, headers) or st.page_token st.cursor = str(int(datetime.now(UTC).timestamp())) session.add(st) session.commit() def _list_ids(self, c, headers, q, cap, cfg) -> list[str]: """messages.list 를 nextPageToken 따라 cap 까지 페이지네이션해 메시지 id 수집.""" ids: list[str] = [] page_token = None while len(ids) < cap: params = {"maxResults": min(cfg.sync_page_size, cap - len(ids)), "q": q} if page_token: params["pageToken"] = page_token self._rl.acquire() r = c.get(f"{API}/users/me/messages", headers=headers, params=params) self._rl.handle_response(r) r.raise_for_status() body = r.json() ids.extend(m["id"] for m in body.get("messages", [])) page_token = body.get("nextPageToken") if not page_token: break return ids[:cap] def _get_raw(self, c, headers, mid, label_map) -> RawRecord | None: """messages.get(full) → RawRecord. 사용자 라벨 주입. 삭제(404)면 None.""" self._rl.acquire() d = c.get(f"{API}/users/me/messages/{mid}", headers=headers, params={"format": "full"}) if d.status_code == 404: return None self._rl.handle_response(d) d.raise_for_status() msg = d.json() msg["_userLabels"] = [ label_map[lid] for lid in msg.get("labelIds", []) if lid in label_map ] return RawRecord( external_id=msg["id"], payload=msg, etag=str(msg.get("historyId", "")), external_updated_at=_epoch_to_dt(msg.get("internalDate")), ) def _history_changes(self, c, headers, start_id: str): """users.history.list → (변경된 msg id 집합, 삭제된 id 집합, 새 historyId). 404(historyId 만료)면 (빈, 빈, None) 반환 → 호출부가 윈도우 폴백.""" changed: set[str] = set() deleted: set[str] = set() new_hist = start_id page = None while True: params = { "startHistoryId": start_id, "historyTypes": ["messageAdded", "messageDeleted", "labelAdded", "labelRemoved"], "maxResults": 500, } if page: params["pageToken"] = page self._rl.acquire() r = c.get(f"{API}/users/me/history", headers=headers, params=params) if r.status_code == 404: return set(), set(), None self._rl.handle_response(r) r.raise_for_status() body = r.json() for h in body.get("history", []): for item in h.get("messagesDeleted", []): deleted.add(item["message"]["id"]) for key in ("messagesAdded", "labelsAdded", "labelsRemoved"): for item in h.get(key, []): changed.add(item["message"]["id"]) new_hist = body.get("historyId", new_hist) page = body.get("nextPageToken") if not page: break changed -= deleted return changed, deleted, new_hist def _current_history_id(self, c, headers) -> str: """users.getProfile → 현재 historyId(증분 sync 시작점).""" self._rl.acquire() r = c.get(f"{API}/users/me/profile", headers=headers) if r.status_code != 200: return "" return str(r.json().get("historyId", "")) def _purge(self, session: Session, deleted_ids: set[str]) -> None: """서버에서 영구삭제된 메시지를 로컬에서도 제거(Email + 링크).""" for ext in deleted_ids: link = session.exec( select(ExternalLink).where( ExternalLink.account_id == self.account.id, ExternalLink.external_id == ext, ExternalLink.entity_type == "email", ) ).first() if not link: continue e = session.get(Email, link.entity_id) if e: session.delete(e) session.delete(link) if deleted_ids: session.commit() def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_email(self.account, raw, provider="gmail") def write(self, session: Session, norm: NormalizedRecord): return upsert_email(session, norm) def event_for(self, norm, entity_id): return "mail.received" def modify_labels( self, session: Session, external_id: str, *, add: list[str] | None = None, remove: list[str] | None = None, ) -> None: """별표(STARRED)·읽음(UNREAD)·보관(INBOX) 라벨을 Gmail 에 실제 반영.""" token = valid_access_token(session, self.account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: self._rl.acquire() r = c.post( f"{API}/users/me/messages/{external_id}/modify", headers={"Authorization": f"Bearer {token}"}, json={"addLabelIds": add or [], "removeLabelIds": remove or []}, ) self._rl.handle_response(r) r.raise_for_status() def trash(self, session: Session, external_id: str) -> None: """메일을 Gmail 휴지통으로 이동(삭제).""" token = valid_access_token(session, self.account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: self._rl.acquire() r = c.post( f"{API}/users/me/messages/{external_id}/trash", headers={"Authorization": f"Bearer {token}"}, ) self._rl.handle_response(r) if r.status_code != 404: # 이미 없으면 성공으로 간주 r.raise_for_status() def get_attachment( self, session: Session, external_id: str, attachment_id: str ) -> bytes: """Gmail 첨부 본문(bytes) 다운로드.""" token = valid_access_token(session, self.account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: self._rl.acquire() r = c.get( f"{API}/users/me/messages/{external_id}/attachments/{attachment_id}", headers={"Authorization": f"Bearer {token}"}, ) self._rl.handle_response(r) r.raise_for_status() data = r.json().get("data", "") return base64.urlsafe_b64decode(data + "===") def send_mail( self, session: Session, *, to: str, subject: str, body: str, cc: str = "", bcc: str = "", attachments: list[dict] | None = None, ) -> str: token = valid_access_token(session, self.account) cfg = get_settings() msg = EmailMessage() msg["To"] = to if cc: msg["Cc"] = cc if bcc: msg["Bcc"] = bcc msg["Subject"] = subject if self.account.external_account_id: msg["From"] = self.account.external_account_id msg.set_content(body) for att in attachments or []: raw_bytes = base64.b64decode(att.get("data_b64", "")) mime = att.get("mime") or "application/octet-stream" maintype, _, subtype = mime.partition("/") msg.add_attachment( raw_bytes, maintype=maintype or "application", subtype=subtype or "octet-stream", filename=att.get("name", "attachment"), ) raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() with httpx.Client(timeout=cfg.connector_http_timeout) as c: self._rl.acquire() r = c.post( f"{API}/users/me/messages/send", headers={"Authorization": f"Bearer {token}"}, json={"raw": raw}, ) self._rl.handle_response(r) r.raise_for_status() return "sent"