diff --git a/docs/design/webmail/ComposeAssist.dc.html b/docs/design/webmail/ComposeAssist.dc.html new file mode 100644 index 0000000..b981fdb --- /dev/null +++ b/docs/design/webmail/ComposeAssist.dc.html @@ -0,0 +1,251 @@ + + + + +메일 쓰기 · 임시 저장 · 자동 완성 · 서명 + + + + + + + +
+ +
+

메일 쓰기 · 임시 저장 · 자동 완성 · 서명

+

쓰기 화면에 더해지는 세 가지입니다. 서명을 고치는 화면은 「설정 · 메일 서명」 보드에 있습니다. 문구 중 오류는 에러 코드 사전에서 가져옵니다.

+
+ +
+ +
+
+

임시 저장 · 자동 저장

+

입력이 3초 멈추면 · 10초에 한 번까지 · 화면을 떠날 때

+
+

바꾼 내용이 있으면 임시보관함에 저장합니다. 아무것도 쓰지 않은 쓰기 화면은 저장하지 않습니다. 쓰기 화면의 「취소」는 「임시 저장」으로 바뀌고, 저장된 채로는 닫기(✕)·다른 메일함으로 가도 묻지 않습니다.

+
+ + + + + 저장하는 중… + +
+
+ + + + + 오후 3:12에 임시 저장됨 + +
+
+ +
+
+

임시 저장 · 실패

+

에러 코드 · MAIL_001 · MAIL_003

+
+

자동 저장이 실패하면 알림 없이 이 자리에만 보이고 조금 뒤 다시 시도합니다. 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄웁니다.

+
+ + + + + 임시 저장하지 못했습니다 + + +
+
+ +
+
+

임시 저장 · 다른 곳에서 바뀐 저장본

+

에러 코드 · MAIL_008

+
+

같은 저장본을 다른 창·기기에서 먼저 고쳤거나 지웠으면, 막지 않고 지금 내용을 새 저장본으로 남깁니다 — 어느 쪽도 지우지 않으므로 임시보관함에 두 통이 보입니다.

+
+ + + + + 다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14 + +
+
+ +
+
+

임시보관함 목록

+

누르면 쓰기 화면에서 이어 씁니다

+
+

보낸 사람 자리에 받는 사람을 보이고, 시각은 마지막으로 저장한 때입니다. 보내면 임시보관함에서 사라집니다.

+ +
+ +
+
+

임시 저장본 삭제

+

쓰기 화면 머리의 휴지통 단추

+
+

삭제는 임시 저장본을 휴지통으로 옮기고 쓰기 화면을 닫습니다 — 알림의 「되돌리기」로 다시 꺼냅니다. 닫기(✕)는 저장한 채 닫습니다.

+
+ 답장 + + +
+
+ + 임시 저장한 메일을 휴지통으로 옮겼습니다. + +
+
+ +
+
+

나가기 확인 · 저장하지 못했을 때

+

자동 저장이 되는 동안에는 묻지 않습니다

+
+

저장이 실패했거나 아직 저장하지 못한 내용이 있을 때만 지금의 나가기 확인을 띄웁니다. 단추와 키는 그대로입니다.

+ +

저장하지 못한 내용이 있습니다

+

이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.

+
+ + +
+
+
+ +
+
+

받는 사람 자동 완성

+

받는 사람 · 참조 · 숨은참조 공통

+
+

이름이나 주소를 한 글자 이상 치면 사내(웹메일에 로그인한 적 있는 직원)와 최근 주고받은 주소에서 찾아 보입니다. 초성으로도 찾습니다(ㄷㅇ → 도윤). 이미 넣은 주소는 빼고 최대 8개, 맞는 글자는 굵게 보입니다.

+
+
+ + 박준호 + +
+
+
+ +
+ 이도윤 + doyun.lee@arasystem.it.kr +
+
+ 김도윤 + doyun.kim@arasystem.it.kr +
+
+
+ +
+ 최도윤 + [협력사 주소] +
+
+

↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기

+
+
+
+ +
+
+

자동 완성 · 주소를 끝까지 친 경우

+

엉뚱한 사람이 들어가지 않게

+
+

친 글자가 완전한 메일 주소면 추천을 미리 고르지 않습니다 — Enter 는 친 주소를 그대로 넣습니다. 화살표로 고른 뒤에만 추천이 들어갑니다.

+
+
+ + +
+
+
+ 이도윤 + doyun.lee@arasystem.it.kr +
+

Enter 는 친 주소를 그대로 넣습니다

+
+
+
+ +
+
+

서명이 들어간 답장

+

새 메일은 본문 끝 · 답장 · 전달은 인용 위

+
+

쓰기 화면을 열 때 서명을 보통 글로 넣어 둡니다 — 그 메일에서 바로 고치거나 지울 수 있고, 임시 저장에도 그대로 남습니다. 서명 그림은 보낼 때 메일 안에 함께 실어 보냅니다.

+
+

도윤님, 확인했습니다.

+


+

김아라 · 인프라팀

+

ARA SYSTEM · [전화번호]

+

[회사 로고]

+
+

원본 메일

+

보낸 사람 이도윤 · 2026. 10. 26. (월) 오전 10:42

+

김아라님, 안녕하세요.

+
+
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index ef60ef9..64c262a 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -1,11 +1,12 @@ -# 웹메일 디자인 원본 (1단계) +# 웹메일 디자인 원본 (1·2단계) 확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다. -- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 14) +- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 19) - 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일) · `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부) · `States`(오류·빈 상태·피드백 모음) + · 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계" - 파일 형식은 캔버스 전용(`` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다. `Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다. @@ -157,10 +158,12 @@ 떠난 화면이나 새로 연 쓰기 화면은 그 결과로 바뀌지 않는다 (2026-10-27 리뷰 반영). 단 보내는 중에 탭을 닫거나 새로고침하면 브라우저 기본 확인창은 뜬다 — 요청이 끊길 수 있고 결과를 알릴 화면이 남지 않기 때문이다. - 맥의 ⌘[ 를 본문 밖에서 눌러 브라우저 "뒤로"가 되어도 이 확인에 걸린다. +- **2단계(임시 저장)부터는 저장된 채로는 묻지 않는다** — 저장하지 못한 내용이 있을 때만 이 확인을 띄우고 제목 · 글이 바뀐다 + (아래 "2단계 › 임시 저장"). 단추 · 키 · 묻지 않는 경우는 그대로다. ### 본문 이미지 → 첨부 파일 -- 본문에는 이미지를 넣지 않는다(사용자 확정). 툴바에 이미지 버튼이 없다. +- 본문에는 이미지를 넣지 않는다(사용자 확정). 툴바에 이미지 버튼이 없다. **서명 그림만 예외**다 (아래 "2단계 › 서명"). - 본문에 붙여 넣거나(Ctrl+V) 끌어다 놓은 이미지는 첨부 파일로 올린다. 끌어다 놓은 다른 파일도 첨부로 올린다(쓰기 화면 어디에 놓아도 같다). 한도는 첨부와 같다 — 넘치면 첨부 목록에 실패로 보인다(`States` 의 "첨부 업로드"). - 클립보드에 **글이 함께 있으면 글을 붙여 넣는다** — 워드 · 엑셀에서 복사하면 고른 부분의 그림이 글과 함께 따라오기 때문이다. @@ -171,6 +174,47 @@ 받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함). - 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초). +## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`) + +### 임시 저장 + +- **자동 저장**: 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지)와 화면을 떠날 때 임시보관함에 저장한다. + 아무것도 쓰지 않은 쓰기 화면은 저장하지 않는다. 저장은 한 번에 하나씩이고, 보내기 전에는 진행 중인 저장이 끝나기를 기다린다. +- **아래 단추 줄**: [보내기] [임시 저장] 뒤에 상태 글(12px) — "저장하는 중…"(#667085) · "오후 3:12에 임시 저장됨"(✓ #0E7C86) · + 실패 "임시 저장하지 못했습니다"(#B42318) + [다시 시도]. 「취소」는 없앤다 — 닫기(✕)가 저장한 채 닫는다. + - 자동 저장이 실패하면 알림 없이 그 자리에만 보이고 조금 뒤 다시 시도한다. 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다. + - 같은 저장본을 다른 창 · 기기에서 먼저 고쳤거나 지웠으면(`MAIL_008`, 휴지통으로 옮겼으면 PUT 의 404 `RES_001`) 막지 않고 지금 내용을 새 저장본으로 남긴다 — + 상태 글 "다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14"(정보 아이콘, #7A5200). 임시보관함에 두 통이 남는다. +- **삭제**: 쓰기 화면 머리의 휴지통 단추(44 · "이 메일 삭제") — 임시 저장본을 휴지통으로 옮기고 닫는다. 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + + [되돌리기](임시보관함으로 되돌린다). 저장본이 아직 없으면 그냥 닫는다. +- **임시보관함 목록**: 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)"), 제목이 없으면 "(제목 없음)" — 둘 다 #667085. + 시각은 마지막으로 저장한 때. 누르면 읽기 화면이 아니라 쓰기 화면에서 이어 쓴다. 보내면 임시보관함에서 사라진다. +- **나가기 확인**: 저장된 채로는 묻지 않는다. 떠나기 전에 남은 변경을 먼저 저장하고, 저장하지 못한 내용이 있을 때만 + 제목 "저장하지 못한 내용이 있습니다", 글 "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다."로 묻는다. + +### 서명 + +- **설정 › 메일 › 서명** — 앱 레일의 "설정"에서 연다. 왼쪽 패널(208 · #F8F9FB)의 "메일" 묶음에 "서명", 오른쪽은 콘텐츠 열(최대 800). +- 서명 칸: 굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림. 글꼴은 쓰기 화면 본문과 같은 맑은 고딕 14px · 줄 간격 1.75. +- **그림**: 그림 단추 · 붙여 넣기 · 끌어다 놓기로 넣는다 — PNG · JPG · GIF, 한 장 300KB 이하, 3장까지. + 그림을 고르면 칸 아래에 크기 줄(작게 96px · 보통 144px · 원본 — 최대 600px · 그림 빼기). 메일 안에 함께 실어 보내므로 + 받는 쪽에서 외부 이미지처럼 막히지 않는다. 지금 있는 흰 로고는 흰 메일 바탕에서 보이지 않으니 서명에는 색이 있는 로고를 쓴다. +- 서명을 넣을 메일: 새 메일 · 답장 · 전달(인용한 원문 위) — 둘 다 기본으로 켠다. 미리 보기에서 [새 메일 | 답장]을 바꿔 본다. +- [저장] → "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다."(#067647). +- 쓰기 화면을 열 때 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위. 그 메일에서 고치거나 지울 수 있고 + 임시 저장에도 남는다. 임시 저장본을 이어 쓸 때는 서명을 다시 넣지 않는다. 본문에 붙여 넣은 다른 그림은 지금처럼 첨부로 올린다. +- 서명 그림처럼 **본문 안에 그림으로 보인 부분은 첨부로 보이지 않는다** — 읽기 화면의 첨부 목록과 목록의 첨부 클립에서 뺀다 + (받은 메일의 본문 그림도 같다). 전달할 때는 원문 그림을 잃지 않게 원본 첨부로 함께 붙는다 (인용 본문에서는 그림이 빠지기 때문이다). + +### 받는 사람 자동 완성 + +- 받는 사람 · 참조 · 숨은참조에서 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8개) — "사내"(웹메일에 로그인한 적 있는 직원) · + "최근 주고받은 주소" 두 묶음(묶음 이름 12px #667085 굵게). 줄은 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363), 고른 줄은 #E3F2F3. +- 이미 넣은 주소는 빼고, 초성으로도 찾는다(ㄷㅇ → 도윤). 목록 아래 안내 "↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기". +- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter 는 친 주소를 그대로 넣습니다"), + 화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다. +- 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -181,3 +225,34 @@ - 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 표시합니다.", 없으면 "본문이 너무 크거나 복잡해 표시할 수 없습니다." - 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 표시합니다." + +### 2단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다) + +- **임시 저장** + - 쉬지 않고 쓰는 동안에도 처음 바꾼 때(또는 마지막 저장을 시작한 때)부터 30초가 지나면 저장한다 — 멈춤을 기다리다 오래 쓴 글을 잃지 않게. + - 저장할 때마다 주소를 `/mail/compose?draft=<새 id>` 로 맞춘다 — 새로고침하거나 다시 열면 마지막 저장본을 이어 쓴다. + 떠나기 전 저장은 주소를 바꾸지 않는다(가던 이동이 취소되므로). 대신 같은 탭에서 옛 `?draft=` 로 돌아오면(뒤로 가기) 마지막 저장본을 연다 + — 저장본이 바뀐 차례는 탭 메모리에만 두고 로그아웃 · 세션 만료 때 지운다. + - 자동 저장 실패 가운데 메일 서버 장애·용량·요청 과다는 15초 → 30초 → 60초 뒤 저절로 다시 한다. 그 밖의 실패는 내용을 고치면 다시 저장한다. + - 「임시 저장」은 아직 한 번도 저장하지 않았으면 바꾼 것이 없어도 저장한다 (빈 메일 · 미리 채운 답장 포함). + - 형식이 틀린 주소 · 사전 검사에 걸린 첨부는 저장하지 않는다 (칸에는 남는다). 올리는 중인 첨부는 "저장하지 못한 내용"으로 친다. + - 찾지 못한 첨부(`MAIL_004`)가 남아 있는 동안은 저장하지 않는다(「임시 저장」 · [다시 시도] 포함) — 첨부가 빠진 판으로 멀쩡한 저장본을 덮지 않게. + 그 첨부를 빼면 다시 저장한다. + - 본문이 한도를 넘으면 보내기처럼 인용을 줄여 저장한다(편집기 화면은 그대로). 인용을 다 줄여도 넘으면 저장하지 않고 보내기와 같은 안내를 + 본문 아래에 보이며, 내용을 고칠 때까지 다시 시도하지 않는다. + - 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면, 첫 저장은 원본을 지우지 않고 새 저장본으로 남긴다 + — 원본은 첨부째 임시보관함에 그대로 있다. + - 떠나기 전 저장은 5초까지만 기다린다. 끝나지 않으면 "저장하지 못한 내용이 있습니다"로 묻고, 묻는 사이에 저장이 끝나면 그대로 떠난다. + - 보내는 중에 화면을 떠났는데 발송이 실패하면, 보내려던 내용을 임시 저장본으로 한 번 남긴다 (실패 알림은 그대로). + - 알려진 한계: 떠나기 전 저장이 첫 저장이었던 새 메일 · 답장 · 전달은, 뒤로 가기로 돌아오면 새 양식이 열린다 (저장본은 임시보관함에 있다). + - 휴지통 단추는 저장본을 휴지통 메일함으로 **옮긴다**(삭제 요청이 아니다 — 다른 창에서 먼저 휴지통으로 옮긴 것을 영구 삭제하지 않게). + [되돌리기]로 되돌리면 "임시보관함으로 되돌렸습니다." 알림을 띄운다. 휴지통 메일함을 알 수 없으면 지우지 않고 실패로 알린다. + 이미 없는 저장본이면 알림 없이 닫는다. 단추가 있는 알림은 10초 보인다. 알림은 마우스 · 초점이 그 위에 있는 동안 사라지지 않고, + 놓으면 그 알림의 시간을 처음부터 다시 센다. + - 이어 쓰는 저장본의 제목 줄: 답장 대상이 있으면 답장, 제목이 `Fwd:`·`FW:`·`전달:` 로 시작하면 전달, 그 밖은 새 메일. +- **서명** + - 서명을 바꾸고 저장하지 않은 채 떠나면 묻는다 — 제목 "저장하지 않은 서명이 있습니다", 글 "이 화면을 나가면 바꾼 서명이 사라집니다.", [계속 고치기] [나가기]. + - 서명 그림 3장은 서명 칸에 있는 그림 수로 센다. 미리 보기의 답장 원문 자리는 예시 이름 대신 "(보낸 사람)" 같은 괄호 글이다. + - 설정 화면의 미리 보기 전환 단추와 그림 크기 줄(작게 · 보통 · 원본 · 그림 빼기, 36px)은 목록의 보기 전환처럼 밀도 예외다 (캔버스 확정값). +- **받는 사람 자동 완성**: Tab 은 고른 추천을 넣고 그 칸에 머문다(고른 것이 없으면 원래 Tab). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다. + 초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다). diff --git a/docs/design/webmail/Settings.dc.html b/docs/design/webmail/Settings.dc.html new file mode 100644 index 0000000..b62a4bf --- /dev/null +++ b/docs/design/webmail/Settings.dc.html @@ -0,0 +1,270 @@ + + + + +설정 · 메일 서명 + + + + + + + +
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+

서명

+

보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.

+
+ +
+

서명 내용

+
+
+ + + + + + + +
+
+

김아라 · 인프라팀

+

ARA SYSTEM · [전화번호]

+

ara.kim@arasystem.it.kr

+

[회사 로고]

+
+
+ 그림 크기 +
+ + + +
+ + 그림을 고르면 나오는 줄입니다 +
+
+

굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.

+
+ +
+ 서명을 넣을 메일 + + +
+ +
+ + + + + 저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다. + + +
+ +
+ +
+
+

미리 보기

+
+ + +
+
+ +
+

(여기에 쓴 내용)

+


+ +
+

김아라 · 인프라팀

+

ARA SYSTEM · [전화번호]

+

ara.kim@arasystem.it.kr

+

[회사 로고]

+
+
+ +
+
+

원본 메일

+ + + + + + +
보낸 사람이도윤 <doyun.lee@arasystem.it.kr>
날짜2026. 10. 26. (월) 오전 10:42
제목Re: 메일 서버 이전 일정 공유
+
+

김아라님, 안녕하세요.

+

말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.

+
+
+
+

{{ previewNote }}

+
+ +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 6d41e4e..471dac4 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -11,11 +11,14 @@ "States.dc.html": { "x": 0, "y": 1020, "w": 1440, "h": 1040, "title": "오류 · 빈 상태 · 피드백" }, "MainList.dc.html": { "x": 1520, "y": 1020, "w": 1440, "h": 900, "title": "메일함 · 목록 보기", "expand": "fill", "is_interactive": true }, "MainListRead.dc.html": { "x": 3040, "y": 1020, "w": 1440, "h": 900, "title": "목록 보기 · 메일 읽기", "expand": "fill", "is_interactive": true }, - "ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" } + "ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" }, + "Settings.dc.html": { "x": 0, "y": 2480, "w": 1440, "h": 900, "title": "설정 · 메일 서명", "expand": "fill", "is_interactive": true }, + "ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html"], "notes": { - "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 } + "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, + "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 06ef334..a38d0e5 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -8,7 +8,7 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` -- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `SYS_001` +- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `SYS_001` - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, @@ -122,7 +122,7 @@ interface MessageSummary { receivedAt: string isRead: boolean isFlagged: boolean - hasAttachment: boolean + hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다 size: number // 바이트 } @@ -138,10 +138,14 @@ interface MessageDetail extends MessageSummary { hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도") isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true - attachments: Attachment[] + attachments: MessageAttachment[] } interface Attachment { id: string; name: string; type: string; size: number } + +interface MessageAttachment extends Attachment { + isInline: boolean // 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다. 전달할 때는 함께 붙인다 +} ``` 역할별 한국어 이름: `inbox` 받은편지함 · `drafts` 임시보관함 · `sent` 보낸편지함 · `junk` 스팸메일함 · `trash` 휴지통 · `archive` 보관함. @@ -149,9 +153,16 @@ interface Attachment { id: string; name: string; type: string; size: number } **`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`), 외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`. -본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다(첨부 목록에는 남는다). +본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다. +넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다 +(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다. **클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어). +**`hasAttachment`** 는 엔진 값이 아니라 서버가 첨부 부분의 머리로 정한다 — "본문 그림"이 아닌 첨부가 하나라도 있으면 `true`. +본문 그림 = 본문에 넣을 수 있는 그림 형식(PNG·JPEG·GIF·WebP) · `Content-ID` 있음 · `Content-Disposition` 이 `attachment` 가 아님(`inline` 이거나 없음) · +크기 1바이트~300KB. 목록은 본문을 읽지 않으므로 이 판정은 "실제로 본문에 들어갔나"(`isInline`)와 드물게 다를 수 있다 +(본문이 가리키지 않는 그런 그림, 20장·합계 2MB 를 넘는 본문 그림 — 목록은 클립이 없는데 상세의 첨부 목록에는 보인다). + **읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 오고, 보여 줄 본문이 잘렸으면 `isBodyTruncated: true` 다. HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 — 약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다. @@ -168,10 +179,18 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | | `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` | | `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` | -| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제 | 404 `RES_001` | +| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | | `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` | | `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` | | `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` | +| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` | +| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` | +| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) | +| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — | +| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` | +| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) | +| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` | +| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` | 표에 없는 실패 (구현에서 확정): @@ -183,7 +202,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) | | 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 | | 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) | -| JSON 본문 1MB 초과 (발송 `POST /api/messages` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` | +| JSON 본문 1MB 초과 (발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` | 메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순). @@ -205,6 +224,8 @@ interface MailQuota { 같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 **같은 키**를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면 다시 보내지 않고 그 결과(201 `{ id }`)를 그대로 돌려준다. 응답이 늦어 `MAIL_001` 을 받았어도 실제로는 나갔을 수 있으므로, 재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 `VAL_001`. 키가 없으면 중복 방지 없이 보낸다.) +서버는 발송 요청에 55초 안에 답한다 — 앞 단계(첨부 확인 · 서명 그림 올리기)가 늦어 보낼 시간이 10초도 남지 않으면 메일을 만들지 않고 503 `MAIL_001` +(아무것도 나가지 않았으니 같은 키로 다시 보내면 된다). 보낸 뒤 이어 쓴 저장본 지우기는 5초까지만 기다리고, 넘으면 먼저 답한 뒤 마저 지운다. - 같은 키의 요청이 **아직 처리 중**이면 기다리지 않고 429 `REQ_001` — 같은 메일이 동시에 두 번 나가지 않게 한다. 엔진 장애(`MAIL_001`)로 끝난 키도 **30초 동안은 처리 중**으로 본다 (시간 초과라면 엔진이 아직 보내는 중일 수 있다). @@ -230,7 +251,8 @@ interface MailQuota { "body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하 "bodyHtml": "

도윤님, 확인했습니다.

…
", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문") "attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006) - "inReplyTo": "<답장할 메일의 id>" // 선택 — 스레드 헤더를 서버가 채운다 + "inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다 + "draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") } ``` @@ -261,6 +283,9 @@ interface MailQuota { `AppleGothic`·`AppleMyungjo`)·일반 이름(`sans-serif`·`serif`·`monospace`)으로만 이뤄진 목록. - 형광 표시는 `mark` 가 아니라 span 의 `background-color` 로 보낸다 (`mark`·`h1` 같은 모르는 태그는 태그만 지우고 글자는 남긴다). - 정화하고 남는 것이 없으면 `body` 만으로 텍스트 메일을 보낸다. + - **그림은 보내는 사람 자신의 서명 그림만** 남는다 — `` 이고 그 id 가 보내는 사람의 그림일 때. + 속성은 `src`·`width`(1~600, 숫자만)·`alt`(200자 이하)만 남는다. 서버가 그 그림을 메일 안에 함께 실어(`multipart/related`, `cid:`) 보내므로 + 받는 쪽에서 외부 이미지처럼 막히지 않는다. 그 밖의 `img`·찾지 못한 서명 그림(지운 그림)은 태그째 지운다. 클라이언트 편집기도 이 그림만 받는다. - 보이지 않을 만큼 작은 글자(8px 미만)·거의 투명한 글자 색(알파 0.3 미만)은 그 선언을 지운다 — 붙여 넣거나 인용한 글에 그렇게 숨긴 글이 그대로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다 (받은 메일을 읽을 때의 정화는 이 제한을 두지 않는다). @@ -278,6 +303,7 @@ interface MailQuota { - 답장·전달의 원문 인용은 두 경우 모두 **"원본 메일"** 머리(보낸 사람·날짜·제목, 전달은 받는 사람·참조도)와 회색 인용 막대로 만든다. 원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다. - **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로). + 서명 그림만 예외다 (아래 "서명"). ### 첨부 업로드 한도 @@ -290,6 +316,121 @@ interface MailQuota { 이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다 (`/auth/me` 가 `AUTH_001` 이면 `/auth/refresh` 한 번 → 실패하면 세션 만료 처리). +### 임시 저장 (`/api/drafts`) + +```ts +interface Draft { + id: string // 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다 + to: Address[] + cc: Address[] + bcc: Address[] + subject: string + body: string // 텍스트 대체 본문 + bodyHtml: string | null // 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null). 다른 메일 프로그램이 만든 초안도 같은 정화를 거친다 + attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다 + inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null) + updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC) +} + +interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string } +``` + +- **초안 본문**은 발송 본문과 같은 필드·한도다(본문 길이 `MAIL_007`, 첨부 20개 `MAIL_006`, 첨부 합계 `FILE_002`, JSON 본문 4MB …). 다만 받는 사람은 0명이어도 되고 + (합계 100명 이하), `draftId` 는 없다. +- **저장**: 처음은 `POST`, 그다음은 `PUT /api/drafts/{직전 id}`. 메일은 고칠 수 없어 서버가 새 초안을 만들고, 다시 읽어 확인한 뒤 옛것을 지운다 — + 클라이언트는 응답의 `id`·`attachments` 로 바꿔 쓴다. 서버는 초안에 발송 중복 방지용 Message-ID·지문 헤더를 넣지 않는다. + 만들기와 지우기를 한 요청에 넣지 않는다 — 엔진은 같은 요청에서 만들기가 실패해도 지우기를 그대로 해서, 저장에 실패한 순간 저장본이 사라진다. + - "임시보관함의 초안" = 임시보관함에**만** 있고 `$draft` 이며 보내기 중에 생긴 내부 초안이 아닌 메일. PUT 의 id 가 그렇지 않으면(휴지통·보낸 메일 등) + 아무것도 만들거나 지우지 않고 404 `RES_001`. + - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 409 `MAIL_008` — 처음 확인에서 없으면 아무것도 만들지 않고, 만든 뒤 지우는 사이에 + 없어졌으면 방금 만든 것을 지운다. + - 클라이언트는 `MAIL_008` 과 PUT 의 404 `RES_001`(다른 창에서 휴지통으로 옮긴 경우 등)을 모두 막지 않고, 지금 내용을 `POST` 로 새로 남긴다. + - 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다. + 그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공). +- **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다), + 버리기 `DELETE /api/messages/{id}`(휴지통으로), 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`. +- **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤 + (같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 · + 확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다. +- **클라이언트 저장 주기**: 바꾼 내용이 있을 때 입력이 3초 멈추면 · 쉬지 않고 쓰는 중에도 30초마다 · 10초에 한 번까지 · 화면을 떠날 때, 한 번에 하나씩. + 찾지 못한 첨부(`MAIL_004`)가 남은 동안과 본문이 한도를 넘는 동안은 저장하지 않는다 (첨부가 빠진 판으로 저장본을 덮지 않게). + 다른 메일 프로그램이 만든 저장본에 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면 첫 저장은 PUT 이 아니라 POST 다 — 원본을 지우지 않는다. + 보내기 전에는 진행 중인 저장을 기다린다 (그래야 `draftId` 가 최신이다). +- **알려진 한계**: + - 저장할 때마다 첨부 내용이 새 초안으로 복사돼 큰 첨부가 있으면 저장이 무겁다. 메일함 용량이 거의 차면 교체하는 순간 두 판이 + 함께 있어 `MAIL_003` 이 날 수 있다. + - 답장 대상은 초안의 사용자 머리 `X-Ara-Reply-To-Id`(메일 id)에 둔다. 우리 서버는 보낼 때 새 메일을 만들어 이 머리가 나가지 않지만, + 다른 메일 프로그램이 그 초안을 그대로 보내면 함께 나갈 수 있다. + - 보내기에서 제출은 됐지만 보낸편지함으로 옮기지 못한 메일은 임시보관함에 보이지 않는 채로 남는다 (같은 키의 재시도는 그 메일을 찾는다). + - 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로 + 임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다). + +### 서명 (`/api/mail-signature`) + +```ts +interface MailSignature { + bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 + body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 + isForNew: boolean // 새 메일에 넣는다 (기본 true) + isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true) + images: SignatureImage[] // 서명이 쓰는 그림 + updatedAt: string | null // 저장한 적 없으면 null +} + +interface SignatureImage { + id: string + type: 'image/png' | 'image/jpeg' | 'image/gif' + size: number // 바이트 + width: number // 원본 픽셀 + height: number +} +``` + +- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다). +- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자, + 서명 그림 3장(서로 다른 그림 기준). +- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다), + 한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다), + 가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게). + 서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`). + 올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`). + 서명 HTML 은 그림을 `` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하). +- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다. + 저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400` + (그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id). +- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다 + (그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다. +- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다 + (`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: `, 이름 `signature-1.png` …, + 첨부가 있으면 그 묶음을 `multipart/mixed` 안에 둔다). 우리 웹메일에서는 위 `hasAttachment`·`isInline` 규칙으로 첨부로 보이지 않는다. + 텍스트 대체 본문에는 그림이 없다. 임시 저장본도 같은 그림 주소를 쓴다. +- **알려진 한계**: 서명 그림을 지운 뒤 그 그림을 쓴 임시 저장본을 보내면 그 그림은 빠진다. 답장·전달로 인용한 원문 속 그림(남의 서명 로고 포함)은 인용에서 빠진다. + +### 받는 사람 추천 (`/api/recipient-suggestions`) + +```ts +interface RecipientSuggestion { + name: string | null + email: string + source: 'directory' | 'recent' // directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소 +} +``` + +- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) — + 커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`). +- **순서**: 사내 → 최근 주고받은 주소, 각 묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. +- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 — + 다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. +- **사내 목록**은 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다 — 메일 엔진의 계정 목록 권한은 쓰지 않는다(일부러 막아 두었다). + 한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다 + (퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다). +- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다. + 추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다. +- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 1,000명), 그 사람이 메일을 보내면 다시 모은다. + 같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다. +- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤). + ### 이 단계에서 하지 않는 것 -실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리. +실시간 새 메일 알림(SSE), 검색 화면, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리, +여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록.