You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

374 lines
16 KiB
Python

# 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]:
"""'이름 <a@b.com>, 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"