# backend/app/connectors/mail/real_outlook.py — Microsoft Graph 메일 sync 커넥터 (phase-16) # OAuth2 액세스 토큰으로 Graph REST(/me/messages) 증분 호출 + /me/sendMail 발송. import re from datetime import UTC, datetime 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, ) GRAPH = "https://graph.microsoft.com/v1.0" # 잘 알려진 폴더(slug). sent/drafts/outbox 는 메시지를 서버에서 안 가져오고 표시만(또는 숨김). _WELLKNOWN = { "inbox": "inbox", "junkemail": "spam", "deleteditems": "trash", "archive": "archive", "sentitems": "_skip", "drafts": "_skip", "outbox": "_skip", } # 시스템 폴더 표시 메타: slug → (한글명, 아이콘, 정렬). 사이드바 트리 노출용. _SYS_META = { "inbox": ("받은편지함", "inbox", 0), "spam": ("스팸", "alert", 60), "trash": ("휴지통", "trash", 65), "archive": ("보관함", "archive", 45), } # 사이드바에서도 숨길 노이즈 폴더(표시명, 소문자). 로캘 영향 최소화 위해 잘 알려진 것만. _NOISE_NAMES = { "conversation history", "sync issues", "scheduled", "subscription", "rss feeds", "outbox", "drafts", "sent items", } # 계정별 well-known 폴더 id 캐시(id 는 안정적 → 매 sync 재해석 방지). _WK_ID_CACHE: dict[str, dict[str, str]] = {} def _parse_dt(s): if not s: return None try: return datetime.fromisoformat(s.replace("Z", "+00:00")) except ValueError: return None def _addrs(to: str) -> list[dict]: """'이름 , c@d.com' → Graph toRecipients.""" out = [] for part in (to or "").split(","): part = part.strip() if not part: continue m = re.search(r"<([^>]+)>", part) out.append({"emailAddress": {"address": m.group(1) if m else part}}) return out class OutlookConnector(BaseConnector): domain = "mail" entity_type = "email" _rl = RateLimiter(rate=4, per=1.0) # Graph throttling 보수적 제한 def _wellknown_ids(self, c, auth) -> dict[str, str]: """well-known 폴더 id → slug 매핑(계정별 캐시). inbox/spam/trash/archive 식별용.""" key = self.account.external_account_id or self.account.id if key in _WK_ID_CACHE: return _WK_ID_CACHE[key] out: dict[str, str] = {} for wk, slug in _WELLKNOWN.items(): self._rl.acquire() r = c.get(f"{GRAPH}/me/mailFolders/{wk}", headers=auth, params={"$select": "id"}) if r.status_code == 200: out[r.json()["id"]] = slug _WK_ID_CACHE[key] = out return out def _sync_folders(self, session, c, auth): """mailFolders 목록 → AccountFolder 미러 갱신 + 메시지 수집 대상 반환. 반환: [(folder_id, slug, email_folder_name)] — 이번 sync 에서 메시지를 가져올 폴더.""" wk = self._wellknown_ids(c, auth) self._rl.acquire() r = c.get( f"{GRAPH}/me/mailFolders", headers=auth, params={ "$top": 100, "$select": "id,displayName,unreadItemCount,totalItemCount", }, ) self._rl.handle_response(r) r.raise_for_status() macct = mail_account_id_for(self.account.external_account_id) # 집중/기타(focused/other)는 받은편지함 안의 탭(slug others)으로 처리 — 별도 폴더 없음. folders: list[dict] = [folder_dict("starred", "별표", "star", 40)] fetch_ids: list[tuple] = [] # (id, slug, name_for_email) for f in r.json().get("value", []): fid = f["id"] name = f.get("displayName", "") unread = int(f.get("unreadItemCount") or 0) total = int(f.get("totalItemCount") or 0) slug = wk.get(fid) if slug == "_skip" or name.lower() in _NOISE_NAMES: continue if slug in _SYS_META: # 받은편지함/스팸/휴지통/보관함 kn, icon, sort = _SYS_META[slug] folders.append( folder_dict(slug, kn, icon, sort, provider_id=fid, unread=unread, total=total) ) fetch_ids.append((fid, slug, "")) elif slug is None and total > 0: # 사용자 커스텀 폴더(내용 있는 것만) cslug = f"of:{fid}" folders.append( folder_dict(cslug, name, "folder", 10, kind="custom", provider_id=fid, unread=unread, total=total) ) fetch_ids.append((fid, cslug, name)) # 보낸편지함/임시보관함은 OutboundMail(우리 발송) 경로로 표시 — 트리 항목만 추가. folders.append(folder_dict("sent", "보낸편지함", "send", 50)) folders.append(folder_dict("drafts", "임시보관함", "pen", 55)) replace_account_folders(session, macct, folders) return fetch_ids def _reconcile_status(self, session, c, auth, fetch_ids) -> None: """최근 받은편지함/스팸을 $select 최소(본문 제외)로 재조회해 두 가지를 맞춘다: ① 서버에서 읽음 처리된 기존 메일의 read/star 반영, ② 서버 폴더에서 사라진(이동/삭제된) 우리 메일 제거 — 윈도우 범위 내 유령 정리.""" macct = mail_account_id_for(self.account.external_account_id) links = session.exec( select(ExternalLink).where( ExternalLink.account_id == self.account.id, ExternalLink.entity_type == "email", ) ).all() ext_to_link = {lk.external_id: lk for lk in links} eid_to_ext = {lk.entity_id: lk.external_id for lk in links} for fid, slug, _ in fetch_ids: if slug != "inbox": # 스팸은 동기화 대상에서 제외(유저 요청) — inbox 만 재조회. continue self._rl.acquire() r = c.get( f"{GRAPH}/me/mailFolders/{fid}/messages", headers=auth, params={ # 동기화한 메일 전체를 덮도록 넉넉히(본문 제외라 1콜로 가볍다). "$top": 250, "$orderby": "receivedDateTime desc", # inferenceClassification 도 함께 받아 기존 메일의 집중/기타 분류 backfill. "$select": "id,isRead,flag,receivedDateTime,inferenceClassification", }, ) if r.status_code != 200: continue server_ids: set[str] = set() cutoff: datetime | None = None # 받아온 윈도우의 가장 오래된 수신시각 for m in r.json().get("value", []): server_ids.add(m["id"]) rcv = _parse_dt(m.get("receivedDateTime", "")) if rcv and (cutoff is None or rcv < cutoff): cutoff = rcv link = ext_to_link.get(m["id"]) if not link: continue e = session.get(Email, link.entity_id) if not e: continue e.read = bool(m.get("isRead", e.read)) e.starred = (m.get("flag") or {}).get("flagStatus") == "flagged" if slug == "inbox": # 집중/기타 분류 갱신(기존 메일 backfill 포함) e.inference = m.get("inferenceClassification") or e.inference session.add(e) # ② 유령 제거: 이 폴더 우리 메일 중 윈도우 범위(>=cutoff)인데 서버에 없으면 이동/삭제됨. if cutoff is not None: ours = session.exec( select(Email).where(Email.account == macct, Email.folder == slug) ).all() for e in ours: ext = eid_to_ext.get(e.id) rcv = e.received_at if rcv is not None and rcv.tzinfo is None: rcv = rcv.replace(tzinfo=UTC) if ext and ext not in server_ids and rcv is not None and rcv >= cutoff: lk = ext_to_link.get(ext) if lk: session.delete(lk) session.delete(e) session.commit() def fetch(self, session: Session, *, full: bool = False): st = self._state_row(session) token = valid_access_token(session, self.account) # token_expired 면 예외 → sync error cfg = get_settings() auth = {"Authorization": f"Bearer {token}"} headers = { **auth, # 원본 HTML 본문(서식·이미지 보존) — Reader 가 샌드박스 iframe 으로 렌더. "Prefer": 'outlook.body-content-type="html"', } expand = ( "attachments($select=id,name,contentType,size,isInline," "microsoft.graph.fileAttachment/contentId)" ) latest = st.cursor or "" with httpx.Client(timeout=cfg.connector_http_timeout) as c: fetch_ids = self._sync_folders(session, c, auth) # 읽음/별표 상태 동기화(증분 fetch 가 안 건드리는 기존 메일) — 가벼운 재조회. self._reconcile_status(session, c, auth, fetch_ids) for fid, slug, fname in fetch_ids: if slug == "spam": # 스팸은 아예 가져오지 않는다(유저 요청). continue # 받은편지함만 증분(매 sync) — 나머지(휴지통/보관함/커스텀)는 full 때만. light = slug == "inbox" if not light and not full: continue params = { "$top": cfg.sync_page_size, "$orderby": "receivedDateTime desc", "$expand": expand, } if st.cursor and not full and light: params["$filter"] = f"receivedDateTime ge {st.cursor}" self._rl.acquire() r = c.get( f"{GRAPH}/me/mailFolders/{fid}/messages", headers=headers, params=params ) self._rl.handle_response(r) r.raise_for_status() for msg in r.json().get("value", []): rcv = msg.get("receivedDateTime", "") if rcv and rcv > latest: latest = rcv # 폴더 라우팅 정보를 payload 에 주입 → normalize 가 Email.folder 로 사용. msg["_folder"] = slug msg["_folder_name"] = fname yield RawRecord( external_id=msg["id"], payload=msg, etag=str(msg.get("@odata.etag", "")), external_updated_at=_parse_dt(rcv), ) if latest: st.cursor = latest session.add(st) session.commit() def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_email(self.account, raw, provider="outlook") def write(self, session: Session, norm: NormalizedRecord): return upsert_email(session, norm) def event_for(self, norm, entity_id): return "mail.received" def patch_message(self, session: Session, external_id: str, payload: dict) -> None: """읽음(isRead)·플래그(flag) 등 메시지 속성을 Graph 에 반영.""" token = valid_access_token(session, self.account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: self._rl.acquire() r = c.patch( f"{GRAPH}/me/messages/{external_id}", headers={"Authorization": f"Bearer {token}"}, json=payload, ) self._rl.handle_response(r) r.raise_for_status() def move_message(self, session: Session, external_id: str, dest: str) -> None: """메일을 폴더로 이동(dest = 'archive' | 'inbox' | 'deleteditems').""" 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"{GRAPH}/me/messages/{external_id}/move", headers={"Authorization": f"Bearer {token}"}, json={"destinationId": dest}, ) self._rl.handle_response(r) r.raise_for_status() def delete_message(self, session: Session, external_id: str) -> None: """메일 삭제(지운 편지함으로 이동).""" token = valid_access_token(session, self.account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: self._rl.acquire() r = c.delete( f"{GRAPH}/me/messages/{external_id}", headers={"Authorization": f"Bearer {token}"}, ) self._rl.handle_response(r) if r.status_code not in (204, 404): r.raise_for_status() def get_attachment( self, session: Session, external_id: str, attachment_id: str ) -> bytes: """Outlook 첨부 본문(bytes) 다운로드.""" import base64 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"{GRAPH}/me/messages/{external_id}/attachments/{attachment_id}", headers={"Authorization": f"Bearer {token}"}, ) self._rl.handle_response(r) r.raise_for_status() content = r.json().get("contentBytes", "") return base64.b64decode(content) 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() message: dict = { "subject": subject, "body": {"contentType": "Text", "content": body}, "toRecipients": _addrs(to), } if cc: message["ccRecipients"] = _addrs(cc) if bcc: message["bccRecipients"] = _addrs(bcc) if attachments: message["attachments"] = [ { "@odata.type": "#microsoft.graph.fileAttachment", "name": att.get("name", "attachment"), "contentType": att.get("mime") or "application/octet-stream", "contentBytes": att.get("data_b64", ""), } for att in attachments ] payload = {"message": message, "saveToSentItems": True} with httpx.Client(timeout=cfg.connector_http_timeout) as c: self._rl.acquire() r = c.post( f"{GRAPH}/me/sendMail", headers={"Authorization": f"Bearer {token}"}, json=payload, ) self._rl.handle_response(r) r.raise_for_status() return "sent"