You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ari_assistant/dev/phase-9-mail-notifications.md

95 KiB

Phase 9 — 메일 + 알림 트리아지

한 줄 요약: 3계정 통합 메일(아리가 새 메일을 읽고 요약·우선순위·분류·할일/일정 추출→작업·캘린더 전송·회신 초안)과 알림 트리아지(지금 / 나중에 / 아리가 처리 3분류 + 집중 보호·묶음 배달 + 발신자 규칙, 분류 이유 칩 + 되돌리기)를 mock-first 커넥터·LLM provider(요약/분류/추출/초안, heuristic 폴백) 위에 구현하고, 메일→작업/일정(federation)·알림 "나중에" 반복→자동화 제안·메일 회신 발송=high-risk→결재함 연합을 완성한다.

이 문서는 포스트-MVP 세트의 일부 — 먼저 dev/overview.mddev/post-mvp-overview.md 를 읽으세요. 선행: phase-2-backend.md(모델/스키마/라우터/시드/LLM 추상화 규약), phase-7-approvals-automation.md(결재함 approval·autonomy·event_bus·suggester), phase-8-calendar-meetings.md(calendar/event 모델, 액션→작업). 후속: phase-10-research-travel.md.


0. 목차

  1. 개요 & 목표
  2. 선행 조건 / 산출물
  3. 상세 구현 — 백엔드
  4. 상세 구현 — 메일 프론트엔드
  5. 상세 구현 — 알림 트리아지 프론트엔드
  6. 데이터 / 타입 / API 계약
  7. 디자인 충실도 노트
  8. 상태 처리 · 엣지 케이스 · 오프라인
  9. 연합 이벤트 (발행/구독)
  10. 테스팅 & 검증
  11. 완료 기준 (Definition of Done)
  12. 다음 단계

1. 개요 & 목표

이 phase가 끝나면 두 페이지가 동작한다 — 메일(/mail)알림(/notifications).

1.1 메일 — "여러 계정을 한 곳에서, 아리가 미리 읽어둔다"

원본 REF/assets/mail.jsx의 3분할 레이아웃(좌 사이드바=계정/폴더, 가운데=메일 목록, 우=리딩+아리 분석)을 픽셀 충실하게 재현한다. 핵심 가치는 아리가 새 메일을 읽고 할 일·일정·답장·정리를 미리 준비하는 것 — 제품 철학 "할까요?가 아니라 이미 해뒀어요"의 메일판:

  • 3계정 통합 수신: work(회사 · Lumi Workspace), personal(개인 · Google), side(사이드 · HEY). 전체 받은편지함 + 계정별 필터.
  • 아리 정리(smart view): 새 메일을 읽고 할 일 / 일정 / 답장 초안으로 트리아지한 히어로 화면(원본 Triage).
  • AI 분석 패널: 메일을 열면 ai{summary, priority, category, tasks[], events[], replies[], file}를 카드로 보여주고, 각 제안을 작업/일정으로 한 번에 추가(POST /api/mail/{id}/extract로 실체화 → 작업 페이지·일정 페이지에 등장)하거나 회신 초안을 작성창에 채워 보낼 수 있다.
  • 회신 발송 = high-risk: 보내기는 phase-7 결재함으로 흘러간다("이미 초안은 써뒀어요, 보내기만 확인").

1.2 알림 — "중요한 것만 울려요"

원본 REF/assets/notify.jsx의 SubRail 3뷰를 재현한다:

  • 트리아지: now(지금) / later(나중에) / held(아리가 처리) 3버킷 + 오늘 요약(stats: total/seen/filtered). 각 알림에는 왜 그렇게 분류했는지 이유 칩(why + tone)과 되돌리기(held→now), 나중에로 미루기(now→later) 액션.
  • 집중 보호 · 묶음 배달: 딥 워크 보호(guard.on/until/held), 조용한 시간(quiet), 묶음 배달(digests).
  • 발신자 규칙: 누구는 즉시, 누구는 다이제스트로(senders).

1.3 이 phase가 실현하는 연합(federation)

연합 흐름 관련 phase
메일→작업 mail.received → 아리 분석 → POST /api/mail/{id}/extract(task) → task 생성 → 작업 페이지 등장 phase-3-tasks.md
메일→일정 동일 extract(event) → event 생성 → 일정 페이지 등장 phase-8-calendar-meetings.md
메일 회신 발송 replies 초안 → 보내기 = high-risk → approval(source=mail) → 결재함 phase-7-approvals-automation.md
알림 "나중에" 반복 notification.triaged(deferred) 반복 패턴 → suggester → automation_suggestion phase-7-approvals-automation.md
발신자 규칙 적용 sender_rule → 새 알림 트리아지 시 버킷 결정 (내부)

CONTRACT 명명 고정: 자동화 엔진 backend/app/automation/, 승인 큐 approval/autonomy_setting, 커넥터 backend/app/connectors/(도메인 mail/chat), 연합 이벤트 event_bus. 이 문서는 phase-7 가 정의한 이름을 그대로 참조한다.


2. 선행 조건 / 산출물

2.1 선행 조건 (의존 phase)

의존 무엇을 가져오는가
phase-2-backend.md SQLModel/Pydantic 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음, main.py에서만 prefix="/api"), run_seed(session=None, reset=True), LLM LLMProvider/get_provider/Classification/generate_json, Person 테이블·people 시드, to_node(task 직렬화)
phase-7-approvals-automation.md Approval(risk/status/source/undo_label/cta/alt/detail), AutonomySetting(level), automation/event_bus.py(publish/subscribe), automation/suggester.py, connectors/base.py + MockConnector 기반 인터페이스
phase-8-calendar-meetings.md Calendar/Event 모델, 일정 생성 헬퍼(메일 extract→event 가 동일 테이블에 쓴다)
phase-1-design-system.md Topbar(13항목, noti badge), SubRail, Icon(중앙 paths 맵), tokens.css(dash.css :root)

메일 페이지는 dash.css 토큰을 그대로 쓰되 페이지 전용 mail.css를 얹는다(원본 mail.css는 자체 :root를 복제하지만, 우리 프로젝트는 tokens.css 단일 출처를 따른다 — §7.1 참조). 알림 페이지는 dash.css 위에 notify.css만 얹는다(원본 그대로).

2.2 산출물 (Deliverables)

backend/app/
├─ models.py                         (+) MailAccount, MailFolder, Email, Sent, Draft,
│                                        Notification, NotificationGuard, Digest, SenderRule, NotifyStats
├─ schemas.py                        (+) 메일/알림 I/O 스키마
├─ seed.py                           (+) seed_mail(), seed_notify()  (run_seed 내부 호출)
├─ connectors/
│  ├─ mail.py                        MailConnector(base) + MockMailConnector(시드 기반)
│  └─ chat.py                        ChatConnector(base) + MockChatConnector(알림 src=chat)
├─ services/
│  ├─ mail_ai.py                     analyze_email(): summary/priority/category/tasks/events/replies/file
│  │                                  (LLM provider, heuristic 폴백)
│  └─ triage.py                      classify_notification(): 발신자 규칙+가드→버킷 결정 + 이유(why/tone)
└─ routers/
   ├─ mail.py                        GET 목록/상세, POST extract(→task/event), POST reply-draft, POST send(→approval)
   └─ notify.py                      GET 트리아지/요약, POST reclassify(now↔later↔held), POST undo,
                                       GET/POST/PATCH senders, GET digests, POST guard

frontend/
├─ app/mail/page.tsx                 3분할 메일 (사이드바/목록/리더+아리 분석/작성 슬라이드오버/트리아지)
├─ app/notifications/page.tsx        SubRail 3뷰 (트리아지/집중 보호/발신자 규칙)
├─ components/mail/
│  ├─ MailSidebar.tsx  MailList.tsx  MailRow.tsx  Reader.tsx  AriPanel.tsx
│  ├─ Sug.tsx  Triage.tsx  Compose.tsx
├─ components/notify/
│  ├─ TriageStream.tsx  WhyChip.tsx  SrcIcon.tsx  GuardPanel.tsx  SenderRules.tsx
├─ lib/hooks/useMail.ts  useNotify.ts
├─ styles/mail.css                   (REF/assets/mail.css 이식, :root 제거 → tokens.css 사용)
└─ styles/notify.css                 (REF/assets/notify.css 그대로 이식)

3. 상세 구현 — 백엔드

3.0 모델 — models.py (추가분)

CONTRACT 데이터 모델 규약: 모든 PK 는 TEXT(str), enum tone 집합 blue|violet|coral|green|amber|ink|faint, 한국어 UI 문구는 원본 데이터 파일 그대로. 메일의 ai{} 묶음은 분석 결과로 여러 테이블/행이 될 수 있으나, 데모 결정성을 위해 시드는 원본 mail-data.jsai를 JSON으로 보존(Email.ai_json)하고, 추출 시 이 JSON을 풀어 task/event로 실체화한다.

# backend/app/models.py  (추가)
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Column, JSON


def now() -> datetime:
    return datetime.now(timezone.utc)


# ---------- enums (값은 원본 데이터 고정) ----------
class MailPriority(str, Enum):
    high = "높음"
    normal = "보통"
    low = "낮음"


class NotifyBucket(str, Enum):
    now = "now"           # 지금 (직접 봐야 함)
    later = "later"       # 나중에 (묶어서 다이제스트)
    held = "held"         # 아리가 처리 (자동 · 되돌리기 가능)


# ============================================================
#  메일 (3계정 통합)
# ============================================================
class MailAccount(SQLModel, table=True):
    __tablename__ = "mail_account"
    id: str = Field(primary_key=True)            # "work" | "personal" | "side"
    name: str                                     # "회사" | "개인" | "사이드"
    email: str                                    # "jiwoo@lumi.co"
    tone: str = "ink"                             # "coral" | "blue" | "violet" (acc-pip 색)
    kind: str = ""                                # "Lumi Workspace" | "Google" | "HEY"
    unread: int = 0                               # 시드 표시값 (실제 카운트는 계산으로도 가능)
    connector: str = "mock"                       # CONNECTOR_MAIL=mock|real (phase-13)
    sort_order: int = 0


class MailFolder(SQLModel, table=True):
    __tablename__ = "mail_folder"
    id: str = Field(primary_key=True)            # "inbox"|"starred"|"sent"|"drafts"|"archive"
    name: str                                     # "받은편지함" 등
    icon: str = "inbox"
    sort_order: int = 0


class Email(SQLModel, table=True):
    __tablename__ = "email"
    id: str = Field(primary_key=True)            # "m1"..."m8"
    account: str = Field(foreign_key="mail_account.id")
    from_key: str                                 # people 키 ("hyunwoo" 등). 'from'은 예약어라 from_key
    to: str = ""                                  # "나", "나, 재무팀" (표시용 문자열)
    subject: str = ""
    time: str = ""                                # "8분 전" (상대 표시값, 원본 그대로)
    date: str = ""                                # "오늘 09:12" (상세 표시값)
    read: bool = False
    starred: bool = False
    archived: bool = False                        # 보관함 상태 (런타임 토글)
    has_attach: bool = False
    labels: list = Field(default_factory=list, sa_column=Column(JSON))   # ["디자인"]
    preview: str = ""
    body: list = Field(default_factory=list, sa_column=Column(JSON))     # 문단 배열
    attachments: list = Field(default_factory=list, sa_column=Column(JSON))  # [{name,size}]
    ai_json: dict = Field(default_factory=dict, sa_column=Column(JSON))  # summary/priority/category/tasks/events/replies/file
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


class Sent(SQLModel, table=True):
    __tablename__ = "sent"
    id: str = Field(primary_key=True)            # "s1","s2" (보낸편지함)
    account: str = Field(foreign_key="mail_account.id")
    to: str = ""                                  # "현우" (표시용)
    to_key: str = ""                              # people 키
    subject: str = ""
    time: str = ""
    date: str = ""
    preview: str = ""
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


class Draft(SQLModel, table=True):
    __tablename__ = "draft"
    id: str = Field(primary_key=True)            # "d1"
    account: str = Field(foreign_key="mail_account.id")
    to: str = ""
    subject: str = ""
    time: str = ""
    preview: str = ""
    body: str = ""
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


# ============================================================
#  알림 트리아지
# ============================================================
class Notification(SQLModel, table=True):
    __tablename__ = "notification"
    id: str = Field(primary_key=True)            # "n1"..., "l1"..., "h1"...
    bucket: NotifyBucket = NotifyBucket.now      # now|later|held
    src: str = "app"                              # "mail"|"cal"|"chat"|"fin"|"app"
    app: str = ""                                 # "메일 · 회사", "캘린더"
    title: str = ""
    sum: str = ""                                 # 본문 요약 (원본 키 'sum' 보존)
    time: str = ""                                # "09:12", "오전", "어제"
    why: str = ""                                 # 분류 이유 칩 텍스트 ("VIP 발신자")
    tone: str = "ink"                             # 이유 칩 색 (coral|blue|violet|green|amber|muted)
    act: str = ""                                 # 주요 액션 라벨 ("답장 초안 보기") — now 전용
    held_by_rule: str = ""                        # held 일 때 적용된 sender_rule id (되돌리기 추적)
    sort_order: int = 0
    created_at: datetime = Field(default_factory=now)


class NotificationGuard(SQLModel, table=True):
    """단일 row(id=1). 집중 보호 + 조용한 시간."""
    __tablename__ = "notification_guard"
    id: Optional[int] = Field(default=None, primary_key=True)
    on: bool = True
    until: str = "16:00"
    held: int = 4                                 # 보류 중 알림 수
    quiet: str = "22:30  07:00"


class Digest(SQLModel, table=True):
    __tablename__ = "digest"
    id: str = Field(primary_key=True)            # "dg-am" 등 (TEXT PK)
    time: str = ""                                # "09:00"
    name: str = ""                                # "아침 브리핑"
    desc: str = ""                                # "밤사이 메일·뉴스·일정 변화"
    count: int = 0
    done: bool = False
    sort_order: int = 0


class SenderRule(SQLModel, table=True):
    __tablename__ = "sender_rule"
    id: str = Field(primary_key=True)            # "sr1"...
    who: str = ""                                 # "수아님"
    from_label: str = ""                          # "메일 · 메신저" (원본 'from')
    rule: str = ""                                # "항상 즉시"
    bucket: NotifyBucket = NotifyBucket.now       # 규칙이 강제하는 기본 버킷
    tone: str = "ink"
    sort_order: int = 0


class NotifyStats(SQLModel, table=True):
    """단일 row(id=1). 오늘 요약."""
    __tablename__ = "notify_stats"
    id: Optional[int] = Field(default=None, primary_key=True)
    total: int = 0                                # 47
    seen: int = 0                                 # 6
    filtered: str = ""                            # "87%"
    later_more: int = 0                           # laterMore=6 (묶은 여분)
    held_more: int = 0                            # heldMore=23

Email.from_key: 원본 mail-data.jsfrom은 파이썬 예약어가 아니지만 SQL/Pydantic 혼동을 막기 위해 from_key로 저장하고, 응답 스키마에서 from으로 직렬화한다(§3.2). body/ai_json/labels/attachmentssa_column=Column(JSON)로 SQLite JSON 컬럼에 저장(phase-8 meeting JSON 패턴과 동일).

3.1 시드 — seed.py (추가분)

원본 REF/assets/mail-data.js, REF/assets/notify-data.js의 값을 그대로 적재한다. people은 phase-2 시드를 재사용하되, 메일에만 등장하는 발신자(대표/우리카드/독서모임/Stripe/지원자)는 메일 전용 person 시드로 추가한다.

# backend/app/seed.py  (추가)
from .models import (
    Person, MailAccount, MailFolder, Email, Sent, Draft,
    Notification, NotificationGuard, Digest, SenderRule, NotifyStats,
    MailPriority, NotifyBucket,
)

# ---- 메일 전용 추가 발신자 (REF/assets/mail-data.js people) ----
# phase-2 의 jiwoo/hyunwoo/minseo/jaeho/sua 는 이미 시드됨. 메일에만 나오는 인물만 추가.
MAIL_PEOPLE = [
    # (id, name, initial, color, role)
    ("daepyo",  "서지원 대표",      "서", "var(--coral)",          "대표이사"),
    ("bank",    "우리카드",          "우", "oklch(0.6 0.12 250)",   "카드 명세"),
    ("book",    "수요 독서모임",      "독", "oklch(0.62 0.1 150)",   "모임 운영"),
    ("stripe",  "Stripe",            "S",  "oklch(0.6 0.15 285)",   "결제 영수증"),
    ("recruit", "박서연 (지원자)",   "박", "oklch(0.64 0.12 30)",   "프로덕트 디자이너 지원"),
]
# 메일에는 role 이 있다. Person 에 role 컬럼이 없으면 무시되거나(엄격모드 아님),
# Person 에 role: str = "" 를 추가해 함께 저장(권장). 본 phase 에서 Person.role(default="") 추가.

# ---- 계정 (REF mail-data.js accounts) ----
MAIL_ACCOUNTS = [
    ("work",     "회사",   "jiwoo@lumi.co",       "coral",  "Lumi Workspace", 3),
    ("personal", "개인",   "jiwoo.kim@gmail.com", "blue",   "Google",          2),
    ("side",     "사이드", "jiwoo@hey.com",       "violet", "HEY",             1),
]
MAIL_FOLDERS = [
    ("inbox",   "받은편지함", "inbox",   0),
    ("starred", "별표",       "star",    1),
    ("sent",    "보낸편지함", "send",    2),
    ("drafts",  "임시보관함", "file",    3),
    ("archive", "보관함",     "archive", 4),
]

# ---- emails (REF mail-data.js emails m1~m8) — ai 묶음을 그대로 ai_json 으로 ----
# (요약: m1 온보딩 v3 / m2 분기 전략 미팅 / m3 1:1 안건 / m4 우리카드 명세 /
#  m5 독서모임 / m6 채용 지원 / m7 Stripe 영수증 / m8 리텐션 코호트 v2)
EMAILS = [
    {
        "id": "m1", "account": "work", "from_key": "hyunwoo", "to": "나",
        "subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
        "time": "8분 전", "date": "오늘 09:12", "read": False, "starred": False, "has_attach": True,
        "attachments": [{"name": "온보딩_v3.fig", "size": "4.2MB"},
                        {"name": "변경사항_요약.pdf", "size": "320KB"}],
        "labels": ["디자인"],
        "preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요. 환영·권한요청·음성안내 3개 화면이 바뀌었고…",
        "body": ["지우님, 안녕하세요. 현우입니다.",
                 "지난 디자인 리뷰에서 주신 코멘트 반영해서 온보딩 시안 v3 정리했습니다. 이번에 바뀐 건 크게 세 화면이에요.",
                 "1) 환영 화면 — 카피 톤을 조금 더 친근하게 바꿨고, 일러스트는 교체 요청 넣어둔 상태입니다.",
                 "2) 권한 요청 화면 — 요청 순서를 재배치했어요. (알림 → 위치 → 연락처)",
                 "3) 음성 안내 화면 — 신규 추가했습니다. 한 번 봐주시면 좋겠어요.",
                 "금요일(6/13)까지 피드백 주시면 다음 스프린트에 바로 반영하겠습니다. 피그마 링크랑 변경사항 요약본 첨부했어요. 감사합니다!"],
        "ai": {
            "summary": "온보딩 시안 v3 도착 — 3개 화면(환영·권한요청·음성안내) 수정. 금요일 6/13까지 피드백 요청.",
            "priority": "높음", "category": "검토 요청",
            "tasks": [
                {"text": "온보딩 시안 v3 피드백 정리", "project": "온보딩 리디자인 · 와이어프레임", "due": "6/13", "prio": "높음"},
                {"text": "음성 안내 화면 신규 검토", "project": "온보딩 리디자인 · 음성 인터페이스", "due": "6/12", "prio": "보통"},
            ],
            "events": [],
            "replies": [
                {"tone": "수락 · 일정 약속", "preview": "금요일까지 피드백 드릴게요",
                 "body": "현우님, 잘 받았어요! 오늘 중으로 살펴보고 금요일 오전까지 화면별로 코멘트 정리해서 드릴게요. 음성 안내 화면은 특히 꼼꼼히 볼게요. 수고하셨어요 👏"},
                {"tone": "질문 추가", "preview": "권한 요청 순서 근거가 궁금해요",
                 "body": "현우님, 시안 잘 봤어요. 권한 요청 순서를 알림 → 위치 → 연락처로 두신 근거가 따로 있을까요? 리서치 결과랑 연결해서 보면 좋을 것 같아 여쭤봐요."},
                {"tone": "간단 확인", "preview": "확인했고 회신 예정",
                 "body": "확인했습니다, 현우님! 금요일까지 피드백 드릴게요."},
            ],
            "file": {"project": "온보딩 리디자인", "reason": "‘와이어프레임’ 작업과 직접 연결돼요"},
        },
    },
    # ... m2~m8 동일 형식으로 mail-data.js 의 값 그대로 (지면상 생략, 실제 시드는 8건 전부) ...
]

반드시 8건 전부(m1~m8) 이식하되, 위처럼 원본 mail-data.js의 필드를 1:1로 옮긴다. m2(분기 전략 미팅)는 events:[{title:"분기 전략 미팅", date:"6/10", day:"오늘", time:"14:00", dur:"60분", place:"대회의실"}]처럼 event 추출 케이스를 포함하므로 extract→일정 federation 테스트의 핵심 케이스가 된다. m7(Stripe)은 tasks/events/replies 모두 비어 priority:"낮음" — 빈 상태 렌더 케이스. read:true인 메일(m6/m7/m8)은 트리아지에서 제외(원본 fresh = emails.filter(m => !m.read && m.ai)).

# ---- 보낸편지함 / 임시보관 (REF mail-data.js sent/drafts) ----
SENT = [
    ("s1", "work", "현우", "hyunwoo", "온보딩 리뷰 일정 공유", "어제", "6월 9일 11:00",
     "현우님, 다음 리뷰는 목요일 오전으로 잡았어요…"),
    ("s2", "work", "민서", "minseo", "회고 문서 잘 봤어요", "3일 전", "6월 7일 16:20",
     "민서님, 회고 문서 정리 깔끔하네요. 액션 아이템만…"),
]
DRAFTS = [
    ("d1", "work", "재무팀", "예산 재배분 안건 정리 (작성 중)", "1시간 전",
     "예산 재배분 관련해서 세 가지 시나리오를…", ""),
]

# ---- 알림 (REF notify-data.js) ----
NOTIFY_STATS = dict(total=47, seen=6, filtered="87%", later_more=6, held_more=23)

NOTIF_NOW = [
    ("n1", "mail", "메일 · 회사", "수아님 — 분기 리포트 피드백",
     "수치 2곳 수정 요청, 목요일까지 회신을 원해요.", "09:12", "VIP 발신자", "coral", "답장 초안 보기"),
    ("n2", "cal", "캘린더", "디자인 리뷰 15분 전",
     "14:00 · 회의실 B · 현우·민서 참석 — 브리핑 준비됨.", "13:45", "곧 시작", "blue", "브리핑 열기"),
    ("n3", "chat", "팀 메신저", "현우님이 나를 멘션",
     "“온보딩 검수, 지우님 확인 필요해요” — #온보딩 채널.", "11:20", "직접 멘션", "violet", "스레드 열기"),
    ("n4", "fin", "카드", "평소와 다른 결제 감지",
     "해외 온라인 결제 42,000원 — 본인 결제가 맞나요?", "10:31", "보안", "coral", "확인하기"),
    ("n5", "app", "배포 시스템", "온보딩 v2 배포 완료",
     "QA 시작 가능 — 체크리스트가 작업 보드에 추가됐어요.", "10:05", "내 프로젝트", "green", "작업 보기"),
    ("n6", "mail", "메일 · 회사", "민서님 — 1:1 노트 공유",
     "지난 1:1 액션 2건을 완료로 표시했어요.", "08:40", "팀", "blue", "노트 보기"),
]
NOTIF_LATER = [
    ("l1", "mail", "메일 · 개인", "뉴스레터 5통 묶음",
     "프로덕트 위클리 외 4종 — 저녁 다이제스트에 들어가요.", "오전", "다이제스트", "violet"),
    ("l2", "app", "분석 도구", "주간 지표 리포트 도착",
     "온보딩 전환율 +2.1%p — 자세한 건 리서치에 정리해둘게요.", "07:00", "주간 보고", "blue"),
    ("l3", "chat", "팀 메신저", "#일반 채널 새 글 12개",
     "회식 장소 투표가 열렸어요 — 마감은 내일 정오.", "오전", "낮은 우선순위", "muted"),
    ("l4", "fin", "구독", "음악 구독 결제 예정 D-3",
     "월 10,900원 — 지난달 재생 시간이 절반으로 줄었어요.", "어제", "결제 예고", "amber"),
    ("l5", "app", "디자인 툴", "코멘트 3건",
     "온보딩 화면 시안에 민서님이 코멘트를 남겼어요.", "어제", "낮은 우선순위", "muted"),
    ("l6", "mail", "메일 · 회사", "사내 워크숍 신청 안내",
     "7월 워크숍 신청 마감 6/20 — 일정 비어 있어요.", "어제", "마감 여유", "muted"),
]
NOTIF_HELD = [
    ("h1", "mail", "메일 · 전체", "프로모션·마케팅 18건 자동 보관",
     "광고성 메일은 읽지 않고 보관함으로 옮겼어요.", "오늘", "규칙: 자동 보관", "muted", "sr5"),
    ("h2", "cal", "캘린더", "회의 초대 자동 수락",
     "목 11:00 QA 싱크’ — 빈 시간 확인 후 수락했어요.", "10:40", "빈 시간 일치", "green", ""),
    ("h3", "app", "쇼핑", "배송 출발 → 일정에 기록",
     "내일 도착 예정 — 캘린더에 메모로 달아뒀어요.", "09:30", "정보성", "blue", ""),
    ("h4", "mail", "메일 · 개인", "인증·OTP 메일 4건 정리",
     "사용이 끝난 인증 메일은 휴지통으로 보냈어요.", "오늘", "일회성", "muted", ""),
    ("h5", "app", "앱 푸시", "광고 푸시 3건 차단",
     "같은 앱에서 반복된 프로모션 푸시를 묶어 차단했어요.", "오늘", "반복 광고", "muted", ""),
    ("h6", "chat", "메일 + 메신저", "중복 알림 합침",
     "같은 문서 공유 알림이 두 채널로 와서 하나로 합쳤어요.", "08:15", "중복", "blue", ""),
]
DIGESTS = [
    ("dg-am",    "09:00", "아침 브리핑",     "밤사이 메일·뉴스·일정 변화", 11, True),
    ("dg-noon",  "13:00", "점심 묶음",       "오전의 ‘나중에’ 알림",      7,  True),
    ("dg-eve",   "18:30", "저녁 다이제스트", "뉴스레터·소셜·구독 소식",   12, False),
]
# (id, who, from_label, rule, bucket, tone)
SENDERS = [
    ("sr1", "수아님",            "메일 · 메신저", "항상 즉시",         "now",   "coral"),
    ("sr2", "현우 · 민서 · 재호", "팀 메신저",     "멘션만 즉시",       "now",   "blue"),
    ("sr3", "뉴스레터 12종",      "메일",          "저녁 다이제스트",   "later", "violet"),
    ("sr4", "은행 · 카드",        "푸시",          "이상 거래만 즉시",  "now",   "green"),
    ("sr5", "마케팅 · 프로모션",  "메일 · 푸시",   "자동 보관",         "held",  "amber"),
]


def seed_mail(s) -> None:
    # 메일 전용 발신자 (role 포함)
    for pid, name, ini, color, role in MAIL_PEOPLE:
        if not s.get(Person, pid):
            s.add(Person(id=pid, name=name, initial=ini, color=color, is_me=False, role=role))
    for i, (aid, name, email, tone, kind, unread) in enumerate(MAIL_ACCOUNTS):
        s.add(MailAccount(id=aid, name=name, email=email, tone=tone, kind=kind,
                          unread=unread, connector="mock", sort_order=i))
    for fid, name, icon, order in MAIL_FOLDERS:
        s.add(MailFolder(id=fid, name=name, icon=icon, sort_order=order))
    for i, m in enumerate(EMAILS):
        s.add(Email(
            id=m["id"], account=m["account"], from_key=m["from_key"], to=m.get("to", ""),
            subject=m["subject"], time=m["time"], date=m["date"],
            read=m.get("read", False), starred=m.get("starred", False),
            has_attach=m.get("has_attach", False), labels=m.get("labels", []),
            preview=m.get("preview", ""), body=m.get("body", []),
            attachments=m.get("attachments", []), ai_json=m.get("ai", {}), sort_order=i))
    for i, (sid, acc, to, tokey, subj, time, date, prev) in enumerate(SENT):
        s.add(Sent(id=sid, account=acc, to=to, to_key=tokey, subject=subj,
                   time=time, date=date, preview=prev, sort_order=i))
    for i, (did, acc, to, subj, time, prev, body) in enumerate(DRAFTS):
        s.add(Draft(id=did, account=acc, to=to, subject=subj, time=time,
                    preview=prev, body=body, sort_order=i))
    s.commit()


def seed_notify(s) -> None:
    s.add(NotifyStats(id=1, **NOTIFY_STATS))
    s.add(NotificationGuard(id=1, on=True, until="16:00", held=4, quiet="22:30  07:00"))
    rows = (
        [(r, NotifyBucket.now)   for r in NOTIF_NOW] +
        [(r, NotifyBucket.later) for r in NOTIF_LATER] +
        [(r, NotifyBucket.held)  for r in NOTIF_HELD]
    )
    order = 0
    for tup, bucket in rows:
        if bucket == NotifyBucket.held:
            nid, src, app, title, summ, time, why, tone, rule = tup
        else:
            nid, src, app, title, summ, time, why, tone, *rest = tup
            rule = ""
        s.add(Notification(id=nid, bucket=bucket, src=src, app=app, title=title, sum=summ,
                           time=time, why=why, tone=tone,
                           act=(tup[8] if bucket == NotifyBucket.now else ""),
                           held_by_rule=rule, sort_order=order))
        order += 1
    for i, (did, time, name, desc, count, done) in enumerate(DIGESTS):
        s.add(Digest(id=did, time=time, name=name, desc=desc, count=count, done=done, sort_order=i))
    for i, (rid, who, frm, rule, bucket, tone) in enumerate(SENDERS):
        s.add(SenderRule(id=rid, who=who, from_label=frm, rule=rule,
                         bucket=NotifyBucket(bucket), tone=tone, sort_order=i))
    s.commit()

run_seed 통합(phase-2 _run 본문 끝, _seed_dashboard(s) 다음에 호출 — phase-7/8 와 동일 패턴):

def _run(s: Session, reset: bool) -> None:
    ...
    _seed_dashboard(s)
    seed_approvals(s)      # phase-7
    seed_automation(s)     # phase-7
    seed_calendar(s)       # phase-8
    seed_mail(s)           # ← 이 phase
    seed_notify(s)         # ← 이 phase
    s.commit()

reset=True 시 삭제 대상 테이블 목록에 신규 10개 테이블을 추가한다(FK 순서: Email/Sent/Draft → MailFolder → MailAccount, Notification/Digest/SenderRule/NotificationGuard/NotifyStats). Person.role 컬럼은 phase-2 Personrole: str = "" 추가 + alembic 마이그레이션(add column person.role).

3.2 스키마 — schemas.py (추가분)

Email.from_key → 응답 from으로 직렬화하기 위해 Pydantic alias 를 쓴다(populate_by_name=True).

# backend/app/schemas.py  (추가)
from pydantic import BaseModel, Field, ConfigDict


# ---------- 메일 ----------
class MailAccountOut(BaseModel):
    id: str; name: str; email: str; tone: str; kind: str; unread: int


class MailFolderOut(BaseModel):
    id: str; name: str; icon: str


class AiTaskOut(BaseModel):
    text: str; project: str; due: str = ""; prio: str = ""

class AiEventOut(BaseModel):
    title: str; date: str = ""; day: str = ""; time: str = ""; dur: str = ""; place: str = ""

class AiReplyOut(BaseModel):
    tone: str; preview: str; body: str

class AiFileOut(BaseModel):
    project: str; reason: str

class AiAnalysisOut(BaseModel):
    summary: str = ""
    priority: str = "보통"                 # 높음|보통|낮음
    category: str = ""
    tasks: list[AiTaskOut] = []
    events: list[AiEventOut] = []
    replies: list[AiReplyOut] = []
    file: AiFileOut | None = None


class EmailRowOut(BaseModel):
    """목록 행 (가벼움)."""
    model_config = ConfigDict(populate_by_name=True)
    id: str
    account: str
    from_: str = Field(alias="from")        # 응답 키는 'from'
    to: str
    subject: str
    time: str
    read: bool
    starred: bool
    has_attach: bool
    labels: list[str] = []
    preview: str
    ai: AiAnalysisOut | None = None         # 목록에서도 pill 표시에 ai 필요


class EmailDetailOut(EmailRowOut):
    date: str
    body: list[str] = []
    attachments: list[dict] = []


class ExtractRequest(BaseModel):
    kind: str                               # "task" | "event" | "file"
    index: int = 0                          # ai.tasks[index] / ai.events[index]


class ExtractResponse(BaseModel):
    kind: str                               # "task"|"event"|"file"
    task: "TaskNode | None" = None          # kind=task 면 생성된 작업
    event_id: str | None = None             # kind=event 면 생성된 일정 id
    project: str | None = None              # kind=file 면 정리된 프로젝트명
    message: str                            # "작업에 추가했어요" 등 토스트 문구


class ExtractAllResponse(BaseModel):
    created_task_ids: list[str] = []
    created_event_ids: list[str] = []
    filed_project: str | None = None
    message: str


class ReplyDraftRequest(BaseModel):
    reply_index: int | None = None          # ai.replies[index] 선택. None 이면 LLM 새 초안
    tone: str | None = None                 # "더 정중하게" 등 (compose AI 보조)


class ReplyDraftOut(BaseModel):
    to: str                                  # "현우 <hyunwoo@lumi.co>"
    from_account: str                        # 메일 account
    subject: str                             # "Re: ..."
    body: str


class SendRequest(BaseModel):
    from_account: str
    to: str
    subject: str
    body: str
    in_reply_to: str | None = None           # 원본 email id


class SendResponse(BaseModel):
    approval_id: str                         # 생성된 결재(high-risk) id
    status: str                              # "pending" (결재함 대기)
    message: str                             # "보낼 준비가 됐어요 — 결재함에서 확인하세요"


# ---------- 알림 ----------
class NotificationOut(BaseModel):
    id: str
    bucket: str                              # now|later|held
    src: str
    app: str
    title: str
    sum: str
    time: str
    why: str
    tone: str
    act: str = ""


class TriageOut(BaseModel):
    now: list[NotificationOut]
    later: list[NotificationOut]
    held: list[NotificationOut]
    counts: dict                             # {now,later,held} (later/held 는 +more 합산)
    stats: dict                              # {total,seen,filtered}


class ReclassifyNotifRequest(BaseModel):
    to_bucket: str                           # "later" | "held" | "now"


class GuardOut(BaseModel):
    on: bool; until: str; held: int; quiet: str

class GuardPatch(BaseModel):
    on: bool

class DigestOut(BaseModel):
    id: str; time: str; name: str; desc: str; count: int; done: bool

class SenderRuleOut(BaseModel):
    id: str; who: str; from_label: str = Field(serialization_alias="from"); rule: str; tone: str; bucket: str

class SenderRuleCreate(BaseModel):
    who: str; from_label: str; rule: str; bucket: str = "now"; tone: str = "ink"

EmailRowOut.from_/SenderRuleOut.from_labelalias/serialization_alias로 원본 키(from)를 보존한다. 프론트 lib/types.tsfrom: string을 쓴다(원본 mail.jsxm.from을 참조하므로 키 일치 필수).

3.3 커넥터 — connectors/mail.py, connectors/chat.py

CONTRACT: 커넥터 추상화 = 도메인별 base 인터페이스 + MockConnector(시드 기반) + RealConnector(phase-13). 설정 CONNECTOR_MAIL=mock|real(기본 mock). 페이지는 mock-first.

# backend/app/connectors/mail.py
from abc import ABC, abstractmethod
from sqlmodel import Session, select
from ..models import Email, MailAccount, Sent


class MailConnector(ABC):
    """phase-13 이 RealConnector(Gmail/Workspace/HEY API)로 같은 인터페이스를 끼운다."""
    @abstractmethod
    def list_inbox(self, s: Session, account: str | None) -> list[Email]: ...
    @abstractmethod
    def get(self, s: Session, email_id: str) -> Email | None: ...
    @abstractmethod
    def send(self, s: Session, *, from_account: str, to: str, subject: str, body: str) -> str:
        """실제 발송. mock 은 Sent 행을 만들고 id 반환. (실 발송은 결재 승인 후 호출)"""


class MockMailConnector(MailConnector):
    """시드 기반. CONNECTOR_MAIL=mock (기본)."""
    def list_inbox(self, s, account):
        q = select(Email).where(Email.archived == False).order_by(Email.sort_order)  # noqa: E712
        if account:
            q = q.where(Email.account == account)
        return s.exec(q).all()

    def get(self, s, email_id):
        return s.get(Email, email_id)

    def send(self, s, *, from_account, to, subject, body):
        import uuid
        sid = "snt-" + uuid.uuid4().hex[:8]
        s.add(Sent(id=sid, account=from_account, to=to, subject=subject,
                   time="방금", date="오늘", preview=body[:40]))
        s.commit()
        return sid


def get_mail_connector() -> MailConnector:
    from ..config import get_settings
    mode = getattr(get_settings(), "connector_mail", "mock")
    if mode == "real":
        from .real_mail import RealMailConnector   # phase-13
        return RealMailConnector()
    return MockMailConnector()

connectors/chat.py는 동일 패턴의 ChatConnector/MockChatConnector(알림 src=chat의 멘션 스트림을 시드에서 읽음). config.pyconnector_mail: str = "mock", connector_chat: str = "mock" 추가(env CONNECTOR_MAIL/CONNECTOR_CHAT).

3.4 메일 AI 서비스 — services/mail_ai.py

아리 분석은 LLM provider(요약/분류/추출/초안)로 수행하고, 미가용/오프라인/테스트 시 시드 ai_json을 그대로 반환(데모 결정성). 즉 시드 메일은 이미 분석돼 있으므로 폴백은 "저장된 분석 재사용"이고, 신규 메일(커넥터가 새로 가져온)에 한해 LLM을 부른다.

# backend/app/services/mail_ai.py
from sqlmodel import Session
from ..models import Email
from ..llm.provider import LLMProvider

ANALYZE_SCHEMA = {
    "type": "object",
    "properties": {
        "summary": {"type": "string"},
        "priority": {"type": "string", "enum": ["높음", "보통", "낮음"]},
        "category": {"type": "string"},
        "tasks": {"type": "array", "items": {"type": "object"}},
        "events": {"type": "array", "items": {"type": "object"}},
        "replies": {"type": "array", "items": {"type": "object"}},
        "file": {"type": ["object", "null"]},
    },
    "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

제목: {subject}
보낸 사람: {sender}
본문:
{body}
"""


def analyze_email(s: Session, email: Email, provider: LLMProvider) -> dict:
    """신규/재분석용. 시드 메일은 ai_json 이 이미 있으면 그대로 사용(결정성)."""
    if email.ai_json:
        return email.ai_json
    prompt = ANALYZE_PROMPT.format(
        subject=email.subject, sender=email.from_key, body="\n".join(email.body))
    try:
        result = provider.generate_json(prompt, ANALYZE_SCHEMA)
    except Exception:
        result = _heuristic_analyze(email)     # provider 실패 시 규칙 폴백
    email.ai_json = result
    s.add(email); s.commit()
    return result


def _heuristic_analyze(email: Email) -> dict:
    """LLM 미가용/오프라인 폴백 — phase-2 HeuristicProvider 정신과 동일.
    시간표현(\\d시/\\d:\\d\\d) → event 후보, 행동동사(부탁/검토/회신) → task 후보."""
    import re
    text = email.subject + " " + " ".join(email.body)
    tasks, events = [], []
    if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", text):
        events.append({"title": email.subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""})
    if re.search(r"부탁|검토|회신|피드백|정리|확인", text):
        tasks.append({"text": email.subject[:30], "project": "받은편지함", "due": "", "prio": "보통"})
    prio = "높음" if ("[중요]" in email.subject or "마감" in text) else "보통"
    return {"summary": email.preview[:60], "priority": prio, "category": "메일",
            "tasks": tasks, "events": events, "replies": [], "file": None}

3.5 알림 트리아지 서비스 — services/triage.py

발신자 규칙 + 가드 → 버킷 결정 + 이유(why/tone). 시드 알림은 이미 버킷이 정해져 있으나, 새 알림(커넥터가 들고 온 mail.received/chat 멘션)은 이 함수가 분류한다. "나중에" 반복 패턴은 suggester(phase-7)로 넘긴다.

# backend/app/services/triage.py
from sqlmodel import Session, select
from ..models import SenderRule, NotificationGuard, Notification, NotifyBucket


def classify_notification(s: Session, *, src: str, sender: str, title: str) -> dict:
    """새 알림 1건의 버킷/이유 결정. 반환 {bucket, why, tone, held_by_rule}."""
    # 1) 발신자 규칙 우선
    for r in s.exec(select(SenderRule).order_by(SenderRule.sort_order)).all():
        if sender and (sender in r.who or r.who in sender):
            why = {"now": f"규칙: {r.rule}", "later": "다이제스트", "held": f"규칙: {r.rule}"}[r.bucket.value]
            return {"bucket": r.bucket.value, "why": why, "tone": r.tone,
                    "held_by_rule": r.id if r.bucket == NotifyBucket.held else ""}
    # 2) 가드(딥 워크) 중이면 긴급 외 보류 → later
    guard = s.get(NotificationGuard, 1)
    if guard and guard.on and src not in ("cal",):     # 캘린더 임박 알림은 통과
        return {"bucket": "later", "why": "집중 보호 중", "tone": "muted", "held_by_rule": ""}
    # 3) 기본 — 직접 봐야 하는 src 는 now
    if src in ("mail", "chat", "cal", "fin"):
        return {"bucket": "now", "why": "기본", "tone": "blue", "held_by_rule": ""}
    return {"bucket": "later", "why": "낮은 우선순위", "tone": "muted", "held_by_rule": ""}


def detect_defer_pattern(s: Session, threshold: int = 3) -> list[dict]:
    """'나중에'로 반복 미룬 발신자/앱 패턴 탐지 → 자동화 제안 후보.
    phase-7 suggester 가 호출해 automation_suggestion 으로 만든다."""
    counts: dict[str, int] = {}
    for n in s.exec(select(Notification).where(Notification.bucket == NotifyBucket.later)).all():
        if n.why == "내가 미룸":          # 사용자가 직접 미룬 것만 카운트(자동 다이제스트 제외)
            counts[n.app] = counts.get(n.app, 0) + 1
    return [{"app": app, "count": c, "offer": f"{app} 알림을 항상 저녁 다이제스트로 묶을까요?"}
            for app, c in counts.items() if c >= threshold]

3.6 라우터 — routers/mail.py

라우터 prefix 규약: 내부 prefix 없이 경로 정의, main.py에서 include_router(mail.router, prefix="/api", tags=["mail"]).

# backend/app/routers/mail.py
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
import uuid
from ..db import get_session
from ..models import (Email, MailAccount, MailFolder, Sent, Draft, Task, Person,
                      TaskStatus, Prio, Approval)
from ..schemas import (MailAccountOut, MailFolderOut, EmailRowOut, EmailDetailOut,
                       ExtractRequest, ExtractResponse, ExtractAllResponse,
                       ReplyDraftRequest, ReplyDraftOut, SendRequest, SendResponse)
from ..connectors.mail import get_mail_connector
from ..services.mail_ai import analyze_email
from ..llm.provider import LLMProvider, get_provider
from ..routers.tasks import to_node
# phase-8 일정 생성 헬퍼
from ..routers.calendar import create_event_from_extract   # {title,date,day,time,dur,place} → event id

router = APIRouter()

# 프로젝트명(라벨) → project_id 매핑 (extract 시 작업 행선지 결정)
PROJECT_LABEL_MAP = {
    "온보딩 리디자인 · 와이어프레임": "onb-wire",
    "온보딩 리디자인 · 음성 인터페이스": "onb-voice",
    "온보딩 리디자인": "onb",
    "경영 전략 · 분기 리포트": "biz-report",
    "경영 전략 · 예산 관리": "biz-budget",
    "경영 전략": "biz",
    "팀 운영 · 채용 & 온보딩": "team-hr",
    "팀 운영": "team",
    "개인 · 성장": "me",
    "개인": "me",
}

def _row_out(s: Session, e: Email) -> EmailRowOut:
    return EmailRowOut.model_validate({
        "id": e.id, "account": e.account, "from": e.from_key, "to": e.to,
        "subject": e.subject, "time": e.time, "read": e.read, "starred": e.starred,
        "has_attach": e.has_attach, "labels": e.labels, "preview": e.preview,
        "ai": e.ai_json or None,
    })

@router.get("/mail/accounts", response_model=list[MailAccountOut])
def accounts(s: Session = Depends(get_session)):
    return s.exec(select(MailAccount).order_by(MailAccount.sort_order)).all()

@router.get("/mail/folders", response_model=list[MailFolderOut])
def folders(s: Session = Depends(get_session)):
    return s.exec(select(MailFolder).order_by(MailFolder.sort_order)).all()

@router.get("/mail", response_model=list[EmailRowOut])
def list_mail(account: str | None = Query(None),
              folder: str = Query("smart"),
              s: Session = Depends(get_session)):
    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)
    return [_row_out(s, e) for e in rows]

@router.get("/mail/{eid}", response_model=EmailDetailOut)
def get_mail(eid: str, s: Session = Depends(get_session)):
    e = get_mail_connector().get(s, eid)
    if not e:
        raise HTTPException(404, "email not found")
    if not e.read:                       # 열람 = 읽음 처리
        e.read = True; s.add(e); s.commit()
    d = _row_out(s, e).model_dump(by_alias=True)
    d.update({"date": e.date, "body": e.body, "attachments": e.attachments})
    return EmailDetailOut.model_validate(d)

@router.post("/mail/{eid}/extract", response_model=ExtractResponse)
def extract(eid: str, body: ExtractRequest, s: Session = Depends(get_session)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    ai = e.ai_json or {}
    if body.kind == "task":
        t = ai["tasks"][body.index]
        pid = PROJECT_LABEL_MAP.get(t["project"], "me")
        tid = "t-" + uuid.uuid4().hex[:8]
        task = Task(id=tid, title=t["text"], project_id=pid, status=TaskStatus.todo,
                    assignee_id="jiwoo", prio=Prio(t.get("prio") or "보통"),
                    notes=f"{e.subject} 메일에서 아리가 추출한 작업이에요.")
        s.add(task); s.commit(); s.refresh(task)
        node = to_node(s, task, s.exec(select(Task)).all())
        return ExtractResponse(kind="task", task=node, message="작업에 추가했어요")
    if body.kind == "event":
        ev = ai["events"][body.index]
        ev_id = create_event_from_extract(s, ev, source=f"mail:{eid}")  # phase-8
        return ExtractResponse(kind="event", event_id=ev_id, message="일정에 추가했어요")
    if body.kind == "file":
        proj = ai["file"]["project"]
        return ExtractResponse(kind="file", project=proj, message=f"{proj}’에 정리했어요")
    raise HTTPException(400, "kind must be task|event|file")

@router.post("/mail/{eid}/extract-all", response_model=ExtractAllResponse)
def extract_all(eid: str, s: Session = Depends(get_session)):
    """원본 '모두 적용' (AriPanel ap-allbar)."""
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    ai = e.ai_json or {}
    task_ids, event_ids = [], []
    for i in range(len(ai.get("tasks", []))):
        task_ids.append(extract(eid, ExtractRequest(kind="task", index=i), s).task.id)
    for i in range(len(ai.get("events", []))):
        event_ids.append(extract(eid, ExtractRequest(kind="event", index=i), s).event_id)
    filed = ai.get("file", {}).get("project") if ai.get("file") else None
    return ExtractAllResponse(created_task_ids=task_ids, created_event_ids=event_ids,
                              filed_project=filed, message="아리가 준비한 작업을 모두 적용했어요")

@router.post("/mail/{eid}/reply-draft", response_model=ReplyDraftOut)
def reply_draft(eid: str, body: ReplyDraftRequest, s: Session = Depends(get_session),
                provider: LLMProvider = Depends(get_provider)):
    e = s.get(Email, eid)
    if not e:
        raise HTTPException(404, "email not found")
    p = s.get(Person, e.from_key)
    to = f"{p.name} <{p.email}>" if p and getattr(p, 'email', None) else (p.name if p else e.from_key)
    ai = e.ai_json or {}
    if body.reply_index is not None and ai.get("replies"):
        draft = ai["replies"][body.reply_index]["body"]
    else:
        # LLM 새 초안 (미가용 시 기본 문구)
        try:
            draft = provider.generate_json(
                f"이 메일에 보낼 정중한 한국어 회신 초안 한 문단:\n{e.subject}\n{' '.join(e.body)}",
                {"type": "object", "properties": {"body": {"type": "string"}}}).get("body", "")
        except Exception:
            draft = "안녕하세요,\n\n메일 잘 받았습니다. 확인하고 빠른 시일 내 회신드리겠습니다.\n\n감사합니다.\n지우 드림"
    return ReplyDraftOut(to=to, from_account=e.account, subject="Re: " + e.subject, body=draft)

@router.post("/mail/send", response_model=SendResponse)
def send(body: SendRequest, s: Session = Depends(get_session)):
    """회신/새 메일 보내기 = high-risk → 결재함(phase-7). 실제 발송은 승인 후."""
    aid = "ap-" + uuid.uuid4().hex[:8]
    appr = Approval(
        id=aid, icon="mail", tone="violet", risk="high", status="pending", source="mail",
        time="보내기 대기", title=f"{body.to}에게 메일을 보낼 준비가 됐어요",
        detail=f"제목: {body.subject}\n\n{body.body[:120]}…",
        cta="보내기", alt="수정", undo_label="")
    s.add(appr); s.commit()
    return SendResponse(approval_id=aid, status="pending",
                        message="보낼 준비가 됐어요 — 결재함에서 확인하세요")

Approvalstatus/source 필드는 phase-7 모델 정의. risk="high"라 mixed/full_auto 에서도 자동 실행되지 않고 결재함 대기(CONTRACT: high=승인 대기). 결재함에서 cta="보내기" 승인 시 phase-7 evaluator 가 get_mail_connector().send(...) 호출 → Sent 행 생성 → approval.executed 이벤트(하루 마감 합산).

3.7 라우터 — routers/notify.py

# backend/app/routers/notify.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
import uuid
from ..db import get_session
from ..models import (Notification, NotificationGuard, Digest, SenderRule, NotifyStats,
                      NotifyBucket)
from ..schemas import (NotificationOut, TriageOut, ReclassifyNotifRequest,
                       GuardOut, GuardPatch, DigestOut, SenderRuleOut, SenderRuleCreate)
from ..automation.event_bus import publish     # phase-7
from ..services.triage import detect_defer_pattern

router = APIRouter()

def _out(n: Notification) -> NotificationOut:
    return NotificationOut(id=n.id, bucket=n.bucket.value, src=n.src, app=n.app,
                           title=n.title, sum=n.sum, time=n.time, why=n.why,
                           tone=n.tone, act=n.act)

@router.get("/notifications", response_model=TriageOut)
def triage(s: Session = Depends(get_session)):
    rows = s.exec(select(Notification).order_by(Notification.sort_order)).all()
    stats = s.get(NotifyStats, 1)
    buckets = {"now": [], "later": [], "held": []}
    for n in rows:
        buckets[n.bucket.value].append(_out(n))
    counts = {
        "now": len(buckets["now"]),
        "later": len(buckets["later"]) + (stats.later_more if stats else 0),
        "held": len(buckets["held"]) + (stats.held_more if stats else 0),
    }
    return TriageOut(now=buckets["now"], later=buckets["later"], held=buckets["held"],
                     counts=counts,
                     stats={"total": stats.total, "seen": stats.seen, "filtered": stats.filtered} if stats else {})

@router.post("/notifications/{nid}/reclassify", response_model=NotificationOut)
def reclassify(nid: str, body: ReclassifyNotifRequest, s: Session = Depends(get_session)):
    n = s.get(Notification, nid)
    if not n:
        raise HTTPException(404, "notification not found")
    prev = n.bucket.value
    n.bucket = NotifyBucket(body.to_bucket)
    # 이유 칩 갱신 (원본: now→later 는 '내가 미룸/muted', held→now 되돌림은 '되돌림/amber')
    if body.to_bucket == "later":
        n.why, n.tone, n.act = "내가 미룸", "muted", ""
    elif body.to_bucket == "now" and prev == "held":
        n.why, n.tone = "되돌림", "amber"
    s.add(n); s.commit()
    # 연합: 사용자가 직접 미룬 알림 → 반복 패턴 탐지 → suggester 후보 이벤트
    if body.to_bucket == "later":
        publish("notification.triaged", {"id": nid, "to": "later", "by": "user"})
        for cand in detect_defer_pattern(s):
            publish("automation.suggested", cand)   # phase-7 suggester 구독
    return _out(n)

@router.post("/notifications/{nid}/undo", response_model=NotificationOut)
def undo(nid: str, s: Session = Depends(get_session)):
    """held → now 되돌리기 (원본 undo)."""
    n = s.get(Notification, nid)
    if not n:
        raise HTTPException(404, "notification not found")
    n.bucket = NotifyBucket.now
    n.why, n.tone = "되돌림", "amber"
    s.add(n); s.commit()
    publish("notification.triaged", {"id": nid, "to": "now", "by": "undo"})
    return _out(n)

@router.get("/notifications/guard", response_model=GuardOut)
def get_guard(s: Session = Depends(get_session)):
    g = s.get(NotificationGuard, 1)
    return GuardOut(on=g.on, until=g.until, held=g.held, quiet=g.quiet)

@router.post("/notifications/guard", response_model=GuardOut)
def set_guard(body: GuardPatch, s: Session = Depends(get_session)):
    g = s.get(NotificationGuard, 1)
    g.on = body.on; s.add(g); s.commit()
    return GuardOut(on=g.on, until=g.until, held=g.held, quiet=g.quiet)

@router.get("/notifications/digests", response_model=list[DigestOut])
def digests(s: Session = Depends(get_session)):
    return s.exec(select(Digest).order_by(Digest.sort_order)).all()

@router.get("/notifications/senders", response_model=list[SenderRuleOut])
def senders(s: Session = Depends(get_session)):
    rows = s.exec(select(SenderRule).order_by(SenderRule.sort_order)).all()
    return [SenderRuleOut(id=r.id, who=r.who, from_label=r.from_label, rule=r.rule,
                          tone=r.tone, bucket=r.bucket.value) for r in rows]

@router.post("/notifications/senders", response_model=SenderRuleOut)
def add_sender(body: SenderRuleCreate, s: Session = Depends(get_session)):
    rid = "sr-" + uuid.uuid4().hex[:6]
    last = s.exec(select(SenderRule)).all()
    r = SenderRule(id=rid, who=body.who, from_label=body.from_label, rule=body.rule,
                   bucket=NotifyBucket(body.bucket), tone=body.tone, sort_order=len(last))
    s.add(r); s.commit()
    return SenderRuleOut(id=r.id, who=r.who, from_label=r.from_label, rule=r.rule,
                         tone=r.tone, bucket=r.bucket.value)

main.py 등록 추가:

from .routers import mail, notify
app.include_router(mail.router, prefix="/api", tags=["mail"])
app.include_router(notify.router, prefix="/api", tags=["notify"])

4. 상세 구현 — 메일 프론트엔드

4.1 라우트 & 데이터 훅

원본 mail.jsx는 localStorage 영속(ariM.*)으로 read/star/archive/added 상태를 들고 있다. 우리 구현은 서버 상태(GET /api/mail)를 정본으로 하되, 낙관적 토글(star/archive)은 클라이언트에서 즉시 반영 후 PATCH(없으면 로컬). 핵심 상호작용은 서버 왕복(extract→작업/일정, send→결재함).

// frontend/lib/hooks/useMail.ts
import useSWR from "swr";
import { api } from "@/lib/api";

export function useMail(account: string | null, folder: string) {
  const qs = new URLSearchParams();
  if (account) qs.set("account", account);
  qs.set("folder", folder);
  const { data: emails, mutate } = useSWR(`/api/mail?${qs}`, api.get);
  const { data: accounts } = useSWR("/api/mail/accounts", api.get);
  const { data: folders } = useSWR("/api/mail/folders", api.get);
  return { emails, accounts, folders, mutate };
}

export const mailApi = {
  detail: (id: string) => api.get(`/api/mail/${id}`),
  extract: (id: string, kind: "task" | "event" | "file", index = 0) =>
    api.post(`/api/mail/${id}/extract`, { kind, index }),
  extractAll: (id: string) => api.post(`/api/mail/${id}/extract-all`, {}),
  replyDraft: (id: string, reply_index: number | null) =>
    api.post(`/api/mail/${id}/reply-draft`, { reply_index }),
  send: (body: any) => api.post(`/api/mail/send`, body),
};

4.2 컴포넌트 트리 (원본 mail.jsx 1:1)

컴포넌트 원본 역할
MailSidebar <aside className="subnav"> 새 메일 버튼, 아리 정리(sn-smart), 폴더(fl-row), 계정(acc-row), 푸터(sn-foot)
MailList <div className="mlist"> 헤더(제목/통수/동기화 ml-sync/검색 ml-search), 행 목록(MailRow)
MailRow MailRow 아바타(+acc-pip), 발신자/시간, 제목/프리뷰, AI pill(중요/할 일 N/일정 N/답장 추천), 별표/첨부
Reader Reader 툴바(답장/전체답장/전달/별표/보관/삭제), 본문, 첨부, AriPanel
AriPanel AriPanel 분석 헤더(우선순위 pill), 요약, 할 일/일정 Sug, 답장 추천 rep, 프로젝트 정리 ap-file, 모두 적용 ap-allbar
Sug Sug 제안 항목(작업/일정) — 추가 버튼(추가됨 토글)
Triage Triage 아리 정리 히어로 + 3컬럼(할 일/일정/답장) — smart view & !sel 일 때
Compose Compose 작성 슬라이드오버(보내는 계정 사이클, AI 작성 도우미 4버튼, 보내기)

AriPanel의 추가 버튼은 원본의 로컬 added 맵 대신 extract API 호출:

// frontend/components/mail/Sug.tsx
function Sug({ kind, text, meta, added, onAdd }: SugProps) {
  return (
    <div className={"sug " + kind + (added ? " added" : "")}>
      <div className="sug-ico"><Icon name={kind === "task" ? "check" : "cal"} /></div>
      <div className="sug-main">
        <div className="sug-text">{text}</div>
        <div className="sug-meta">{meta}</div>
      </div>
      <button className={"sug-add" + (added ? " done" : "")} onClick={onAdd}>
        <Icon name={added ? "tick" : "plus"} w={added ? 3 : 1.9} />
        {added ? "추가됨" : (kind === "task" ? "작업" : "일정")}
      </button>
    </div>
  );
}
// app/mail/page.tsx (발췌 — 추가 핸들러)
const onAdd = async (key: string, kind: "task" | "event" | "file", index: number, label: string) => {
  if (added[key]) return;
  setAdded((a) => ({ ...a, [key]: true }));         // 낙관적
  const res = await mailApi.extract(selId!, kind, index);
  toast(res.message, kind === "event" ? "coral" : kind === "file" ? "violet" : "blue");
  // 연합: 작업/일정 페이지의 SWR 캐시 무효화 (등장)
  if (kind === "task") globalMutate("/api/tasks");
  if (kind === "event") globalMutate("/api/calendar");
};

const addAll = async () => {
  const res = await mailApi.extractAll(selId!);
  setAddedAllFor(selId!);
  toast(res.message, "green");
  globalMutate("/api/tasks"); globalMutate("/api/calendar");
};

const onReply = async (id: string, replyIndex: number | null) => {
  const d = await mailApi.replyDraft(id, replyIndex);
  setCompose({ from: d.from_account, to: d.to, subject: d.subject, body: d.body, inReplyTo: id });
};

const onSend = async (c: ComposeState) => {
  const res = await mailApi.send({
    from_account: c.from, to: c.to, subject: c.subject, body: c.body, in_reply_to: c.inReplyTo,
  });
  setCompose(null);
  toast(res.message, "violet");                      // "결재함에서 확인하세요"
  globalMutate("/api/approvals");                    // 결재함 등장 (phase-7)
};

4.3 레이아웃 (원본 mail.css 그대로)

.mwork { display:grid; grid-template-columns: 248px 384px minmax(0,1fr); gap:16px; height: calc(100vh - 96px); }. 사이드바/목록/리더는 글래스 카드(var(--glass) + var(--blur) + var(--glass-brd) + box-shadow: var(--shadow), var(--glass-hi), border-radius: var(--radius)=22px). 선택 행 .mrow.on { background: var(--card); box-shadow: inset 3px 0 0 0 var(--coral); }. 미읽음 점 .mrow.unread::before { background: var(--blue); }. 반응형: 1320px→사이드바 224/목록 340, 1080px→사이드바 숨김, 760px→.mlist.has-sel { display:none }(목록↔리더 토글).


5. 상세 구현 — 알림 트리아지 프론트엔드

5.1 라우트 & 데이터 훅

// frontend/lib/hooks/useNotify.ts
import useSWR from "swr";
import { api } from "@/lib/api";

export function useNotify() {
  const { data: triage, mutate } = useSWR("/api/notifications", api.get);
  const { data: guard, mutate: mGuard } = useSWR("/api/notifications/guard", api.get);
  const { data: digests } = useSWR("/api/notifications/digests", api.get);
  const { data: senders } = useSWR("/api/notifications/senders", api.get);
  return { triage, guard, digests, senders, mutate, mGuard };
}

export const notifyApi = {
  reclassify: (id: string, to_bucket: string) =>
    api.post(`/api/notifications/${id}/reclassify`, { to_bucket }),
  undo: (id: string) => api.post(`/api/notifications/${id}/undo`, {}),
  setGuard: (on: boolean) => api.post(`/api/notifications/guard`, { on }),
};

5.2 페이지 구조 (원본 notify.jsx 1:1)

페이지 셸은 dash.css.dash/.pagehead/.work/.board/.card를 그대로 쓴다(원본 notify.jsx는 MVP 셸 Topbar/SubRail을 import). SubRail 3항목:

const NT_RAIL = [
  { id: "triage",  icon: "bell",   label: "트리아지", dot: counts.now > 0 },
  { sep: true },
  { id: "guard",   icon: "shield", label: "집중 보호 · 묶음" },
  { id: "senders", icon: "users",  label: "발신자 규칙" },
];
const HEAD = {
  triage:  { title: "알림",      em: "중요한 것만 울려요" },
  guard:   { title: "집중 보호", em: "조용히, 그래도 놓치지 않게" },
  senders: { title: "발신자 규칙", em: "누구는 바로, 누구는 모아서" },
};

페이지헤드 eyebrow(원본 그대로): 오늘 {stats.total}건 도착 · 직접 본 건 {stats.seen}건 · {stats.filtered}는 아리가 걸렀어요.

5.3 트리아지 스트림 (탭 + 이유 칩 + 액션)

// frontend/components/notify/TriageStream.tsx
const NT_TABS = [
  { id: "now", label: "지금" },
  { id: "later", label: "나중에" },
  { id: "held", label: "아리가 처리" },
];
const NT_CAP = {
  now: "직접 봐야 하는 알림만 남겼어요. 나머지는 아리가 들고 있을게요.",
  later: "급하지 않은 알림은 묶어서 다이제스트 시간에 전달돼요.",
  held: "아리가 이미 처리한 알림이에요. 전부 되돌릴 수 있어요.",
};

function TriageStream({ triage, tab, setTab, onLater, onUndo }: Props) {
  const list = triage[tab];
  const more = tab === "later" ? triage.counts.later - triage.later.length
             : tab === "held"  ? triage.counts.held  - triage.held.length : 0;
  return (
    <section className="card sp2 r2">
      <div className="ch">
        <div className="ico"><Icon name="bell" /></div>
        <div className="htext"><h3>트리아지</h3><div className="sub">아리가 분류한 오늘의 알림</div></div>
      </div>
      <div className="nt-tabs">
        {NT_TABS.map((t) => (
          <button key={t.id} className={"nt-tab" + (tab === t.id ? " on" : "")} onClick={() => setTab(t.id)}>
            {t.label}<span className="n">{triage.counts[t.id]}</span>
          </button>
        ))}
      </div>
      <div className="nt-cap">{NT_CAP[tab]}</div>
      <div className="nt-list">
        {list.map((it) => (
          <div className="nt-row" key={it.id}>
            <SrcIcon src={it.src} />
            <div className="nt-body">
              <div className="nt-app"><span>{it.app}</span><WhyChip why={it.why} tone={it.tone} /></div>
              <div className="nt-title">{it.title}</div>
              <div className="nt-sum">{it.sum}</div>
              {tab === "now" && (
                <div className="nt-acts">
                  {it.act && <button className="nt-act">{it.act}</button>}
                  <button className="nt-act ghost" onClick={() => onLater(it)}>나중에</button>
                </div>
              )}
              {tab === "held" && (
                <div className="nt-acts">
                  <button className="nt-act ghost" onClick={() => onUndo(it)}>되돌리기</button>
                </div>
              )}
            </div>
            <span className="nt-time">{it.time}</span>
          </div>
        ))}
        {list.length === 0 && (
          <div className="nt-empty"><Icon name="tick" />비어 있어요. 오늘은 조용한 날이네요.</div>
        )}
      </div>
      {more > 0 && <div className="nt-more">비슷한 알림 +{more}  아리가 묶어뒀어요</div>}
    </section>
  );
}

WhyChip(이유 칩)과 SrcIcon(소스 아이콘)은 원본 그대로:

function WhyChip({ why, tone }: { why: string; tone: string }) {
  const t = tone === "muted" ? "var(--muted)" : `var(--${tone})`;
  return (
    <span className="nt-why" style={{ color: t, background: `color-mix(in oklab, ${t} 13%, transparent)` }}>
      {why}
    </span>
  );
}

const NT_SRC = {
  mail: { local: false, d: "mail", tone: "var(--blue)" },
  cal:  { local: false, d: "cal",  tone: "var(--violet)" },
  chat: { local: true,  d: "msg",  tone: "var(--violet)" },   // 페이지 전용 path
  fin:  { local: true,  d: "card", tone: "var(--green)" },
  app:  { local: false, d: "grid", tone: "var(--ink-2)" },
};

msg/card는 원본 notify.jsx의 페이지 전용 로컬 path(NT_P). 우리 구현은 Icon 중앙 맵에 msg/card를 추가하거나, notify 전용 NI 컴포넌트를 둔다(원본과 동일). path: msg = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z", card = "M2 5h20v14H2z|M2 10h20".

5.4 집중 보호 · 발신자 규칙

GuardPanel(원본 view === "guard"): 딥 워크 보호 토글(.sw/.sw.on), 조용한 시간(gd-quiet), 묶음 배달 목록(dg2-row, done 이면 opacity:0.5 + "— 전달됨"). SenderRules(원본 view === "senders"): sd-row(점·who·from·rule 칩) + 안내 노트(sd-note, "‘나중에’로 미룬 발신자가 쌓이면 아리가 새 규칙을 제안해요. 자동화 페이지에서 언제든 고칠 수 있어요." → phase-7 자동화 연계).

토글 동작:

const onToggleGuard = async () => {
  const next = !guard.on;
  mGuard({ ...guard, on: next }, false);   // 낙관적
  await notifyApi.setGuard(next);
  mGuard();
};

6. 데이터 / 타입 / API 계약

6.1 API 엔드포인트 표

메서드 경로 용도 요청 응답
GET /api/mail/accounts 3계정 MailAccountOut[]
GET /api/mail/folders 폴더 MailFolderOut[]
GET /api/mail?account=&folder= 목록 query EmailRowOut[]
GET /api/mail/{id} 상세(+읽음) EmailDetailOut
POST /api/mail/{id}/extract 할일/일정/정리 실체화 {kind,index} ExtractResponse
POST /api/mail/{id}/extract-all 모두 적용 ExtractAllResponse
POST /api/mail/{id}/reply-draft 회신 초안 {reply_index?} ReplyDraftOut
POST /api/mail/send 보내기→결재함 SendRequest SendResponse
GET /api/notifications 트리아지(3버킷+요약) TriageOut
POST /api/notifications/{id}/reclassify 재분류(now↔later↔held) {to_bucket} NotificationOut
POST /api/notifications/{id}/undo held→now 되돌리기 NotificationOut
GET/POST /api/notifications/guard 집중 보호 조회/토글 {on} GuardOut
GET /api/notifications/digests 묶음 배달 DigestOut[]
GET/POST /api/notifications/senders 발신자 규칙 조회/추가 SenderRuleCreate SenderRuleOut[]/SenderRuleOut

6.2 대표 요청/응답 JSON

GET /api/mail/m1 (상세 — from은 people 키, ai는 분석 묶음):

{
  "id": "m1", "account": "work", "from": "hyunwoo", "to": "나",
  "subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
  "time": "8분 전", "date": "오늘 09:12", "read": true, "starred": false, "has_attach": true,
  "labels": ["디자인"],
  "preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요…",
  "body": ["지우님, 안녕하세요. 현우입니다.", "..."],
  "attachments": [{"name":"온보딩_v3.fig","size":"4.2MB"},{"name":"변경사항_요약.pdf","size":"320KB"}],
  "ai": {
    "summary": "온보딩 시안 v3 도착 — 3개 화면(환영·권한요청·음성안내) 수정. 금요일 6/13까지 피드백 요청.",
    "priority": "높음", "category": "검토 요청",
    "tasks": [{"text":"온보딩 시안 v3 피드백 정리","project":"온보딩 리디자인 · 와이어프레임","due":"6/13","prio":"높음"}],
    "events": [],
    "replies": [{"tone":"수락 · 일정 약속","preview":"금요일까지 피드백 드릴게요","body":"현우님, 잘 받았어요! ..."}],
    "file": {"project":"온보딩 리디자인","reason":"‘와이어프레임’ 작업과 직접 연결돼요"}
  }
}

POST /api/mail/m1/extract 본문 {"kind":"task","index":0} → 응답:

{ "kind":"task", "message":"작업에 추가했어요",
  "task": {"id":"t-3a9f...","title":"온보딩 시안 v3 피드백 정리","project_id":"onb-wire",
           "status":"todo","assignee_id":"jiwoo","prio":"높음",
           "notes":"‘온보딩 시안 v3 — 금요일까지 피드백 부탁드려요’ 메일에서 아리가 추출한 작업이에요.","children":[]} }

POST /api/mail/m2/extract 본문 {"kind":"event","index":0} (m2 분기 전략 미팅) → 응답:

{ "kind":"event", "event_id":"ev-7c1d...", "message":"일정에 추가했어요" }

POST /api/mail/send (회신 보내기) → 결재함 대기:

{ "approval_id":"ap-8b2e...", "status":"pending", "message":"보낼 준비가 됐어요 — 결재함에서 확인하세요" }

GET /api/notifications (트리아지):

{
  "now": [ {"id":"n1","bucket":"now","src":"mail","app":"메일 · 회사","title":"수아님 — 분기 리포트 피드백",
            "sum":"수치 2곳 수정 요청, 목요일까지 회신을 원해요.","time":"09:12","why":"VIP 발신자","tone":"coral","act":"답장 초안 보기"} ],
  "later": [ {"id":"l1","bucket":"later","src":"mail","app":"메일 · 개인","title":"뉴스레터 5통 묶음","sum":"...","time":"오전","why":"다이제스트","tone":"violet","act":""} ],
  "held": [ {"id":"h1","bucket":"held","src":"mail","app":"메일 · 전체","title":"프로모션·마케팅 18건 자동 보관","sum":"...","time":"오늘","why":"규칙: 자동 보관","tone":"muted","act":""} ],
  "counts": { "now": 6, "later": 12, "held": 29 },
  "stats": { "total": 47, "seen": 6, "filtered": "87%" }
}

counts.later = later.length(6) + later_more(6) = 12, counts.held = held.length(6) + held_more(23) = 29 (원본 notify.jsxcounts 계산 그대로).

POST /api/notifications/n1/reclassify 본문 {"to_bucket":"later"}{"id":"n1","bucket":"later","why":"내가 미룸","tone":"muted","act":"", ...}.

POST /api/notifications/h1/undo{"id":"h1","bucket":"now","why":"되돌림","tone":"amber", ...}.

6.3 프론트 타입 (lib/types.ts 발췌)

export type AiTask = { text: string; project: string; due?: string; prio?: string };
export type AiEvent = { title: string; date?: string; day?: string; time?: string; dur?: string; place?: string };
export type AiReply = { tone: string; preview: string; body: string };
export type AiAnalysis = {
  summary: string; priority: "높음" | "보통" | "낮음"; category: string;
  tasks: AiTask[]; events: AiEvent[]; replies: AiReply[]; file: { project: string; reason: string } | null;
};
export type EmailRow = {
  id: string; account: string; from: string; to: string; subject: string;
  time: string; read: boolean; starred: boolean; has_attach: boolean;
  labels: string[]; preview: string; ai: AiAnalysis | null;
};
export type Notification = {
  id: string; bucket: "now" | "later" | "held"; src: "mail"|"cal"|"chat"|"fin"|"app";
  app: string; title: string; sum: string; time: string; why: string; tone: string; act: string;
};
export type Triage = {
  now: Notification[]; later: Notification[]; held: Notification[];
  counts: { now: number; later: number; held: number };
  stats: { total: number; seen: number; filtered: string };
};

7. 디자인 충실도 노트

7.1 토큰/테마 — 단일 출처 일치

원본 mail.css/notify.css는 학습용으로 자체 :root를 복제하지만(--blue:#4f72e0 등), 본 프로젝트는 styles/tokens.css(dash.css :root 이식, overview §11.1) 단일 출처를 따른다. 따라서 mail.css를 이식할 때 상단 :root/[data-theme="dark"] 블록을 제거하고 토큰 정의는 tokens.css에 위임한다(값은 동일: --blue #4f72e0, --coral #df7256, --green #4e9b66, --violet #8b6fd4, --amber #e0a23c, --radius 22px, --radius-sm 14px). notify.css:root가 없으므로 그대로 이식.

7.2 메일 — 인용 값

요소 원본 값 (mail.css)
작업 영역 그리드 grid-template-columns: 248px 384px minmax(0,1fr); gap:16px; height: calc(100vh - 96px)
메일 행 선택 .mrow.on { background: var(--card); box-shadow: inset 3px 0 0 0 var(--coral) }
미읽음 점 .mrow.unread::before { width:7px; height:7px; background: var(--blue) }
acc-pip .mr-ava .acc-pip { width:12px; height:12px; border:2px solid var(--card) } (계정 색 var(--${tone}))
AI pill 색 task=--blue 12%, event=--coral 13%, reply=--green 13%, prio=--coral 13%
우선순위 pill .ap-prio.높음 { color:#fff; background: var(--coral) }, .보통 { color: var(--amber); background: --amber 16% }, .낮음 { color: var(--muted); background: var(--card-2) }
아리 분석 패널 radial-gradient(120% 90% at 0% 0%, --coral 14%, transparent 55%), var(--card) + border: --coral 24%
추가됨 상태 .sug.added { background: --green 9%; border: --green 30% }, .sug-add.done { color: var(--green) }
정리(파일) 추가 .ap-file .sug-add { background: var(--violet) } (정리는 violet 톤)
작성 슬라이드오버 .compose { width:620px; animation: slidein 0.24s; border-left: 1px solid var(--line) }

문구 인용(원본 그대로 유지): 빈 리더 "읽을 메일을 선택하세요" / "메일을 열면 아리가 내용을 분석해 할 일·일정·답장을 미리 준비해 드려요.", 분석 헤더 "아리가 분석했어요" + "{category} · 새 메일을 읽고 필요한 일을 정리했어요", 요약 "요약 — " prefix(<b>), 모두 적용 바 "아리가 준비한 {N}개 작업을 한 번에 적용할 수 있어요", 트리아지 히어로 "새 메일 {fresh}통을 읽고 할 일을 정리해뒀어요", 작성 도우미 4버튼 초안 작성 / 더 정중하게 / 짧게 / 다듬기, 푸터 "지우님 · Pro 플랜 · 3개 계정".

7.3 알림 — 인용 값

요소 원본 값 (notify.css)
토글 스위치 .sw { width:42px; height:24px } .sw.on { background: var(--green) } .sw.on::after { transform: translateX(18px) }
탭 on .nt-tab.on { background: var(--fill); color: var(--on-fill) }, .n = font-mono
이유 칩 .nt-why { font-size:10px; font-weight:700; padding:1px 7px; border-radius:999px } (색 = color-mix(in oklab, var(--tone) 13%, transparent))
소스 아이콘 .nt-ic { width:34px; height:34px; border-radius:10px; background: var(--glass-2) }
액션 .nt-act { background: var(--fill); color: var(--on-fill) }, .nt-act.ghost { background: var(--glass-2) }
더보기 .nt-more { border:1px dashed var(--line-2) } "비슷한 알림 +N건 — 아리가 묶어뒀어요"
집중 보호 카드 .gd-hold { background: linear-gradient(100deg, --coral 13%, transparent 70%), var(--glass-2); border: --coral 30% }, off → var(--glass-2)
요약 그리드 .nt-sumgrid { grid-template-columns:1fr 1fr }, .nt-sumcell.hi = coral 강조

문구 인용: 탭 라벨 지금 / 나중에 / 아리가 처리, 캡션 3종(NT_CAP), 빈 상태 "비어 있어요. 오늘은 조용한 날이네요.", 가드 "딥 워크 보호 중 — {until}까지" / "알림 {held}건 보류 중 · 끝나면 한 번에 전달해요", 조용한 시간 "긴급·VIP만 통과", 발신자 안내 노트(§5.4), 오늘 요약 셀 도착 / 지금 볼 것 / 아리가 처리 / 걸러냄, 하단 노트 "‘지금 볼 것’만 알림으로 울리고, 나머지는 묶음 배달 시간에 모아서 보여드려요.".


8. 상태 처리 · 엣지 케이스 · 오프라인

상황 처리
메일 미선택 Reader 빈 상태(rd-empty, mail 아이콘 + 안내). smart view 이면 Triage 표시
폴더에 메일 없음 "메일이 없어요 / 이 보기에 표시할 메일이 없습니다."(rd-empty, inbox 아이콘)
ai 비어있는 메일(m7) AI pill 미표시(원본 조건 `priority==="높음"
extract 중복 클릭 낙관적 added[key] 가드 → 서버도 멱등 아님이므로 프론트 가드 필수. 이미 추가된 항목은 pointer-events:none(.sug-add.done)
extract-all 부분 실패 각 extract 개별 try, 성공분만 토스트 카운트(현 구현은 전부 성공 가정 — 실패 시 재시도 토스트)
회신 초안 LLM 미가용 provider 실패 → 기본 문구(지우 드림) 폴백. 토스트 영향 없음
send 후 결재함 메일 페이지는 토스트만, 실제 발송은 결재함 승인까지 보류(원본의 즉시 "메일을 보냈어요" 대신 "결재함에서 확인하세요"로 변경 — 철학 일치)
알림 빈 버킷 nt-empty "조용한 날" 표시
reclassify/undo 낙관적 후 서버 실패 mutate() 롤백(서버 재요청으로 정합)
가드 ON 상태에서 알림 도착 services/triage.classify_notificationcal 외 src 를 later 로 보류(이유 "집중 보호 중")
오프라인(LLM 없음) 시드 메일은 ai_json 그대로 사용(결정성). 신규 메일 분석은 _heuristic_analyze 폴백. 알림 분류는 규칙 기반(triage.py)이라 LLM 불필요
from 발신자 person 미존재 메일 전용 person 시드(daepyo/bank/book/stripe/recruit) 보장. 누락 시 from_key 문자열 그대로 표시(가드)
HTML 본문 본문은 문단 배열(body: string[])로 plain text — XSS 없음. 요약 summary<b> 포함 → dangerouslySetInnerHTML"<b>요약 — </b>" prefix + escape 된 summary (신뢰된 시드, overview §15 새니타이즈 원칙)

9. 연합 이벤트 (발행/구독)

CONTRACT 연합 이벤트 모델(event_bus). 이 phase의 발행/구독:

9.1 발행 (publish)

이벤트 발행 시점 페이로드 구독자
mail.received 커넥터가 새 메일 적재(phase-13 real / mock 시드) {email_id, account, from} mail_ai.analyze_email(요약·추출), triage.classify_notification(메일 알림 생성)
task.created POST /api/mail/{id}/extract(task) {task_id, source:"mail:{id}"} 작업 페이지(GET /api/tasks 등장), 리스크 레이더 재계산
event.created POST /api/mail/{id}/extract(event) {event_id, source:"mail:{id}"} 일정 페이지(phase-8)
notification.triaged reclassify/undo `{id, to, by:"user" "undo"}`
automation.suggested "나중에" 반복 ≥3 {app, count, offer} phase-7 suggester → automation_suggestion
approval.enqueued POST /api/mail/send(high-risk) {approval_id, source:"mail"} 결재함(phase-7), 대시보드 badge

9.2 구독 (subscribe)

이벤트 출처 이 phase의 반응
mail.received 커넥터/스케줄러(phase-14 worker) 자동 분석 + 알림 생성(src=mail, 발신자 규칙 적용)
approval.executed 결재함 승인(phase-7) — 메일 send 결재 승인 get_mail_connector().send(...) 실제 발송 → Sent 행 → approval.executed 집계(하루 마감)
meeting.ended phase-8 회의 도우미 (간접) 회의 action items 가 메일 회신 초안으로 이어질 수 있음 — phase-10+

9.3 시나리오 (PROJECT-README §5 반영)

[현우님 메일 m1 도착]
  → mail.received 발행
  → analyze_email: 요약·할일 2건·답장 3종 준비 (이미 시드된 ai_json)
  → 사용자: 메일 열람 → AriPanel "온보딩 시안 v3 피드백 정리" 작업 추가
      → POST /api/mail/m1/extract {kind:task,index:0}
      → task 생성(project=onb-wire) → task.created 발행
      → 작업 페이지에 "다른 작업과 똑같이" 등장 (개인=프로젝트 철학)
  → 사용자: "수락 · 일정 약속" 답장 초안 선택 → Compose 채워짐 → 보내기
      → POST /api/mail/send → approval(source=mail, risk=high, status=pending)
      → 결재함에 "보내기 대기" 카드 등장 (이미 초안은 써뒀어요)
      → 결재함 승인 → connector.send → approval.executed → 하루 마감 "오늘 보낸 메일 1" 합산
[알림 트리아지 → 자동화 제안]
  사용자가 '뉴스레터 5통 묶음'(l1)을 며칠째 '나중에'로 미룸
  → POST /api/notifications/l1/reclassify {to_bucket:later} (why="내가 미룸")
  → notification.triaged 발행 → detect_defer_pattern: '메일 · 개인' 3회 이상
  → automation.suggested 발행
  → phase-7 suggester: "‘메일 · 개인’ 뉴스레터를 항상 저녁 다이제스트로 묶을까요?" 제안 카드

10. 테스팅 & 검증

10.1 실행 명령

# 백엔드
cd backend && uv run python -m app.seed          # 시드(메일/알림 포함) 적재
uv run uvicorn app.main:app --reload             # API 기동
uv run pytest tests/test_api_mail.py tests/test_api_notify.py \
              tests/test_mail_ai.py tests/test_triage.py -v

# 프론트
cd frontend && pnpm dev                           # http://localhost:3000/mail , /notifications
pnpm vitest run tests/mail tests/notify
pnpm playwright test playwright/mail.spec.ts playwright/notify.spec.ts
pnpm playwright test --grep @a11y                 # axe 접근성

10.2 백엔드 테스트 케이스 (pytest)

# tests/test_api_mail.py
def test_list_accounts(client):
    r = client.get("/api/mail/accounts")
    assert [a["id"] for a in r.json()] == ["work", "personal", "side"]
    assert r.json()[0]["tone"] == "coral"            # 회사=coral

def test_email_detail_marks_read(client):
    assert client.get("/api/mail/m1").json()["read"] is True   # 열람=읽음
    assert client.get("/api/mail/m1").json()["from"] == "hyunwoo"  # alias 'from'

def test_extract_task_creates_task(client):
    r = client.post("/api/mail/m1/extract", json={"kind": "task", "index": 0})
    body = r.json()
    assert body["kind"] == "task"
    assert body["task"]["project_id"] == "onb-wire"   # 라벨→project_id 매핑
    assert body["task"]["prio"] == "높음"
    # 연합: 작업 목록에 등장
    titles = [t["title"] for t in client.get("/api/tasks").json()]
    assert "온보딩 시안 v3 피드백 정리" in _flatten_titles(client.get("/api/tasks").json())

def test_extract_event_creates_event(client):
    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):
    r = client.post("/api/mail/m1/extract-all", json={})
    assert len(r.json()["created_task_ids"]) == 2     # m1 tasks 2건
    assert r.json()["filed_project"] == "온보딩 리디자인"

def test_reply_draft_picks_seeded(client):
    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):
    r = client.post("/api/mail/send", json={"from_account": "work", "to": "현우 <hyunwoo@lumi.co>",
                                            "subject": "Re: 온보딩", "body": "확인했어요"})
    assert r.json()["status"] == "pending"
    appr = client.get(f"/api/approvals/{r.json()['approval_id']}").json()  # phase-7
    assert appr["risk"] == "high" and appr["source"] == "mail"

def test_m7_empty_ai(client):
    ai = client.get("/api/mail/m7").json()["ai"]
    assert ai["priority"] == "낮음" and ai["tasks"] == [] and ai["events"] == []
# tests/test_api_notify.py
def test_triage_counts(client):
    t = client.get("/api/notifications").json()
    assert len(t["now"]) == 6
    assert t["counts"]["later"] == 12      # 6 + later_more(6)
    assert t["counts"]["held"] == 29       # 6 + held_more(23)
    assert t["stats"] == {"total": 47, "seen": 6, "filtered": "87%"}

def test_now_has_why_chip(client):
    n1 = next(n for n in client.get("/api/notifications").json()["now"] if n["id"] == "n1")
    assert n1["why"] == "VIP 발신자" and n1["tone"] == "coral" and n1["act"] == "답장 초안 보기"

def test_reclassify_to_later(client):
    r = client.post("/api/notifications/n1/reclassify", json={"to_bucket": "later"})
    assert r.json()["bucket"] == "later" and r.json()["why"] == "내가 미룸" and r.json()["tone"] == "muted"

def test_undo_held_to_now(client):
    r = client.post("/api/notifications/h2/undo", json={})
    assert r.json()["bucket"] == "now" and r.json()["why"] == "되돌림" and r.json()["tone"] == "amber"

def test_guard_toggle(client):
    assert client.post("/api/notifications/guard", json={"on": False}).json()["on"] is False

def test_senders_have_from_alias(client):
    rows = client.get("/api/notifications/senders").json()
    assert rows[0]["who"] == "수아님" and rows[0]["from"] == "메일 · 메신저" and rows[0]["rule"] == "항상 즉시"

def test_add_sender(client):
    r = client.post("/api/notifications/senders",
                    json={"who": "뉴스레터 X", "from_label": "메일", "rule": "저녁 다이제스트", "bucket": "later"})
    assert r.json()["bucket"] == "later"
# tests/test_triage.py  (서비스 단위)
def test_sender_rule_wins(session):
    seed_notify(session)
    out = classify_notification(session, src="mail", sender="수아님", title="...")
    assert out["bucket"] == "now"                  # sr1 항상 즉시
def test_marketing_held(session):
    seed_notify(session)
    out = classify_notification(session, src="mail", sender="마케팅 · 프로모션", title="할인")
    assert out["bucket"] == "held" and out["held_by_rule"] == "sr5"
def test_defer_pattern(session):
    seed_notify(session)
    # 같은 app 을 3회 '내가 미룸' 으로 → 후보 반환
    for nid in ("l1", "l2"):  # 셋업: 임의로 why='내가 미룸' 변경
        ...
    cands = detect_defer_pattern(session, threshold=3)
    assert all(c["count"] >= 3 for c in cands)
# tests/test_mail_ai.py
def test_seed_email_uses_stored_ai(session, heuristic_provider):
    seed_mail(session)
    e = session.get(Email, "m1")
    out = analyze_email(session, e, heuristic_provider)
    assert out["summary"].startswith("온보딩 시안 v3")   # 시드 ai_json 재사용(결정성)
def test_new_email_heuristic_fallback(session, heuristic_provider):
    e = Email(id="mX", account="work", from_key="hyunwoo",
              subject="[중요] 14시 미팅 부탁", body=["14시에 봬요"], ai_json={})
    out = analyze_email(session, e, heuristic_provider)
    assert out["priority"] == "높음"                      # [중요]
    assert len(out["events"]) == 1                         # 14시 → event 후보

10.3 프론트 테스트 (Vitest + RTL)

  • MailRow: ai pill 렌더 — priority="높음"이면 중요 pill, tasks.length이면 할 일 N, events→일정 N, replies→답장 추천. m7(빈 ai)은 pill 없음.
  • AriPanel: tasks/events/replies/file 섹션 조건부 렌더, keys>1일 때만 ap-allbar, added 상태에서 버튼 추가됨/done 클래스.
  • TriageStream: 탭 전환 시 NT_CAP 변경, now 탭에만 액션 버튼, held 탭에만 되돌리기, 빈 버킷 nt-empty, more>0이면 nt-more.
  • WhyChip: tone="muted"var(--muted), 그 외 var(--tone).
  • GuardPanel: 토글 on/off 시 .sw.on 클래스 + 문구 전환("딥 워크 보호 중"↔"보호 꺼짐").

10.4 E2E (Playwright)

// playwright/mail.spec.ts
test("메일 ai 추출 → 작업 페이지 왕복", async ({ page }) => {
  await page.goto("/mail");
  await page.getByText("온보딩 시안 v3").click();            // m1 열기
  await page.getByText("아리가 분석했어요").waitFor();
  await page.getByRole("button", { name: /작업$/ }).first().click();  // Sug 작업 추가
  await expect(page.getByText("작업에 추가했어요")).toBeVisible();    // 토스트
  await page.goto("/tasks");
  await expect(page.getByText("온보딩 시안 v3 피드백 정리")).toBeVisible();  // 등장
});

test("회신 초안 선택 → 결재함 대기", async ({ page }) => {
  await page.goto("/mail");
  await page.getByText("온보딩 시안 v3").click();
  await page.getByText("금요일까지 피드백 드릴게요").click();   // 답장 추천 → Compose
  await page.getByRole("button", { name: "보내기" }).click();
  await expect(page.getByText("결재함에서 확인하세요")).toBeVisible();
});

// playwright/notify.spec.ts
test("트리아지 재분류 · 되돌리기", async ({ page }) => {
  await page.goto("/notifications");
  await page.getByRole("button", { name: /지금/ }).click();
  await page.getByText("수아님 — 분기 리포트 피드백").locator("..").getByText("나중에").click();
  await page.getByRole("button", { name: /나중에/ }).click();
  await expect(page.getByText("내가 미룸")).toBeVisible();        // 이유 칩
  await page.getByRole("button", { name: /아리가 처리/ }).click();
  await page.getByText("회의 초대 자동 수락").locator("..").getByText("되돌리기").click();
  await page.getByRole("button", { name: /지금/ }).click();
  await expect(page.getByText("되돌림")).toBeVisible();
});

test("발신자 규칙 적용", async ({ page }) => {
  await page.goto("/notifications");
  await page.getByText("발신자 규칙").click();
  await expect(page.getByText("수아님")).toBeVisible();
  await expect(page.getByText("항상 즉시")).toBeVisible();
});

10.5 수동 QA 체크리스트

  • /mail 3분할 레이아웃, 1320/1080/760px 반응형(목록↔리더 토글) 확인
  • 전체 받은편지함 ↔ 계정별 필터(회사/개인/사이드) 전환, acc-pip 색 일치
  • 아리 정리(smart) 히어로 — 새 메일 N통 카운트, 3컬럼(할 일/일정/답장)
  • m1 열람 → 읽음 처리(미읽음 점 사라짐), AriPanel 요약·할일 2·답장 3·정리 1 표시
  • "작업" 추가 → 토스트 → /tasks에서 등장 / "일정" 추가(m2) → /일정에서 등장
  • "모두 적용" → 모두 적용됨 상태, 토스트
  • 답장 추천 클릭 → Compose 채워짐(Re: 제목, body) → 보내기 → "결재함에서 확인하세요" → /결재함 대기 카드
  • m7(빈 ai) → pill 없음, AriPanel 요약만, 모두 적용 바 없음
  • /notifications 트리아지 — 지금 6 / 나중에 12 / 아리가 처리 29, eyebrow 오늘 47건 도착 · 직접 본 건 6건 · 87%는 아리가 걸렀어요
  • 이유 칩 색(VIP 발신자=coral, 곧 시작=blue, 직접 멘션=violet, 보안=coral, 내 프로젝트=green)
  • "나중에" 미루기 → 이유 "내가 미룸"(muted), "되돌리기" → "되돌림"(amber)
  • 집중 보호 토글 on/off, 묶음 배달 3건(09:00/13:00/18:30, 저녁만 미전달)
  • 발신자 규칙 5건 + 안내 노트(자동화 연계 문구)
  • 라이트/다크 테마 토글 시 두 페이지 모두 토큰 정상 반영
  • 오프라인(Ollama 중지) — 시드 메일 분석 그대로, 알림 분류 정상

10.6 통과 기준

  1. 백엔드 pytest 전부 green(메일 8건/알림 18건 시드, extract→task/event 왕복, send→high-risk approval, 트리아지 counts, reclassify/undo 이유 칩, 발신자 규칙).
  2. E2E: 메일→작업 등장 / 회신→결재함 / 알림 재분류·되돌리기 / 발신자 규칙 표시 통과.
  3. axe: 두 페이지 0 violations(버튼 aria-label, 토글 aria-label, 대비).
  4. 디자인: §7 인용 값(레이아웃 px, 색, 문구)과 픽셀/문구 일치.

11. 완료 기준 (Definition of Done)

  • models.py 10개 신규 테이블 + Person.role 추가, alembic 마이그레이션 왕복(downgrade base && upgrade head) 성공
  • seed_mail/seed_notifyrun_seed 에 통합, 메일 8건(m1~m8)·알림 18건·계정 3·발신자 규칙 5·묶음 3 적재
  • connectors/mail.py·chat.py base+Mock 구현, CONNECTOR_MAIL/CONNECTOR_CHAT env(기본 mock), phase-13 real 자리 확보
  • services/mail_ai.py(LLM+heuristic 폴백, 시드 ai_json 재사용)·services/triage.py(규칙+가드+패턴 탐지)
  • routers/mail.py·notify.py 전 엔드포인트(§6.1) 동작, prefix 규약 준수(라우터 내부 prefix 없음)
  • 메일 extract → task/event 실체화(federation), 작업/일정 페이지 등장
  • 메일 send → high-risk approval(source=mail) → 결재함 대기(phase-7)
  • 알림 reclassify/undo 이유 칩 갱신, "나중에" 반복 → automation.suggested 발행
  • 프론트 메일 3분할(사이드바/목록/리더+AriPanel/Compose/Triage) 원본 1:1, 알림 SubRail 3뷰 원본 1:1
  • mail.css(:root 제거 후 tokens.css 사용)·notify.css 이식, 라이트/다크 정상
  • 상태/엣지(§8) 처리, 오프라인 결정성 보장
  • pytest/Vitest/Playwright/axe 전부 green, 수동 QA 체크리스트(§10.5) 통과
  • 상호 참조 정확(overview/phase-2/7/8/10), CONTRACT 명명 일치(automation/connectors/event_bus/approval/autonomy)

12. 다음 단계

다음 문서: phase-10-research-travel.md — 리서치(에이전트 오케스트레이션 + RAG 지식베이스 + 웹검색)와 여행(여행 플래너 에이전트). 본 phase의 메일→작업/일정 federation, 커넥터 추상화(mock-first), LLM provider 폴백 패턴을 그대로 이어받아, backend/app/agents/(plan→act→observe→reflect)와 backend/app/rag/(ingest→chunk→embed→vector store)를 도입한다. 알림 "나중에" 패턴→자동화 제안은 phase-7 suggester 와 계속 연동되며, 리서치/여행 결과의 high-risk 동작(예약/결제)은 동일하게 결재함(phase-7)으로 흐른다.