You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1057 lines
39 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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="보낼 준비가 됐어요 — 결재함에서 확인하세요"
)