From fa6799d9f72ef3e9d272156f8dc73e2c17967f04 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 22:07:45 +0900 Subject: [PATCH] =?UTF-8?q?docs:=209=EB=8B=A8=EA=B3=84(=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=C2=B7=20=EC=9D=B8=EC=87=84=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=20=EC=82=AC=EB=9E=8C=20=EC=B0=A8=EB=8B=A8)?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보내기 취소 · 예약 발송(엔진이 제출을 붙잡는다 — FUTURERELEASE), 외부 이미지 중계, 인쇄, 보낸 사람 차단, 보내기 · 읽기 설정의 계약(2절)을 정하고, 확정한 캔버스 보드 넷(MailReadTools · MailPrint · MailSendLater · SettingsSendRead)을 둔다. 디자인 README 에 구현하며 정한 보완을 적었다. 리뷰(화면 한 번 · 서버 두 번)로 정한 것도 함께 적었다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(MAIL_015 — 모든 옮기기 · 지우기 길, 확인하지 못하면 닫힌 쪽), 발송은 늘 envelope 을 직접 준다(엔진이 받지 않는 주소를 not-found 로 빼고 다시 제출 — 전에는 말없이 빠졌다), 다시 취소해도 200, 이미지 중계의 동시 수(서버 16 · 직원 6 — 받는 수가 적은 직원부터), 차단 주소는 dot-atom 만 · 다시 POST 하면 꺼진 규칙을 켠다. 엔진 사실(2026-10-29): envelope 의 거절은 제출 전체 · 첫 주소 하나, 내보내는 사본에서 Bcc 를 뗀다(개발의 Mailpit 이 보이는 Bcc 줄은 Mailpit 이 붙인다), 제출 조회의 정렬 이름은 sentAt. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/MailPrint.dc.html | 96 ++++++++ docs/design/webmail/MailReadTools.dc.html | 172 ++++++++++++++ docs/design/webmail/MailSendLater.dc.html | 203 +++++++++++++++++ docs/design/webmail/README.md | 109 +++++++++ docs/design/webmail/SettingsSendRead.dc.html | 140 ++++++++++++ docs/design/webmail/canvas.json | 11 +- docs/mail/api-contract.md | 227 +++++++++++++++++-- 7 files changed, 940 insertions(+), 18 deletions(-) create mode 100644 docs/design/webmail/MailPrint.dc.html create mode 100644 docs/design/webmail/MailReadTools.dc.html create mode 100644 docs/design/webmail/MailSendLater.dc.html create mode 100644 docs/design/webmail/SettingsSendRead.dc.html diff --git a/docs/design/webmail/MailPrint.dc.html b/docs/design/webmail/MailPrint.dc.html new file mode 100644 index 0000000..f21f73e --- /dev/null +++ b/docs/design/webmail/MailPrint.dc.html @@ -0,0 +1,96 @@ + + + + +인쇄 화면 + + + + + + + +
+ +
+

인쇄 화면

+

⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 새 탭에 메일 한 통만 담은 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄웁니다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않습니다. 긴 메일은 여러 쪽으로 이어집니다.

+
+ +
+ +
+
+ 인쇄 화면 — 화면에만 보이는 줄 + + +
+ +
+
+
+ ARA SYSTEM 메일 + 김아라 <ara.kim@arasystem.it.kr> +
+

Re: 메일 서버 이전 일정 공유

+
+
보낸 사람
이도윤 <doyun.lee@arasystem.it.kr>
+
받는 사람
김아라 <ara.kim@arasystem.it.kr>
+
참조
인프라팀 <infra@arasystem.it.kr>
+
날짜
2026년 10월 26일 (월) 오전 10:42
+
첨부
메일서버_전환_체크리스트.xlsx (48 KB)
+
+
+
+

김아라님, 안녕하세요.

+

말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다. 전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.

+

전환 체크리스트를 첨부했으니 확인 부탁드립니다.

+

이도윤 드림

+
+
+
+
+ +
+
+

인쇄되는 것

+
    +
  • 머리 줄 — 「ARA SYSTEM 메일」과 인쇄한 직원의 이름 · 주소
  • +
  • 제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것과 같습니다 — 숨은참조는 넣지 않습니다)
  • +
  • 첨부 — 파일 이름과 크기만(내용은 인쇄하지 않습니다)
  • +
  • 본문 — 읽기 화면과 같은 정화를 거친 그대로. 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따릅니다
  • +
+
+
+

이미지 · 긴 메일

+
    +
  • 외부 이미지는 읽기 화면에서 보이고 있던 대로 인쇄합니다(막혀 있었으면 인쇄에도 없습니다)
  • +
  • 본문은 인쇄할 폭(A4)에 맞춰 다시 잰 뒤 인쇄합니다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않습니다
  • +
  • 인쇄 창을 닫아도 인쇄 화면 탭은 남습니다 — [인쇄]로 다시, [닫기]로 탭을 닫습니다
  • +
+
+
+

못 열 때

+

메일을 찾을 수 없으면 인쇄 화면에 "메일을 찾을 수 없습니다"와 [닫기], 받지 못하면 [다시 시도]가 보이고 인쇄 창은 띄우지 않습니다. 세션이 끝났으면 로그인 화면으로 갑니다.

+
+
+
+
+
+ + + diff --git a/docs/design/webmail/MailReadTools.dc.html b/docs/design/webmail/MailReadTools.dc.html new file mode 100644 index 0000000..1bbba02 --- /dev/null +++ b/docs/design/webmail/MailReadTools.dc.html @@ -0,0 +1,172 @@ + + + + +읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단 + + + + + + + +
+ +
+

읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단

+

외부 이미지는 지금처럼 막아 두고, 원하면 그 메일만 보이게 합니다. 이미지는 사내 서버가 대신 받아 보여 주므로 보낸 사람에게 이 PC 의 주소가 드러나지 않습니다. 인쇄 · 차단은 ⋯ 메뉴에서 합니다.

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

[클라우드 브리프] 10월 넷째 주 — 서버 비용 줄이는 다섯 가지

+
+ +
+
+ 클라우드 브리프 + news@cloudbrief.example + 2026. 10. 29. (목) 오전 8:00 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+ +
+ + 외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다. + +
+ +
+

안녕하세요, 이번 주 클라우드 브리프입니다. 서버 비용을 줄이는 다섯 가지 방법을 정리했습니다.

+

1. 쓰지 않는 디스크 정리하기 — 스냅숏만 남기고 붙어 있지 않은 볼륨을 지우면 …

+

2. 예약 인스턴스로 바꾸기 — 1년 약정이면 …

+

(이미지가 있던 자리는 비어 있습니다 — 글은 그대로 읽힙니다.)

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

「인쇄」는 새 탭에 인쇄 화면을 열고 바로 인쇄 창을 띄웁니다(다음 보드). 읽기 화면에서 Ctrl+P 를 눌러도 같습니다. 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」입니다. 사내 직원 · 내 주소는 차단 항목이 없습니다.

+ +
+

보낸 사람 차단

+

앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.

+
+ + +
+ +

차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.

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

[이미지 보기]를 누른 뒤

+

그 메일만 · 다시 열면 설정대로

+
+
+
(받아 온 이미지)
+

안녕하세요, 이번 주 클라우드 브리프입니다. …

+
+

안내 줄은 사라집니다. 이미지를 받지 못한 자리(없어진 주소 · 너무 큰 파일 · 사내망 주소)는 비어 있습니다. 메일 안의 배경 그림은 보이지 않습니다.

+
+ +
+
+

설정에 따라 처음부터 보일 때

+

설정 › 메일 › 보내기 · 읽기 › 외부 이미지

+
+
    +
  • 늘 묻기(처음 값) — 메일마다 [이미지 보기]
  • +
  • 주소록 · 사내 직원의 메일은 늘 보기 — 보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다. 나머지는 [이미지 보기]
  • +
  • 늘 보기 — 모든 메일에서 바로 보입니다
  • +
+

스팸메일함의 메일은 설정과 상관없이 늘 묻습니다. 답장 · 전달에 인용한 본문에는 이미지가 실리지 않습니다(지금과 같음).

+
+ +
+
+

차단한 뒤

+

알림 · 스팸메일함에서 읽을 때

+
+
+ news@cloudbrief.example 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다. + +
+
+ + 차단한 보낸 사람의 메일입니다. + +
+

[되돌리기]는 차단을 풀고 메일을 받은편지함으로 돌려놓습니다. 「이 도메인 전체」는 gmail.com · naver.com 처럼 여러 사람이 쓰는 메일 서비스에서는 고를 수 없습니다(까닭을 칸 아래에).

+
+
+
+
+ + + diff --git a/docs/design/webmail/MailSendLater.dc.html b/docs/design/webmail/MailSendLater.dc.html new file mode 100644 index 0000000..e0219ae --- /dev/null +++ b/docs/design/webmail/MailSendLater.dc.html @@ -0,0 +1,203 @@ + + + + +보내기 취소 · 예약 발송 + + + + + + + +
+ +
+

보내기 취소 · 예약 발송

+

[보내기]를 누르면 메일 서버가 몇 초(설정 — 처음 10초) 기다렸다가 보냅니다. 그동안 [보내기 취소]로 쓰기 화면에 돌아옵니다. 기다리는 곳이 서버라 창을 닫아도 그대로 보내집니다. 예약 발송은 같은 방식으로 7일 안의 시각까지 기다립니다.

+
+ +
+ +
+
+

쓰기 화면 — 보내기 ▾

+

단추를 둘로 나눕니다 — 오른쪽 ▾ 는 메뉴

+
+
+
+ + + +
+ + 2분 전에 저장함 +
+
+ +
+

메뉴는 「예약 발송…」 하나로 시작합니다. 단축키 Ctrl+Enter 는 그대로 [보내기]입니다. 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추가 함께 꺼집니다(지금의 [보내기]와 같은 규칙).

+
+ +
+

예약 발송

+
+ + + + +
+

이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다.

+
+ + +
+
+ +
+

예약 시각 규칙

+
    +
  • 골라 둔 시각이 이미 지났거나 5분 안이면 칸 아래 "지금부터 5분 뒤 이후의 시각을 고르세요."
  • +
  • 7일을 넘으면 "7일 안의 시각만 고를 수 있습니다." — 메일 서버의 한도입니다
  • +
  • 시각은 30분 단위로 고릅니다. 오늘 오후가 남았으면 「오늘 오후 6:00」도 첫 줄에 보입니다
  • +
  • 예약한 메일도 받는 사람 확인은 지금 합니다 — 없는 주소면 지금과 같이 칸 아래에서 막습니다
  • +
+
+
+ +
+
+
+

보낸 뒤 알림

+

[보내기 취소]는 기다리는 시간 동안만 — 그 뒤 알림은 저절로 닫힙니다

+
+
+
+ 메일을 보냈습니다. + +
+ +
+
+ 보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소) + +
+
+ + 보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다. +
+
+ 이미 보낸 메일이라 취소할 수 없습니다. +
+
+ + 10월 30일 (금) 오전 9:00 에 보냅니다. + +
+
+ +
+

보내기 취소의 흐름

+
    +
  1. [보내기] → 쓰기 화면을 닫고 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(설정한 초 — 마우스를 올려도 시간은 멈추지 않습니다)
  2. +
  3. [보내기 취소] → 서버가 보내지 않고 그 메일을 임시보관함으로 돌려놓습니다 → 쓰기 화면이 그 메일로 다시 열립니다(받는 사람 · 제목 · 본문 · 첨부 그대로)
  4. +
  5. 시간이 지나면 알림이 닫히고 메일은 보낸 것입니다 — 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."
  6. +
  7. 받는 사람 확인은 [보내기]를 누른 순간에 합니다 — 일부가 받지 못하면 그 알림에도 [보내기 취소]가 있어 주소를 고쳐 다시 보낼 수 있습니다
  8. +
  9. 설정 › 메일 › 보내기 · 읽기에서 끔 · 5 · 10 · 20 · 30초 — 끄면 지금처럼 바로 보냅니다
  10. +
+

기다리는 동안에도 보낸편지함에 보입니다(날짜 칸에 "보내는 중"). 다른 화면으로 옮겨 다녀도 알림은 그대로입니다. 로그아웃 · 다른 계정 로그인 때는 알림을 닫습니다 — 메일은 그대로 보내집니다.

+
+
+ +
+ +
+ 받은편지함5 + 보낸편지함 + 예약됨2 + 임시보관함 +

「예약됨」은 예약한 메일이 있을 때만 보낸편지함 아래에 보입니다. 숫자는 예약한 통 수입니다.

+
+ +
+
+

예약됨

+ 2통 · 보낼 시각 차례 +
+ + 받는 사람: 이도윤 + 11월 첫 주 배포 일정 확인 부탁드립니다 + 10월 30일 (금) 오전 9:00 + + + 받는 사람: 인프라팀 외 2명 + [공지] 메일 서버 전환 완료 안내 + 11월 2일 (월) 오전 9:00 + +

고르는 칸 · 여러 통 처리는 없습니다(한 통씩 엽니다). 보낸 메일은 저절로 이 목록에서 빠지고 보낸편지함에 남습니다.

+
+ +
+
+

예약한 메일을 열면

+

읽기 화면 머리 아래 안내 줄

+
+
+ + 10월 30일 (금) 오전 9:00 에 보낼 메일입니다. + + +
+
    +
  • [예약 취소] → 보내지 않고 임시보관함으로 옮깁니다 — 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]
  • +
  • [고치기] → 예약을 취소하고 쓰기 화면으로 엽니다 — 다시 [보내기] 또는 「예약 발송…」
  • +
  • 답장 · 전달 · 스팸 신고 단추는 이 메일에는 없습니다. [삭제]는 예약을 취소한 뒤 휴지통으로 옮깁니다(확인 창)
  • +
+
+ 이도윤 · 11월 첫 주 배포 일정 확인 부탁드립니다 + 예약 · 10월 30일 오전 9:00 +
+

보낸편지함에서도 보입니다 — 날짜 칸이 "예약 · 보낼 시각"(보내기 취소를 기다리는 동안은 "보내는 중")입니다.

+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 3a4384e..7ff5bfe 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -466,6 +466,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록). 사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄. +## 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 (2026-10-29 확정 — `MailReadTools` · `MailPrint` · `MailSendLater` · `SettingsSendRead`) + +작고 자주 쓰는 다섯 가지를 한 번에 더한다. 계약은 `docs/mail/api-contract.md` 2절의 9단계 절들. 새 아이콘: 인쇄(printer) · 시계(clock) · 차단(원 + 빗금). + +### 읽기 화면 — 외부 이미지 · ⋯ 메뉴 · 차단 (`MailReadTools`) + +- **외부 이미지 줄**: 지금의 노란 안내 줄("외부 이미지를 차단했습니다. …") 오른쪽에 [이미지 보기](36 — 밀도 예외 `--size-notice-action`). 누르면 그 메일만 이미지를 보이고 안내 줄은 사라진다. + 다시 열면 설정대로다. 받지 못한 이미지 자리는 비어 있다. 메일 안의 배경 그림은 보이지 않는다(알려진 한계). +- **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.** +- **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글). + 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭. +- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다." + 고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭. + 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소]. +- **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면 + 회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기]. + +### 인쇄 화면 (`MailPrint`) + +- ⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 **새 탭**에 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄운다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않는다. +- 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다. +- 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것 — 숨은참조는 넣지 않는다) → + 첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다. +- 외부 이미지는 읽기 화면에서 보이던 대로. 본문은 인쇄 폭(A4)에 맞춰 높이를 다시 잰 뒤 인쇄한다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않는다 + (2026-10-29 확인: 지금 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다). +- 못 열 때: 메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 [다시 시도] — 인쇄 창은 띄우지 않는다. 세션이 끝났으면 로그인 화면으로. + +### 보내기 취소 · 예약 발송 (`MailSendLater`) + +- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). Ctrl+Enter 는 그대로 [보내기]. + 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙). +- **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각. + 오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]. + 칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠). +- **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다. + 일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게). + [보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다." + 예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다. +- **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례, + 고르는 칸 · 여러 통 처리는 없다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. +- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 — + 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]). 답장 · 전달 · 스팸 신고 단추는 없다. [삭제]는 예약을 취소한 뒤 휴지통으로(확인 창). +- **보낸편지함**: 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00"(청록) — 보내기 취소를 기다리는 동안은 "보내는 중". + +### 설정 · 보내기 · 읽기 · 차단한 주소 (`SettingsSendRead`) + +- **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림. +- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다. + - 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다." + - 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자 + "외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다." +- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다." + - 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." · + "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다." + - 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기]. + 이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -692,3 +749,55 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다). - **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구. 다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다). + +### 9단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다) + +- **외부 이미지**: [이미지 보기]는 받는 동안 꺼진다(`aria-disabled` · `aria-busy`). 받지 못하면 인터셉터가 알리고 줄은 그대로다. 그 메일의 본문 칸만 바꾸고(받는 사이 다른 메일을 열었으면 버린다), + 줄이 사라져 초점을 잃으면 본문 틀로 옮긴다. 본문 틀의 CSP 는 보일 때만 그림에 우리 출처의 중계 경로(`/api/mail-images/`)를 연다 — 경로까지 좁힌다(2026-10-29 리뷰). + 중계 주소로 인정하는 모양은 `/api/mail-images/` 뿐이다(짝: backend `TOKEN_PATTERN`). 답장 · 전달 인용에는 중계 그림도 실리지 않는다. +- **인쇄**: 라우트 `mail-print`(앱 틀 없음 · 로그인 필요), 탭 제목은 메일 제목(인쇄 머리글 · PDF 이름으로 쓰인다). `window.open` 은 누름 처리 안에서 바로 부른다(팝업 차단을 피하려고). + - Ctrl+P(⌘P)는 메일이 다 열려 있을 때만 가로챈다 — 본문 틀 안에서 눌러도 같고, 한글 조합 중 · 다른 수식키 · 불러오는 중이면 브라우저 인쇄 그대로다. 라틴 글자가 아닌 배열은 `code === 'KeyP'` 로도 잡는다. + - 종이 210mm · 여백 16mm 15mm 는 @page 와 같은 값이다(둘을 함께 고친다). 본문 틀의 load(그림까지) · 글꼴 준비 · 한 프레임 뒤에 인쇄 창을 한 번만 띄운다 — + 그사이 다시 받기 시작했으면 띄우지 않고, [인쇄]는 다 잰 뒤에만 눌린다. [닫기]로 닫히지 않는 탭(주소로 바로 들어온 탭)은 메일 화면으로 간다. + - 첨부는 목록에 보이는 것만 "이름 (크기)", 날짜는 "2026년 10월 26일 (월) 오전 10:42". 받기 실패는 화면이 그린다(알림 없음). 문구: "(제목 없음)" · "(보낸 사람 없음)" · 받는 중 "메일을 불러오는 중". +- **보내기 취소 알림**: 마감은 설정한 초를 알면 "지금 + 그 초 − 1초", 모르면 min(sendAt, 지금 + 30초)이다(설정은 메일 화면에 들어올 때 받아 둔다 — 아는 값이 0 인데 서버가 붙잡았으면 sendAt). + 이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 ``(aria-hidden)다. + - 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다. + - 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]. + - 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기]. +- **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로). +- **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다. + 직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.". + 창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 — + 이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로. +- **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다. + 예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다. + - 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다. + 빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.". + - [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." — + 쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로. +- **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고, + 시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다. +- **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`): + - 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고 + 여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글. + - 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) → + 발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게). + - 서버도 막는다(`MAIL_015` — 화면이 막지 못한 때만 보인다): 여러 통 처리는 그 메일만 남기고(`failedIds` · `skippedIds` — 지금처럼), 메일함 지우기 · 비우기는 붙잡힌 메일만 남으면 + 진행 알림이 멈추고 인터셉터 알림 "보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요."(IMAP 으로 옮긴 메일에서만 생긴다 — 4단계 처리의 실패와 같은 모양). +- **기다린 뒤 다음 메일로**: 삭제 · 스팸 · 차단 · 예약 취소처럼 응답을 기다린 뒤 옮기는 일은 그사이에도 같은 목록 · 같은 메일을 보고 있을 때만 한다(3 · 4단계의 단추도 함께 고쳤다 — 2026-10-29 리뷰). +- **차단**: + - 항목이 없는 때: 보낸 사람이 없을 때 · 내 주소 · 사내 도메인(그 하위 포함). 차단 목록은 메일 화면에 들어올 때 알림 없이 받고, 못 받았으면 ⋯ 를 열 때 다시 받는다(모르는 동안은 「보낸 사람 차단」). + 500개면 항목을 끄고(`aria-disabled`) "차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.". + - 창: 고르기 묶음 이름 "차단할 범위", 도메인을 고를 수 없는 까닭 "여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.". 「이 메일도 스팸메일함으로 옮기기」는 이미 스팸메일함이면 숨긴다. + - 옮겼으면 삭제처럼 다음으로 간다. [되돌리기]는 차단을 풀고 **원래 메일함**으로 되돌린다(보드의 "받은편지함" 대신 — 받은편지함 밖에서 막았을 수 있다). + 도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다. + - 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." · + 되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.". +- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯). + 칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다. +- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음). + 성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸. + 엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." — + 차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절). +- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금). diff --git a/docs/design/webmail/SettingsSendRead.dc.html b/docs/design/webmail/SettingsSendRead.dc.html new file mode 100644 index 0000000..b689be1 --- /dev/null +++ b/docs/design/webmail/SettingsSendRead.dc.html @@ -0,0 +1,140 @@ + + + + +설정 · 보내기 · 읽기 · 차단한 주소 + + + + + + + +
+ +
+

설정 · 보내기 · 읽기 · 차단한 주소

+

메일 묶음에 두 화면을 더합니다. 「보내기 · 읽기」는 고르는 대로 저장합니다(저장 단추 없음 — 알림 설정과 같은 결). 이 설정은 서버에 저장되어 어느 PC 에서나 같습니다.

+
+ +
+ + + +
+
+

보내기 · 읽기

+ 저장했습니다 +
+ +
+

보내기 취소

+
+ + + + + +
+

[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.

+
+ +
+ +
+ 외부 이미지 + + + +

외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.

+
+
+ +
+
+
+

차단한 주소

+ 3 / 500 +
+

이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.

+
+ +
+ +
+ + +
+

메일 주소나 @도메인 형식으로 써 주세요.

+
+ +
    +
  • + news@cloudbrief.example10월 29일 차단 + +
  • +
  • + @promo-mail.example도메인 전체 · 10월 21일 차단 + +
  • +
  • + sales@unknown-shop.example10월 18일 차단 + +
  • +
+ +
+ 칸 아래 안내(보드에 없던 경우): "이미 차단한 주소입니다." · "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다." + [차단 풀기]는 바로 풀고 알림 "‘news@cloudbrief.example’ 차단을 풀었습니다." + [되돌리기]. 이미 받은 메일은 옮기지 않습니다. + 차단한 주소가 없으면 목록 자리에 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." +
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 4666f6e..46f295e 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -29,9 +29,13 @@ "MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" }, "Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true }, "ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" }, - "ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" } + "ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" }, + "MailReadTools.dc.html": { "x": 0, "y": 16820, "w": 1440, "h": 1220, "title": "읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단" }, + "MailPrint.dc.html": { "x": 1520, "y": 16820, "w": 1440, "h": 1060, "title": "인쇄 화면" }, + "MailSendLater.dc.html": { "x": 3040, "y": 16820, "w": 1440, "h": 1500, "title": "보내기 취소 · 예약 발송" }, + "SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "title": "설정 · 보내기 · 읽기 · 차단한 주소" } }, - "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", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.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", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html"], "notes": { "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 }, @@ -40,7 +44,8 @@ "phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }, - "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 } + "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }, + "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 4f18f30..d3abaa5 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` `AUTH_005` `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` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `SYS_001` +- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `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` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, @@ -152,6 +152,7 @@ interface MessageSummary { isFlagged: boolean hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다 size: number // 바이트 + pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null } interface MessageDetail extends MessageSummary { @@ -163,7 +164,8 @@ interface MessageDetail extends MessageSummary { references: string[] htmlBody: string | null // 서버가 정화한 HTML (아래 참고) textBody: string | null - hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 + hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 (보이게 했으면 false) + imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정) isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도") isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true attachments: MessageAttachment[] @@ -180,7 +182,7 @@ interface MessageAttachment extends Attachment { **`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`), -외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`. +외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true` (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계). 본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다. 넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다 (인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다. @@ -205,24 +207,24 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` | | `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` | -| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | +| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | -| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | +| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` | | `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` | | `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` | | `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` | -| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` | +| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` | | `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` | | `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` | | `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` | | `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`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?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` | -| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | -| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | -| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | -| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과") | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | +| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` | +| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) | +| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | +| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | +| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | +| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | | `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/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` | @@ -234,6 +236,14 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `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` | +| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` | +| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` | +| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) | +| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — | +| `PATCH /api/mail-settings` | `Partial` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` | +| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` | +| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` | +| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` | 표에 없는 실패 (구현에서 확정): @@ -303,7 +313,8 @@ interface MailQuota { "bodyHtml": "

도윤님, 확인했습니다.

…
", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문") "attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006) "inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다 - "draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") + "draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") + "sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송") } ``` @@ -313,6 +324,7 @@ interface MailQuota { interface SentMessage { id: string // 보낸편지함의 메일 id rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열 + pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null } interface RejectedRecipient { email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번) @@ -332,6 +344,23 @@ interface RejectedRecipient { - 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다. 결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다. - 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로. +- **엔진이 받지 않는 주소**(9단계 리뷰 뒤 — 2026-10-29 실엔진): 발송은 늘 envelope(받는 사람 · 참조 · 숨은참조, 같은 주소는 하나)을 직접 준다. + 엔진은 envelope 에 받지 않는 주소(모르는 최상위 도메인 `x@example.cmo` · 따옴표 앞부분 `"a b"@…` 등)가 있으면 제출 **전체**를 거절하고 그 가운데 첫 주소 하나만 알려 준다. + 서버는 그 주소를 `not-found` 로 빼고 같은 메일을 나머지에게 다시 제출한다 — 붙잡기(9단계)는 그대로이고, 차례마다 **하나씩** 뺀다. + 거절 글이 따옴표로 싼 주소와 정확히 같은 받는 사람부터 찾는다 — 따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다(2026-10-29 리뷰 2). + - 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400 `VAL_001`(21번째를 뺀 뒤 남은 사람이 없으면 `MAIL_010`). + - 남는 사람이 없으면 만든 사본을 지우고 400 `MAIL_010`(아래 "모두 거절"과 같다). + - 다시 낼 시간(발송 55초 안에서 10초 이상)이 모자라면 사본을 지우고 503 `MAIL_001` — 아직 아무것도 제출하지 않았으니 같은 키로 다시 보내면 된다. + 발송 응답은 사본 정리까지 56초 안이다(정리는 남은 시간 안에서, 남은 시간이 없어도 1초까지는 기다린다). + - 다시 낸 제출이 요청째 거절되면(메서드 오류 · HTTP 4xx — 엔진이 실행하지 않았다) 사본을 지우고 첫 제출과 같은 오류로 끝난다. + 결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx)는 지우지 않는다 — 나갔을 수 있어 같은 키의 재시도가 맡는다(첫 제출과 같다). + - 거절 글에 우리 주소가 없으면 지금처럼 400 `VAL_001`. + - 전에는 envelope 을 주지 않아 엔진이 그런 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 오타 도메인으로 간 사람이 응답에 없었다. + - 결과(`deliveryStatus`)를 읽지 못해도 envelope 에서 뺀 사람은 `rejectedRecipients` 에 싣는다. 같은 키의 재시도는 제출 기록의 envelope 에 없는 받는 사람을 `not-found` 로 다시 만든다. + - 주소는 대소문자를 무시하고 국제화 도메인은 `xn--` 꼴로 맞춘다 — 엔진은 유니코드 · `xn--` 모두 받고 제출 기록에는 `xn--` 로 둔다. + 엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서). + - **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다). + 개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다). - **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다), 이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`. 같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다). @@ -977,11 +1006,179 @@ interface MailFilterRun { "창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기]. 탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다. +### 보내기 취소 · 예약 발송 (`POST /api/messages` 의 기다리기 · `/api/scheduled-messages`) — 9단계 + +메일 서버(엔진)가 제출을 잠시 붙잡았다가 보낸다 — `EmailSubmission` 의 FUTURERELEASE(`envelope.mailFrom.parameters` 의 `HOLDFOR` 초 · `HOLDUNTIL` 시각). +붙잡힌 동안은 `undoStatus: pending` 이고, 취소하면(`canceled`) 배달되지 않고, 시간이 되면 보낸다(`final`) — Stalwart 0.16.27, 2026-10-29 확인. +기다리는 곳이 서버라 창을 닫아도 보내진다. 화면은 디자인 README "9단계"(디자인 원본 `MailSendLater`). + +```ts +interface PendingSend { + sendAt: string // 엔진이 보낼 시각(UTC) + kind: 'undo' | 'scheduled' // 보내기 취소를 기다리는 중 · 예약 발송 +} +``` + +- **보내기 취소**: `sendAt` 없이 보내면 서버가 그 직원의 설정(`undoSendSeconds` — 아래 "보내기 · 읽기 설정", 처음 10초)만큼 붙잡는다(`HOLDFOR`). 0 이면 지금처럼 바로 보낸다. + 응답 `pendingSend: { kind: 'undo', sendAt }` — 클라이언트는 남은 시간을 이 시각으로 센다. 다만 PC 시계가 어긋나면 알림이 설정보다 오래 남으므로, + 설정한 초를 알면 "지금 + 그 초 − 조금"을 넘기지 않는다(구현하며 정함 — 2026-10-29 리뷰). +- **예약 발송**: 발송 본문의 `sendAt`(UTC) 까지 붙잡는다(`HOLDUNTIL`) — 지금부터 5분 뒤 이상 · 7일 안. 밖이면 400 `VAL_001`. + `sendAt` 은 `Z` 로 끝나는 UTC 만 받고 소수 초는 버린다. `null` 은 없는 것과 같다(바로 보내거나 보내기 취소 설정대로). + 엔진이 그 시각을 거절하면(`forbiddenMailFrom` — 엔진 한도가 바뀐 경우 등) 400 `VAL_001` 이고 만든 보낸 사본은 지운다(구현하며 정함). + 7일은 엔진 한도다(`x:MtaExtensions` 의 `futureRelease` — 로그인 세션 `7d`. JMAP 능력의 `maxDelayedSend` 는 30일로 보이지만 엔진이 7일 넘게는 거절한다 — 2026-10-29 확인). + 붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이라 7일 안은 안전하다(같은 날 확인). 예약에는 보내기 취소 시간을 더하지 않는다. 응답 `pendingSend: { kind: 'scheduled', sendAt }`. +- **받는 사람 확인은 제출 순간에 한다** — 붙잡아도 엔진이 사내 받는 사람을 그 자리에서 묻는다(없는 사내 주소는 곧바로 `delivered: no` · `550 5.1.2`, 반송 없음 — 2026-10-29 확인). + 그래서 위 "받는 사람별 결과"(일부 거절 `rejectedRecipients` · 모두 거절 `MAIL_010` · `MAIL_011`)는 그대로다. 바깥 주소는 풀린 뒤 배달되고, 실패하면 지금처럼 반송 메일이 온다. + 붙잡은 제출의 결과는 사내 · 바깥 모두 곧바로 `250 2.1.5 Queued` 다(거절된 사람만 `delivered: no`). 모두 거절인데 제출이 아직 기다리고 있으면(모두 4xx 등) + 같은 요청에서 먼저 취소하고 사본을 지운다 — 풀리는 시각에 다시 보내지 않게(취소를 엔진이 받지 않아도 사본은 지우고 warn). 모두 5xx 면 엔진이 붙잡아도 곧바로 `final` 이라 지우기만 한다(같은 날 확인). +- **보낸 사본의 자리**: 제출하면서 지금처럼 보낸편지함으로 옮긴다 — 보내기 전에도 보낸편지함에 보이고 `pendingSend` 로 "보내는 중" · "예약 · 시각"을 보인다. + 예약은 사본에 내부 표시(키워드 `$ara-scheduled` — 목록 · 화면에는 표시로 내보내지 않는다. 보낸 뒤에도 남는다 — 해가 없다)를 붙여 보내기 취소를 기다리는 것과 가른다. + `pendingSend` 는 엔진의 기다리는 제출(`undoStatus: pending`)이면서 **보낼 시각이 아직 오지 않은 것**에만 붙는다(구현하며 정함) — 붙잡지 않은 발송도 대기열에서 배달하는 동안 잠깐 pending 이고, + 보낼 시각이 지나 배달 중인 메일은 `null` 이다(그때 취소하면 `MAIL_014`). 기다리는 제출은 보낼 시각으로 찾는다(`EmailSubmission/query` 의 `filter.after: 지금` — 엔진의 색인을 탄다. + `undoStatus` 조건은 모든 제출을 훑어 계정마다 65~140ms 라 쓰지 않는다). 차례는 보낼 시각 오름차순 — 엔진의 정렬 이름은 `sentAt` 이다(`sendAt` 은 `unsupportedSort` — 2026-10-29 확인). + 한 번에 500개까지 읽고, 닿으면 warn 을 남긴다. 목록 · 상세 · 한 통 바꾸기 응답에 엔진 호출(찾기 · 읽기)이 붙고, 읽지 못하면 `pendingSend: null` 로 준다(표시만 — 옮기기 · 지우기를 막는 확인은 닫힌 쪽으로, 아래). +- **붙잡힌 메일은 취소로만 보낸편지함을 떠난다**(구현하며 정함 — 2026-10-29 리뷰): 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다(지운 메일도 나간다) — 우리 API 로는 그렇게 되지 않게 막는다. + - 한 통 `PATCH /api/messages/{id}` 의 `mailboxId` · `isJunk` 와 `DELETE /api/messages/{id}` 는 409 `MAIL_015`(아무것도 바꾸지 않는다), 여러 통 `PATCH /api/messages` 는 그 id 를 `failedIds` 로, + `POST /api/message-deletions` 는 `skippedIds` 로. 읽음 · 중요 표시는 바꿀 수 있다. + - 메일함 지우기 · 비우기(4단계 "메일함 관리")는 붙잡힌 메일을 그대로 두고 나머지를 처리한다 — 붙잡힌 메일은 `remaining` 에 들고, + 한 요청에서 처리한 것이 없으면(남은 것이 붙잡힌 메일뿐) 409 `MAIL_015` 로 끝난다(지우기는 메일함을 지우지 않는다). 우리 API 로는 붙잡힌 메일이 보낸편지함 밖에 있을 수 없어 IMAP 으로 옮긴 메일에서만 생긴다. + - 이미 받은 메일에 필터 적용(7단계)은 붙잡힌 메일을 옮기지 않고 표시만 한다(`matched` 에는 센다). + - 막을지 볼 때 엔진에 묻지 못하면(오류 · 시간) 바꾸지 않고 503 `MAIL_001` 이다 — 위의 모든 길이 닫힌 쪽으로 실패한다. + 다 받았는지는 응답의 `total`(`calculateTotal`)로 정하고, 모르면 다 보지 못한 것으로 본다(2026-10-29 리뷰 2 — 요청한 limit 으로 정하지 않는다). + 기다리는 제출을 찾는 조회가 500개에 닿으면 그 메일 id 로 다시 묻고(같은 "지금 뒤" 조건), 그것도 넘치면 `MAIL_001`. + 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다(화면 표시 `pendingSend` 는 그때 null). + - **알려진 한계**: IMAP · 다른 JMAP 프로그램이 옮기거나 지우는 것은 막지 못한다 — 엔진은 그래도 시간이 되면 보낸다. + - 클라이언트는 붙잡힌 메일을 목록에서 고르지 못하게 하고(고르기 칸 `aria-disabled` + 까닭 — 모두 고르기 · 범위도 건너뛴다), + 읽기 화면에는 옮기기 · 스팸 단추를 두지 않는다 — [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 옮긴다(보내기 취소를 기다리는 메일도 같다). + `MAIL_015` 는 인터셉터 알림(메일함 처리는 진행 알림도 거기서 멈춘다 — 4단계와 같은 결). +- `GET /api/scheduled-messages` — 예약한 메일(`kind: 'scheduled'` 이고 아직 보내지 않은 것)을 보낼 시각 차례로. 화면은 `pageInfo.total` 로 메일함 패널의 「예약됨」(있을 때만)을 보인다 — + 메일 화면에 들어올 때 · 보내거나 취소한 뒤 · 새 메일 확인(3단계) · 실시간 알림(5단계)이 바뀜을 알릴 때 다시 묻는다. 보낸 메일은 저절로 빠진다(엔진의 제출이 `final`). + 엔진이 풀어도 새 메일 확인 · 실시간 알림의 상태(메일함 · 메일)는 바뀌지 않는다 — 그래서 화면은 보이는 목록 · 열린 메일 · 「예약됨」 첫 항목 가운데 가장 이른 `sendAt` + 2초에 + 보이는 목록 · 열린 메일 · 「예약됨」 수를 다시 맞추고, 지난 시각이 아직 보이면 5초마다 몇 번 더 본다. 취소 · 고치기가 `MAIL_014` 를 받았을 때도 같다(구현하며 정함 — 2026-10-29 리뷰). +- `DELETE /api/scheduled-messages/{id}`(`id` 는 보낸 사본의 메일 id) — 붙잡은 발송을 취소한다(보내기 취소 · 예약 취소 모두). 엔진에서 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨 + 초안으로 되돌린다(`$draft` 와 취소 표시 `$ara-canceled` 를 붙이고 예약 표시를 뗀다 — 발송 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자 초안이다) → + 200 `{ draftId }`(같은 메일 id — 쓰기 화면이 이 id 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다. + 이어 쓸 때(구현하며 정함): 서명 그림 부분(Content-ID `sig-…`)은 첨부에서 빼고 본문의 `cid:sig-…` 는 서명 그림 주소로 되돌린다(보낼 때 다시 `cid:` 로 — "서명"), + 답장 대상은 같은 스레드에서 `In-Reply-To` 로 찾아 초안의 `inReplyTo` 로 준다. + - 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409 `MAIL_014` — 아무것도 바꾸지 않는다. 없는 메일은 404 `RES_001`. + - **다시 취소해도 된다**(2026-10-29 리뷰 — 응답이 끊겨 다시 누른 경우): 그 메일의 제출이 모두 `canceled` 이고 취소 표시(`$ara-canceled`)가 있으면 **어느 메일함에 있든** 그대로 200 `{ draftId }`(같은 id — + 옮기지 않는다: 사용자가 그 초안을 휴지통에 버린 뒤 늦게 온 취소가 되살리지 않게), 표시가 없으면(앞의 옮기기가 실패) 되돌리기를 다시 한다 — 그래도 못 하면 503 `MAIL_001`. + - 엔진이 취소를 확인하지 않으면(응답의 `updated` 에 그 제출이 없음 — 거절도 아님) 옮기지 않고 503 `MAIL_001` — 옮기면 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다. + - 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다). + - 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` — + 그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그). +- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다. + 서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다. + 붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다. + 같은 키의 메일을 찾으면 그 제출을 늘 읽는다(엔진 호출 1회 더 — 제출이 모두 `canceled` 면 위의 `MAIL_005`). +- 보낸 뒤 이어 쓴 저장본(`draftId`) 지우기는 지금처럼 한다 — 취소하면 붙잡혀 있던 사본이 새 초안이 된다. +- 붙잡은 메일은 엔진을 다시 띄워도 남는다(엔진의 대기열은 저장소에 있다). 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진에 묻는다. +- **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다. +- **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다). + 일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.". + `MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다. + +### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계 + +받은 메일의 외부 이미지는 지금처럼 지운 채 준다(`hasBlockedImages`). 보이게 할 때는 서버가 그 메일의 이미지 주소를 **중계 주소**로 바꿔 넣고, 그 주소로 오는 요청에 +서버가 바깥 이미지를 대신 받아 준다 — 보낸 쪽 서버에는 직원 PC 의 주소 · 브라우저가 드러나지 않고(우리 서버의 주소만), 화면의 CSP(`img-src 'self' data:`)도 그대로다. +보낸 쪽은 이미지를 받은 시각으로 메일을 열어 본 것을 알 수 있다 — 그래서 처음 값은 "늘 묻기"다. 화면은 디자인 README "9단계"(디자인 원본 `MailReadTools` · `SettingsSendRead`). + +- `GET /api/messages/{id}?images=show` — 외부 이미지를 보이는 본문(`hasBlockedImages: false` — srcset · 배경 그림 · 한도 밖처럼 지운 것이 있어도). + `imagesShown` 은 실제로 바꿔 넣은 그림이 있을 때만 true 다 — 외부 이미지가 없는 메일은 `images=show` 여도 false(구현하며 정함). + 쿼리 없이 물으면 설정(아래 `externalImages`)을 따른다: `ask` 는 지금처럼 지우고, `trusted` 는 보낸 사람이 내 주소록(3절)이나 사내 목록(직원 · 공용 주소 — 3절 "사내 목록")에 있으면 보이고, + `always` 는 늘 보인다. **스팸메일함의 메일은 설정과 상관없이 지운다**(`images=show` 로만 보인다). `MessageDetail.imagesShown` 이 그 결과다. + `trusted` 에서 주소록 · 사내 목록을 읽지 못하면 보이지 않는 쪽으로 다룬다. 머리글 From 은 꾸밀 수 있다(알려진 한계 — 꾸며도 중계라 직원 PC 의 주소는 드러나지 않는다). +- 바꾸는 것은 `` 의 `src`(http · https · `//`)뿐이다 — `srcset` 은 지금처럼 지우고, style · 속성의 배경 그림(`url()`)도 지금처럼 지운다(알려진 한계). + 주소 2,048자 이하만, 한 통에 100개까지(그 뒤는 지운다). 같은 주소는 한 통 안에서 같은 중계 주소를 쓴다(브라우저가 한 번만 받는다). 중계 주소는 `/api/mail-images/<표>` — 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 **암호화한**(인증 암호 AES-GCM — + 고친 표는 풀리지 않는다) 불투명 글이다. 서명만 하면 표를 풀어 원래 주소(추적 번호가 든다)를 읽을 수 있어 접근 로그 · 주소창에 남는다 — 그래서 암호화한다. + 키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 404 이고, 메일을 다시 열면 새 표를 받는다. ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다. +- `GET /api/mail-images/{token}` — 로그인 필요(쿠키), 표의 직원과 같아야 한다. 서버가 바깥 이미지를 받아 그대로 준다(표준 래퍼 없음): + - 주소: http · https, 기본 포트(80 · 443)만. 이름을 풀어 **모든 주소가 공인 주소일 때만**, 푼 그 주소로 연결한다(다시 풀지 않는다 — DNS 바꿔치기를 막는다). + 막는 곳: 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) · 멀티캐스트 · 예약 · 문서용 대역, IPv6 의 같은 것(::1 · fc00::/7 · fe80::/10 · ff00::/8 · IPv4 대응 · NAT64) — + 우리 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)도 사설이라 막힌다(메일 엔진 관리 API · DB 를 부르게 만드는 공격). + 점 없는 이름(도커 서비스 이름 등)은 묻지 않고 막는다. 이름은 c-ares(`dns.Resolver`)로 푼다 — libuv 스레드 풀을 막지 않게(구현하며 정함). + - 다시 가기(redirect)는 3번까지, 매번 위 검사. 전체 10초, 받는 크기 5MB(넘으면 끊는다). + - 형식은 받은 바이트의 앞부분으로 정한다 — PNG · JPEG · GIF · WebP 만(SVG · 그 밖은 거절). 바깥이 보낸 `Content-Type` 은 믿지 않는다. + - 바깥으로는 쿠키 · Referer 없이, 고정한 User-Agent 로 묻는다. + - 응답: 받은 바이트 + 판정한 `Content-Type` · `Cache-Control: private, max-age=3600` · `X-Content-Type-Options: nosniff` · `Content-Disposition: inline` · `Content-Security-Policy: default-src 'none'; sandbox`. + - 실패는 모두 404 `RES_001` — 까닭을 주지 않는다(표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다. + 이 404 는 전역 필터를 거치지 않고 컨트롤러가 직접 쓴다 — 형식 · `traceId` 는 같고, 로그는 debug 만(바깥 탓의 실패가 잦아 warn 이면 알림이 쌓인다 — 구현하며 정함). + - 동시에 받는 수는 서버 전체 16개 · 한 직원 6개까지(넘으면 기다린다 — 30초 · 200개까지, 넘으면 404). 기다리는 줄은 직원마다이고, 서버 자리가 나면 + 자기 6개에 막히지 않은 직원 가운데 지금 받는 수가 가장 적은 직원의 맨 앞 요청부터 든다(같으면 먼저 기다린 쪽 — 몇 사람이 서버 자리를 다 차지하지 못하게, 2026-10-29 리뷰 2). + 자리는 응답을 다 보낼(또는 브라우저가 끊을) 때까지 쥔다 — 메모리에 쥐는 그림은 많아야 16 × 5MB(구현하며 정함 — 2026-10-29 리뷰). + 요청 수 한도는 이 경로만 넉넉하게 — IP 마다 1분 600회(뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT). + - 로그: 주소는 남기지 않는다(추적 번호 · 개인 정보가 들어 있다) — 실패 까닭과 바깥 호스트 이름만 debug. 요청 주소를 남기는 로그의 `/api/mail-images/<표>` 는 글자 수만 남긴다(`maskUrlForLog`). +- 화면: 본문 틀의 내부 CSP 를 보일 때만 `img-src data: <화면 주소>/api/mail-images/` 로 넓히고(중계 경로만 — `'self'` 면 정화를 빠져나간 CSS `url()` 이 같은 출처의 다른 API 를 부를 수 있다, 2026-10-29 리뷰), + 클라이언트 정화(DOMPurify)도 `/api/mail-images/` 로 시작하는 주소만 남긴다(그 밖의 바깥 주소는 지금처럼 지운다). + 답장 · 전달 인용에는 이미지가 실리지 않는다(보내는 정화가 서명 그림 밖의 `img` 를 지운다 — 중계 주소가 바깥으로 나가지 않는다). +- **운영 확인**: 백엔드 컨테이너가 바깥 80 · 443 으로 나갈 수 있어야 한다(호스트 방화벽 · 프록시) — 막혀 있으면 이미지 자리가 비어 보인다. + 막기는 사설 대역에 기대므로 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)은 사설 대역으로 둔다. 서버 자신의 공인 주소로 풀리는 이름은 공인 주소라 막지 않는다 — + 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다(2026-10-29 리뷰, 운영 절차는 docker 스킬). + +### 인쇄 — 9단계 + +API 는 새로 없다. 인쇄 화면(새 탭 — 메일 한 통, 디자인 원본 `MailPrint`)은 `GET /api/messages/{id}` 로 그린다 — 읽기 화면에서 외부 이미지를 보이고 있었으면 `images=show`. + +### 보낸 사람 차단 (`/api/mail-blocks`) — 9단계 + +```ts +interface BlockedSender { + address: string // 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로) + blockedAt: string // 차단한 시각(UTC) +} +interface MailBlockList { + senders: BlockedSender[] // 최근에 차단한 것부터, 500개까지 + isActive: boolean // 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다 +} +``` + +- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. + 이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`). +- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰), + 도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다). + 이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`). + 내 주소와 사내 도메인(`MAIL_DOMAIN` 과 그 하위)은 막을 수 없다. + 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com · daum.net · hanmail.net · kakao.com · nate.com · outlook.com · hotmail.com · live.com · yahoo.com · icloud.com 등 — 목록은 서버 상수)는 + 도메인으로 막을 수 없다(주소로는 된다). 클라이언트도 같은 목록을 먼저 본다(짝 — 한쪽을 고치면 둘 다). 500개를 넘으면 400 `VAL_001`. +- **저장 자리**: 필터 · 부재중과 같은 Sieve 스크립트(`arasystem` — 6단계)의 맨 앞. 머리 주석의 원본(JSON)에 `blocked`(주소 · 차단한 시각)를 더하고, 원본 판은 **차단한 주소가 있을 때만 2** 로 쓴다(없으면 지금처럼 1). + 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. 판 2 를 모르는 옛 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 로 막힌다 + (옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 되돌리기 계획: 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다. + 만든 Sieve(스팸 표시 감싸기 밖 · 필터 · 부재중 앞): `if anyof (address :all :is "from" [주소들], address :domain :is "from" [도메인들]) { fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>"; stop; }`. + 비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이다 — 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없어 `:comparator` 를 뺐다(구현하며 정함 — + 주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다, 한계). `fileinto` 의 대체 이름은 실제 스팸메일함 이름이다(비었거나 `${` 가 들어 있으면 "Junk"). 스팸메일함이 없으면 503 `MAIL_001`. + 실제 배달로 확인했다(2026-10-29, 38가지 — 대소문자를 섞은 주소 · 도메인은 스팸메일함으로, 하위 도메인 · 이름만 같은 다른 도메인 · 차단하지 않은 주소는 손대지 않음, + 차단이 있어도 사내 직원의 메일은 받은편지함. `SieveScript/validate` 는 모르는 test 이름을 걸러 주지 않아 배달로 본다). +- 스크립트 크기 한도(90,000바이트)를 필터와 함께 쓴다 — 넘으면 400 `MAIL_013`(사전 문구가 필터 · 차단 목록을 함께 말한다). +- 쓰기는 필터처럼 읽은 상태 그대로일 때만(`ifInState`) · 어긋나면 다시 읽어 세 번까지. 차단은 필터의 `version` 을 묻지 않는다(주소 하나를 더하고 빼는 일이라 그사이 바뀐 필터를 덮지 않는다). +- 로그: 주소는 가린다(`maskEmail` — 도메인 차단은 도메인만). +- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체, + 「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다. + 스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다. + +### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계 + +직원마다의 설정을 서버 DB 에 둔다(표 `mail_settings` — 직원 주소마다 한 줄, 없으면 처음 값). 어느 PC 에서나 같다. + +```ts +interface MailSettings { + undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10 + externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask' +} +``` + +- 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다). + 서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함). +- 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음). + 칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰). + "저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다. + ### 이 단계에서 하지 않는 것 -탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), +탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로), 여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조, -끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄. +끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, +예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만). ---