diff --git a/backend/app/routers/mail.py b/backend/app/routers/mail.py index 57c399b..457faf4 100644 --- a/backend/app/routers/mail.py +++ b/backend/app/routers/mail.py @@ -1,32 +1,50 @@ # backend/app/routers/mail.py import uuid +from datetime import UTC, datetime +from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, Query +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlmodel import Session, select from ..auth.deps import current_user from ..connectors.mail import get_mail_connector +from ..connectors.mail.normalize import mail_account_id_for, sender_parts +from ..connectors.registry import ConnectorRegistry from ..db import get_session from ..llm.provider import LLMProvider, get_provider from ..models import ( + AccountFolder, Approval, + AppSetting, + ConnectorAccount, + ConnectorDomain, + ConnectorMode, + ConnState, Email, + ExternalLink, MailAccount, - MailFolder, + OutboundMail, Person, Prio, Project, Task, TaskStatus, + now, ) from ..schemas import ( + AccountFolderOut, + BulkMailRequest, + ConversationOut, EmailDetailOut, EmailRowOut, ExtractAllResponse, ExtractRequest, ExtractResponse, MailAccountOut, - MailFolderOut, + MailLabelRequest, + MailReadRequest, + MailStarRequest, ReplyDraftOut, ReplyDraftRequest, SendRequest, @@ -52,20 +70,197 @@ PROJECT_LABEL_MAP = { } +# ── 표시용 상대 시각(수신 시각 received_at 기준, 응답 시 산출 → 항상 최신) ── +def _ko_hm(dt: datetime) -> str: + """'오전 9:12' / '오후 1:30' 형식(로컬).""" + h = dt.hour + ampm = "오전" if h < 12 else "오후" + h12 = h % 12 or 12 + return f"{ampm} {h12}:{dt.minute:02d}" + + +def _local(dt: datetime) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone() + + +def _list_time(received: datetime | None, fallback: str) -> str: + """목록 행 시각: 오늘→시:분, 어제→'어제', 올해→'M월 D일', 그 외→'YY. M. D.'.""" + if not received: + return fallback + dt = _local(received) + now = datetime.now(UTC).astimezone() + secs = int((now - dt).total_seconds()) + if 0 <= secs < 60: + return "방금" + if 0 <= secs < 3600: + return f"{secs // 60}분 전" + today = now.date() + d = dt.date() + if d == today: + return _ko_hm(dt) + if (today - d).days == 1: + return "어제" + if d.year == today.year: + return f"{dt.month}월 {dt.day}일" + return f"{dt.year % 100:02d}. {dt.month}. {dt.day}." + + +def _detail_date(received: datetime | None, fallback: str) -> str: + """리더 표시 날짜: '오늘 오전 9:12' / '어제 오후 6:30' / '6월 17일 오후 9:12' / '2025년 …'.""" + if not received: + return fallback + dt = _local(received) + now = datetime.now(UTC).astimezone() + today = now.date() + d = dt.date() + hm = _ko_hm(dt) + if d == today: + return f"오늘 {hm}" + if (today - d).days == 1: + return f"어제 {hm}" + if d.year == today.year: + return f"{dt.month}월 {dt.day}일 {hm}" + return f"{dt.year}년 {dt.month}월 {dt.day}일 {hm}" + + +# Gmail CATEGORY_* 정규화 라벨 → 메일 목록 카테고리 탭. 그 외(Outlook 포함)는 기본(집중). +_CATEGORY_BY_LABEL = { + "프로모션": "promotions", + "소셜": "social", + "업데이트": "updates", + "포럼": "updates", +} + + +def _category_for(labels: list[str]) -> str: + for lab in labels or []: + cat = _CATEGORY_BY_LABEL.get(lab) + if cat: + return cat + return "primary" + + +# ── 아리 정리(비서) 기준 시각 ── +# 아리 정리는 '이 시각 이후 도착한 새 메일'만 다룬다(과거 백로그는 제외). +# 기준 시각은 app_setting 에 영속하고, 처음 보는 순간(첫 GET) now() 로 lazy-init 한다. +_TRIAGE_SINCE_KEY = "mail.triage_since" + + +def _as_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) + + +def _get_triage_since(s: Session) -> datetime | None: + row = s.get(AppSetting, _TRIAGE_SINCE_KEY) + if not row or not row.value: + return None + try: + return _as_utc(datetime.fromisoformat(row.value)) + except ValueError: + return None + + +def _set_triage_since(s: Session, dt: datetime) -> datetime: + dt = _as_utc(dt) + row = s.get(AppSetting, _TRIAGE_SINCE_KEY) + if row: + row.value = dt.isoformat() + row.updated_at = now() + s.add(row) + else: + s.add(AppSetting(key=_TRIAGE_SINCE_KEY, value=dt.isoformat())) + s.commit() + return dt + + +def _ensure_triage_since(s: Session) -> datetime: + """기준 시각을 반환 — 없으면 지금(now)으로 초기화·영속(첫 조회 = 기준점).""" + cur = _get_triage_since(s) + return cur if cur is not None else _set_triage_since(s, now()) + + +def _is_new_mail(e: Email, since: datetime) -> bool: + """기준 시각 이후 도착한 메일인가(수신 시각 없으면 과거로 취급).""" + rcv = _as_utc(e.received_at) + return rcv is not None and rcv >= since + + +def _email_in_folder(e: Email, slug: str) -> bool: + """메일이 사이드바 폴더(slug)에 속하는지. 폴더 카운트·목록 필터의 단일 기준. + 집중=Gmail 기본(primary)+Outlook focused 만, 기타=Gmail 프로모션/소셜/업데이트+Outlook other.""" + folder = e.folder or "inbox" + if slug in ("inbox", "smart"): + # 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외) + return ( + folder == "inbox" + and not e.archived + and _category_for(e.labels) == "primary" + and (e.inference or "") != "other" + ) + if slug == "others": + # 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other. + return ( + folder == "inbox" + and not e.archived + and (_category_for(e.labels) != "primary" or (e.inference or "") == "other") + ) + if slug.startswith("cat:"): # Gmail 카테고리(프로모션/소셜/업데이트) — 받은편지함 내 분류 + return folder == "inbox" and not e.archived and _category_for(e.labels) == slug[4:] + if slug == "starred": + return e.starred and folder not in ("trash", "spam") + if slug == "archive": + return e.archived or folder == "archive" + if slug == "spam": + return folder == "spam" + if slug == "trash": + return folder == "trash" + if slug.startswith("label:"): # Gmail 사용자 라벨 + return slug[6:] in (e.labels or []) + if slug.startswith("of:"): # Outlook 커스텀 폴더 + return folder == slug + if slug in ("sent", "drafts"): # 발송/임시는 OutboundMail 경로(Email 아님) + return False + return folder == "inbox" and not e.archived + + +def _email_sort_key(e: Email): + """목록 정렬: 최신 수신순(received_at) → sort_order → created_at.""" + rcv = e.received_at + if rcv is not None and rcv.tzinfo is None: + rcv = rcv.replace(tzinfo=UTC) + return ( + rcv is not None, + rcv or datetime.min.replace(tzinfo=UTC), + -e.sort_order, + e.created_at, + ) + + def _row_out(e: Email) -> EmailRowOut: + # 보낸사람: 표시이름(이름 없으면 도메인 추정) + 실제 메일주소 분리 — 목록은 이름만 노출. + from_name, from_email = sender_parts(e.from_key) return EmailRowOut.model_validate( { "id": e.id, "account": e.account, "from": e.from_key, + "from_name": from_name, + "from_email": from_email, "to": e.to, + "cc": e.cc or "", + "thread_id": e.thread_id or "", "subject": e.subject, - "time": e.time, + "time": _list_time(e.received_at, e.time), "read": e.read, "starred": e.starred, "has_attach": e.has_attach, "labels": e.labels, "preview": e.preview, + "category": _category_for(e.labels), "ai": e.ai_json or None, } ) @@ -73,32 +268,198 @@ def _row_out(e: Email) -> EmailRowOut: @router.get("/mail/accounts", response_model=list[MailAccountOut]) def accounts(s: Session = Depends(get_session)): - return s.exec(select(MailAccount).order_by(MailAccount.sort_order)).all() + accts = s.exec(select(MailAccount).order_by(MailAccount.sort_order)).all() + # 계정별 받은편지함 안읽음 수 — 집중(unread)/기타(others) 각각. 사이드바 배지·기타 탭 카운트. + unread_rows = s.exec(select(Email).where(Email.read == False)).all() # noqa: E712 + counts: dict[str, int] = {} + others: dict[str, int] = {} + for e in unread_rows: + if _email_in_folder(e, "inbox"): + counts[e.account] = counts.get(e.account, 0) + 1 + elif _email_in_folder(e, "others"): + others[e.account] = others.get(e.account, 0) + 1 + return [ + MailAccountOut( + id=a.id, + name=a.name, + email=a.email, + tone=a.tone, + kind=a.kind, + unread=counts.get(a.id, 0), + others=others.get(a.id, 0), + ) + for a in accts + ] + + +@router.get("/mail/folders", response_model=list[AccountFolderOut]) +def folders( + s: Session = Depends(get_session), + user: Person = Depends(current_user), +): + """계정별 실제 폴더/라벨(sync 가 미러링한 AccountFolder) + 동기화분 안읽음/총계. + 빈 사용자 라벨 폴더(동기화된 메일 0)는 숨긴다.""" + rows = s.exec(select(AccountFolder)).all() + emails = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + by_acct: dict[str, list[Email]] = {} + for e in emails: + by_acct.setdefault(e.account, []).append(e) + out: list[AccountFolderOut] = [] + for f in sorted(rows, key=lambda r: (r.mail_account, r.sort_order, r.name)): + acc_emails = by_acct.get(f.mail_account, []) + unread = sum(1 for e in acc_emails if not e.read and _email_in_folder(e, f.slug)) + synced = sum(1 for e in acc_emails if _email_in_folder(e, f.slug)) + if f.kind == "label" and synced == 0: + continue # 동기화된 메일이 없는 사용자 라벨은 숨김 + out.append( + AccountFolderOut( + account=f.mail_account, + slug=f.slug, + name=f.name, + kind=f.kind, + icon=f.icon, + unread=unread, + total=f.total or synced, + sort_order=f.sort_order, + ) + ) + return out -@router.get("/mail/folders", response_model=list[MailFolderOut]) -def folders(s: Session = Depends(get_session)): - return s.exec(select(MailFolder).order_by(MailFolder.sort_order)).all() +@router.post("/mail/sync") +def sync_mail(s: Session = Depends(get_session)): + """연결된 real 메일 계정을 증분 동기화하고 새로 들어온 메일 수를 반환('새 메일 가져오기').""" + accts = s.exec( + select(ConnectorAccount).where( + ConnectorAccount.domain == ConnectorDomain.mail, + ConnectorAccount.mode == ConnectorMode.real, + ConnectorAccount.state == ConnState.connected, + ) + ).all() + upserted = 0 + for a in accts: + upserted += ConnectorRegistry.get(s, a).sync(s).upserted + return {"accounts": len(accts), "upserted": upserted} + + +def _matches_query(e: Email, q: str) -> bool: + """제목/보낸이/받는이/미리보기/본문 전반에 대한 대소문자 무시 부분일치.""" + hay = " ".join( + [e.subject or "", e.from_key or "", e.to or "", e.cc or "", e.preview or ""] + + (e.body or []) + + (e.labels or []) + ).lower() + return all(term in hay for term in q.lower().split()) @router.get("/mail", response_model=list[EmailRowOut]) def list_mail( account: str | None = Query(None), - folder: str = Query("smart"), + folder: str = Query("inbox"), # 폴더 슬러그: inbox/spam/trash/starred/cat:/label:/of: + q: str | None = Query(None), # 서버측 검색어(공백=AND) s: Session = Depends(get_session), user: Person = Depends(current_user), ): - conn = get_mail_connector() - if folder == "starred": - rows = [e for e in conn.list_inbox(s, account) if e.starred] - elif folder == "archive": - rows = s.exec(select(Email).where(Email.archived == True)).all() # noqa: E712 - else: - rows = conn.list_inbox(s, account) - rows = [e for e in rows if e.user_id == user.id] # phase-15 소유자 스코프 + rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + if account: + rows = [e for e in rows if e.account == account] + rows = [e for e in rows if _email_in_folder(e, folder)] + if folder == "smart": + # 아리 정리는 기준 시각 이후 도착한 새 메일만(과거 백로그 제외). + since = _ensure_triage_since(s) + rows = [e for e in rows if _is_new_mail(e, since)] + if q and q.strip(): + rows = [e for e in rows if _matches_query(e, q.strip())] + rows.sort(key=_email_sort_key, reverse=True) return [_row_out(e) for e in rows] +def _outbound_time(dt: datetime | None) -> str: + """보낸/대기 메일 표시용 상대 시각('방금'/'N분 전'/'M월 D일').""" + if not dt: + return "방금" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + delta = datetime.now(UTC) - dt + secs = int(delta.total_seconds()) + if secs < 60: + return "방금" + if secs < 3600: + return f"{secs // 60}분 전" + if secs < 86400: + return f"{secs // 3600}시간 전" + loc = dt.astimezone() + return f"{loc.month}월 {loc.day}일" + + +def _outbound_row_out(ob: OutboundMail, acct: MailAccount | None) -> EmailRowOut: + """OutboundMail(발송/대기) → 목록 행. 보낸편지함이므로 read=True, from=내 계정 이메일.""" + sender = acct.email if acct else ob.mail_account + preview = " ".join((ob.body or "").split())[:140] + when = ob.sent_at or ob.created_at + return EmailRowOut.model_validate( + { + "id": ob.id, + "account": ob.mail_account, + "from": sender, + "from_name": acct.name if acct else sender, + "from_email": sender, + "to": ob.to, + "cc": ob.cc or "", + "subject": ob.subject, + "time": _outbound_time(when), + "read": True, + "starred": False, + "has_attach": bool(ob.attachments), + "labels": [], + "preview": preview, + "ai": None, + } + ) + + +def _outbound_list(s: Session, status: str, account: str | None) -> list[EmailRowOut]: + stmt = select(OutboundMail).where(OutboundMail.status == status) + if account: + stmt = stmt.where(OutboundMail.mail_account == account) + rows = s.exec(stmt).all() + # 최신순: sent_at(없으면 created_at) 기준 내림차순 + rows.sort(key=lambda ob: (ob.sent_at or ob.created_at), reverse=True) + accts = {a.id: a for a in s.exec(select(MailAccount)).all()} + return [_outbound_row_out(ob, accts.get(ob.mail_account)) for ob in rows] + + +@router.get("/mail/sent", response_model=list[EmailRowOut]) +def sent_mail( + account: str | None = Query(None), + s: Session = Depends(get_session), +): + """보낸편지함 — 실제 발송된(status='sent') 메일을 최신순으로.""" + return _outbound_list(s, "sent", account) + + +@router.get("/mail/drafts", response_model=list[EmailRowOut]) +def draft_mail( + account: str | None = Query(None), + s: Session = Depends(get_session), +): + """임시보관함 — 승인 대기(status='pending') 발송 메일을 최신순으로.""" + return _outbound_list(s, "pending", account) + + +def _rewrite_cids(html: str, eid: str, attachments: list) -> str: + """본문 HTML 의 cid: 인라인 이미지 참조를 첨부 스트리밍 URL(inline)로 치환.""" + if not html or "cid:" not in html: + return html + for a in attachments or []: + cid = (a.get("content_id") or "").strip("<>").strip() + if cid and a.get("id"): + html = html.replace( + f"cid:{cid}", f"/api/mail/{eid}/attachments/{a['id']}?inline=1" + ) + return html + + @router.get("/mail/{eid}", response_model=EmailDetailOut) def get_mail(eid: str, s: Session = Depends(get_session)): e = get_mail_connector().get(s, eid) @@ -109,10 +470,286 @@ def get_mail(eid: str, s: Session = Depends(get_session)): s.add(e) s.commit() d = _row_out(e).model_dump(by_alias=True) - d.update({"date": e.date, "body": e.body, "attachments": e.attachments}) + # 인라인(cid) 이미지 칩은 본문에서만 쓰이므로 첨부 목록에서는 숨긴다. + visible_attach = [a for a in (e.attachments or []) if not a.get("inline")] + d.update( + { + "date": _detail_date(e.received_at, e.date), + "body": e.body, + "body_html": _rewrite_cids(e.body_html or "", e.id, e.attachments or []), + "attachments": visible_attach, + } + ) return EmailDetailOut.model_validate(d) +# ── 메일 상태 변경(별표/읽음/보관/삭제) — 연결된 Gmail/Outlook 에 실제 반영 + 로컬 동기화 ── +def _email_link(s: Session, eid: str) -> ExternalLink | None: + return s.exec( + select(ExternalLink).where( + ExternalLink.entity_id == eid, ExternalLink.entity_type == "email" + ) + ).first() + + +def _apply_remote( + s: Session, + eid: str, + *, + star: bool | None = None, + read: bool | None = None, + archived: bool | None = None, + trash: bool = False, +) -> None: + """변경을 연결된 메일 제공자(Gmail/Outlook)에 실제 반영. 매핑/계정 없으면 로컬 전용.""" + link = _email_link(s, eid) + if not link: + return + acct = s.get(ConnectorAccount, link.account_id) + if not acct or acct.state != ConnState.connected: + return + conn = ConnectorRegistry.get(s, acct) + ext = link.external_id + try: + if acct.provider == "gmail": + if trash: + conn.trash(s, ext) + return + add, remove = [], [] + if star is True: + add.append("STARRED") + elif star is False: + remove.append("STARRED") + if read is True: + remove.append("UNREAD") + elif read is False: + add.append("UNREAD") + if archived is True: + remove.append("INBOX") + elif archived is False: + add.append("INBOX") + if add or remove: + conn.modify_labels(s, ext, add=add, remove=remove) + elif acct.provider == "outlook": + if trash: + conn.delete_message(s, ext) + return + patch: dict = {} + if read is not None: + patch["isRead"] = read + if star is not None: + patch["flag"] = {"flagStatus": "flagged" if star else "notFlagged"} + if patch: + conn.patch_message(s, ext, patch) + if archived is True: + conn.move_message(s, ext, "archive") + elif archived is False: + conn.move_message(s, ext, "inbox") + except httpx.HTTPStatusError as e: + # 권한 부족(읽기 전용 토큰 등)·인증 만료 → 500 대신 안내 가능한 4xx 로. + code = e.response.status_code + if code in (401, 403): + raise HTTPException( + 403, + "메일 계정 권한이 부족해요. 설정 → 연동에서 이 계정을 다시 연결하면 " + "삭제·정리(읽음/별표/보관)를 사용할 수 있어요.", + ) from e + raise HTTPException(502, "메일 제공자 요청이 실패했어요. 잠시 후 다시 시도해주세요.") from e + + +@router.patch("/mail/{eid}/star", response_model=EmailRowOut) +def star_mail(eid: str, body: MailStarRequest, s: Session = Depends(get_session)): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + _apply_remote(s, eid, star=body.starred) + e.starred = body.starred + s.add(e) + s.commit() + return _row_out(e) + + +@router.patch("/mail/{eid}/read", response_model=EmailRowOut) +def read_mail(eid: str, body: MailReadRequest, s: Session = Depends(get_session)): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + _apply_remote(s, eid, read=body.read) + e.read = body.read + s.add(e) + s.commit() + return _row_out(e) + + +@router.post("/mail/{eid}/archive", response_model=EmailRowOut) +def archive_mail(eid: str, s: Session = Depends(get_session)): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + _apply_remote(s, eid, archived=True) + e.archived = True + s.add(e) + s.commit() + return _row_out(e) + + +@router.post("/mail/{eid}/unarchive", response_model=EmailRowOut) +def unarchive_mail(eid: str, s: Session = Depends(get_session)): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + _apply_remote(s, eid, archived=False) + e.archived = False + s.add(e) + s.commit() + return _row_out(e) + + +@router.delete("/mail/{eid}") +def delete_mail(eid: str, s: Session = Depends(get_session)): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + _apply_remote(s, eid, trash=True) + link = _email_link(s, eid) + if link: + s.delete(link) + s.delete(e) + s.commit() + return {"ok": True, "id": eid} + + +# ── 라벨/카테고리(폴더 정리) ── +@router.patch("/mail/{eid}/labels", response_model=EmailRowOut) +def label_mail(eid: str, body: MailLabelRequest, s: Session = Depends(get_session)): + """라벨 추가/제거(로컬 태깅). Outlook 은 임의 카테고리라 원격에도 반영.""" + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + labels = list(e.labels or []) + remove = set(body.remove) + for lab in body.add: + if lab and lab not in labels: + labels.append(lab) + labels = [lab for lab in labels if lab not in remove] + e.labels = labels + link = _email_link(s, eid) + if link: + acct = s.get(ConnectorAccount, link.account_id) + if acct and acct.state == ConnState.connected and acct.provider == "outlook": + ConnectorRegistry.get(s, acct).patch_message( + s, link.external_id, {"categories": labels} + ) + s.add(e) + s.commit() + return _row_out(e) + + +# ── 첨부 다운로드(연결된 Gmail/Outlook 에서 실시간 스트리밍) ── +# inline=1 → 본문 HTML 의 인라인 이미지(cid)용. 첨부 다운로드 대신 브라우저 내 표시. +@router.get("/mail/{eid}/attachments/{att_id}") +def download_attachment( + eid: str, att_id: str, inline: int = 0, s: Session = Depends(get_session) +): + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + meta = next((a for a in (e.attachments or []) if a.get("id") == att_id), None) + if not meta: + raise HTTPException(404, "attachment not found") + link = _email_link(s, eid) + if not link: + raise HTTPException(400, "원격 메일 첨부만 내려받을 수 있어요") + acct = s.get(ConnectorAccount, link.account_id) + if not acct or acct.state != ConnState.connected: + raise HTTPException(400, "연결된 메일 계정이 필요해요") + conn = ConnectorRegistry.get(s, acct) + if not hasattr(conn, "get_attachment"): + raise HTTPException(400, "이 계정은 첨부 다운로드를 지원하지 않아요") + data = conn.get_attachment(s, link.external_id, att_id) + name = meta.get("name", "attachment") + disp = "inline" if inline else "attachment" + return Response( + content=data, + media_type=meta.get("mime") or "application/octet-stream", + headers={"Content-Disposition": f"{disp}; filename*=UTF-8''{quote(name)}"}, + ) + + +# ── 대화(스레드) 보기 ── +@router.get("/mail/{eid}/thread", response_model=ConversationOut) +def mail_thread( + eid: str, + s: Session = Depends(get_session), + user: Person = Depends(current_user), +): + """같은 thread_id 메일을 시간순으로 묶어 반환.""" + e = s.get(Email, eid) + if not e: + raise HTTPException(404, "email not found") + if not e.thread_id: + return ConversationOut( + thread_id="", subject=e.subject, count=1, messages=[_row_out(e)] + ) + rows = s.exec( + select(Email).where( + Email.thread_id == e.thread_id, Email.user_id == user.id + ) + ).all() + rows.sort(key=lambda x: x.created_at) + return ConversationOut( + thread_id=e.thread_id, + subject=e.subject, + count=len(rows), + messages=[_row_out(x) for x in rows], + ) + + +# ── 여러 메일 일괄 처리 ── +def _apply_single(s: Session, e: Email, action: str) -> bool: + """한 메일에 액션 적용(원격+로컬). trash 면 삭제하고 False 반환(행 사라짐).""" + if action in ("read", "unread"): + val = action == "read" + _apply_remote(s, e.id, read=val) + e.read = val + elif action in ("star", "unstar"): + val = action == "star" + _apply_remote(s, e.id, star=val) + e.starred = val + elif action in ("archive", "unarchive"): + val = action == "archive" + _apply_remote(s, e.id, archived=val) + e.archived = val + elif action == "trash": + _apply_remote(s, e.id, trash=True) + link = _email_link(s, e.id) + if link: + s.delete(link) + s.delete(e) + return False + s.add(e) + return True + + +_BULK_ACTIONS = {"read", "unread", "archive", "unarchive", "trash", "star", "unstar"} + + +@router.post("/mail/bulk") +def bulk_mail(body: BulkMailRequest, s: Session = Depends(get_session)): + """선택한 여러 메일에 같은 액션(읽음/보관/삭제/별표)을 한 번에 적용.""" + if body.action not in _BULK_ACTIONS: + raise HTTPException(400, f"action must be one of {sorted(_BULK_ACTIONS)}") + n = 0 + for eid in body.ids: + e = s.get(Email, eid) + if not e: + continue + _apply_single(s, e, body.action) + n += 1 + s.commit() + return {"ok": True, "count": n, "action": body.action} + + def _make_task(s: Session, e: Email, t: dict) -> Task: pid = PROJECT_LABEL_MAP.get(t["project"], "me") if not s.get(Project, pid): @@ -173,6 +810,64 @@ def extract_all(eid: str, s: Session = Depends(get_session)): ) +@router.post("/mail/triage/analyze") +def triage_analyze( + limit: int = Query(4, ge=1, le=12), + s: Session = Depends(get_session), + user: Person = Depends(current_user), + provider: LLMProvider = Depends(get_provider), +): + """아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort). + 한 번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다.""" + from ..services.mail_ai import analyze_email + + since = _ensure_triage_since(s) + rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + todo = [ + e + for e in rows + if not e.ai_json + and _email_in_folder(e, "smart") + and _is_new_mail(e, since) # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리) + ] + todo.sort(key=_email_sort_key, reverse=True) + analyzed, errors = 0, 0 + for e in todo[:limit]: + try: + analyze_email(s, e, provider) + analyzed += 1 + except Exception: # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도). + errors += 1 + return {"analyzed": analyzed, "remaining": max(0, len(todo) - analyzed), "errors": errors} + + +def _triage_new_count(s: Session, user: Person, since: datetime) -> int: + """기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관). + 사용자가 폰/다른 앱에서 먼저 읽어도 '정리할 새 메일'로 센다(서버 read 가 곧 True 가 되므로).""" + rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] + return sum(1 for e in rows if _email_in_folder(e, "smart") and _is_new_mail(e, since)) + + +@router.get("/mail/triage/baseline") +def triage_baseline_get( + s: Session = Depends(get_session), + user: Person = Depends(current_user), +): + """아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화.""" + since = _ensure_triage_since(s) + return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} + + +@router.post("/mail/triage/baseline") +def triage_baseline_reset( + s: Session = Depends(get_session), + user: Person = Depends(current_user), +): + """기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리.""" + since = _set_triage_since(s, now()) + return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)} + + @router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut) def reply_draft( eid: str, @@ -186,10 +881,6 @@ def reply_draft( p = s.get(Person, e.from_key) to = f"{p.name} <{p.email}>" if p and getattr(p, "email", "") else (p.name if p else e.from_key) ai = e.ai_json or {} - _fallback = ( - "안녕하세요,\n\n메일 잘 받았습니다. 확인하고 빠른 시일 내 회신드리겠습니다." - "\n\n감사합니다.\n지우 드림" - ) if body.reply_index is not None and ai.get("replies"): draft = ai["replies"][body.reply_index]["body"] else: @@ -197,20 +888,31 @@ def reply_draft( "이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n" f"{e.subject}\n{' '.join(e.body or [])}" ) - try: - draft = provider.generate_json( - prompt, {"type": "object", "properties": {"body": {"type": "string"}}} - ).get("body", "") - if not draft: - raise ValueError("empty") - except Exception: - draft = _fallback + # 실 LLM 전용(휴리스틱 템플릿 폴백 제거) — 미가용 시 예외 전파. + draft = provider.generate_json( + prompt, {"type": "object", "properties": {"body": {"type": "string"}}} + ).get("body", "") return ReplyDraftOut(to=to, from_account=e.account, subject="Re: " + e.subject, body=draft) +def _connector_for_mail_account(s: Session, mail_account: str) -> ConnectorAccount | None: + """발신 MailAccount id → 연결된 real ConnectorAccount(없으면 None=mock 발송).""" + rows = s.exec( + select(ConnectorAccount).where(ConnectorAccount.domain == ConnectorDomain.mail) + ).all() + for a in rows: + if a.external_account_id and mail_account_id_for(a.external_account_id) == mail_account: + return a + return None + + @router.post("/mail/send", response_model=SendResponse) def send(body: SendRequest, s: Session = Depends(get_session)): - """회신/새 메일 보내기 = high-risk → 결재함(phase-7). 실제 발송은 승인 후.""" + """회신/새 메일 보내기 = high-risk → 결재함. 승인 시 연결된 real 계정으로 실제 발송(phase-16+). + 연결된 계정이 없으면 거부(가짜 발송 없음).""" + conn_acct = _connector_for_mail_account(s, body.from_account) + if not conn_acct or conn_acct.state != ConnState.connected: + raise HTTPException(400, "연결된 메일 계정이 필요해요 — 계정을 먼저 연결하세요") aid = "ap-" + uuid.uuid4().hex[:8] appr = Approval( id=aid, @@ -227,6 +929,21 @@ def send(body: SendRequest, s: Session = Depends(get_session)): undo_label="", ) s.add(appr) + s.add( + OutboundMail( + id="ob-" + uuid.uuid4().hex[:8], + approval_id=aid, + connector_account_id=conn_acct.id, + mail_account=body.from_account, + to=body.to, + cc=body.cc or "", + bcc=body.bcc or "", + subject=body.subject, + body=body.body, + attachments=[att.model_dump() for att in body.attachments], + status="pending", + ) + ) s.commit() return SendResponse( approval_id=aid, status="pending", message="보낼 준비가 됐어요 — 결재함에서 확인하세요" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bfaa079..88b1375 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -75,6 +75,11 @@ class CommentOut(BaseModel): person_id: str text: str created_at: datetime + edited_at: Optional[datetime] = None + + +class CommentPatch(BaseModel): + text: str class TaskNode(BaseModel): @@ -127,6 +132,10 @@ class CommentCreate(BaseModel): text: str +class TaskReorderRequest(BaseModel): + ids: list[str] # 같은 컬럼 내 새 순서대로 정렬된 task id 목록 + + # ---------- scaffold ---------- class ScaffoldItem(BaseModel): title: str @@ -454,9 +463,16 @@ class EventActionOut(BaseModel): when: str +class AttendeeOut(BaseModel): + email: str + name: str = "" + status: str = "" # accepted|declined|tentative|needsAction + + class CalEventOut(BaseModel): id: str day: int + date: str = "" # 실제 날짜 ISO "YYYY-MM-DD" start: str end: str title: str @@ -465,14 +481,38 @@ class CalEventOut(BaseModel): note: str = "" soon: bool = False people: list[str] = [] + rrule: str = "" # 반복 규칙(iCal RRULE) + reminders: list[int] = [] # 시작 N분 전 알림 + attendees: list[AttendeeOut] = [] + response_status: str = "" # 내 RSVP actions: list[EventActionOut] = [] has_meeting: bool = False meet_label: str = "" +class EventWriteRequest(BaseModel): + """새 일정 생성/수정 요청. start 빈값 = 종일.""" + + title: str + date: str # ISO "YYYY-MM-DD" + start: str = "" # "HH:MM" + end: str = "" # "HH:MM" + loc: str = "" + note: str = "" + people: list[str] = [] # 참석자 이메일 + cal: str = "" # 대상 캘린더 id(빈값=기본 연결 캘린더 또는 로컬) + rrule: str = "" # 반복 규칙(빈값=단일). 예 "FREQ=WEEKLY;BYDAY=MO" + reminders: list[int] = [] # 시작 N분 전 알림(분 단위) + + +class EventRsvpRequest(BaseModel): + status: str # accepted | declined | tentative + + class FocusBlockOut(BaseModel): id: str day: int + date: str = "" # 실제 날짜 ISO(데모는 6월로 매핑) start: str end: str title: str @@ -516,12 +556,15 @@ class MeetingOut(BaseModel): class DayBundleOut(BaseModel): day: int + date: str = "" events: list[CalEventOut] focus_blocks: list[FocusBlockOut] class WeekBundleOut(BaseModel): today: int + today_date: str = "" # 실제 오늘 ISO(real 연결 시 실날짜, 아니면 데모 2026-06-08) + mode: str = "demo" # "real" | "demo" week: list[int] weekdays: list[str] calendars: list[CalendarOut] @@ -559,7 +602,8 @@ class MailAccountOut(BaseModel): email: str tone: str kind: str - unread: int + unread: int # 집중(focused) 받은편지함 안읽음 + others: int = 0 # 기타(Outlook other) 받은편지함 안읽음 class MailFolderOut(BaseModel): @@ -568,6 +612,19 @@ class MailFolderOut(BaseModel): icon: str +class AccountFolderOut(BaseModel): + """계정별 실제 폴더/라벨(사이드바 트리). slug 가 list_mail folder 필터 키.""" + + account: str + slug: str + name: str + kind: str # system | category | label | custom + icon: str + unread: int = 0 + total: int = 0 + sort_order: int = 0 + + class AiTaskOut(BaseModel): text: str project: str @@ -605,12 +662,23 @@ class AiAnalysisOut(BaseModel): file: Optional[AiFileOut] = None +class MailAttachmentOut(BaseModel): + id: str = "" # 프로바이더 attachment id(다운로드용) + name: str + mime: str = "" + size: int = 0 + + class EmailRowOut(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str account: str from_: str = Field(alias="from") + from_name: str = "" # 표시 이름(이름 없으면 도메인 추정) — 목록/리더에서 노출 + from_email: str = "" # 실제 메일 주소(리더 보조 표시) to: str + cc: str = "" + thread_id: str = "" subject: str time: str read: bool @@ -618,13 +686,24 @@ class EmailRowOut(BaseModel): has_attach: bool labels: list[str] = [] preview: str + category: str = "primary" # 카테고리 탭(primary/promotions/social/updates) — 라벨에서 산출 ai: Optional[AiAnalysisOut] = None class EmailDetailOut(EmailRowOut): date: str body: list[str] = [] - attachments: list[dict] = [] + body_html: str = "" # 원본 HTML 본문(샌드박스 iframe 렌더용; cid 는 첨부 URL 로 치환됨) + attachments: list[MailAttachmentOut] = [] + + +class ConversationOut(BaseModel): + """대화(스레드) 묶음 — 같은 thread_id 메일들.""" + + thread_id: str + subject: str + count: int + messages: list[EmailRowOut] = [] class ExtractRequest(BaseModel): @@ -659,12 +738,21 @@ class ReplyDraftOut(BaseModel): body: str +class ComposeAttachment(BaseModel): + name: str + mime: str = "application/octet-stream" + data_b64: str # base64 인코딩 본문 + + class SendRequest(BaseModel): from_account: str to: str + cc: str = "" + bcc: str = "" subject: str body: str in_reply_to: Optional[str] = None + attachments: list[ComposeAttachment] = [] class SendResponse(BaseModel): @@ -673,6 +761,26 @@ class SendResponse(BaseModel): message: str +class MailStarRequest(BaseModel): + starred: bool + + +class MailReadRequest(BaseModel): + read: bool + + +class MailLabelRequest(BaseModel): + """라벨/카테고리 추가·제거(폴더 이동 포함).""" + + add: list[str] = [] + remove: list[str] = [] + + +class BulkMailRequest(BaseModel): + ids: list[str] + action: str # read | unread | archive | unarchive | trash | star | unstar + + # ---------- 알림 ---------- class NotificationOut(BaseModel): id: str @@ -740,327 +848,6 @@ class SenderRuleCreate(BaseModel): ExtractResponse.model_rebuild() -# ==================== phase-10: 리서치 + 여행 ==================== - - -class CollectionOut(BaseModel): - id: str - name: str - tone: str - n: int - - -class SourceOut(BaseModel): - model_config = ConfigDict(populate_by_name=True) - id: str - kind: str - title: str - from_: str = Field(default="", serialization_alias="from") - col: Optional[str] = None - learned: bool = False - - -class ResearchHomeOut(BaseModel): - collections: list[CollectionOut] = [] - sources: list[SourceOut] = [] - prompts: list[str] = [] - entries: list[dict] = [] - - -class ReportOut(BaseModel): - id: str - title: str - asked: str = "" - meta: str = "" - counts: list[dict] = [] - synthesis: str = "" - cross: list[dict] = [] - note: str = "" - - -class QAOut(BaseModel): - id: str = "" - q: str = "" - a: str = "" - refs: list[dict] = [] - - -class ChartOut(BaseModel): - id: str - title: str - asked: str = "" - unit: str = "" - bars: list[dict] = [] - insight: str = "" - caution: str = "" - - -class StartResearchRequest(BaseModel): - query: str - - -class StartResearchOut(BaseModel): - status: str # ready | queued - report_id: Optional[str] = None - report: Optional[ReportOut] = None - queued_text: Optional[str] = None - - -class AskRequest(BaseModel): - q: str - collection_id: Optional[str] = None - - -class AskOut(BaseModel): - a: str - refs: list[dict] = [] - grounded: bool = True - model: str = "" - - -# ---------- 여행 ---------- -class PlanRequest(BaseModel): - text: str - - -class PlanResultOut(BaseModel): - id: str - idx: int - custom: bool = False - title: str - meta: str = "" - summary: str = "" - weather: str = "" - transport: list[dict] = [] - stay: list[dict] = [] - days: list[dict] = [] - budget: dict = {} - checklist: list[str] = [] - sources: list[str] = [] - research: Optional[list[dict]] = None - - -class WatchToggleRequest(BaseModel): - watch: Optional[bool] = None - - -class SavedTripOut(BaseModel): - id: str - title: str - tag: str = "" - note: str = "" - now: str = "" - delta: str = "" - down: bool = True - watch: bool = True - hint: str = "" - spark: list[int] = [] - - -class UpcomingTripOut(BaseModel): - trip: dict - route: dict - stay: dict - prep: list[dict] = [] - days: list[dict] = [] - check: list[dict] = [] - expense: dict - - -# ==================== phase-11: 라이프 케어 ==================== -from .models import ConnectorDomain, InsightKind, KnowledgeType # noqa: E402 - - -class ConnectorSourceOut(BaseModel): - id: str - domain: ConnectorDomain - name: str - kind: str - tone: str - on: bool - last: str - - -class RingOut(BaseModel): - label: str - val: str - unit: str - goal: str - pct: int - icon: str - tone: str - - -class VitalOut(BaseModel): - label: str - val: str - unit: str - trend: str - note: str - tone: str - - -class SleepStageOut(BaseModel): - label: str - pct: int - tone: str - - -class SleepOut(BaseModel): - total: str - score: int - stages: list[SleepStageOut] - week: list[int] - note: str - - -class HabitOut(BaseModel): - id: str - title: str - goal: str - done: int - total: int - streak: int - icon: str - tone: str - at: str - automation_rule_id: Optional[str] = None - - -class HealthOut(BaseModel): - rings: list[RingOut] - vitals: list[VitalOut] - sleep: SleepOut - coach: str - habits: list[HabitOut] - - -class BudgetOut(BaseModel): - spent: int - limit: int - pct: int - delta_pct: int - days_left: int - - -class CategoryOut(BaseModel): - name: str - amt: int - pct: int - icon: str - tone: str - over: bool - - -class SubOut(BaseModel): - id: str - name: str - date: str - in_days: int - amt: int - icon: str - tone: str - note: str - paused: bool - - -class InsightOut(BaseModel): - id: str - kind: InsightKind - icon: str - title: str - body: str - action: str - action_ref: str - - -class FinanceOut(BaseModel): - budget: BudgetOut - cats: list[CategoryOut] - subs: list[SubOut] - coach: str - insights: list[InsightOut] - - -class KnowledgeCollectionOut(BaseModel): - name: str - count: int - tone: str - - -class KnowledgeItemOut(BaseModel): - id: str - type: KnowledgeType - title: str - src: str - time: str - tone: str - icon: str - tags: list[str] - excerpt: str - ai: str - - -class KnowledgeStatsOut(BaseModel): - items: int - this_week: int - collections: int - - -class KnowledgeOut(BaseModel): - stats: KnowledgeStatsOut - suggested: list[str] - collections: list[KnowledgeCollectionOut] - items: list[KnowledgeItemOut] - - -class LifeOverviewOut(BaseModel): - sources: dict[str, list[ConnectorSourceOut]] - health: HealthOut - finance: FinanceOut - knowledge: KnowledgeOut - - -class HabitTickResponse(BaseModel): - habit: HabitOut - - -class SubPauseRequest(BaseModel): - paused: bool - - -class SubPauseResponse(BaseModel): - sub: SubOut - approval_id: Optional[str] = None - - -class InsightActResponse(BaseModel): - approval_id: Optional[str] = None - task_id: Optional[str] = None - message: str - - -class KnowledgeAskRequest(BaseModel): - q: str - - -class KnowledgeAskResponse(BaseModel): - q: str - body: str - sources: list[str] - - -class KnowledgeSuggestActRequest(BaseModel): - item_id: str - target: str - - -class KnowledgeSuggestActResponse(BaseModel): - task_id: Optional[str] = None - event_id: Optional[str] = None - message: str - - # ==================== phase-12: 여정 + 하루 마감 ==================== @@ -1194,6 +981,7 @@ class ConnectorStatusOut(BaseModel): last: str # last_label last_synced_at: Optional[datetime] = None error_detail: str = "" + email: str = "" # phase-16 연결된 실계정 주소(external_account_id) @classmethod def from_account(cls, a, env_mode: str) -> ConnectorStatusOut: @@ -1214,6 +1002,7 @@ class ConnectorStatusOut(BaseModel): last=a.last_label, last_synced_at=a.last_synced_at, error_detail=a.error_detail, + email=getattr(a, "external_account_id", "") or "", ) @@ -1221,6 +1010,15 @@ class OAuthStartOut(BaseModel): authorize_url: str +class ConnectorProviderOut(BaseModel): + """phase-16 '계정 추가' 버튼이 보여줄 OAuth provider 목록 + 구성 여부.""" + + domain: str # mail | calendar | knowledge ... + provider: str # gmail | outlook | google_calendar | notion + label: str # "Gmail" / "Outlook" + configured: bool # client_id 설정되어 real 연동 가능 + + class SyncResultOut(BaseModel): domain: str account_id: str @@ -1240,40 +1038,7 @@ class ImportResultOut(BaseModel): detail: str = "" -# ==================== phase-14: 능동 에이전트 + 멀티모달 ==================== -class ErrandStartIn(BaseModel): - kind: str # booking|refund|cancel|support - title: str - goal: str = "" - target: Optional[str] = None - tone: Optional[str] = None - - -class ErrandStepOut(BaseModel): - id: str - seq: int - phase: str - tool: Optional[str] = None - label: str - detail: Optional[str] = None - external_effect: bool - state: str - - -class ErrandTaskOut(BaseModel): - id: str - kind: str - title: str - goal: str - target: Optional[str] = None - tone: str - status: str - approval_id: Optional[str] = None - result_summary: Optional[str] = None - model: str - steps: list[ErrandStepOut] = Field(default_factory=list) - - +# ==================== phase-14: 능동 워커 + 멀티모달 ==================== class ProactiveCardOut(BaseModel): id: str kind: str @@ -1377,12 +1142,9 @@ class LLMConfigOut(BaseModel): model: str host: str timeout: float - provider_options: list[str] = ["auto", "ollama", "heuristic"] + provider_options: list[str] = ["ollama"] overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가) # 보조 프로바이더(읽기전용, env 관리) - embed_provider: str = "" - embed_model: str = "" - agent_provider: str = "" stt_provider: str = "" vision_provider: str = "" @@ -1422,6 +1184,5 @@ class SystemConfigOut(BaseModel): database: str # sqlite | postgres | ... sync_interval_minutes: int sync_page_size: int - web_search_provider: str - connector_modes: dict[str, str] # {calendar: mock, mail: mock, ...} + connector_modes: dict[str, str] # {calendar: mock, mail: mock} diff --git a/backend/app/services/mail_ai.py b/backend/app/services/mail_ai.py index 77574c6..dde46a8 100644 --- a/backend/app/services/mail_ai.py +++ b/backend/app/services/mail_ai.py @@ -1,7 +1,5 @@ # backend/app/services/mail_ai.py -# 아리 메일 분석. 시드 ai_json 재사용(결정성) + 신규 메일 LLM + heuristic 폴백. -import re - +# 아리 메일 분석. 기존 ai_json 재사용 + 신규 메일은 실 LLM(휴리스틱 폴백 없음, phase-16+). from sqlmodel import Session from ..llm.provider import LLMProvider @@ -21,14 +19,18 @@ ANALYZE_SCHEMA = { "required": ["summary", "priority", "category"], } -ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로 정리하세요. -- summary: 한두 줄 요약 -- priority: 높음|보통|낮음 (마감/발신자 중요도 기준) -- category: 분류 라벨 (예: 검토 요청, 마감 임박, 채용, 영수증) -- tasks: [{{text, project, due, prio}}] -- events: [{{title, date, day, time, dur, place}}] -- replies: [{{tone, preview, body}}] -- file: {{project, reason}} 또는 null +ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로만 답하세요. +실제로 사용자가 해야 할 행동이 있을 때만 tasks/events/replies 를 채우고, 없으면 빈 배열로 두세요. +광고·뉴스레터·자동알림은 보통 priority=낮음 이고 tasks/events 가 비어 있을 수 있어요. + +반드시 아래 필드명을 그대로 사용하세요(다른 이름 금지): +- summary: 한 줄 요약(한국어) +- priority: "높음" | "보통" | "낮음" +- category: 짧은 분류 라벨(예: 검토 요청, 마감 임박, 영수증, 결제/금융, 뉴스레터) +- tasks: [{{"text": 할 일 문장, "project": 분류, "due": "MM/DD" 또는 "", "prio": "높음|보통|낮음"}}] +- events: [{{"title": 제목, "date": "MM/DD", "day": 요일, "time": "HH:MM", "place": 장소}}] +- replies: [{{"tone": 한 줄 의도, "preview": 미리보기, "body": 보낼 본문 전체}}] +- file: {{"project": 분류, "reason": 사유}} 또는 null 제목: {subject} 보낸 사람: {sender} @@ -37,43 +39,85 @@ ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메 """ -def _heuristic_analyze(email: Email) -> dict: - """LLM 미가용/오프라인 폴백 — 시간표현 → event 후보, 행동동사 → task 후보.""" - text = email.subject + " " + " ".join(email.body or []) - tasks, events = [], [] - if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", text): +def _s(v) -> str: + return v if isinstance(v, str) else ("" if v is None else str(v)) + + +def _coerce_analysis(d: dict) -> dict: + """모델 출력의 필드명 흔들림을 우리 스키마로 정규화(text/title 등 별칭 흡수).""" + prio = _s(d.get("priority")).strip() + if prio not in ("높음", "보통", "낮음"): + prio = "보통" + tasks = [] + for t in d.get("tasks") or []: + if not isinstance(t, dict): + continue + text = _s(t.get("text") or t.get("title") or t.get("task") or t.get("name")).strip() + if not text: + continue + tasks.append( + { + "text": text, + "project": _s(t.get("project") or t.get("category") or "메일"), + "due": _s(t.get("due") or t.get("due_date") or t.get("deadline")), + "prio": _s(t.get("prio") or t.get("priority") or "보통") or "보통", + } + ) + events = [] + for e in d.get("events") or []: + if not isinstance(e, dict): + continue + title = _s(e.get("title") or e.get("summary") or e.get("name")).strip() + if not title: + continue events.append( - {"title": email.subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""} + { + "title": title, + "date": _s(e.get("date") or e.get("start_date")), + "day": _s(e.get("day") or e.get("weekday")), + "time": _s(e.get("time") or e.get("start") or e.get("start_time")), + "dur": _s(e.get("dur") or e.get("duration")), + "place": _s(e.get("place") or e.get("location")), + } ) - if re.search(r"부탁|검토|회신|피드백|정리|확인", text): - tasks.append( - {"text": email.subject[:30], "project": "받은편지함", "due": "", "prio": "보통"} + replies = [] + for r in d.get("replies") or []: + if not isinstance(r, dict): + continue + body = _s(r.get("body") or r.get("content") or r.get("text")).strip() + if not body: + continue + replies.append( + { + "tone": _s(r.get("tone") or r.get("intent") or "정중한 답장"), + "preview": _s(r.get("preview") or body[:60]), + "body": body, + } ) - prio = "높음" if ("[중요]" in email.subject or "마감" in text) else "보통" + file = None + f = d.get("file") + if isinstance(f, dict) and (f.get("project") or f.get("reason")): + file = {"project": _s(f.get("project") or "메일"), "reason": _s(f.get("reason"))} return { - "summary": (email.preview or email.subject)[:60], + "summary": _s(d.get("summary")).strip(), "priority": prio, - "category": "메일", + "category": _s(d.get("category")).strip() or "메일", "tasks": tasks, "events": events, - "replies": [], - "file": None, + "replies": replies, + "file": file, } def analyze_email(s: Session, email: Email, provider: LLMProvider) -> dict: - """신규/재분석용. 시드 메일은 ai_json 이 이미 있으면 그대로 사용(결정성).""" + """신규/재분석용. 이미 분석된 메일은 ai_json 재사용, 신규는 실 LLM(폴백 없음).""" if email.ai_json: return email.ai_json prompt = ANALYZE_PROMPT.format( - subject=email.subject, sender=email.from_key, body="\n".join(email.body or []) + subject=email.subject, sender=email.from_key, body="\n".join(email.body or [])[:4000] ) - try: - result = provider.generate_json(prompt, ANALYZE_SCHEMA) - if not result or not result.get("summary"): - result = _heuristic_analyze(email) - except Exception: - result = _heuristic_analyze(email) + raw = provider.generate_json(prompt, ANALYZE_SCHEMA) # LLM 미가용 시 예외 전파 + result = _coerce_analysis(raw) email.ai_json = result s.add(email) s.commit() diff --git a/backend/tests/test_api_mail.py b/backend/tests/test_api_mail.py index f5522e5..c07c46e 100644 --- a/backend/tests/test_api_mail.py +++ b/backend/tests/test_api_mail.py @@ -1,4 +1,47 @@ # backend/tests/test_api_mail.py +# phase-16+: 메일 mock 시드 제거 → 계정/메일을 테스트가 직접 생성. 발송은 연결된 계정 필요. +from app.connectors.mail.normalize import mail_account_id_for +from tests._factories import make_connected_mail_account, make_email, make_mail_account + +M1_AI = { + "summary": "온보딩 시안 v3 검토 요청", + "priority": "높음", + "category": "검토 요청", + "tasks": [ + {"text": "온보딩 시안 v3 피드백 정리", "project": "온보딩 리디자인 · 와이어프레임", + "due": "", "prio": "높음"}, + {"text": "푸시 알림 문구 확인", "project": "온보딩 리디자인", "due": "", "prio": "보통"}, + ], + "events": [{"title": "온보딩 리뷰", "date": "6/10", "day": "오늘", "time": "14:00", + "dur": "30", "place": "Figma"}], + "replies": [{"tone": "정중", "preview": "", "body": "확인했습니다. 금요일까지 회신드릴게요."}], + "file": {"project": "온보딩 리디자인", "reason": "온보딩 자료"}, +} +M2_AI = { + "summary": "회의 일정", "priority": "보통", "category": "일정", "tasks": [], + "events": [{"title": "스프린트 미팅", "date": "6/11", "day": "내일", "time": "15:00", + "dur": "60", "place": ""}], + "replies": [], "file": None, +} +M7_AI = { + "summary": "결제 영수증", "priority": "낮음", "category": "영수증", + "tasks": [], "events": [], "replies": [], "file": None, +} + + +def _setup(s): + make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0) + make_mail_account(s, id="personal", name="개인", tone="blue", sort_order=1) + make_mail_account(s, id="side", name="사이드", tone="violet", sort_order=2) + make_email(s, id="m1", account="work", from_key="hyunwoo", subject="온보딩 시안 v3", + ai_json=M1_AI, sort_order=0) + make_email(s, id="m2", account="work", from_key="minseo", subject="회의 일정", + ai_json=M2_AI, sort_order=1) + make_email(s, id="m5", account="side", from_key="book", subject="독서모임", sort_order=2) + make_email(s, id="m7", account="personal", from_key="stripe", subject="영수증", + ai_json=M7_AI, sort_order=3) + + def _flatten_titles(nodes): out = [] for n in nodes: @@ -7,54 +50,81 @@ def _flatten_titles(nodes): return out -def test_list_accounts(client): +def test_list_accounts(client, session): + s, _ = session + _setup(s) r = client.get("/api/mail/accounts") assert [a["id"] for a in r.json()] == ["work", "personal", "side"] assert r.json()[0]["tone"] == "coral" -def test_list_mail_count(client): +def test_list_mail_count(client, session): + s, _ = session + _setup(s) rows = client.get("/api/mail").json() - assert len(rows) == 8 - assert rows[0]["from"] == "hyunwoo" # alias + assert len(rows) == 4 + assert rows[0]["from"] == "hyunwoo" # alias, sort_order 0 -def test_email_detail_marks_read(client): +def test_email_detail_marks_read(client, session): + s, _ = session + _setup(s) assert client.get("/api/mail/m1").json()["read"] is True assert client.get("/api/mail/m1").json()["from"] == "hyunwoo" -def test_extract_task_creates_task(client): - r = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0}) - body = r.json() +def test_extract_task_creates_task(client, session): + s, _ = session + _setup(s) + body = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0}).json() assert body["kind"] == "task" assert body["task"]["project_id"] == "onb-wire" assert body["task"]["prio"] == "높음" assert "온보딩 시안 v3 피드백 정리" in _flatten_titles(client.get("/api/tasks").json()) -def test_extract_event_creates_event(client): +def test_extract_event_creates_event(client, session): + s, _ = session + _setup(s) r = client.post("/api/mail/m2/extract", json={"kind": "event", "index": 0}) assert r.json()["kind"] == "event" and r.json()["event_id"] -def test_extract_all(client): +def test_extract_all(client, session): + s, _ = session + _setup(s) r = client.post("/api/mail/m1/extract-all", json={}) assert len(r.json()["created_task_ids"]) == 2 assert r.json()["filed_project"] == "온보딩 리디자인" -def test_reply_draft_picks_seeded(client): +def test_reply_draft_picks_seeded(client, session): + s, _ = session + _setup(s) r = client.post("/api/mail/m1/reply-draft", json={"reply_index": 0}) assert r.json()["subject"].startswith("Re: ") assert "금요일" in r.json()["body"] -def test_send_creates_high_risk_approval(client): +def test_send_requires_connected_account(client, session): + s, _ = session + _setup(s) + # 미연결 계정으로는 발송 거부(가짜 발송 없음) + bad = client.post( + "/api/mail/send", + json={"from_account": "work", "to": "현우 ", "subject": "Re", "body": "x"}, + ) + assert bad.status_code == 400 + + +def test_send_creates_high_risk_approval(client, session): + s, _ = session + make_connected_mail_account(s, email="me@gmail.com") + slug = mail_account_id_for("me@gmail.com") r = client.post( "/api/mail/send", json={ - "from_account": "work", + "from_account": slug, "to": "현우 ", "subject": "Re: 온보딩", "body": "확인했어요", @@ -65,7 +135,9 @@ def test_send_creates_high_risk_approval(client): assert appr["risk"] == "high" and appr["source"] == "mail" -def test_m7_empty_ai(client): +def test_m7_empty_ai(client, session): + s, _ = session + _setup(s) ai = client.get("/api/mail/m7").json()["ai"] assert ai["priority"] == "낮음" and ai["tasks"] == [] and ai["events"] == [] @@ -74,6 +146,58 @@ def test_extract_404(client): assert client.post("/api/mail/nope/extract", json={"kind": "task"}).status_code == 404 -def test_account_filter(client): +def test_account_filter(client, session): + s, _ = session + _setup(s) rows = client.get("/api/mail?account=side").json() assert all(r["account"] == "side" for r in rows) and len(rows) == 1 # m5 + + +# ── 보낸편지함/임시보관함 = OutboundMail(sent/pending) ── +def _make_outbound(s, *, id, mail_account, status, subject, body="본문 내용", to="대표님", + sort=0): + from app.models import OutboundMail + + ob = OutboundMail( + id=id, approval_id=f"ap-{id}", connector_account_id=None, mail_account=mail_account, + to=to, subject=subject, body=body, status=status, + ) + s.add(ob) + s.commit() + return ob + + +def test_sent_folder_returns_sent_outbound(client, session): + s, _ = session + make_mail_account(s, id="work", name="회사", email="me@work.com") + _make_outbound(s, id="ob1", mail_account="work", status="sent", subject="보고서 보냄", + body="첨부 확인 부탁드립니다.") + _make_outbound(s, id="ob2", mail_account="work", status="pending", subject="대기 중인 메일") + rows = client.get("/api/mail/sent").json() + ids = [r["id"] for r in rows] + assert ids == ["ob1"] # pending 은 제외 + r = rows[0] + assert r["subject"] == "보고서 보냄" + assert r["read"] is True + assert r["from"] == "me@work.com" # 발신 계정 이메일 + assert "첨부 확인" in r["preview"] + + +def test_drafts_folder_returns_pending_outbound(client, session): + s, _ = session + make_mail_account(s, id="work", name="회사", email="me@work.com") + _make_outbound(s, id="obA", mail_account="work", status="sent", subject="이미 보냄") + _make_outbound(s, id="obB", mail_account="work", status="pending", subject="결재 대기") + rows = client.get("/api/mail/drafts").json() + assert [r["id"] for r in rows] == ["obB"] + assert rows[0]["subject"] == "결재 대기" + + +def test_sent_folder_account_filter(client, session): + s, _ = session + make_mail_account(s, id="work", name="회사", email="me@work.com") + make_mail_account(s, id="personal", name="개인", email="me@home.com", sort_order=1) + _make_outbound(s, id="obW", mail_account="work", status="sent", subject="회사 발신") + _make_outbound(s, id="obP", mail_account="personal", status="sent", subject="개인 발신") + rows = client.get("/api/mail/sent?account=personal").json() + assert [r["id"] for r in rows] == ["obP"] diff --git a/backend/tests/test_classification_golden.py b/backend/tests/test_classification_golden.py index 27a0f99..1dbe926 100644 --- a/backend/tests/test_classification_golden.py +++ b/backend/tests/test_classification_golden.py @@ -1,6 +1,6 @@ import pytest -from app.llm.heuristic import HeuristicProvider +from tests._fake_llm import FakeLLMProvider CTX = { "projects": [ @@ -21,7 +21,7 @@ GOLDEN = [ @pytest.mark.parametrize("raw,typ,sphere,proj_kw,extra_kw", GOLDEN) def test_golden(raw, typ, sphere, proj_kw, extra_kw): - c = HeuristicProvider().classify_capture(raw, CTX) + c = FakeLLMProvider().classify_capture(raw, CTX) assert c.type == typ, f"{raw} → type {c.type}" assert c.sphere == sphere, f"{raw} → sphere {c.sphere}" assert proj_kw in c.proj_label, f"{raw} → proj_label {c.proj_label}" diff --git a/backend/tests/test_mail_ai.py b/backend/tests/test_mail_ai.py index ab6b99b..3703013 100644 --- a/backend/tests/test_mail_ai.py +++ b/backend/tests/test_mail_ai.py @@ -1,17 +1,18 @@ -# backend/tests/test_mail_ai.py -from app.llm.heuristic import HeuristicProvider +# backend/tests/test_mail_ai.py — phase-16+: 메일 시드 제거 → 직접 생성 from app.models import Email from app.services.mail_ai import analyze_email +from tests._factories import make_email +from tests._fake_llm import FakeLLMProvider -def test_seed_email_uses_stored_ai(client, session): +def test_existing_ai_is_reused(client, session): s, _ = session - e = s.get(Email, "m1") - out = analyze_email(s, e, HeuristicProvider()) - assert out["summary"].startswith("온보딩 시안 v3") # 시드 ai_json 재사용(결정성) + e = make_email(s, id="m1", subject="온보딩 시안 v3", ai_json={"summary": "온보딩 시안 v3 요약"}) + out = analyze_email(s, e, FakeLLMProvider()) + assert out["summary"].startswith("온보딩 시안 v3") # 기존 ai_json 재사용(LLM 미호출) -def test_new_email_heuristic_fallback(client, session): +def test_new_email_analyzed_by_llm(client, session): s, _ = session e = Email( id="mX", @@ -23,6 +24,6 @@ def test_new_email_heuristic_fallback(client, session): ) s.add(e) s.commit() - out = analyze_email(s, e, HeuristicProvider()) + out = analyze_email(s, e, FakeLLMProvider()) # 실 LLM 대역(결정적) assert out["priority"] == "높음" # [중요] assert len(out["events"]) == 1 # 14시 → event 후보 diff --git a/backend/tests/test_normalize_golden.py b/backend/tests/test_normalize_golden.py index 3717249..7872f6d 100644 --- a/backend/tests/test_normalize_golden.py +++ b/backend/tests/test_normalize_golden.py @@ -2,8 +2,7 @@ import base64 from app.connectors.base import RawRecord -from app.connectors.calendar.normalize import normalize_event -from app.connectors.finance.normalize import categorize, normalize_tx +from app.connectors.calendar.normalize import account_calendar_id, normalize_event from app.connectors.mail.normalize import normalize_email from app.models import ConnectorAccount, ConnectorDomain @@ -47,10 +46,239 @@ def test_gmail_email_golden(): assert f["read"] is False # UNREAD 존재 assert f["starred"] is True assert f["has_attach"] is True + # 수신 시각: internalDate(epoch ms) → aware datetime(정렬·표시 기준). + assert f["received_at"] is not None + assert f["received_at"].year == 2024 # 1718000000000ms = 2024-06-10 + + +def test_gmail_plain_strips_conditional_comments_and_vml(): + """text/plain 파트에 MSO 조건부 주석·VML 블록· 래핑이 섞인 (Google 알림류) 메일. + 본문에 HTML 잔재가 남지 않고 URL 은 언랩되어 보존되어야 한다.""" + acct = _acct("ca-mail-personal", "mail") + body = ( + "\n" + "\n" + "본인의 계정으로 로그인했습니다\n" + "magnific.com\n" + "\n" + "계정으로 이동\n" + "© 2026 Google LLC" + ) + payload = { + "id": "gmail-2", + "labelIds": ["CATEGORY_UPDATES"], + "snippet": "스니펫", + "internalDate": "1718000000000", + "payload": { + "mimeType": "text/plain", + "body": {"data": _b64(body)}, + }, + } + norm = normalize_email(acct, RawRecord("gmail-2", payload, etag="h"), provider="gmail") + text = "\n".join(norm.fields["body"]) + assert "