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
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"
|