# backend/app/routers/mail.py import uuid from datetime import UTC, datetime from urllib.parse import quote 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, OutboundMail, Person, Prio, Project, Task, TaskStatus, TriageEvent, now, ) from ..schemas import ( AccountFolderOut, BulkMailRequest, ConversationOut, EmailDetailOut, EmailRowOut, ExtractAllResponse, ExtractRequest, ExtractResponse, MailAccountOut, MailLabelRequest, MailReadRequest, MailStarRequest, ReplyDraftOut, ReplyDraftRequest, SendRequest, SendResponse, ) from .calendar import create_event_from_extract # phase-8 일정 federation from .tasks import to_node router = APIRouter() # 프로젝트명(라벨) → project_id 매핑 PROJECT_LABEL_MAP = { "온보딩 리디자인 · 와이어프레임": "onb-wire", "온보딩 리디자인 · 음성 인터페이스": "onb-voice", "온보딩 리디자인": "onb", "경영 전략 · 분기 리포트": "biz-report", "경영 전략 · 예산 관리": "biz-budget", "경영 전략": "biz", "팀 운영 · 채용 & 온보딩": "team-hr", "팀 운영": "team", "개인 · 성장": "me", "개인": "me", } # ── 표시용 상대 시각(수신 시각 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. (프로모션·기타는 제외) focused = ( folder == "inbox" and not e.archived and _category_for(e.labels) == "primary" and (e.inference or "") != "other" ) # 아리 정리(smart)는 사용자가 기각(dismiss)한 메일을 다이제스트에서만 뺀다 — # 실제 받은편지함(inbox)에는 그대로 남는다(읽음/보관/삭제 아님). if slug == "smart": return focused and not e.dismissed return focused 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": _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, } ) @router.get("/mail/accounts", response_model=list[MailAccountOut]) def accounts(s: Session = Depends(get_session)): 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.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("inbox"), # 폴더 슬러그: inbox/spam/trash/starred/cat:/label:/of: q: str | None = Query(None), # 서버측 검색어(공백=AND) s: Session = Depends(get_session), user: Person = Depends(current_user), ): 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) if not e: raise HTTPException(404, "email not found") if not e.read: # 열람 = 읽음 처리 e.read = True s.add(e) s.commit() d = _row_out(e).model_dump(by_alias=True) # 인라인(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): pid = "me" tid = "t-" + uuid.uuid4().hex[:8] task = Task( id=tid, title=t["text"], project_id=pid, status=TaskStatus.todo, assignee_id="jiwoo", prio=Prio(t.get("prio") or "보통"), notes=f"‘{e.subject}’ 메일에서 아리가 추출한 작업이에요.", ) s.add(task) s.commit() s.refresh(task) return task @router.post("/mail/{eid}/extract", response_model=ExtractResponse) def extract(eid: str, body: ExtractRequest, s: Session = Depends(get_session)): e = s.get(Email, eid) if not e: raise HTTPException(404, "email not found") ai = e.ai_json or {} if body.kind == "task": task = _make_task(s, e, ai["tasks"][body.index]) node = to_node(s, task, s.exec(select(Task)).all()) return ExtractResponse(kind="task", task=node, message="작업에 추가했어요") if body.kind == "event": ev = ai["events"][body.index] ev_id = create_event_from_extract(s, ev, source=f"mail:{eid}") return ExtractResponse(kind="event", event_id=ev_id, message="일정에 추가했어요") if body.kind == "file": proj = ai["file"]["project"] return ExtractResponse(kind="file", project=proj, message=f"‘{proj}’에 정리했어요") raise HTTPException(400, "kind must be task|event|file") @router.post("/mail/{eid}/extract-all", response_model=ExtractAllResponse) def extract_all(eid: str, s: Session = Depends(get_session)): e = s.get(Email, eid) if not e: raise HTTPException(404, "email not found") ai = e.ai_json or {} task_ids, event_ids = [], [] for t in ai.get("tasks", []): task_ids.append(_make_task(s, e, t).id) for ev in ai.get("events", []): event_ids.append(create_event_from_extract(s, ev, source=f"mail:{eid}")) filed = ai.get("file", {}).get("project") if ai.get("file") else None return ExtractAllResponse( created_task_ids=task_ids, created_event_ids=event_ids, filed_project=filed, message="아리가 준비한 작업을 모두 적용했어요", ) def _ai_actionable(ai: dict | None) -> bool: """분석 결과가 아리 정리 '카드'로 노출되는가 — 할 일/일정/답장이 있거나 중요(높음). 프론트 Triage 카드 필터(actionCount>0 || 높음)와 동일 기준. 정보성 메일은 카드가 없다.""" if not ai: return False reply = 1 if (ai.get("replies") or []) else 0 actions = len(ai.get("tasks") or []) + len(ai.get("events") or []) + reply return actions > 0 or ai.get("priority") == "높음" def _is_triage_card(e: Email) -> bool: """아리 정리에 실제로 노출될 새 메일인가 — 미분석(곧 정리됨)이거나 분석 결과에 액션이 있음. 분석 끝난 정보성 메일(액션 없음)은 카드가 없으니 배지에서도 빼서 패널 숫자와 일치시킨다.""" if not e.ai_json: return True # 미분석 = 정리 대기(pending) — 곧 자동 분석되어 카드가 되거나 빠진다 return _ai_actionable(e.ai_json) def _triage_summary( s: Session, user: Person, since: datetime, day_start: datetime | None = None ) -> dict: """아리 정리 요약 — 카드 수(count) + 자동 처리 현황(새 메일 총수·자동 걸러낸 수). 빈 패널이 '아무것도 안 함'처럼 보이지 않게, 아리가 새 메일을 읽고 무엇을 넘겼는지 보여준다. - count : 패널에 카드로 노출되는 수(미분석 대기 + 액션 있는 집중 메일) = 사이드바 배지. 기준 시각 이후 전체 — 어제 도착한 챙길 거리도 빠뜨리지 않는다. - new_total: '오늘'(day_start) 도착한 새 메일 총수(스팸·홍보 포함). 없으면 기준 이후 전체. - screened : 그중 집중이 아니라 아리가 자동으로 넘긴 수(스팸/홍보/소셜/기타/보관). 오늘로 한정하는 이유: 기준 시각이 며칠 전이면 누적치가 커져('새 메일 60통') 오늘 받은 양과 동떨어진다 — 표시는 '오늘 온 새 메일' 기준으로 맞춘다(경계=클라 로컬 자정).""" rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] new = [e for e in rows if _is_new_mail(e, since)] count = sum(1 for e in new if _email_in_folder(e, "smart") and _is_triage_card(e)) # 표시용 총수/걸러낸 수는 오늘 도착분만(누적 방지). 집중 후보(기각 포함)=inbox 판정과 동일. floor = max(since, _as_utc(day_start)) if day_start else since today = [e for e in new if _is_new_mail(e, floor)] focused = sum(1 for e in today if _email_in_folder(e, "inbox")) return {"count": count, "new_total": len(today), "screened": len(today) - focused} def _triage_new_count(s: Session, user: Person, since: datetime) -> int: """기준 시각 이후 도착한 집중(focused) 새 메일 중 '아리 정리에 노출되는' 수 — 배지=패널 일치. 미분석(대기)은 포함하되, 분석 끝난 정보성 메일(액션 없음)은 카드가 없으므로 제외(읽음 무관).""" return _triage_summary(s, user, since)["count"] def _parse_day_start(day_start: str | None) -> datetime | None: """클라가 보낸 로컬 자정(ISO)을 UTC-aware datetime 으로 — 파싱 실패 시 None.""" if not day_start: return None try: return _as_utc(datetime.fromisoformat(day_start)) except ValueError: return None @router.get("/mail/triage/baseline") def triage_baseline_get( day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계(서버 UTC TZ 보정) s: Session = Depends(get_session), user: Person = Depends(current_user), ): """아리 정리 기준 시각(ISO) + '오늘'(day_start) 도착한 새 메일 요약 — 없으면 지금으로 초기화.""" since = _ensure_triage_since(s) summary = _triage_summary(s, user, since, _parse_day_start(day_start)) return {"since": since.isoformat(), **summary} @router.get("/mail/triage/log") def triage_log( day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계 s: Session = Depends(get_session), user: Person = Depends(current_user), ): """오늘(day_start 이후) 아리 자동 정리 실행 로그 — 최근 것부터 최대 20개. 실제로 메일을 읽고 분류했는지/에러가 났는지 보여주는 관측성 피드(무활동 사이클은 기록 안 됨).""" floor = _parse_day_start(day_start) rows = s.exec(select(TriageEvent).order_by(TriageEvent.at.desc())).all() if floor is not None: rows = [r for r in rows if _as_utc(r.at) >= floor] return [ {"at": r.at.isoformat(), "analyzed": r.analyzed, "cards": r.cards, "error": r.error} for r in rows[:20] ] def _triage_bucket(e: Email) -> str: """이 메일을 아리가 어디로 분류했는지 — 상세 로그용 한 단어 라벨(집중/홍보/스팸 등).""" folder = e.folder or "inbox" if folder == "spam": return "스팸" if folder == "trash": return "휴지통" if e.archived: return "보관" if _email_in_folder(e, "inbox"): return "집중" if (e.inference or "") == "other": return "기타" return {"promotions": "홍보", "social": "소셜", "updates": "업데이트"}.get( _category_for(e.labels), "기타" ) @router.get("/mail/triage/classified") def triage_classified( day_start: str | None = Query(None), # 클라 로컬 자정(ISO) — '오늘' 경계 s: Session = Depends(get_session), user: Person = Depends(current_user), ): """오늘 도착한 새 메일을 '아리가 어떻게 분류·분석했는지' 하나씩 — 상세 로그 뷰용. 집중/홍보/스팸 버킷 + (집중이면) 분석 결과(우선순위·할 일/일정/답장)까지. 최신순 최대 200통.""" since = _ensure_triage_since(s) floor = max(since, _parse_day_start(day_start) or since) mine = [e for e in s.exec(select(Email)).all() if e.user_id == user.id] rows = [e for e in mine if _is_new_mail(e, floor)] rows.sort(key=_email_sort_key, reverse=True) out = [] for e in rows[:200]: ai = e.ai_json or None from_name, _ = sender_parts(e.from_key) out.append( { "id": e.id, "account": e.account, "from_name": from_name, "subject": e.subject, "time": _list_time(e.received_at, e.time), "bucket": _triage_bucket(e), "focused": _email_in_folder(e, "inbox"), "analyzed": ai is not None, "priority": ai.get("priority") if ai else None, "tasks": len(ai.get("tasks") or []) if ai else 0, "events": len(ai.get("events") or []) if ai else 0, "replies": 1 if (ai and (ai.get("replies") or [])) else 0, "is_card": _email_in_folder(e, "smart") and _is_triage_card(e), } ) return out @router.post("/mail/{mail_id}/triage/dismiss") def triage_dismiss( mail_id: str, s: Session = Depends(get_session), user: Person = Depends(current_user), ): """아리 정리에서 이 카드를 기각 — 다이제스트에서 빼고 다시 분석/표시하지 않는다. 실제 받은편지함에는 그대로 남는다(읽음/보관/삭제와 무관).""" e = s.get(Email, mail_id) if not e or e.user_id != user.id: raise HTTPException(404, "메일을 찾을 수 없어요") e.dismissed = True s.add(e) s.commit() since = _ensure_triage_since(s) return {"ok": True, **_triage_summary(s, user, since)} @router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut) def reply_draft( eid: str, body: ReplyDraftRequest, s: Session = Depends(get_session), provider: LLMProvider = Depends(get_provider), ): e = s.get(Email, eid) if not e: raise HTTPException(404, "email not found") 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 {} if body.reply_index is not None and ai.get("replies"): draft = ai["replies"][body.reply_index]["body"] else: prompt = ( "이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n" f"{e.subject}\n{' '.join(e.body or [])}" ) # 실 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 → 결재함. 승인 시 연결된 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, icon="mail", tone="violet", risk="high", status="pending", source="mail", time="보내기 대기", title=f"{body.to}에게 메일을 보낼 준비가 됐어요", detail=f"제목: {body.subject}\n\n{body.body[:120]}…", cta="보내기", alt="수정", 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="보낼 준비가 됐어요 — 결재함에서 확인하세요" )