@ -1,32 +1,50 @@
# backend/app/routers/mail.py
import uuid
from datetime import UTC , datetime
from urllib . parse import quote
from fastapi import APIRouter , Depends , HTTPException , Query
import httpx
from fastapi import APIRouter , Depends , HTTPException , Query , Response
from sqlmodel import Session , select
from . . auth . deps import current_user
from . . connectors . mail import get_mail_connector
from . . connectors . mail . normalize import mail_account_id_for , sender_parts
from . . connectors . registry import ConnectorRegistry
from . . db import get_session
from . . llm . provider import LLMProvider , get_provider
from . . models import (
AccountFolder ,
Approval ,
AppSetting ,
ConnectorAccount ,
ConnectorDomain ,
ConnectorMode ,
ConnState ,
Email ,
ExternalLink ,
MailAccount ,
MailFolder ,
Outbound Mail,
Person ,
Prio ,
Project ,
Task ,
TaskStatus ,
now ,
)
from . . schemas import (
AccountFolderOut ,
BulkMailRequest ,
ConversationOut ,
EmailDetailOut ,
EmailRowOut ,
ExtractAllResponse ,
ExtractRequest ,
ExtractResponse ,
MailAccountOut ,
MailFolderOut ,
MailLabelRequest ,
MailReadRequest ,
MailStarRequest ,
ReplyDraftOut ,
ReplyDraftRequest ,
SendRequest ,
@ -52,20 +70,197 @@ PROJECT_LABEL_MAP = {
}
# ── 표시용 상대 시각(수신 시각 received_at 기준, 응답 시 산출 → 항상 최신) ──
def _ko_hm ( dt : datetime ) - > str :
""" ' 오전 9:12 ' / ' 오후 1:30 ' 형식(로컬). """
h = dt . hour
ampm = " 오전 " if h < 12 else " 오후 "
h12 = h % 12 or 12
return f " { ampm } { h12 } : { dt . minute : 02d } "
def _local ( dt : datetime ) - > datetime :
if dt . tzinfo is None :
dt = dt . replace ( tzinfo = UTC )
return dt . astimezone ( )
def _list_time ( received : datetime | None , fallback : str ) - > str :
""" 목록 행 시각: 오늘→시:분, 어제→ ' 어제 ' , 올해→ ' M월 D일 ' , 그 외→ ' YY. M. D. ' . """
if not received :
return fallback
dt = _local ( received )
now = datetime . now ( UTC ) . astimezone ( )
secs = int ( ( now - dt ) . total_seconds ( ) )
if 0 < = secs < 60 :
return " 방금 "
if 0 < = secs < 3600 :
return f " { secs / / 60 } 분 전 "
today = now . date ( )
d = dt . date ( )
if d == today :
return _ko_hm ( dt )
if ( today - d ) . days == 1 :
return " 어제 "
if d . year == today . year :
return f " { dt . month } 월 { dt . day } 일 "
return f " { dt . year % 100 : 02d } . { dt . month } . { dt . day } . "
def _detail_date ( received : datetime | None , fallback : str ) - > str :
""" 리더 표시 날짜: ' 오늘 오전 9:12 ' / ' 어제 오후 6:30 ' / ' 6월 17일 오후 9:12 ' / ' 2025년 … ' . """
if not received :
return fallback
dt = _local ( received )
now = datetime . now ( UTC ) . astimezone ( )
today = now . date ( )
d = dt . date ( )
hm = _ko_hm ( dt )
if d == today :
return f " 오늘 { hm } "
if ( today - d ) . days == 1 :
return f " 어제 { hm } "
if d . year == today . year :
return f " { dt . month } 월 { dt . day } 일 { hm } "
return f " { dt . year } 년 { dt . month } 월 { dt . day } 일 { hm } "
# Gmail CATEGORY_* 정규화 라벨 → 메일 목록 카테고리 탭. 그 외(Outlook 포함)는 기본(집중).
_CATEGORY_BY_LABEL = {
" 프로모션 " : " promotions " ,
" 소셜 " : " social " ,
" 업데이트 " : " updates " ,
" 포럼 " : " updates " ,
}
def _category_for ( labels : list [ str ] ) - > str :
for lab in labels or [ ] :
cat = _CATEGORY_BY_LABEL . get ( lab )
if cat :
return cat
return " primary "
# ── 아리 정리(비서) 기준 시각 ──
# 아리 정리는 '이 시각 이후 도착한 새 메일'만 다룬다(과거 백로그는 제외).
# 기준 시각은 app_setting 에 영속하고, 처음 보는 순간(첫 GET) now() 로 lazy-init 한다.
_TRIAGE_SINCE_KEY = " mail.triage_since "
def _as_utc ( dt : datetime | None ) - > datetime | None :
if dt is None :
return None
return dt if dt . tzinfo else dt . replace ( tzinfo = UTC )
def _get_triage_since ( s : Session ) - > datetime | None :
row = s . get ( AppSetting , _TRIAGE_SINCE_KEY )
if not row or not row . value :
return None
try :
return _as_utc ( datetime . fromisoformat ( row . value ) )
except ValueError :
return None
def _set_triage_since ( s : Session , dt : datetime ) - > datetime :
dt = _as_utc ( dt )
row = s . get ( AppSetting , _TRIAGE_SINCE_KEY )
if row :
row . value = dt . isoformat ( )
row . updated_at = now ( )
s . add ( row )
else :
s . add ( AppSetting ( key = _TRIAGE_SINCE_KEY , value = dt . isoformat ( ) ) )
s . commit ( )
return dt
def _ensure_triage_since ( s : Session ) - > datetime :
""" 기준 시각을 반환 — 없으면 지금(now)으로 초기화·영속(첫 조회 = 기준점). """
cur = _get_triage_since ( s )
return cur if cur is not None else _set_triage_since ( s , now ( ) )
def _is_new_mail ( e : Email , since : datetime ) - > bool :
""" 기준 시각 이후 도착한 메일인가(수신 시각 없으면 과거로 취급). """
rcv = _as_utc ( e . received_at )
return rcv is not None and rcv > = since
def _email_in_folder ( e : Email , slug : str ) - > bool :
""" 메일이 사이드바 폴더(slug)에 속하는지. 폴더 카운트·목록 필터의 단일 기준.
집중 = Gmail 기본 ( primary ) + Outlook focused 만 , 기타 = Gmail 프로모션 / 소셜 / 업데이트 + Outlook other . """
folder = e . folder or " inbox "
if slug in ( " inbox " , " smart " ) :
# 집중(Focused): Gmail 기본(primary) 카테고리 + Outlook focused. (프로모션·기타는 제외)
return (
folder == " inbox "
and not e . archived
and _category_for ( e . labels ) == " primary "
and ( e . inference or " " ) != " other "
)
if slug == " others " :
# 기타(Others): Gmail 비기본(프로모션/소셜/업데이트) 또는 Outlook other.
return (
folder == " inbox "
and not e . archived
and ( _category_for ( e . labels ) != " primary " or ( e . inference or " " ) == " other " )
)
if slug . startswith ( " cat: " ) : # Gmail 카테고리(프로모션/소셜/업데이트) — 받은편지함 내 분류
return folder == " inbox " and not e . archived and _category_for ( e . labels ) == slug [ 4 : ]
if slug == " starred " :
return e . starred and folder not in ( " trash " , " spam " )
if slug == " archive " :
return e . archived or folder == " archive "
if slug == " spam " :
return folder == " spam "
if slug == " trash " :
return folder == " trash "
if slug . startswith ( " label: " ) : # Gmail 사용자 라벨
return slug [ 6 : ] in ( e . labels or [ ] )
if slug . startswith ( " of: " ) : # Outlook 커스텀 폴더
return folder == slug
if slug in ( " sent " , " drafts " ) : # 발송/임시는 OutboundMail 경로(Email 아님)
return False
return folder == " inbox " and not e . archived
def _email_sort_key ( e : Email ) :
""" 목록 정렬: 최신 수신순(received_at) → sort_order → created_at. """
rcv = e . received_at
if rcv is not None and rcv . tzinfo is None :
rcv = rcv . replace ( tzinfo = UTC )
return (
rcv is not None ,
rcv or datetime . min . replace ( tzinfo = UTC ) ,
- e . sort_order ,
e . created_at ,
)
def _row_out ( e : Email ) - > EmailRowOut :
# 보낸사람: 표시이름(이름 없으면 도메인 추정) + 실제 메일주소 분리 — 목록은 이름만 노출.
from_name , from_email = sender_parts ( e . from_key )
return EmailRowOut . model_validate (
{
" id " : e . id ,
" account " : e . account ,
" from " : e . from_key ,
" from_name " : from_name ,
" from_email " : from_email ,
" to " : e . to ,
" cc " : e . cc or " " ,
" thread_id " : e . thread_id or " " ,
" subject " : e . subject ,
" time " : e . time ,
" time " : _list_time( e . received_at , e. time ) ,
" read " : e . read ,
" starred " : e . starred ,
" has_attach " : e . has_attach ,
" labels " : e . labels ,
" preview " : e . preview ,
" category " : _category_for ( e . labels ) ,
" ai " : e . ai_json or None ,
}
)
@ -73,32 +268,198 @@ def _row_out(e: Email) -> EmailRowOut:
@router.get ( " /mail/accounts " , response_model = list [ MailAccountOut ] )
def accounts ( s : Session = Depends ( get_session ) ) :
return s . exec ( select ( MailAccount ) . order_by ( MailAccount . sort_order ) ) . all ( )
accts = s . exec ( select ( MailAccount ) . order_by ( MailAccount . sort_order ) ) . all ( )
# 계정별 받은편지함 안읽음 수 — 집중(unread)/기타(others) 각각. 사이드바 배지·기타 탭 카운트.
unread_rows = s . exec ( select ( Email ) . where ( Email . read == False ) ) . all ( ) # noqa: E712
counts : dict [ str , int ] = { }
others : dict [ str , int ] = { }
for e in unread_rows :
if _email_in_folder ( e , " inbox " ) :
counts [ e . account ] = counts . get ( e . account , 0 ) + 1
elif _email_in_folder ( e , " others " ) :
others [ e . account ] = others . get ( e . account , 0 ) + 1
return [
MailAccountOut (
id = a . id ,
name = a . name ,
email = a . email ,
tone = a . tone ,
kind = a . kind ,
unread = counts . get ( a . id , 0 ) ,
others = others . get ( a . id , 0 ) ,
)
for a in accts
]
@router.get ( " /mail/folders " , response_model = list [ AccountFolderOut ] )
def folders (
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
) :
""" 계정별 실제 폴더/라벨(sync 가 미러링한 AccountFolder) + 동기화분 안읽음/총계.
빈 사용자 라벨 폴더 ( 동기화된 메일 0 ) 는 숨긴다 . """
rows = s . exec ( select ( AccountFolder ) ) . all ( )
emails = [ e for e in s . exec ( select ( Email ) ) . all ( ) if e . user_id == user . id ]
by_acct : dict [ str , list [ Email ] ] = { }
for e in emails :
by_acct . setdefault ( e . account , [ ] ) . append ( e )
out : list [ AccountFolderOut ] = [ ]
for f in sorted ( rows , key = lambda r : ( r . mail_account , r . sort_order , r . name ) ) :
acc_emails = by_acct . get ( f . mail_account , [ ] )
unread = sum ( 1 for e in acc_emails if not e . read and _email_in_folder ( e , f . slug ) )
synced = sum ( 1 for e in acc_emails if _email_in_folder ( e , f . slug ) )
if f . kind == " label " and synced == 0 :
continue # 동기화된 메일이 없는 사용자 라벨은 숨김
out . append (
AccountFolderOut (
account = f . mail_account ,
slug = f . slug ,
name = f . name ,
kind = f . kind ,
icon = f . icon ,
unread = unread ,
total = f . total or synced ,
sort_order = f . sort_order ,
)
)
return out
@router.post ( " /mail/sync " )
def sync_mail ( s : Session = Depends ( get_session ) ) :
""" 연결된 real 메일 계정을 증분 동기화하고 새로 들어온 메일 수를 반환( ' 새 메일 가져오기 ' ). """
accts = s . exec (
select ( ConnectorAccount ) . where (
ConnectorAccount . domain == ConnectorDomain . mail ,
ConnectorAccount . mode == ConnectorMode . real ,
ConnectorAccount . state == ConnState . connected ,
)
) . all ( )
upserted = 0
for a in accts :
upserted + = ConnectorRegistry . get ( s , a ) . sync ( s ) . upserted
return { " accounts " : len ( accts ) , " upserted " : upserted }
@router.get ( " /mail/folders " , response_model = list [ MailFolderOut ] )
def folders ( s : Session = Depends ( get_session ) ) :
return s . exec ( select ( MailFolder ) . order_by ( MailFolder . sort_order ) ) . all ( )
def _matches_query ( e : Email , q : str ) - > bool :
""" 제목/보낸이/받는이/미리보기/본문 전반에 대한 대소문자 무시 부분일치. """
hay = " " . join (
[ e . subject or " " , e . from_key or " " , e . to or " " , e . cc or " " , e . preview or " " ]
+ ( e . body or [ ] )
+ ( e . labels or [ ] )
) . lower ( )
return all ( term in hay for term in q . lower ( ) . split ( ) )
@router.get ( " /mail " , response_model = list [ EmailRowOut ] )
def list_mail (
account : str | None = Query ( None ) ,
folder : str = Query ( " smart " ) ,
folder : str = Query ( " inbox " ) , # 폴더 슬러그: inbox/spam/trash/starred/cat:/label:/of:
q : str | None = Query ( None ) , # 서버측 검색어(공백=AND)
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
) :
conn = get_mail_connector ( )
if folder == " starred " :
rows = [ e for e in conn . list_inbox ( s , account ) if e . starred ]
elif folder == " archive " :
rows = s . exec ( select ( Email ) . where ( Email . archived == True ) ) . all ( ) # noqa: E712
else :
rows = conn . list_inbox ( s , account )
rows = [ e for e in rows if e . user_id == user . id ] # phase-15 소유자 스코프
rows = [ e for e in s . exec ( select ( Email ) ) . all ( ) if e . user_id == user . id ]
if account :
rows = [ e for e in rows if e . account == account ]
rows = [ e for e in rows if _email_in_folder ( e , folder ) ]
if folder == " smart " :
# 아리 정리는 기준 시각 이후 도착한 새 메일만(과거 백로그 제외).
since = _ensure_triage_since ( s )
rows = [ e for e in rows if _is_new_mail ( e , since ) ]
if q and q . strip ( ) :
rows = [ e for e in rows if _matches_query ( e , q . strip ( ) ) ]
rows . sort ( key = _email_sort_key , reverse = True )
return [ _row_out ( e ) for e in rows ]
def _outbound_time ( dt : datetime | None ) - > str :
""" 보낸/대기 메일 표시용 상대 시각( ' 방금 ' / ' N분 전 ' / ' M월 D일 ' ). """
if not dt :
return " 방금 "
if dt . tzinfo is None :
dt = dt . replace ( tzinfo = UTC )
delta = datetime . now ( UTC ) - dt
secs = int ( delta . total_seconds ( ) )
if secs < 60 :
return " 방금 "
if secs < 3600 :
return f " { secs / / 60 } 분 전 "
if secs < 86400 :
return f " { secs / / 3600 } 시간 전 "
loc = dt . astimezone ( )
return f " { loc . month } 월 { loc . day } 일 "
def _outbound_row_out ( ob : OutboundMail , acct : MailAccount | None ) - > EmailRowOut :
""" OutboundMail(발송/대기) → 목록 행. 보낸편지함이므로 read=True, from=내 계정 이메일. """
sender = acct . email if acct else ob . mail_account
preview = " " . join ( ( ob . body or " " ) . split ( ) ) [ : 140 ]
when = ob . sent_at or ob . created_at
return EmailRowOut . model_validate (
{
" id " : ob . id ,
" account " : ob . mail_account ,
" from " : sender ,
" from_name " : acct . name if acct else sender ,
" from_email " : sender ,
" to " : ob . to ,
" cc " : ob . cc or " " ,
" subject " : ob . subject ,
" time " : _outbound_time ( when ) ,
" read " : True ,
" starred " : False ,
" has_attach " : bool ( ob . attachments ) ,
" labels " : [ ] ,
" preview " : preview ,
" ai " : None ,
}
)
def _outbound_list ( s : Session , status : str , account : str | None ) - > list [ EmailRowOut ] :
stmt = select ( OutboundMail ) . where ( OutboundMail . status == status )
if account :
stmt = stmt . where ( OutboundMail . mail_account == account )
rows = s . exec ( stmt ) . all ( )
# 최신순: sent_at(없으면 created_at) 기준 내림차순
rows . sort ( key = lambda ob : ( ob . sent_at or ob . created_at ) , reverse = True )
accts = { a . id : a for a in s . exec ( select ( MailAccount ) ) . all ( ) }
return [ _outbound_row_out ( ob , accts . get ( ob . mail_account ) ) for ob in rows ]
@router.get ( " /mail/sent " , response_model = list [ EmailRowOut ] )
def sent_mail (
account : str | None = Query ( None ) ,
s : Session = Depends ( get_session ) ,
) :
""" 보낸편지함 — 실제 발송된(status= ' sent ' ) 메일을 최신순으로. """
return _outbound_list ( s , " sent " , account )
@router.get ( " /mail/drafts " , response_model = list [ EmailRowOut ] )
def draft_mail (
account : str | None = Query ( None ) ,
s : Session = Depends ( get_session ) ,
) :
""" 임시보관함 — 승인 대기(status= ' pending ' ) 발송 메일을 최신순으로. """
return _outbound_list ( s , " pending " , account )
def _rewrite_cids ( html : str , eid : str , attachments : list ) - > str :
""" 본문 HTML 의 cid: 인라인 이미지 참조를 첨부 스트리밍 URL(inline)로 치환. """
if not html or " cid: " not in html :
return html
for a in attachments or [ ] :
cid = ( a . get ( " content_id " ) or " " ) . strip ( " <> " ) . strip ( )
if cid and a . get ( " id " ) :
html = html . replace (
f " cid: { cid } " , f " /api/mail/ { eid } /attachments/ { a [ ' id ' ] } ?inline=1 "
)
return html
@router.get ( " /mail/ {eid} " , response_model = EmailDetailOut )
def get_mail ( eid : str , s : Session = Depends ( get_session ) ) :
e = get_mail_connector ( ) . get ( s , eid )
@ -109,10 +470,286 @@ def get_mail(eid: str, s: Session = Depends(get_session)):
s . add ( e )
s . commit ( )
d = _row_out ( e ) . model_dump ( by_alias = True )
d . update ( { " date " : e . date , " body " : e . body , " attachments " : e . attachments } )
# 인라인(cid) 이미지 칩은 본문에서만 쓰이므로 첨부 목록에서는 숨긴다.
visible_attach = [ a for a in ( e . attachments or [ ] ) if not a . get ( " inline " ) ]
d . update (
{
" date " : _detail_date ( e . received_at , e . date ) ,
" body " : e . body ,
" body_html " : _rewrite_cids ( e . body_html or " " , e . id , e . attachments or [ ] ) ,
" attachments " : visible_attach ,
}
)
return EmailDetailOut . model_validate ( d )
# ── 메일 상태 변경(별표/읽음/보관/삭제) — 연결된 Gmail/Outlook 에 실제 반영 + 로컬 동기화 ──
def _email_link ( s : Session , eid : str ) - > ExternalLink | None :
return s . exec (
select ( ExternalLink ) . where (
ExternalLink . entity_id == eid , ExternalLink . entity_type == " email "
)
) . first ( )
def _apply_remote (
s : Session ,
eid : str ,
* ,
star : bool | None = None ,
read : bool | None = None ,
archived : bool | None = None ,
trash : bool = False ,
) - > None :
""" 변경을 연결된 메일 제공자(Gmail/Outlook)에 실제 반영. 매핑/계정 없으면 로컬 전용. """
link = _email_link ( s , eid )
if not link :
return
acct = s . get ( ConnectorAccount , link . account_id )
if not acct or acct . state != ConnState . connected :
return
conn = ConnectorRegistry . get ( s , acct )
ext = link . external_id
try :
if acct . provider == " gmail " :
if trash :
conn . trash ( s , ext )
return
add , remove = [ ] , [ ]
if star is True :
add . append ( " STARRED " )
elif star is False :
remove . append ( " STARRED " )
if read is True :
remove . append ( " UNREAD " )
elif read is False :
add . append ( " UNREAD " )
if archived is True :
remove . append ( " INBOX " )
elif archived is False :
add . append ( " INBOX " )
if add or remove :
conn . modify_labels ( s , ext , add = add , remove = remove )
elif acct . provider == " outlook " :
if trash :
conn . delete_message ( s , ext )
return
patch : dict = { }
if read is not None :
patch [ " isRead " ] = read
if star is not None :
patch [ " flag " ] = { " flagStatus " : " flagged " if star else " notFlagged " }
if patch :
conn . patch_message ( s , ext , patch )
if archived is True :
conn . move_message ( s , ext , " archive " )
elif archived is False :
conn . move_message ( s , ext , " inbox " )
except httpx . HTTPStatusError as e :
# 권한 부족(읽기 전용 토큰 등)·인증 만료 → 500 대신 안내 가능한 4xx 로.
code = e . response . status_code
if code in ( 401 , 403 ) :
raise HTTPException (
403 ,
" 메일 계정 권한이 부족해요. 설정 → 연동에서 이 계정을 다시 연결하면 "
" 삭제·정리(읽음/별표/보관)를 사용할 수 있어요. " ,
) from e
raise HTTPException ( 502 , " 메일 제공자 요청이 실패했어요. 잠시 후 다시 시도해주세요. " ) from e
@router.patch ( " /mail/ {eid} /star " , response_model = EmailRowOut )
def star_mail ( eid : str , body : MailStarRequest , s : Session = Depends ( get_session ) ) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
_apply_remote ( s , eid , star = body . starred )
e . starred = body . starred
s . add ( e )
s . commit ( )
return _row_out ( e )
@router.patch ( " /mail/ {eid} /read " , response_model = EmailRowOut )
def read_mail ( eid : str , body : MailReadRequest , s : Session = Depends ( get_session ) ) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
_apply_remote ( s , eid , read = body . read )
e . read = body . read
s . add ( e )
s . commit ( )
return _row_out ( e )
@router.post ( " /mail/ {eid} /archive " , response_model = EmailRowOut )
def archive_mail ( eid : str , s : Session = Depends ( get_session ) ) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
_apply_remote ( s , eid , archived = True )
e . archived = True
s . add ( e )
s . commit ( )
return _row_out ( e )
@router.post ( " /mail/ {eid} /unarchive " , response_model = EmailRowOut )
def unarchive_mail ( eid : str , s : Session = Depends ( get_session ) ) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
_apply_remote ( s , eid , archived = False )
e . archived = False
s . add ( e )
s . commit ( )
return _row_out ( e )
@router.delete ( " /mail/ {eid} " )
def delete_mail ( eid : str , s : Session = Depends ( get_session ) ) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
_apply_remote ( s , eid , trash = True )
link = _email_link ( s , eid )
if link :
s . delete ( link )
s . delete ( e )
s . commit ( )
return { " ok " : True , " id " : eid }
# ── 라벨/카테고리(폴더 정리) ──
@router.patch ( " /mail/ {eid} /labels " , response_model = EmailRowOut )
def label_mail ( eid : str , body : MailLabelRequest , s : Session = Depends ( get_session ) ) :
""" 라벨 추가/제거(로컬 태깅). Outlook 은 임의 카테고리라 원격에도 반영. """
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
labels = list ( e . labels or [ ] )
remove = set ( body . remove )
for lab in body . add :
if lab and lab not in labels :
labels . append ( lab )
labels = [ lab for lab in labels if lab not in remove ]
e . labels = labels
link = _email_link ( s , eid )
if link :
acct = s . get ( ConnectorAccount , link . account_id )
if acct and acct . state == ConnState . connected and acct . provider == " outlook " :
ConnectorRegistry . get ( s , acct ) . patch_message (
s , link . external_id , { " categories " : labels }
)
s . add ( e )
s . commit ( )
return _row_out ( e )
# ── 첨부 다운로드(연결된 Gmail/Outlook 에서 실시간 스트리밍) ──
# inline=1 → 본문 HTML 의 인라인 이미지(cid)용. 첨부 다운로드 대신 브라우저 내 표시.
@router.get ( " /mail/ {eid} /attachments/ {att_id} " )
def download_attachment (
eid : str , att_id : str , inline : int = 0 , s : Session = Depends ( get_session )
) :
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
meta = next ( ( a for a in ( e . attachments or [ ] ) if a . get ( " id " ) == att_id ) , None )
if not meta :
raise HTTPException ( 404 , " attachment not found " )
link = _email_link ( s , eid )
if not link :
raise HTTPException ( 400 , " 원격 메일 첨부만 내려받을 수 있어요 " )
acct = s . get ( ConnectorAccount , link . account_id )
if not acct or acct . state != ConnState . connected :
raise HTTPException ( 400 , " 연결된 메일 계정이 필요해요 " )
conn = ConnectorRegistry . get ( s , acct )
if not hasattr ( conn , " get_attachment " ) :
raise HTTPException ( 400 , " 이 계정은 첨부 다운로드를 지원하지 않아요 " )
data = conn . get_attachment ( s , link . external_id , att_id )
name = meta . get ( " name " , " attachment " )
disp = " inline " if inline else " attachment "
return Response (
content = data ,
media_type = meta . get ( " mime " ) or " application/octet-stream " ,
headers = { " Content-Disposition " : f " { disp } ; filename*=UTF-8 ' ' { quote ( name ) } " } ,
)
# ── 대화(스레드) 보기 ──
@router.get ( " /mail/ {eid} /thread " , response_model = ConversationOut )
def mail_thread (
eid : str ,
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
) :
""" 같은 thread_id 메일을 시간순으로 묶어 반환. """
e = s . get ( Email , eid )
if not e :
raise HTTPException ( 404 , " email not found " )
if not e . thread_id :
return ConversationOut (
thread_id = " " , subject = e . subject , count = 1 , messages = [ _row_out ( e ) ]
)
rows = s . exec (
select ( Email ) . where (
Email . thread_id == e . thread_id , Email . user_id == user . id
)
) . all ( )
rows . sort ( key = lambda x : x . created_at )
return ConversationOut (
thread_id = e . thread_id ,
subject = e . subject ,
count = len ( rows ) ,
messages = [ _row_out ( x ) for x in rows ] ,
)
# ── 여러 메일 일괄 처리 ──
def _apply_single ( s : Session , e : Email , action : str ) - > bool :
""" 한 메일에 액션 적용(원격+로컬). trash 면 삭제하고 False 반환(행 사라짐). """
if action in ( " read " , " unread " ) :
val = action == " read "
_apply_remote ( s , e . id , read = val )
e . read = val
elif action in ( " star " , " unstar " ) :
val = action == " star "
_apply_remote ( s , e . id , star = val )
e . starred = val
elif action in ( " archive " , " unarchive " ) :
val = action == " archive "
_apply_remote ( s , e . id , archived = val )
e . archived = val
elif action == " trash " :
_apply_remote ( s , e . id , trash = True )
link = _email_link ( s , e . id )
if link :
s . delete ( link )
s . delete ( e )
return False
s . add ( e )
return True
_BULK_ACTIONS = { " read " , " unread " , " archive " , " unarchive " , " trash " , " star " , " unstar " }
@router.post ( " /mail/bulk " )
def bulk_mail ( body : BulkMailRequest , s : Session = Depends ( get_session ) ) :
""" 선택한 여러 메일에 같은 액션(읽음/보관/삭제/별표)을 한 번에 적용. """
if body . action not in _BULK_ACTIONS :
raise HTTPException ( 400 , f " action must be one of { sorted ( _BULK_ACTIONS ) } " )
n = 0
for eid in body . ids :
e = s . get ( Email , eid )
if not e :
continue
_apply_single ( s , e , body . action )
n + = 1
s . commit ( )
return { " ok " : True , " count " : n , " action " : body . action }
def _make_task ( s : Session , e : Email , t : dict ) - > Task :
pid = PROJECT_LABEL_MAP . get ( t [ " project " ] , " me " )
if not s . get ( Project , pid ) :
@ -173,6 +810,64 @@ def extract_all(eid: str, s: Session = Depends(get_session)):
)
@router.post ( " /mail/triage/analyze " )
def triage_analyze (
limit : int = Query ( 4 , ge = 1 , le = 12 ) ,
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
provider : LLMProvider = Depends ( get_provider ) ,
) :
""" 아리 비서: 안읽음 집중 받은편지함 중 미분석 메일을 LLM 으로 배치 분석(best-effort).
한 번에 limit 통만 처리하고 remaining 으로 남은 수를 알려 점진 분석을 가능케 한다 . """
from . . services . mail_ai import analyze_email
since = _ensure_triage_since ( s )
rows = [ e for e in s . exec ( select ( Email ) ) . all ( ) if e . user_id == user . id ]
todo = [
e
for e in rows
if not e . ai_json
and _email_in_folder ( e , " smart " )
and _is_new_mail ( e , since ) # 기준 시각 이후 새 메일(읽음 무관 — 폰서 먼저 읽어도 정리)
]
todo . sort ( key = _email_sort_key , reverse = True )
analyzed , errors = 0 , 0
for e in todo [ : limit ] :
try :
analyze_email ( s , e , provider )
analyzed + = 1
except Exception : # LLM 일시 오류·파싱 실패는 건너뛴다(다음 호출서 재시도).
errors + = 1
return { " analyzed " : analyzed , " remaining " : max ( 0 , len ( todo ) - analyzed ) , " errors " : errors }
def _triage_new_count ( s : Session , user : Person , since : datetime ) - > int :
""" 기준 시각 이후 도착한 집중(focused) 새 메일 수 — 아리 정리 배지(읽음 여부 무관).
사용자가 폰 / 다른 앱에서 먼저 읽어도 ' 정리할 새 메일 ' 로 센다 ( 서버 read 가 곧 True 가 되므로 ) . """
rows = [ e for e in s . exec ( select ( Email ) ) . all ( ) if e . user_id == user . id ]
return sum ( 1 for e in rows if _email_in_folder ( e , " smart " ) and _is_new_mail ( e , since ) )
@router.get ( " /mail/triage/baseline " )
def triage_baseline_get (
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
) :
""" 아리 정리 기준 시각(ISO) + 그 이후 도착한 새 메일 수 — 없으면 지금으로 초기화. """
since = _ensure_triage_since ( s )
return { " since " : since . isoformat ( ) , " count " : _triage_new_count ( s , user , since ) }
@router.post ( " /mail/triage/baseline " )
def triage_baseline_reset (
s : Session = Depends ( get_session ) ,
user : Person = Depends ( current_user ) ,
) :
""" 기준 시각을 지금으로 재설정 — 이후 도착하는 새 메일부터 다시 정리. """
since = _set_triage_since ( s , now ( ) )
return { " since " : since . isoformat ( ) , " count " : _triage_new_count ( s , user , since ) }
@router.post ( " /mail/ {eid} /reply-draft " , response_model = ReplyDraftOut )
def reply_draft (
eid : str ,
@ -186,10 +881,6 @@ def reply_draft(
p = s . get ( Person , e . from_key )
to = f " { p . name } < { p . email } > " if p and getattr ( p , " email " , " " ) else ( p . name if p else e . from_key )
ai = e . ai_json or { }
_fallback = (
" 안녕하세요, \n \n 메일 잘 받았습니다. 확인하고 빠른 시일 내 회신드리겠습니다. "
" \n \n 감사합니다. \n 지우 드림 "
)
if body . reply_index is not None and ai . get ( " replies " ) :
draft = ai [ " replies " ] [ body . reply_index ] [ " body " ]
else :
@ -197,20 +888,31 @@ def reply_draft(
" 이 메일에 보낼 정중한 한국어 회신 초안 한 문단: \n "
f " { e . subject } \n { ' ' . join ( e . body or [ ] ) } "
)
try :
# 실 LLM 전용(휴리스틱 템플릿 폴백 제거) — 미가용 시 예외 전파.
draft = provider . generate_json (
prompt , { " type " : " object " , " properties " : { " body " : { " type " : " string " } } }
) . get ( " body " , " " )
if not draft :
raise ValueError ( " empty " )
except Exception :
draft = _fallback
return ReplyDraftOut ( to = to , from_account = e . account , subject = " Re: " + e . subject , body = draft )
def _connector_for_mail_account ( s : Session , mail_account : str ) - > ConnectorAccount | None :
""" 발신 MailAccount id → 연결된 real ConnectorAccount(없으면 None=mock 발송). """
rows = s . exec (
select ( ConnectorAccount ) . where ( ConnectorAccount . domain == ConnectorDomain . mail )
) . all ( )
for a in rows :
if a . external_account_id and mail_account_id_for ( a . external_account_id ) == mail_account :
return a
return None
@router.post ( " /mail/send " , response_model = SendResponse )
def send ( body : SendRequest , s : Session = Depends ( get_session ) ) :
""" 회신/새 메일 보내기 = high-risk → 결재함(phase-7). 실제 발송은 승인 후. """
""" 회신/새 메일 보내기 = high-risk → 결재함. 승인 시 연결된 real 계정으로 실제 발송(phase-16+).
연결된 계정이 없으면 거부 ( 가짜 발송 없음 ) . """
conn_acct = _connector_for_mail_account ( s , body . from_account )
if not conn_acct or conn_acct . state != ConnState . connected :
raise HTTPException ( 400 , " 연결된 메일 계정이 필요해요 — 계정을 먼저 연결하세요 " )
aid = " ap- " + uuid . uuid4 ( ) . hex [ : 8 ]
appr = Approval (
id = aid ,
@ -227,6 +929,21 @@ def send(body: SendRequest, s: Session = Depends(get_session)):
undo_label = " " ,
)
s . add ( appr )
s . add (
OutboundMail (
id = " ob- " + uuid . uuid4 ( ) . hex [ : 8 ] ,
approval_id = aid ,
connector_account_id = conn_acct . id ,
mail_account = body . from_account ,
to = body . to ,
cc = body . cc or " " ,
bcc = body . bcc or " " ,
subject = body . subject ,
body = body . body ,
attachments = [ att . model_dump ( ) for att in body . attachments ] ,
status = " pending " ,
)
)
s . commit ( )
return SendResponse (
approval_id = aid , status = " pending " , message = " 보낼 준비가 됐어요 — 결재함에서 확인하세요 "