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.

951 lines
34 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,
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. (프로모션·기타는 제외)
return (
folder == "inbox"
and not e.archived
and _category_for(e.labels) == "primary"
and (e.inference or "") != "other"
)
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="아리가 준비한 작업을 모두 적용했어요",
)
@router.post("/mail/triage/analyze")
def triage_analyze(
limit: int = Query(4, ge=1, le=12),
s: Session = Depends(get_session),
user: Person = Depends(current_user),
provider: LLMProvider = Depends(get_provider),
):
"""아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort).
한 번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다."""
from ..services.mail_ai import analyze_email
since = _ensure_triage_since(s)
rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
todo = [
e
for e in rows
if not e.ai_json
and _email_in_folder(e, "smart")
and _is_new_mail(e, since) # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리)
]
todo.sort(key=_email_sort_key, reverse=True)
analyzed, errors = 0, 0
for e in todo[:limit]:
try:
analyze_email(s, e, provider)
analyzed += 1
except Exception: # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도).
errors += 1
return {"analyzed": analyzed, "remaining": max(0, len(todo) - analyzed), "errors": errors}
def _triage_new_count(s: Session, user: Person, since: datetime) -> int:
"""기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관).
사용자가 폰/다른 앱에서 먼저 읽어도 '정리할 새 메일'로 센다(서버 read 가 곧 True 가 되므로)."""
rows = [e for e in s.exec(select(Email)).all() if e.user_id == user.id]
return sum(1 for e in rows if _email_in_folder(e, "smart") and _is_new_mail(e, since))
@router.get("/mail/triage/baseline")
def triage_baseline_get(
s: Session = Depends(get_session),
user: Person = Depends(current_user),
):
"""아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화."""
since = _ensure_triage_since(s)
return {"since": since.isoformat(), "count": _triage_new_count(s, user, since)}
@router.post("/mail/triage/baseline")
def triage_baseline_reset(
s: Session = Depends(get_session),
user: Person = Depends(current_user),
):
"""기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리."""
since = _set_triage_since(s, now())
return {"since": since.isoformat(), "count": _triage_new_count(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="보낼 준비가 됐어요 — 결재함에서 확인하세요"
)