feat(mail): 집중/기타 분류·아리 정리(비서 다이제스트)·실폴더 + 버그 수정

- 집중/기타: Gmail 기본(primary)+Outlook focused=집중, 프로모션/소셜/업데이트+
  Outlook other=기타. 사이드바 실폴더 미러·받은편지함 탭(집중/기타 안읽음 카운트)
- 아리 정리: 기준 시각 이후 '도착한' 새 메일 기준(읽음 여부 무관 — 폰서 먼저 읽어도
  정리). LLM 배치 분석으로 할 일·일정·답장 카드화. baseline 응답 count
- 버그 수정: ① 처음 보는 폴더 클릭 시 트리 닫힘 → useMail keepPreviousData
  ② 12:28 도착 메일이 아리 정리에 안 뜸(read=1) → 도착 기준으로 전환
  ③ 기타 안읽음 미표시 → MailAccountOut.others + 탭 카운트
- HTML 본문 렌더(MailHtmlBody, 샌드박스 iframe), 라우팅 [[...slug]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent c5cf6816f8
commit 9db9ed7242

@ -1,32 +1,50 @@
# backend/app/routers/mail.py
import uuid
from datetime import UTC, datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query
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,
MailFolder,
OutboundMail,
Person,
Prio,
Project,
Task,
TaskStatus,
now,
)
from ..schemas import (
AccountFolderOut,
BulkMailRequest,
ConversationOut,
EmailDetailOut,
EmailRowOut,
ExtractAllResponse,
ExtractRequest,
ExtractResponse,
MailAccountOut,
MailFolderOut,
MailLabelRequest,
MailReadRequest,
MailStarRequest,
ReplyDraftOut,
ReplyDraftRequest,
SendRequest,
@ -52,20 +70,197 @@ PROJECT_LABEL_MAP = {
}
# ── 표시용 상대 시각(수신 시각 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": e.time,
"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,
}
)
@ -73,32 +268,198 @@ def _row_out(e: Email) -> EmailRowOut:
@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()
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}
@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()
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("smart"),
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),
):
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 소유자 스코프
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)
@ -109,10 +470,286 @@ def get_mail(eid: str, s: Session = Depends(get_session)):
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})
# 인라인(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):
@ -173,6 +810,64 @@ def extract_all(eid: str, s: Session = Depends(get_session)):
)
@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,
@ -186,10 +881,6 @@ def reply_draft(
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:
@ -197,20 +888,31 @@ def reply_draft(
"이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n"
f"{e.subject}\n{' '.join(e.body or [])}"
)
try:
# 실 LLM 전용(휴리스틱 템플릿 폴백 제거) — 미가용 시 예외 전파.
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)
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 → 결재함(phase-7). 실제 발송은 승인 후."""
"""회신/새 메일 보내기 = 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,
@ -227,6 +929,21 @@ def send(body: SendRequest, s: Session = Depends(get_session)):
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="보낼 준비가 됐어요 — 결재함에서 확인하세요"

@ -75,6 +75,11 @@ class CommentOut(BaseModel):
person_id: str
text: str
created_at: datetime
edited_at: Optional[datetime] = None
class CommentPatch(BaseModel):
text: str
class TaskNode(BaseModel):
@ -127,6 +132,10 @@ class CommentCreate(BaseModel):
text: str
class TaskReorderRequest(BaseModel):
ids: list[str] # 같은 컬럼 내 새 순서대로 정렬된 task id 목록
# ---------- scaffold ----------
class ScaffoldItem(BaseModel):
title: str
@ -454,9 +463,16 @@ class EventActionOut(BaseModel):
when: str
class AttendeeOut(BaseModel):
email: str
name: str = ""
status: str = "" # accepted|declined|tentative|needsAction
class CalEventOut(BaseModel):
id: str
day: int
date: str = "" # 실제 날짜 ISO "YYYY-MM-DD"
start: str
end: str
title: str
@ -465,14 +481,38 @@ class CalEventOut(BaseModel):
note: str = ""
soon: bool = False
people: list[str] = []
rrule: str = "" # 반복 규칙(iCal RRULE)
reminders: list[int] = [] # 시작 N분 전 알림
attendees: list[AttendeeOut] = []
response_status: str = "" # 내 RSVP
actions: list[EventActionOut] = []
has_meeting: bool = False
meet_label: str = ""
class EventWriteRequest(BaseModel):
"""새 일정 생성/수정 요청. start 빈값 = 종일."""
title: str
date: str # ISO "YYYY-MM-DD"
start: str = "" # "HH:MM"
end: str = "" # "HH:MM"
loc: str = ""
note: str = ""
people: list[str] = [] # 참석자 이메일
cal: str = "" # 대상 캘린더 id(빈값=기본 연결 캘린더 또는 로컬)
rrule: str = "" # 반복 규칙(빈값=단일). 예 "FREQ=WEEKLY;BYDAY=MO"
reminders: list[int] = [] # 시작 N분 전 알림(분 단위)
class EventRsvpRequest(BaseModel):
status: str # accepted | declined | tentative
class FocusBlockOut(BaseModel):
id: str
day: int
date: str = "" # 실제 날짜 ISO(데모는 6월로 매핑)
start: str
end: str
title: str
@ -516,12 +556,15 @@ class MeetingOut(BaseModel):
class DayBundleOut(BaseModel):
day: int
date: str = ""
events: list[CalEventOut]
focus_blocks: list[FocusBlockOut]
class WeekBundleOut(BaseModel):
today: int
today_date: str = "" # 실제 오늘 ISO(real 연결 시 실날짜, 아니면 데모 2026-06-08)
mode: str = "demo" # "real" | "demo"
week: list[int]
weekdays: list[str]
calendars: list[CalendarOut]
@ -559,7 +602,8 @@ class MailAccountOut(BaseModel):
email: str
tone: str
kind: str
unread: int
unread: int # 집중(focused) 받은편지함 안읽음
others: int = 0 # 기타(Outlook other) 받은편지함 안읽음
class MailFolderOut(BaseModel):
@ -568,6 +612,19 @@ class MailFolderOut(BaseModel):
icon: str
class AccountFolderOut(BaseModel):
"""계정별 실제 폴더/라벨(사이드바 트리). slug 가 list_mail folder 필터 키."""
account: str
slug: str
name: str
kind: str # system | category | label | custom
icon: str
unread: int = 0
total: int = 0
sort_order: int = 0
class AiTaskOut(BaseModel):
text: str
project: str
@ -605,12 +662,23 @@ class AiAnalysisOut(BaseModel):
file: Optional[AiFileOut] = None
class MailAttachmentOut(BaseModel):
id: str = "" # 프로바이더 attachment id(다운로드용)
name: str
mime: str = ""
size: int = 0
class EmailRowOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
account: str
from_: str = Field(alias="from")
from_name: str = "" # 표시 이름(이름 없으면 도메인 추정) — 목록/리더에서 노출
from_email: str = "" # 실제 메일 주소(리더 보조 표시)
to: str
cc: str = ""
thread_id: str = ""
subject: str
time: str
read: bool
@ -618,13 +686,24 @@ class EmailRowOut(BaseModel):
has_attach: bool
labels: list[str] = []
preview: str
category: str = "primary" # 카테고리 탭(primary/promotions/social/updates) — 라벨에서 산출
ai: Optional[AiAnalysisOut] = None
class EmailDetailOut(EmailRowOut):
date: str
body: list[str] = []
attachments: list[dict] = []
body_html: str = "" # 원본 HTML 본문(샌드박스 iframe 렌더용; cid 는 첨부 URL 로 치환됨)
attachments: list[MailAttachmentOut] = []
class ConversationOut(BaseModel):
"""대화(스레드) 묶음 — 같은 thread_id 메일들."""
thread_id: str
subject: str
count: int
messages: list[EmailRowOut] = []
class ExtractRequest(BaseModel):
@ -659,12 +738,21 @@ class ReplyDraftOut(BaseModel):
body: str
class ComposeAttachment(BaseModel):
name: str
mime: str = "application/octet-stream"
data_b64: str # base64 인코딩 본문
class SendRequest(BaseModel):
from_account: str
to: str
cc: str = ""
bcc: str = ""
subject: str
body: str
in_reply_to: Optional[str] = None
attachments: list[ComposeAttachment] = []
class SendResponse(BaseModel):
@ -673,6 +761,26 @@ class SendResponse(BaseModel):
message: str
class MailStarRequest(BaseModel):
starred: bool
class MailReadRequest(BaseModel):
read: bool
class MailLabelRequest(BaseModel):
"""라벨/카테고리 추가·제거(폴더 이동 포함)."""
add: list[str] = []
remove: list[str] = []
class BulkMailRequest(BaseModel):
ids: list[str]
action: str # read | unread | archive | unarchive | trash | star | unstar
# ---------- 알림 ----------
class NotificationOut(BaseModel):
id: str
@ -740,327 +848,6 @@ class SenderRuleCreate(BaseModel):
ExtractResponse.model_rebuild()
# ==================== phase-10: 리서치 + 여행 ====================
class CollectionOut(BaseModel):
id: str
name: str
tone: str
n: int
class SourceOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
kind: str
title: str
from_: str = Field(default="", serialization_alias="from")
col: Optional[str] = None
learned: bool = False
class ResearchHomeOut(BaseModel):
collections: list[CollectionOut] = []
sources: list[SourceOut] = []
prompts: list[str] = []
entries: list[dict] = []
class ReportOut(BaseModel):
id: str
title: str
asked: str = ""
meta: str = ""
counts: list[dict] = []
synthesis: str = ""
cross: list[dict] = []
note: str = ""
class QAOut(BaseModel):
id: str = ""
q: str = ""
a: str = ""
refs: list[dict] = []
class ChartOut(BaseModel):
id: str
title: str
asked: str = ""
unit: str = ""
bars: list[dict] = []
insight: str = ""
caution: str = ""
class StartResearchRequest(BaseModel):
query: str
class StartResearchOut(BaseModel):
status: str # ready | queued
report_id: Optional[str] = None
report: Optional[ReportOut] = None
queued_text: Optional[str] = None
class AskRequest(BaseModel):
q: str
collection_id: Optional[str] = None
class AskOut(BaseModel):
a: str
refs: list[dict] = []
grounded: bool = True
model: str = ""
# ---------- 여행 ----------
class PlanRequest(BaseModel):
text: str
class PlanResultOut(BaseModel):
id: str
idx: int
custom: bool = False
title: str
meta: str = ""
summary: str = ""
weather: str = ""
transport: list[dict] = []
stay: list[dict] = []
days: list[dict] = []
budget: dict = {}
checklist: list[str] = []
sources: list[str] = []
research: Optional[list[dict]] = None
class WatchToggleRequest(BaseModel):
watch: Optional[bool] = None
class SavedTripOut(BaseModel):
id: str
title: str
tag: str = ""
note: str = ""
now: str = ""
delta: str = ""
down: bool = True
watch: bool = True
hint: str = ""
spark: list[int] = []
class UpcomingTripOut(BaseModel):
trip: dict
route: dict
stay: dict
prep: list[dict] = []
days: list[dict] = []
check: list[dict] = []
expense: dict
# ==================== phase-11: 라이프 케어 ====================
from .models import ConnectorDomain, InsightKind, KnowledgeType # noqa: E402
class ConnectorSourceOut(BaseModel):
id: str
domain: ConnectorDomain
name: str
kind: str
tone: str
on: bool
last: str
class RingOut(BaseModel):
label: str
val: str
unit: str
goal: str
pct: int
icon: str
tone: str
class VitalOut(BaseModel):
label: str
val: str
unit: str
trend: str
note: str
tone: str
class SleepStageOut(BaseModel):
label: str
pct: int
tone: str
class SleepOut(BaseModel):
total: str
score: int
stages: list[SleepStageOut]
week: list[int]
note: str
class HabitOut(BaseModel):
id: str
title: str
goal: str
done: int
total: int
streak: int
icon: str
tone: str
at: str
automation_rule_id: Optional[str] = None
class HealthOut(BaseModel):
rings: list[RingOut]
vitals: list[VitalOut]
sleep: SleepOut
coach: str
habits: list[HabitOut]
class BudgetOut(BaseModel):
spent: int
limit: int
pct: int
delta_pct: int
days_left: int
class CategoryOut(BaseModel):
name: str
amt: int
pct: int
icon: str
tone: str
over: bool
class SubOut(BaseModel):
id: str
name: str
date: str
in_days: int
amt: int
icon: str
tone: str
note: str
paused: bool
class InsightOut(BaseModel):
id: str
kind: InsightKind
icon: str
title: str
body: str
action: str
action_ref: str
class FinanceOut(BaseModel):
budget: BudgetOut
cats: list[CategoryOut]
subs: list[SubOut]
coach: str
insights: list[InsightOut]
class KnowledgeCollectionOut(BaseModel):
name: str
count: int
tone: str
class KnowledgeItemOut(BaseModel):
id: str
type: KnowledgeType
title: str
src: str
time: str
tone: str
icon: str
tags: list[str]
excerpt: str
ai: str
class KnowledgeStatsOut(BaseModel):
items: int
this_week: int
collections: int
class KnowledgeOut(BaseModel):
stats: KnowledgeStatsOut
suggested: list[str]
collections: list[KnowledgeCollectionOut]
items: list[KnowledgeItemOut]
class LifeOverviewOut(BaseModel):
sources: dict[str, list[ConnectorSourceOut]]
health: HealthOut
finance: FinanceOut
knowledge: KnowledgeOut
class HabitTickResponse(BaseModel):
habit: HabitOut
class SubPauseRequest(BaseModel):
paused: bool
class SubPauseResponse(BaseModel):
sub: SubOut
approval_id: Optional[str] = None
class InsightActResponse(BaseModel):
approval_id: Optional[str] = None
task_id: Optional[str] = None
message: str
class KnowledgeAskRequest(BaseModel):
q: str
class KnowledgeAskResponse(BaseModel):
q: str
body: str
sources: list[str]
class KnowledgeSuggestActRequest(BaseModel):
item_id: str
target: str
class KnowledgeSuggestActResponse(BaseModel):
task_id: Optional[str] = None
event_id: Optional[str] = None
message: str
# ==================== phase-12: 여정 + 하루 마감 ====================
@ -1194,6 +981,7 @@ class ConnectorStatusOut(BaseModel):
last: str # last_label
last_synced_at: Optional[datetime] = None
error_detail: str = ""
email: str = "" # phase-16 연결된 실계정 주소(external_account_id)
@classmethod
def from_account(cls, a, env_mode: str) -> ConnectorStatusOut:
@ -1214,6 +1002,7 @@ class ConnectorStatusOut(BaseModel):
last=a.last_label,
last_synced_at=a.last_synced_at,
error_detail=a.error_detail,
email=getattr(a, "external_account_id", "") or "",
)
@ -1221,6 +1010,15 @@ class OAuthStartOut(BaseModel):
authorize_url: str
class ConnectorProviderOut(BaseModel):
"""phase-16 '계정 추가' 버튼이 보여줄 OAuth provider 목록 + 구성 여부."""
domain: str # mail | calendar | knowledge ...
provider: str # gmail | outlook | google_calendar | notion
label: str # "Gmail" / "Outlook"
configured: bool # client_id 설정되어 real 연동 가능
class SyncResultOut(BaseModel):
domain: str
account_id: str
@ -1240,40 +1038,7 @@ class ImportResultOut(BaseModel):
detail: str = ""
# ==================== phase-14: 능동 에이전트 + 멀티모달 ====================
class ErrandStartIn(BaseModel):
kind: str # booking|refund|cancel|support
title: str
goal: str = ""
target: Optional[str] = None
tone: Optional[str] = None
class ErrandStepOut(BaseModel):
id: str
seq: int
phase: str
tool: Optional[str] = None
label: str
detail: Optional[str] = None
external_effect: bool
state: str
class ErrandTaskOut(BaseModel):
id: str
kind: str
title: str
goal: str
target: Optional[str] = None
tone: str
status: str
approval_id: Optional[str] = None
result_summary: Optional[str] = None
model: str
steps: list[ErrandStepOut] = Field(default_factory=list)
# ==================== phase-14: 능동 워커 + 멀티모달 ====================
class ProactiveCardOut(BaseModel):
id: str
kind: str
@ -1377,12 +1142,9 @@ class LLMConfigOut(BaseModel):
model: str
host: str
timeout: float
provider_options: list[str] = ["auto", "ollama", "heuristic"]
provider_options: list[str] = ["ollama"]
overridden: bool = False # 런타임 오버레이가 활성인가(env 기본과 다른가)
# 보조 프로바이더(읽기전용, env 관리)
embed_provider: str = ""
embed_model: str = ""
agent_provider: str = ""
stt_provider: str = ""
vision_provider: str = ""
@ -1422,6 +1184,5 @@ class SystemConfigOut(BaseModel):
database: str # sqlite | postgres | ...
sync_interval_minutes: int
sync_page_size: int
web_search_provider: str
connector_modes: dict[str, str] # {calendar: mock, mail: mock, ...}
connector_modes: dict[str, str] # {calendar: mock, mail: mock}

@ -1,7 +1,5 @@
# backend/app/services/mail_ai.py
# 아리 메일 분석. 시드 ai_json 재사용(결정성) + 신규 메일 LLM + heuristic 폴백.
import re
# 아리 메일 분석. 기존 ai_json 재사용 + 신규 메일은 실 LLM(휴리스틱 폴백 없음, phase-16+).
from sqlmodel import Session
from ..llm.provider import LLMProvider
@ -21,14 +19,18 @@ ANALYZE_SCHEMA = {
"required": ["summary", "priority", "category"],
}
ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로 정리하세요.
- summary: 한두 요약
- priority: 높음|보통|낮음 (마감/발신자 중요도 기준)
- category: 분류 라벨 (: 검토 요청, 마감 임박, 채용, 영수증)
- tasks: [{{text, project, due, prio}}]
- events: [{{title, date, day, time, dur, place}}]
- replies: [{{tone, preview, body}}]
- file: {{project, reason}} 또는 null
ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메일을 읽고 JSON 으로만 답하세요.
실제로 사용자가 해야 행동이 있을 때만 tasks/events/replies 채우고, 없으면 배열로 두세요.
광고·뉴스레터·자동알림은 보통 priority=낮음 이고 tasks/events 비어 있을 있어요.
반드시 아래 필드명을 그대로 사용하세요(다른 이름 금지):
- summary: 요약(한국어)
- priority: "높음" | "보통" | "낮음"
- category: 짧은 분류 라벨(: 검토 요청, 마감 임박, 영수증, 결제/금융, 뉴스레터)
- tasks: [{{"text": 문장, "project": 분류, "due": "MM/DD" 또는 "", "prio": "높음|보통|낮음"}}]
- events: [{{"title": 제목, "date": "MM/DD", "day": 요일, "time": "HH:MM", "place": 장소}}]
- replies: [{{"tone": 의도, "preview": 미리보기, "body": 보낼 본문 전체}}]
- file: {{"project": 분류, "reason": 사유}} 또는 null
제목: {subject}
보낸 사람: {sender}
@ -37,43 +39,85 @@ ANALYZE_PROMPT = """당신은 한국어 비서 '아리'입니다. 아래 이메
"""
def _heuristic_analyze(email: Email) -> dict:
"""LLM 미가용/오프라인 폴백 — 시간표현 → event 후보, 행동동사 → task 후보."""
text = email.subject + " " + " ".join(email.body or [])
tasks, events = [], []
if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", text):
def _s(v) -> str:
return v if isinstance(v, str) else ("" if v is None else str(v))
def _coerce_analysis(d: dict) -> dict:
"""모델 출력의 필드명 흔들림을 우리 스키마로 정규화(text/title 등 별칭 흡수)."""
prio = _s(d.get("priority")).strip()
if prio not in ("높음", "보통", "낮음"):
prio = "보통"
tasks = []
for t in d.get("tasks") or []:
if not isinstance(t, dict):
continue
text = _s(t.get("text") or t.get("title") or t.get("task") or t.get("name")).strip()
if not text:
continue
tasks.append(
{
"text": text,
"project": _s(t.get("project") or t.get("category") or "메일"),
"due": _s(t.get("due") or t.get("due_date") or t.get("deadline")),
"prio": _s(t.get("prio") or t.get("priority") or "보통") or "보통",
}
)
events = []
for e in d.get("events") or []:
if not isinstance(e, dict):
continue
title = _s(e.get("title") or e.get("summary") or e.get("name")).strip()
if not title:
continue
events.append(
{"title": email.subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""}
{
"title": title,
"date": _s(e.get("date") or e.get("start_date")),
"day": _s(e.get("day") or e.get("weekday")),
"time": _s(e.get("time") or e.get("start") or e.get("start_time")),
"dur": _s(e.get("dur") or e.get("duration")),
"place": _s(e.get("place") or e.get("location")),
}
)
if re.search(r"부탁|검토|회신|피드백|정리|확인", text):
tasks.append(
{"text": email.subject[:30], "project": "받은편지함", "due": "", "prio": "보통"}
replies = []
for r in d.get("replies") or []:
if not isinstance(r, dict):
continue
body = _s(r.get("body") or r.get("content") or r.get("text")).strip()
if not body:
continue
replies.append(
{
"tone": _s(r.get("tone") or r.get("intent") or "정중한 답장"),
"preview": _s(r.get("preview") or body[:60]),
"body": body,
}
)
prio = "높음" if ("[중요]" in email.subject or "마감" in text) else "보통"
file = None
f = d.get("file")
if isinstance(f, dict) and (f.get("project") or f.get("reason")):
file = {"project": _s(f.get("project") or "메일"), "reason": _s(f.get("reason"))}
return {
"summary": (email.preview or email.subject)[:60],
"summary": _s(d.get("summary")).strip(),
"priority": prio,
"category": "메일",
"category": _s(d.get("category")).strip() or "메일",
"tasks": tasks,
"events": events,
"replies": [],
"file": None,
"replies": replies,
"file": file,
}
def analyze_email(s: Session, email: Email, provider: LLMProvider) -> dict:
"""신규/재분석용. 시드 메일은 ai_json 이 이미 있으면 그대로 사용(결정성)."""
"""신규/재분석용. 이미 분석된 메일은 ai_json 재사용, 신규는 실 LLM(폴백 없음)."""
if email.ai_json:
return email.ai_json
prompt = ANALYZE_PROMPT.format(
subject=email.subject, sender=email.from_key, body="\n".join(email.body or [])
subject=email.subject, sender=email.from_key, body="\n".join(email.body or [])[:4000]
)
try:
result = provider.generate_json(prompt, ANALYZE_SCHEMA)
if not result or not result.get("summary"):
result = _heuristic_analyze(email)
except Exception:
result = _heuristic_analyze(email)
raw = provider.generate_json(prompt, ANALYZE_SCHEMA) # LLM 미가용 시 예외 전파
result = _coerce_analysis(raw)
email.ai_json = result
s.add(email)
s.commit()

@ -1,4 +1,47 @@
# backend/tests/test_api_mail.py
# phase-16+: 메일 mock 시드 제거 → 계정/메일을 테스트가 직접 생성. 발송은 연결된 계정 필요.
from app.connectors.mail.normalize import mail_account_id_for
from tests._factories import make_connected_mail_account, make_email, make_mail_account
M1_AI = {
"summary": "온보딩 시안 v3 검토 요청",
"priority": "높음",
"category": "검토 요청",
"tasks": [
{"text": "온보딩 시안 v3 피드백 정리", "project": "온보딩 리디자인 · 와이어프레임",
"due": "", "prio": "높음"},
{"text": "푸시 알림 문구 확인", "project": "온보딩 리디자인", "due": "", "prio": "보통"},
],
"events": [{"title": "온보딩 리뷰", "date": "6/10", "day": "오늘", "time": "14:00",
"dur": "30", "place": "Figma"}],
"replies": [{"tone": "정중", "preview": "", "body": "확인했습니다. 금요일까지 회신드릴게요."}],
"file": {"project": "온보딩 리디자인", "reason": "온보딩 자료"},
}
M2_AI = {
"summary": "회의 일정", "priority": "보통", "category": "일정", "tasks": [],
"events": [{"title": "스프린트 미팅", "date": "6/11", "day": "내일", "time": "15:00",
"dur": "60", "place": ""}],
"replies": [], "file": None,
}
M7_AI = {
"summary": "결제 영수증", "priority": "낮음", "category": "영수증",
"tasks": [], "events": [], "replies": [], "file": None,
}
def _setup(s):
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
make_mail_account(s, id="personal", name="개인", tone="blue", sort_order=1)
make_mail_account(s, id="side", name="사이드", tone="violet", sort_order=2)
make_email(s, id="m1", account="work", from_key="hyunwoo", subject="온보딩 시안 v3",
ai_json=M1_AI, sort_order=0)
make_email(s, id="m2", account="work", from_key="minseo", subject="회의 일정",
ai_json=M2_AI, sort_order=1)
make_email(s, id="m5", account="side", from_key="book", subject="독서모임", sort_order=2)
make_email(s, id="m7", account="personal", from_key="stripe", subject="영수증",
ai_json=M7_AI, sort_order=3)
def _flatten_titles(nodes):
out = []
for n in nodes:
@ -7,54 +50,81 @@ def _flatten_titles(nodes):
return out
def test_list_accounts(client):
def test_list_accounts(client, session):
s, _ = session
_setup(s)
r = client.get("/api/mail/accounts")
assert [a["id"] for a in r.json()] == ["work", "personal", "side"]
assert r.json()[0]["tone"] == "coral"
def test_list_mail_count(client):
def test_list_mail_count(client, session):
s, _ = session
_setup(s)
rows = client.get("/api/mail").json()
assert len(rows) == 8
assert rows[0]["from"] == "hyunwoo" # alias
assert len(rows) == 4
assert rows[0]["from"] == "hyunwoo" # alias, sort_order 0
def test_email_detail_marks_read(client):
def test_email_detail_marks_read(client, session):
s, _ = session
_setup(s)
assert client.get("/api/mail/m1").json()["read"] is True
assert client.get("/api/mail/m1").json()["from"] == "hyunwoo"
def test_extract_task_creates_task(client):
r = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0})
body = r.json()
def test_extract_task_creates_task(client, session):
s, _ = session
_setup(s)
body = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0}).json()
assert body["kind"] == "task"
assert body["task"]["project_id"] == "onb-wire"
assert body["task"]["prio"] == "높음"
assert "온보딩 시안 v3 피드백 정리" in _flatten_titles(client.get("/api/tasks").json())
def test_extract_event_creates_event(client):
def test_extract_event_creates_event(client, session):
s, _ = session
_setup(s)
r = client.post("/api/mail/m2/extract", json={"kind": "event", "index": 0})
assert r.json()["kind"] == "event" and r.json()["event_id"]
def test_extract_all(client):
def test_extract_all(client, session):
s, _ = session
_setup(s)
r = client.post("/api/mail/m1/extract-all", json={})
assert len(r.json()["created_task_ids"]) == 2
assert r.json()["filed_project"] == "온보딩 리디자인"
def test_reply_draft_picks_seeded(client):
def test_reply_draft_picks_seeded(client, session):
s, _ = session
_setup(s)
r = client.post("/api/mail/m1/reply-draft", json={"reply_index": 0})
assert r.json()["subject"].startswith("Re: ")
assert "금요일" in r.json()["body"]
def test_send_creates_high_risk_approval(client):
def test_send_requires_connected_account(client, session):
s, _ = session
_setup(s)
# 미연결 계정으로는 발송 거부(가짜 발송 없음)
bad = client.post(
"/api/mail/send",
json={"from_account": "work", "to": "현우 <hyunwoo@lumi.co>", "subject": "Re", "body": "x"},
)
assert bad.status_code == 400
def test_send_creates_high_risk_approval(client, session):
s, _ = session
make_connected_mail_account(s, email="me@gmail.com")
slug = mail_account_id_for("me@gmail.com")
r = client.post(
"/api/mail/send",
json={
"from_account": "work",
"from_account": slug,
"to": "현우 <hyunwoo@lumi.co>",
"subject": "Re: 온보딩",
"body": "확인했어요",
@ -65,7 +135,9 @@ def test_send_creates_high_risk_approval(client):
assert appr["risk"] == "high" and appr["source"] == "mail"
def test_m7_empty_ai(client):
def test_m7_empty_ai(client, session):
s, _ = session
_setup(s)
ai = client.get("/api/mail/m7").json()["ai"]
assert ai["priority"] == "낮음" and ai["tasks"] == [] and ai["events"] == []
@ -74,6 +146,58 @@ def test_extract_404(client):
assert client.post("/api/mail/nope/extract", json={"kind": "task"}).status_code == 404
def test_account_filter(client):
def test_account_filter(client, session):
s, _ = session
_setup(s)
rows = client.get("/api/mail?account=side").json()
assert all(r["account"] == "side" for r in rows) and len(rows) == 1 # m5
# ── 보낸편지함/임시보관함 = OutboundMail(sent/pending) ──
def _make_outbound(s, *, id, mail_account, status, subject, body="본문 내용", to="대표님",
sort=0):
from app.models import OutboundMail
ob = OutboundMail(
id=id, approval_id=f"ap-{id}", connector_account_id=None, mail_account=mail_account,
to=to, subject=subject, body=body, status=status,
)
s.add(ob)
s.commit()
return ob
def test_sent_folder_returns_sent_outbound(client, session):
s, _ = session
make_mail_account(s, id="work", name="회사", email="me@work.com")
_make_outbound(s, id="ob1", mail_account="work", status="sent", subject="보고서 보냄",
body="첨부 확인 부탁드립니다.")
_make_outbound(s, id="ob2", mail_account="work", status="pending", subject="대기 중인 메일")
rows = client.get("/api/mail/sent").json()
ids = [r["id"] for r in rows]
assert ids == ["ob1"] # pending 은 제외
r = rows[0]
assert r["subject"] == "보고서 보냄"
assert r["read"] is True
assert r["from"] == "me@work.com" # 발신 계정 이메일
assert "첨부 확인" in r["preview"]
def test_drafts_folder_returns_pending_outbound(client, session):
s, _ = session
make_mail_account(s, id="work", name="회사", email="me@work.com")
_make_outbound(s, id="obA", mail_account="work", status="sent", subject="이미 보냄")
_make_outbound(s, id="obB", mail_account="work", status="pending", subject="결재 대기")
rows = client.get("/api/mail/drafts").json()
assert [r["id"] for r in rows] == ["obB"]
assert rows[0]["subject"] == "결재 대기"
def test_sent_folder_account_filter(client, session):
s, _ = session
make_mail_account(s, id="work", name="회사", email="me@work.com")
make_mail_account(s, id="personal", name="개인", email="me@home.com", sort_order=1)
_make_outbound(s, id="obW", mail_account="work", status="sent", subject="회사 발신")
_make_outbound(s, id="obP", mail_account="personal", status="sent", subject="개인 발신")
rows = client.get("/api/mail/sent?account=personal").json()
assert [r["id"] for r in rows] == ["obP"]

@ -1,6 +1,6 @@
import pytest
from app.llm.heuristic import HeuristicProvider
from tests._fake_llm import FakeLLMProvider
CTX = {
"projects": [
@ -21,7 +21,7 @@ GOLDEN = [
@pytest.mark.parametrize("raw,typ,sphere,proj_kw,extra_kw", GOLDEN)
def test_golden(raw, typ, sphere, proj_kw, extra_kw):
c = HeuristicProvider().classify_capture(raw, CTX)
c = FakeLLMProvider().classify_capture(raw, CTX)
assert c.type == typ, f"{raw} → type {c.type}"
assert c.sphere == sphere, f"{raw} → sphere {c.sphere}"
assert proj_kw in c.proj_label, f"{raw} → proj_label {c.proj_label}"

@ -1,17 +1,18 @@
# backend/tests/test_mail_ai.py
from app.llm.heuristic import HeuristicProvider
# backend/tests/test_mail_ai.py — phase-16+: 메일 시드 제거 → 직접 생성
from app.models import Email
from app.services.mail_ai import analyze_email
from tests._factories import make_email
from tests._fake_llm import FakeLLMProvider
def test_seed_email_uses_stored_ai(client, session):
def test_existing_ai_is_reused(client, session):
s, _ = session
e = s.get(Email, "m1")
out = analyze_email(s, e, HeuristicProvider())
assert out["summary"].startswith("온보딩 시안 v3") # 시드 ai_json 재사용(결정성)
e = make_email(s, id="m1", subject="온보딩 시안 v3", ai_json={"summary": "온보딩 시안 v3 요약"})
out = analyze_email(s, e, FakeLLMProvider())
assert out["summary"].startswith("온보딩 시안 v3") # 기존 ai_json 재사용(LLM 미호출)
def test_new_email_heuristic_fallback(client, session):
def test_new_email_analyzed_by_llm(client, session):
s, _ = session
e = Email(
id="mX",
@ -23,6 +24,6 @@ def test_new_email_heuristic_fallback(client, session):
)
s.add(e)
s.commit()
out = analyze_email(s, e, HeuristicProvider())
out = analyze_email(s, e, FakeLLMProvider()) # 실 LLM 대역(결정적)
assert out["priority"] == "높음" # [중요]
assert len(out["events"]) == 1 # 14시 → event 후보

@ -2,8 +2,7 @@
import base64
from app.connectors.base import RawRecord
from app.connectors.calendar.normalize import normalize_event
from app.connectors.finance.normalize import categorize, normalize_tx
from app.connectors.calendar.normalize import account_calendar_id, normalize_event
from app.connectors.mail.normalize import normalize_email
from app.models import ConnectorAccount, ConnectorDomain
@ -47,10 +46,239 @@ def test_gmail_email_golden():
assert f["read"] is False # UNREAD 존재
assert f["starred"] is True
assert f["has_attach"] is True
# 수신 시각: internalDate(epoch ms) → aware datetime(정렬·표시 기준).
assert f["received_at"] is not None
assert f["received_at"].year == 2024 # 1718000000000ms = 2024-06-10
def test_gmail_plain_strips_conditional_comments_and_vml():
"""text/plain 파트에 MSO 조건부 주석·VML 블록·<url> 래핑이 섞인 (Google 알림류) 메일.
본문에 HTML 잔재가 남지 않고 URL 언랩되어 보존되어야 한다."""
acct = _acct("ca-mail-personal", "mail")
body = (
"<!--[if !mso]><!-->\n"
"<!--[if false]><!-->\n"
"본인의 계정으로 로그인했습니다\n"
"<https://c.gle/AOPyTOKEN>magnific.com\n"
"<!--[if mso]>\n"
'<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"\n'
'style="height:48px;" fillcolor="#0b57d0">\n'
"<w:anchorlock/>\n"
"<![endif]-->\n"
"계정으로 이동\n"
"&copy; 2026 Google LLC"
)
payload = {
"id": "gmail-2",
"labelIds": ["CATEGORY_UPDATES"],
"snippet": "스니펫",
"internalDate": "1718000000000",
"payload": {
"mimeType": "text/plain",
"body": {"data": _b64(body)},
},
}
norm = normalize_email(acct, RawRecord("gmail-2", payload, etag="h"), provider="gmail")
text = "\n".join(norm.fields["body"])
assert "<!--" not in text and "<![" not in text # 조건부 주석 제거
assert "v:roundrect" not in text and "<w:" not in text # VML 제거
assert "fillcolor" not in text and "anchorlock" not in text
assert "본인의 계정으로 로그인했습니다" in text # 평문 보존
assert "https://c.gle/AOPyTOKEN" in text # URL 언랩·보존
assert "© 2026 Google LLC" in text # 엔티티 디코딩
def test_gmail_captures_html_body_and_strips_active_content():
"""text/html 파트가 있으면 원본 HTML 을 body_html 로 보존(서식/이미지) 하되,
script/on* 능동 콘텐츠는 제거하고 인라인 cid 첨부는 content_id 함께 수집한다."""
acct = _acct("ca-mail-personal", "mail")
html = (
'<div style="color:red">안녕하세요 <b>홍길동</b>님'
'<img src="https://ex.com/a.png"><img src="cid:logo123">'
'<script>alert(1)</script><a href="javascript:evil()" onclick="x()">링크</a></div>'
)
payload = {
"id": "gmail-h",
"labelIds": [],
"snippet": "s",
"internalDate": "1718000000000",
"payload": {
"mimeType": "multipart/alternative",
"parts": [
{"mimeType": "text/plain", "body": {"data": _b64("안녕하세요 홍길동님")}},
{"mimeType": "text/html", "body": {"data": _b64(html)}},
{
"mimeType": "image/png",
"filename": "logo.png",
"headers": [{"name": "Content-ID", "value": "<logo123>"}],
"body": {"attachmentId": "att-logo", "size": 99},
},
],
},
}
norm = normalize_email(acct, RawRecord("gmail-h", payload, etag="h"), provider="gmail")
f = norm.fields
bh = f["body_html"]
assert "<b>홍길동</b>" in bh # 서식 보존
assert 'src="https://ex.com/a.png"' in bh # 이미지 보존
assert "cid:logo123" in bh # cid 는 읽기 시 치환(정규화 단계선 유지)
assert "<script" not in bh and "alert(1)" not in bh # script 제거
assert "onclick" not in bh # on* 핸들러 제거
assert "javascript:" not in bh # javascript: 스킴 제거
# 인라인 cid 이미지가 content_id 와 함께 첨부로 수집됨
inline = [a for a in f["attachments"] if a.get("content_id") == "logo123"]
assert inline and inline[0]["inline"] is True and inline[0]["id"] == "att-logo"
def test_outlook_captures_html_body():
"""Outlook html 본문(Prefer html)이 body_html 로 보존된다."""
acct = _acct("ca-mail-personal", "mail")
payload = {
"id": "ol-h",
"from": {"emailAddress": {"address": "a@b.com"}},
"subject": "공지",
"body": {"contentType": "html", "content": "<p>안내 <b>본문</b></p><script>x()</script>"},
"toRecipients": [{"emailAddress": {"address": "me@x.com"}}],
"attachments": [],
}
payload["receivedDateTime"] = "2026-06-18T11:57:01Z"
norm = normalize_email(acct, RawRecord("ol-h", payload, etag="h"), provider="outlook")
bh = norm.fields["body_html"]
assert "<b>본문</b>" in bh and "<script" not in bh
# 수신 시각: receivedDateTime(ISO) → aware datetime.
rcv = norm.fields["received_at"]
assert rcv is not None and rcv.year == 2026 and rcv.month == 6 and rcv.day == 18
def test_email_category_and_list_time():
"""라벨 → 카테고리 매핑(프로모션/소셜/업데이트/기본)과 상대시각 산출."""
from datetime import UTC, datetime, timedelta
from app.routers.mail import _category_for, _list_time
assert _category_for(["프로모션"]) == "promotions"
assert _category_for(["소셜"]) == "social"
assert _category_for(["업데이트"]) == "updates"
assert _category_for(["포럼"]) == "updates"
assert _category_for(["중요", "개인"]) == "primary"
assert _category_for([]) == "primary"
# 상대시각: 방금 / N분 전, received_at 없으면 fallback 사용.
now = datetime.now(UTC)
assert _list_time(now, "x") == "방금"
assert _list_time(now - timedelta(minutes=8), "x") == "8분 전"
assert _list_time(None, "8분 전") == "8분 전"
def test_gmail_plain_shortens_tracking_url_and_decodes_uc_entity():
"""초장문 트래킹 URL 은 host/… 로 축약, 대문자 엔티티(&NBSP;)는 디코딩."""
acct = _acct("ca-mail-personal", "mail")
long_url = "http://url3015.example.com/ls/click?upn=" + "A1b2C3d4" * 40
body = f"[Nellis Auction]{long_url}\n가격&NBSP;업데이트\n실제 안내 내용입니다"
payload = {
"id": "gmail-3",
"labelIds": [],
"snippet": "s",
"internalDate": "1718000000000",
"payload": {"mimeType": "text/plain", "body": {"data": _b64(body)}},
}
norm = normalize_email(acct, RawRecord("gmail-3", payload, etag="h"), provider="gmail")
text = "\n".join(norm.fields["body"])
assert long_url not in text # 원본 장문 URL 사라짐
assert "http://url3015.example.com/…" in text # host/… 로 축약
assert "가격 업데이트" in text # &NBSP; → 공백
assert "실제 안내 내용입니다" in text # 본문 보존
def test_gmail_folder_routing():
"""labelIds → 시스템 폴더(스팸/휴지통/받은편지함). 카테고리는 폴더 아님."""
from app.connectors.mail.normalize import _gmail_folder
assert _gmail_folder(["SPAM"]) == "spam"
assert _gmail_folder(["TRASH"]) == "trash"
assert _gmail_folder(["INBOX", "CATEGORY_PROMOTIONS"]) == "inbox"
assert _gmail_folder(["SENT"]) == "sent"
def test_email_in_folder_predicate():
"""폴더 슬러그 필터: 받은편지함=집중만, 카테고리/라벨/스팸/휴지통/커스텀 분기."""
from app.models import Email
from app.routers.mail import _email_in_folder
inbox = Email(id="a", account="x", from_key="f", folder="inbox", labels=[])
promo = Email(id="b", account="x", from_key="f", folder="inbox", labels=["프로모션"])
spam = Email(id="c", account="x", from_key="f", folder="spam", labels=[])
labeled = Email(id="d", account="x", from_key="f", folder="inbox", labels=["Shopping"])
custom = Email(id="e", account="x", from_key="f", folder="of:XYZ", labels=[])
assert _email_in_folder(inbox, "inbox") is True
assert _email_in_folder(promo, "inbox") is False # 프로모션은 집중(받은편지함)서 빠짐
assert _email_in_folder(promo, "others") is True # → 기타로
assert _email_in_folder(promo, "cat:promotions") is True
assert _email_in_folder(spam, "spam") is True
assert _email_in_folder(spam, "inbox") is False # 스팸은 받은편지함에 안 나옴
assert _email_in_folder(labeled, "label:Shopping") is True
assert _email_in_folder(custom, "of:XYZ") is True
assert _email_in_folder(inbox, "of:XYZ") is False
def test_sender_display_name():
"""보낸사람 표시 이름: '이름 <메일>'의 이름, 없으면 도메인 추정, 메일주소 분리."""
from app.connectors.mail.normalize import _outlook_from, sender_parts
assert sender_parts("Costco Wholesale <c@digital.costco.com>") == (
"Costco Wholesale",
"c@digital.costco.com",
)
# 표시 이름 없음 → 도메인(SLD) 기반 추정(서브도메인 제거).
assert sender_parts("noreply@e.coupang.com") == ("Coupang", "noreply@e.coupang.com")
assert sender_parts("info@trupanion.com")[0] == "Trupanion"
# Outlook 발신자: 이름 보존('이름 <메일>'), 이름 없으면 주소만.
assert _outlook_from({"name": "MS Team", "address": "a@ms.com"}) == "MS Team <a@ms.com>"
assert _outlook_from({"name": "", "address": "n@x.com"}) == "n@x.com"
def test_outlook_inference_and_other_folder():
"""Outlook inferenceClassification(focused/other) 정규화 + '기타' 폴더 라우팅."""
from app.connectors.mail.normalize import normalize_email
from app.models import Email
from app.routers.mail import _email_in_folder
acct = _acct("ca-mail-personal", "mail")
payload = {
"id": "ol-i",
"from": {"emailAddress": {"name": "쿠팡", "address": "x@coupang.com"}},
"subject": "광고",
"body": {"contentType": "text", "content": "내용"},
"toRecipients": [{"emailAddress": {"address": "me@x.com"}}],
"attachments": [],
"inferenceClassification": "other",
"receivedDateTime": "2026-06-18T11:57:01Z",
}
norm = normalize_email(acct, RawRecord("ol-i", payload, etag="i"), provider="outlook")
assert norm.fields["inference"] == "other"
assert norm.fields["from_key"] == "쿠팡 <x@coupang.com>"
# 집중(inbox)/기타(others) 탭 분류 — Outlook inference + Gmail 카테고리 통합.
ol_focused = Email(id="f", account="x", from_key="f", folder="inbox", inference="focused")
ol_other = Email(id="o", account="x", from_key="f", folder="inbox", inference="other")
gm_primary = Email(id="g", account="x", from_key="f", folder="inbox", labels=[])
gm_promo = Email(id="p", account="x", from_key="f", folder="inbox", labels=["프로모션"])
# 집중: Outlook focused + Gmail 기본(primary)
assert _email_in_folder(ol_focused, "inbox") is True
assert _email_in_folder(gm_primary, "inbox") is True
assert _email_in_folder(ol_other, "inbox") is False
assert _email_in_folder(gm_promo, "inbox") is False
# 기타: Outlook other + Gmail 비기본(프로모션 등)
assert _email_in_folder(ol_other, "others") is True
assert _email_in_folder(gm_promo, "others") is True
assert _email_in_folder(ol_focused, "others") is False
assert _email_in_folder(gm_primary, "others") is False
def test_google_calendar_event_golden():
acct = _acct("ca-cal-google", "calendar")
acct.provider = "google_calendar"
acct.external_account_id = "me@gmail.com"
payload = {
"id": "ev-1",
"summary": "분기 전략 미팅",
@ -64,17 +292,34 @@ def test_google_calendar_event_golden():
assert norm.entity_type == "event"
assert f["day"] == 10 and f["start"] == "14:00" and f["end"] == "15:00"
assert f["title"] == "분기 전략 미팅" and f["loc"] == "대회의실"
assert f["cal"] == "work" # 유효 calendar 로 폴백
# 이벤트는 연결된 Google 계정 캘린더(계정별 슬러그)로 귀속.
assert f["cal"] == account_calendar_id(acct) and f["cal"].startswith("gcal-")
assert f["people"] == "a@x.co, b@x.co"
def test_finance_categorize():
assert categorize("스타벅스 강남R점") == "카페·간식"
assert categorize("카카오T 택시") == "교통"
assert categorize("넷플릭스 멤버십") == "구독"
acct = _acct("ca-fin-woori", "finance", name="우리카드")
norm = normalize_tx(
acct, RawRecord("t1", {"merchant": "쿠팡", "amount": "23,900", "date": "2026-06-08"})
)
assert norm.fields["amount"] == 23900 and norm.fields["category"] == "쇼핑"
assert norm.fields["account_label"] == "우리카드"
def test_outlook_calendar_event_golden():
acct = _acct("ca-cal-outlook", "calendar")
acct.provider = "outlook_calendar"
acct.external_account_id = "me@corp.com"
payload = {
"id": "AAMk-ev-1",
"subject": "주간 스탠드업",
"bodyPreview": "지난주 회고 + 이번주 계획",
"location": {"displayName": "3층 포커스룸"},
"start": {"dateTime": "2026-06-12T10:30:00.0000000", "timeZone": "Korea Standard Time"},
"end": {"dateTime": "2026-06-12T11:00:00.0000000", "timeZone": "Korea Standard Time"},
"attendees": [
{"emailAddress": {"address": "lead@corp.com", "name": "리드"}},
{"emailAddress": {"address": "me@corp.com"}},
],
"@odata.etag": 'W/"abc"',
}
norm = normalize_event(acct, RawRecord("AAMk-ev-1", payload), provider="outlook_calendar")
f = norm.fields
assert norm.entity_type == "event"
assert f["day"] == 12 and f["start"] == "10:30" and f["end"] == "11:00"
assert f["title"] == "주간 스탠드업" and f["loc"] == "3층 포커스룸"
# Outlook 이벤트도 연결된 계정 캘린더(계정별 슬러그)로 귀속.
assert f["cal"] == account_calendar_id(acct) and f["cal"].startswith("ocal-")
assert f["note"] == "지난주 회고 + 이번주 계획"
assert f["people"] == "lead@corp.com, me@corp.com"

@ -0,0 +1,298 @@
# backend/tests/test_phase16_mail.py — phase-16 실계정 메일(Gmail/Outlook OAuth + 멀티계정 + 발송)
import time
import pytest
from sqlmodel import select
from app.config import get_settings
from app.connectors import oauth
from app.connectors.base import RawRecord
from app.connectors.mail.normalize import mail_account_id_for, normalize_email
from app.connectors.mail.outbound import send_outbound
from app.connectors.mail.real_outlook import OutlookConnector
from app.connectors.registry import ConnectorRegistry, _impl
from app.crypto import encrypt_token
from app.models import (
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
MailAccount,
OAuthState,
OutboundMail,
Sent,
)
class _FakeResp:
def __init__(self, status, payload=None):
self.status_code = status
self._payload = payload or {}
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
@pytest.fixture()
def oauth_creds(monkeypatch):
monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "g-sec")
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid")
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _mock_token_and_identity(monkeypatch, *, email, token=None):
tok = token or {"access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "s"}
monkeypatch.setattr(oauth.httpx, "post", lambda *a, **k: _FakeResp(200, tok))
# google: gmail/v1/users/me/profile → emailAddress ; microsoft: graph /me → mail
monkeypatch.setattr(
oauth.httpx,
"get",
lambda *a, **k: _FakeResp(200, {"emailAddress": email, "mail": email}),
)
# ── OAuth: Outlook provider + tenant URL ──
def test_outlook_authorize_url_uses_microsoft_tenant(session, oauth_creds):
s, _ = session
url = oauth.start_oauth(s, "mail", "outlook", redirect_after="/settings?tab=mail")
assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
assert "code_challenge=" in url and "state=" in url # PKCE 보존
assert "Mail.Read" in url and "Mail.Send" in url and "offline_access" in url
# ── 멀티계정: 이메일별 고유 계정 id + MailAccount upsert ──
def test_finish_oauth_creates_unique_account_per_email(session, oauth_creds, monkeypatch):
s, _ = session
def connect(provider, email):
url = oauth.start_oauth(s, "mail", provider)
state = url.split("state=")[1].split("&")[0]
_mock_token_and_identity(monkeypatch, email=email)
return oauth.finish_oauth(s, code="C", state=state)
a1 = connect("gmail", "alice@gmail.com")
a2 = connect("gmail", "bob@gmail.com")
a3 = connect("outlook", "carol@outlook.com")
# 같은 provider 여도 이메일이 다르면 계정 id 가 갈린다(덮어쓰기 없음)
assert a1.id != a2.id
assert a1.external_account_id == "alice@gmail.com"
assert a3.provider == "outlook" and a3.mode == ConnectorMode.real
# 메일 페이지 노출용 MailAccount 도 함께 생성
assert s.get(MailAccount, mail_account_id_for("alice@gmail.com")) is not None
assert s.get(MailAccount, mail_account_id_for("carol@outlook.com")) is not None
# state row 소거
assert s.get(OAuthState, "x") is None
# ── normalize: Graph(Outlook) 메시지 → 내부 Email 필드 ──
def test_normalize_outlook_golden(session):
acct = ConnectorAccount(
id="ca-mail-outlook-xyz",
domain=ConnectorDomain.mail,
provider="outlook",
external_account_id="me@outlook.com",
)
payload = {
"id": "AAMk-1",
"subject": "분기 리포트 검토 부탁",
"bodyPreview": "안녕하세요, 리포트 초안입니다.",
"body": {"contentType": "text", "content": "안녕하세요,\n리포트 초안입니다.\n확인 부탁드려요."},
"from": {"emailAddress": {"name": "현우", "address": "hyunwoo@corp.com"}},
"toRecipients": [{"emailAddress": {"address": "me@outlook.com"}}],
"isRead": False,
"flag": {"flagStatus": "flagged"},
"hasAttachments": True,
"categories": ["업무"],
"receivedDateTime": "2026-06-15T09:00:00Z",
}
raw = RawRecord(external_id="AAMk-1", payload=payload, etag="W/1")
norm = normalize_email(acct, raw, provider="outlook")
f = norm.fields
assert f["account"] == mail_account_id_for("me@outlook.com")
# 발신자 표시 이름 보존('이름 <메일>') — 목록은 이름만, 리더는 이름+주소 노출.
assert f["from_key"] == "현우 <hyunwoo@corp.com>"
assert f["subject"] == "분기 리포트 검토 부탁"
assert f["read"] is False and f["starred"] is True and f["has_attach"] is True
assert "리포트 초안입니다." in " ".join(f["body"])
assert f["labels"] == ["업무"]
def test_normalize_outlook_html_body_stripped(session):
acct = ConnectorAccount(id="ca-o2", domain=ConnectorDomain.mail, provider="outlook")
payload = {
"id": "h1",
"subject": "공지",
"bodyPreview": "프리뷰",
"body": {"contentType": "html", "content": "<div><p>안녕하세요</p><p>좋은 하루</p></div>"},
"from": {"emailAddress": {"address": "x@y.com"}},
"isRead": True,
}
norm = normalize_email(acct, RawRecord(external_id="h1", payload=payload), provider="outlook")
# 태그 제거 + 블록(<p>) 경계마다 줄바꿈 → 읽기 좋은 다중 줄.
assert norm.fields["body"] == ["안녕하세요", "좋은 하루"]
# ── registry: provider 로 Gmail/Outlook 분기 ──
def test_connector_dispatch_by_provider(session):
s, _ = session
from app.connectors.mail.real_gmail import GmailConnector
assert _impl("mail", "real", "outlook").__name__ == "OutlookConnector"
assert _impl("mail", "real", "gmail").__name__ == "GmailConnector"
out_acct = ConnectorAccount(
id="ca-o", domain=ConnectorDomain.mail, provider="outlook", mode=ConnectorMode.real
)
gm_acct = ConnectorAccount(
id="ca-g", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real
)
assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookConnector)
assert isinstance(ConnectorRegistry.get(s, gm_acct), GmailConnector)
# ── 발송: real 연결 계정 → connector.send_mail 호출 ──
def test_send_outbound_real_calls_connector(session, monkeypatch):
s, _ = session
acct = ConnectorAccount(
id="ca-mail-gmail-abc",
domain=ConnectorDomain.mail,
provider="gmail",
mode=ConnectorMode.real,
state=ConnState.connected,
external_account_id="me@gmail.com",
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
)
s.add(acct)
ob = OutboundMail(
id="ob-1",
approval_id="ap-1",
connector_account_id=acct.id,
mail_account=mail_account_id_for("me@gmail.com"),
to="친구 <pal@x.com>",
subject="안녕",
body="잘 지내?",
status="pending",
)
s.add(ob)
s.commit()
calls = {}
from app.connectors.mail.real_gmail import GmailConnector
def fake_send(self, session, *, to, subject, body, cc="", bcc="", attachments=None):
calls.update(to=to, subject=subject, body=body)
return "sent"
monkeypatch.setattr(GmailConnector, "send_mail", fake_send)
out = send_outbound(s, ob)
assert out.status == "sent" and out.sent_at is not None
assert calls == {"to": "친구 <pal@x.com>", "subject": "안녕", "body": "잘 지내?"}
# ── 발송: 미연결 → failed(가짜 발송 없음, phase-16+) ──
def test_send_outbound_unconnected_fails(session):
s, _ = session
ob = OutboundMail(
id="ob-2",
approval_id="ap-2",
connector_account_id=None,
mail_account="work",
to="대표님",
subject="보고",
body="첨부 확인 부탁드립니다.",
status="pending",
)
s.add(ob)
s.commit()
out = send_outbound(s, ob)
assert out.status == "failed" # 가짜 Sent 행 생성 없음
assert s.exec(select(Sent)).all() == []
# ── 발송 실패는 failed 로 격리(서버 안 죽음) ──
def test_send_outbound_failure_isolated(session, monkeypatch):
s, _ = session
acct = ConnectorAccount(
id="ca-fail",
domain=ConnectorDomain.mail,
provider="gmail",
mode=ConnectorMode.real,
state=ConnState.connected,
external_account_id="me@gmail.com",
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
)
s.add(acct)
ob = OutboundMail(
id="ob-3", approval_id="ap-3", connector_account_id=acct.id, mail_account="x",
to="a@b.com", subject="s", body="b", status="pending",
)
s.add(ob)
s.commit()
from app.connectors.mail.real_gmail import GmailConnector
def boom(self, session, **k):
raise RuntimeError("network down")
monkeypatch.setattr(GmailConnector, "send_mail", boom)
out = send_outbound(s, ob)
assert out.status == "failed" and "network down" in out.error_detail
# ── API: /connectors/providers 구성 여부 ──
def test_providers_endpoint_unconfigured(client):
rows = client.get("/api/connectors/providers?domain=mail").json()
by = {r["provider"]: r for r in rows}
assert set(by) == {"gmail", "outlook"}
assert by["gmail"]["configured"] is False and by["outlook"]["configured"] is False
def test_providers_endpoint_configured(client, oauth_creds):
rows = client.get("/api/connectors/providers").json()
by = {r["provider"]: r for r in rows}
assert by["gmail"]["configured"] is True and by["outlook"]["configured"] is True
# ── API: /mail/send (연결된 계정) → 결재 + OutboundMail(pending) 생성 ──
def test_mail_send_creates_pending_outbound(client, session):
s, _ = session
acct = ConnectorAccount(
id="ca-mail-gmail-x", domain=ConnectorDomain.mail, provider="gmail",
mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com",
)
s.add(acct)
s.commit()
slug = mail_account_id_for("me@gmail.com")
r = client.post(
"/api/mail/send",
json={"from_account": slug, "to": "대표님", "subject": "보고", "body": "확인 부탁"},
)
assert r.status_code == 200
aid = r.json()["approval_id"]
s.expire_all()
ob = s.exec(select(OutboundMail).where(OutboundMail.approval_id == aid)).first()
assert ob is not None and ob.status == "pending"
assert ob.connector_account_id == "ca-mail-gmail-x" # 연결된 real 계정에 묶임
assert ob.subject == "보고"
def test_mail_send_unconnected_rejected(client, session):
# 연결된 계정 없으면 400(가짜 발송 없음)
r = client.post(
"/api/mail/send",
json={"from_account": "work", "to": "대표님", "subject": "보고", "body": "x"},
)
assert r.status_code == 400
# ── 시작 검증: 미구성 outlook 은 400 ──
def test_oauth_start_outlook_requires_microsoft_client_id(client):
r = client.get("/api/connectors/oauth/start?domain=mail&provider=outlook")
assert r.status_code == 400

@ -0,0 +1,48 @@
# backend/tests/test_triage_baseline.py
# 아리 정리(비서) 기준 시각: smart 보기/분석은 '기준 이후 도착한 새 메일'만 다룬다.
from datetime import datetime, timedelta
from tests._factories import make_email, make_mail_account
def _seed_old_new(s, since: datetime):
"""기준 시각 전/후로 도착한 집중(focused) 안읽음 메일을 하나씩 생성."""
make_mail_account(s, id="work", name="회사", tone="coral", sort_order=0)
make_email(s, id="old", account="work", subject="지난 메일",
received_at=since - timedelta(hours=2))
make_email(s, id="new", account="work", subject="새 메일",
received_at=since + timedelta(hours=2))
def test_smart_folder_shows_only_new_mail(client, session):
s, _ = session
since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"])
_seed_old_new(s, since)
# 아리 정리(smart): 기준 이후 도착한 'new'만.
smart = [r["id"] for r in client.get("/api/mail?folder=smart").json()]
assert smart == ["new"]
# 사이드바 배지(새 메일 수, 읽음 무관)도 기준 이후 새 메일만 센다.
assert client.get("/api/mail/triage/baseline").json()["count"] == 1
# 일반 받은편지함(inbox)은 둘 다 보인다(기준 무관).
inbox = sorted(r["id"] for r in client.get("/api/mail?folder=inbox").json())
assert inbox == ["new", "old"]
def test_baseline_get_initializes_and_post_advances(client, session):
s, _ = session
first = client.get("/api/mail/triage/baseline").json()["since"]
assert first # 첫 조회 시 now 로 lazy-init
advanced = client.post("/api/mail/triage/baseline").json()["since"]
assert datetime.fromisoformat(advanced) >= datetime.fromisoformat(first)
def test_triage_analyze_skips_old_mail(client, session):
s, _ = session
since = datetime.fromisoformat(client.post("/api/mail/triage/baseline").json()["since"])
_seed_old_new(s, since)
res = client.post("/api/mail/triage/analyze").json()
assert res["analyzed"] == 1 # 새 메일 1통만 분석
assert res["remaining"] == 0
# 기준 이후 새 메일만 분석되고, 이전 메일의 ai 는 비어 있다.
assert client.get("/api/mail/new").json()["ai"] is not None
assert client.get("/api/mail/old").json()["ai"] is None

@ -0,0 +1,8 @@
// frontend/app/mail/[[...slug]]/page.tsx — 메일 페이지 (옵셔널 캐치올: /mail, /mail/<id>)
// <id> 세그먼트는 MailClient 가 useParams() 로 읽어 해당 메일을 바로 연다.
import "@/styles/mail.css";
import { MailClient } from "@/components/mail/MailClient";
export default function MailPage() {
return <MailClient />;
}

@ -1,7 +0,0 @@
// frontend/app/mail/page.tsx — 메일 페이지 (server component; Topbar 는 layout)
import "@/styles/mail.css";
import { MailClient } from "@/components/mail/MailClient";
export default function MailPage() {
return <MailClient />;
}

@ -1,17 +1,26 @@
// frontend/components/mail/Compose.tsx — 작성 슬라이드오버 (계정 사이클 · AI 작성 도우미 · 보내기)
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@/components/Icon";
import type { MailAccount } from "@/lib/types";
import type { ComposeAttachment, MailAccount } from "@/lib/types";
export type ComposeState = {
from: string;
to?: string;
cc?: string;
bcc?: string;
subject?: string;
body?: string;
inReplyTo?: string | null;
attachments?: ComposeAttachment[];
};
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const AI_DEFAULT =
"안녕하세요,\n\n메일 잘 받았습니다. 내용 확인하고 빠른 시일 내 회신드리겠습니다.\n\n감사합니다.\n지우 드림";
@ -26,11 +35,37 @@ export function Compose({
onClose: () => void;
onSend: (c: Required<Pick<ComposeState, "from">> & ComposeState) => void;
}) {
const [from, setFrom] = useState(init.from || "work");
const [from, setFrom] = useState(init.from || accounts[0]?.id || "");
const [to, setTo] = useState(init.to || "");
const [cc, setCc] = useState(init.cc || "");
const [bcc, setBcc] = useState(init.bcc || "");
const [showCc, setShowCc] = useState(!!(init.cc || init.bcc));
const [subject, setSubject] = useState(init.subject || "");
const [body, setBody] = useState(init.body || "");
const [attachments, setAttachments] = useState<ComposeAttachment[]>(init.attachments || []);
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const onFiles = async (files: FileList | null) => {
if (!files) return;
const read = (f: File): Promise<ComposeAttachment> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
name: f.name,
mime: f.type || "application/octet-stream",
data_b64: String(reader.result).split(",")[1] ?? "",
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(f);
});
const added = await Promise.all(Array.from(files).map(read));
setAttachments((prev) => [...prev, ...added]);
if (fileRef.current) fileRef.current.value = "";
};
const removeAttachment = (i: number) =>
setAttachments((prev) => prev.filter((_, idx) => idx !== i));
const accMap: Record<string, MailAccount> = {};
accounts.forEach((a) => (accMap[a.id] = a));
@ -91,7 +126,34 @@ export function Compose({
placeholder="이름 또는 이메일"
aria-label="받는 사람"
/>
{!showCc && (
<button className="cp-cc-toggle" onClick={() => setShowCc(true)} type="button">
</button>
)}
</div>
{showCc && (
<>
<div className="cp-field">
<label></label>
<input
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc — 쉼표로 구분"
aria-label="참조"
/>
</div>
<div className="cp-field">
<label></label>
<input
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc — 쉼표로 구분"
aria-label="숨은참조"
/>
</div>
</>
)}
<div className="cp-field">
<label></label>
<input
@ -160,16 +222,45 @@ export function Compose({
aria-label="본문"
placeholder="내용을 입력하거나, 위에서 ‘초안 작성’을 눌러 아리에게 맡겨보세요…"
/>
{attachments.length > 0 && (
<div className="cp-attachments">
{attachments.map((at, i) => (
<div className="cp-att" key={i}>
<Icon name="file" />
<span className="cp-att-name">{at.name}</span>
<span className="cp-att-size">{fmtSize((at.data_b64.length * 3) / 4)}</span>
<button
className="cp-att-x"
onClick={() => removeAttachment(i)}
aria-label="첨부 제거"
type="button"
>
<Icon name="x" />
</button>
</div>
))}
</div>
)}
</div>
<div className="cp-foot">
<button
className="cp-send"
onClick={() => onSend({ from, to, subject, body, inReplyTo: init.inReplyTo })}
onClick={() =>
onSend({ from, to, cc, bcc, subject, body, inReplyTo: init.inReplyTo, attachments })
}
>
<Icon name="send" />
</button>
<button className="cp-tool" aria-label="첨부">
<input
ref={fileRef}
type="file"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
<button className="cp-tool" aria-label="첨부" onClick={() => fileRef.current?.click()}>
<Icon name="clip" />
</button>
<div className="cp-spacer" />

@ -1,10 +1,12 @@
// frontend/components/mail/MailClient.tsx — 메일 페이지 오케스트레이터 (Topbar 는 layout)
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR, { useSWRConfig } from "swr";
import { tasksApi } from "@/lib/tasks/api";
import { mailApi } from "@/lib/mail/api";
import { useMail } from "@/lib/hooks/useMail";
import { useDialog } from "@/components/Dialog";
import type { AiReply, EmailDetail, EmailRow, ExtractKind, MailAccount, Person } from "@/lib/types";
import { MailSidebar } from "./MailSidebar";
import { MailList } from "./MailList";
@ -12,9 +14,13 @@ import { Reader } from "./Reader";
import { Triage } from "./Triage";
import { Compose, type ComposeState } from "./Compose";
// 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정.
const ACC_PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"];
const FOLDER_META: Record<string, string> = {
smart: "아리 정리",
inbox: "받은편지함",
others: "기타",
starred: "별표",
sent: "보낸편지함",
drafts: "임시보관함",
@ -34,10 +40,12 @@ function toneColor(tone: string): string {
}
export function MailClient() {
// 폴더는 "smart"(아리 정리) 가 기본 — 백엔드는 smart→전체 inbox
const [folder, setFolder] = useState<string>("smart");
// 기본은 통합 받은편지함(전체 받은편지함). 아리 정리(smart)는 사이드바에서 선택.
// folder 는 폴더 슬러그(inbox/spam/trash/starred/cat:…/label:…/of:…) — 사이드바 트리가 지정.
const [folder, setFolder] = useState<string>("inbox");
const [acctFilter, setAcctFilter] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [selId, setSelId] = useState<string | null>(null);
const [detail, setDetail] = useState<EmailDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
@ -45,15 +53,37 @@ export function MailClient() {
const [compose, setCompose] = useState<ComposeState | null>(null);
const [toasts, setToasts] = useState<Toast[]>([]);
const [syncing, setSyncing] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const shellRef = useRef<HTMLDivElement>(null);
const tid = useRef(0);
const didMount = useRef(false);
// URL 로 열린 메일을 표현 — /mail/<id> 의 <id> 세그먼트(옵셔널 캐치올).
// 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를
// 리마운트시켜 folder/검색 상태가 날아가고 openMail 루프가 발생하기 때문.
const params = useParams();
const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined;
// 검색은 서버측(백엔드 q 파라미터). 입력은 250ms 디바운스해 키 입력마다 호출하지 않음.
useEffect(() => {
const h = setTimeout(() => setDebouncedQuery(query), 250);
return () => clearTimeout(h);
}, [query]);
const listFolder = folder === "sent" || folder === "drafts" ? "inbox" : folder;
const { emails, accounts, folders, mutate } = useMail(acctFilter, listFolder);
const { emails, accounts, folders, mutate } = useMail(acctFilter, folder, debouncedQuery);
const { mutate: globalMutate } = useSWRConfig();
const { confirm } = useDialog();
const { data: people } = useSWR<Person[]>("people", () => tasksApi.people(), {
revalidateOnFocus: false,
});
// 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지).
// 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다.
const { data: baseline, mutate: mutateBaseline } = useSWR(
"mail/triage/baseline",
() => mailApi.triageBaseline(),
{ revalidateOnFocus: false, refreshInterval: 30000 },
);
const peopleMap = useMemo<Record<string, Person>>(() => {
const m: Record<string, Person> = {};
@ -78,7 +108,21 @@ export function MailClient() {
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
};
// 목록 + 사이드바 배지(계정/폴더 안읽음)까지 함께 갱신.
// 읽음/별표/보관/삭제 등은 백엔드가 즉시 반영하지만, 계정/폴더 SWR 키는 별도라
// mutate() 만으론 사이드바 숫자가 안 줄어든다 → 함께 revalidate.
const revalidateAll = async () => {
await Promise.all([
mutate(),
globalMutate("mail/accounts"),
globalMutate("mail/folders"),
globalMutate("mail/triage/baseline"), // 아리 정리 배지(새 안읽음)
]);
};
const openMail = async (id: string) => {
// 보낸편지함/임시보관함 행(ob-…)은 상세 조회 대상이 아니라 목록 표시 전용.
if (folder === "sent" || folder === "drafts") return;
setSelId(id);
setDetail(null);
setDetailLoading(true);
@ -89,42 +133,181 @@ export function MailClient() {
toast("메일을 불러오지 못했어요", "coral");
} finally {
setDetailLoading(false);
mutate(); // 읽음 처리 반영
revalidateAll(); // 읽음 처리 + 사이드바 배지 반영
}
};
const pickFolder = (id: string) => {
setFolder(id);
// URL → 상세: routeId(주소의 <id>)가 바뀌면 해당 메일을 연다(직접 입력·뒤로가기·딥링크).
useEffect(() => {
if (!routeId) {
setSelId(null);
setDetail(null);
};
const pickAccount = (id: string | null) => {
setAcctFilter(id);
return;
}
if (routeId === selId) return; // 이미 열려 있음
openMail(routeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId]);
// 상세 → URL: selId 가 바뀌면 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip).
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const target = selId ? `/mail/${selId}` : "/mail";
if (window.location.pathname !== target) window.history.replaceState(null, "", target);
}, [selId]);
// 사이드바 트리 선택 = (계정 범위, 폴더 슬러그) 동시 지정.
const pickScope = (acct: string | null, f: string) => {
setAcctFilter(acct);
setFolder(f);
setSelId(null);
setDetail(null);
setSelected(new Set());
};
const onStar = async (id: string) => {
// 낙관적 토글 (서버 PATCH 없음 — 로컬 반영)
if (detail && detail.id === id) setDetail({ ...detail, starred: !detail.starred });
mutate(
(cur) => (cur || []).map((m) => (m.id === id ? { ...m, starred: !m.starred } : m)),
false,
);
const cur = emails?.find((m) => m.id === id);
const next = !(cur?.starred ?? detail?.starred ?? false);
// 낙관적 토글 → 서버 반영 → 실패 시 롤백
if (detail && detail.id === id) setDetail({ ...detail, starred: next });
mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, starred: next } : m)), false);
try {
await mailApi.star(id, next);
revalidateAll();
} catch (e) {
if (detail && detail.id === id) setDetail({ ...detail, starred: !next });
revalidateAll();
toast(e instanceof Error ? e.message : "별표를 바꾸지 못했어요", "coral");
}
};
const onArchive = (id: string) => {
const onArchive = async (id: string) => {
setSelId(null);
setDetail(null);
mutate((list) => (list || []).filter((m) => m.id !== id), false);
try {
await mailApi.archive(id);
revalidateAll();
toast("보관함으로 옮겼어요", "green");
void id;
} catch (e) {
revalidateAll();
toast(e instanceof Error ? e.message : "보관하지 못했어요", "coral");
}
};
const onMarkUnread = async (id: string) => {
// 안 읽음 처리 → 목록 낙관적 갱신 → 리더 닫고 목록으로 복귀
mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, read: false } : m)), false);
setSelId(null);
setDetail(null);
try {
await mailApi.setRead(id, false);
revalidateAll();
toast("안 읽음으로 표시했어요", "blue");
} catch (e) {
revalidateAll();
toast(e instanceof Error ? e.message : "안 읽음으로 표시하지 못했어요", "coral");
}
};
const onDelete = async (id: string) => {
const ok = await confirm({
title: "메일 삭제",
message: "이 메일을 삭제할까요?",
confirmText: "삭제",
tone: "danger",
});
if (!ok) return;
setSelId(null);
setDetail(null);
mutate((list) => (list || []).filter((m) => m.id !== id), false);
try {
await mailApi.remove(id);
revalidateAll();
toast("메일을 삭제했어요", "green");
} catch (e) {
revalidateAll();
toast(e instanceof Error ? e.message : "삭제하지 못했어요", "coral");
}
};
const doAdd = async (key: string, kind: ExtractKind, index: number) => {
// ── 여러 메일 선택 + 일괄 처리 ──
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const clearSelected = () => setSelected(new Set());
const onBulk = async (action: string) => {
const ids = [...selected];
if (!ids.length) return;
if (action === "trash") {
const ok = await confirm({
title: "메일 삭제",
message: `${ids.length}개 메일을 삭제할까요?`,
confirmText: "삭제",
tone: "danger",
});
if (!ok) return;
}
// 즉시 목록에서 반영(삭제/보관은 사라지고, 읽음/별표는 토글)
mutate((list) => {
if (action === "trash" || action === "archive")
return (list || []).filter((m) => !selected.has(m.id));
return (list || []).map((m) =>
selected.has(m.id)
? {
...m,
read: action === "read" ? true : action === "unread" ? false : m.read,
starred: action === "star" ? true : action === "unstar" ? false : m.starred,
}
: m,
);
}, false);
clearSelected();
try {
const res = await mailApi.bulk(ids, action);
revalidateAll();
toast(`${res.count}개 메일을 처리했어요`, "green");
} catch (e) {
revalidateAll();
toast(e instanceof Error ? e.message : "일괄 처리에 실패했어요", "coral");
}
};
// 전체 답장 / 전달 — 기존 발송(결재) 경로 재사용해 Compose 채움.
const onReplyAll = (m: EmailDetail) => {
const sender = peopleMap[m.from];
const to = sender?.email ? `${sender.name} <${sender.email}>` : (sender?.name ?? m.from);
setCompose({
from: m.account,
to,
subject: "Re: " + m.subject,
body: "",
inReplyTo: m.id,
});
};
const onForward = (m: EmailDetail) => {
const quoted = (m.body || []).map((p) => "> " + p).join("\n");
setCompose({
from: m.account,
to: "",
subject: "Fwd: " + m.subject,
body: `\n\n---------- 전달된 메일 ----------\n${quoted}`,
});
};
// 추출(할일/일정/파일) — mailId 지정(없으면 현재 열린 메일 selId). 리더·아리비서 공용.
const doAddFor = async (mailId: string, key: string, kind: ExtractKind, index: number) => {
if (added[key]) return;
setAdded((a) => ({ ...a, [key]: true })); // 낙관적
try {
const res = await mailApi.extract(selId!, kind, index);
const res = await mailApi.extract(mailId, kind, index);
toast(res.message, kind === "event" ? "coral" : kind === "file" ? "violet" : "blue");
if (kind === "task") globalMutate("/api/tasks");
if (kind === "event") globalMutate("/api/calendar");
@ -133,8 +316,46 @@ export function MailClient() {
toast("추가하지 못했어요", "coral");
}
};
const doAdd = (key: string, kind: ExtractKind, index: number) =>
doAddFor(selId!, key, kind, index);
// 아리 비서: 새 메일을 가져와(sync) 미분석분을 LLM 으로 정리. 먼저 동기화해
// 기준 시각 이후 새로 도착한 메일을 받고, 작은 배치(2통)로 나눠 반복 분석 —
// 요청을 짧게 유지하고 라운드마다 목록을 갱신해 카드를 점진적으로 노출한다.
const runAnalyze = async () => {
if (analyzing) return;
setAnalyzing(true);
let total = 0;
try {
await mailApi.sync().catch(() => {}); // 새 메일 먼저 가져오기(실패해도 분석 진행)
await revalidateAll();
for (let round = 0; round < 8; round++) {
const res = await mailApi.triageAnalyze(2);
total += res.analyzed;
await mutate(); // 라운드마다 카드 갱신
if (res.remaining === 0 || res.analyzed === 0) break;
}
toast(total > 0 ? `새 메일 ${total}통을 정리했어요` : "정리할 새 메일이 없어요", "blue");
} catch {
if (total > 0) await mutate();
toast("정리하지 못했어요 (아리 모델 확인)", "coral");
} finally {
setAnalyzing(false);
}
};
const addAll = async (m: EmailDetail) => {
// 기준 시각 재설정 — 지금 이후 도착하는 새 메일부터 다시 정리.
const onResetBaseline = async () => {
try {
await mailApi.triageBaselineReset();
await Promise.all([mutateBaseline(), mutate()]);
toast("지금부터 도착하는 새 메일만 정리할게요", "blue");
} catch {
toast("기준 시각을 바꾸지 못했어요", "coral");
}
};
const addAll = async (m: EmailRow) => {
if (!m.ai) return;
const next = { ...added };
m.ai.tasks.forEach((_, i) => (next["t:" + m.id + ":" + i] = true));
@ -151,7 +372,7 @@ export function MailClient() {
}
};
// 답장: AriPanel/Triage 의 reply 선택 → 서버 초안 → Compose 채움
// 답장: AriPanel/Triage 의 reply 선택 → 서버 초안(AI) + 원본 인용 → Compose 채움
const onReply = async (
m: EmailRow | EmailDetail,
replyIndex: number | null,
@ -160,11 +381,23 @@ export function MailClient() {
void _r;
try {
const d = await mailApi.replyDraft(m.id, replyIndex);
// 원본 본문: 열린 상세(detail) 우선, 없으면 m 자체가 상세면 그 body 사용
const bodyLines =
detail && detail.id === m.id
? detail.body
: "body" in m && Array.isArray(m.body)
? m.body
: [];
const quoted = bodyLines.map((p) => "> " + p).join("\n");
const draft = d.body || "";
const body = quoted
? `${draft}\n\n--------\n원본 메일:\n${quoted}`
: draft;
setCompose({
from: d.from_account,
to: d.to,
subject: d.subject,
body: d.body,
body,
inReplyTo: m.id,
});
} catch {
@ -177,9 +410,12 @@ export function MailClient() {
const res = await mailApi.send({
from_account: c.from,
to: c.to ?? "",
cc: c.cc ?? "",
bcc: c.bcc ?? "",
subject: c.subject ?? "",
body: c.body ?? "",
in_reply_to: c.inReplyTo ?? null,
attachments: c.attachments ?? [],
});
setCompose(null);
toast(res.message, "violet"); // "결재함에서 확인하세요"
@ -201,45 +437,124 @@ export function MailClient() {
);
}
// 검색 (클라이언트)
const q = query.trim().toLowerCase();
const visible = q
? emails.filter((m) => {
const name = peopleMap[m.from]?.name ?? m.from;
return (m.subject + " " + name + " " + m.preview).toLowerCase().includes(q);
})
: emails;
// 목록은 서버가 폴더 슬러그로 이미 필터·정렬 — 그대로 표시.
const visible = emails;
// 폴더를 계정별로 그룹화(사이드바 계정 노드 서브트리). 정렬 키대로.
const foldersByAccount: Record<string, typeof folders> = {};
folders.forEach((f) => (foldersByAccount[f.account] ||= []).push(f));
Object.values(foldersByAccount).forEach((list) =>
list.sort((a, b) => a.sort_order - b.sort_order),
);
// 통합 받은편지함 노드: 계정 공통(시스템·카테고리) 폴더만 슬러그별 합산(커스텀/라벨 제외).
const unifiedMap: Record<string, (typeof folders)[number]> = {};
folders.forEach((f) => {
// 통합 상단 목록은 공통 시스템 폴더만(받은편지함/별표/보낸/임시/보관/스팸/휴지통).
// 카테고리(Gmail 프로모션·Outlook 기타)/라벨/커스텀은 각 계정 노드에서만 표시.
if (f.kind !== "system") return;
const cur = unifiedMap[f.slug];
if (cur) {
cur.unread += f.unread;
cur.total += f.total;
} else unifiedMap[f.slug] = { ...f, account: "" };
});
const unifiedFolders = Object.values(unifiedMap).sort((a, b) => a.sort_order - b.sort_order);
// 아리 정리 배지 = 기준 시각 이후 도착한 새 메일 수(읽음 무관) — 백엔드가 baseline 으로 산출.
const smartCount = baseline?.count ?? 0;
// 사이드바 트리 배지(집중 안읽음)는 계정 목록의 unread(백엔드 산출)에서 — 현재 보기와 무관하게 정확.
const accUnread = (id: string) => accMap[id]?.unread ?? 0;
const unifiedUnread = accounts.reduce((sum, a) => sum + a.unread, 0);
// 기타(Outlook other) 안읽음 — 통합/계정별. 사이드바 기타 행·받은편지함 탭 카운트에 사용.
const unifiedOthersUnread = accounts.reduce((sum, a) => sum + (a.others ?? 0), 0);
const focusedUnread = acctFilter ? (accMap[acctFilter]?.unread ?? 0) : unifiedUnread;
const othersUnread = acctFilter ? (accMap[acctFilter]?.others ?? 0) : unifiedOthersUnread;
const accColor = (id: string) => {
const i = accounts.findIndex((a) => a.id === id);
return ACC_PALETTE[(i < 0 ? 0 : i) % ACC_PALETTE.length];
};
const unreadCount = emails.filter((m) => !m.read).length;
const smartCount = emails.filter((m) => !m.read && m.ai).length;
const starredCount = emails.filter((m) => m.starred).length;
const accCount = (id: string) => emails.filter((m) => m.account === id && !m.read).length;
// 목록 제목: 폴더 슬러그 → 표시명(시스템은 META, 커스텀/카테고리/라벨은 폴더명에서).
const curFolder =
(acctFilter ? foldersByAccount[acctFilter] : unifiedFolders)?.find((f) => f.slug === folder) ||
folders.find((f) => f.slug === folder);
const listTitle = FOLDER_META[folder] ?? curFolder?.name ?? "받은편지함";
const sel = detail && detail.id === selId ? detail : null;
const showTriage = folder === "smart" && !selId;
// 아리 비서(정리) 보기 = smart 폴더. 메일 리스트 대신 비서 다이제스트를 보여준다.
const digestView = folder === "smart";
const readerEl = (
<Reader
m={sel}
person={sel ? peopleMap[sel.from] : undefined}
account={sel ? accMap[sel.account] : undefined}
added={added}
loading={detailLoading}
onAdd={doAdd}
onReply={onReply}
onReplyAll={onReplyAll}
onForward={onForward}
onAddAll={addAll}
onArchive={onArchive}
onStar={onStar}
onMarkUnread={onMarkUnread}
onDelete={onDelete}
onOpenMsg={openMail}
/>
);
return (
<div className="mailpage" ref={shellRef}>
<div className="mwork">
<div
className={
"mwork" + (digestView ? " digest" : "") + (digestView && selId ? " reading" : "")
}
>
<MailSidebar
folders={folders}
accounts={accounts}
folder={folder}
acctFilter={acctFilter}
smartCount={smartCount}
unreadCount={unreadCount}
starredCount={starredCount}
accCount={accCount}
onCompose={() => setCompose({ from: acctFilter || "work" })}
onPickFolder={pickFolder}
onPickAccount={pickAccount}
unifiedUnread={unifiedUnread}
accUnread={accUnread}
unifiedFolders={unifiedFolders}
foldersByAccount={foldersByAccount}
accColor={accColor}
onCompose={() => setCompose({ from: acctFilter || accounts[0]?.id || "" })}
onSelectSmart={() => pickScope(null, "smart")}
onSelectScope={pickScope}
onToast={toast}
/>
{digestView ? (
<>
<Triage
emails={emails}
people={peopleMap}
accColor={accColor}
added={added}
analyzing={analyzing}
since={baseline?.since}
onAnalyze={runAnalyze}
onResetBaseline={onResetBaseline}
onOpen={openMail}
onAdd={doAddFor}
onAddAll={addAll}
onReply={onReply}
/>
{selId && readerEl}
</>
) : (
<>
<MailList
title={FOLDER_META[folder] ?? "받은편지함"}
title={listTitle}
metaLabel={acctFilter ? (accMap[acctFilter]?.name ?? "") + " · " : ""}
count={visible.length}
folder={folder}
focusedUnread={focusedUnread}
othersUnread={othersUnread}
onSelectTab={(slug) => pickScope(acctFilter, slug)}
query={query}
syncing={syncing}
emails={visible}
@ -247,39 +562,32 @@ export function MailClient() {
accounts={accMap}
selId={selId}
hasSel={!!selId}
selected={selected}
onToggleSelect={toggleSelect}
onClearSelected={clearSelected}
onBulk={onBulk}
onQuery={setQuery}
onSync={() => {
onSync={async () => {
setSyncing(true);
setTimeout(() => {
try {
const res = await mailApi.sync();
await revalidateAll();
toast(
res.upserted > 0 ? `새 메일 ${res.upserted}통을 가져왔어요` : "새 메일이 없어요",
"blue",
);
} catch {
toast("메일을 가져오지 못했어요", "coral");
} finally {
setSyncing(false);
mutate();
toast("새 메일을 가져왔어요", "blue");
}, 1100);
}
}}
onOpen={openMail}
onStar={onStar}
accColor={accColor}
/>
{showTriage ? (
<Triage
emails={emails}
people={peopleMap}
onOpen={openMail}
onReply={(m, idx, r) => onReply(m, idx, r)}
/>
) : (
<Reader
m={sel}
person={sel ? peopleMap[sel.from] : undefined}
account={sel ? accMap[sel.account] : undefined}
added={added}
loading={detailLoading}
onAdd={doAdd}
onReply={onReply}
onAddAll={addAll}
onArchive={onArchive}
onStar={onStar}
/>
{readerEl}
</>
)}
</div>

@ -0,0 +1,76 @@
// frontend/components/mail/MailHtmlBody.tsx — 원본 HTML 메일을 샌드박스 iframe 으로 렌더.
// sandbox 에 allow-scripts 를 주지 않아 메일 속 JS 가 실행되지 않음(XSS 차단).
// allow-same-origin 은 본문 높이 측정(scrollHeight)에 필요, allow-popups 는 링크 새 탭 열기용.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
function wrapDocument(html: string): string {
return `<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<base target="_blank">
<style>
html, body { margin: 0; padding: 0; background: #fff; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
font-size: 14px; line-height: 1.6; color: #1a1a1a;
padding: 6px 4px; word-break: break-word; overflow-wrap: anywhere;
}
img { max-width: 100%; height: auto; }
a { color: #2563eb; }
table { max-width: 100%; }
blockquote { margin: 0 0 0 12px; padding-left: 12px; border-left: 3px solid #e5e7eb; color: #555; }
</style></head><body>${html}</body></html>`;
}
export function MailHtmlBody({ html }: { html: string }) {
const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(160);
const srcDoc = useMemo(() => wrapDocument(html), [html]);
useEffect(() => {
const iframe = ref.current;
if (!iframe) return;
let ro: ResizeObserver | null = null;
const timers: ReturnType<typeof setTimeout>[] = [];
const measure = () => {
const doc = iframe.contentDocument;
if (doc?.body) {
const h = Math.max(doc.body.scrollHeight, doc.documentElement?.scrollHeight || 0);
if (h > 0) setHeight(h + 8);
}
};
const onLoad = () => {
measure();
const doc = iframe.contentDocument;
if (doc?.body) {
ro = new ResizeObserver(measure);
ro.observe(doc.body);
// 이미지가 늦게 로드되면 높이가 늘어나므로 로드 시 재측정.
Array.from(doc.images || []).forEach((img) => {
if (!img.complete) img.addEventListener("load", measure);
});
}
// 폰트·원격 이미지 등 후속 레이아웃 변화 대비 몇 차례 재측정.
[150, 500, 1200].forEach((t) => timers.push(setTimeout(measure, t)));
};
iframe.addEventListener("load", onLoad);
return () => {
iframe.removeEventListener("load", onLoad);
ro?.disconnect();
timers.forEach(clearTimeout);
};
}, [srcDoc]);
return (
<iframe
ref={ref}
className="rd-html"
title="메일 본문"
srcDoc={srcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
style={{ width: "100%", height, border: 0, display: "block" }}
/>
);
}

@ -8,6 +8,10 @@ export function MailList({
title,
metaLabel,
count,
folder,
focusedUnread,
othersUnread,
onSelectTab,
query,
syncing,
emails,
@ -15,26 +19,43 @@ export function MailList({
accounts,
selId,
hasSel,
selected,
onToggleSelect,
onClearSelected,
onBulk,
onQuery,
onSync,
onOpen,
onStar,
accColor,
}: {
title: string;
metaLabel: string;
count: number;
folder: string;
focusedUnread: number;
othersUnread: number;
onSelectTab: (slug: string) => void;
query: string;
syncing: boolean;
emails: EmailRow[];
people: Record<string, Person>;
accounts: Record<string, MailAccount>;
accColor: (id: string) => string;
selId: string | null;
hasSel: boolean;
selected: Set<string>;
onToggleSelect: (id: string) => void;
onClearSelected: () => void;
onBulk: (action: string) => void;
onQuery: (q: string) => void;
onSync: () => void;
onOpen: (id: string) => void;
onStar: (id: string) => void;
}) {
const selCount = selected.size;
// 받은편지함은 집중(Focused)/기타(Others) 탭으로 분할 — Gmail 기본/Outlook focused = 집중.
const showTabs = folder === "inbox" || folder === "others";
return (
<div className={"mlist" + (hasSel ? " has-sel" : "")}>
<div className="ml-head">
@ -53,6 +74,28 @@ export function MailList({
{syncing ? "동기화 중" : "동기화"}
</button>
</div>
{showTabs && (
<div className="ml-cats" role="tablist" aria-label="받은편지함 분류">
<button
className={"ml-cat" + (folder === "inbox" ? " on" : "")}
role="tab"
aria-selected={folder === "inbox"}
onClick={() => onSelectTab("inbox")}
>
{focusedUnread > 0 && <span className="ml-cat-cnt">{focusedUnread}</span>}
</button>
<button
className={"ml-cat" + (folder === "others" ? " on" : "")}
role="tab"
aria-selected={folder === "others"}
onClick={() => onSelectTab("others")}
>
{othersUnread > 0 && <span className="ml-cat-cnt">{othersUnread}</span>}
</button>
</div>
)}
<div className="ml-search">
<Icon name="search" />
<input
@ -63,17 +106,47 @@ export function MailList({
/>
</div>
</div>
{selCount > 0 && (
<div className="ml-bulk" role="toolbar" aria-label="선택 항목 작업">
<span className="ml-bulk-count">{selCount} </span>
<button className="ml-bulk-btn" onClick={() => onBulk("read")}>
</button>
<button className="ml-bulk-btn" onClick={() => onBulk("unread")}>
</button>
<button className="ml-bulk-btn" onClick={() => onBulk("archive")}>
</button>
<button className="ml-bulk-btn danger" onClick={() => onBulk("trash")}>
</button>
<button className="ml-bulk-btn ghost" onClick={onClearSelected} aria-label="선택 해제">
<Icon name="x" />
</button>
</div>
)}
<div className="ml-scroll" role="region" aria-label="메일 목록" tabIndex={0}>
{emails.map((m) => (
<div className={"ml-rowwrap" + (selected.has(m.id) ? " picked" : "")} key={m.id}>
<label className="ml-check" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => onToggleSelect(m.id)}
aria-label="메일 선택"
/>
</label>
<MailRow
key={m.id}
m={m}
person={people[m.from]}
account={accounts[m.account]}
accColor={accColor(m.account)}
on={selId === m.id}
onClick={() => onOpen(m.id)}
onStar={onStar}
/>
</div>
))}
{emails.length === 0 && (
<div className="rd-empty" style={{ height: "auto", padding: "60px 30px" }}>

@ -11,6 +11,7 @@ export function MailRow({
m,
person,
account,
accColor,
on,
onClick,
onStar,
@ -18,20 +19,22 @@ export function MailRow({
m: EmailRow;
person?: Person;
account?: MailAccount;
accColor?: string; // 계정별 구분 색(계정 tone 이 겹치므로 팔레트에서 배정)
on: boolean;
onClick: () => void;
onStar: (id: string) => void;
}) {
const name = person?.name ?? m.from;
const initial = person?.initial ?? name.slice(0, 1);
const color = person?.color ?? "var(--muted)";
const accTone = account?.tone ?? "ink";
// 표시 이름: 연락처(person) > 백엔드 표시이름(from_name) > 원문(from). 메일주소 대신 이름.
const name = person?.name ?? (m.from_name || m.from);
// 아바타는 '어느 계정' 표시 — 계정 색 + 계정 이니셜(색 dot 대신 명확하게).
const accName = account?.name ?? m.account;
const accInitial = accName.slice(0, 1).toUpperCase();
const color = accColor ?? (account?.tone ? `var(--${account.tone})` : "var(--muted)");
const showPills = m.ai && (m.ai.priority === "높음" || aiCount(m) > 0);
return (
<div className={"mrow" + (on ? " on" : "") + (m.read ? "" : " unread")} onClick={onClick}>
<div className="mr-ava" style={{ background: color }}>
{initial}
<span className="acc-pip" style={{ background: `var(--${accTone})` }} />
<div className="mr-ava" style={{ background: color }} title={`${accName} · ${account?.email ?? ""}`}>
{accInitial}
</div>
<div className="mr-body">
<div className="mr-top">

@ -1,36 +1,127 @@
// frontend/components/mail/MailSidebar.tsx — 좌측 사이드바 (새 메일 · 아리 정리 · 폴더 · 계정)
// frontend/components/mail/MailSidebar.tsx — 좌측 사이드바
// 계정 루트 트리: 전체 받은편지함 + 각 계정 아래 '실제' 폴더/라벨(Gmail 라벨·Outlook 폴더)을 서브트리로.
"use client";
import { useState } from "react";
import { Icon } from "@/components/Icon";
import type { IconName } from "@/components/icons/paths";
import type { MailAccount, MailFolder } from "@/lib/types";
import type { AccountFolder, MailAccount } from "@/lib/types";
export function MailSidebar({
// 한 계정(또는 통합) 가지: 부모 행 + 실제 폴더 자식들.
function Branch({
nodeKey,
scope,
unread,
label,
sub,
dot,
open,
folders,
acctFilter,
folder,
onToggle,
onSelectScope,
}: {
nodeKey: string;
scope: string | null; // null=통합, 그 외=계정 id
unread: number;
label: string;
sub?: string;
dot?: string; // 계정 색
open: boolean;
folders: AccountFolder[]; // 이 가지에 표시할 실제 폴더/라벨
acctFilter: string | null;
folder: string;
onToggle: (key: string) => void;
onSelectScope: (acctFilter: string | null, folder: string) => void;
}) {
const scopeActive = acctFilter === scope && folder !== "smart";
return (
<div className="mt-branch">
<div className={"mt-parent" + (scopeActive ? " scope" : "")}>
<button
className={"mt-twist" + (open ? " open" : "")}
aria-label={open ? "접기" : "펼치기"}
onClick={() => onToggle(nodeKey)}
>
<Icon name="chev" />
</button>
<button
className="mt-parent-main"
onClick={() => {
onSelectScope(scope, "inbox");
if (!open) onToggle(nodeKey);
}}
>
{dot ? <span className="acc-dot" style={{ background: dot }} /> : <Icon name="layers" />}
<span className="mt-parent-txt">
<b>{label}</b>
{sub && <span>{sub}</span>}
</span>
{unread > 0 && <span className="fl-cnt">{unread}</span>}
</button>
</div>
{open && (
<div className="mt-kids">
{folders.map((f) => {
const on = acctFilter === scope && folder === f.slug;
return (
<button
key={f.slug}
className={"fl-row mt-child" + (on ? " on" : "")}
onClick={() => onSelectScope(scope, f.slug)}
title={f.name}
>
<Icon name={(f.icon || "folder") as IconName} />
<span className="fl-name">{f.name}</span>
{f.unread > 0 && <span className="fl-cnt">{f.unread}</span>}
</button>
);
})}
</div>
)}
</div>
);
}
export function MailSidebar({
accounts,
folder,
acctFilter,
smartCount,
unreadCount,
starredCount,
accCount,
unifiedUnread,
accUnread,
unifiedFolders,
foldersByAccount,
accColor,
onCompose,
onPickFolder,
onPickAccount,
onSelectSmart,
onSelectScope,
onToast,
}: {
folders: MailFolder[];
accounts: MailAccount[];
folder: string;
acctFilter: string | null;
smartCount: number;
unreadCount: number;
starredCount: number;
accCount: (id: string) => number;
unifiedUnread: number;
accUnread: (id: string) => number;
unifiedFolders: AccountFolder[];
foldersByAccount: Record<string, AccountFolder[]>;
accColor: (id: string) => string;
onCompose: () => void;
onPickFolder: (id: string) => void;
onPickAccount: (id: string | null) => void;
onSelectSmart: () => void;
onSelectScope: (acctFilter: string | null, folder: string) => void;
onToast: (msg: string, tone: string) => void;
}) {
// 펼친 계정 집합(기본은 전부 접힘). 키: 계정 id.
const [openAccts, setOpenAccts] = useState<Set<string>>(new Set());
const toggle = (key: string) =>
setOpenAccts((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<aside className="subnav">
<button className="compose-btn" onClick={onCompose}>
@ -39,7 +130,7 @@ export function MailSidebar({
<div className="sn-scroll" role="region" aria-label="메일 폴더·계정" tabIndex={0}>
<button
className={"sn-smart" + (folder === "smart" ? " on" : "")}
onClick={() => onPickFolder("smart")}
onClick={onSelectSmart}
>
<div className="sp-ico">
<Icon name="spark" />
@ -51,20 +142,29 @@ export function MailSidebar({
{smartCount > 0 && <span className="sp-cnt">{smartCount}</span>}
</button>
{folders.map((f) => {
const cnt = f.id === "inbox" ? unreadCount : f.id === "starred" ? starredCount : 0;
{/* 전체 받은편지함 — 공통 시스템 폴더 플랫 목록(트리 아님). 계정 통합. */}
<div className="sn-unified-folders">
{unifiedFolders.map((f) => {
// 받은편지함 행은 집중/기타(others) 탭을 포함하므로 둘 다일 때 활성.
const isInbox = f.slug === "inbox";
const on =
acctFilter === null && (folder === f.slug || (isInbox && folder === "others"));
const label = isInbox ? "전체 받은편지함" : f.name;
const cnt = isInbox ? unifiedUnread : f.unread;
return (
<button
key={f.id}
className={"fl-row" + (folder === f.id ? " on" : "")}
onClick={() => onPickFolder(f.id)}
key={f.slug}
className={"fl-row" + (on ? " on" : "")}
onClick={() => onSelectScope(null, f.slug)}
title={label}
>
<Icon name={f.icon as IconName} />
<span className="fl-name">{f.name}</span>
<Icon name={(f.icon || "folder") as IconName} />
<span className="fl-name">{label}</span>
{cnt > 0 && <span className="fl-cnt">{cnt}</span>}
</button>
);
})}
</div>
<div className="sn-label">
{" "}
@ -76,30 +176,22 @@ export function MailSidebar({
<Icon name="plus" />
</button>
</div>
<button
className={"acc-row" + (acctFilter === null ? " on" : "")}
onClick={() => onPickAccount(null)}
>
<span className="acc-dot" style={{ background: "var(--fill)" }} />
<div className="acc-info">
<b> </b>
<span>3 </span>
</div>
{unreadCount > 0 && <span className="fl-cnt">{unreadCount}</span>}
</button>
{accounts.map((a) => (
<button
<Branch
key={a.id}
className={"acc-row" + (acctFilter === a.id ? " on" : "")}
onClick={() => onPickAccount(acctFilter === a.id ? null : a.id)}
>
<span className="acc-dot" style={{ background: `var(--${a.tone})` }} />
<div className="acc-info">
<b>{a.name}</b>
<span>{a.email}</span>
</div>
{accCount(a.id) > 0 && <span className="fl-cnt">{accCount(a.id)}</span>}
</button>
nodeKey={a.id}
scope={a.id}
unread={accUnread(a.id)}
label={a.name}
sub={a.email}
dot={accColor(a.id)}
open={openAccts.has(a.id)}
folders={foldersByAccount[a.id] ?? []}
acctFilter={acctFilter}
folder={folder}
onToggle={toggle}
onSelectScope={onSelectScope}
/>
))}
<button className="acc-add" onClick={() => onToast("계정 연결 화면을 여는 중…", "blue")}>
<Icon name="plus" />
@ -110,7 +202,7 @@ export function MailSidebar({
<div className="av-lg"></div>
<div className="txt">
<b></b>
<span>Pro · 3 </span>
<span>Pro · {accounts.length} </span>
</div>
</div>
</aside>

@ -1,8 +1,18 @@
// frontend/components/mail/Reader.tsx — 리딩 패널 (툴바 + 본문 + 첨부 + AriPanel)
// frontend/components/mail/Reader.tsx — 리딩 패널 (툴바 + 본문 + 첨부 + 대화 + AriPanel)
"use client";
import { useEffect, useState } from "react";
import { Icon } from "@/components/Icon";
import type { AiReply, EmailDetail, MailAccount, Person } from "@/lib/types";
import { mailApi } from "@/lib/mail/api";
import type { AiReply, Conversation, EmailDetail, MailAccount, Person } from "@/lib/types";
import { AriPanel } from "./AriPanel";
import { MailHtmlBody } from "./MailHtmlBody";
function fmtBytes(n: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export function Reader({
m,
@ -12,9 +22,14 @@ export function Reader({
loading,
onAdd,
onReply,
onReplyAll,
onForward,
onAddAll,
onArchive,
onStar,
onMarkUnread,
onDelete,
onOpenMsg,
}: {
m: EmailDetail | null;
person?: Person;
@ -23,10 +38,30 @@ export function Reader({
loading?: boolean;
onAdd: (key: string, kind: "task" | "event" | "file", index: number) => void;
onReply: (m: EmailDetail, replyIndex: number | null, r: AiReply | null) => void;
onReplyAll: (m: EmailDetail) => void;
onForward: (m: EmailDetail) => void;
onAddAll: (m: EmailDetail) => void;
onArchive: (id: string) => void;
onStar: (id: string) => void;
onMarkUnread: (id: string) => void;
onDelete: (id: string) => void;
onOpenMsg?: (id: string) => void;
}) {
const [thread, setThread] = useState<Conversation | null>(null);
const threadId = m?.thread_id ?? "";
const mid = m?.id ?? "";
useEffect(() => {
setThread(null);
if (!mid || !threadId) return;
let live = true;
mailApi
.thread(mid)
.then((t) => live && t.count > 1 && setThread(t))
.catch(() => {});
return () => {
live = false;
};
}, [mid, threadId]);
if (!m) {
return (
<div className="reader">
@ -40,10 +75,10 @@ export function Reader({
</div>
);
}
const name = person?.name ?? m.from;
const name = person?.name ?? (m.from_name || m.from);
const initial = person?.initial ?? name.slice(0, 1);
const color = person?.color ?? "var(--muted)";
const email = person?.email;
const email = person?.email ?? m.from_email;
const role = person?.role;
const accTone = account?.tone ?? "ink";
const accName = account?.name ?? m.account;
@ -56,20 +91,31 @@ export function Reader({
<Icon name="reply" />
</button>
<button className="rd-tool icon-only" aria-label="전체 답장">
<button className="rd-tool icon-only" aria-label="전체 답장" onClick={() => onReplyAll(m)}>
<Icon name="replyAll" />
</button>
<button className="rd-tool icon-only" aria-label="전달">
<button className="rd-tool icon-only" aria-label="전달" onClick={() => onForward(m)}>
<Icon name="forward" />
</button>
<div className="rd-spacer" />
<button className="rd-tool icon-only" aria-label="별표" onClick={() => onStar(m.id)}>
<Icon name="star" w={m.starred ? 0 : 1.9} fill={m.starred ? "currentColor" : "none"} />
</button>
<button
className="rd-tool icon-only"
aria-label="안 읽음으로 표시"
onClick={() => onMarkUnread(m.id)}
>
<Icon name="mail" />
</button>
<button className="rd-tool icon-only" onClick={() => onArchive(m.id)} aria-label="보관">
<Icon name="archive" />
</button>
<button className="rd-tool icon-only danger" aria-label="삭제">
<button
className="rd-tool icon-only danger"
aria-label="삭제"
onClick={() => onDelete(m.id)}
>
<Icon name="trash" />
</button>
</div>
@ -87,6 +133,7 @@ export function Reader({
<div className="when">{m.date}</div>
</div>
<div className="rd-to"> : {m.to}</div>
{m.cc && <div className="rd-to">: {m.cc}</div>}
<div className="rd-acctag">
<span className="acc-dot" style={{ background: `var(--${accTone})` }} />
{accName}
@ -94,22 +141,66 @@ export function Reader({
</div>
<div className="rd-divider" />
{m.body_html ? (
<MailHtmlBody html={m.body_html} />
) : (
<div className="rd-body">
{m.body.map((para, i) => (
<p key={i}>{para}</p>
))}
</div>
)}
{m.attachments && m.attachments.length > 0 && (
<div className="rd-attachments">
{m.attachments.map((at, i) => (
{m.attachments.map((at, i) =>
at.id ? (
<a
className="att-chip"
key={i}
href={mailApi.attachmentUrl(m.id, at.id)}
download={at.name}
title={`${at.name} 내려받기`}
>
<Icon name="file" />
<div className="at-meta">
<b>{at.name}</b>
<span>{fmtBytes(at.size)}</span>
</div>
<Icon name="down" />
</a>
) : (
<div className="att-chip" key={i}>
<Icon name="file" />
<div className="at-meta">
<b>{at.name}</b>
<span>{at.size}</span>
<span>{fmtBytes(at.size)}</span>
</div>
</div>
),
)}
</div>
)}
{thread && (
<div className="rd-thread">
<div className="rd-thread-head">
<Icon name="mail" />
{thread.count}
</div>
{thread.messages
.filter((tm) => tm.id !== m.id)
.map((tm) => (
<button
className="rd-thread-msg"
key={tm.id}
onClick={() => onOpenMsg?.(tm.id)}
disabled={!onOpenMsg}
>
<b className="tm-from">{tm.from}</b>
<span className="tm-prev">{tm.preview}</span>
<span className="tm-time">{tm.time}</span>
</button>
))}
</div>
)}

@ -1,169 +1,327 @@
// frontend/components/mail/Triage.tsx — 아리 정리 히어로 (smart view & 미선택)
// frontend/components/mail/Triage.tsx — 아리 비서 다이제스트(아리 정리 보기)
// 새 메일 중 '챙길 거리'를 발신자 카드로 정리: 요약 + 할 일/일정/답장 액션을 바로 적용.
"use client";
import { Icon } from "@/components/Icon";
import type { AiEvent, AiReply, AiTask, EmailRow, Person } from "@/lib/types";
import type { AiAnalysis, AiReply, EmailRow, ExtractKind, Person } from "@/lib/types";
type TaskItem = AiTask & { src: string; m: EmailRow };
type EventItem = AiEvent & { src: string; m: EmailRow };
type ReplyItem = { src: string; subj: string; r: AiReply; m: EmailRow };
const PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"];
const PRIO_RANK: Record<string, number> = { : 0, : 1, : 2 };
// 기준 시각(ISO) → 사용자 로컬 표기('6월 18일 오후 2:30').
function fmtSince(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("ko-KR", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
// 발신자 이름 → 안정적 색(메일마다 같은 발신자는 같은 색).
function senderColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
// 메일 한 통에 달린 액션 수(할 일 + 일정 + 답장 1).
function actionCount(ai: AiAnalysis): number {
return ai.tasks.length + ai.events.length + (ai.replies.length ? 1 : 0);
}
// 카드 상태 배지 — 우선순위·액션 종류에서 비서 톤으로 산출.
function statusOf(ai: AiAnalysis): { text: string; tone: string } {
if (ai.priority === "높음") return { text: "중요 · 액션 필요", tone: "coral" };
if (ai.events.length) return { text: "일정 · 준비 필요", tone: "violet" };
if (ai.tasks.length) return { text: "할 일 · 확인 필요", tone: "blue" };
if (ai.replies.length) return { text: "답장 추천", tone: "green" };
return { text: "검토 필요", tone: "muted" };
}
function ActionRow({
icon,
kind,
title,
meta,
done,
onAdd,
}: {
icon: "check" | "cal";
kind: "task" | "event";
title: string;
meta: string;
done: boolean;
onAdd: () => void;
}) {
return (
<div className={"sc-item " + kind}>
<div className={"si-ico " + kind}>
<Icon name={icon} />
</div>
<div className="si-body">
<b>{title}</b>
{meta && <span className="si-meta">{meta}</span>}
</div>
<button
className={"si-add" + (done ? " done" : "")}
onClick={onAdd}
disabled={done}
aria-label={done ? "추가됨" : kind === "task" ? "할 일로 추가" : "일정에 추가"}
>
<Icon name={done ? "check" : "plus"} />
</button>
</div>
);
}
function SecretaryCard({
m,
name,
color,
added,
onOpen,
onAdd,
onAddAll,
onReply,
}: {
m: EmailRow;
name: string;
color: string;
added: Record<string, boolean>;
onOpen: (id: string) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void;
}) {
const ai = m.ai;
if (!ai) return null;
const status = statusOf(ai);
const n = actionCount(ai);
const reply = ai.replies[0];
return (
<article className="sc-card">
<header className="sc-head">
<div className="sc-ava" style={{ background: color }}>
{name.slice(0, 1).toUpperCase()}
</div>
<div className="sc-who">
<b>{name}</b>
<span className={"sc-status " + status.tone}>{status.text}</span>
</div>
<button className="sc-open" onClick={() => onOpen(m.id)} aria-label="메일 열기">
<Icon name="arrow" />
</button>
</header>
<h3 className="sc-title" onClick={() => onOpen(m.id)}>
{ai.summary || m.subject}
</h3>
{ai.summary && m.subject !== ai.summary && <p className="sc-sub">{m.subject}</p>}
{n > 0 && (
<div className="sc-actions">
{ai.tasks.map((t, i) => (
<ActionRow
key={"t" + i}
icon="check"
kind="task"
title={t.text}
meta={[t.due, t.project].filter(Boolean).join(" · ")}
done={!!added["t:" + m.id + ":" + i]}
onAdd={() => onAdd(m.id, "t:" + m.id + ":" + i, "task", i)}
/>
))}
{ai.events.map((e, i) => (
<ActionRow
key={"e" + i}
icon="cal"
kind="event"
title={e.title}
meta={[e.date && `${e.date}${e.day ? ` (${e.day})` : ""}`, e.time, e.place]
.filter(Boolean)
.join(" · ")}
done={!!added["e:" + m.id + ":" + i]}
onAdd={() => onAdd(m.id, "e:" + m.id + ":" + i, "event", i)}
/>
))}
{reply && (
<button className="sc-item reply" onClick={() => onReply(m, 0, reply)}>
<div className="si-ico reply">
<Icon name="reply" />
</div>
<div className="si-body">
<b> · {reply.tone}</b>
<span className="si-meta">{reply.preview}</span>
</div>
<Icon name="arrow" />
</button>
)}
</div>
)}
{n > 1 && (
<button className="sc-applyall" onClick={() => onAddAll(m)}>
<Icon name="zap" />
{n}
</button>
)}
</article>
);
}
export function Triage({
emails,
people,
accColor,
added,
analyzing,
since,
onAnalyze,
onResetBaseline,
onOpen,
onAdd,
onAddAll,
onReply,
}: {
emails: EmailRow[];
people: Record<string, Person>;
accColor: (id: string) => string;
added: Record<string, boolean>;
analyzing: boolean;
since?: string;
onAnalyze: () => void;
onResetBaseline: () => void;
onOpen: (id: string) => void;
onReply: (m: EmailRow, replyIndex: number, r: AiReply) => void;
onAdd: (mailId: string, key: string, kind: ExtractKind, index: number) => void;
onAddAll: (m: EmailRow) => void;
onReply: (m: EmailRow, idx: number, r: AiReply) => void;
}) {
const fresh = emails.filter((m) => !m.read && m.ai);
const tasks: TaskItem[] = [];
const events: EventItem[] = [];
const replies: ReplyItem[] = [];
const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from;
fresh.forEach((m) => {
if (!m.ai) return;
m.ai.tasks.forEach((t) => tasks.push({ ...t, src: nameOf(m), m }));
m.ai.events.forEach((e) => events.push({ ...e, src: nameOf(m), m }));
if (m.ai.replies.length)
replies.push({ src: nameOf(m), subj: m.subject, r: m.ai.replies[0], m });
const nameOf = (m: EmailRow) => people[m.from]?.name ?? m.from_name ?? m.from;
const colorOf = (m: EmailRow) =>
people[m.from]?.color ?? senderColor(nameOf(m)) ?? accColor(m.account);
// 새 메일 = 기준 시각 이후 도착(서버가 smart 보기로 이미 필터). 읽음 여부와 무관 —
// 사용자가 폰/다른 앱에서 먼저 읽어도 아리가 챙길 거리를 정리한다.
const fresh = emails.filter((m) => m.ai);
// 챙길 거리: 할 일/일정/답장이 있거나 중요(높음)인 새 메일.
const cards = fresh
.filter((m) => m.ai && (actionCount(m.ai) > 0 || m.ai.priority === "높음"))
.sort((a, b) => {
const pa = PRIO_RANK[a.ai!.priority] ?? 1;
const pb = PRIO_RANK[b.ai!.priority] ?? 1;
if (pa !== pb) return pa - pb;
return actionCount(b.ai!) - actionCount(a.ai!);
});
const tot = cards.reduce(
(acc, m) => {
acc.tasks += m.ai!.tasks.length;
acc.events += m.ai!.events.length;
acc.replies += m.ai!.replies.length ? 1 : 0;
return acc;
},
{ tasks: 0, events: 0, replies: 0 },
);
// 아직 아리가 정리하지 않은(분석 전) 새 메일 수.
const pending = emails.filter((m) => !m.ai).length;
return (
<div className="reader">
<div className="rd-scroll" role="region" aria-label="아리 정리" tabIndex={0}>
<div className="triage">
<div className="tr-hero">
<div className="ap-avatar">
<div className="secretary">
<div className="sec-scroll" role="region" aria-label="아리 비서 정리" tabIndex={0}>
<header className="sec-hero">
<div className="sec-spark">
<Icon name="spark" />
</div>
<div className="th-main">
<div className="th-eyebrow"> · </div>
<h2> {fresh.length} </h2>
<div className="sec-hero-main">
<div className="sec-eyebrow"> · </div>
<h2>
{cards.length > 0
? `새 메일 ${fresh.length}통 중 ${cards.length}건을 챙겨뒀어요`
: pending > 0
? `새 메일 ${pending}통, 정리해 드릴까요?`
: "지금은 챙길 새 메일이 없어요"}
</h2>
<p>
. , <b> </b>{" "}
<b> </b> . ·
.
<b> </b> ·· .
, .
</p>
<div className="tr-stats">
<div className="tr-stat task">
<Icon name="check" />
<b>{tasks.length}</b>
<span> </span>
</div>
<div className="tr-stat event">
<Icon name="cal" />
<b>{events.length}</b>
<span> </span>
</div>
<div className="tr-stat reply">
<Icon name="reply" />
<b>{replies.length}</b>
<span> </span>
</div>
</div>
</div>
</div>
<div className="tr-cols">
<div className="tr-card task">
<div className="tr-card-head">
<div className="h-ico">
<div className="sec-hero-row">
{cards.length > 0 && (
<div className="sec-stats">
<span className="sec-stat task">
<Icon name="check" />
</div>
<b> </b>
<span className="n">{tasks.length}</span>
</div>
{tasks.length === 0 && <div className="tr-empty"> </div>}
{tasks.map((t, i) => (
<div
className="tr-item"
key={i}
onClick={() => onOpen(t.m.id)}
style={{ cursor: "pointer" }}
>
<div className="ti-src">{t.src} </div>
<div className="ti-text">{t.text}</div>
<div className="ti-meta">
<span className="chip2">
<Icon name="folder" />
{t.project}
<b>{tot.tasks}</b>
</span>
<span className="chip2">
<span className="sec-stat event">
<Icon name="cal" />
{t.due}
<b>{tot.events}</b>
</span>
<span className="sec-stat reply">
<Icon name="reply" />
<b>{tot.replies}</b>
</span>
</div>
)}
<button className="sec-analyze" onClick={onAnalyze} disabled={analyzing}>
<Icon name={analyzing ? "refresh" : "spark"} />
{analyzing
? "아리가 읽는 중…"
: pending > 0
? `새 메일 ${pending}통 정리 맡기기`
: "새 메일 확인하기"}
</button>
</div>
))}
</div>
<div className="tr-card event">
<div className="tr-card-head">
<div className="h-ico">
<Icon name="cal" />
</div>
<b> </b>
<span className="n">{events.length}</span>
</div>
{events.length === 0 && <div className="tr-empty"> </div>}
{events.map((e, i) => (
<div
className="tr-item"
key={i}
onClick={() => onOpen(e.m.id)}
style={{ cursor: "pointer" }}
>
<div className="ti-src">{e.src} </div>
<div className="ti-text">{e.title}</div>
<div className="ti-meta">
<span className="chip2">
<Icon name="cal" />
{e.date} ({e.day})
</span>
<span className="chip2">
<Icon name="clock" />
{e.time}
{since && (
<div className="sec-baseline">
<span>
<b>{fmtSince(since)}</b>
</span>
<button onClick={onResetBaseline} disabled={analyzing}>
</button>
</div>
)}
</div>
))}
</div>
</header>
<div className="tr-card reply">
<div className="tr-card-head">
<div className="h-ico">
<Icon name="reply" />
</div>
<b> </b>
<span className="n">{replies.length}</span>
</div>
{replies.length === 0 && <div className="tr-empty"> </div>}
{replies.map((rp, i) => (
<div
className="tr-item"
key={i}
onClick={() => onReply(rp.m, 0, rp.r)}
style={{ cursor: "pointer" }}
>
<div className="ti-src">
{rp.src} · {rp.subj}
</div>
<div className="ti-text" style={{ fontWeight: 600 }}>
{rp.r.tone}
</div>
<div className="ti-meta">
<span className="chip2">
<Icon name="pen" />{rp.r.preview}
</span>
{cards.length === 0 ? (
<div className="sec-empty">
<div className="se-ico">
<Icon name={pending > 0 ? "spark" : "check"} />
</div>
{pending > 0 ? (
<>
<h3> {pending} </h3>
<p> ·· .</p>
</>
) : (
<>
<h3> </h3>
<p> ·· .</p>
</>
)}
</div>
) : (
<div className="sec-cards">
{cards.map((m) => (
<SecretaryCard
key={m.id}
m={m}
name={nameOf(m)}
color={colorOf(m)}
added={added}
onOpen={onOpen}
onAdd={onAdd}
onAddAll={onAddAll}
onReply={onReply}
/>
))}
</div>
</div>
</div>
)}
</div>
</div>
);

@ -1,23 +1,36 @@
// frontend/lib/hooks/useMail.ts — 메일 목록/계정/폴더 SWR 훅
"use client";
import useSWR from "swr";
import type { EmailRow, MailAccount, MailFolder } from "@/lib/types";
import type { AccountFolder, EmailRow, MailAccount } from "@/lib/types";
import { mailApi } from "@/lib/mail/api";
export function useMail(account: string | null, folder: string) {
export function useMail(account: string | null, folder: string, query = "") {
// 보낸편지함/임시보관함은 전용 엔드포인트(발송/대기 메일)에서 가져온다.
const fetcher =
folder === "sent"
? () => mailApi.sent(account)
: folder === "drafts"
? () => mailApi.drafts(account)
: () => mailApi.list(account, folder, query);
const {
data: emails,
error: emailsError,
mutate,
isLoading,
} = useSWR<EmailRow[]>(["mail", account, folder], () => mailApi.list(account, folder), {
revalidateOnFocus: false,
} = useSWR<EmailRow[]>(["mail", account, folder, query], fetcher, {
// 백엔드 자동 풀링이 새 메일을 적재하므로 주기적으로(10초)·포커스 시 목록을 갱신.
revalidateOnFocus: true,
refreshInterval: 10_000,
// 처음 보는 폴더로 전환할 때 직전 목록을 유지 → emails 가 undefined 로 떨어지지
// 않게 해 전체 화면 스켈레톤(사이드바 언마운트=트리 닫힘)을 막는다.
keepPreviousData: true,
});
const { data: accounts } = useSWR<MailAccount[]>("mail/accounts", () => mailApi.accounts(), {
revalidateOnFocus: false,
});
const { data: folders } = useSWR<MailFolder[]>("mail/folders", () => mailApi.folders(), {
const { data: folders } = useSWR<AccountFolder[]>("mail/folders", () => mailApi.folders(), {
revalidateOnFocus: false,
refreshInterval: 30_000, // 폴더 카운트도 주기 갱신
});
return { emails, accounts, folders, error: emailsError, isLoading, mutate };
}

@ -1,12 +1,13 @@
// frontend/lib/mail/api.ts — mailApi (accounts/folders/list/detail/extract/reply/send)
import type {
AccountFolder,
Conversation,
EmailDetail,
EmailRow,
ExtractAllResponse,
ExtractKind,
ExtractResponse,
MailAccount,
MailFolder,
ReplyDraft,
SendRequest,
SendResponse,
@ -14,7 +15,16 @@ import type {
const J = { "Content-Type": "application/json" };
async function ok<T>(r: Response): Promise<T> {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
if (!r.ok) {
let msg = `${r.status} ${r.statusText}`;
try {
const j = await r.json();
if (j?.detail) msg = j.detail;
} catch {
/* 본문 없음 */
}
throw new Error(msg);
}
return r.json() as Promise<T>;
}
@ -22,15 +32,31 @@ export const mailApi = {
accounts: () =>
fetch("/api/mail/accounts", { cache: "no-store" }).then((r) => ok<MailAccount[]>(r)),
folders: () => fetch("/api/mail/folders", { cache: "no-store" }).then((r) => ok<MailFolder[]>(r)),
folders: () =>
fetch("/api/mail/folders", { cache: "no-store" }).then((r) => ok<AccountFolder[]>(r)),
list: (account: string | null, folder: string) => {
list: (account: string | null, folder: string, q?: string) => {
const qs = new URLSearchParams();
if (account) qs.set("account", account);
qs.set("folder", folder);
if (q && q.trim()) qs.set("q", q.trim());
return fetch(`/api/mail?${qs}`, { cache: "no-store" }).then((r) => ok<EmailRow[]>(r));
},
sent: (account: string | null) => {
const qs = new URLSearchParams();
if (account) qs.set("account", account);
const suffix = qs.toString() ? `?${qs}` : "";
return fetch(`/api/mail/sent${suffix}`, { cache: "no-store" }).then((r) => ok<EmailRow[]>(r));
},
drafts: (account: string | null) => {
const qs = new URLSearchParams();
if (account) qs.set("account", account);
const suffix = qs.toString() ? `?${qs}` : "";
return fetch(`/api/mail/drafts${suffix}`, { cache: "no-store" }).then((r) => ok<EmailRow[]>(r));
},
detail: (id: string) =>
fetch(`/api/mail/${id}`, { cache: "no-store" }).then((r) => ok<EmailDetail>(r)),
@ -46,6 +72,24 @@ export const mailApi = {
ok<ExtractAllResponse>(r),
),
// 아리 비서: 안읽음 집중 메일 중 미분석분을 LLM 으로 배치 분석(점진).
triageAnalyze: (limit = 6) =>
fetch(`/api/mail/triage/analyze?limit=${limit}`, { method: "POST", headers: J, body: "{}" }).then(
(r) => ok<{ analyzed: number; remaining: number; errors: number }>(r),
),
// 아리 정리 기준 시각(이 시각 이후 도착한 새 메일만 정리) + 새 메일 수(읽음 무관).
triageBaseline: () =>
fetch("/api/mail/triage/baseline", { cache: "no-store" }).then((r) =>
ok<{ since: string; count: number }>(r),
),
// 기준 시각을 지금으로 재설정 — 이후 새 메일부터 다시.
triageBaselineReset: () =>
fetch("/api/mail/triage/baseline", { method: "POST", headers: J, body: "{}" }).then((r) =>
ok<{ since: string; count: number }>(r),
),
replyDraft: (id: string, reply_index: number | null) =>
fetch(`/api/mail/${id}/reply-draft`, {
method: "POST",
@ -57,4 +101,56 @@ export const mailApi = {
fetch("/api/mail/send", { method: "POST", headers: J, body: JSON.stringify(body) }).then((r) =>
ok<SendResponse>(r),
),
sync: () =>
fetch("/api/mail/sync", { method: "POST", headers: J, body: "{}" }).then((r) =>
ok<{ accounts: number; upserted: number }>(r),
),
star: (id: string, starred: boolean) =>
fetch(`/api/mail/${id}/star`, {
method: "PATCH",
headers: J,
body: JSON.stringify({ starred }),
}).then((r) => ok<EmailRow>(r)),
setRead: (id: string, read: boolean) =>
fetch(`/api/mail/${id}/read`, {
method: "PATCH",
headers: J,
body: JSON.stringify({ read }),
}).then((r) => ok<EmailRow>(r)),
archive: (id: string) =>
fetch(`/api/mail/${id}/archive`, { method: "POST", headers: J, body: "{}" }).then((r) =>
ok<EmailRow>(r),
),
unarchive: (id: string) =>
fetch(`/api/mail/${id}/unarchive`, { method: "POST", headers: J, body: "{}" }).then((r) =>
ok<EmailRow>(r),
),
remove: (id: string) =>
fetch(`/api/mail/${id}`, { method: "DELETE" }).then((r) => ok<{ ok: boolean; id: string }>(r)),
// 첨부 다운로드용 URL(브라우저가 직접 내려받음)
attachmentUrl: (id: string, attId: string) => `/api/mail/${id}/attachments/${attId}`,
thread: (id: string) =>
fetch(`/api/mail/${id}/thread`, { cache: "no-store" }).then((r) => ok<Conversation>(r)),
labels: (id: string, add: string[], remove: string[]) =>
fetch(`/api/mail/${id}/labels`, {
method: "PATCH",
headers: J,
body: JSON.stringify({ add, remove }),
}).then((r) => ok<EmailRow>(r)),
bulk: (ids: string[], action: string) =>
fetch("/api/mail/bulk", {
method: "POST",
headers: J,
body: JSON.stringify({ ids, action }),
}).then((r) => ok<{ ok: boolean; count: number; action: string }>(r)),
};

@ -21,6 +21,13 @@
padding-top: 12px;
height: calc(100vh - 96px);
}
/* 아리 비서 보기: 메일 리스트 없이 사이드바 + 비서 다이제스트(읽기 중엔 + 리더). */
.mailpage .mwork.digest {
grid-template-columns: 248px minmax(0, 1fr);
}
.mailpage .mwork.digest.reading {
grid-template-columns: 248px minmax(380px, 0.95fr) minmax(0, 1.15fr);
}
/* -------- 사이드바: 계정 + 폴더 + 라벨 -------- */
.mailpage .subnav {
@ -152,6 +159,14 @@
font-variant-numeric: tabular-nums;
}
/* 전체 받은편지함 — 공통 시스템 폴더 플랫 목록(트리 아님). */
.mailpage .sn-unified-folders {
display: flex;
flex-direction: column;
gap: 1px;
margin-bottom: 4px;
}
.mailpage .sn-label {
font-size: 10.5px;
font-weight: 700;
@ -285,6 +300,102 @@
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* 계정 루트 트리 — 부모(통합/계정) + 폴더 서브트리 */
.mailpage .mt-branch {
margin-top: 1px;
}
.mailpage .mt-parent {
display: flex;
align-items: center;
gap: 1px;
border-radius: 10px;
}
.mailpage .mt-parent.scope {
background: var(--glass-2);
}
.mailpage .mt-twist {
flex-shrink: 0;
width: 20px;
height: 30px;
display: grid;
place-items: center;
color: var(--faint);
border-radius: 8px;
transition: color 0.13s;
}
.mailpage .mt-twist .ic {
width: 12px;
height: 12px;
transition: transform 0.16s ease;
}
.mailpage .mt-twist.open .ic {
transform: rotate(90deg);
}
.mailpage .mt-twist:hover {
color: var(--ink);
}
.mailpage .mt-parent-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
text-align: left;
padding: 7px 8px;
border-radius: 9px;
transition: background 0.13s;
}
.mailpage .mt-parent-main:hover {
background: var(--glass-2);
}
.mailpage .mt-parent-main > .ic {
width: 16px;
height: 16px;
color: var(--muted);
flex-shrink: 0;
}
.mailpage .mt-parent-txt {
min-width: 0;
flex: 1;
line-height: 1.2;
}
.mailpage .mt-parent-txt b {
font-size: 13px;
font-weight: 600;
color: var(--ink);
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.mailpage .mt-parent-txt span {
font-size: 10.5px;
color: var(--muted);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mailpage .mt-parent-main .fl-cnt {
font-size: 11px;
font-weight: 700;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* 폴더 자식: 들여쓰기 + 연결선 */
.mailpage .mt-kids {
margin: 1px 0 4px 20px;
padding-left: 8px;
border-left: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 1px;
}
.mailpage .mt-child {
padding: 6px 9px;
font-size: 12.5px;
}
.mailpage .acc-add {
display: flex;
align-items: center;
@ -445,6 +556,57 @@
color: var(--faint);
}
/* 카테고리 탭(Outlook/Gmail 식 피벗) — 받은편지함 상단 */
.mailpage .ml-cats {
display: flex;
gap: 6px;
margin-top: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.mailpage .ml-cats::-webkit-scrollbar {
display: none;
}
.mailpage .ml-cat {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 6px 12px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
background: var(--card-2);
border: 1px solid var(--line);
transition:
color 0.14s,
background 0.14s,
border-color 0.14s;
}
.mailpage .ml-cat:hover {
color: var(--ink);
}
.mailpage .ml-cat.on {
color: #fff;
background: var(--blue);
border-color: var(--blue);
}
.mailpage .ml-cat-cnt {
font-size: 10.5px;
font-weight: 700;
line-height: 1;
padding: 2px 5px;
border-radius: 999px;
background: color-mix(in oklab, var(--blue) 16%, transparent);
color: var(--blue);
font-variant-numeric: tabular-nums;
}
.mailpage .ml-cat.on .ml-cat-cnt {
background: rgba(255, 255, 255, 0.24);
color: #fff;
}
.mailpage .ml-scroll {
overflow-y: auto;
flex: 1;
@ -780,6 +942,15 @@
color: var(--ink-2);
margin: 0 0 14px;
}
/* 원본 HTML 메일 본문(샌드박스 iframe) — 흰 배경 카드로 분리해 가독성 확보 */
.mailpage .rd-html {
width: 100%;
border: 0;
display: block;
background: #fff;
border-radius: 10px;
margin: 2px 0 4px;
}
.mailpage .rd-attachments {
display: flex;
flex-wrap: wrap;
@ -1808,3 +1979,655 @@
width: 100vw;
}
}
/* ── phase-18: 일괄 선택 · 첨부 다운로드 · cc/bcc · 대화(스레드) ── */
.mailpage .att-chip {
text-decoration: none;
color: inherit;
}
.mailpage .att-chip .ic:last-child {
color: var(--muted);
width: 15px;
height: 15px;
margin-left: 2px;
}
/* 일괄 선택 행 + 체크박스 */
.mailpage .ml-rowwrap {
display: flex;
align-items: stretch;
}
.mailpage .ml-check {
display: flex;
align-items: center;
padding: 0 4px 0 10px;
cursor: pointer;
}
.mailpage .ml-check input {
width: 15px;
height: 15px;
accent-color: var(--blue);
cursor: pointer;
}
.mailpage .ml-rowwrap.picked {
background: color-mix(in srgb, var(--blue) 9%, transparent);
}
.mailpage .ml-rowwrap > .mrow {
flex: 1;
min-width: 0;
}
/* 일괄 작업 바 */
.mailpage .ml-bulk {
display: flex;
align-items: center;
gap: 7px;
padding: 9px 14px;
border-bottom: 1px solid var(--line);
background: var(--card-2);
}
.mailpage .ml-bulk-count {
font-size: 12px;
font-weight: 600;
color: var(--muted);
margin-right: auto;
}
.mailpage .ml-bulk-btn {
font-size: 12px;
padding: 5px 11px;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--card);
cursor: pointer;
transition: background 0.12s;
}
.mailpage .ml-bulk-btn:hover {
background: var(--card-3);
}
.mailpage .ml-bulk-btn.danger {
color: var(--coral);
}
.mailpage .ml-bulk-btn.ghost {
border: none;
background: transparent;
padding: 5px 6px;
}
.mailpage .ml-bulk-btn.ghost .ic {
width: 15px;
height: 15px;
}
/* cc/bcc 토글 */
.mailpage .cp-cc-toggle {
margin-left: auto;
font-size: 11.5px;
color: var(--muted);
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
}
.mailpage .cp-cc-toggle:hover {
color: var(--ink);
}
/* 작성 첨부 칩 */
.mailpage .cp-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.mailpage .cp-att {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border-radius: 10px;
background: var(--card-2);
border: 1px solid var(--line);
font-size: 12px;
}
.mailpage .cp-att .ic {
width: 15px;
height: 15px;
color: var(--coral);
}
.mailpage .cp-att-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailpage .cp-att-size {
color: var(--muted);
font-size: 10.5px;
}
.mailpage .cp-att-x {
background: transparent;
border: none;
cursor: pointer;
color: var(--muted);
display: flex;
padding: 0;
}
.mailpage .cp-att-x .ic {
width: 13px;
height: 13px;
color: var(--muted);
}
.mailpage .cp-att-x:hover .ic {
color: var(--coral);
}
/* 대화(스레드) */
.mailpage .rd-thread {
margin-top: 22px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.mailpage .rd-thread-head {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
margin-bottom: 10px;
}
.mailpage .rd-thread-head .ic {
width: 15px;
height: 15px;
}
.mailpage .rd-thread-msg {
display: flex;
align-items: baseline;
gap: 10px;
width: 100%;
text-align: left;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--card-2);
cursor: pointer;
margin-bottom: 7px;
transition: background 0.12s;
}
.mailpage .rd-thread-msg:hover {
background: var(--card-3);
}
.mailpage .rd-thread-msg:disabled {
cursor: default;
}
.mailpage .rd-thread-msg .tm-from {
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
}
.mailpage .rd-thread-msg .tm-prev {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailpage .rd-thread-msg .tm-time {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
/* ====================== 아리 비서 다이제스트(아리 정리) ====================== */
.mailpage .secretary {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
}
.mailpage .sec-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 22px;
scrollbar-width: thin;
}
.mailpage .sec-hero {
display: flex;
gap: 14px;
padding: 18px 18px 20px;
border-radius: 16px;
background:
radial-gradient(120% 160% at 0% 0%, color-mix(in oklab, var(--coral) 16%, transparent), transparent 55%),
var(--card-2);
border: 1px solid var(--line);
margin-bottom: 18px;
}
.mailpage .sec-spark {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 12px;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(135deg, var(--coral), var(--violet));
box-shadow: 0 8px 18px -8px var(--coral);
}
.mailpage .sec-spark .ic {
width: 20px;
height: 20px;
}
.mailpage .sec-hero-main {
min-width: 0;
}
.mailpage .sec-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--coral);
margin-bottom: 4px;
}
.mailpage .sec-hero-main h2 {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--ink);
margin: 0 0 6px;
}
.mailpage .sec-hero-main p {
font-size: 13px;
line-height: 1.6;
color: var(--ink-2);
margin: 0;
}
.mailpage .sec-stats {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.mailpage .sec-stat {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
background: var(--card);
border: 1px solid var(--line);
}
.mailpage .sec-stat .ic {
width: 14px;
height: 14px;
}
.mailpage .sec-stat b {
font-weight: 800;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.mailpage .sec-stat.task .ic {
color: var(--blue);
}
.mailpage .sec-stat.event .ic {
color: var(--coral);
}
.mailpage .sec-stat.reply .ic {
color: var(--green);
}
/* ---- 발신자 카드 ---- */
.mailpage .sec-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.mailpage .sc-card {
border: 1px solid var(--line);
border-radius: 16px;
background: var(--card);
padding: 16px 16px 14px;
box-shadow: var(--shadow-sm);
transition: border-color 0.14s;
}
.mailpage .sc-card:hover {
border-color: var(--line-2);
}
.mailpage .sc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.mailpage .sc-ava {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 10px;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 14px;
}
.mailpage .sc-who {
min-width: 0;
flex: 1;
line-height: 1.3;
}
.mailpage .sc-who b {
display: block;
font-size: 14px;
font-weight: 700;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mailpage .sc-status {
font-size: 11.5px;
font-weight: 600;
color: var(--ink-2);
}
.mailpage .sc-status.coral {
color: var(--coral);
}
.mailpage .sc-status.violet {
color: var(--violet);
}
.mailpage .sc-status.blue {
color: var(--blue);
}
.mailpage .sc-status.green {
color: var(--green);
}
.mailpage .sc-open {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 9px;
display: grid;
place-items: center;
color: var(--muted);
transition:
background 0.13s,
color 0.13s;
}
.mailpage .sc-open:hover {
background: var(--card-2);
color: var(--ink);
}
.mailpage .sc-open .ic {
width: 17px;
height: 17px;
}
.mailpage .sc-title {
font-size: 14.5px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
margin: 0 0 4px;
cursor: pointer;
}
.mailpage .sc-title:hover {
color: var(--coral);
}
.mailpage .sc-sub {
font-size: 12.5px;
line-height: 1.5;
color: var(--ink-2);
margin: 0 0 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mailpage .sc-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.mailpage .sc-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
padding: 10px 11px;
border-radius: 12px;
background: var(--card-2);
border: 1px solid transparent;
transition:
background 0.13s,
border-color 0.13s;
}
.mailpage button.sc-item {
cursor: pointer;
}
.mailpage button.sc-item:hover {
border-color: var(--line);
}
.mailpage .si-ico {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
}
.mailpage .si-ico .ic {
width: 15px;
height: 15px;
}
.mailpage .si-ico.task {
color: var(--blue);
background: color-mix(in oklab, var(--blue) 14%, transparent);
}
.mailpage .si-ico.event {
color: var(--coral);
background: color-mix(in oklab, var(--coral) 14%, transparent);
}
.mailpage .si-ico.reply {
color: var(--green);
background: color-mix(in oklab, var(--green) 14%, transparent);
}
.mailpage .si-body {
min-width: 0;
flex: 1;
line-height: 1.35;
}
.mailpage .si-body b {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mailpage .si-meta {
font-size: 11.5px;
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.mailpage .sc-item.reply > .ic {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--muted);
}
.mailpage .si-add {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 9px;
display: grid;
place-items: center;
color: #fff;
background: var(--ink);
cursor: pointer;
transition:
transform 0.12s,
background 0.14s;
}
.mailpage .si-add:hover {
transform: translateY(-1px);
}
.mailpage .si-add .ic {
width: 16px;
height: 16px;
}
.mailpage .si-add.done {
background: var(--green);
cursor: default;
}
.mailpage .sc-applyall {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 8px 14px;
border-radius: 10px;
font-size: 12.5px;
font-weight: 700;
color: var(--coral);
background: color-mix(in oklab, var(--coral) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--coral) 26%, transparent);
cursor: pointer;
transition:
background 0.14s,
transform 0.12s;
}
.mailpage .sc-applyall:hover {
transform: translateY(-1px);
background: color-mix(in oklab, var(--coral) 16%, transparent);
}
.mailpage .sc-applyall .ic {
width: 15px;
height: 15px;
}
/* ---- 비어있음 ---- */
.mailpage .sec-empty {
text-align: center;
padding: 50px 30px;
color: var(--ink-2);
}
.mailpage .sec-empty .se-ico {
width: 52px;
height: 52px;
margin: 0 auto 14px;
border-radius: 15px;
display: grid;
place-items: center;
color: var(--green);
background: color-mix(in oklab, var(--green) 14%, transparent);
}
.mailpage .sec-empty .se-ico .ic {
width: 24px;
height: 24px;
}
.mailpage .sec-empty h3 {
font-size: 15px;
font-weight: 700;
color: var(--ink);
margin: 0 0 6px;
}
.mailpage .sec-empty p {
font-size: 13px;
margin: 0;
}
/* 비서 히어로: 통계 + 정리 맡기기 버튼 한 줄. */
.mailpage .sec-hero-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.mailpage .sec-hero-row .sec-stats {
margin-top: 0;
}
.mailpage .sec-analyze {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 8px 15px;
border-radius: 11px;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--coral), var(--violet));
box-shadow: 0 8px 18px -9px var(--coral);
cursor: pointer;
transition:
transform 0.12s,
filter 0.14s;
}
.mailpage .sec-analyze:hover {
transform: translateY(-1px);
filter: brightness(1.05);
}
.mailpage .sec-analyze:disabled {
cursor: default;
opacity: 0.85;
}
.mailpage .sec-analyze .ic {
width: 15px;
height: 15px;
}
.mailpage .sec-analyze:disabled .ic {
animation: sec-spin 0.9s linear infinite;
}
@keyframes sec-spin {
to {
transform: rotate(360deg);
}
}
/* 기준 시각 안내 + '지금부터 다시' 재설정 */
.mailpage .sec-baseline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
font-size: 11.5px;
color: var(--muted);
}
.mailpage .sec-baseline b {
color: var(--text);
font-weight: 650;
}
.mailpage .sec-baseline button {
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
background: var(--surface);
cursor: pointer;
transition:
color 0.12s,
border-color 0.12s;
}
.mailpage .sec-baseline button:hover {
color: var(--text);
border-color: var(--muted);
}
.mailpage .sec-baseline button:disabled {
cursor: default;
opacity: 0.6;
}

@ -27,6 +27,8 @@ function detail(ai: AiAnalysis | null): EmailDetail {
account: "work",
from: "hyunwoo",
to: "나",
cc: "",
thread_id: "",
subject: "온보딩 시안 v3",
time: "8분 전",
read: true,
@ -34,9 +36,11 @@ function detail(ai: AiAnalysis | null): EmailDetail {
has_attach: true,
labels: [],
preview: "p",
category: "primary",
ai,
date: "오늘 09:12",
body: ["본문"],
body_html: "",
attachments: [],
};
}

@ -23,6 +23,8 @@ function row(over: Partial<EmailRow> = {}): EmailRow {
account: "work",
from: "hyunwoo",
to: "나",
cc: "",
thread_id: "",
subject: "온보딩 시안 v3",
time: "8분 전",
read: false,
@ -30,6 +32,7 @@ function row(over: Partial<EmailRow> = {}): EmailRow {
has_attach: true,
labels: ["디자인"],
preview: "미리보기",
category: "primary",
ai: baseAi,
...over,
};

Loading…
Cancel
Save