# backend/tests/test_phase16_mail.py — phase-16 실계정 메일(Gmail/Outlook OAuth + 멀티계정 + 발송) import time import pytest from sqlmodel import select from app.config import get_settings from app.connectors import oauth from app.connectors.base import RawRecord from app.connectors.mail.normalize import mail_account_id_for, normalize_email from app.connectors.mail.outbound import send_outbound from app.connectors.mail.real_outlook import OutlookConnector from app.connectors.registry import ConnectorRegistry, _impl from app.crypto import encrypt_token from app.models import ( ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState, MailAccount, OAuthState, OutboundMail, Sent, ) class _FakeResp: def __init__(self, status, payload=None): self.status_code = status self._payload = payload or {} def json(self): return self._payload def raise_for_status(self): if self.status_code >= 400: raise RuntimeError(f"http {self.status_code}") @pytest.fixture() def oauth_creds(monkeypatch): monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid") monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "g-sec") monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid") monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec") get_settings.cache_clear() yield get_settings.cache_clear() def _mock_token_and_identity(monkeypatch, *, email, token=None): tok = token or {"access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "s"} monkeypatch.setattr(oauth.httpx, "post", lambda *a, **k: _FakeResp(200, tok)) # google: gmail/v1/users/me/profile → emailAddress ; microsoft: graph /me → mail monkeypatch.setattr( oauth.httpx, "get", lambda *a, **k: _FakeResp(200, {"emailAddress": email, "mail": email}), ) # ── OAuth: Outlook provider + tenant URL ── def test_outlook_authorize_url_uses_microsoft_tenant(session, oauth_creds): s, _ = session url = oauth.start_oauth(s, "mail", "outlook", redirect_after="/settings?tab=mail") assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") assert "code_challenge=" in url and "state=" in url # PKCE 보존 assert "Mail.Read" in url and "Mail.Send" in url and "offline_access" in url # ── 멀티계정: 이메일별 고유 계정 id + MailAccount upsert ── def test_finish_oauth_creates_unique_account_per_email(session, oauth_creds, monkeypatch): s, _ = session def connect(provider, email): url = oauth.start_oauth(s, "mail", provider) state = url.split("state=")[1].split("&")[0] _mock_token_and_identity(monkeypatch, email=email) return oauth.finish_oauth(s, code="C", state=state) a1 = connect("gmail", "alice@gmail.com") a2 = connect("gmail", "bob@gmail.com") a3 = connect("outlook", "carol@outlook.com") # 같은 provider 여도 이메일이 다르면 계정 id 가 갈린다(덮어쓰기 없음) assert a1.id != a2.id assert a1.external_account_id == "alice@gmail.com" assert a3.provider == "outlook" and a3.mode == ConnectorMode.real # 메일 페이지 노출용 MailAccount 도 함께 생성 assert s.get(MailAccount, mail_account_id_for("alice@gmail.com")) is not None assert s.get(MailAccount, mail_account_id_for("carol@outlook.com")) is not None # state row 소거 assert s.get(OAuthState, "x") is None # ── normalize: Graph(Outlook) 메시지 → 내부 Email 필드 ── def test_normalize_outlook_golden(session): acct = ConnectorAccount( id="ca-mail-outlook-xyz", domain=ConnectorDomain.mail, provider="outlook", external_account_id="me@outlook.com", ) payload = { "id": "AAMk-1", "subject": "분기 리포트 검토 부탁", "bodyPreview": "안녕하세요, 리포트 초안입니다.", "body": {"contentType": "text", "content": "안녕하세요,\n리포트 초안입니다.\n확인 부탁드려요."}, "from": {"emailAddress": {"name": "현우", "address": "hyunwoo@corp.com"}}, "toRecipients": [{"emailAddress": {"address": "me@outlook.com"}}], "isRead": False, "flag": {"flagStatus": "flagged"}, "hasAttachments": True, "categories": ["업무"], "receivedDateTime": "2026-06-15T09:00:00Z", } raw = RawRecord(external_id="AAMk-1", payload=payload, etag="W/1") norm = normalize_email(acct, raw, provider="outlook") f = norm.fields assert f["account"] == mail_account_id_for("me@outlook.com") # 발신자 표시 이름 보존('이름 <메일>') — 목록은 이름만, 리더는 이름+주소 노출. assert f["from_key"] == "현우 " assert f["subject"] == "분기 리포트 검토 부탁" assert f["read"] is False and f["starred"] is True and f["has_attach"] is True assert "리포트 초안입니다." in " ".join(f["body"]) assert f["labels"] == ["업무"] def test_normalize_outlook_html_body_stripped(session): acct = ConnectorAccount(id="ca-o2", domain=ConnectorDomain.mail, provider="outlook") payload = { "id": "h1", "subject": "공지", "bodyPreview": "프리뷰", "body": {"contentType": "html", "content": "

안녕하세요

좋은 하루

"}, "from": {"emailAddress": {"address": "x@y.com"}}, "isRead": True, } norm = normalize_email(acct, RawRecord(external_id="h1", payload=payload), provider="outlook") # 태그 제거 + 블록(

) 경계마다 줄바꿈 → 읽기 좋은 다중 줄. assert norm.fields["body"] == ["안녕하세요", "좋은 하루"] # ── registry: provider 로 Gmail/Outlook 분기 ── def test_connector_dispatch_by_provider(session): s, _ = session from app.connectors.mail.real_gmail import GmailConnector assert _impl("mail", "real", "outlook").__name__ == "OutlookConnector" assert _impl("mail", "real", "gmail").__name__ == "GmailConnector" out_acct = ConnectorAccount( id="ca-o", domain=ConnectorDomain.mail, provider="outlook", mode=ConnectorMode.real ) gm_acct = ConnectorAccount( id="ca-g", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real ) assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookConnector) assert isinstance(ConnectorRegistry.get(s, gm_acct), GmailConnector) # ── 발송: real 연결 계정 → connector.send_mail 호출 ── def test_send_outbound_real_calls_connector(session, monkeypatch): s, _ = session acct = ConnectorAccount( id="ca-mail-gmail-abc", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com", token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}), ) s.add(acct) ob = OutboundMail( id="ob-1", approval_id="ap-1", connector_account_id=acct.id, mail_account=mail_account_id_for("me@gmail.com"), to="친구 ", subject="안녕", body="잘 지내?", status="pending", ) s.add(ob) s.commit() calls = {} from app.connectors.mail.real_gmail import GmailConnector def fake_send(self, session, *, to, subject, body, cc="", bcc="", attachments=None): calls.update(to=to, subject=subject, body=body) return "sent" monkeypatch.setattr(GmailConnector, "send_mail", fake_send) out = send_outbound(s, ob) assert out.status == "sent" and out.sent_at is not None assert calls == {"to": "친구 ", "subject": "안녕", "body": "잘 지내?"} # ── 발송: 미연결 → failed(가짜 발송 없음, phase-16+) ── def test_send_outbound_unconnected_fails(session): s, _ = session ob = OutboundMail( id="ob-2", approval_id="ap-2", connector_account_id=None, mail_account="work", to="대표님", subject="보고", body="첨부 확인 부탁드립니다.", status="pending", ) s.add(ob) s.commit() out = send_outbound(s, ob) assert out.status == "failed" # 가짜 Sent 행 생성 없음 assert s.exec(select(Sent)).all() == [] # ── 발송 실패는 failed 로 격리(서버 안 죽음) ── def test_send_outbound_failure_isolated(session, monkeypatch): s, _ = session acct = ConnectorAccount( id="ca-fail", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com", token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}), ) s.add(acct) ob = OutboundMail( id="ob-3", approval_id="ap-3", connector_account_id=acct.id, mail_account="x", to="a@b.com", subject="s", body="b", status="pending", ) s.add(ob) s.commit() from app.connectors.mail.real_gmail import GmailConnector def boom(self, session, **k): raise RuntimeError("network down") monkeypatch.setattr(GmailConnector, "send_mail", boom) out = send_outbound(s, ob) assert out.status == "failed" and "network down" in out.error_detail # ── API: /connectors/providers 구성 여부 ── def test_providers_endpoint_unconfigured(client): rows = client.get("/api/connectors/providers?domain=mail").json() by = {r["provider"]: r for r in rows} assert set(by) == {"gmail", "outlook"} assert by["gmail"]["configured"] is False and by["outlook"]["configured"] is False def test_providers_endpoint_configured(client, oauth_creds): rows = client.get("/api/connectors/providers").json() by = {r["provider"]: r for r in rows} assert by["gmail"]["configured"] is True and by["outlook"]["configured"] is True # ── API: /mail/send (연결된 계정) → 결재 + OutboundMail(pending) 생성 ── def test_mail_send_creates_pending_outbound(client, session): s, _ = session acct = ConnectorAccount( id="ca-mail-gmail-x", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com", ) s.add(acct) s.commit() slug = mail_account_id_for("me@gmail.com") r = client.post( "/api/mail/send", json={"from_account": slug, "to": "대표님", "subject": "보고", "body": "확인 부탁"}, ) assert r.status_code == 200 aid = r.json()["approval_id"] s.expire_all() ob = s.exec(select(OutboundMail).where(OutboundMail.approval_id == aid)).first() assert ob is not None and ob.status == "pending" assert ob.connector_account_id == "ca-mail-gmail-x" # 연결된 real 계정에 묶임 assert ob.subject == "보고" def test_mail_send_unconnected_rejected(client, session): # 연결된 계정 없으면 400(가짜 발송 없음) r = client.post( "/api/mail/send", json={"from_account": "work", "to": "대표님", "subject": "보고", "body": "x"}, ) assert r.status_code == 400 # ── 시작 검증: 미구성 outlook 은 400 ── def test_oauth_start_outlook_requires_microsoft_client_id(client): r = client.get("/api/connectors/oauth/start?domain=mail&provider=outlook") assert r.status_code == 400