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