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