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.

234 lines
7.6 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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from ..auth.deps import current_user
from ..connectors.mail import get_mail_connector
from ..db import get_session
from ..llm.provider import LLMProvider, get_provider
from ..models import (
Approval,
Email,
MailAccount,
MailFolder,
Person,
Prio,
Project,
Task,
TaskStatus,
)
from ..schemas import (
EmailDetailOut,
EmailRowOut,
ExtractAllResponse,
ExtractRequest,
ExtractResponse,
MailAccountOut,
MailFolderOut,
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",
}
def _row_out(e: Email) -> EmailRowOut:
return EmailRowOut.model_validate(
{
"id": e.id,
"account": e.account,
"from": e.from_key,
"to": e.to,
"subject": e.subject,
"time": e.time,
"read": e.read,
"starred": e.starred,
"has_attach": e.has_attach,
"labels": e.labels,
"preview": e.preview,
"ai": e.ai_json or None,
}
)
@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()
@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.get("/mail", response_model=list[EmailRowOut])
def list_mail(
account: str | None = Query(None),
folder: str = Query("smart"),
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 소유자 스코프
return [_row_out(e) for e in rows]
@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)
d.update({"date": e.date, "body": e.body, "attachments": e.attachments})
return EmailDetailOut.model_validate(d)
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="아리가 준비한 작업을 모두 적용했어요",
)
@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 {}
_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:
prompt = (
"이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\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
return ReplyDraftOut(to=to, from_account=e.account, subject="Re: " + e.subject, body=draft)
@router.post("/mail/send", response_model=SendResponse)
def send(body: SendRequest, s: Session = Depends(get_session)):
"""회신/새 메일 보내기 = high-risk → 결재함(phase-7). 실제 발송은 승인 후."""
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.commit()
return SendResponse(
approval_id=aid, status="pending", message="보낼 준비가 됐어요 — 결재함에서 확인하세요"
)