# Phase 9 — 메일 + 알림 트리아지 > 한 줄 요약: 3계정 통합 메일(아리가 새 메일을 읽고 **요약·우선순위·분류·할일/일정 추출→작업·캘린더 전송·회신 초안**)과 알림 트리아지(**지금 / 나중에 / 아리가 처리** 3분류 + 집중 보호·묶음 배달 + 발신자 규칙, 분류 *이유 칩* + *되돌리기*)를 mock-first 커넥터·LLM provider(요약/분류/추출/초안, heuristic 폴백) 위에 구현하고, **메일→작업/일정(federation)**·**알림 "나중에" 반복→자동화 제안**·**메일 회신 발송=high-risk→결재함** 연합을 완성한다. > 이 문서는 포스트-MVP 세트의 일부 — 먼저 `dev/overview.md` 와 `dev/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. [개요 & 목표](#1-개요--목표) 2. [선행 조건 / 산출물](#2-선행-조건--산출물) 3. [상세 구현 — 백엔드](#3-상세-구현--백엔드) 4. [상세 구현 — 메일 프론트엔드](#4-상세-구현--메일-프론트엔드) 5. [상세 구현 — 알림 트리아지 프론트엔드](#5-상세-구현--알림-트리아지-프론트엔드) 6. [데이터 / 타입 / API 계약](#6-데이터--타입--api-계약) 7. [디자인 충실도 노트](#7-디자인-충실도-노트) 8. [상태 처리 · 엣지 케이스 · 오프라인](#8-상태-처리--엣지-케이스--오프라인) 9. [연합 이벤트 (발행/구독)](#9-연합-이벤트-발행구독) 10. [테스팅 & 검증](#10-테스팅--검증) 11. [완료 기준 (Definition of Done)](#11-완료-기준-definition-of-done) 12. [다음 단계](#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.js`의 `ai`를 JSON으로 보존(`Email.ai_json`)하고, 추출 시 이 JSON을 풀어 `task`/`event`로 실체화한다. ```python # 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.js`의 `from`은 파이썬 예약어가 아니지만 SQL/Pydantic 혼동을 막기 위해 `from_key`로 저장하고, **응답 스키마에서 `from`으로 직렬화**한다(§3.2). `body`/`ai_json`/`labels`/`attachments`는 `sa_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 시드**로 추가한다. ```python # 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)`). ```python # ---- 보낸편지함 / 임시보관 (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 와 동일 패턴): ```python 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 `Person`에 `role: str = ""` 추가 + alembic 마이그레이션(`add column person.role`). ### 3.2 스키마 — `schemas.py` (추가분) `Email.from_key` → 응답 `from`으로 직렬화하기 위해 Pydantic `alias` 를 쓴다(`populate_by_name=True`). ```python # 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 # "현우 " 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_label`은 `alias`/`serialization_alias`로 원본 키(`from`)를 보존한다. 프론트 `lib/types.ts`는 `from: string`을 쓴다(원본 `mail.jsx`가 `m.from`을 참조하므로 키 일치 필수). ### 3.3 커넥터 — `connectors/mail.py`, `connectors/chat.py` CONTRACT: 커넥터 추상화 = 도메인별 base 인터페이스 + `MockConnector`(시드 기반) + `RealConnector`(phase-13). 설정 `CONNECTOR_MAIL=mock|real`(기본 mock). 페이지는 **mock-first**. ```python # 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.py`에 `connector_mail: str = "mock"`, `connector_chat: str = "mock"` 추가(env `CONNECTOR_MAIL`/`CONNECTOR_CHAT`). ### 3.4 메일 AI 서비스 — `services/mail_ai.py` 아리 분석은 **LLM provider**(요약/분류/추출/초안)로 수행하고, 미가용/오프라인/테스트 시 **시드 `ai_json`을 그대로 반환**(데모 결정성). 즉 시드 메일은 이미 분석돼 있으므로 폴백은 "저장된 분석 재사용"이고, 신규 메일(커넥터가 새로 가져온)에 한해 LLM을 부른다. ```python # 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)로 넘긴다. ```python # 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"])`. ```python # 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="보낼 준비가 됐어요 — 결재함에서 확인하세요") ``` > `Approval`의 `status`/`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` ```python # 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` 등록 추가: ```python 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→결재함). ```ts // 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` | `