test+chore: 테스트 인프라·E2E 스펙·개발 문서 갱신
- 테스트 헬퍼(_factories·_fake_llm)·conftest 정비, phase18 고급기능 테스트 추가, federation/multiuser/risk/nl_parser/export 갱신, 폐기 시드 테스트 제거 - playwright E2E 스펙 전반 갱신, dev/phase-16-real-mail 문서 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
10fcaec080
commit
6fe8a74e59
@ -0,0 +1,100 @@
|
|||||||
|
# backend/tests/_factories.py — 테스트용 최소 데이터 빌더 (phase-16+: 3개 도메인 시드 제거 후)
|
||||||
|
# 시드가 더 이상 메일/일정/작업 데이터를 깔지 않으므로, 기능 테스트는 필요한 데이터를 직접 만든다.
|
||||||
|
from app.models import (
|
||||||
|
CalEvent,
|
||||||
|
ConnectorAccount,
|
||||||
|
ConnectorDomain,
|
||||||
|
ConnectorMode,
|
||||||
|
ConnState,
|
||||||
|
Email,
|
||||||
|
MailAccount,
|
||||||
|
Meeting,
|
||||||
|
MeetingAction,
|
||||||
|
Prio,
|
||||||
|
Task,
|
||||||
|
TaskComment,
|
||||||
|
TaskStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_task(s, *, id, title="테스트 작업", project_id="me", parent_id=None,
|
||||||
|
status="todo", assignee_id="jiwoo", prio="보통", **kw):
|
||||||
|
t = Task(
|
||||||
|
id=id, title=title, project_id=project_id, parent_id=parent_id,
|
||||||
|
status=TaskStatus(status), assignee_id=assignee_id, prio=Prio(prio), **kw,
|
||||||
|
)
|
||||||
|
s.add(t)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def make_comment(s, *, id, task_id, person_id="minseo", text="확인했습니다"):
|
||||||
|
c = TaskComment(id=id, task_id=task_id, person_id=person_id, text=text)
|
||||||
|
s.add(c)
|
||||||
|
s.commit()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def make_mail_account(s, *, id="work", name="회사", email="jiwoo@gmail.com",
|
||||||
|
tone="blue", kind="Google", connector="gmail", sort_order=0):
|
||||||
|
a = MailAccount(id=id, name=name, email=email, tone=tone, kind=kind,
|
||||||
|
connector=connector, sort_order=sort_order)
|
||||||
|
s.add(a)
|
||||||
|
s.commit()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def make_email(s, *, id, account="work", from_key="hyunwoo", to="나", subject="테스트 메일",
|
||||||
|
time="방금", date="오늘", read=False, starred=False, has_attach=False,
|
||||||
|
labels=None, preview="", body=None, ai_json=None, sort_order=0,
|
||||||
|
received_at=None):
|
||||||
|
e = Email(
|
||||||
|
id=id, account=account, from_key=from_key, to=to, subject=subject, time=time,
|
||||||
|
date=date, read=read, starred=starred, has_attach=has_attach,
|
||||||
|
labels=labels or [], preview=preview, body=body or [], ai_json=ai_json or {},
|
||||||
|
sort_order=sort_order, received_at=received_at,
|
||||||
|
)
|
||||||
|
s.add(e)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(e)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def make_cal_event(s, *, id, day=8, start="10:00", end="11:00", title="테스트 일정",
|
||||||
|
cal="work", loc="", note="", soon=False, people="", sort_order=0):
|
||||||
|
ev = CalEvent(id=id, day=day, start=start, end=end, title=title, cal=cal,
|
||||||
|
loc=loc, note=note, soon=soon, people=people, sort_order=sort_order)
|
||||||
|
s.add(ev)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(ev)
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
def make_meeting(s, *, event_id, phase="upcoming", one_on_one=False):
|
||||||
|
m = Meeting(id=event_id, event_id=event_id, phase=phase, one_on_one=one_on_one)
|
||||||
|
s.add(m)
|
||||||
|
s.commit()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def make_meeting_action(s, *, id, meeting_id=None, event_id=None, idx=0, who="나",
|
||||||
|
text="액션", when_text="이번 주", source="meeting", added=False):
|
||||||
|
a = MeetingAction(id=id, meeting_id=meeting_id, event_id=event_id, idx=idx, who=who,
|
||||||
|
text=text, when_text=when_text, source=source, added=added)
|
||||||
|
s.add(a)
|
||||||
|
s.commit()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def make_connected_mail_account(s, *, id="ca-mail-gmail-test", provider="gmail",
|
||||||
|
email="jiwoo@gmail.com"):
|
||||||
|
"""발송 테스트용: real+connected 메일 ConnectorAccount."""
|
||||||
|
a = ConnectorAccount(
|
||||||
|
id=id, user_id="jiwoo", domain=ConnectorDomain.mail, provider=provider,
|
||||||
|
mode=ConnectorMode.real, state=ConnState.connected,
|
||||||
|
external_account_id=email, name=email, kind="Google",
|
||||||
|
)
|
||||||
|
s.add(a)
|
||||||
|
s.commit()
|
||||||
|
return a
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
# backend/tests/test_phase18_features.py — phase-18 신규 기능
|
||||||
|
# 메일(검색·일괄·라벨·cc) · 일정(반복·알림·RSVP) · 작업(정렬·댓글 수정/삭제) · 반복규칙 변환
|
||||||
|
from app.connectors.calendar.normalize import classify_event
|
||||||
|
from app.connectors.calendar.recurrence import (
|
||||||
|
graph_to_rrule,
|
||||||
|
parse_rrule,
|
||||||
|
rrule_to_google,
|
||||||
|
rrule_to_graph,
|
||||||
|
)
|
||||||
|
from tests._factories import make_comment, make_email, make_mail_account, make_task
|
||||||
|
|
||||||
|
|
||||||
|
# ── 일정 카테고리 분류(공휴일/생일/Gmail) ──
|
||||||
|
def test_classify_google_birthday_and_gmail():
|
||||||
|
assert classify_event("google_calendar", {"eventType": "birthday"}, "축하") == "cat-birthday"
|
||||||
|
assert classify_event("google_calendar", {"eventType": "fromGmail"}, "예약") == "cat-gmail"
|
||||||
|
assert classify_event("google_calendar", {"eventType": "default"}, "주간 회의") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_by_title_heuristic():
|
||||||
|
assert classify_event("google_calendar", {"eventType": "default"}, "유나 생일") == "cat-birthday"
|
||||||
|
assert classify_event("google_calendar", {"eventType": "default"}, "추석 연휴") == "cat-holiday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_outlook_categories():
|
||||||
|
p = {"categories": ["Holiday"]}
|
||||||
|
assert classify_event("outlook_calendar", p, "Christmas") == "cat-holiday"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 반복 규칙(RRULE) 변환: Google/Graph 양방향 ──
|
||||||
|
def test_rrule_to_google_wraps_prefix():
|
||||||
|
assert rrule_to_google("FREQ=WEEKLY;BYDAY=MO") == ["RRULE:FREQ=WEEKLY;BYDAY=MO"]
|
||||||
|
assert rrule_to_google("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rrule_graph_roundtrip_weekly():
|
||||||
|
g = rrule_to_graph("FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2", "2026-06-15")
|
||||||
|
assert g["pattern"]["type"] == "weekly"
|
||||||
|
assert g["pattern"]["interval"] == 2
|
||||||
|
assert set(g["pattern"]["daysOfWeek"]) == {"monday", "wednesday"}
|
||||||
|
back = graph_to_rrule(g)
|
||||||
|
r = parse_rrule(back)
|
||||||
|
assert r["FREQ"] == "WEEKLY" and r["INTERVAL"] == "2"
|
||||||
|
assert set(r["BYDAY"]) == {"MO", "WE"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rrule_to_graph_monthly_uses_day_of_month():
|
||||||
|
g = rrule_to_graph("FREQ=MONTHLY", "2026-06-15")
|
||||||
|
assert g["pattern"]["type"] == "absoluteMonthly"
|
||||||
|
assert g["pattern"]["dayOfMonth"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_rrule_to_graph_count_range():
|
||||||
|
g = rrule_to_graph("FREQ=DAILY;COUNT=5", "2026-06-15")
|
||||||
|
assert g["range"]["type"] == "numbered"
|
||||||
|
assert g["range"]["numberOfOccurrences"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메일 서버측 검색 ──
|
||||||
|
def test_mail_search_filters_by_query(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_mail_account(s, id="work")
|
||||||
|
make_email(s, id="m1", subject="분기 리포트 검토", preview="리포트 초안")
|
||||||
|
make_email(s, id="m2", subject="점심 약속", preview="강남에서 봐요")
|
||||||
|
hits = client.get("/api/mail?q=리포트").json()
|
||||||
|
ids = {m["id"] for m in hits}
|
||||||
|
assert ids == {"m1"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메일 일괄 처리 ──
|
||||||
|
def test_mail_bulk_mark_read(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_mail_account(s, id="work")
|
||||||
|
make_email(s, id="m1", read=False)
|
||||||
|
make_email(s, id="m2", read=False)
|
||||||
|
r = client.post("/api/mail/bulk", json={"ids": ["m1", "m2"], "action": "read"})
|
||||||
|
assert r.status_code == 200 and r.json()["count"] == 2
|
||||||
|
rows = {m["id"]: m for m in client.get("/api/mail").json()}
|
||||||
|
assert rows["m1"]["read"] is True and rows["m2"]["read"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_bulk_trash_removes(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_mail_account(s, id="work")
|
||||||
|
make_email(s, id="m1")
|
||||||
|
client.post("/api/mail/bulk", json={"ids": ["m1"], "action": "trash"})
|
||||||
|
assert client.get("/api/mail/m1").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_bulk_invalid_action_400(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_email(s, id="m1")
|
||||||
|
assert client.post("/api/mail/bulk", json={"ids": ["m1"], "action": "nope"}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메일 라벨 추가/제거 ──
|
||||||
|
def test_mail_labels_add_remove(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_mail_account(s, id="work")
|
||||||
|
make_email(s, id="m1", labels=["기존"])
|
||||||
|
r = client.patch("/api/mail/m1/labels", json={"add": ["중요"], "remove": ["기존"]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["labels"] == ["중요"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메일 cc 직렬화 ──
|
||||||
|
def test_mail_detail_exposes_cc(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_mail_account(s, id="work")
|
||||||
|
make_email(s, id="m1")
|
||||||
|
e = client.get("/api/mail/m1").json()
|
||||||
|
assert "cc" in e and "thread_id" in e
|
||||||
|
|
||||||
|
|
||||||
|
# ── 일정 생성: 반복 + 알림 (로컬) ──
|
||||||
|
def test_create_event_with_recurrence_and_reminders(client):
|
||||||
|
body = {
|
||||||
|
"title": "주간 회의",
|
||||||
|
"date": "2026-06-17",
|
||||||
|
"start": "10:00",
|
||||||
|
"end": "10:30",
|
||||||
|
"rrule": "FREQ=WEEKLY",
|
||||||
|
"reminders": [10, 30],
|
||||||
|
"people": ["a@b.com"],
|
||||||
|
}
|
||||||
|
ev = client.post("/api/calendar/events", json=body).json()
|
||||||
|
assert ev["rrule"] == "FREQ=WEEKLY"
|
||||||
|
assert ev["reminders"] == [10, 30]
|
||||||
|
assert ev["attendees"][0]["email"] == "a@b.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 일정 RSVP (로컬) ──
|
||||||
|
def test_event_rsvp_updates_response(client):
|
||||||
|
body = {"title": "초대받은 회의", "date": "2026-06-17", "start": "14:00", "end": "15:00",
|
||||||
|
"people": ["host@x.com"]}
|
||||||
|
ev = client.post("/api/calendar/events", json=body).json()
|
||||||
|
r = client.post(f"/api/calendar/events/{ev['id']}/rsvp", json={"status": "accepted"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["response_status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_rsvp_invalid_status_400(client):
|
||||||
|
body = {"title": "회의", "date": "2026-06-17", "start": "14:00", "end": "15:00"}
|
||||||
|
ev = client.post("/api/calendar/events", json=body).json()
|
||||||
|
assert client.post(
|
||||||
|
f"/api/calendar/events/{ev['id']}/rsvp", json={"status": "maybe"}
|
||||||
|
).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── 작업 드래그 정렬 ──
|
||||||
|
def test_task_reorder_sets_sort_order(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_task(s, id="t1", project_id="me", sort_order=0)
|
||||||
|
make_task(s, id="t2", project_id="me", sort_order=1)
|
||||||
|
make_task(s, id="t3", project_id="me", sort_order=2)
|
||||||
|
r = client.post("/api/tasks/reorder", json={"ids": ["t3", "t1", "t2"]})
|
||||||
|
assert r.status_code == 200 and r.json()["count"] == 3
|
||||||
|
order = {t["id"]: t["sort_order"] for t in client.get("/api/tasks").json()}
|
||||||
|
assert order["t3"] == 0 and order["t1"] == 1 and order["t2"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── 댓글 수정/삭제 ──
|
||||||
|
def test_comment_edit_sets_edited_at(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_task(s, id="t1", project_id="me")
|
||||||
|
make_comment(s, id="c1", task_id="t1", text="원본")
|
||||||
|
r = client.patch("/api/tasks/t1/comments/c1", json={"text": "수정됨"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["text"] == "수정됨" and r.json()["edited_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_delete(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_task(s, id="t1", project_id="me")
|
||||||
|
make_comment(s, id="c1", task_id="t1")
|
||||||
|
assert client.delete("/api/tasks/t1/comments/c1").status_code == 200
|
||||||
|
node = client.get("/api/tasks/t1").json()
|
||||||
|
assert node["comments"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_edit_wrong_task_404(client, session):
|
||||||
|
s, _ = session
|
||||||
|
make_task(s, id="t1", project_id="me")
|
||||||
|
make_comment(s, id="c1", task_id="t1")
|
||||||
|
assert client.patch("/api/tasks/t9/comments/c1", json={"text": "x"}).status_code == 404
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# backend/tests/test_seed_research_trip.py
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from app.models import (
|
|
||||||
RagChunk,
|
|
||||||
ResearchChart,
|
|
||||||
ResearchCollection,
|
|
||||||
ResearchQA,
|
|
||||||
ResearchReport,
|
|
||||||
ResearchSource,
|
|
||||||
SavedTrip,
|
|
||||||
Trip,
|
|
||||||
TripChecklist,
|
|
||||||
TripDay,
|
|
||||||
TripPlan,
|
|
||||||
TripPrep,
|
|
||||||
TripRoute,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_research_seed_counts(client, session):
|
|
||||||
s, _ = session
|
|
||||||
assert len(s.exec(select(ResearchCollection)).all()) == 3
|
|
||||||
srcs = s.exec(select(ResearchSource)).all()
|
|
||||||
assert len(srcs) == 5 and sum(1 for x in srcs if x.learned) == 4
|
|
||||||
assert len(s.exec(select(ResearchReport)).all()) == 1
|
|
||||||
assert len(s.exec(select(ResearchQA)).all()) == 1
|
|
||||||
assert len(s.exec(select(ResearchChart)).all()) == 1
|
|
||||||
assert len(s.exec(select(RagChunk)).all()) >= 5 # 소스마다 1청크 이상
|
|
||||||
|
|
||||||
|
|
||||||
def test_trip_seed_counts(client, session):
|
|
||||||
s, _ = session
|
|
||||||
assert len(s.exec(select(Trip)).all()) == 1
|
|
||||||
assert len(s.exec(select(TripRoute)).all()) == 2
|
|
||||||
preps = s.exec(select(TripPrep)).all()
|
|
||||||
assert len(preps) == 5 and sum(1 for p in preps if p.state == "doing") == 1
|
|
||||||
assert len(s.exec(select(TripDay)).all()) == 2
|
|
||||||
assert len(s.exec(select(TripChecklist)).all()) == 7
|
|
||||||
assert len(s.exec(select(SavedTrip)).all()) == 3
|
|
||||||
assert len(s.exec(select(TripPlan)).all()) == 2
|
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
# Phase 16 — 실제 이메일 계정 연동 (Gmail + Outlook)
|
||||||
|
|
||||||
|
> mock 시드 메일을 넘어 **내 Gmail·Outlook 계정을 OAuth로 붙여** 받은편지함을 수집하고,
|
||||||
|
> 회신/새 메일을 **결재 승인 시 실제 발송**한다. mock 데모는 1바이트도 바뀌지 않는다
|
||||||
|
> (`CONNECTOR_MAIL=mock` 기본, real은 연결된 계정에만 적용).
|
||||||
|
|
||||||
|
이 문서는 **OAuth 앱 자격증명 발급(Google Cloud · Azure) 가이드**와 동작 확인 절차다.
|
||||||
|
코드는 이미 구현되어 있고, 자격증명만 `backend/.env`에 넣으면 실연동이 켜진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 무엇이 동작하나
|
||||||
|
|
||||||
|
- **Gmail**: Google OAuth(Authorization Code + PKCE) → Gmail REST API로 받은편지함 증분 수집 + 발송.
|
||||||
|
- **Outlook / Microsoft 365**: Microsoft Graph OAuth → `/me/messages` 수집 + `/me/sendMail` 발송.
|
||||||
|
- **Outlook / M365 캘린더**: 같은 Microsoft 앱 등록에 `Calendars.Read` 권한만 더하면
|
||||||
|
`/me/calendarView`로 일정 수집(메일과 **별개 계정** "Outlook 캘린더 추가"로 붙음, 설정 → 일정 탭).
|
||||||
|
- **회사 M365**: 개인 Outlook.com과 **같은 앱 하나**로 처리(테넌트 `common`). 회사 계정은
|
||||||
|
"Outlook 추가"를 한 번 더 눌러 로그인하면 별도 계정으로 붙음. 단 회사가 외부 앱 동의를
|
||||||
|
잠가뒀으면 "관리자 승인 필요"가 떠서 IT에 이 앱(클라이언트 ID) 승인을 요청해야 함.
|
||||||
|
- **멀티계정**: 같은 제공자라도 이메일 주소가 다르면 별도 계정으로 추가(덮어쓰기 없음).
|
||||||
|
- **읽기 + 보내기**: 메일 페이지의 회신/새 메일은 high-risk **결재함**으로 가고, 승인 순간
|
||||||
|
연결된 real 계정이면 진짜 발송, 미연결이면 보낸편지함(mock) 기록.
|
||||||
|
|
||||||
|
> **왜 OAuth인가 (SMTP/IMAP 비밀번호 불가):** Gmail은 2022년 basic-auth(앱 비밀번호 제외)를,
|
||||||
|
> Outlook.com(개인)은 2024-09-16부로 IMAP/POP/SMTP basic-auth를, M365(직장/학교)는 그 이전에
|
||||||
|
> 차단했다. Outlook은 OAuth2가 **유일한** 경로라 둘 다 OAuth로 통일했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Google Cloud — Gmail OAuth 클라이언트 발급
|
||||||
|
|
||||||
|
1. <https://console.cloud.google.com> → 프로젝트 생성(예: `ari-mail`).
|
||||||
|
2. **API 및 서비스 → 라이브러리** → **Gmail API** 사용 설정.
|
||||||
|
3. **OAuth 동의 화면**:
|
||||||
|
- User Type = **외부(External)**, 앱 이름/지원 이메일 입력 후 저장.
|
||||||
|
- **게시 상태 = 테스트(Testing)** 로 두고, **테스트 사용자**에 본인 Gmail 주소 추가.
|
||||||
|
(테스트 모드면 Google 검수 없이 본인 계정으로 바로 사용 가능 — 개인용은 이걸로 충분.)
|
||||||
|
- 스코프는 굳이 추가 안 해도 됨(요청 시 동적으로 동의 화면에 표시됨).
|
||||||
|
4. **사용자 인증 정보 → 사용자 인증 정보 만들기 → OAuth 클라이언트 ID**:
|
||||||
|
- 애플리케이션 유형 = **웹 애플리케이션**.
|
||||||
|
- **승인된 리디렉션 URI** =
|
||||||
|
`http://localhost:31800/api/connectors/oauth/callback`
|
||||||
|
- 만들면 **클라이언트 ID / 클라이언트 보안 비밀** 발급 → 복사.
|
||||||
|
|
||||||
|
요청 스코프(코드가 자동 요청): `gmail.readonly`, `gmail.send`
|
||||||
|
(연결 계정 이메일은 `users/me/profile`로 조회 — 추가 스코프 불필요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Azure Portal — Outlook(Microsoft Graph) 앱 등록
|
||||||
|
|
||||||
|
1. <https://portal.azure.com> → **Microsoft Entra ID → 앱 등록 → 새 등록**.
|
||||||
|
2. **지원되는 계정 유형** = "모든 조직 디렉터리 + 개인 Microsoft 계정"(개인 Outlook.com 포함).
|
||||||
|
- 이 경우 테넌트는 기본 `common`(아래 `.env` `MICROSOFT_TENANT` 기본값).
|
||||||
|
3. **리디렉션 URI** = 플랫폼 **웹**,
|
||||||
|
`http://localhost:31800/api/connectors/oauth/callback`
|
||||||
|
4. **인증서 및 비밀 → 새 클라이언트 비밀** 생성 → **값(Value)** 복사(이때만 보임).
|
||||||
|
5. **API 권한 → 권한 추가 → Microsoft Graph → 위임된 권한**:
|
||||||
|
- 메일: `Mail.Read`, `Mail.Send`, `User.Read`, `offline_access` 추가.
|
||||||
|
- 일정도 쓰려면: **`Calendars.Read`** 추가(이걸 넣으면 설정 → 일정 탭에 "Outlook 캘린더 추가"가 활성).
|
||||||
|
- 개인 계정은 관리자 동의 불필요(동의 화면에서 본인이 동의). **회사 M365**는 테넌트 정책에
|
||||||
|
따라 "관리자 승인 필요"가 뜰 수 있음 → 별도 앱이 아니라 IT에 이 앱 승인을 요청.
|
||||||
|
6. **개요**에서 **애플리케이션(클라이언트) ID** 복사.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. backend/.env 주입
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# 토큰 암호화 키(실연동 필수 — 32바이트 이상 임의 문자열)
|
||||||
|
ARI_SECRET_KEY=<openssl rand -hex 32 등으로 생성한 강한 키>
|
||||||
|
|
||||||
|
# real 메일 연동 ON (phase-16+: 메일은 mock 제거됨 — 연결 전까지 빈 상태)
|
||||||
|
CONNECTOR_MAIL=real
|
||||||
|
|
||||||
|
# Google (Gmail)
|
||||||
|
GOOGLE_CLIENT_ID=<2단계에서 발급>
|
||||||
|
GOOGLE_CLIENT_SECRET=<2단계에서 발급>
|
||||||
|
|
||||||
|
# Microsoft (Outlook)
|
||||||
|
MICROSOFT_CLIENT_ID=<3단계에서 발급>
|
||||||
|
MICROSOFT_CLIENT_SECRET=<3단계의 비밀 '값'>
|
||||||
|
MICROSOFT_TENANT=common
|
||||||
|
|
||||||
|
# google·microsoft 공용 콜백(양쪽 콘솔에 등록한 것과 정확히 일치해야 함)
|
||||||
|
OAUTH_REDIRECT_URI=http://localhost:31800/api/connectors/oauth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
> `ARI_SECRET_KEY`를 바꾸면 기존에 암호화 저장된 토큰은 복호화 불가(재연결 필요).
|
||||||
|
> 프로덕션에선 HTTPS 콜백 URL로 바꾸고 각 콘솔에도 그 URL을 등록한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 동작 확인
|
||||||
|
|
||||||
|
1. 백엔드 기동: `cd backend && uv run uvicorn app.main:app --port 31800`
|
||||||
|
프론트 기동: `cd frontend && pnpm dev` (`:31300`).
|
||||||
|
2. `/settings?tab=mail` → **계정 추가** 버튼 → **Gmail**(또는 Outlook) 선택.
|
||||||
|
- 자격증명이 설정됐으면 항목이 활성화됨(미설정이면 "설정 필요"로 비활성).
|
||||||
|
3. 제공자 동의 화면 → 허용 → 콜백이 `/settings?tab=mail?connect=ok`로 복귀하며 토스트.
|
||||||
|
4. 연결 직후 1회 초기 sync로 받은편지함 일부가 수집됨 → **메일 페이지**에 그 계정 탭으로 노출.
|
||||||
|
5. 다른 이메일로 한 번 더 추가하면 **별도 계정**으로 붙음(멀티계정).
|
||||||
|
6. 메일 회신/새 메일 작성 → **결재함**에 pending → **승인** → 실제 수신함으로 발송 확인.
|
||||||
|
(연결 안 된 계정에서 보내면 보낸편지함 mock 기록만 남고 외부 발송은 안 됨 = 데모 안전.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 구현 노트 (파일 맵)
|
||||||
|
|
||||||
|
| 영역 | 파일 | 변경 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OAuth | `backend/app/connectors/oauth.py` | `microsoft` provider, `_family(outlook→microsoft)`, gmail.send 스코프, `_fetch_identity`(프로필 이메일), 멀티계정 `_ensure_account`+`MailAccount` upsert |
|
||||||
|
| 설정 | `backend/app/config.py` | `microsoft_client_id/secret`, `microsoft_tenant`, `oauth_redirect_uri` |
|
||||||
|
| 커넥터 | `backend/app/connectors/mail/real_outlook.py` (신규), `real_gmail.py`(`send_mail`), `normalize.py`(outlook 분기 + real 계정 매핑), `registry.py`(provider별 Gmail/Outlook 분기) | |
|
||||||
|
| 발송 | `backend/app/models.py`(`OutboundMail`), 마이그레이션 `p17e5f6a7b8c9`, `routers/mail.py`(`send()`), `connectors/mail/outbound.py`(`send_outbound`), `worker/main.py`(approval.executed 구독) | |
|
||||||
|
| 라우터 | `backend/app/routers/connectors.py` | `/connectors/providers`, oauth_start outlook 검증, 콜백 `redirect_after` 반영 |
|
||||||
|
| 프론트 | `frontend/components/connectors/AddAccountMenu.tsx`(신규), `settings/ConnectorList.tsx`·`MailTab.tsx`·`SettingsClient.tsx`(connect 토스트), `lib/connectors/api.ts`(`providers`/`oauthStart`), `lib/types.ts` | |
|
||||||
|
|
||||||
|
**설계 원칙**
|
||||||
|
- `effective_mode`: 시드 mock 계정은 env=real이어도 mock 유지(데모 결정성). real은 **연결된 계정**에만.
|
||||||
|
- 토큰은 `app/crypto.py` Fernet로 암호화 저장(평문 금지), 만료 시 `valid_access_token`이 refresh.
|
||||||
|
- 수집은 `ExternalLink` 기준 멱등 upsert(중복 없음), 발송 실패는 `OutboundMail.status=failed`로 격리.
|
||||||
|
- 발송 훅은 동기 event_bus 구독이라 `WORKER_ENABLED=false`(기본) 데모에서도 승인 즉시 처리.
|
||||||
|
테스트는 lifespan 미가동이라 자동 발송 안 됨 → `send_outbound` 헬퍼를 직접 단위 테스트.
|
||||||
|
|
||||||
|
## 7. 범위 밖(후속)
|
||||||
|
첨부 다운로드/발송, 라벨 양방향 동기화, push/webhook(현재 polling), 프로덕션 OAuth 검수, 멀티유저.
|
||||||
Loading…
Reference in New Issue