diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md
index 8a4b88c..6300747 100644
--- a/.claude/skills/docker/SKILL.md
+++ b/.claude/skills/docker/SKILL.md
@@ -124,6 +124,12 @@ sudo docker compose --env-file .env.production logs -f backend
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
+ 외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
+ - **백엔드의 바깥 연결**(운영 확인 항목, 9단계): 외부 이미지 중계는 backend 컨테이너가 바깥 80 · 443 으로 나가 이미지를 받아 온다 — 호스트 방화벽 · 사내 프록시가 막으면
+ 이미지 자리가 비어 보인다(다른 기능은 영향 없음). 사설 · 내부 주소로는 백엔드가 스스로 나가지 않는다(SSRF 막기 — 계약 2절 "외부 이미지 보기").
+ 그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다).
+ 서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다
+ (메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰).
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md
index 6f219c3..335512a 100644
--- a/.claude/skills/nestjs/SKILL.md
+++ b/.claude/skills/nestjs/SKILL.md
@@ -36,6 +36,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
| 사내 목록(로그인 기록) | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록한다 — 8단계부터는 엔진의 사내 목록(아래 주소록)을 **읽지 못할 때만** 받는 사람 추천의 "사내"를 대신한다. 직원 역할에는 엔진의 계정 목록(Principal) 권한이 없다(일부러 막아 두었다) |
| 주소록 (8단계) | `modules/contacts` | 내 주소록(연락처 · 그룹)과 사내 목록 — 계약 3절. 컨트롤러 `contacts.controller.ts` · `contact-groups.controller.ts` · `directory-entries.controller.ts`, 서비스 `contacts.service.ts`(직원마다 주소록 캐시 `contact-book-cache.ts` — 엔진 state 로 확인(`ContactCard/get` 빈 ids, 3ms, 판은 읽기 전에 물은 것), 200명 · 모두 2,400만 · 한 사람 800만 · 30분, 같은 직원의 바꾸기는 차례로(`writeQueues`), 엔진에는 `ifInState` — 어긋남 · `serverUnavailable` 은 다시 읽어 세 번까지, 넘으면 `MAIL_001`. 쓰기의 크기 한도 800만(`MAX_ADDRESS_BOOK_WEIGHT` — 캐시의 한 사람 상한과 같은 값, 넘게 커지는 쓰기는 `CONTACT_002`). 고치기는 `PATCH`(보낸 칸만 검사) — 칸 안의 남의 속성은 어댑터 `contact-card.ts` 의 `contactPatch` 가 지금 카드와 합쳐 지킨다. 되돌리기 표 `contact-restore-store.ts`(지우기 직전에 읽은 카드 전체 — 5분 · 직원마다 20개 · 256KB · 모두 32MB) + `contact-restorations.controller.ts`) · `directory.service.ts`(사내 목록 10분 캐시, 거르기 `directory-policy.ts` — 로그인 막기와 같은 `isEmployeeAddress` + 설명의 `[setup.mjs]`). 순수 함수 `address-book.ts` · `contact-search.ts`(초성 · 겹자음 · 전화 숫자 · NFC) · `contact-paging.ts`(위치 커서 — `shared/utils/offset-cursor.ts`, 받는 사람 추천 커서도 이것), 경로 id `contact-id.pipe.ts`, 빈 글 → null `TrimStringOrNull()`. 포트 `contacts.provider.ts`(`CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER`) — 엔진 어댑터는 `modules/mail/jmap/` 의 `jmap-contacts.provider.ts` · `contact-card.ts`(JSContact ↔ 도메인) · `jmap-directory.provider.ts`(`JmapClient.callDirectory()` — **사내 목록 계정만** 대리 접근). 엔진 교체는 `contacts.module.ts` 의 useClass 만. 모듈 순환을 막으려고 `MAIL_SETTINGS` · `JmapClient` 는 `modules/mail/mail-engine.module.ts` 에 둔다(메일 → 주소록 방향만, 인증 모듈은 이것을 가져온다). 사내 목록 계정(`MAIL_DIRECTORY_LOCAL_PART`, 기본 `mail-directory`)은 setup 이 복구 관리자로만 만든다 — `mail.settings.ts` 의 막는 목록에도 들어 있다 |
+| 보내기 취소 · 예약 발송 (9단계) | `modules/mail/jmap/pending-send.ts` · `jmap/envelope.ts` · `scheduled-message.controller.ts` · `scheduled-messages.service.ts` | 기다리는 곳은 엔진이다 — 제출의 FUTURERELEASE(`HOLDFOR` · `HOLDUNTIL`, 엔진 한도 7일), 취소는 `undoStatus: canceled` 뒤 그 메일을 임시보관함으로(`$draft` + `$ara-canceled`, 다시 취소해도 200). `pending-send.ts` 는 붙잡기 매개변수 · 기다리는 제출 찾기(`EmailSubmission/query` 의 `filter.after: 지금` — `undoStatus` 조건은 모든 제출을 훑는다, 정렬 이름 `sentAt`) · `pendingSend` 판정(pending 이면서 보낼 시각이 아직)이다. **발송은 늘 envelope 을 직접 준다**(`envelope.ts`) — 엔진이 받지 않는 주소(모르는 최상위 도메인 등)가 있으면 제출 전체를 거절하고 첫 주소 하나만 알려 주므로, 어댑터가 그 주소를 not-found 로 빼고 같은 메일을 다시 제출한다(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지 · 발송 55초 안). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 한 통 · 여러 통 `PATCH`(옮기기 · 스팸) · `DELETE` · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고(`pending-send.ts` 의 `readHeldEmails` — 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않고 `MAIL_001`, 닫힌 쪽으로), 막으면 `MAIL_015` · `failedIds` · `skippedIds`. 예약 표시 키워드 `$ara-scheduled` — 계약 2절 "보내기 취소 · 예약 발송" · "받는 사람별 결과" |
+| 외부 이미지 중계 (9단계) | `modules/mail/images/` | `GET /api/mail-images/{token}`. 표 `mail-image-token.ts`(AES-256-GCM — 직원 · 원래 주소 · 1시간, 키는 프로세스가 뜰 때 무작위. 표 모양은 **짝: frontend `shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`**), 주소 검사 `mail-image-url.ts` + `shared/utils/public-address.ts`(모든 주소가 공인일 때만 · 점 없는 이름은 묻지 않고 막는다), 받기 `mail-image-fetcher.ts`(다시 가기 3번 · 10초 · 5MB — Content-Length 가 있으면 그 크기로 한 번 잡는다), 연결 `node-image-transport.ts`(푼 주소로 고정 연결 · Host 는 원래 이름 · 쿠키 · Referer 없음, 이름 풀기는 c-ares `dns.Resolver` — 80 · 443 고정, 다른 포트 · DNS 서버는 테스트의 `forTesting()` 으로만), 형식 `mail-image-format.ts`(앞 바이트로 PNG · JPEG · GIF · WebP), 차례 `mail-image-proxy.service.ts`(서버 16 · 직원 6, 30초 · 200개까지 기다림 — 줄은 직원마다이고 받는 수가 가장 적은 직원부터 든다, 자리는 응답이 끝날 때까지 `releaseWhenDone`), `mail-image.controller.ts`(실패는 모두 404 를 직접 쓴다 — debug 로그만, IP 마다 1분 600회). 본문의 `
` 를 중계 주소로 바꾸는 것은 `html/sanitize-mail-html.ts`(`externalImageSrc`) — 계약 2절 "외부 이미지 보기" |
+| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -238,7 +241,10 @@ SwaggerModule.setup('api-docs', app, document);
> 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈),
> 주소록의 직원마다 캐시 · 바꾸기 차례 · 사내 목록 10분 캐시(`contacts.service.ts` · `directory.service.ts` — 캐시는 엔진 state 로 확인하고 쓰기는 `ifInState` 라 틀리지는 않는다.
> 인스턴스마다 따로 읽어 엔진 읽기가 늘고, 같은 직원의 동시 바꾸기가 어긋나 다시 하는 일이 잦아질 뿐 → 필요하면 잠금 · 공유 캐시),
- > 주소록의 되돌리기 표(`contact-restore-store.ts` — 그대로면 다른 인스턴스로 간 [되돌리기]가 404 `RES_001` 이다 → 공유 저장소에 5분짜리 항목으로):
+ > 주소록의 되돌리기 표(`contact-restore-store.ts` — 그대로면 다른 인스턴스로 간 [되돌리기]가 404 `RES_001` 이다 → 공유 저장소에 5분짜리 항목으로),
+ > 외부 이미지 표의 암호 키(`mail-image-token.ts` — 프로세스가 뜰 때 무작위라 그대로면 다른 인스턴스로 간 이미지 요청이 모두 404 → 공유 비밀(env)로),
+ > 외부 이미지의 동시 수(`mail-image-proxy.service.ts` — 서버 16 · 직원 6 을 인스턴스마다 따로 센다. 틀리지는 않고 바깥으로 나가는 수 · 메모리가 인스턴스 수만큼 는다 → 필요하면 공유 셈),
+ > 보내기 · 읽기 설정의 60초 기억(`mail-user-settings.service.ts` — 다른 인스턴스에서 바꾸면 60초까지 옛 값(보내기 취소 초 · 외부 이미지)을 쓴다 → 무효화 알림이나 공유 캐시):
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -399,4 +405,7 @@ dc exec backend npm uninstall <패키지>
**이미 받은 메일에 필터 적용**(7단계)은 서버가 엔진 Sieve 의 판정을 따라 한다 — 필터의 칸 · 견줄 곳 · 비교 방법을 바꾸면 `sieve-script.ts` 의 `FIELD_TESTS` ·
`mail-filter-match.ts` · `jmap/filter-run-texts.ts`(· HTML 은 `html/html-text.ts`)를 함께 고치고 실배달로 다시 맞춘다(엔진의 규칙은 계약 2절 "이미 받은 메일에 적용").
메일을 지우지 않는다 — `Email/set update` 만 쓴다(옮기기는 `ifInState` — 어긋나면 다시 확인, 표시는 조건 없이).
+ **붙잡힌 메일**(9단계 — 보내기 취소 · 예약을 기다리는 보낸 사본)을 옮기거나 지우는 새 경로를 만들면 `pending-send.ts` 의 `readHeldEmails` 로 먼저 묻고 막는다
+ (`MAIL_015` · `failedIds` · `skippedIds` — 읽지 못하면 바꾸지 않고 `MAIL_001`). 엔진은 옮기거나 지운 메일도 시간이 되면 보낸다.
+ **발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인).
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md
index 12db125..5fe3411 100644
--- a/.claude/skills/vue3/SKILL.md
+++ b/.claude/skills/vue3/SKILL.md
@@ -37,7 +37,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
-| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
+| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
@@ -60,6 +60,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
+| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
+| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
+| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts
index e8d95b3..5f22c7a 100644
--- a/backend/src/common/errors/error-codes.spec.ts
+++ b/backend/src/common/errors/error-codes.spec.ts
@@ -64,6 +64,19 @@ describe('에러 코드 레지스트리', () => {
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
});
+ it('MAIL_013 의 문구는 필터와 차단 목록(9단계)을 함께 말한다 — 같은 엔진 규칙의 한도라서', () => {
+ expect(ERROR_CODES.MAIL_013.message).toContain('필터');
+ expect(ERROR_CODES.MAIL_013.message).toContain('차단');
+ });
+
+ it('이미 보낸 · 기다리는 발송이 아닌 메일의 취소(MAIL_014)는 409 다 (9단계)', () => {
+ expect(ERROR_CODES.MAIL_014.status).toBe(HttpStatus.CONFLICT);
+ });
+
+ it('붙잡힌 메일을 옮기거나 지우기(MAIL_015)는 409 다 — 취소로만 보낸편지함을 떠난다 (9단계)', () => {
+ expect(ERROR_CODES.MAIL_015.status).toBe(HttpStatus.CONFLICT);
+ });
+
it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => {
expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT);
expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT);
diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts
index 265b83a..9331cb8 100644
--- a/backend/src/common/errors/error-codes.ts
+++ b/backend/src/common/errors/error-codes.ts
@@ -155,11 +155,26 @@ const DOMAIN_ERROR_CODES = {
message:
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
},
- // 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기)
+ // 필터 · 부재중 응답 · 보낸 사람 차단(9단계)으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다
+ // (계약 2절 "메일 필터"의 크기 · "보낸 사람 차단"). 뜻은 6단계 그대로이고 문구만 차단 목록을 함께 말한다
MAIL_013: {
status: HttpStatus.BAD_REQUEST,
message:
- '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
+ '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
+ },
+ // 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
+ // 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
+ MAIL_014: {
+ status: HttpStatus.CONFLICT,
+ message: '이미 보낸 메일이라 취소할 수 없습니다.',
+ },
+ // 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)의 보낸 사본을 옮기거나 지우려 했다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다
+ // (옮기거나 지워도 엔진은 시간이 되면 보낸다). 아무것도 바꾸지 않았다. 여러 통 처리는 이 코드 대신 그 id 를 failedIds · skippedIds 로 돌려준다
+ // (계약 2절 "보내기 취소 · 예약 발송")
+ MAIL_015: {
+ status: HttpStatus.CONFLICT,
+ message:
+ '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
},
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
diff --git a/backend/src/migrations/1793255372738-AddMailSettings.ts b/backend/src/migrations/1793255372738-AddMailSettings.ts
new file mode 100644
index 0000000..375995a
--- /dev/null
+++ b/backend/src/migrations/1793255372738-AddMailSettings.ts
@@ -0,0 +1,21 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * 보내기 · 읽기 설정 표 (docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" — 9단계).
+ * - mail_settings: 직원 주소마다 한 줄 — 보내기 취소 초(undo_send_seconds) · 외부 이미지(external_images). 없으면 처음 값(10 · ask)
+ * 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory)는 건드리지 않는다.
+ * (자동 생성: npm run migration:generate — 엔티티는 modules/mail-user-settings/mail-user-settings.entity.ts)
+ */
+export class AddMailSettings1793255372738 implements MigrationInterface {
+ name = 'AddMailSettings1793255372738';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "mail_settings" ("user_email" character varying(254) NOT NULL, "undo_send_seconds" smallint NOT NULL, "external_images" character varying(16) NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_b2ca6c2963fd9eca971baa44421" PRIMARY KEY ("user_email"))`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE "mail_settings"`);
+ }
+}
diff --git a/backend/src/modules/contacts/contacts.service.spec.ts b/backend/src/modules/contacts/contacts.service.spec.ts
index 3c65f50..381ba9c 100644
--- a/backend/src/modules/contacts/contacts.service.spec.ts
+++ b/backend/src/modules/contacts/contacts.service.spec.ts
@@ -323,6 +323,24 @@ describe('ContactsService (주소록)', () => {
book.seedGroup('g1', '거래처', ['urn:uuid:c-pa', 'urn:uuid:gone']);
});
+ it('hasEmail — 내 주소록의 연락처 주소인지(대소문자 · 앞뒤 공백 무시), 판을 확인한 주소록에서 (9단계 외부 이미지)', async () => {
+ await expect(
+ service.hasEmail(ARA.email, ' jihoon@HANBIT.example '),
+ ).resolves.toBe(true);
+ await expect(
+ service.hasEmail(ARA.email, 'someone@hanbit.example'),
+ ).resolves.toBe(false);
+
+ // 다른 곳에서 더한 연락처도 판이 바뀌었으니 바로 맞는다
+ book.seedContact('c-new', {
+ emails: [{ address: 'someone@hanbit.example', label: 'work' }],
+ });
+ book.touch();
+ await expect(
+ service.hasEmail(ARA.email, 'someone@hanbit.example'),
+ ).resolves.toBe(true);
+ });
+
it('이름 차례 · total 포함 — 연락처의 그룹은 이 주소록의 그룹만', async () => {
const page = await service.list(ARA, listQuery());
diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts
index b27638b..a13e2ad 100644
--- a/backend/src/modules/contacts/contacts.service.ts
+++ b/backend/src/modules/contacts/contacts.service.ts
@@ -867,6 +867,16 @@ export class ContactsService {
return (await this.freshBook(userEmail)).suggestionLines;
}
+ /**
+ * 그 주소(대소문자 무시)의 연락처가 내 주소록에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 "외부 이미지 보기" — 9단계).
+ * 판을 확인한 주소록에서 본다 (연락처를 바꾸면 바로 맞는다)
+ */
+ async hasEmail(userEmail: string, address: string): Promise {
+ return (await this.freshBook(userEmail)).contactsByEmail.has(
+ emailKey(address),
+ );
+ }
+
/** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */
private async groupMemberEmails(
userEmail: string,
diff --git a/backend/src/modules/contacts/directory.service.spec.ts b/backend/src/modules/contacts/directory.service.spec.ts
index e5abaaf..cfabcc2 100644
--- a/backend/src/modules/contacts/directory.service.spec.ts
+++ b/backend/src/modules/contacts/directory.service.spec.ts
@@ -90,6 +90,24 @@ describe('DirectoryService (사내 목록)', () => {
afterEach(() => jest.restoreAllMocks());
+ it('hasEmail — 사내 목록(직원 · 공용 주소)의 주소인지(대소문자 · 앞뒤 공백 무시) — 빼는 계정은 아니다, 같은 기억을 쓴다 (9단계 외부 이미지)', async () => {
+ await expect(service.hasEmail(' Doyun.Lee@AraSystem.test ')).resolves.toBe(
+ true,
+ );
+ await expect(service.hasEmail('sales@arasystem.test')).resolves.toBe(true);
+ await expect(service.hasEmail('admin@arasystem.test')).resolves.toBe(false);
+ await expect(service.hasEmail('nobody@arasystem.test')).resolves.toBe(
+ false,
+ );
+ expect(listAccounts).toHaveBeenCalledTimes(1);
+
+ listAccounts.mockRejectedValue(new BusinessException('REQ_001'));
+ jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS);
+ expect(
+ (await failureOf(service.hasEmail('doyun.lee@arasystem.test'))).code,
+ ).toBe('MAIL_001');
+ });
+
it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => {
const page = await service.list(query());
diff --git a/backend/src/modules/contacts/directory.service.ts b/backend/src/modules/contacts/directory.service.ts
index ddba1f5..56f4118 100644
--- a/backend/src/modules/contacts/directory.service.ts
+++ b/backend/src/modules/contacts/directory.service.ts
@@ -18,6 +18,8 @@ import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy';
interface DirectoryListing {
readonly entries: readonly DirectoryEntry[];
readonly byId: ReadonlyMap;
+ /** 주소(소문자) — 외부 이미지의 "사내 직원" 판정 (9단계) */
+ readonly emails: ReadonlySet;
readonly expiresAt: number;
}
@@ -101,6 +103,14 @@ export class DirectoryService {
return pageOf(matched, start, query.limit, scope);
}
+ /**
+ * 그 주소(대소문자 무시)가 사내 목록(직원 · 공용 주소)에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 — 9단계).
+ * 읽지 못하면 MAIL_001 (부르는 쪽이 없는 것으로 본다)
+ */
+ async hasEmail(address: string): Promise {
+ return (await this.listing()).emails.has(address.trim().toLowerCase());
+ }
+
/** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */
async findById(id: string): Promise {
const [entry] = await this.findByIds([id]);
@@ -132,6 +142,7 @@ export class DirectoryService {
const listing: DirectoryListing = {
entries,
byId: new Map(entries.map((entry) => [entry.id, entry])),
+ emails: new Set(entries.map((entry) => entry.email.toLowerCase())),
expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS,
};
this.cached = listing;
diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts
new file mode 100644
index 0000000..e52d968
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts
@@ -0,0 +1,49 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+
+import { MailUserSettingsPatchDto } from './mail-user-settings.dto';
+
+/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
+async function constraintsOf(body: object): Promise {
+ const errors = await validate(
+ plainToInstance(MailUserSettingsPatchDto, body),
+ {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ },
+ );
+ return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
+}
+
+describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', () => {
+ it('고를 수 있는 값이면 통과한다 — 한 칸만 보내도 된다 (빈 본문은 서비스가 VAL_001)', async () => {
+ for (const undoSendSeconds of [0, 5, 10, 20, 30]) {
+ expect(await constraintsOf({ undoSendSeconds })).toEqual([]);
+ }
+ for (const externalImages of ['ask', 'trusted', 'always']) {
+ expect(await constraintsOf({ externalImages })).toEqual([]);
+ }
+ expect(
+ await constraintsOf({ undoSendSeconds: 20, externalImages: 'trusted' }),
+ ).toEqual([]);
+ expect(await constraintsOf({})).toEqual([]);
+ });
+
+ it('고를 수 없는 초 · 글로 쓴 초 · null 은 형식 오류', async () => {
+ for (const undoSendSeconds of [7, -1, 31, 10.5, '10', null, true]) {
+ expect(await constraintsOf({ undoSendSeconds })).toEqual(['isIn']);
+ }
+ });
+
+ it('모르는 외부 이미지 값 · 대문자 · null 은 형식 오류', async () => {
+ for (const externalImages of ['never', 'ASK', '', null, 1]) {
+ expect(await constraintsOf({ externalImages })).toEqual(['isIn']);
+ }
+ });
+
+ it('모르는 칸은 받지 않는다', async () => {
+ expect(await constraintsOf({ theme: 'dark' })).toEqual([
+ 'whitelistValidation',
+ ]);
+ });
+});
diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts
new file mode 100644
index 0000000..73f5850
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts
@@ -0,0 +1,66 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, ValidateIf } from 'class-validator';
+
+import {
+ EXTERNAL_IMAGE_MODES,
+ UNDO_SEND_SECONDS_CHOICES,
+ type ExternalImageMode,
+ type MailUserSettings,
+ type MailUserSettingsPatch,
+ type UndoSendSeconds,
+} from '@/modules/mail-user-settings/mail-user-settings.types';
+
+/** 보낸 칸만 검사한다 — 보내지 않은 칸(undefined)은 그대로 둔다. null 은 형식 오류다 (IsOptional 은 null 도 건너뛴다) */
+const whenSent = (_object: object, value: unknown) => value !== undefined;
+
+const UNDO_DESCRIPTION =
+ `보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` +
+ '예약 발송에는 더하지 않는다';
+const IMAGES_DESCRIPTION =
+ '외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' +
+ '스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)';
+
+/** GET · PATCH /api/mail-settings 의 응답 — 계약 2절 MailSettings */
+export class MailUserSettingsDto implements MailUserSettings {
+ @ApiProperty({
+ description: UNDO_DESCRIPTION,
+ enum: [...UNDO_SEND_SECONDS_CHOICES],
+ example: 10,
+ })
+ undoSendSeconds!: UndoSendSeconds;
+
+ @ApiProperty({
+ description: IMAGES_DESCRIPTION,
+ enum: [...EXTERNAL_IMAGE_MODES],
+ example: 'ask',
+ })
+ externalImages!: ExternalImageMode;
+}
+
+/**
+ * PATCH /api/mail-settings 본문 — 바꿀 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다).
+ * 고를 수 있는 값이 아니면(문자열 "10" · null 포함) 400 VAL_001
+ */
+export class MailUserSettingsPatchDto implements MailUserSettingsPatch {
+ @ApiPropertyOptional({
+ description: UNDO_DESCRIPTION,
+ enum: [...UNDO_SEND_SECONDS_CHOICES],
+ example: 20,
+ })
+ @ValidateIf(whenSent)
+ @IsIn(UNDO_SEND_SECONDS_CHOICES, {
+ message: `보내기 취소 시간은 ${UNDO_SEND_SECONDS_CHOICES.join(' · ')}초 가운데에서 골라 주세요.`,
+ })
+ undoSendSeconds?: UndoSendSeconds;
+
+ @ApiPropertyOptional({
+ description: IMAGES_DESCRIPTION,
+ enum: [...EXTERNAL_IMAGE_MODES],
+ example: 'trusted',
+ })
+ @ValidateIf(whenSent)
+ @IsIn(EXTERNAL_IMAGE_MODES, {
+ message: '외부 이미지 설정을 다시 골라 주세요.',
+ })
+ externalImages?: ExternalImageMode;
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts
new file mode 100644
index 0000000..b6ea0fa
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts
@@ -0,0 +1,62 @@
+import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
+import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import {
+ ApiErrorResponse,
+ ApiStandardResponse,
+} from '@/common/decorators/api-standard-response.decorator';
+import {
+ CurrentUser,
+ type AuthenticatedUser,
+} from '@/common/decorators/current-user.decorator';
+import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
+import {
+ MailUserSettingsDto,
+ MailUserSettingsPatchDto,
+} from './dto/mail-user-settings.dto';
+import { MailUserSettingsService } from './mail-user-settings.service';
+
+/** 보내기 · 읽기 설정 라우트 (`/api/mail-settings`) — 계약 2절 "보내기 · 읽기 설정" (9단계) */
+export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings';
+
+// 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정", 9단계) — 서버 DB 에 둔다(어느 PC 에서나 같다)
+@ApiTags('Mail')
+@ApiCookieAuth()
+@UseGuards(JwtAuthGuard)
+@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
+@Controller(MAIL_USER_SETTINGS_ROUTE)
+export class MailUserSettingsController {
+ constructor(private readonly settings: MailUserSettingsService) {}
+
+ @Get()
+ @ApiOperation({
+ summary: '보내기 · 읽기 설정',
+ description:
+ '저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.',
+ })
+ @ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' })
+ get(@CurrentUser() user: AuthenticatedUser) {
+ return this.settings.get(user.email);
+ }
+
+ @Patch()
+ @ApiOperation({
+ summary: '보내기 · 읽기 설정 바꾸기 (보낸 칸만)',
+ description:
+ '바꿀 칸만 보낸다(하나 이상). 보내지 않은 칸은 그대로다 — 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다. 바꾼 뒤의 설정 전체를 돌려준다.',
+ })
+ @ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' })
+ @ApiErrorResponse(
+ 'VAL_001',
+ '빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드',
+ )
+ update(
+ @CurrentUser() user: AuthenticatedUser,
+ @Body() dto: MailUserSettingsPatchDto,
+ ) {
+ return this.settings.update(user.email, {
+ undoSendSeconds: dto.undoSendSeconds,
+ externalImages: dto.externalImages,
+ });
+ }
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts
new file mode 100644
index 0000000..291139d
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts
@@ -0,0 +1,37 @@
+import { Column, Entity, PrimaryColumn } from 'typeorm';
+
+/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
+const EMAIL_COLUMN_LENGTH = 254;
+
+/** 외부 이미지 설정 값의 최대 길이 — 'trusted' 가 가장 길다 (여유를 둔다) */
+const EXTERNAL_IMAGES_COLUMN_LENGTH = 16;
+
+/**
+ * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원 주소마다 한 줄. 없으면 처음 값(10초 · 늘 묻기)이다.
+ * 메일 엔진이 아니라 우리 DB 에 둔다 — 어느 PC 에서나 같고, 엔진을 바꿔도 남는다.
+ * 값은 서비스가 검사한 것만 들어간다 (mail-user-settings.types.ts)
+ */
+@Entity({ name: 'mail_settings' })
+export class MailUserSettingsEntity {
+ @PrimaryColumn({
+ name: 'user_email',
+ type: 'varchar',
+ length: EMAIL_COLUMN_LENGTH,
+ })
+ userEmail!: string;
+
+ /** 보내기 취소를 기다리는 초 — 0 · 5 · 10 · 20 · 30 */
+ @Column({ name: 'undo_send_seconds', type: 'smallint' })
+ undoSendSeconds!: number;
+
+ /** 외부 이미지 — ask · trusted · always */
+ @Column({
+ name: 'external_images',
+ type: 'varchar',
+ length: EXTERNAL_IMAGES_COLUMN_LENGTH,
+ })
+ externalImages!: string;
+
+ @Column({ name: 'updated_at', type: 'timestamptz' })
+ updatedAt!: Date;
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.module.ts b/backend/src/modules/mail-user-settings/mail-user-settings.module.ts
new file mode 100644
index 0000000..e8a03e2
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { MailUserSettingsController } from './mail-user-settings.controller';
+import { MailUserSettingsEntity } from './mail-user-settings.entity';
+import { MailUserSettingsRepository } from './mail-user-settings.repository';
+import { MailUserSettingsService } from './mail-user-settings.service';
+
+/**
+ * 보내기 · 읽기 설정 모듈 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다의 설정을 서버 DB(`mail_settings`)에 둔다.
+ * 메일 모듈이 발송(보내기 취소 초) · 메일 읽기(외부 이미지)에 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다)
+ */
+@Module({
+ imports: [TypeOrmModule.forFeature([MailUserSettingsEntity])],
+ controllers: [MailUserSettingsController],
+ providers: [MailUserSettingsRepository, MailUserSettingsService],
+ exports: [MailUserSettingsService],
+})
+export class MailUserSettingsModule {}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts
new file mode 100644
index 0000000..b0e40ec
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts
@@ -0,0 +1,80 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import type { Repository } from 'typeorm';
+
+import { MailUserSettingsEntity } from './mail-user-settings.entity';
+import type {
+ MailUserSettings,
+ MailUserSettingsPatch,
+} from './mail-user-settings.types';
+
+/** 바꿀 수 있는 칸 → 표의 열 이름 */
+const COLUMNS: Readonly> = {
+ undoSendSeconds: 'undo_send_seconds',
+ externalImages: 'external_images',
+};
+
+/** RETURNING 한 줄 (열 이름이 키다) */
+interface ReturnedRow {
+ readonly user_email: string;
+ readonly undo_send_seconds: number;
+ readonly external_images: string;
+ readonly updated_at: Date;
+}
+
+/**
+ * 보내기 · 읽기 설정 표(`mail_settings`) 접근 — 값의 검사 · 처음 값은 MailUserSettingsService 가 정한다.
+ */
+@Injectable()
+export class MailUserSettingsRepository {
+ constructor(
+ @InjectRepository(MailUserSettingsEntity)
+ private readonly rows: Repository,
+ ) {}
+
+ find(userEmail: string): Promise {
+ return this.rows.findOneBy({ userEmail });
+ }
+
+ /**
+ * 보낸 칸만 바꾼다 — 한 문장(INSERT … ON CONFLICT DO UPDATE SET <보낸 칸>)이라 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다.
+ * 줄이 없으면 보내지 않은 칸은 `base`(처음 값)로 넣는다.
+ * @returns 저장한 뒤의 줄
+ */
+ async upsert(
+ userEmail: string,
+ patch: MailUserSettingsPatch,
+ base: MailUserSettings,
+ now: Date,
+ ): Promise {
+ const changed = (Object.keys(COLUMNS) as (keyof MailUserSettings)[])
+ .filter((key) => patch[key] !== undefined)
+ .map((key) => COLUMNS[key]);
+ const result = await this.rows
+ .createQueryBuilder()
+ .insert()
+ .into(MailUserSettingsEntity)
+ .values({
+ userEmail,
+ undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds,
+ externalImages: patch.externalImages ?? base.externalImages,
+ updatedAt: now,
+ })
+ .orUpdate([...changed, 'updated_at'], ['user_email'])
+ // 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING 열 이름 (행의 키는 열 이름)
+ .returning([
+ 'userEmail',
+ 'undoSendSeconds',
+ 'externalImages',
+ 'updatedAt',
+ ])
+ .execute();
+ const [row] = (result.raw ?? []) as ReturnedRow[];
+ const saved = new MailUserSettingsEntity();
+ saved.userEmail = row?.user_email ?? userEmail;
+ saved.undoSendSeconds = Number(row?.undo_send_seconds);
+ saved.externalImages = row?.external_images ?? '';
+ saved.updatedAt = row?.updated_at ?? now;
+ return saved;
+ }
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts
new file mode 100644
index 0000000..463a047
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts
@@ -0,0 +1,218 @@
+import { Logger } from '@nestjs/common';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import { MailUserSettingsEntity } from './mail-user-settings.entity';
+import type { MailUserSettingsRepository } from './mail-user-settings.repository';
+import {
+ MAIL_USER_SETTINGS_CACHE_MAX_USERS,
+ MAIL_USER_SETTINGS_CACHE_TTL_MS,
+ MailUserSettingsService,
+} from './mail-user-settings.service';
+import {
+ DEFAULT_MAIL_USER_SETTINGS,
+ type MailUserSettings,
+ type MailUserSettingsPatch,
+} from './mail-user-settings.types';
+
+const ARA = 'ara.kim@arasystem.test';
+
+function row(
+ userEmail: string,
+ undoSendSeconds: number,
+ externalImages: string,
+): MailUserSettingsEntity {
+ const entity = new MailUserSettingsEntity();
+ entity.userEmail = userEmail;
+ entity.undoSendSeconds = undoSendSeconds;
+ entity.externalImages = externalImages;
+ entity.updatedAt = new Date('2026-10-29T00:00:00.000Z');
+ return entity;
+}
+
+/** 대역 표 — 직원마다 한 줄 */
+function fakeRepository() {
+ const rows = new Map();
+ const find = jest.fn((userEmail: string) =>
+ Promise.resolve(rows.get(userEmail) ?? null),
+ );
+ const upsert = jest.fn(
+ (
+ userEmail: string,
+ patch: MailUserSettingsPatch,
+ base: MailUserSettings,
+ ) => {
+ const current = rows.get(userEmail);
+ const saved = row(
+ userEmail,
+ patch.undoSendSeconds ??
+ current?.undoSendSeconds ??
+ base.undoSendSeconds,
+ patch.externalImages ?? current?.externalImages ?? base.externalImages,
+ );
+ rows.set(userEmail, saved);
+ return Promise.resolve(saved);
+ },
+ );
+ return {
+ rows,
+ find,
+ upsert,
+ repository: { find, upsert } as unknown as MailUserSettingsRepository,
+ };
+}
+
+async function failureOf(
+ run: () => Promise,
+): Promise<{ code?: string; reason?: unknown }> {
+ try {
+ await run();
+ } catch (error) {
+ return error instanceof BusinessException
+ ? { code: error.code, reason: error.context?.reason }
+ : { code: 'NOT_BUSINESS' };
+ }
+ return {};
+}
+
+describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () => {
+ let logSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ logSpy = jest
+ .spyOn(Logger.prototype, 'log')
+ .mockImplementation(() => undefined);
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ it('저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다', async () => {
+ const fake = fakeRepository();
+ const service = new MailUserSettingsService(fake.repository);
+
+ await expect(service.get(ARA)).resolves.toEqual({
+ undoSendSeconds: 10,
+ externalImages: 'ask',
+ });
+ expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({
+ undoSendSeconds: 10,
+ externalImages: 'ask',
+ });
+ expect(fake.upsert).not.toHaveBeenCalled();
+ });
+
+ it('표의 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다', async () => {
+ const fake = fakeRepository();
+ fake.rows.set(ARA, row(ARA, 7, 'always'));
+ fake.rows.set('b@arasystem.test', row('b@arasystem.test', 30, 'never'));
+ const service = new MailUserSettingsService(fake.repository);
+
+ await expect(service.get(ARA)).resolves.toEqual({
+ undoSendSeconds: 10,
+ externalImages: 'always',
+ });
+ await expect(service.get('b@arasystem.test')).resolves.toEqual({
+ undoSendSeconds: 30,
+ externalImages: 'ask',
+ });
+ });
+
+ it('1분 기억한다 — 주소의 대소문자 · 앞뒤 공백과 상관없이, 지나면 다시 읽는다', async () => {
+ jest.useFakeTimers({ now: Date.parse('2026-10-29T00:00:00.000Z') });
+ const fake = fakeRepository();
+ fake.rows.set(ARA, row(ARA, 20, 'trusted'));
+ const service = new MailUserSettingsService(fake.repository);
+
+ await service.get(ARA);
+ await service.get(` ${ARA.toUpperCase()} `);
+ expect(fake.find).toHaveBeenCalledTimes(1);
+
+ jest.setSystemTime(Date.now() + MAIL_USER_SETTINGS_CACHE_TTL_MS);
+ await service.get(ARA);
+ expect(fake.find).toHaveBeenCalledTimes(2);
+ });
+
+ it('바꾸기는 보낸 칸만 — 저장한 값을 돌려주고 바로 기억에 넣는다 (다시 읽지 않는다), 로그에는 가린 주소', async () => {
+ const fake = fakeRepository();
+ fake.rows.set(ARA, row(ARA, 20, 'trusted'));
+ const service = new MailUserSettingsService(fake.repository);
+ await service.get(ARA);
+
+ await expect(
+ service.update(ARA, { externalImages: 'always' }),
+ ).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' });
+ expect(fake.upsert).toHaveBeenCalledWith(
+ ARA,
+ { externalImages: 'always' },
+ DEFAULT_MAIL_USER_SETTINGS,
+ expect.any(Date),
+ );
+ await expect(service.get(ARA)).resolves.toEqual({
+ undoSendSeconds: 20,
+ externalImages: 'always',
+ });
+ expect(fake.find).toHaveBeenCalledTimes(1);
+
+ const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n');
+ expect(lines).toContain('외부 이미지 always');
+ expect(lines).not.toContain(ARA);
+ });
+
+ it('빈 바꾸기는 VAL_001 — 저장하지 않는다', async () => {
+ const fake = fakeRepository();
+ const service = new MailUserSettingsService(fake.repository);
+
+ expect(await failureOf(() => service.update(ARA, {}))).toEqual({
+ code: 'VAL_001',
+ reason: 'empty-mail-settings-patch',
+ });
+ expect(fake.upsert).not.toHaveBeenCalled();
+ });
+
+ it('읽는 사이 이 서버에서 바꿨으면 늦게 끝난 옛 읽기가 바꾼 값을 덮지 않는다', async () => {
+ const fake = fakeRepository();
+ fake.rows.set(ARA, row(ARA, 10, 'ask'));
+ let release!: () => void;
+ fake.find.mockImplementationOnce(
+ (userEmail: string) =>
+ new Promise((resolve) => {
+ const old = fake.rows.get(userEmail) ?? null;
+ release = () => resolve(old);
+ }),
+ );
+ const service = new MailUserSettingsService(fake.repository);
+
+ const reading = service.get(ARA);
+ await service.update(ARA, { undoSendSeconds: 30 });
+ release();
+
+ await expect(reading).resolves.toEqual({
+ undoSendSeconds: 30,
+ externalImages: 'ask',
+ });
+ await expect(service.get(ARA)).resolves.toEqual({
+ undoSendSeconds: 30,
+ externalImages: 'ask',
+ });
+ expect(fake.find).toHaveBeenCalledTimes(1);
+ });
+
+ it(`기억하는 직원은 ${MAIL_USER_SETTINGS_CACHE_MAX_USERS}명까지 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다`, async () => {
+ const fake = fakeRepository();
+ const service = new MailUserSettingsService(fake.repository);
+
+ await service.get('first@arasystem.test');
+ for (let i = 0; i < MAIL_USER_SETTINGS_CACHE_MAX_USERS; i += 1) {
+ await service.get(`u${i}@arasystem.test`);
+ }
+ fake.find.mockClear();
+
+ await service.get(
+ `u${MAIL_USER_SETTINGS_CACHE_MAX_USERS - 1}@arasystem.test`,
+ );
+ expect(fake.find).not.toHaveBeenCalled();
+ await service.get('first@arasystem.test');
+ expect(fake.find).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts
new file mode 100644
index 0000000..bd13302
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts
@@ -0,0 +1,125 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import { traceTag } from '@/shared/logger/request-context';
+import { maskEmail } from '@/shared/utils/mask';
+import type { MailUserSettingsEntity } from './mail-user-settings.entity';
+import { MailUserSettingsRepository } from './mail-user-settings.repository';
+import {
+ DEFAULT_MAIL_USER_SETTINGS,
+ isExternalImageMode,
+ isUndoSendSeconds,
+ type MailUserSettings,
+ type MailUserSettingsPatch,
+} from './mail-user-settings.types';
+
+/** 직원마다 설정을 기억하는 시간 — 발송 · 메일 읽기마다 DB 를 묻지 않게. 이 서버에서 바꾸면 바로 맞춘다 */
+export const MAIL_USER_SETTINGS_CACHE_TTL_MS = 60_000;
+/** 기억하는 직원 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 (한 사람에 몇십 바이트) */
+export const MAIL_USER_SETTINGS_CACHE_MAX_USERS = 1_000;
+
+interface CacheEntry {
+ readonly value: MailUserSettings;
+ readonly expiresAt: number;
+ /** 이 값을 넣은 차례 — 그보다 앞서 시작한 읽기는 이 값을 덮지 않는다 */
+ readonly sequence: number;
+}
+
+function cacheKey(userEmail: string): string {
+ return userEmail.trim().toLowerCase();
+}
+
+/** 표의 줄 → 설정 — 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다 */
+function toSettings(row: MailUserSettingsEntity | null): MailUserSettings {
+ if (row === null) return DEFAULT_MAIL_USER_SETTINGS;
+ return {
+ undoSendSeconds: isUndoSendSeconds(row.undoSendSeconds)
+ ? row.undoSendSeconds
+ : DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds,
+ externalImages: isExternalImageMode(row.externalImages)
+ ? row.externalImages
+ : DEFAULT_MAIL_USER_SETTINGS.externalImages,
+ };
+}
+
+/**
+ * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
+ * - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다
+ * - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다
+ *
+ * ⚠️ 기억은 프로세스 메모리다. 인스턴스를 늘리면 다른 인스턴스는 바꾼 값을 1분까지 늦게 본다 (보내기 취소 시간 · 외부 이미지가 1분 늦게 바뀔 뿐 — 틀린 값을 저장하지는 않는다)
+ */
+@Injectable()
+export class MailUserSettingsService {
+ private readonly logger = new Logger(MailUserSettingsService.name);
+ private readonly cache = new Map();
+ /** 기억에 넣은 차례 — 읽는 사이 바뀐 값을 옛 읽기가 덮지 않게 */
+ private sequence = 0;
+
+ constructor(private readonly repository: MailUserSettingsRepository) {}
+
+ async get(userEmail: string): Promise {
+ const key = cacheKey(userEmail);
+ const cached = this.cache.get(key);
+ if (cached && cached.expiresAt > Date.now()) {
+ // 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다
+ this.cache.delete(key);
+ this.cache.set(key, cached);
+ return cached.value;
+ }
+
+ const startedAt = this.sequence;
+ const value = toSettings(await this.repository.find(userEmail));
+ const latest = this.cache.get(key);
+ // 읽는 사이 이 서버에서 바꿨으면 그 값이 더 새롭다 — 덮지 않고 그 값을 준다
+ if (latest && latest.sequence > startedAt) return latest.value;
+ this.remember(key, value);
+ return value;
+ }
+
+ async update(
+ userEmail: string,
+ patch: MailUserSettingsPatch,
+ ): Promise {
+ if (
+ patch.undoSendSeconds === undefined &&
+ patch.externalImages === undefined
+ ) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'empty-mail-settings-patch' },
+ });
+ }
+ const saved = toSettings(
+ await this.repository.upsert(
+ userEmail,
+ patch,
+ DEFAULT_MAIL_USER_SETTINGS,
+ new Date(),
+ ),
+ );
+ this.remember(cacheKey(userEmail), saved);
+ this.logger.log(
+ `${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`,
+ );
+ return saved;
+ }
+
+ private remember(key: string, value: MailUserSettings): void {
+ const now = Date.now();
+ for (const [other, entry] of this.cache) {
+ if (entry.expiresAt <= now) this.cache.delete(other);
+ }
+ this.sequence += 1;
+ this.cache.delete(key);
+ this.cache.set(key, {
+ value,
+ expiresAt: now + MAIL_USER_SETTINGS_CACHE_TTL_MS,
+ sequence: this.sequence,
+ });
+ while (this.cache.size > MAIL_USER_SETTINGS_CACHE_MAX_USERS) {
+ const oldest = this.cache.keys().next().value;
+ if (oldest === undefined) break;
+ this.cache.delete(oldest);
+ }
+ }
+}
diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts
new file mode 100644
index 0000000..90e6fde
--- /dev/null
+++ b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts
@@ -0,0 +1,40 @@
+/**
+ * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다 한 줄, 서버 DB(`mail_settings`)에 둔다.
+ * 메일 엔진 연결 설정(modules/mail/mail.settings.ts 의 MailSettings)과는 다르다 — 이름이 겹치지 않게 MailUserSettings 로 부른다.
+ */
+
+/** 보내기 취소를 기다리는 초 — 0 이면 바로 보낸다 */
+export const UNDO_SEND_SECONDS_CHOICES = [0, 5, 10, 20, 30] as const;
+export type UndoSendSeconds = (typeof UNDO_SEND_SECONDS_CHOICES)[number];
+
+/**
+ * 외부 이미지 — ask 늘 묻기 · trusted 보낸 사람이 내 주소록 · 사내 목록에 있으면 늘 보기 · always 늘 보기.
+ * 스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)
+ */
+export const EXTERNAL_IMAGE_MODES = ['ask', 'trusted', 'always'] as const;
+export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number];
+
+export interface MailUserSettings {
+ readonly undoSendSeconds: UndoSendSeconds;
+ readonly externalImages: ExternalImageMode;
+}
+
+/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */
+export type MailUserSettingsPatch = Partial;
+
+/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */
+export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({
+ undoSendSeconds: 10,
+ externalImages: 'ask',
+});
+
+/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */
+export function isUndoSendSeconds(value: unknown): value is UndoSendSeconds {
+ return (UNDO_SEND_SECONDS_CHOICES as readonly unknown[]).includes(value);
+}
+
+export function isExternalImageMode(
+ value: unknown,
+): value is ExternalImageMode {
+ return (EXTERNAL_IMAGE_MODES as readonly unknown[]).includes(value);
+}
diff --git a/backend/src/modules/mail/dto/mail-block.dto.spec.ts b/backend/src/modules/mail/dto/mail-block.dto.spec.ts
new file mode 100644
index 0000000..b60e828
--- /dev/null
+++ b/backend/src/modules/mail/dto/mail-block.dto.spec.ts
@@ -0,0 +1,62 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+
+import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
+import { MailBlockCreateDto } from './mail-block.dto';
+
+/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */
+async function constraintsOf(body: object): Promise {
+ const errors = await validate(plainToInstance(MailBlockCreateDto, body), {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
+}
+
+describe('보낸 사람 차단 본문 (POST /api/mail-blocks — 9단계)', () => {
+ it('주소 · @도메인 — 앞뒤 공백은 빼고 본다 (막을 수 없는 대상은 서비스가 본다)', async () => {
+ for (const address of [
+ 'kim@spam.example',
+ ' Kim@Spam.Example ',
+ '@spam.example',
+ '@xn--vg1b002a.example',
+ '@arasystem.test',
+ ]) {
+ expect(await constraintsOf({ address })).toEqual([]);
+ }
+ });
+
+ it('검사 차례는 형식 → 길이 → 모양', async () => {
+ expect(await constraintsOf({ address: 42 })).toEqual([
+ 'isString',
+ 'maxCodeUnits',
+ 'isBlockTarget',
+ ]);
+ expect(
+ await constraintsOf({
+ address: `${'a'.repeat(MAX_EMAIL_LENGTH)}@spam.example`,
+ }),
+ ).toEqual(['maxCodeUnits', 'isBlockTarget']);
+ });
+
+ it.each([
+ ['빈 글', ' '],
+ ['@ 만', '@'],
+ ['점 없는 도메인', '@localhost'],
+ ['IP 주소', '@127.0.0.1'],
+ ['숫자 최상위 도메인', '@spam.123'],
+ ['밑줄 도메인', '@spam_ads.example'],
+ ['한글 도메인(punycode 로)', '@스팸.example'],
+ ['${ 가 든 주소', '${x}@spam.example'],
+ ['주소 모양이 아님', 'kim at spam.example'],
+ ])('모양이 틀리면 형식 오류 — %s', async (_label, address) => {
+ expect(await constraintsOf({ address })).toEqual(['isBlockTarget']);
+ });
+
+ it('주소가 없거나 모르는 칸이 있으면 형식 오류', async () => {
+ expect(await constraintsOf({})).not.toEqual([]);
+ expect(
+ await constraintsOf({ address: 'kim@spam.example', note: '메모' }),
+ ).toEqual(['whitelistValidation']);
+ });
+});
diff --git a/backend/src/modules/mail/dto/mail-block.dto.ts b/backend/src/modules/mail/dto/mail-block.dto.ts
new file mode 100644
index 0000000..e1c001e
--- /dev/null
+++ b/backend/src/modules/mail/dto/mail-block.dto.ts
@@ -0,0 +1,98 @@
+import { applyDecorators } from '@nestjs/common';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, ValidateBy } from 'class-validator';
+
+import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
+import { TrimString } from '@/common/decorators/trim-string.decorator';
+import { parseBlockTarget } from '@/modules/mail/mail-blocks';
+import {
+ MAX_EMAIL_LENGTH,
+ MAX_MAIL_BLOCKS,
+} from '@/modules/mail/mail.constants';
+import type { BlockedSender, MailBlockList } from '@/modules/mail/mail.types';
+
+/**
+ * 보낸 사람 차단 — POST 본문과 응답 (계약 2절 "보낸 사람 차단" — 9단계).
+ * 여기서는 모양만 본다(주소는 발송의 주소와 같은 규칙 · 도메인은 `@` + 호스트 이름 — mail-blocks.ts). 내 주소 · 사내 도메인 ·
+ * 여러 사람이 쓰는 메일 서비스의 도메인 · 500개는 서비스 · 어댑터가 본다 (모두 400 VAL_001).
+ * 검사 차례(applyDecorators — 적은 차례대로): 형식 → 길이 → 모양
+ */
+
+/** 차단할 주소 · @도메인의 모양 */
+function IsBlockTarget() {
+ return ValidateBy(
+ {
+ name: 'isBlockTarget',
+ validator: {
+ validate: (value: unknown) =>
+ typeof value === 'string' && parseBlockTarget(value) !== null,
+ },
+ },
+ { message: '메일 주소나 @도메인 형식으로 써 주세요.' },
+ );
+}
+
+function BlockAddress(): PropertyDecorator {
+ return applyDecorators(
+ TrimString(),
+ IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }),
+ MaxCodeUnits(MAX_EMAIL_LENGTH, {
+ message: `차단할 주소는 ${MAX_EMAIL_LENGTH}자 이하로 써 주세요.`,
+ }),
+ IsBlockTarget(),
+ );
+}
+
+/** POST /api/mail-blocks 본문 */
+export class MailBlockCreateDto {
+ @ApiProperty({
+ description:
+ `차단할 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두, 하위 도메인은 따로) — 앞뒤 공백을 빼고 ${MAX_EMAIL_LENGTH}자까지, 소문자로 맞춰 저장한다. ` +
+ '내 주소 · 사내 도메인(그 하위 포함)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인으로 막을 수 없다(주소로는 된다)',
+ example: '@spam.example',
+ maxLength: MAX_EMAIL_LENGTH,
+ })
+ @BlockAddress()
+ address!: string;
+}
+
+/** 계약 2절 BlockedSender */
+export class BlockedSenderDto implements BlockedSender {
+ @ApiProperty({
+ description:
+ '소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example")',
+ example: 'kim@spam.example',
+ })
+ address!: string;
+
+ @ApiProperty({
+ description: '차단한 시각 (ISO 8601 UTC)',
+ example: '2026-10-29T06:00:00.000Z',
+ })
+ blockedAt!: string;
+}
+
+/** 계약 2절 MailBlockList — GET /api/mail-blocks */
+export class MailBlockListDto implements MailBlockList {
+ @ApiProperty({
+ type: [BlockedSenderDto],
+ description: `최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지`,
+ })
+ senders!: BlockedSenderDto[];
+
+ @ApiProperty({
+ description:
+ '엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다. false 면 지금은 차단도 동작하지 않는다(필터 · 부재중 · 차단 가운데 무엇이든 저장하면 다시 켜진다)',
+ example: true,
+ })
+ isActive!: boolean;
+}
+
+/** DELETE /api/mail-blocks/{address} 의 응답 */
+export class MailBlockRemovedDto {
+ @ApiProperty({
+ description: '차단을 푼 주소 · 도메인 (소문자로 맞춘 값)',
+ example: 'kim@spam.example',
+ })
+ address!: string;
+}
diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts
index 4cb8cc9..3fb2656 100644
--- a/backend/src/modules/mail/dto/mail-response.dto.ts
+++ b/backend/src/modules/mail/dto/mail-response.dto.ts
@@ -21,6 +21,8 @@ import type {
MessageDetail,
MessagesPatchResult,
MessageSummary,
+ PendingSend,
+ PendingSendKind,
RecipientRejectionReason,
RecipientSuggestion,
RejectedRecipient,
@@ -215,6 +217,25 @@ export class MailQuotaDto implements MailQuota {
attachmentTotalMaxBytes!: number;
}
+const PENDING_SEND_KINDS: PendingSendKind[] = ['undo', 'scheduled'];
+
+/** 계약 2절 "보내기 취소 · 예약 발송"의 PendingSend — 엔진이 붙잡아 둔 발송 (9단계) */
+export class PendingSendDto implements PendingSend {
+ @ApiProperty({
+ description:
+ '엔진이 보낼 시각 (ISO 8601 UTC) — 클라이언트는 보내기 취소의 남은 시간을 설정한 초가 아니라 이 시각으로 센다',
+ example: '2026-10-30T00:00:00.000Z',
+ })
+ sendAt!: string;
+
+ @ApiProperty({
+ description: 'undo = 보내기 취소를 기다리는 중 · scheduled = 예약 발송',
+ enum: PENDING_SEND_KINDS,
+ example: 'scheduled',
+ })
+ kind!: PendingSendKind;
+}
+
export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '메일 id', example: 'eaaaaab' })
id!: string;
@@ -270,6 +291,14 @@ export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
size!: number;
+
+ @ApiProperty({
+ description:
+ '아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약 — 엔진의 기다리는 제출로 정한다)이면 그 발송, 그 밖에는 null (9단계)',
+ type: PendingSendDto,
+ nullable: true,
+ })
+ pendingSend!: PendingSendDto | null;
}
export class MessageDetailDto
@@ -324,11 +353,19 @@ export class MessageDetailDto
textBody!: string | null;
@ApiProperty({
- description: '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내',
+ description:
+ '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내. 보이게 했으면(images=show 또는 설정) false',
example: false,
})
hasBlockedImages!: boolean;
+ @ApiProperty({
+ description:
+ '외부 이미지를 중계 주소(/api/mail-images/{token})로 바꿔 넣었으면 true — images=show 또는 설정(externalImages)으로 (9단계 "외부 이미지 보기")',
+ example: false,
+ })
+ imagesShown!: boolean;
+
@ApiProperty({
description:
'HTML 이 있었지만 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true → 서식 없이 textBody 를 보여 주고 안내한다 (textBody 도 없으면 표시할 수 없음). ' +
@@ -400,6 +437,40 @@ export class SentMessageDto implements SentMessage {
type: [RejectedRecipientDto],
})
rejectedRecipients!: RejectedRecipientDto[];
+
+ @ApiProperty({
+ description:
+ '엔진이 붙잡아 둔 발송이면 그 정보 — 보내기 취소(설정의 초만큼) · 예약(sendAt). 바로 보냈으면 null (9단계)',
+ type: PendingSendDto,
+ nullable: true,
+ })
+ pendingSend!: PendingSendDto | null;
+}
+
+/** GET /api/scheduled-messages 의 응답 — 예약한 메일, 보낼 시각 차례 (9단계) */
+export class ScheduledMessagePageDto {
+ @ApiProperty({
+ type: [MessageSummaryDto],
+ description:
+ '예약한 메일(pendingSend.kind 가 scheduled 이고 아직 보내지 않은 것) — 보낼 시각 차례(같으면 id 차례). 보낸 메일은 저절로 빠진다',
+ })
+ items!: MessageSummaryDto[];
+
+ @ApiProperty({
+ type: PageInfoDto,
+ description: 'total 포함 — 메일함 패널의 「예약됨」 통 수',
+ })
+ pageInfo!: PageInfoDto;
+}
+
+/** DELETE /api/scheduled-messages/{id} 의 응답 — 취소한 발송을 되돌린 임시 저장본 (9단계) */
+export class ScheduledMessageCanceledDto {
+ @ApiProperty({
+ description:
+ '임시 저장본 id — 보낸 사본과 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다',
+ example: 'maaaaad',
+ })
+ draftId!: string;
}
/** 계약 2절 "새 메일 확인"의 MailState */
diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts
index 4143f2a..e220bca 100644
--- a/backend/src/modules/mail/dto/mail-vacation.dto.ts
+++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts
@@ -38,7 +38,8 @@ export function isUtcDateTime(value: unknown): boolean {
);
}
-function IsUtcDateTime(options: ValidationOptions) {
+/** ISO 8601 UTC 시각 검사 — 부재중 기간 · 예약 발송 시각(send-message.dto.ts — 9단계)이 같이 쓴다 */
+export function IsUtcDateTime(options: ValidationOptions) {
return ValidateBy(
{ name: 'isUtcDateTime', validator: { validate: isUtcDateTime } },
options,
diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts
new file mode 100644
index 0000000..0ef15c5
--- /dev/null
+++ b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts
@@ -0,0 +1,27 @@
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+
+import { MessageDetailQueryDto } from './message-detail-query.dto';
+
+/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고) */
+async function constraintsOf(query: object): Promise {
+ const errors = await validate(plainToInstance(MessageDetailQueryDto, query), {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
+}
+
+describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 images)', () => {
+ it('images 는 없거나 show 만', async () => {
+ expect(await constraintsOf({})).toEqual([]);
+ expect(await constraintsOf({ images: 'show' })).toEqual([]);
+ });
+
+ it('모르는 값 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => {
+ for (const images of ['SHOW', '1', 'true', '']) {
+ expect(await constraintsOf({ images })).toEqual(['isIn']);
+ }
+ expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']);
+ });
+});
diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.ts b/backend/src/modules/mail/dto/message-detail-query.dto.ts
new file mode 100644
index 0000000..6c24fb1
--- /dev/null
+++ b/backend/src/modules/mail/dto/message-detail-query.dto.ts
@@ -0,0 +1,21 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, IsOptional } from 'class-validator';
+
+/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */
+export const SHOW_IMAGES = 'show';
+
+/**
+ * GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도).
+ * 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001
+ */
+export class MessageDetailQueryDto {
+ @ApiPropertyOptional({
+ description:
+ 'show — 외부 이미지를 보이는 본문(imagesShown: true · hasBlockedImages: false). 없으면 설정(ask 지우기 · trusted 주소록 · 사내 목록의 보낸 사람 · always 늘)을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다',
+ enum: [SHOW_IMAGES],
+ example: SHOW_IMAGES,
+ })
+ @IsOptional()
+ @IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' })
+ images?: typeof SHOW_IMAGES;
+}
diff --git a/backend/src/modules/mail/dto/send-message.dto.spec.ts b/backend/src/modules/mail/dto/send-message.dto.spec.ts
index d85557a..2b17cd4 100644
--- a/backend/src/modules/mail/dto/send-message.dto.spec.ts
+++ b/backend/src/modules/mail/dto/send-message.dto.spec.ts
@@ -94,3 +94,34 @@ describe('SendMessageDto 첨부 목록', () => {
).toContain('arrayMaxSize');
});
});
+
+describe('SendMessageDto 예약 시각 (sendAt — 9단계)', () => {
+ const message = (sendAt: unknown) => ({
+ to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
+ subject: '제목',
+ body: '본문',
+ sendAt,
+ });
+
+ it('없거나 null 이면(보내기 취소 설정대로) · ISO 8601 UTC 시각이면 형식은 맞다 — 범위(5분 뒤 ~ 7일)는 서비스가 본다', async () => {
+ expect(await constraintsOf(message(undefined))).toEqual([]);
+ expect(await constraintsOf(message(null))).toEqual([]);
+ expect(await constraintsOf(message('2030-01-01T09:00:00.000Z'))).toEqual(
+ [],
+ );
+ expect(await constraintsOf(message('2030-01-01T09:00:00Z'))).toEqual([]);
+ });
+
+ it('시간대 오프셋 · 날짜만 · 없는 날짜 · 소문자 z · 글이 아님은 형식 오류', async () => {
+ for (const sendAt of [
+ '2030-01-01T18:00:00+09:00',
+ '2030-01-01',
+ '2030-02-30T09:00:00.000Z',
+ '2030-01-01T09:00:00.000z',
+ '내일 아침',
+ 1893488400000,
+ ]) {
+ expect(await constraintsOf(message(sendAt))).not.toEqual([]);
+ }
+ });
+});
diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts
index bf91134..7d25961 100644
--- a/backend/src/modules/mail/dto/send-message.dto.ts
+++ b/backend/src/modules/mail/dto/send-message.dto.ts
@@ -17,6 +17,7 @@ import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
} from '@/modules/mail/attachment.policy';
+import { IsUtcDateTime } from '@/modules/mail/dto/mail-vacation.dto';
import {
MAIL_ID_PATTERN,
MAX_BODY_HTML_LENGTH,
@@ -188,6 +189,18 @@ export class SendMessageDto extends ComposeContentDto {
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' })
draftId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ '예약 발송 시각 (ISO 8601 UTC — 소수 초는 버린다) — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안(엔진 한도). 밖이면 400 VAL_001. ' +
+ '메일 서버가 그때까지 붙잡았다가 보낸다(창을 닫아도 보내진다 — 예약에는 보내기 취소 시간을 더하지 않는다). 없거나 null 이면 그 직원 설정의 보내기 취소 초만큼 붙잡는다 (9단계)',
+ example: '2026-10-30T00:00:00.000Z',
+ nullable: true,
+ })
+ @IsOptional()
+ @IsString({ message: '예약 시각 형식이 올바르지 않습니다.' })
+ @IsUtcDateTime({ message: '예약 시각 형식이 올바르지 않습니다.' })
+ sendAt?: string | null;
}
/** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */
diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
index 6e9520b..bf6f2b7 100644
--- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
+++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
@@ -110,6 +110,7 @@ describe('sanitizeMailHtml', () => {
html: 'y',
hasBlockedImages: false,
embeddedContentIds: new Set(),
+ proxiedImageCount: 0,
});
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
expect(cpuMs()).toBeLessThan(2_000);
@@ -266,6 +267,114 @@ describe('sanitizeMailHtml', () => {
});
});
+ describe('외부 이미지 보기 (externalImageSrc — 9단계)', () => {
+ /** 중계 주소 — 테스트에서는 원래 주소를 그대로 보이게 둔다 (실제 표는 암호화한 글이다) */
+ const proxied = (url: URL) =>
+ `/api/mail-images/${Buffer.from(url.href).toString('base64url')}`;
+ const show = (html: string) =>
+ sanitizeMailHtml(html, new Map(), { externalImageSrc: proxied });
+
+ it('
의 외부 src(http · https · //)만 중계 주소로 바꾼다 — 바꾼 수를 알려 준다', () => {
+ const result = show(
+ '안녕
' +
+ '
',
+ );
+
+ expect(result.html).toBe(
+ '안녕
' +
+ `
` +
+ `
` +
+ `
`,
+ );
+ expect(result.proxiedImageCount).toBe(3);
+ expect(result.hasBlockedImages).toBe(false);
+ });
+
+ it('srcset · style 의 url() · background 속성은 지금처럼 지운다 (알려진 한계) — 지운 것이 있으면 hasBlockedImages', () => {
+ const result = show(
+ '
' +
+ '배경
' +
+ '',
+ );
+
+ expect(result.html).toBe(
+ '배경
',
+ );
+ expect(result.proxiedImageCount).toBe(0);
+ expect(result.hasBlockedImages).toBe(true);
+ });
+
+ it('중계할 수 없는 주소(기본 포트가 아님 · 사용자 정보 · 2,048자 초과)는 지운다', () => {
+ const result = show(
+ '
' +
+ '
' +
+ `
`,
+ );
+
+ expect(result.html).toBe('');
+ expect(result.proxiedImageCount).toBe(0);
+ expect(result.hasBlockedImages).toBe(true);
+ });
+
+ it('같은 주소는 같은 중계 주소를 한 번만 만든다 · 서로 다른 주소는 한 통에 100개까지(그 뒤는 지운다)', () => {
+ let issued = 0;
+ const counting = (url: URL) => {
+ issued += 1;
+ return proxied(url);
+ };
+ const same = sanitizeMailHtml(
+ '
'.repeat(150),
+ new Map(),
+ { externalImageSrc: counting },
+ );
+ expect(issued).toBe(1);
+ expect(same.proxiedImageCount).toBe(1);
+ expect(same.html.match(/
`
`,
+ ).join(''),
+ new Map(),
+ { externalImageSrc: counting },
+ );
+ expect(issued).toBe(100);
+ expect(many.proxiedImageCount).toBe(100);
+ expect(many.html.match(/
{
+ let issued = 0;
+ const result = sanitizeMailHtml(
+ '' +
+ '
' +
+ `
`,
+ new Map([['logo@x', PNG_DATA_URI]]),
+ {
+ externalImageSrc: (url) => {
+ issued += 1;
+ return proxied(url);
+ },
+ },
+ );
+
+ expect(issued).toBe(0);
+ expect(result.html).toBe(
+ `
`,
+ );
+ });
+
+ it('보이게 하지 않으면(옵션 없음) 바꾼 수는 0 이다', () => {
+ expect(
+ sanitizeMailHtml('
')
+ .proxiedImageCount,
+ ).toBe(0);
+ });
+ });
+
describe('위험한 요소', () => {
it.each([
[
diff --git a/backend/src/modules/mail/html/sanitize-mail-html.ts b/backend/src/modules/mail/html/sanitize-mail-html.ts
index 15818b3..d437578 100644
--- a/backend/src/modules/mail/html/sanitize-mail-html.ts
+++ b/backend/src/modules/mail/html/sanitize-mail-html.ts
@@ -1,5 +1,7 @@
import sanitizeHtml from 'sanitize-html';
+import { MAX_PROXIED_IMAGES_PER_MESSAGE } from '@/modules/mail/images/mail-image.constants';
+import { parseProxiedImageUrl } from '@/modules/mail/images/mail-image-url';
import { normalizeContentId } from './inline-images';
/**
@@ -13,6 +15,9 @@ import { normalizeContentId } from './inline-images';
* - 이벤트 핸들러(on*)·id·class 는 허용 속성이 아니므로 버린다
* - 링크는 http·https·mailto 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`)
* - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단)
+ * - 보이게 할 때(옵션 `externalImageSrc` — 9단계 "외부 이미지 보기")는 `
` 의 src 만 중계 주소로 바꾼다:
+ * http · https · `//` 이고 기본 포트 · 2,048자 이하(images/mail-image-url.ts)인 주소를 한 통에 100개까지. 그 밖의 외부 이미지 ·
+ * srcset · style 과 속성의 배경 그림(url())은 지금처럼 지운다(알려진 한계). 같은 주소는 같은 중계 주소를 쓴다
* - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다
*
* ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 길이·중첩 비용을 본다 (mail.service.ts) —
@@ -29,8 +34,10 @@ import { normalizeContentId } from './inline-images';
export interface SanitizedMailHtml {
readonly html: string;
- /** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
+ /** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 (보이게 할 때도 지운 것 — srcset · 배경 그림 · 한도 밖 — 이 있으면 true) */
readonly hasBlockedImages: boolean;
+ /** 중계 주소로 바꿔 넣은 외부 이미지 수 (보이게 할 때만 — 같은 주소는 한 번) */
+ readonly proxiedImageCount: number;
/**
* 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값).
* 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다
@@ -295,28 +302,36 @@ function secureLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Attributes {
return next;
}
+/** 이미지 하나를 정할 때 쓰는 것 — 정화 한 번(한 통)마다 하나 */
+interface ImageContext {
+ /** 정규화한 Content-ID → data: URI */
+ readonly inlineImages: ReadonlyMap;
+ readonly markBlocked: () => void;
+ readonly markEmbedded: (contentId: string) => void;
+ /** 외부 이미지 주소 → 중계 주소 — 보이게 할 때만 있고, 넣지 못하면(주소 · 개수 한도) null */
+ readonly proxy: ((src: string) => string | null) | null;
+}
+
function resolveImage(
attribs: sanitizeHtml.Attributes,
- inlineImages: ReadonlyMap,
- markBlocked: () => void,
- markEmbedded: (contentId: string) => void,
+ context: ImageContext,
): sanitizeHtml.Attributes {
const next: sanitizeHtml.Attributes = { ...attribs };
delete next.src;
delete next.srcset;
if (attribs.srcset && EMBEDDED_EXTERNAL_URL.test(attribs.srcset)) {
- markBlocked();
+ context.markBlocked();
}
const src = attribs.src?.trim() ?? '';
if (/^cid:/i.test(src)) {
const contentId = normalizeContentId(src.slice(4));
- const dataUri = inlineImages.get(contentId);
+ const dataUri = context.inlineImages.get(contentId);
if (dataUri) {
next.src = dataUri;
// 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다
- markEmbedded(contentId);
+ context.markEmbedded(contentId);
}
return next;
}
@@ -324,7 +339,15 @@ function resolveImage(
next.src = src;
return next;
}
- if (EXTERNAL_URL.test(src)) markBlocked();
+ if (EXTERNAL_URL.test(src)) {
+ // 보이게 할 때 — 같은 서버의 중계 주소(상대 경로, 스킴 없음)로 바꾼다. 넣지 못한 것은 지운다
+ const proxied = context.proxy?.(src) ?? null;
+ if (proxied !== null) {
+ next.src = proxied;
+ return next;
+ }
+ context.markBlocked();
+ }
return next;
}
@@ -337,6 +360,14 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
);
}
+export interface SanitizeMailHtmlOptions {
+ /**
+ * 외부 이미지를 보인다(9단계) — 중계할 수 있는 원래 주소(정규화한 것 — images/mail-image-url.ts)를 받아 중계 주소(`/api/mail-images/…`)를 돌려준다.
+ * 없으면 지금처럼 외부 이미지를 지운다
+ */
+ readonly externalImageSrc?: (url: URL) => string;
+}
+
/**
* 메일 HTML 을 정화한다.
* @param inlineImages 정규화한 Content-ID → data: URI (서비스 계층이 크기·형식을 확인해 만든 것)
@@ -344,6 +375,7 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
export function sanitizeMailHtml(
html: string,
inlineImages: ReadonlyMap = new Map(),
+ options: SanitizeMailHtmlOptions = {},
): SanitizedMailHtml {
let hasBlockedImages = false;
const markBlocked = () => {
@@ -353,6 +385,27 @@ export function sanitizeMailHtml(
const markEmbedded = (contentId: string) => {
embeddedContentIds.add(contentId);
};
+ // 원래 주소(정규화한 것) → 중계 주소 — 같은 주소는 같은 중계 주소(브라우저가 한 번만 받는다). 한 통에 100개까지
+ const proxiedBySource = new Map();
+ const { externalImageSrc } = options;
+ const proxy = externalImageSrc
+ ? (src: string): string | null => {
+ const url = parseProxiedImageUrl(src);
+ if (url === null) return null;
+ const known = proxiedBySource.get(url.href);
+ if (known !== undefined) return known;
+ if (proxiedBySource.size >= MAX_PROXIED_IMAGES_PER_MESSAGE) return null;
+ const proxied = externalImageSrc(url);
+ proxiedBySource.set(url.href, proxied);
+ return proxied;
+ }
+ : null;
+ const imageContext: ImageContext = {
+ inlineImages,
+ markBlocked,
+ markEmbedded,
+ proxy,
+ };
const output = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
@@ -367,12 +420,7 @@ export function sanitizeMailHtml(
transformTags: {
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
img: (tagName, attribs) => {
- const next = resolveImage(
- attribs,
- inlineImages,
- markBlocked,
- markEmbedded,
- );
+ const next = resolveImage(attribs, imageContext);
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
@@ -390,5 +438,10 @@ export function sanitizeMailHtml(
},
});
- return { html: output, hasBlockedImages, embeddedContentIds };
+ return {
+ html: output,
+ hasBlockedImages,
+ embeddedContentIds,
+ proxiedImageCount: proxiedBySource.size,
+ };
}
diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts
index 1004267..d61a2c6 100644
--- a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts
+++ b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts
@@ -601,6 +601,7 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
html: '',
hasBlockedImages: false,
embeddedContentIds: new Set(),
+ proxiedImageCount: 0,
});
});
diff --git a/backend/src/modules/mail/html/signature-images.spec.ts b/backend/src/modules/mail/html/signature-images.spec.ts
index 6e2605e..5bf2855 100644
--- a/backend/src/modules/mail/html/signature-images.spec.ts
+++ b/backend/src/modules/mail/html/signature-images.spec.ts
@@ -1,7 +1,11 @@
+import { startCpuTimer } from '@/../test/cpu-time';
import {
findSignatureImageIds,
+ parseSignatureImageContentId,
parseSignatureImageId,
+ restoreSignatureImageSources,
rewriteSignatureImages,
+ signatureImageContentId,
signatureImagePath,
} from './signature-images';
@@ -56,4 +60,45 @@ describe('서명 그림 주소 (signature-images)', () => {
`서명
`,
);
});
+
+ it('보낸 메일의 서명 그림 Content-ID — 보내는 사람 도메인(소문자)으로 만들고, 그 꼴이면 그림 id 를 돌려준다', () => {
+ expect(signatureImageContentId(ID, 'Ara.Kim@ArASystem.test')).toBe(
+ `sig-${ID}@arasystem.test`,
+ );
+ expect(parseSignatureImageContentId(`sig-${ID}@arasystem.test`)).toBe(ID);
+ expect(
+ parseSignatureImageContentId(` SIG-${ID.toUpperCase()}@x.example `),
+ ).toBe(ID);
+ for (const cid of [
+ `logo@x.example`,
+ `sig-${ID}`,
+ `sig-not-a-uuid@x.example`,
+ `xsig-${ID}@x.example`,
+ `sig-${ID}@a@b`,
+ ]) {
+ expect(parseSignatureImageContentId(cid)).toBeNull();
+ }
+ });
+
+ it('취소한 발송을 이어 쓸 때(9단계) — 본문의 cid:sig-… 그림 주소를 서명 그림 주소로 되돌린다 (다른 그림 · 속성은 그대로)', () => {
+ const sent =
+ `서명
` +
+ `
` +
+ '
';
+
+ expect(restoreSignatureImageSources(sent)).toBe(
+ `서명
` +
+ `
` +
+ '
',
+ );
+ });
+
+ it('되돌리기는 입력 길이에 비례한다 — src= 뒤 공백이 아주 길어도', () => {
+ const cpuMs = startCpuTimer();
+ const html = `
`.repeat(4);
+
+ expect(restoreSignatureImageSources(html)).toBe(html);
+ // CPU 시간 (test/cpu-time.ts) — 지금 수십 ms. 공백을 여러 \s* 가 나눠 가지면 공백 수의 제곱이다
+ expect(cpuMs()).toBeLessThan(2_000);
+ });
});
diff --git a/backend/src/modules/mail/html/signature-images.ts b/backend/src/modules/mail/html/signature-images.ts
index 8f502b0..5debd0d 100644
--- a/backend/src/modules/mail/html/signature-images.ts
+++ b/backend/src/modules/mail/html/signature-images.ts
@@ -31,11 +31,55 @@ function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+/** 보낸 메일의 서명 그림 Content-ID — `sig-{그림 id}@{도메인}` (signatureImageContentId 가 만든 모양) */
+const SIGNATURE_IMAGE_CONTENT_ID = new RegExp(
+ `^sig-(${UUID})@[^\\s@<>]+$`,
+ 'i',
+);
+
+/**
+ * 보낸 메일 본문의 서명 그림 주소 — `src="cid:sig-{그림 id}@{도메인}"`. 따옴표는 정화가 쓰는 큰따옴표와 작은따옴표 둘 다.
+ * 같은 공백을 여러 `\s*` 가 나눠 갖지 않게 둔다(입력 길이에 비례)
+ */
+const SENT_SIGNATURE_IMAGE_SRC = new RegExp(
+ `\\bsrc\\s*=\\s*(["'])cid:sig-(${UUID})@[^"'\\s<>]*\\1`,
+ 'gi',
+);
+
/** 그림 id 의 주소 */
export function signatureImagePath(id: string): string {
return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`;
}
+/**
+ * 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다). 도메인은 보내는 사람의 것(소문자)
+ */
+export function signatureImageContentId(
+ imageId: string,
+ sender: string,
+): string {
+ const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
+ return `sig-${imageId}@${domain}`;
+}
+
+/** 우리가 보낸 메일의 서명 그림 부분인가 — Content-ID 가 `sig-{UUID}@…` 꼴이면 그 그림 id(소문자), 아니면 null */
+export function parseSignatureImageContentId(cid: string): string | null {
+ const match = cid.trim().match(SIGNATURE_IMAGE_CONTENT_ID);
+ return match ? match[1].toLowerCase() : null;
+}
+
+/**
+ * 보낸 메일 본문의 서명 그림(`cid:sig-…`)을 서명 그림 주소로 되돌린다 — 취소한 발송을 임시 저장본으로 이어 쓸 때(9단계 "보내기 취소").
+ * 보내는 정화 **전에** 쓴다 — 정화가 그 사람 자신의 그림만 남긴다(남의 그림 id · 지운 그림은 정화가 지운다)
+ */
+export function restoreSignatureImageSources(html: string): string {
+ return html.replace(
+ SENT_SIGNATURE_IMAGE_SRC,
+ (_whole, _quote: string, id: string) =>
+ `src="${signatureImagePath(id.toLowerCase())}"`,
+ );
+}
+
/** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */
export function parseSignatureImageId(src: string | undefined): string | null {
const match = src?.trim().match(SIGNATURE_IMAGE_SRC);
diff --git a/backend/src/modules/mail/images/mail-image-fetcher.spec.ts b/backend/src/modules/mail/images/mail-image-fetcher.spec.ts
new file mode 100644
index 0000000..68b1aaf
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-fetcher.spec.ts
@@ -0,0 +1,425 @@
+import { gifBytes, pngBytes } from '@/../test/image-fixtures';
+import {
+ MAIL_IMAGE_MAX_BYTES,
+ MAIL_IMAGE_USER_AGENT,
+} from './mail-image.constants';
+import {
+ MailImageFetcher,
+ type MailImageRequest,
+ type MailImageResolver,
+ type MailImageResponse,
+ type MailImageTransport,
+ type ResolvedAddress,
+} from './mail-image-fetcher';
+
+const PUBLIC: ResolvedAddress = { address: '93.184.216.34', family: 4 };
+const PUBLIC_V6: ResolvedAddress = {
+ address: '2606:2800:220:1:248:1893:25c8:1946',
+ family: 6,
+};
+
+/** 가짜 응답 — 본문 조각을 차례로 준다. 닫으면 closed */
+function reply(
+ status: number,
+ options: {
+ body?: Buffer[];
+ location?: string;
+ contentLength?: number | null;
+ failAfter?: number;
+ } = {},
+): MailImageResponse & { closed: boolean } {
+ const chunks = options.body ?? [];
+ const response = {
+ status,
+ location: options.location ?? null,
+ contentLength:
+ options.contentLength === undefined
+ ? chunks.reduce((sum, chunk) => sum + chunk.length, 0)
+ : options.contentLength,
+ closed: false,
+ body: (async function* () {
+ for (const [index, chunk] of chunks.entries()) {
+ if (options.failAfter !== undefined && index >= options.failAfter) {
+ throw new Error('socket hang up');
+ }
+ await Promise.resolve();
+ yield chunk;
+ }
+ })(),
+ close() {
+ response.closed = true;
+ },
+ };
+ return response;
+}
+
+/** 이름 → 주소 표 · 주소 → 응답 차례로 흉내 내는 대역 — 받은 요청을 모은다 */
+function fakes(
+ dns: Record,
+ replies: (
+ | MailImageResponse
+ | Error
+ | ((request: MailImageRequest) => Promise)
+ )[],
+) {
+ const lookups: string[] = [];
+ const requests: MailImageRequest[] = [];
+ const resolver: MailImageResolver = {
+ resolve: (hostname) => {
+ lookups.push(hostname);
+ const answer = dns[hostname];
+ if (answer === undefined) return Promise.reject(new Error('ENOTFOUND'));
+ return answer instanceof Error
+ ? Promise.reject(answer)
+ : Promise.resolve(answer);
+ },
+ };
+ const transport: MailImageTransport = {
+ get: (request) => {
+ requests.push(request);
+ const next = replies.shift();
+ if (next === undefined) return Promise.reject(new Error('no reply'));
+ if (next instanceof Error) return Promise.reject(next);
+ if (typeof next === 'function') return next(request);
+ return Promise.resolve(next);
+ },
+ };
+ return {
+ fetcher: new MailImageFetcher(resolver, transport),
+ lookups,
+ requests,
+ };
+}
+
+const live = () => new AbortController().signal;
+
+describe('MailImageFetcher — 바깥 이미지 받기 (SSRF 막기)', () => {
+ it('이름을 풀어 공인 주소면 그 주소로만 연결해 받는다 — 형식은 받은 바이트로, 머리글은 고정한 것뿐', async () => {
+ const png = pngBytes(600, 200, 2_000);
+ const { fetcher, lookups, requests } = fakes(
+ { 'news.example.com': [PUBLIC, PUBLIC_V6] },
+ [reply(200, { body: [png.subarray(0, 100), png.subarray(100)] })],
+ );
+
+ const result = await fetcher.fetch(
+ 'https://news.example.com/a.png?u=1',
+ live(),
+ );
+
+ expect(result).toEqual({ ok: true, type: 'image/png', data: png });
+ expect(lookups).toEqual(['news.example.com']);
+ expect(requests).toHaveLength(1);
+ expect(requests[0].url.href).toBe('https://news.example.com/a.png?u=1');
+ expect(requests[0].addresses).toEqual([PUBLIC, PUBLIC_V6]);
+ expect(requests[0].headers).toEqual({
+ 'user-agent': MAIL_IMAGE_USER_AGENT,
+ accept: expect.stringContaining('image/png') as unknown,
+ 'accept-encoding': 'identity',
+ });
+ // 쿠키 · Referer · 인증 머리글은 없다
+ for (const name of Object.keys(requests[0].headers)) {
+ expect(name).not.toMatch(/^(?:cookie|referer|authorization)$/i);
+ }
+ });
+
+ it('바깥이 보낸 형식과 상관없이 그림이 아니면(HTML · SVG) 주지 않는다', async () => {
+ const { fetcher } = fakes({ 'news.example.com': [PUBLIC] }, [
+ reply(200, {
+ body: [Buffer.from('')],
+ }),
+ ]);
+
+ await expect(
+ fetcher.fetch('https://news.example.com/a.png', live()),
+ ).resolves.toEqual({
+ ok: false,
+ reason: 'not-an-image',
+ host: 'news.example.com',
+ });
+ });
+
+ it.each([
+ ['ftp', 'ftp://news.example.com/a.png'],
+ ['기본 포트가 아님', 'https://news.example.com:8080/a.png'],
+ ['사용자 정보', 'https://u:p@news.example.com/a.png'],
+ ['깨진 주소', 'not a url'],
+ ])(
+ '중계할 수 없는 주소(%s)는 이름을 풀지도 연결하지도 않는다',
+ async (_label, url) => {
+ const { fetcher, lookups, requests } = fakes({}, []);
+
+ const result = await fetcher.fetch(url, live());
+
+ expect(result).toMatchObject({ ok: false, reason: 'bad-url' });
+ expect(lookups).toEqual([]);
+ expect(requests).toEqual([]);
+ },
+ );
+
+ it.each([
+ 'http://127.0.0.1/a.png',
+ 'http://2130706433/a.png',
+ 'http://10.0.0.5/a.png',
+ 'http://172.16.71.3/api',
+ 'http://169.254.169.254/latest/meta-data/',
+ 'http://[::1]/a.png',
+ 'http://[::ffff:127.0.0.1]/a.png',
+ 'http://[fd00::1]/a.png',
+ ])(
+ 'IP 로 쓴 사설 · 루프백 · 링크 로컬 주소(%s)는 연결하지 않는다',
+ async (url) => {
+ const { fetcher, lookups, requests } = fakes({}, []);
+
+ const result = await fetcher.fetch(url, live());
+
+ expect(result).toMatchObject({ ok: false, reason: 'not-public-address' });
+ expect(lookups).toEqual([]);
+ expect(requests).toEqual([]);
+ },
+ );
+
+ it('IP 로 쓴 공인 주소는 이름을 풀지 않고 그 주소로 연결한다', async () => {
+ const { fetcher, lookups, requests } = fakes({}, [
+ reply(200, { body: [gifBytes(1, 1)] }),
+ ]);
+
+ const result = await fetcher.fetch('http://93.184.216.34/p.gif', live());
+
+ expect(result).toMatchObject({ ok: true, type: 'image/gif' });
+ expect(lookups).toEqual([]);
+ expect(requests[0].addresses).toEqual([PUBLIC]);
+ });
+
+ it('점이 없는 이름(도커 서비스 이름 · localhost)은 풀어 보지도 않는다', async () => {
+ const { fetcher, lookups, requests } = fakes({ mail: [PUBLIC] }, []);
+
+ for (const url of ['http://mail/a.png', 'http://localhost/a.png']) {
+ expect(await fetcher.fetch(url, live())).toMatchObject({
+ ok: false,
+ reason: 'single-label-host',
+ });
+ }
+ expect(lookups).toEqual([]);
+ expect(requests).toEqual([]);
+ });
+
+ it('풀어 나온 주소 가운데 하나라도 공인이 아니면(DNS 로 사내 주소를 섞음) 연결하지 않는다', async () => {
+ const { fetcher, requests } = fakes(
+ {
+ 'mixed.example.com': [PUBLIC, { address: '10.0.0.7', family: 4 }],
+ 'inside.example.com': [{ address: '127.0.0.1', family: 4 }],
+ 'v6.example.com': [{ address: 'fe80::1', family: 6 }],
+ },
+ [],
+ );
+
+ for (const host of ['mixed', 'inside', 'v6']) {
+ expect(
+ await fetcher.fetch(`https://${host}.example.com/a.png`, live()),
+ ).toEqual({
+ ok: false,
+ reason: 'not-public-address',
+ host: `${host}.example.com`,
+ });
+ }
+ expect(requests).toEqual([]);
+ });
+
+ it('이름을 풀지 못하거나 주소가 없으면 dns-failed', async () => {
+ const { fetcher } = fakes(
+ { 'empty.example.com': [], 'broken.example.com': new Error('ESERVFAIL') },
+ [],
+ );
+
+ for (const host of ['empty', 'broken', 'nowhere']) {
+ expect(
+ await fetcher.fetch(`https://${host}.example.com/a.png`, live()),
+ ).toMatchObject({ ok: false, reason: 'dns-failed' });
+ }
+ });
+
+ it('다시 가기는 매번 처음과 같은 검사 — 상대 주소는 이어 풀고, 사설 주소 · 다른 스킴으로 가면 멈춘다', async () => {
+ const png = pngBytes(10, 10);
+ const ok = fakes({ 'a.example.com': [PUBLIC], 'b.example.com': [PUBLIC] }, [
+ reply(302, { location: '/moved.png' }),
+ reply(301, { location: 'https://b.example.com/final.png' }),
+ reply(200, { body: [png] }),
+ ]);
+ await expect(
+ ok.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).resolves.toMatchObject({ ok: true, type: 'image/png' });
+ expect(ok.requests.map((request) => request.url.href)).toEqual([
+ 'http://a.example.com/a.png',
+ 'http://a.example.com/moved.png',
+ 'https://b.example.com/final.png',
+ ]);
+
+ const toPrivate = fakes(
+ {
+ 'a.example.com': [PUBLIC],
+ 'evil.example.com': [{ address: '172.16.71.5', family: 4 }],
+ },
+ [reply(302, { location: 'http://evil.example.com/' })],
+ );
+ await expect(
+ toPrivate.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).resolves.toEqual({
+ ok: false,
+ reason: 'not-public-address',
+ host: 'evil.example.com',
+ });
+ expect(toPrivate.requests).toHaveLength(1);
+
+ for (const location of [
+ 'http://127.0.0.1:8080/admin',
+ 'file:///etc/passwd',
+ 'gopher://a.example.com/',
+ '',
+ ]) {
+ const bad = fakes({ 'a.example.com': [PUBLIC] }, [
+ reply(307, { location }),
+ ]);
+ expect(
+ await bad.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'bad-redirect' });
+ expect(bad.requests).toHaveLength(1);
+ }
+ });
+
+ it('다시 가기는 3번까지 — 네 번째는 따라가지 않는다', async () => {
+ const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [
+ reply(302, { location: '/1' }),
+ reply(302, { location: '/2' }),
+ reply(302, { location: '/3' }),
+ reply(302, { location: '/4' }),
+ ]);
+
+ expect(await fetcher.fetch('http://a.example.com/0', live())).toMatchObject(
+ { ok: false, reason: 'too-many-redirects' },
+ );
+ expect(requests).toHaveLength(4);
+ });
+
+ it('200 이 아닌 응답(404 · 304 · 500)은 주지 않고 본문을 닫는다', async () => {
+ for (const status of [404, 304, 500, 206]) {
+ const response = reply(status, { body: [pngBytes(1, 1)] });
+ const { fetcher } = fakes({ 'a.example.com': [PUBLIC] }, [response]);
+ expect(
+ await fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'bad-status' });
+ expect(response.closed).toBe(true);
+ }
+ });
+
+ it(`알려 준 길이가 ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB 를 넘으면 읽지 않고, 읽는 중에 넘으면 끊는다`, async () => {
+ const declared = reply(200, {
+ body: [pngBytes(1, 1)],
+ contentLength: MAIL_IMAGE_MAX_BYTES + 1,
+ });
+ const first = fakes({ 'a.example.com': [PUBLIC] }, [declared]);
+ expect(
+ await first.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'too-large' });
+ expect(declared.closed).toBe(true);
+
+ const chunk = Buffer.alloc(1024 * 1024);
+ pngBytes(1, 1).copy(chunk);
+ const streamed = reply(200, {
+ body: Array.from({ length: 6 }, () => chunk),
+ contentLength: null,
+ });
+ const second = fakes({ 'a.example.com': [PUBLIC] }, [streamed]);
+ expect(
+ await second.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'too-large' });
+ expect(streamed.closed).toBe(true);
+ });
+
+ it('길이를 알려 주면 그 길이로 한 번 잡은 버퍼에 조각을 바로 쓴다 — 알려 주지 않으면(chunked) 모아 한 번 잇는다, 바이트는 같다', async () => {
+ const png = pngBytes(40, 30, 50_000);
+ const parts = [
+ png.subarray(0, 7),
+ png.subarray(7, 30_000),
+ png.subarray(30_000),
+ ];
+ const concat = jest.spyOn(Buffer, 'concat');
+ try {
+ const declared = fakes({ 'a.example.com': [PUBLIC] }, [
+ reply(200, { body: parts, contentLength: png.length }),
+ ]);
+ expect(
+ await declared.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toEqual({ ok: true, type: 'image/png', data: png });
+ // 알려 준 길이는 잇기 없이
+ expect(concat).not.toHaveBeenCalled();
+
+ const chunked = fakes({ 'a.example.com': [PUBLIC] }, [
+ reply(200, { body: parts, contentLength: null }),
+ ]);
+ expect(
+ await chunked.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toEqual({ ok: true, type: 'image/png', data: png });
+ // 모르는 길이는 끝에 한 번만 잇는다
+ expect(concat).toHaveBeenCalledTimes(1);
+ } finally {
+ concat.mockRestore();
+ }
+ });
+
+ it('알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length) — 닫는다, 덜 오고 끝나면 받은 만큼으로 판정한다', async () => {
+ const png = pngBytes(10, 10);
+ const longer = reply(200, {
+ body: [png, Buffer.alloc(10)],
+ contentLength: png.length,
+ });
+ const over = fakes({ 'a.example.com': [PUBLIC] }, [longer]);
+ expect(
+ await over.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'bad-length' });
+ expect(longer.closed).toBe(true);
+
+ const shorter = fakes({ 'a.example.com': [PUBLIC] }, [
+ reply(200, { body: [png], contentLength: png.length + 100 }),
+ ]);
+ expect(
+ await shorter.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toEqual({ ok: true, type: 'image/png', data: png });
+ });
+
+ it('연결하지 못함 · 본문을 읽다 끊김은 connect-failed', async () => {
+ const refused = fakes({ 'a.example.com': [PUBLIC] }, [
+ new Error('ECONNREFUSED'),
+ ]);
+ expect(
+ await refused.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'connect-failed' });
+
+ const broken = reply(200, {
+ body: [pngBytes(1, 1), Buffer.alloc(10)],
+ failAfter: 1,
+ });
+ const cut = fakes({ 'a.example.com': [PUBLIC] }, [broken]);
+ expect(
+ await cut.fetcher.fetch('http://a.example.com/a.png', live()),
+ ).toMatchObject({ ok: false, reason: 'connect-failed' });
+ expect(broken.closed).toBe(true);
+ });
+
+ it('클라이언트가 끊으면 받던 것을 멈추고 canceled — 전송에 같은 신호를 넘긴다', async () => {
+ const controller = new AbortController();
+ const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [
+ (request) =>
+ new Promise((_resolve, reject) => {
+ request.signal.addEventListener('abort', () =>
+ reject(new Error('aborted')),
+ );
+ controller.abort();
+ }),
+ ]);
+
+ await expect(
+ fetcher.fetch('http://a.example.com/a.png', controller.signal),
+ ).resolves.toMatchObject({ ok: false, reason: 'canceled' });
+ expect(requests[0].signal.aborted).toBe(true);
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image-fetcher.ts b/backend/src/modules/mail/images/mail-image-fetcher.ts
new file mode 100644
index 0000000..4b75802
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-fetcher.ts
@@ -0,0 +1,304 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { isIP } from 'net';
+
+import { isPublicIpAddress } from '@/shared/utils/public-address';
+import {
+ MAIL_IMAGE_FETCH_TIMEOUT_MS,
+ MAIL_IMAGE_MAX_BYTES,
+ MAIL_IMAGE_MAX_REDIRECTS,
+ MAIL_IMAGE_USER_AGENT,
+} from './mail-image.constants';
+import {
+ detectProxiedImageType,
+ type ProxiedImageType,
+} from './mail-image-format';
+import {
+ checkProxiedImageUrl,
+ hostOf,
+ parseProxiedImageUrl,
+} from './mail-image-url';
+
+/**
+ * 바깥 이미지 받기 (계약 2절 "외부 이미지 보기" — 9단계) — 이 서버가 직원 대신 바깥 서버에 묻는 길이라 SSRF 를 막는 곳이다.
+ *
+ * - 주소: http · https · 기본 포트 · 사용자 정보 없음 (mail-image-url.ts)
+ * - 이름을 풀어 나온 **모든** 주소가 공인 주소일 때만, 그 주소로만 연결한다 — 연결할 때 이름을 다시 풀지 않는다(DNS 바꿔치기 막기).
+ * IP 로 쓴 주소는 그 주소를 그대로 본다. 점이 없는 이름(`mail` · `db` 같은 도커 서비스 이름)은 풀어 보지도 않는다
+ * - 다시 가기(redirect 301 · 302 · 303 · 307 · 308)는 3번까지 — 매번 위 검사를 처음부터 다시
+ * - 전체 10초(이름 풀기 · 연결 · 다시 가기 · 본문 모두), 받는 크기 5MB — 넘으면 끊는다
+ * - 형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP) — 바깥의 Content-Type 은 믿지 않는다
+ * - 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다 (연결 · 머리글은 전송(transport)이 만든다)
+ *
+ * 이름 풀기(MAIL_IMAGE_RESOLVER)와 전송(MAIL_IMAGE_TRANSPORT)은 주입받는다 — 단위 테스트는 가짜로, 앱은 node-image-transport.ts 의 구현으로
+ */
+
+/** 이름을 풀어 나온 주소 하나 */
+export interface ResolvedAddress {
+ readonly address: string;
+ readonly family: 4 | 6;
+}
+
+/** 이름 풀기 — 나온 주소를 모두 준다(A · AAAA). 풀지 못하면 던진다. signal 이 끊기면 멈춘다 */
+export interface MailImageResolver {
+ resolve(hostname: string, signal: AbortSignal): Promise;
+}
+export const MAIL_IMAGE_RESOLVER = Symbol('MAIL_IMAGE_RESOLVER');
+
+/** 한 번 묻는 것 — 확인한 주소로만 연결한다 */
+export interface MailImageRequest {
+ readonly url: URL;
+ /** 이 주소들로만 연결한다 (이름을 다시 풀지 않는다) — 모두 공인 주소로 확인한 것 */
+ readonly addresses: readonly ResolvedAddress[];
+ readonly headers: Readonly>;
+ readonly signal: AbortSignal;
+}
+
+/** 받은 응답 — 본문은 다 읽거나 close() 로 닫는다 */
+export interface MailImageResponse {
+ readonly status: number;
+ /** Location 머리글 (없으면 null) */
+ readonly location: string | null;
+ /** Content-Length (없거나 읽을 수 없으면 null) */
+ readonly contentLength: number | null;
+ readonly body: AsyncIterable;
+ close(): void;
+}
+
+/** 전송 — GET 한 번. 연결하지 못하면 던진다 */
+export interface MailImageTransport {
+ get(request: MailImageRequest): Promise;
+}
+export const MAIL_IMAGE_TRANSPORT = Symbol('MAIL_IMAGE_TRANSPORT');
+
+/** 받지 못한 까닭 — 로그(debug)에만 쓴다. 응답은 모두 404 */
+export type MailImageFailureReason =
+ | 'bad-url'
+ | 'single-label-host'
+ | 'not-public-address'
+ | 'dns-failed'
+ | 'connect-failed'
+ | 'bad-status'
+ | 'bad-redirect'
+ | 'too-many-redirects'
+ | 'too-large'
+ | 'bad-length'
+ | 'not-an-image'
+ | 'timeout'
+ | 'canceled';
+
+export type MailImageFetchResult =
+ | {
+ readonly ok: true;
+ readonly type: ProxiedImageType;
+ readonly data: Buffer;
+ }
+ | {
+ readonly ok: false;
+ readonly reason: MailImageFailureReason;
+ /** 그때 묻던 바깥 호스트 이름 (주소 전체는 남기지 않는다) — 모르면 null */
+ readonly host: string | null;
+ };
+
+/** 다시 가기 응답 */
+const REDIRECT_STATUSES: ReadonlySet = new Set([
+ 301, 302, 303, 307, 308,
+]);
+
+const HTTP_OK = 200;
+
+/** 바깥으로 보내는 머리글 — 이것뿐이다 (쿠키 · Referer · 브라우저의 머리글은 넘기지 않는다) */
+const REQUEST_HEADERS: Readonly> = Object.freeze({
+ 'user-agent': MAIL_IMAGE_USER_AGENT,
+ accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1',
+ // 압축하지 않은 바이트로 받는다 — 형식은 받은 바이트의 앞부분으로 정한다
+ 'accept-encoding': 'identity',
+});
+
+/** 실패를 결과로 — 던지지 않는다 */
+class FetchFailure extends Error {
+ constructor(
+ readonly reason: MailImageFailureReason,
+ readonly host: string | null,
+ ) {
+ super(reason);
+ }
+}
+
+@Injectable()
+export class MailImageFetcher {
+ constructor(
+ @Inject(MAIL_IMAGE_RESOLVER) private readonly resolver: MailImageResolver,
+ @Inject(MAIL_IMAGE_TRANSPORT)
+ private readonly transport: MailImageTransport,
+ ) {}
+
+ /**
+ * 바깥 이미지를 받는다 — 받으면 형식 · 바이트, 못 받으면 까닭 (던지지 않는다).
+ * @param signal 클라이언트가 끊으면 멈춘다 (전체 10초 제한은 여기서 더한다)
+ */
+ async fetch(
+ source: string,
+ signal: AbortSignal,
+ ): Promise {
+ const deadline = AbortSignal.timeout(MAIL_IMAGE_FETCH_TIMEOUT_MS);
+ const combined = AbortSignal.any([signal, deadline]);
+ let host: string | null = null;
+ try {
+ let url = parseProxiedImageUrl(source);
+ if (url === null) throw new FetchFailure('bad-url', null);
+
+ for (let redirects = 0; ; redirects += 1) {
+ host = hostOf(url);
+ const addresses = await this.publicAddresses(url, combined);
+ const response = await this.request(url, addresses, combined);
+
+ if (REDIRECT_STATUSES.has(response.status)) {
+ response.close();
+ if (redirects >= MAIL_IMAGE_MAX_REDIRECTS) {
+ throw new FetchFailure('too-many-redirects', host);
+ }
+ url = this.redirectTarget(url, response.location);
+ continue;
+ }
+ if (response.status !== HTTP_OK) {
+ response.close();
+ throw new FetchFailure('bad-status', host);
+ }
+ const data = await readCapped(response, host);
+ const type = detectProxiedImageType(data);
+ if (type === null) throw new FetchFailure('not-an-image', host);
+ return { ok: true, type, data };
+ }
+ } catch (error) {
+ if (signal.aborted) return { ok: false, reason: 'canceled', host };
+ if (deadline.aborted) return { ok: false, reason: 'timeout', host };
+ if (error instanceof FetchFailure) {
+ return { ok: false, reason: error.reason, host: error.host };
+ }
+ return { ok: false, reason: 'connect-failed', host };
+ }
+ }
+
+ /** 연결해도 되는 주소 — IP 로 쓴 주소는 그대로, 이름은 풀어서. 하나라도 공인 주소가 아니면 실패 */
+ private async publicAddresses(
+ url: URL,
+ signal: AbortSignal,
+ ): Promise {
+ const host = hostOf(url);
+ const family = isIP(host);
+ if (family === 4 || family === 6) {
+ if (!isPublicIpAddress(host)) {
+ throw new FetchFailure('not-public-address', host);
+ }
+ return [{ address: host, family }];
+ }
+ // 점이 없는 이름은 사내 · 도커 망의 이름이다(mail · db · localhost) — 풀어 보지 않는다
+ if (!host.includes('.')) throw new FetchFailure('single-label-host', host);
+
+ let addresses: ResolvedAddress[];
+ try {
+ addresses = await this.resolver.resolve(host, signal);
+ } catch {
+ throw new FetchFailure('dns-failed', host);
+ }
+ if (addresses.length === 0) throw new FetchFailure('dns-failed', host);
+ if (!addresses.every(({ address }) => isPublicIpAddress(address))) {
+ throw new FetchFailure('not-public-address', host);
+ }
+ return addresses;
+ }
+
+ private async request(
+ url: URL,
+ addresses: readonly ResolvedAddress[],
+ signal: AbortSignal,
+ ): Promise {
+ try {
+ return await this.transport.get({
+ url,
+ addresses,
+ headers: REQUEST_HEADERS,
+ signal,
+ });
+ } catch {
+ throw new FetchFailure('connect-failed', hostOf(url));
+ }
+ }
+
+ /** 다시 갈 주소 — 지금 주소에 이어 푼 뒤 처음과 같은 검사를 한다 */
+ private redirectTarget(current: URL, location: string | null): URL {
+ if (location === null || location.trim() === '') {
+ throw new FetchFailure('bad-redirect', hostOf(current));
+ }
+ let next: URL;
+ try {
+ next = new URL(location.trim(), current);
+ } catch {
+ throw new FetchFailure('bad-redirect', hostOf(current));
+ }
+ const checked = checkProxiedImageUrl(next);
+ if (checked === null) {
+ throw new FetchFailure('bad-redirect', hostOf(current));
+ }
+ return checked;
+ }
+}
+
+/**
+ * 본문을 읽는다 — 5MB 를 넘으면(알려 준 길이든 읽는 중이든) 닫고 실패. 받은 조각을 두 번 베끼지 않는다(동시 16장 × 5MB — 2026-10-29 리뷰):
+ * - 길이를 알려 주면(Content-Length) 그만큼 한 번 잡아 조각을 바로 그 자리에 쓴다 — 알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length)
+ * - 모르면(chunked) 받은 조각을 그대로 모아 끝에 한 번만 잇는다
+ */
+async function readCapped(
+ response: MailImageResponse,
+ host: string,
+): Promise {
+ const declared = response.contentLength;
+ if (declared !== null && declared > MAIL_IMAGE_MAX_BYTES) {
+ response.close();
+ throw new FetchFailure('too-large', host);
+ }
+ try {
+ return declared !== null
+ ? await readDeclared(response, declared, host)
+ : await readChunked(response, host);
+ } catch (error) {
+ response.close();
+ throw error;
+ }
+}
+
+/** 알려 준 길이만큼 한 번 잡은 버퍼에 바로 쓴다 — 덜 오면(끊김 없이 끝남) 받은 만큼만 */
+async function readDeclared(
+ response: MailImageResponse,
+ declared: number,
+ host: string,
+): Promise {
+ const data = Buffer.allocUnsafe(declared);
+ let offset = 0;
+ for await (const chunk of response.body) {
+ if (offset + chunk.length > declared) {
+ throw new FetchFailure('bad-length', host);
+ }
+ data.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return offset === declared ? data : data.subarray(0, offset);
+}
+
+/** 길이를 모르는 본문 — 조각을 베끼지 않고 모아 끝에 한 번 잇는다 */
+async function readChunked(
+ response: MailImageResponse,
+ host: string,
+): Promise {
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ for await (const chunk of response.body) {
+ total += chunk.length;
+ if (total > MAIL_IMAGE_MAX_BYTES) {
+ throw new FetchFailure('too-large', host);
+ }
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks, total);
+}
diff --git a/backend/src/modules/mail/images/mail-image-format.spec.ts b/backend/src/modules/mail/images/mail-image-format.spec.ts
new file mode 100644
index 0000000..8511c12
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-format.spec.ts
@@ -0,0 +1,49 @@
+import {
+ gifBytes,
+ jpegBytes,
+ pngBytes,
+ webpBytes,
+} from '@/../test/image-fixtures';
+import { detectProxiedImageType } from './mail-image-format';
+
+describe('detectProxiedImageType — 받은 바이트로 정하는 형식', () => {
+ it('PNG · JPEG · GIF · WebP(손실 · 무손실 · 확장)를 알아본다', () => {
+ expect(detectProxiedImageType(pngBytes(600, 200))).toBe('image/png');
+ expect(detectProxiedImageType(jpegBytes(640, 480))).toBe('image/jpeg');
+ expect(detectProxiedImageType(jpegBytes(640, 480, { sof: 0xc2 }))).toBe(
+ 'image/jpeg',
+ );
+ expect(detectProxiedImageType(gifBytes(1, 1))).toBe('image/gif');
+ expect(detectProxiedImageType(gifBytes(1, 1, 'GIF87a'))).toBe('image/gif');
+ for (const chunk of ['VP8 ', 'VP8L', 'VP8X']) {
+ expect(detectProxiedImageType(webpBytes(chunk))).toBe('image/webp');
+ }
+ });
+
+ it.each([
+ [
+ 'SVG',
+ Buffer.from(
+ '',
+ ),
+ ],
+ ['HTML', Buffer.from('hi')],
+ ['빈 바이트', Buffer.alloc(0)],
+ [
+ 'PNG 서명만',
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ ],
+ [
+ '다른 RIFF(WAV)',
+ Buffer.concat([
+ Buffer.from('RIFF\0\0\0\0WAVEfmt ', 'latin1'),
+ Buffer.alloc(16),
+ ]),
+ ],
+ ['모르는 WebP 덩어리', webpBytes('ABCD')],
+ ['BMP', Buffer.from('BM\0\0\0\0\0\0\0\0\0\0\0\0', 'latin1')],
+ ['gzip 으로 압축한 것', Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00])],
+ ])('%s 는 그림으로 보지 않는다', (_label, data) => {
+ expect(detectProxiedImageType(data)).toBeNull();
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image-format.ts b/backend/src/modules/mail/images/mail-image-format.ts
new file mode 100644
index 0000000..6573183
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-format.ts
@@ -0,0 +1,36 @@
+import { detectSignatureImage } from '@/modules/mail-signature/signature-image-format';
+
+/**
+ * 바깥에서 받은 이미지의 형식 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 바이트의 앞부분으로 정한다.
+ * 바깥이 보낸 Content-Type 은 믿지 않는다 — PNG · JPEG · GIF · WebP 만 받고 SVG(스크립트를 담을 수 있다) · 그 밖은 받지 않는다.
+ * PNG · JPEG · GIF 는 서명 그림과 같은 판별(머리까지 맞아야 한다 — mail-signature/signature-image-format.ts)을 쓰고, WebP 는 RIFF 머리를 본다
+ */
+
+export type ProxiedImageType =
+ | 'image/png'
+ | 'image/jpeg'
+ | 'image/gif'
+ | 'image/webp';
+
+const RIFF = Buffer.from('RIFF', 'ascii');
+const WEBP = Buffer.from('WEBP', 'ascii');
+/** WebP 의 첫 덩어리 — 손실 · 무손실 · 확장 */
+const WEBP_CHUNKS: ReadonlySet = new Set(['VP8 ', 'VP8L', 'VP8X']);
+/** 'RIFF' 크기(4) 'WEBP' 덩어리 이름(4) */
+const WEBP_HEADER_BYTES = 16;
+
+function isWebp(data: Buffer): boolean {
+ return (
+ data.length >= WEBP_HEADER_BYTES &&
+ data.subarray(0, 4).equals(RIFF) &&
+ data.subarray(8, 12).equals(WEBP) &&
+ WEBP_CHUNKS.has(data.subarray(12, 16).toString('latin1'))
+ );
+}
+
+/** 받은 바이트의 형식 — 그림이 아니거나 머리가 망가졌으면 null */
+export function detectProxiedImageType(data: Buffer): ProxiedImageType | null {
+ const known = detectSignatureImage(data);
+ if (known) return known.type;
+ return isWebp(data) ? 'image/webp' : null;
+}
diff --git a/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts b/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts
new file mode 100644
index 0000000..5b399c0
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-proxy.service.spec.ts
@@ -0,0 +1,456 @@
+import { Logger } from '@nestjs/common';
+
+import { pngBytes } from '@/../test/image-fixtures';
+import {
+ MAIL_IMAGE_CONCURRENCY,
+ MAIL_IMAGE_CONCURRENCY_PER_USER,
+ MAIL_IMAGE_MAX_WAITING_PER_USER,
+ MAIL_IMAGE_QUEUE_WAIT_MS,
+} from './mail-image.constants';
+import type {
+ MailImageFetcher,
+ MailImageFetchResult,
+} from './mail-image-fetcher';
+import { MailImageProxyService } from './mail-image-proxy.service';
+import { MailImageTokens } from './mail-image-token';
+
+const ARA = 'ara.kim@arasystem.test';
+const DOYUN = 'doyun.lee@arasystem.test';
+const SOURCE = 'https://news.example.com/open.gif?u=8812';
+const PNG = pngBytes(10, 10);
+
+/** 테스트가 열 때까지 끝나지 않는 받기 */
+interface PendingFetch {
+ readonly source: string;
+ readonly signal: AbortSignal;
+ finish(result: MailImageFetchResult): void;
+}
+
+function setup() {
+ const tokens = new MailImageTokens();
+ const pending: PendingFetch[] = [];
+ const fetcher = {
+ fetch: jest.fn(
+ (source: string, signal: AbortSignal) =>
+ new Promise((resolve) => {
+ pending.push({ source, signal, finish: resolve });
+ }),
+ ),
+ };
+ const service = new MailImageProxyService(
+ tokens,
+ fetcher as unknown as MailImageFetcher,
+ );
+ return { tokens, fetcher, pending, service };
+}
+
+const OK: MailImageFetchResult = { ok: true, type: 'image/png', data: PNG };
+
+/** 기다리던 일(마이크로태스크)이 돌게 한다 */
+const flush = async () => {
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
+};
+
+/** 직원 n 명의 주소 */
+const employee = (n: number) => `user${n}@arasystem.test`;
+
+/**
+ * 남은 받기를 모두 끝내고 받은 차례를 모두 돌려준다 — 돌려주면 기다리던 것이 들어와 새 받기가 생기므로 다 끝날 때까지 되풀이한다
+ * @returns 끝난 수 (모두 끝났는지는 부른 쪽이 본다)
+ */
+async function drain(
+ pending: PendingFetch[],
+ opens: Promise<{ release(): void } | null>[],
+): Promise {
+ const settled = new Set();
+ opens.forEach((open, index) => {
+ void open.then((image) => {
+ image?.release();
+ settled.add(index);
+ });
+ });
+ for (let round = 0; settled.size < opens.length && round < 100; round += 1) {
+ for (const fetch of pending) fetch.finish(OK);
+ await flush();
+ }
+ return settled.size;
+}
+
+describe('MailImageProxyService — 외부 이미지 중계', () => {
+ let debugSpy: jest.SpyInstance;
+ beforeEach(() => {
+ debugSpy = jest
+ .spyOn(Logger.prototype, 'debug')
+ .mockImplementation(() => undefined);
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ it('표를 풀어 원래 주소로 받는다 — 받으면 형식 · 바이트와 차례(release 할 때까지 쥔다 · 두 번 불러도 한 번)', async () => {
+ const { tokens, pending, service } = setup();
+ const token = tokens.issue(ARA, SOURCE);
+
+ const opened = service.open(ARA, token, new AbortController().signal);
+ await flush();
+ expect(pending.map(({ source }) => source)).toEqual([SOURCE]);
+ pending[0].finish(OK);
+
+ const image = await opened;
+ expect(image).toEqual({
+ type: 'image/png',
+ data: PNG,
+ release: expect.any(Function) as unknown,
+ });
+ // 받은 뒤에도 응답을 다 보낼 때까지(release) 자리를 쥔다
+ expect(service.load(ARA)).toMatchObject({ active: 1, totalActive: 1 });
+ image?.release();
+ image?.release();
+ expect(service.load(ARA)).toEqual({
+ active: 0,
+ waiting: 0,
+ totalActive: 0,
+ totalWaiting: 0,
+ });
+ });
+
+ it('다른 직원의 표 · 고친 표는 바깥에 묻지 않고 null', async () => {
+ const { tokens, fetcher, service } = setup();
+ const token = tokens.issue(ARA, SOURCE);
+
+ await expect(
+ service.open(DOYUN, token, new AbortController().signal),
+ ).resolves.toBeNull();
+ await expect(
+ service.open(ARA, `${token}x`, new AbortController().signal),
+ ).resolves.toBeNull();
+ expect(fetcher.fetch).not.toHaveBeenCalled();
+ });
+
+ it('받지 못하면 null 이고 차례를 바로 돌려준다 — 로그(debug)에는 까닭과 바깥 호스트 이름만, 원래 주소는 남기지 않는다', async () => {
+ const { tokens, pending, service } = setup();
+ const opened = service.open(
+ ARA,
+ tokens.issue(ARA, SOURCE),
+ new AbortController().signal,
+ );
+ await flush();
+ expect(service.load(ARA).active).toBe(1);
+ pending[0].finish({
+ ok: false,
+ reason: 'not-public-address',
+ host: 'news.example.com',
+ });
+
+ await expect(opened).resolves.toBeNull();
+ expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
+ const logged = debugSpy.mock.calls.map(([message]) => String(message));
+ expect(logged.some((line) => line.includes('not-public-address'))).toBe(
+ true,
+ );
+ expect(logged.some((line) => line.includes('news.example.com'))).toBe(true);
+ for (const line of logged) {
+ expect(line).not.toContain('open.gif');
+ expect(line).not.toContain('u=8812');
+ expect(line).not.toContain(ARA);
+ }
+ });
+
+ it('받는 중에 클라이언트가 끊으면(받기가 canceled 로 끝남) 차례를 돌려준다', async () => {
+ const { tokens, pending, service } = setup();
+ const leaving = new AbortController();
+ const opened = service.open(ARA, tokens.issue(ARA, SOURCE), leaving.signal);
+ await flush();
+ expect(pending[0].signal).toBe(leaving.signal);
+
+ leaving.abort();
+ pending[0].finish({ ok: false, reason: 'canceled', host: null });
+ await expect(opened).resolves.toBeNull();
+ expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
+ });
+
+ it('받기가 던져도 차례를 돌려준다', async () => {
+ const { tokens, fetcher, service } = setup();
+ fetcher.fetch.mockRejectedValueOnce(new Error('뜻밖의 오류'));
+
+ await expect(
+ service.open(
+ ARA,
+ tokens.issue(ARA, SOURCE),
+ new AbortController().signal,
+ ),
+ ).rejects.toThrow('뜻밖의 오류');
+ expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
+ });
+
+ it(`한 직원은 동시에 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지 받고 나머지는 기다린다 — 하나를 돌려주면 다음이 들어간다, 다른 직원은 따로 센다`, async () => {
+ const { tokens, pending, service } = setup();
+ const token = tokens.issue(ARA, SOURCE);
+ const opens = Array.from(
+ { length: MAIL_IMAGE_CONCURRENCY_PER_USER + 2 },
+ () => service.open(ARA, token, new AbortController().signal),
+ );
+ await flush();
+
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER);
+ expect(service.load(ARA)).toMatchObject({
+ active: MAIL_IMAGE_CONCURRENCY_PER_USER,
+ waiting: 2,
+ });
+
+ const other = service.open(
+ DOYUN,
+ tokens.issue(DOYUN, SOURCE),
+ new AbortController().signal,
+ );
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1);
+
+ // 받기만 끝나서는 자리가 나지 않는다 — 응답을 다 보내고 돌려줘야(release) 다음이 들어간다
+ pending[0].finish(OK);
+ const first = await opens[0];
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1);
+ first?.release();
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 2);
+ expect(service.load(ARA)).toMatchObject({
+ active: MAIL_IMAGE_CONCURRENCY_PER_USER,
+ waiting: 1,
+ });
+
+ // 나머지를 모두 받고 돌려준다
+ const rest = [...opens.slice(1), other];
+ await expect(drain(pending, rest)).resolves.toBe(rest.length);
+ expect(service.load(ARA)).toEqual({
+ active: 0,
+ waiting: 0,
+ totalActive: 0,
+ totalWaiting: 0,
+ });
+ expect(service.load(DOYUN)).toMatchObject({ active: 0, waiting: 0 });
+ });
+
+ it(`서버 전체는 동시에 ${MAIL_IMAGE_CONCURRENCY}개까지 — ${MAIL_IMAGE_CONCURRENCY + 1}번째는 자기 직원의 자리가 남아도 기다리고, 하나를 돌려주면 들어간다`, async () => {
+ const { tokens, pending, service } = setup();
+ // 직원 하나가 하나씩 — 직원마다의 상한에는 걸리지 않는다
+ const opens = Array.from({ length: MAIL_IMAGE_CONCURRENCY }, (_, n) =>
+ service.open(
+ employee(n),
+ tokens.issue(employee(n), SOURCE),
+ new AbortController().signal,
+ ),
+ );
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY);
+
+ const late = employee(MAIL_IMAGE_CONCURRENCY);
+ const waiting = service.open(
+ late,
+ tokens.issue(late, SOURCE),
+ new AbortController().signal,
+ );
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY);
+ expect(service.load(late)).toEqual({
+ active: 0,
+ waiting: 1,
+ totalActive: MAIL_IMAGE_CONCURRENCY,
+ totalWaiting: 1,
+ });
+
+ pending[0].finish(OK);
+ (await opens[0])?.release();
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY + 1);
+ expect(pending.at(-1)?.source).toBe(SOURCE);
+ expect(service.load(late)).toMatchObject({ active: 1, waiting: 0 });
+
+ for (const fetch of pending.slice(1)) fetch.finish(OK);
+ for (const image of await Promise.all([...opens.slice(1), waiting])) {
+ image?.release();
+ }
+ expect(service.load(late)).toEqual({
+ active: 0,
+ waiting: 0,
+ totalActive: 0,
+ totalWaiting: 0,
+ });
+ });
+
+ it('자기 직원의 상한에 막힌 직원은 건너뛴다 — 먼저 기다린 직원이 막혀 있으면 뒤에 온 다른 직원이 먼저 들어가고, 같은 직원끼리는 온 차례다', async () => {
+ const { tokens, pending, service } = setup();
+ const araToken = tokens.issue(ARA, SOURCE);
+ // 아라가 자기 상한까지 + 하나 더 기다린다
+ const araOpens = Array.from(
+ { length: MAIL_IMAGE_CONCURRENCY_PER_USER + 1 },
+ () => service.open(ARA, araToken, new AbortController().signal),
+ );
+ // 전체 상한까지 다른 직원들로 채운다
+ const fillers = Array.from(
+ { length: MAIL_IMAGE_CONCURRENCY - MAIL_IMAGE_CONCURRENCY_PER_USER },
+ (_, n) =>
+ service.open(
+ employee(n),
+ tokens.issue(employee(n), SOURCE),
+ new AbortController().signal,
+ ),
+ );
+ await flush();
+ // 도윤은 아라의 기다림 뒤에 선다
+ const doyun = service.open(
+ DOYUN,
+ tokens.issue(DOYUN, SOURCE),
+ new AbortController().signal,
+ );
+ await flush();
+ expect(service.load(DOYUN)).toMatchObject({ waiting: 1, totalWaiting: 2 });
+
+ // 다른 직원(아라가 아닌) 하나가 돌려주면 — 앞의 아라는 자기 상한에 막혀 있으니 도윤이 들어간다
+ const fillerIndex = MAIL_IMAGE_CONCURRENCY_PER_USER;
+ pending[fillerIndex].finish(OK);
+ (await fillers[0])?.release();
+ await flush();
+ expect(service.load(DOYUN)).toMatchObject({ active: 1, waiting: 0 });
+ expect(service.load(ARA)).toMatchObject({
+ active: MAIL_IMAGE_CONCURRENCY_PER_USER,
+ waiting: 1,
+ });
+
+ // 아라 하나가 돌려주면 기다리던 아라가 들어간다
+ pending[0].finish(OK);
+ (await araOpens[0])?.release();
+ await flush();
+ expect(service.load(ARA)).toMatchObject({
+ active: MAIL_IMAGE_CONCURRENCY_PER_USER,
+ waiting: 0,
+ });
+
+ for (const fetch of pending) fetch.finish(OK);
+ for (const image of await Promise.all([...araOpens, ...fillers, doyun])) {
+ image?.release();
+ }
+ expect(service.load(ARA).totalActive).toBe(0);
+ });
+
+ it('전체 자리가 나면 지금 쥔 자리가 가장 적은 직원부터(같으면 먼저 기다린 쪽) — 이미지가 많은 메일을 연 직원들이 자리를 돌려 가며 차지하지 않는다 (9단계 리뷰 2)', async () => {
+ // 리뷰의 장면 그대로 — 전체 16 · 직원 6
+ expect([MAIL_IMAGE_CONCURRENCY, MAIL_IMAGE_CONCURRENCY_PER_USER]).toEqual([
+ 16, 6,
+ ]);
+ const { tokens, pending, service } = setup();
+ const [a, b, c, d] = [employee(1), employee(2), employee(3), employee(4)];
+ const openAs = (user: string, count: number) =>
+ Array.from({ length: count }, () =>
+ service.open(
+ user,
+ tokens.issue(user, SOURCE),
+ new AbortController().signal,
+ ),
+ );
+ // A · B · C 가 이미지가 많은 메일을 연다 — 전체 16자리를 6 · 6 · 4로 채우고 각자 5장씩 기다린다
+ const aOpens = openAs(a, 6 + 5);
+ await flush();
+ const bOpens = openAs(b, 6 + 5);
+ await flush();
+ const cOpens = openAs(c, 4 + 5);
+ await flush();
+ expect(service.load(a)).toMatchObject({
+ active: 6,
+ waiting: 5,
+ totalActive: 16,
+ });
+ expect(service.load(b)).toMatchObject({ active: 6, waiting: 5 });
+ expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 });
+ // 뒤에 온 D 는 한 장
+ const dOpens = openAs(d, 1);
+ await flush();
+ expect(service.load(d)).toMatchObject({ active: 0, waiting: 1 });
+
+ /** 받는 중인 요청 하나를 끝내고 차례를 돌려준다 — pending 은 들어간 차례(A 0~5 · B 6~11 · C 12~15) */
+ const finishAndRelease = async (
+ fetchIndex: number,
+ open: Promise<{ release(): void } | null>,
+ ) => {
+ pending[fetchIndex].finish(OK);
+ (await open)?.release();
+ await flush();
+ };
+
+ // A 가 한 장을 돌려준다 — 자리를 비운 A 의 다음 요청(먼저 기다렸다)이 아니라 쥔 자리가 없는 D 가 들어간다
+ await finishAndRelease(0, aOpens[0]);
+ expect(service.load(d)).toMatchObject({ active: 1, waiting: 0 });
+ expect(service.load(a)).toMatchObject({ active: 5, waiting: 5 });
+
+ // A 가 또 하나 — 쥔 자리가 A 4 · C 4 로 같다: 먼저 기다린 A
+ await finishAndRelease(1, aOpens[1]);
+ expect(service.load(a)).toMatchObject({ active: 5, waiting: 4 });
+ expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 });
+
+ // B 가 하나 — A 5 · B 5 · C 4 → C
+ await finishAndRelease(6, bOpens[0]);
+ expect(service.load(c)).toMatchObject({ active: 5, waiting: 4 });
+ expect(service.load(b)).toMatchObject({ active: 5, waiting: 5 });
+
+ const all = [...aOpens, ...bOpens, ...cOpens, ...dOpens];
+ expect(await drain(pending, all)).toBe(all.length);
+ expect(service.load(a)).toEqual({
+ active: 0,
+ waiting: 0,
+ totalActive: 0,
+ totalWaiting: 0,
+ });
+ });
+
+ it('기다리다 클라이언트가 끊으면 차례에서 빠지고 바깥에 묻지 않는다', async () => {
+ const { tokens, pending, service } = setup();
+ const token = tokens.issue(ARA, SOURCE);
+ const holders = Array.from(
+ { length: MAIL_IMAGE_CONCURRENCY_PER_USER },
+ () => service.open(ARA, token, new AbortController().signal),
+ );
+ const leaving = new AbortController();
+ const waiting = service.open(ARA, token, leaving.signal);
+ await flush();
+ expect(service.load(ARA).waiting).toBe(1);
+
+ leaving.abort();
+ await expect(waiting).resolves.toBeNull();
+ expect(service.load(ARA)).toMatchObject({ waiting: 0, totalWaiting: 0 });
+
+ for (const fetch of pending) fetch.finish(OK);
+ for (const image of await Promise.all(holders)) image?.release();
+ await flush();
+ expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER);
+ expect(service.load(ARA).totalActive).toBe(0);
+ });
+
+ it(`${MAIL_IMAGE_QUEUE_WAIT_MS / 1000}초를 넘게 기다리면 받지 않는다 · 기다리는 수가 ${MAIL_IMAGE_MAX_WAITING_PER_USER}을 넘으면 바로 null`, async () => {
+ jest.useFakeTimers();
+ const { tokens, pending, service } = setup();
+ const token = tokens.issue(ARA, SOURCE);
+ for (let i = 0; i < MAIL_IMAGE_CONCURRENCY_PER_USER; i += 1) {
+ void service.open(ARA, token, new AbortController().signal);
+ }
+ const waiters = Array.from(
+ { length: MAIL_IMAGE_MAX_WAITING_PER_USER },
+ () => service.open(ARA, token, new AbortController().signal),
+ );
+ await flush();
+ await expect(
+ service.open(ARA, token, new AbortController().signal),
+ ).resolves.toBeNull();
+
+ jest.advanceTimersByTime(MAIL_IMAGE_QUEUE_WAIT_MS);
+ await expect(Promise.all(waiters)).resolves.toEqual(
+ waiters.map(() => null),
+ );
+ expect(service.load(ARA)).toMatchObject({
+ active: MAIL_IMAGE_CONCURRENCY_PER_USER,
+ waiting: 0,
+ totalWaiting: 0,
+ });
+ for (const fetch of pending) fetch.finish(OK);
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image-proxy.service.ts b/backend/src/modules/mail/images/mail-image-proxy.service.ts
new file mode 100644
index 0000000..f810f34
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-proxy.service.ts
@@ -0,0 +1,237 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import { traceTag } from '@/shared/logger/request-context';
+import { maskEmail } from '@/shared/utils/mask';
+import {
+ MAIL_IMAGE_CONCURRENCY,
+ MAIL_IMAGE_CONCURRENCY_PER_USER,
+ MAIL_IMAGE_MAX_WAITING_PER_USER,
+ MAIL_IMAGE_QUEUE_WAIT_MS,
+} from './mail-image.constants';
+import { MailImageFetcher } from './mail-image-fetcher';
+import type { ProxiedImageType } from './mail-image-format';
+import { MailImageTokens } from './mail-image-token';
+
+/**
+ * 중계해 줄 이미지 — 받은 차례를 쥐고 있다. 응답을 다 보냈거나 연결이 닫혔을 때 release() 를 부른다
+ * (그때까지의 버퍼도 동시 수에 든다 — 느린 브라우저에 보내는 동안의 메모리까지 센다). 여러 번 불러도 한 번만 돌려준다
+ */
+export interface ProxiedImage {
+ readonly type: ProxiedImageType;
+ readonly data: Buffer;
+ readonly release: () => void;
+}
+
+/** 차례를 기다리는 요청 하나 — 그 직원의 줄(온 차례)에 선다 */
+interface Waiter {
+ readonly key: string;
+ /** 줄에 선 차례 — 쥔 자리가 같은 직원끼리는 먼저 온 쪽 */
+ readonly seq: number;
+ readonly admit: () => void;
+}
+
+/**
+ * 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — 표를 풀어 원래 주소를 얻고 바깥에서 받는다.
+ * - 동시 수: 프로세스 전체 MAIL_IMAGE_CONCURRENCY(16)개 · 한 직원 MAIL_IMAGE_CONCURRENCY_PER_USER(6)개 — 넘으면 **직원마다의 줄**(온 차례)에서 기다린다.
+ * 자리가 나면 기다리는 직원 가운데 **지금 쥔 자리가 가장 적은 직원**의 맨 앞을 들인다(같으면 먼저 온 쪽, 자기 6개에 막힌 직원은 건너뛴다) —
+ * 이미지가 많은 뉴스레터를 연 직원 몇이 전체 자리를 돌려 가며 차지해 다른 직원의 그림이 30초 기다림으로 비지 않게(2026-10-29 리뷰 2).
+ * 한 장이 5MB 까지라 전체 상한이 없으면 여러 직원이 함께 열 때 메모리가 수 GB 로 는다(2026-10-29 리뷰 — 운영 backend 한도 1g)
+ * - 차례는 받기부터 응답을 다 보내거나 연결이 닫힐 때까지 쥔다(ProxiedImage.release — 컨트롤러가 부른다). 받지 못하면 여기서 돌려준다
+ * - 실패는 까닭을 가리지 않고 null — 컨트롤러가 모두 404 RES_001 로 답한다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님)
+ * - 로그: 원래 주소는 남기지 않는다(추적 번호 · 개인 정보가 든다) — 실패 까닭과 바깥 호스트 이름만 debug
+ * - 기다림: 30초를 넘거나 한 직원의 기다리는 수가 200 을 넘으면 받지 않는다(null). 클라이언트가 끊으면 기다리던 자리에서 빠진다
+ *
+ * ⚠️ 동시 수는 프로세스 메모리에서 센다 — 인스턴스를 늘리면 인스턴스마다 16 · 6개다 (바깥으로 나가는 양이 그만큼 는다 — 필요하면 공유 셈)
+ */
+@Injectable()
+export class MailImageProxyService {
+ private readonly logger = new Logger(MailImageProxyService.name);
+ /** 받는 중(차례를 쥔) 수 — 전체 · 직원마다 */
+ private active = 0;
+ private readonly activeByUser = new Map();
+ /** 기다리는 줄 — 직원마다 온 차례 (빈 줄은 두지 않는다) */
+ private readonly lines = new Map();
+ private waitingTotal = 0;
+ private nextSeq = 0;
+
+ constructor(
+ private readonly tokens: MailImageTokens,
+ private readonly fetcher: MailImageFetcher,
+ ) {}
+
+ /**
+ * 표의 이미지를 받는다 — 받지 못하면 null(차례는 이미 돌려줬다). 받았으면 차례를 쥔 채 돌려준다 — 부른 쪽이 release() 한다.
+ * @param signal 클라이언트가 끊으면 기다림 · 받기를 멈춘다
+ */
+ async open(
+ user: string,
+ token: string,
+ signal: AbortSignal,
+ ): Promise {
+ const source = this.tokens.open(token, user);
+ if (source === null) {
+ this.debug(user, 'bad-token', null);
+ return null;
+ }
+
+ const release = await this.acquire(user, signal);
+ if (release === null) {
+ this.debug(user, signal.aborted ? 'canceled' : 'queue-full', null);
+ return null;
+ }
+ let handedOver = false;
+ try {
+ const result = await this.fetcher.fetch(source, signal);
+ if (!result.ok) {
+ this.debug(user, result.reason, result.host);
+ return null;
+ }
+ handedOver = true;
+ return { type: result.type, data: result.data, release };
+ } finally {
+ if (!handedOver) release();
+ }
+ }
+
+ /** 지금 받는 수 · 기다리는 수 — 그 직원 · 전체 (테스트 · 진단용) */
+ load(user: string): {
+ readonly active: number;
+ readonly waiting: number;
+ readonly totalActive: number;
+ readonly totalWaiting: number;
+ } {
+ const key = slotKey(user);
+ return {
+ active: this.activeByUser.get(key) ?? 0,
+ waiting: this.lines.get(key)?.length ?? 0,
+ totalActive: this.active,
+ totalWaiting: this.waitingTotal,
+ };
+ }
+
+ /** 차례를 얻는다 — 얻으면 돌려줄 함수(한 번만 돈다), 못 얻으면(기다림이 넘침 · 너무 오래 · 끊김) null */
+ private acquire(
+ user: string,
+ signal: AbortSignal,
+ ): Promise<(() => void) | null> {
+ const key = slotKey(user);
+ if (signal.aborted) return Promise.resolve(null);
+ // 비어 있는 자리가 있는데도 줄에 남은 사람은 자기 직원의 상한에 막힌 사람뿐이다(자리가 날 때마다 들일 수 있는 만큼 들인다) — 앞지르지 않는다
+ if (this.canStart(key)) {
+ this.start(key);
+ return Promise.resolve(this.releaser(key));
+ }
+ const line = this.lines.get(key) ?? [];
+ if (line.length >= MAIL_IMAGE_MAX_WAITING_PER_USER) {
+ return Promise.resolve(null);
+ }
+
+ return new Promise((resolve) => {
+ const leave = () => {
+ clearTimeout(timer);
+ signal.removeEventListener('abort', giveUp);
+ const index = line.indexOf(waiter);
+ if (index < 0) return;
+ line.splice(index, 1);
+ this.waitingTotal -= 1;
+ if (line.length === 0 && this.lines.get(key) === line) {
+ this.lines.delete(key);
+ }
+ };
+ const giveUp = () => {
+ leave();
+ resolve(null);
+ };
+ const waiter: Waiter = {
+ key,
+ seq: this.nextSeq++,
+ admit: () => {
+ leave();
+ this.start(key);
+ resolve(this.releaser(key));
+ },
+ };
+ const timer = setTimeout(giveUp, MAIL_IMAGE_QUEUE_WAIT_MS);
+ timer.unref();
+ signal.addEventListener('abort', giveUp, { once: true });
+ line.push(waiter);
+ this.waitingTotal += 1;
+ this.lines.set(key, line);
+ });
+ }
+
+ /** 전체 · 그 직원의 자리가 남았나 */
+ private canStart(key: string): boolean {
+ return (
+ this.active < MAIL_IMAGE_CONCURRENCY &&
+ (this.activeByUser.get(key) ?? 0) < MAIL_IMAGE_CONCURRENCY_PER_USER
+ );
+ }
+
+ private start(key: string): void {
+ this.active += 1;
+ this.count(this.activeByUser, key, 1);
+ }
+
+ /** 차례를 돌려주는 함수 — 여러 번 불러도 한 번만 돌려준다 */
+ private releaser(key: string): () => void {
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this.active = Math.max(0, this.active - 1);
+ this.count(this.activeByUser, key, -1);
+ this.admitWaiting();
+ };
+ }
+
+ /** 자리가 남는 동안 다음 사람을 들인다 — 전체 자리가 차거나 들일 수 있는 사람이 없으면 멈춘다 */
+ private admitWaiting(): void {
+ while (this.active < MAIL_IMAGE_CONCURRENCY) {
+ const next = this.nextWaiter();
+ if (next === null) return;
+ // admit 이 줄에서 빼고 자리를 잡는다
+ next.admit();
+ }
+ }
+
+ /**
+ * 다음에 들일 요청 — 자기 상한(6)에 막히지 않은 직원 가운데 지금 쥔 자리가 가장 적은 직원의 맨 앞(같으면 먼저 온 쪽).
+ * 기다리는 직원 수에 비례한다 (직원마다 줄의 맨 앞만 본다)
+ */
+ private nextWaiter(): Waiter | null {
+ let best: Waiter | null = null;
+ let bestHeld = Number.POSITIVE_INFINITY;
+ for (const [key, line] of this.lines) {
+ const held = this.activeByUser.get(key) ?? 0;
+ if (held >= MAIL_IMAGE_CONCURRENCY_PER_USER) continue;
+ const head = line[0];
+ if (
+ best === null ||
+ held < bestHeld ||
+ (held === bestHeld && head.seq < best.seq)
+ ) {
+ best = head;
+ bestHeld = held;
+ }
+ }
+ return best;
+ }
+
+ /** 직원마다의 셈 — 0 이 되면 지운다 (줄지 않는 표를 두지 않는다) */
+ private count(map: Map, key: string, delta: number): void {
+ const next = (map.get(key) ?? 0) + delta;
+ if (next > 0) map.set(key, next);
+ else map.delete(key);
+ }
+
+ private debug(user: string, reason: string, host: string | null): void {
+ this.logger.debug(
+ `${traceTag()} 바깥 이미지를 주지 않았습니다: user=${maskEmail(user)}, 까닭=${reason}${host ? `, host=${host}` : ''}`,
+ );
+ }
+}
+
+function slotKey(user: string): string {
+ return user.trim().toLowerCase();
+}
diff --git a/backend/src/modules/mail/images/mail-image-token.spec.ts b/backend/src/modules/mail/images/mail-image-token.spec.ts
new file mode 100644
index 0000000..f57b516
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-token.spec.ts
@@ -0,0 +1,93 @@
+import {
+ MAIL_IMAGE_TOKEN_TTL_MS,
+ MAX_MAIL_IMAGE_TOKEN_LENGTH,
+ MAX_PROXIED_IMAGE_URL_LENGTH,
+} from './mail-image.constants';
+import { MailImageTokens } from './mail-image-token';
+
+const ARA = 'ara.kim@arasystem.test';
+const DOYUN = 'doyun.lee@arasystem.test';
+const URL = 'https://news.example.com/open.gif?u=8812&c=newsletter-10';
+const NOW = Date.parse('2026-10-29T06:00:00.000Z');
+
+/** base64url 표의 한 바이트를 바꾼다 */
+function tamper(token: string, index: number): string {
+ const raw = Buffer.from(token, 'base64url');
+ raw[index] ^= 0x01;
+ return raw.toString('base64url');
+}
+
+describe('MailImageTokens — 외부 이미지 중계 표', () => {
+ it('만든 표를 그 직원이 1시간 안에 풀면 원래 주소다 (주소의 대소문자는 가리지 않는다)', () => {
+ const tokens = new MailImageTokens();
+ const token = tokens.issue(ARA, URL, NOW);
+
+ expect(tokens.open(token, ARA, NOW)).toBe(URL);
+ expect(tokens.open(token, 'ARA.Kim@ArASystem.test', NOW + 1000)).toBe(URL);
+ expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS - 1)).toBe(
+ URL,
+ );
+ });
+
+ it('표에는 원래 주소가 보이지 않는다 — 암호화한 불투명 글(base64url)이고, 같은 주소도 표마다 다르다', () => {
+ const tokens = new MailImageTokens();
+ const token = tokens.issue(ARA, URL, NOW);
+
+ expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
+ const decoded = Buffer.from(token, 'base64url').toString('latin1');
+ for (const piece of ['news.example.com', 'open.gif', 'u=8812', 'ara.kim']) {
+ expect(token).not.toContain(piece);
+ expect(decoded).not.toContain(piece);
+ }
+ expect(tokens.issue(ARA, URL, NOW)).not.toBe(token);
+ });
+
+ it('지난 표 · 다른 직원 · 다른 서버(키)의 표는 풀리지 않는다', () => {
+ const tokens = new MailImageTokens();
+ const token = tokens.issue(ARA, URL, NOW);
+
+ expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS)).toBeNull();
+ expect(tokens.open(token, DOYUN, NOW)).toBeNull();
+ expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull();
+ });
+
+ it('한 바이트라도 고친 표는 풀리지 않는다 (인증 암호 — 판 · iv · 암호문 · 태그 어디든)', () => {
+ const tokens = new MailImageTokens();
+ const token = tokens.issue(ARA, URL, NOW);
+ const length = Buffer.from(token, 'base64url').length;
+
+ for (const index of [0, 1, 12, 13, 20, length - 17, length - 1]) {
+ expect(tokens.open(tamper(token, index), ARA, NOW)).toBeNull();
+ }
+ });
+
+ it.each([
+ ['빈 글', ''],
+ ['base64url 이 아닌 글자', 'abc.def'],
+ ['슬래시', 'abc/def'],
+ ['너무 짧음', 'AQ'],
+ ['너무 긺', 'A'.repeat(MAX_MAIL_IMAGE_TOKEN_LENGTH + 1)],
+ ])('모양이 틀린 표(%s)는 풀어 보지 않는다', (_label, token) => {
+ expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull();
+ });
+
+ it(`가장 긴 주소(${MAX_PROXIED_IMAGE_URL_LENGTH}자)의 표도 표 길이 상한 안이다 — 한글이 든 주소도 정규화하면 퍼센트 인코딩이라 같은 상한`, () => {
+ const tokens = new MailImageTokens();
+ const longUrl = `https://img.example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH - 24)}`;
+ expect(longUrl).toHaveLength(MAX_PROXIED_IMAGE_URL_LENGTH);
+ const token = tokens.issue(
+ 'very.long.local.part.of.an.employee@arasystem.test',
+ longUrl,
+ NOW,
+ );
+
+ expect(token.length).toBeLessThanOrEqual(MAX_MAIL_IMAGE_TOKEN_LENGTH);
+ expect(
+ tokens.open(
+ token,
+ 'very.long.local.part.of.an.employee@arasystem.test',
+ NOW,
+ ),
+ ).toBe(longUrl);
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image-token.ts b/backend/src/modules/mail/images/mail-image-token.ts
new file mode 100644
index 0000000..54d0394
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-token.ts
@@ -0,0 +1,127 @@
+import { Injectable } from '@nestjs/common';
+import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
+
+import {
+ MAIL_IMAGE_TOKEN_TTL_MS,
+ MAX_MAIL_IMAGE_TOKEN_LENGTH,
+} from './mail-image.constants';
+
+/**
+ * 외부 이미지 중계 표 (계약 2절 "외부 이미지 보기" — 9단계) — `/api/mail-images/<표>` 의 표.
+ *
+ * 표는 직원 · 원래 이미지 주소 · 끝나는 시각(1시간)을 **서버 비밀로 암호화한** 불투명 글이다 — 인증 암호 AES-256-GCM.
+ * - 서명만 하면 표를 풀어 원래 주소(추적 번호 · 개인 정보가 든다)를 읽을 수 있어, 접근 로그 · 주소창 · 브라우저 기록에 그대로 남는다 — 그래서 암호화한다
+ * - 인증 암호라 고친 표는 풀리지 않는다(태그가 맞지 않음) — 다른 직원 · 다른 주소로 바꿔 쓸 수 없다
+ * - 키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 풀리지 않고(404), 메일을 다시 열면 새 표를 받는다.
+ * ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다 (다른 인스턴스가 만든 표를 풀지 못한다)
+ * - 모양: base64url( 판 1바이트 | iv 12바이트 | 암호문 | 태그 16바이트 ), 추가 인증 자료(AAD)에 용도 이름을 넣어 다른 용도의 암호문과 섞이지 않게 한다
+ */
+
+const ALGORITHM = 'aes-256-gcm';
+const KEY_BYTES = 32;
+const IV_BYTES = 12;
+const TAG_BYTES = 16;
+/** 표 모양의 판 — 모양을 바꾸면 올린다 (옛 판은 풀지 않는다) */
+const TOKEN_VERSION = 1;
+const AAD = Buffer.from('arasystem/mail-image-token/v1', 'utf8');
+/**
+ * base64url 글자만(채움 `=` 없이) — 그 밖의 글자가 든 경로는 풀어 보지 않는다.
+ * 짝: frontend `src/shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`(정화가 남기는 중계 주소의 표 글자) — 한쪽을 고치면 둘 다
+ */
+const TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/;
+
+/** 표 안의 것 — 짧은 키로 (표 길이를 줄인다) */
+interface TokenPayload {
+ /** 직원 주소 (소문자) */
+ readonly u: string;
+ /** 원래 이미지 주소 (정규화한 http · https 주소) */
+ readonly s: string;
+ /** 끝나는 시각 (ms) */
+ readonly e: number;
+}
+
+function userKey(user: string): string {
+ return user.trim().toLowerCase();
+}
+
+function isPayload(value: unknown): value is TokenPayload {
+ if (typeof value !== 'object' || value === null) return false;
+ const { u, s, e } = value as Partial>;
+ return (
+ typeof u === 'string' &&
+ typeof s === 'string' &&
+ typeof e === 'number' &&
+ Number.isSafeInteger(e)
+ );
+}
+
+@Injectable()
+export class MailImageTokens {
+ private readonly key = randomBytes(KEY_BYTES);
+
+ /** 그 직원이 그 주소의 이미지를 받을 표 — 지금부터 1시간 */
+ issue(user: string, url: string, nowMs: number = Date.now()): string {
+ const payload: TokenPayload = {
+ u: userKey(user),
+ s: url,
+ e: nowMs + MAIL_IMAGE_TOKEN_TTL_MS,
+ };
+ const iv = randomBytes(IV_BYTES);
+ const cipher = createCipheriv(ALGORITHM, this.key, iv, {
+ authTagLength: TAG_BYTES,
+ });
+ cipher.setAAD(AAD);
+ const sealed = Buffer.concat([
+ cipher.update(JSON.stringify(payload), 'utf8'),
+ cipher.final(),
+ ]);
+ return Buffer.concat([
+ Buffer.from([TOKEN_VERSION]),
+ iv,
+ sealed,
+ cipher.getAuthTag(),
+ ]).toString('base64url');
+ }
+
+ /**
+ * 표를 푼다 — 그 직원의 것이고 끝나지 않았으면 원래 주소, 아니면 null.
+ * 까닭(모양이 틀림 · 다른 키 · 고침 · 지남 · 다른 직원)을 가리지 않는다 — 부르는 쪽은 모두 404 로 답한다
+ */
+ open(token: string, user: string, nowMs: number = Date.now()): string | null {
+ if (token.length > MAX_MAIL_IMAGE_TOKEN_LENGTH) return null;
+ if (!TOKEN_PATTERN.test(token)) return null;
+ const raw = Buffer.from(token, 'base64url');
+ if (raw.length < 1 + IV_BYTES + TAG_BYTES + 1) return null;
+ if (raw[0] !== TOKEN_VERSION) return null;
+
+ const iv = raw.subarray(1, 1 + IV_BYTES);
+ const tag = raw.subarray(raw.length - TAG_BYTES);
+ const sealed = raw.subarray(1 + IV_BYTES, raw.length - TAG_BYTES);
+ let plain: string;
+ try {
+ const decipher = createDecipheriv(ALGORITHM, this.key, iv, {
+ authTagLength: TAG_BYTES,
+ });
+ decipher.setAAD(AAD);
+ decipher.setAuthTag(tag);
+ plain = Buffer.concat([
+ decipher.update(sealed),
+ decipher.final(),
+ ]).toString('utf8');
+ } catch {
+ // 다른 키 · 고친 표 — 태그가 맞지 않는다
+ return null;
+ }
+
+ let payload: unknown;
+ try {
+ payload = JSON.parse(plain);
+ } catch {
+ return null;
+ }
+ if (!isPayload(payload)) return null;
+ if (payload.u !== userKey(user)) return null;
+ if (payload.e <= nowMs) return null;
+ return payload.s;
+ }
+}
diff --git a/backend/src/modules/mail/images/mail-image-url.spec.ts b/backend/src/modules/mail/images/mail-image-url.spec.ts
new file mode 100644
index 0000000..911acfa
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-url.spec.ts
@@ -0,0 +1,80 @@
+import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants';
+import {
+ checkProxiedImageUrl,
+ hostOf,
+ parseProxiedImageUrl,
+} from './mail-image-url';
+
+describe('parseProxiedImageUrl — 중계할 수 있는 바깥 이미지 주소', () => {
+ it.each([
+ ['https://news.example.com/a.png', 'https://news.example.com/a.png'],
+ ['http://news.example.com/a.png?x=1', 'http://news.example.com/a.png?x=1'],
+ [' https://news.example.com/a.png ', 'https://news.example.com/a.png'],
+ ['//cdn.example.com/a.gif', 'https://cdn.example.com/a.gif'],
+ ['HTTPS://News.Example.COM/A.png', 'https://news.example.com/A.png'],
+ ['http://news.example.com:80/a.png', 'http://news.example.com/a.png'],
+ ['https://news.example.com:443/a.png', 'https://news.example.com/a.png'],
+ [
+ 'https://뉴스.example.com/그림.png',
+ 'https://xn--vg1b002a.example.com/%EA%B7%B8%EB%A6%BC.png',
+ ],
+ [
+ 'https://news.example.com/a.png#frag',
+ 'https://news.example.com/a.png#frag',
+ ],
+ ])('%j → %s', (src, href) => {
+ expect(parseProxiedImageUrl(src)?.href).toBe(href);
+ });
+
+ it.each([
+ ['빈 글', ''],
+ ['상대 주소', '/images/a.png'],
+ ['data:', 'data:image/png;base64,AAAA'],
+ ['cid:', 'cid:logo@x'],
+ ['javascript:', 'javascript:alert(1)'],
+ ['ftp:', 'ftp://example.com/a.png'],
+ ['file:', 'file:///etc/passwd'],
+ ['기본 포트가 아님', 'https://example.com:8443/a.png'],
+ ['http 에 443', 'http://example.com:443/a.png'],
+ ['메일 엔진 관리 포트', 'http://mail:8080/api'],
+ ['사용자 정보', 'https://user:pass@example.com/a.png'],
+ ['사용자 이름만', 'https://user@example.com/a.png'],
+ ['깨진 주소', 'https://'],
+ [
+ '너무 긺',
+ `https://example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH)}`,
+ ],
+ ])('%s(%j)은 중계하지 않는다', (_label, src) => {
+ expect(parseProxiedImageUrl(src)).toBeNull();
+ });
+
+ it('정규화해서 2,048자를 넘는 주소(퍼센트 인코딩으로 늘어남)도 중계하지 않는다', () => {
+ const src = `https://example.com/${'가'.repeat(400)}`;
+ expect(src.length).toBeLessThan(MAX_PROXIED_IMAGE_URL_LENGTH);
+ expect(parseProxiedImageUrl(src)).toBeNull();
+ });
+
+ it('숫자로 쓴 IP 주소는 정규화한다 — 사설 · 루프백인지는 받을 때 본다 (10진 · 16진 표기도 같은 주소)', () => {
+ expect(parseProxiedImageUrl('http://2130706433/a.png')?.hostname).toBe(
+ '127.0.0.1',
+ );
+ expect(parseProxiedImageUrl('http://0x7f.1/a.png')?.hostname).toBe(
+ '127.0.0.1',
+ );
+ expect(hostOf(parseProxiedImageUrl('http://[::1]/a.png') as URL)).toBe(
+ '::1',
+ );
+ });
+
+ it('다시 가기 대상도 같은 검사 — 다른 스킴 · 포트 · 사용자 정보는 막는다', () => {
+ const base = new URL('https://news.example.com/a.png');
+ expect(checkProxiedImageUrl(new URL('/b.png', base))?.href).toBe(
+ 'https://news.example.com/b.png',
+ );
+ expect(checkProxiedImageUrl(new URL('http://127.0.0.1:8080/'))).toBeNull();
+ expect(checkProxiedImageUrl(new URL('gopher://example.com/'))).toBeNull();
+ expect(
+ checkProxiedImageUrl(new URL('https://a:b@example.com/')),
+ ).toBeNull();
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image-url.ts b/backend/src/modules/mail/images/mail-image-url.ts
new file mode 100644
index 0000000..9516ed1
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image-url.ts
@@ -0,0 +1,46 @@
+import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants';
+
+/**
+ * 중계할 수 있는 바깥 이미지 주소 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 메일의 `
` 를 중계 주소로 바꿀 때와,
+ * 바깥이 다시 가라고 한 주소(redirect)를 따라갈 때 같은 검사를 한다.
+ * - http · https 만 — `//host/…`(프로토콜 상대)는 https 로 본다
+ * - 기본 포트만(80 · 443) — WHATWG URL 은 기본 포트를 빈 글로 둔다(`http://a:80` → 포트 없음). 그 밖의 포트는 사내 서비스 포트 찌르기에 쓰인다
+ * - 사용자 정보(`user:pass@`) 없음
+ * - 길이: 원래 글 · 정규화한 주소 모두 2,048자 이하
+ * 이름(호스트)이 공인 주소로 풀리는지는 받을 때 본다 (mail-image-fetcher.ts — 이름은 그때그때 다른 주소로 풀릴 수 있다)
+ */
+
+const ALLOWED_PROTOCOLS: ReadonlySet = new Set(['http:', 'https:']);
+
+/** 이미 만든 주소(다시 가기 대상 등)가 중계할 수 있는 주소인가 — 그렇다면 그 주소, 아니면 null */
+export function checkProxiedImageUrl(url: URL): URL | null {
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) return null;
+ if (url.port !== '') return null;
+ if (url.username !== '' || url.password !== '') return null;
+ if (url.hostname === '') return null;
+ if (url.href.length > MAX_PROXIED_IMAGE_URL_LENGTH) return null;
+ return url;
+}
+
+/** `
` 의 글 → 중계할 주소 (정규화한 URL) — 중계할 수 없으면 null */
+export function parseProxiedImageUrl(src: string): URL | null {
+ const trimmed = src.trim();
+ if (trimmed === '' || trimmed.length > MAX_PROXIED_IMAGE_URL_LENGTH) {
+ return null;
+ }
+ let url: URL;
+ try {
+ url = new URL(trimmed.startsWith('//') ? `https:${trimmed}` : trimmed);
+ } catch {
+ return null;
+ }
+ return checkProxiedImageUrl(url);
+}
+
+/** 주소의 호스트 — IPv6 는 대괄호를 뗀 모양 (연결 · 주소 검사에 쓴다) */
+export function hostOf(url: URL): string {
+ const { hostname } = url;
+ return hostname.startsWith('[') && hostname.endsWith(']')
+ ? hostname.slice(1, -1)
+ : hostname;
+}
diff --git a/backend/src/modules/mail/images/mail-image.constants.ts b/backend/src/modules/mail/images/mail-image.constants.ts
new file mode 100644
index 0000000..fee867e
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image.constants.ts
@@ -0,0 +1,58 @@
+/**
+ * 외부 이미지 보기의 한도 (계약 2절 "외부 이미지 보기" — 9단계) — 숫자를 한 곳에 모은다.
+ */
+
+/** 메일 한 통에서 중계 주소로 바꾸는 이미지 수 — 그 뒤의 외부 이미지는 지운다 */
+export const MAX_PROXIED_IMAGES_PER_MESSAGE = 100;
+
+/** 중계할 이미지 주소의 길이 (JS 문자열 길이 — 원래 글과 정규화한 주소 모두) — 넘으면 지운다 */
+export const MAX_PROXIED_IMAGE_URL_LENGTH = 2_048;
+
+/** 중계 표가 유효한 시간 — 메일을 다시 열면 새 표를 받는다 */
+export const MAIL_IMAGE_TOKEN_TTL_MS = 60 * 60_000;
+
+/**
+ * 표 글의 최대 길이 — 주소 2,048자에 직원 주소 · 끝 시각 · 암호 덧붙임(iv · 인증 태그)이 base64url 로 늘어난 것보다 넉넉히.
+ * 넘는 경로는 풀어 보지 않고 404 다
+ */
+export const MAX_MAIL_IMAGE_TOKEN_LENGTH = 4_096;
+
+/** 바깥 이미지를 받는 전체 시간 — 이름 풀기 · 연결 · 다시 가기 · 본문 읽기 모두 합쳐 */
+export const MAIL_IMAGE_FETCH_TIMEOUT_MS = 10_000;
+
+/** 받는 크기 상한 — 넘으면 끊는다 */
+export const MAIL_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
+
+/** 다시 가기(redirect)를 따라가는 횟수 — 매번 주소 · 이름 풀기 · 공인 주소 검사를 다시 한다 */
+export const MAIL_IMAGE_MAX_REDIRECTS = 3;
+
+/** 한 직원이 동시에 받는 수 — 넘으면 기다린다 */
+export const MAIL_IMAGE_CONCURRENCY_PER_USER = 6;
+
+/**
+ * 프로세스 전체에서 동시에 받는 수 — 넘으면 기다리고, 자리가 나면 지금 받는 수가 가장 적은 직원부터 들인다(mail-image-proxy.service.ts). 한 장이 MAIL_IMAGE_MAX_BYTES(5MB)까지라
+ * 전체로 대략 16 × 5MB 를 넘게 쥐지 않는다 (운영 backend 메모리 한도 1g — 2026-10-29 리뷰). 차례는 응답을 다 보낼 때까지 쥔다
+ */
+export const MAIL_IMAGE_CONCURRENCY = 16;
+
+/**
+ * 차례를 기다리는 최대 시간 — 넘으면 받지 않고 404 (앞단 nginx 의 /api 응답 대기 60초보다 짧게).
+ * 바깥 서버가 느린 이미지가 많은 메일에서 기다림이 끝없이 쌓이지 않게 둔다
+ */
+export const MAIL_IMAGE_QUEUE_WAIT_MS = 30_000;
+
+/** 한 직원이 차례를 기다릴 수 있는 수 — 넘으면 바로 404 (뉴스레터 한 통의 이미지 수보다 넉넉히) */
+export const MAIL_IMAGE_MAX_WAITING_PER_USER = 200;
+
+/** 이 경로의 요청 수 한도 — IP 마다 1분 600회 (뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT) */
+export const MAIL_IMAGE_THROTTLE_TTL_MS = 60_000;
+export const MAIL_IMAGE_THROTTLE_LIMIT = 600;
+
+/** 바깥으로 묻는 고정 User-Agent — 브라우저의 것을 넘기지 않는다 */
+export const MAIL_IMAGE_USER_AGENT = 'ARA-SYSTEM-Mail-Image-Proxy/1.0';
+
+/** 응답의 브라우저 캐시 — 표(주소)마다 1시간, 공유 캐시에는 두지 않는다 */
+export const MAIL_IMAGE_CACHE_CONTROL = 'private, max-age=3600';
+
+/** 응답의 CSP — 그림을 문서로 열어도 아무것도 불러오거나 실행하지 않게 */
+export const MAIL_IMAGE_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
diff --git a/backend/src/modules/mail/images/mail-image.controller.spec.ts b/backend/src/modules/mail/images/mail-image.controller.spec.ts
new file mode 100644
index 0000000..d143822
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image.controller.spec.ts
@@ -0,0 +1,168 @@
+import { EventEmitter } from 'events';
+import type { Request, Response } from 'express';
+
+import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
+import { pngBytes } from '@/../test/image-fixtures';
+import { MailImageController, releaseWhenDone } from './mail-image.controller';
+import type {
+ MailImageProxyService,
+ ProxiedImage,
+} from './mail-image-proxy.service';
+
+const ARA: AuthenticatedUser = {
+ email: 'ara.kim@arasystem.test',
+ name: '김아라',
+};
+const PNG = pngBytes(4, 4);
+
+/** express 응답 대역 — 머리글 · 끝 · 닫힘만 흉내 낸다 (finish 는 테스트가 낸다 — 느린 브라우저) */
+class FakeResponse extends EventEmitter {
+ statusCode = 200;
+ headersSent = false;
+ writableEnded = false;
+ writableFinished = false;
+ destroyed = false;
+ readonly headers = new Map();
+ body: unknown = null;
+
+ status(code: number): this {
+ this.statusCode = code;
+ return this;
+ }
+ setHeader(name: string, value: string): this {
+ this.headers.set(name.toLowerCase(), value);
+ return this;
+ }
+ end(data?: unknown): this {
+ this.headersSent = true;
+ this.writableEnded = true;
+ this.body = data ?? null;
+ return this;
+ }
+ json(data: unknown): this {
+ return this.end(data);
+ }
+ /** 다 보냈다 (커널에 넘겼다) */
+ finish(): void {
+ this.writableFinished = true;
+ this.emit('finish');
+ this.emit('close');
+ }
+ /** 브라우저가 끊었다 */
+ hangUp(): void {
+ this.destroyed = true;
+ this.emit('close');
+ }
+}
+
+const request = {
+ header: () => 'trace-1',
+} as unknown as Request;
+
+function lease(): ProxiedImage & { release: jest.Mock } {
+ return { type: 'image/png', data: PNG, release: jest.fn() };
+}
+
+function controllerWith(open: MailImageProxyService['open']) {
+ const proxy = { open: jest.fn(open) };
+ return {
+ proxy,
+ controller: new MailImageController(
+ proxy as unknown as MailImageProxyService,
+ ),
+ };
+}
+
+describe('MailImageController — 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 (9단계 리뷰 뒤)', () => {
+ it('보내면 끝(finish)에서 돌려준다 — 그 전에는 돌려주지 않는다, 끝과 닫힘이 둘 다 와도 한 번', async () => {
+ const image = lease();
+ const { controller } = controllerWith(() => Promise.resolve(image));
+ const res = new FakeResponse();
+
+ await controller.get(ARA, 'tok', request, res as unknown as Response);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers.get('content-type')).toBe('image/png');
+ expect(res.headers.get('content-length')).toBe(String(PNG.length));
+ expect(res.body).toBe(PNG);
+ // 느린 브라우저 — 아직 다 보내지 못했다
+ expect(image.release).not.toHaveBeenCalled();
+
+ res.finish();
+ expect(image.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('보내는 중에 브라우저가 끊으면(close) 돌려준다', async () => {
+ const image = lease();
+ const { controller } = controllerWith(() => Promise.resolve(image));
+ const res = new FakeResponse();
+
+ await controller.get(ARA, 'tok', request, res as unknown as Response);
+ expect(image.release).not.toHaveBeenCalled();
+
+ res.hangUp();
+ expect(image.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('받는 사이 브라우저가 끊었으면 아무것도 쓰지 않고 바로 돌려준다 — 받기에도 끊김을 알린다', async () => {
+ const image = lease();
+ const res = new FakeResponse();
+ let signal: AbortSignal | undefined;
+ const { controller } = controllerWith((_user, _token, abort) => {
+ signal = abort;
+ res.hangUp();
+ return Promise.resolve(image);
+ });
+
+ await controller.get(ARA, 'tok', request, res as unknown as Response);
+
+ expect(signal?.aborted).toBe(true);
+ expect(res.body).toBeNull();
+ expect(image.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('받지 못하면(null) 표준 실패 형식의 404 RES_001 — 돌려줄 차례가 없다(서비스가 이미 돌려줬다)', async () => {
+ const { controller } = controllerWith(() => Promise.resolve(null));
+ const res = new FakeResponse();
+
+ await controller.get(ARA, 'tok', request, res as unknown as Response);
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({
+ success: false,
+ error: {
+ code: 'RES_001',
+ message: expect.any(String) as unknown,
+ traceId: 'trace-1',
+ },
+ });
+ });
+});
+
+describe('releaseWhenDone', () => {
+ it('이미 다 보냈거나 닫혔으면 바로 돌려준다', () => {
+ for (const state of [
+ { writableFinished: true, destroyed: false },
+ { writableFinished: false, destroyed: true },
+ ]) {
+ const res = Object.assign(new FakeResponse(), state);
+ const release = jest.fn();
+ releaseWhenDone(res as unknown as Response, release);
+ expect(release).toHaveBeenCalledTimes(1);
+ expect(res.listenerCount('finish')).toBe(0);
+ expect(res.listenerCount('close')).toBe(0);
+ }
+ });
+
+ it('끝이나 닫힘을 기다렸다 돌려주고 듣기를 거둔다', () => {
+ const res = new FakeResponse();
+ const release = jest.fn();
+ releaseWhenDone(res as unknown as Response, release);
+ expect(release).not.toHaveBeenCalled();
+
+ res.hangUp();
+ expect(release).toHaveBeenCalledTimes(1);
+ expect(res.listenerCount('finish')).toBe(0);
+ expect(res.listenerCount('close')).toBe(0);
+ });
+});
diff --git a/backend/src/modules/mail/images/mail-image.controller.ts b/backend/src/modules/mail/images/mail-image.controller.ts
new file mode 100644
index 0000000..ff4d83e
--- /dev/null
+++ b/backend/src/modules/mail/images/mail-image.controller.ts
@@ -0,0 +1,165 @@
+import {
+ Controller,
+ Get,
+ HttpStatus,
+ Param,
+ Req,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiCookieAuth,
+ ApiOperation,
+ ApiProduces,
+ ApiResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { Throttle } from '@nestjs/throttler';
+import type { Request, Response } from 'express';
+
+import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
+import {
+ CurrentUser,
+ type AuthenticatedUser,
+} from '@/common/decorators/current-user.decorator';
+import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
+import { getErrorDefinition } from '@/common/errors/error-codes';
+import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
+import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
+import { MAIL_IMAGES_ROUTE } from '@/modules/mail/mail.constants';
+import {
+ MAIL_IMAGE_CACHE_CONTROL,
+ MAIL_IMAGE_CONCURRENCY,
+ MAIL_IMAGE_CONCURRENCY_PER_USER,
+ MAIL_IMAGE_CONTENT_SECURITY_POLICY,
+ MAIL_IMAGE_FETCH_TIMEOUT_MS,
+ MAIL_IMAGE_MAX_BYTES,
+ MAIL_IMAGE_MAX_REDIRECTS,
+ MAIL_IMAGE_THROTTLE_LIMIT,
+ MAIL_IMAGE_THROTTLE_TTL_MS,
+} from './mail-image.constants';
+import { MailImageProxyService } from './mail-image-proxy.service';
+
+/**
+ * 응답을 다 보냈거나(finish) 연결이 닫혔을 때(close) release 를 부른다 — 이미 그렇다면 바로. 둘 다 와도 한 번 (release 가 한 번만 돈다)
+ */
+export function releaseWhenDone(res: Response, release: () => void): void {
+ if (res.writableFinished || res.destroyed) {
+ release();
+ return;
+ }
+ const done = () => {
+ res.removeListener('finish', done);
+ res.removeListener('close', done);
+ release();
+ };
+ res.once('finish', done);
+ res.once('close', done);
+}
+
+/**
+ * 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — `GET /api/mail-images/{token}`.
+ * 받은 메일의 외부 이미지를 보일 때 서버가 그 메일의 `
` 를 이 주소로 바꿔 넣는다(mail.service.ts — 표는 mail-image-token.ts).
+ *
+ * 실패는 모두 404 RES_001 — 까닭을 주지 않는다. 표준 실패 형식(JSON)은 같지만 전역 예외 필터를 거치지 않고 여기서 쓴다:
+ * 필터는 4xx 마다 warn 로그를 남기는데, 뉴스레터 한 통에도 받지 못하는 이미지가 여럿이라(바깥 404 · 그림이 아님) 로그가 넘친다 —
+ * 까닭은 서비스가 debug 로만 남긴다(주소 없이 바깥 호스트 이름만). 로그인 · 요청 수 한도의 실패(401 · 429)는 평소처럼 필터가 답한다
+ */
+@ApiTags('Mail')
+@ApiCookieAuth()
+@UseGuards(JwtAuthGuard)
+@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
+@Controller(MAIL_IMAGES_ROUTE)
+export class MailImageController {
+ constructor(private readonly proxy: MailImageProxyService) {}
+
+ @Get(':token')
+ @SkipTransform()
+ // 이 경로만 넉넉하게 — 뉴스레터 한 통에 이미지가 수십 개다 (사무실 NAT 하나 뒤에 여러 직원)
+ @Throttle({
+ default: {
+ ttl: MAIL_IMAGE_THROTTLE_TTL_MS,
+ limit: MAIL_IMAGE_THROTTLE_LIMIT,
+ },
+ })
+ @ApiOperation({
+ summary:
+ '외부 이미지 (표준 래퍼 없음 — 서버가 바깥 이미지를 대신 받아 준다)',
+ description:
+ '메일 상세(images=show 또는 설정)가 외부 이미지 주소를 이 주소로 바꿔 넣는다. 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 암호화한 글이다 — 그 직원만, 1시간 안에만 쓴다. ' +
+ `서버는 http · https(기본 포트만)이고 이름을 풀어 나온 모든 주소가 공인 주소일 때만 그 주소로 연결한다(다시 가기 ${MAIL_IMAGE_MAX_REDIRECTS}번까지 매번 같은 검사, 전체 ${MAIL_IMAGE_FETCH_TIMEOUT_MS / 1000}초, ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB). ` +
+ '형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP 만 — SVG 등은 받지 않는다). 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다. ' +
+ `동시에 받는 수는 서버 전체 ${MAIL_IMAGE_CONCURRENCY}개 · 한 직원 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지(넘으면 기다린다 — 자리가 나면 지금 받는 수가 가장 적은 직원부터, 응답을 다 보낼 때까지 센다). 요청 수 한도는 이 경로만 IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회. ` +
+ `응답: 판정한 Content-Type · Cache-Control: ${MAIL_IMAGE_CACHE_CONTROL} · X-Content-Type-Options: nosniff · Content-Disposition: inline · Content-Security-Policy: ${MAIL_IMAGE_CONTENT_SECURITY_POLICY}.`,
+ })
+ @ApiProduces('image/png', 'image/jpeg', 'image/gif', 'image/webp')
+ @ApiResponse({
+ status: HttpStatus.OK,
+ description: '바깥 이미지',
+ schema: { type: 'string', format: 'binary' },
+ })
+ @ApiErrorResponse(
+ 'RES_001',
+ '받지 않음 — 까닭을 가리지 않는다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다',
+ )
+ @ApiErrorResponse(
+ 'REQ_001',
+ `IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회 초과`,
+ )
+ async get(
+ @CurrentUser() user: AuthenticatedUser,
+ @Param('token') token: string,
+ @Req() req: Request,
+ @Res() res: Response,
+ ): Promise {
+ // 클라이언트가 끊으면 바깥에서 받던 것도 멈춘다 (기다리던 차례에서도 빠진다) — 응답의 'close' 는 다 보내기 전에 연결이 닫혀도 온다
+ // (요청의 'close' 는 본문을 다 읽으면 오므로 쓰지 않는다 — mail-events.controller.ts 와 같다)
+ const disconnected = new AbortController();
+ const abort = () => {
+ if (!res.writableEnded) disconnected.abort();
+ };
+ res.once('close', abort);
+ // 처리기에 닿기 전에 끊겼으면 'close' 는 이미 지나갔다
+ if (res.destroyed) disconnected.abort();
+ try {
+ const image = await this.proxy.open(
+ user.email,
+ token,
+ disconnected.signal,
+ );
+ // 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 — 느린 브라우저에 보내는 동안의 버퍼도 동시 수에 든다
+ if (image !== null) releaseWhenDone(res, image.release);
+ if (res.headersSent || res.destroyed) return;
+ if (image === null) {
+ this.notFound(req, res);
+ return;
+ }
+ res.status(HttpStatus.OK);
+ res.setHeader('Content-Type', image.type);
+ res.setHeader('Content-Length', String(image.data.length));
+ res.setHeader('Cache-Control', MAIL_IMAGE_CACHE_CONTROL);
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Disposition', 'inline');
+ res.setHeader(
+ 'Content-Security-Policy',
+ MAIL_IMAGE_CONTENT_SECURITY_POLICY,
+ );
+ res.end(image.data);
+ } finally {
+ res.removeListener('close', abort);
+ }
+ }
+
+ /** 표준 실패 형식의 404 RES_001 — 전역 필터와 같은 모양(머리말의 까닭으로 여기서 쓴다) */
+ private notFound(req: Request, res: Response): void {
+ const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
+ res.status(HttpStatus.NOT_FOUND).json({
+ success: false,
+ error: {
+ code: 'RES_001',
+ message: getErrorDefinition('RES_001').message,
+ ...(traceId ? { traceId } : {}),
+ },
+ });
+ }
+}
diff --git a/backend/src/modules/mail/images/node-image-transport.spec.ts b/backend/src/modules/mail/images/node-image-transport.spec.ts
new file mode 100644
index 0000000..e84b830
--- /dev/null
+++ b/backend/src/modules/mail/images/node-image-transport.spec.ts
@@ -0,0 +1,241 @@
+import { createSocket, type Socket as UdpSocket } from 'dgram';
+import { Resolver } from 'dns/promises';
+import {
+ createServer,
+ type IncomingMessage,
+ type Server,
+ type ServerResponse,
+} from 'http';
+import type { AddressInfo } from 'net';
+
+import { pngBytes } from '@/../test/image-fixtures';
+import type { MailImageRequest } from './mail-image-fetcher';
+import {
+ DnsMailImageResolver,
+ NodeMailImageTransport,
+} from './node-image-transport';
+
+/**
+ * 실제 연결 고정 구현 (9단계 리뷰 뒤) — 127.0.0.1 에 띄운 HTTP 서버 · 답하지 않는 DNS 서버로 본다.
+ * 풀리지 않는 이름(`*.pinned.test`)으로 묻는다 — 연결이 되면 그 이름을 다시 풀지 않고 넘겨준 주소로 이었다는 뜻이다
+ * (전송을 fetch(undici — lookup 을 받지 않는다)로 바꾸거나 lookup 을 빠뜨리면 여기서 실패한다)
+ */
+
+const PINNED_HOST = 'images.pinned.test';
+const LOOPBACK = { address: '127.0.0.1', family: 4 } as const;
+const PNG = pngBytes(8, 8, 1_000);
+
+/** 바깥으로 보내는 머리글 — fetcher 가 주는 것과 같은 모양 */
+const HEADERS = {
+ 'user-agent': 'ARA-SYSTEM-Mail-Image-Proxy/1.0',
+ accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1',
+ 'accept-encoding': 'identity',
+};
+
+/** 서버가 받은 요청 하나 — 처리기가 받은 것과 응답 */
+interface Received {
+ readonly request: IncomingMessage;
+ readonly response: ServerResponse;
+}
+
+/** 127.0.0.1 의 빈 포트로 연 HTTP 서버 — 받은 요청을 차례로 기다릴 수 있다 */
+async function openServer(): Promise<{
+ readonly server: Server;
+ readonly port: number;
+ next(): Promise;
+}> {
+ const arrived: Received[] = [];
+ const waiting: ((received: Received) => void)[] = [];
+ const server = createServer((request, response) => {
+ const received = { request, response };
+ const waiter = waiting.shift();
+ if (waiter) waiter(received);
+ else arrived.push(received);
+ });
+ await new Promise((resolve) =>
+ server.listen(0, '127.0.0.1', () => resolve()),
+ );
+ return {
+ server,
+ port: (server.address() as AddressInfo).port,
+ next: () => {
+ const ready = arrived.shift();
+ return ready
+ ? Promise.resolve(ready)
+ : new Promise((resolve) => waiting.push(resolve));
+ },
+ };
+}
+
+function closeServer(server: Server): Promise {
+ server.closeAllConnections();
+ return new Promise((resolve) => server.close(() => resolve()));
+}
+
+/** 소켓이 닫힐 때 풀리는 약속 */
+function closed(request: IncomingMessage): Promise {
+ return new Promise((resolve) => {
+ if (request.socket.destroyed) resolve();
+ else request.socket.once('close', () => resolve());
+ });
+}
+
+/** 원래 주소는 기본 포트(80)다 — 실제로 잇는 포트는 전송의 테스트 전용 자리(forTesting)가 정한다 */
+function requestFor(
+ signal: AbortSignal,
+ path = '/logo.png?u=1',
+): MailImageRequest {
+ return {
+ url: new URL(`http://${PINNED_HOST}${path}`),
+ addresses: [LOOPBACK],
+ headers: HEADERS,
+ signal,
+ };
+}
+
+async function readAll(body: AsyncIterable): Promise {
+ const chunks: Uint8Array[] = [];
+ for await (const chunk of body) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+describe('NodeMailImageTransport — 넘겨준 주소로만 잇는다 (127.0.0.1 의 서버)', () => {
+ let opened: Awaited>;
+ beforeEach(async () => {
+ opened = await openServer();
+ });
+ afterEach(async () => {
+ await closeServer(opened.server);
+ });
+
+ it('풀리지 않는 이름이어도 넘겨준 주소로 잇고, 원래 이름을 Host 로 보낸다 — 머리글은 넘겨준 것과 Host · Connection 뿐(쿠키 · Referer 없음)', async () => {
+ const transport = NodeMailImageTransport.forTesting({ http: opened.port });
+ const response = transport.get(requestFor(new AbortController().signal));
+
+ const { request, response: reply } = await opened.next();
+ expect(request.url).toBe('/logo.png?u=1');
+ expect(request.headers.host).toBe(`${PINNED_HOST}:${opened.port}`);
+ expect(request.headers.cookie).toBeUndefined();
+ expect(request.headers.referer).toBeUndefined();
+ expect(Object.keys(request.headers).sort()).toEqual(
+ ['accept', 'accept-encoding', 'connection', 'host', 'user-agent'].sort(),
+ );
+ expect(request.headers['user-agent']).toBe(HEADERS['user-agent']);
+ reply.writeHead(200, {
+ 'content-type': 'text/html',
+ 'content-length': String(PNG.length),
+ });
+ reply.end(PNG);
+
+ const got = await response;
+ expect(got.status).toBe(200);
+ expect(got.contentLength).toBe(PNG.length);
+ expect(got.location).toBeNull();
+ await expect(readAll(got.body)).resolves.toEqual(PNG);
+ });
+
+ it('다시 가기 응답의 Location · 길이 없는 응답(chunked)을 그대로 넘긴다', async () => {
+ const transport = NodeMailImageTransport.forTesting({ http: opened.port });
+ const redirect = transport.get(requestFor(new AbortController().signal));
+ const first = await opened.next();
+ first.response.writeHead(302, { location: '/elsewhere.png' });
+ first.response.end();
+ const moved = await redirect;
+ expect(moved.status).toBe(302);
+ expect(moved.location).toBe('/elsewhere.png');
+ moved.close();
+
+ const chunked = transport.get(requestFor(new AbortController().signal));
+ const second = await opened.next();
+ second.response.writeHead(200, { 'content-type': 'image/png' });
+ second.response.write(PNG.subarray(0, 10));
+ second.response.end(PNG.subarray(10));
+ const streamed = await chunked;
+ expect(streamed.contentLength).toBeNull();
+ await expect(readAll(streamed.body)).resolves.toEqual(PNG);
+ });
+
+ it('응답을 기다리는 사이 끊으면(signal) 연결이 닫히고 약속은 거절된다', async () => {
+ const transport = NodeMailImageTransport.forTesting({ http: opened.port });
+ const controller = new AbortController();
+ const pending = transport.get(requestFor(controller.signal));
+ const { request } = await opened.next();
+ const socketClosed = closed(request);
+
+ controller.abort();
+ await expect(pending).rejects.toThrow();
+ await socketClosed;
+ expect(request.socket.destroyed).toBe(true);
+ });
+
+ it('본문을 읽는 중에 끊으면(signal) 연결이 닫히고 읽기가 멈춘다 · close() 도 연결을 닫는다', async () => {
+ const transport = NodeMailImageTransport.forTesting({ http: opened.port });
+ const controller = new AbortController();
+ const reading = transport.get(requestFor(controller.signal));
+ const first = await opened.next();
+ first.response.writeHead(200, { 'content-length': '100000' });
+ first.response.write(PNG);
+ const got = await reading;
+ const firstClosed = closed(first.request);
+
+ controller.abort();
+ await expect(readAll(got.body)).rejects.toThrow();
+ await firstClosed;
+
+ const closing = transport.get(requestFor(new AbortController().signal));
+ const second = await opened.next();
+ second.response.writeHead(200, { 'content-length': '100000' });
+ second.response.write(PNG);
+ const secondClosed = closed(second.request);
+ (await closing).close();
+ await secondClosed;
+ expect(second.request.socket.destroyed).toBe(true);
+ });
+
+ it('앱의 전송은 기본 포트(http 80) 로 잇는다 — 테스트 전용 자리가 아니면 바뀌지 않는다', () => {
+ const transport = new NodeMailImageTransport();
+ expect(
+ (transport as unknown as { ports: { http: number; https: number } })
+ .ports,
+ ).toEqual({ http: 80, https: 443 });
+ });
+});
+
+describe('DnsMailImageResolver — 끊으면 이름 풀기를 취소한다 (127.0.0.1 의 답하지 않는 DNS)', () => {
+ let dns: UdpSocket;
+ let port: number;
+ let queries: number;
+ let firstQuery: Promise;
+ beforeEach(async () => {
+ queries = 0;
+ dns = createSocket('udp4');
+ firstQuery = new Promise((resolve) =>
+ dns.on('message', () => {
+ queries += 1;
+ resolve();
+ }),
+ );
+ await new Promise((resolve) => dns.bind(0, '127.0.0.1', resolve));
+ port = dns.address().port;
+ });
+ afterEach(async () => {
+ jest.restoreAllMocks();
+ await new Promise((resolve) => dns.close(() => resolve()));
+ });
+
+ it('묻는 사이 끊으면 Resolver 를 취소하고 곧바로 거절한다 (제한 시간을 기다리지 않는다)', async () => {
+ const cancel = jest.spyOn(Resolver.prototype, 'cancel');
+ const resolver = DnsMailImageResolver.forTesting([`127.0.0.1:${port}`]);
+ const controller = new AbortController();
+
+ const resolving = resolver.resolve('black.hole.test', controller.signal);
+ await firstQuery;
+ expect(queries).toBeGreaterThan(0);
+ controller.abort();
+
+ // 취소하지 않으면 Resolver 는 제한 시간(3초 × 2번)까지 기다린다 — jest 의 테스트 시간(5초)을 넘겨 실패한다.
+ // 거절 값은 Node 의 ECANCELLED 다 (jest 의 다른 realm 에서는 instanceof Error 가 아니라 'dns-failed' 로 감싸 보인다)
+ await expect(resolving).rejects.toThrow();
+ expect(cancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/backend/src/modules/mail/images/node-image-transport.ts b/backend/src/modules/mail/images/node-image-transport.ts
new file mode 100644
index 0000000..565d40a
--- /dev/null
+++ b/backend/src/modules/mail/images/node-image-transport.ts
@@ -0,0 +1,167 @@
+import { Injectable } from '@nestjs/common';
+import { Resolver } from 'dns/promises';
+import type { LookupAddress, LookupOptions } from 'dns';
+import { request as httpRequest, type IncomingMessage } from 'http';
+import { request as httpsRequest } from 'https';
+
+import type {
+ MailImageRequest,
+ MailImageResolver,
+ MailImageResponse,
+ MailImageTransport,
+ ResolvedAddress,
+} from './mail-image-fetcher';
+import { hostOf } from './mail-image-url';
+
+/**
+ * 바깥 이미지 받기의 실제 이름 풀기 · 전송 (mail-image-fetcher.ts 가 주입받는다) — fetcher 의 단위 테스트는 가짜를 쓰고,
+ * 이 파일은 127.0.0.1 에 띄운 서버 · DNS 로 따로 본다(node-image-transport.spec.ts). 그래서 테스트 전용 자리(forTesting)가 둘 있다 —
+ * 연결 포트와 DNS 서버. 앱(Nest 의 useClass)은 쓰지 않는다: 포트는 80 · 443 고정이고 env · 설정으로 열지 않는다
+ */
+
+/** 이름 풀기 한 번의 제한 시간 · 다시 묻는 횟수 — 전체 10초 안에서 */
+const DNS_TIMEOUT_MS = 3_000;
+const DNS_TRIES = 2;
+
+/**
+ * DNS 로 A · AAAA 를 함께 묻는다 — c-ares(Resolver)로 묻는다. `dns.lookup`(getaddrinfo)은 libuv 스레드 풀(기본 4개)을 막고 기다려,
+ * 느린 이름 여러 개가 메일 엔진 · DB 연결의 이름 풀기까지 붙잡을 수 있다. 요청마다 새 Resolver — 끊으면 그것만 취소한다.
+ * 한쪽만 풀려도 그 주소들을 쓴다 (나온 주소는 모두 공인인지 fetcher 가 본다). 둘 다 못 풀면 던진다
+ */
+@Injectable()
+export class DnsMailImageResolver implements MailImageResolver {
+ /** 물을 DNS 서버 — 앱은 null(운영체제 설정 그대로), 테스트만 준다 */
+ private servers: readonly string[] | null = null;
+
+ /** 테스트 전용 — 이 DNS 서버(127.0.0.1 의 가짜)에 묻는다 */
+ static forTesting(servers: readonly string[]): DnsMailImageResolver {
+ const resolver = new DnsMailImageResolver();
+ resolver.servers = [...servers];
+ return resolver;
+ }
+
+ async resolve(
+ hostname: string,
+ signal: AbortSignal,
+ ): Promise {
+ const resolver = new Resolver({
+ timeout: DNS_TIMEOUT_MS,
+ tries: DNS_TRIES,
+ });
+ if (this.servers !== null) resolver.setServers([...this.servers]);
+ const cancel = () => resolver.cancel();
+ signal.addEventListener('abort', cancel, { once: true });
+ try {
+ const [v4, v6] = await Promise.allSettled([
+ resolver.resolve4(hostname),
+ resolver.resolve6(hostname),
+ ]);
+ const addresses: ResolvedAddress[] = [
+ ...(v4.status === 'fulfilled'
+ ? v4.value.map((address) => ({ address, family: 4 as const }))
+ : []),
+ ...(v6.status === 'fulfilled'
+ ? v6.value.map((address) => ({ address, family: 6 as const }))
+ : []),
+ ];
+ if (addresses.length === 0 && v4.status === 'rejected') {
+ throw v4.reason instanceof Error ? v4.reason : new Error('dns-failed');
+ }
+ return addresses;
+ } finally {
+ signal.removeEventListener('abort', cancel);
+ }
+ }
+}
+
+/** Node 의 lookup 자리 — 확인한 주소만 돌려준다 (연결할 때 이름을 다시 풀지 않는다). 주소 고르기(happy eyeballs)는 `all` 로 */
+function pinnedLookup(addresses: readonly ResolvedAddress[]) {
+ return (
+ _hostname: string,
+ options: LookupOptions,
+ callback: (
+ error: NodeJS.ErrnoException | null,
+ address: string | LookupAddress[],
+ family?: number,
+ ) => void,
+ ): void => {
+ if (options.all) {
+ callback(
+ null,
+ addresses.map(({ address, family }) => ({ address, family })),
+ );
+ return;
+ }
+ const [first] = addresses;
+ callback(null, first.address, first.family);
+ };
+}
+
+function headerValue(value: string | string[] | undefined): string | null {
+ if (Array.isArray(value)) return value[0] ?? null;
+ return value ?? null;
+}
+
+function contentLengthOf(value: string | string[] | undefined): number | null {
+ const raw = headerValue(value);
+ if (raw === null || !/^\d{1,15}$/.test(raw.trim())) return null;
+ return Number(raw.trim());
+}
+
+/** 연결 포트 — 주소의 체계마다 */
+interface TransportPorts {
+ readonly http: number;
+ readonly https: number;
+}
+
+/** http 80 · https 443 — 바꾸지 않는다 (mail-image-url.ts 가 기본 포트의 주소만 받는다). 테스트만 forTesting 으로 */
+const DEFAULT_PORTS: TransportPorts = Object.freeze({ http: 80, https: 443 });
+
+/**
+ * Node 의 http · https 로 GET 한 번 — 연결은 확인한 주소로만(lookup 자리), 연결을 모아 두지 않는다(agent: false).
+ * TLS 는 원래 이름으로 확인한다(SNI · 인증서 — 이름은 그대로 두고 주소만 정해 준다). 머리글은 fetcher 가 준 것뿐이다(Host 는 원래 이름)
+ */
+@Injectable()
+export class NodeMailImageTransport implements MailImageTransport {
+ private ports: TransportPorts = DEFAULT_PORTS;
+
+ /** 테스트 전용 — 127.0.0.1 에 띄운 서버의 포트로 잇는다 (앱은 기본 포트 고정) */
+ static forTesting(ports: Partial): NodeMailImageTransport {
+ const transport = new NodeMailImageTransport();
+ transport.ports = { ...DEFAULT_PORTS, ...ports };
+ return transport;
+ }
+
+ get(request: MailImageRequest): Promise {
+ const { url } = request;
+ const secure = url.protocol === 'https:';
+ const send = secure ? httpsRequest : httpRequest;
+ return new Promise((resolve, reject) => {
+ const outgoing = send(
+ {
+ protocol: url.protocol,
+ hostname: hostOf(url),
+ port: secure ? this.ports.https : this.ports.http,
+ path: `${url.pathname}${url.search}`,
+ method: 'GET',
+ headers: { ...request.headers },
+ agent: false,
+ lookup: pinnedLookup(request.addresses),
+ signal: request.signal,
+ },
+ (incoming: IncomingMessage) => {
+ resolve({
+ status: incoming.statusCode ?? 0,
+ location: headerValue(incoming.headers.location),
+ contentLength: contentLengthOf(incoming.headers['content-length']),
+ body: incoming,
+ close: () => incoming.destroy(),
+ });
+ },
+ );
+ // 응답을 받은 뒤의 오류(끊김 · 취소)도 받아 둔다 — 듣는 곳이 없는 'error' 는 프로세스를 멈춘다. 본문 오류는 읽는 쪽에서 난다
+ outgoing.on('error', reject);
+ outgoing.end();
+ });
+ }
+}
diff --git a/backend/src/modules/mail/jmap/delivery-status.spec.ts b/backend/src/modules/mail/jmap/delivery-status.spec.ts
index de20a9c..0359742 100644
--- a/backend/src/modules/mail/jmap/delivery-status.spec.ts
+++ b/backend/src/modules/mail/jmap/delivery-status.spec.ts
@@ -1,10 +1,10 @@
import type { RecipientFields } from '@/modules/mail/mail.types';
import {
- DELIVERED_TO_ALL,
classifySmtpReply,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
+ submittedEnvelope,
toRejectedRecipients,
} from './delivery-status';
import type { JmapEmailSubmission } from './jmap.types';
@@ -197,13 +197,115 @@ describe('받는 사람별 발송 결과 (delivery-status)', () => {
});
});
- it('받지 않은 사람이 없는 결과(DELIVERED_TO_ALL)는 여러 요청이 함께 쓰므로 얼려 둔다 — 배열까지', () => {
- expect(DELIVERED_TO_ALL).toEqual({
- rejected: [],
- isEveryoneRejected: false,
+ describe('엔진이 envelope 에서 받지 않은 주소 (9단계 리뷰 뒤 — envelope.ts)', () => {
+ const recipients: RecipientFields = {
+ to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
+ cc: [{ name: null, email: 'typo@example.cmo' }],
+ bcc: [{ name: null, email: 'Idn@한국.example.com' }],
+ };
+
+ it('낸 envelope 에 없는 받는 사람은 not-found — 결과가 없어도(붙잡은 바깥 받는 사람) 정한다, 요청의 차례 그대로', () => {
+ expect(
+ readDeliveryResult(
+ recipients,
+ [],
+ ['doyun.lee@arasystem.test', 'idn@xn--3e0b707e.example.com'],
+ ),
+ ).toEqual({
+ rejected: [
+ {
+ email: 'typo@example.cmo',
+ reason: 'not-found',
+ smtpStatus: null,
+ envelopeRefused: true,
+ },
+ ],
+ isEveryoneRejected: false,
+ });
+ });
+
+ it('envelope 에서 빠진 사람과 SMTP 로 거절된 사람을 함께 — 모두면 모두 거절(MAIL_010)', () => {
+ const result = readDeliveryResult(
+ recipients,
+ [
+ submission({
+ 'doyun.lee@arasystem.test': REFUSED_NOT_FOUND,
+ 'idn@xn--3e0b707e.example.com': REFUSED_RATE_LIMIT,
+ }),
+ ],
+ ['doyun.lee@arasystem.test', 'Idn@한국.example.com'],
+ );
+ expect(
+ result.rejected.map(({ email, reason }) => [email, reason]),
+ ).toEqual([
+ ['doyun.lee@arasystem.test', 'not-found'],
+ ['typo@example.cmo', 'not-found'],
+ ['Idn@한국.example.com', 'temporary'],
+ ]);
+ expect(result.isEveryoneRejected).toBe(true);
+ expect(everyoneRejectedCode(result.rejected)).toBe('MAIL_010');
+ });
+
+ it('국제화 도메인은 엔진의 결과 열쇠(xn--)와 맞춘다 — 요청의 유니코드 주소 그대로 돌려준다', () => {
+ expect(
+ readDeliveryResult(only('idn@한국.example.com'), [
+ submission({ 'idn@xn--3e0b707e.example.com': REFUSED_NOT_FOUND }),
+ ]).rejected,
+ ).toEqual([
+ {
+ email: 'idn@한국.example.com',
+ reason: 'not-found',
+ smtpStatus: '550 5.1.2',
+ },
+ ]);
+ });
+
+ it('envelope 을 모르면(null) 그 규칙을 쓰지 않는다 — 지금처럼 결과만 본다', () => {
+ expect(readDeliveryResult(recipients, [], null)).toEqual({
+ rejected: [],
+ isEveryoneRejected: false,
+ });
+ });
+
+ it('같은 키의 재시도 — 제출들의 envelope 받는 사람을 모은다, 하나라도 envelope 을 모르면 null', () => {
+ expect(
+ submittedEnvelope([
+ {
+ id: 's1',
+ envelope: {
+ rcptTo: [{ email: 'a@x.example' }, { email: 'b@y.example' }],
+ },
+ },
+ { id: 's2', envelope: { rcptTo: [{ email: 'c@z.example' }] } },
+ ]),
+ ).toEqual(['a@x.example', 'b@y.example', 'c@z.example']);
+ expect(submittedEnvelope([{ id: 's1' }])).toBeNull();
+ expect(submittedEnvelope([{ id: 's1', envelope: null }])).toBeNull();
+ expect(submittedEnvelope([])).toBeNull();
+ });
+
+ it('로그에는 envelope 에서 빠진 사람을 따로 적는다', () => {
+ expect(
+ describeRejections([
+ {
+ email: 'typo@example.cmo',
+ reason: 'not-found',
+ smtpStatus: null,
+ envelopeRefused: true,
+ },
+ ]),
+ ).toBe('받지 않은 사람 1명: t***@example.cmo(엔진이 주소를 받지 않음)');
+ expect(
+ toRejectedRecipients([
+ {
+ email: 'typo@example.cmo',
+ reason: 'not-found',
+ smtpStatus: null,
+ envelopeRefused: true,
+ },
+ ]),
+ ).toEqual([{ email: 'typo@example.cmo', reason: 'not-found' }]);
});
- expect(Object.isFrozen(DELIVERED_TO_ALL)).toBe(true);
- expect(Object.isFrozen(DELIVERED_TO_ALL.rejected)).toBe(true);
});
it('응답에는 주소와 까닭만 싣는다 — SMTP 상태 코드는 로그 전용', () => {
diff --git a/backend/src/modules/mail/jmap/delivery-status.ts b/backend/src/modules/mail/jmap/delivery-status.ts
index b19e977..3332789 100644
--- a/backend/src/modules/mail/jmap/delivery-status.ts
+++ b/backend/src/modules/mail/jmap/delivery-status.ts
@@ -5,6 +5,7 @@ import type {
RejectedRecipient,
} from '@/modules/mail/mail.types';
import { maskEmail } from '@/shared/utils/mask';
+import { envelopeKey } from './envelope';
import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
/**
@@ -16,13 +17,20 @@ import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
* - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도)
* - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다.
* 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다
- * 판단은 deliveryStatus 로만 한다 — undoStatus 는 모두 거절이면 final, 하나라도 대기열에 들어가면 pending 일 뿐이다.
+ * 판단은 deliveryStatus 로 한다 — undoStatus 는 모두 거절이면 final(붙잡아도 — 2026-10-29 확인), 하나라도 대기열에 들어가면 pending 일 뿐이다.
+ * 붙잡은 제출도 받는 사람마다 그 자리에서 `delivered: "queued"` · `250 2.1.5 Queued` 로 남는다(사내 · 바깥 모두 — 같은 날 확인).
+ *
+ * 엔진이 envelope 에서 받지 않은 주소(9단계 리뷰 뒤 — envelope.ts)는 제출에 들지 않아 결과가 없다 — 그래서 제출의 envelope(rcptTo)을 알면
+ * **그 envelope 에 없는 받는 사람은 not-found** 다. 결과가 없는 것으로 정하지 않는다: 붙잡은 바깥 받는 사람의 결과가 늘 먼저 있다고 기대하지 않는다.
+ * 처음 발송(보낸 envelope)과 같은 키의 재시도(제출을 다시 읽은 envelope)가 같은 답을 내게 하는 규칙이다
*/
/** 받지 않은 사람 하나 — 로그에 남길 SMTP 상태 코드를 함께 둔다 (응답에는 email · reason 만 — toRejectedRecipients) */
export interface RecipientRejection extends RejectedRecipient {
- /** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 null */
+ /** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 · envelope 에서 빠졌으면 null */
readonly smtpStatus: string | null;
+ /** 엔진이 envelope 에서 받지 않아 제출에 들지 않았다 (SMTP 로 묻지도 않았다 — 로그에만 쓴다) */
+ readonly envelopeRefused?: boolean;
}
/** 한 번 보낸 결과 */
@@ -33,12 +41,6 @@ export interface DeliveryResult {
readonly isEveryoneRejected: boolean;
}
-/** 받지 않은 사람이 없다 — 결과를 읽지 못했을 때도 이것으로 본다 (제출은 이미 되었다). 여러 요청이 함께 쓰는 값이라 얼려 둔다 */
-export const DELIVERED_TO_ALL: DeliveryResult = Object.freeze({
- rejected: Object.freeze([]),
- isEveryoneRejected: false,
-});
-
/**
* SMTP 응답 맨 앞의 상태 — 기본 코드(`550`)와 확장 상태(`5.1.2` — RFC 3463), 둘 다 없을 수 있다.
* 여러 줄 응답의 이음표(`550-5.1.1`)도 받는다. 맨 앞만 보므로 응답 글 안의 숫자(판 번호 등)는 읽지 않는다
@@ -52,11 +54,6 @@ const ADDRESS_STATUS_SUBJECT = '1';
/** 로그 한 줄에 적는 받지 않은 사람 수 — 넘으면 "외 N명" */
const LOGGED_REJECTIONS_MAX = 10;
-/** 주소를 맞출 때의 열쇠 — 대소문자와 앞뒤 공백을 무시한다 */
-function addressKey(email: string): string {
- return email.trim().toLowerCase();
-}
-
/**
* 받지 않은 SMTP 응답의 까닭 — 4xx 는 temporary, 확장 상태 5.1.x 는 not-found, 그 밖(5xx · 읽을 수 없는 응답)은 refused.
* 기본 코드가 없으면 확장 상태의 첫 자리로 본다 (`delivered: "no"` 라 어느 쪽이든 이 사람에게는 가지 않는다)
@@ -86,13 +83,16 @@ export function classifySmtpReply(reply: string | null | undefined): {
}
/**
- * 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
- * `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다.
- * 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다.
+ * 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 envelopeKey(대소문자 무시 · 국제화 도메인은 `xn--`)로 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
+ * - envelope(제출에 실은 받는 사람)을 알면 그 안에 없는 사람은 not-found (엔진이 envelope 에서 받지 않았다 — 머리말)
+ * - `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다
+ * - 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다
+ * @param envelope 제출에 실은 받는 사람 주소 — 모르면 null (envelope 규칙을 쓰지 않는다)
*/
export function readDeliveryResult(
recipients: RecipientFields,
submissions: readonly JmapEmailSubmission[],
+ envelope: readonly string[] | null = null,
): DeliveryResult {
const refusals = new Map();
const accepted = new Set();
@@ -100,7 +100,7 @@ export function readDeliveryResult(
for (const [address, status] of Object.entries(
submission.deliveryStatus ?? {},
)) {
- const key = addressKey(address);
+ const key = envelopeKey(address);
if (status?.delivered !== 'no') {
accepted.add(key);
} else if (!refusals.has(key)) {
@@ -108,6 +108,8 @@ export function readDeliveryResult(
}
}
}
+ const submitted =
+ envelope === null ? null : new Set(envelope.map(envelopeKey));
const seen = new Set();
const rejected: RecipientRejection[] = [];
@@ -116,10 +118,19 @@ export function readDeliveryResult(
...recipients.cc,
...recipients.bcc,
]) {
- const key = addressKey(email);
+ const key = envelopeKey(email);
if (seen.has(key)) continue;
seen.add(key);
+ if (submitted !== null && !submitted.has(key)) {
+ rejected.push({
+ email,
+ reason: 'not-found',
+ smtpStatus: null,
+ envelopeRefused: true,
+ });
+ continue;
+ }
const refusal = accepted.has(key) ? undefined : refusals.get(key);
if (refusal) {
rejected.push({ email, ...classifySmtpReply(refusal.smtpReply) });
@@ -131,6 +142,24 @@ export function readDeliveryResult(
};
}
+/**
+ * 제출들의 envelope 받는 사람 (같은 키의 재시도가 제출을 다시 읽을 때) — 하나라도 envelope 을 읽지 못하면 null (envelope 규칙을 쓰지 않는다)
+ */
+export function submittedEnvelope(
+ submissions: readonly JmapEmailSubmission[],
+): string[] | null {
+ if (submissions.length === 0) return null;
+ const addresses: string[] = [];
+ for (const submission of submissions) {
+ const rcptTo = submission.envelope?.rcptTo;
+ if (!Array.isArray(rcptTo)) return null;
+ for (const recipient of rcptTo) {
+ if (typeof recipient?.email === 'string') addresses.push(recipient.email);
+ }
+ }
+ return addresses;
+}
+
/** 응답에 싣는 모양 (계약의 RejectedRecipient) — SMTP 상태 코드는 로그 전용이라 뺀다 */
export function toRejectedRecipients(
rejected: readonly RecipientRejection[],
@@ -157,8 +186,8 @@ export function describeRejections(
const listed = rejected
.slice(0, LOGGED_REJECTIONS_MAX)
.map(
- ({ email, smtpStatus }) =>
- `${maskEmail(email)}(${smtpStatus ?? '상태 코드 없음'})`,
+ ({ email, smtpStatus, envelopeRefused }) =>
+ `${maskEmail(email)}(${envelopeRefused ? '엔진이 주소를 받지 않음' : (smtpStatus ?? '상태 코드 없음')})`,
);
const more = rejected.length - listed.length;
return `받지 않은 사람 ${rejected.length}명: ${listed.join(', ')}${more > 0 ? ` 외 ${more}명` : ''}`;
diff --git a/backend/src/modules/mail/jmap/envelope.spec.ts b/backend/src/modules/mail/jmap/envelope.spec.ts
new file mode 100644
index 0000000..a6a528c
--- /dev/null
+++ b/backend/src/modules/mail/jmap/envelope.spec.ts
@@ -0,0 +1,208 @@
+import { startCpuTimer } from '@/../test/cpu-time';
+import {
+ addressNamedIn,
+ envelopeKey,
+ envelopeRecipients,
+ isEnvelopeRefusal,
+ submissionEnvelope,
+} from './envelope';
+
+describe('envelope — 발송 제출의 envelope (9단계 리뷰 뒤 — 늘 직접 준다)', () => {
+ it('받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소는 처음 쓴 것 하나 (대소문자 · 국제화 도메인 모양 무시)', () => {
+ expect(
+ envelopeRecipients({
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ cc: [
+ { name: null, email: ' DOYUN.LEE@arasystem.test ' },
+ { name: null, email: 'kim@partner.example' },
+ { name: null, email: 'idn@한국.example.com' },
+ ],
+ bcc: [
+ { name: null, email: 'seojun.park@arasystem.test' },
+ { name: null, email: 'IDN@xn--3e0b707e.example.com' },
+ ],
+ }),
+ ).toEqual([
+ 'doyun.lee@arasystem.test',
+ 'kim@partner.example',
+ 'idn@한국.example.com',
+ 'seojun.park@arasystem.test',
+ ]);
+ });
+
+ it('맞추는 열쇠 — 소문자, 도메인은 xn-- 꼴(엔진이 제출의 envelope 에 두는 모양), 바꿀 수 없는 도메인은 소문자 그대로', () => {
+ expect(envelopeKey(' Kim@Partner.EXAMPLE ')).toBe('kim@partner.example');
+ expect(envelopeKey('idn@한국.example.com')).toBe(
+ 'idn@xn--3e0b707e.example.com',
+ );
+ expect(envelopeKey('한글@example.com')).toBe('한글@example.com');
+ expect(envelopeKey('x@[1.2.3.4]')).toBe('x@[1.2.3.4]');
+ expect(envelopeKey('no-at-sign')).toBe('no-at-sign');
+ });
+
+ it('제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(없으면 null), 받는 사람마다 매개변수 null', () => {
+ expect(
+ submissionEnvelope('ara.kim@arasystem.test', ['a@x.example'], {
+ HOLDFOR: '5',
+ }),
+ ).toEqual({
+ mailFrom: {
+ email: 'ara.kim@arasystem.test',
+ parameters: { HOLDFOR: '5' },
+ },
+ rcptTo: [{ email: 'a@x.example', parameters: null }],
+ });
+ expect(
+ submissionEnvelope(
+ 'ara.kim@arasystem.test',
+ ['a@x.example', 'b@y.example'],
+ null,
+ ),
+ ).toEqual({
+ mailFrom: { email: 'ara.kim@arasystem.test', parameters: null },
+ rcptTo: [
+ { email: 'a@x.example', parameters: null },
+ { email: 'b@y.example', parameters: null },
+ ],
+ });
+ });
+
+ it('엔진이 envelope 을 받지 않은 거절 — invalidProperties 의 속성이 envelope 이거나 그 안일 때만', () => {
+ expect(
+ isEnvelopeRefusal({
+ type: 'invalidProperties',
+ properties: ['envelope'],
+ }),
+ ).toBe(true);
+ expect(
+ isEnvelopeRefusal({
+ type: 'invalidProperties',
+ properties: ['envelope/rcptTo/1/email'],
+ }),
+ ).toBe(true);
+ expect(
+ isEnvelopeRefusal({ type: 'invalidProperties', properties: ['emailId'] }),
+ ).toBe(false);
+ expect(
+ isEnvelopeRefusal({
+ type: 'forbiddenMailFrom',
+ properties: ['envelope'],
+ }),
+ ).toBe(false);
+ expect(isEnvelopeRefusal(undefined)).toBe(false);
+ });
+
+ describe('거절 설명에 적힌 우리 주소 하나 (addressNamedIn) — 개발 엔진이 돌려준 설명의 모양 (Stalwart 0.16.27, 2026-10-29)', () => {
+ const addresses = [
+ 'ara.kim@arasystem.test',
+ 'typo1@example.cmo',
+ 'Typo.Two@Example.CMO',
+ 'idn@한국.cmo',
+ '"a b"@example.com',
+ ];
+
+ it.each<[string, string, string | null]>([
+ [
+ '보낸 모양 그대로',
+ 'Invalid e-mail address "typo1@example.cmo".',
+ 'typo1@example.cmo',
+ ],
+ [
+ '대소문자 무시',
+ 'Invalid e-mail address "typo.two@example.cmo".',
+ 'Typo.Two@Example.CMO',
+ ],
+ [
+ '국제화 도메인(유니코드 그대로)',
+ 'Invalid e-mail address "idn@한국.cmo".',
+ 'idn@한국.cmo',
+ ],
+ [
+ '국제화 도메인(xn-- 꼴)',
+ 'Invalid e-mail address "idn@xn--3e0b707e.cmo".',
+ 'idn@한국.cmo',
+ ],
+ [
+ '따옴표 앞부분 — 따옴표 · 역슬래시를 이스케이프해 적는다',
+ 'Invalid e-mail address "\\"a b\\"@example.com".',
+ '"a b"@example.com',
+ ],
+ [
+ '따옴표 없이 문장 끝',
+ 'Invalid e-mail address typo1@example.cmo.',
+ 'typo1@example.cmo',
+ ],
+ ['우리 주소가 없음', 'Invalid e-mail address "other@example.cmo".', null],
+ ['설명이 없음', '', null],
+ ])('%s', (_label, description, named) => {
+ expect(addressNamedIn(description, addresses)).toBe(named);
+ });
+
+ it('다른 주소의 일부는 맞지 않는다 — a@x 는 ba@x 안에서, x@example.co 는 x@example.com 안에서 찾지 않는다', () => {
+ expect(
+ addressNamedIn('Invalid e-mail address "ba@x.example".', [
+ 'a@x.example',
+ 'ba@x.example',
+ ]),
+ ).toBe('ba@x.example');
+ expect(
+ addressNamedIn('Invalid e-mail address "x@example.com".', [
+ 'x@example.co',
+ ]),
+ ).toBeNull();
+ expect(addressNamedIn(undefined, addresses)).toBeNull();
+ });
+
+ describe('따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다 (9단계 리뷰 2 — 발송 DTO 의 IsEmail 이 받는 모양)', () => {
+ const KIM = 'kim@example.com';
+ /** 엔진이 설명에 적는 모양 — 따옴표 · 역슬래시를 이스케이프 (envelope.ts 머리말) */
+ const escape = (address: string) =>
+ address.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+
+ it.each([
+ '"Kim "@x.cmo',
+ '"kim@example.com "@x.cmo',
+ '"kim@example.com"@x.cmo',
+ '"(kim@example.com)"@x.cmo',
+ ])('%s 가 거절되면 그 주소 하나만', (bad) => {
+ const description = `Invalid e-mail address "${escape(bad)}".`;
+ expect(addressNamedIn(description, [KIM, bad])).toBe(bad);
+ // 받는 사람의 차례가 바뀌어도 같다
+ expect(addressNamedIn(description, [bad, KIM])).toBe(bad);
+ });
+
+ it('설명의 모양이 달라 정확히 맞는 것이 없으면 경계로 찾는다 — 여럿이 걸리면 가장 긴 것 하나 (짧은 주소가 긴 주소의 글 안에 든 것이다)', () => {
+ const bad = '"Kim "@x.cmo';
+ expect(
+ addressNamedIn(`Invalid e-mail address: '${bad}'`, [KIM, bad]),
+ ).toBe(bad);
+ });
+
+ it('경계로 찾은 것의 길이가 같으면 envelope 의 앞 차례 하나 — 한 차례에 하나만 뺀다', () => {
+ expect(
+ addressNamedIn('Invalid e-mail addresses a1@x.cmo, a2@x.cmo', [
+ 'a2@x.cmo',
+ 'a1@x.cmo',
+ ]),
+ ).toBe('a2@x.cmo');
+ });
+ });
+
+ it('설명 길이에 비례한다 — 받는 사람 100명 · 설명 1만 자에도 CPU 시간이 작다', () => {
+ const many = Array.from(
+ { length: 100 },
+ (_, index) => `user${index}@x.example`,
+ );
+ const description = `${'user1@x.examplez '.repeat(600)}Invalid e-mail address "user99@x.example".`;
+ // 정확히 맞는 따옴표가 없을 때(경계로 찾기)도 — 따옴표를 600개 더한다
+ const unquoted = `${'"user1@x.examplez" '.repeat(600)}Invalid e-mail address user99@x.example.`;
+ const cpuMs = startCpuTimer();
+ for (let round = 0; round < 20; round += 1) {
+ expect(addressNamedIn(description, many)).toBe('user99@x.example');
+ expect(addressNamedIn(unquoted, many)).toBe('user99@x.example');
+ }
+ // 지금 수십 ms — 한도는 넉넉히 (CPU 시간, test/cpu-time.ts)
+ expect(cpuMs()).toBeLessThan(1_000);
+ });
+ });
+});
diff --git a/backend/src/modules/mail/jmap/envelope.ts b/backend/src/modules/mail/jmap/envelope.ts
new file mode 100644
index 0000000..c9a8e0d
--- /dev/null
+++ b/backend/src/modules/mail/jmap/envelope.ts
@@ -0,0 +1,187 @@
+import { domainToASCII } from 'url';
+
+import type { RecipientFields } from '@/modules/mail/mail.types';
+import type { JmapError } from './jmap.types';
+
+/**
+ * 발송 제출(EmailSubmission/set)의 envelope — 순수 함수 (계약 2절 "받는 사람별 결과").
+ *
+ * 발송은 늘 envelope 을 직접 준다(9단계 리뷰 뒤 — 붙잡든 아니든). 엔진(Stalwart 0.16.27, 2026-10-29 확인)은:
+ * - 직접 준 envelope 에 받지 않는 주소가 있으면(모르는 최상위 도메인 `x@example.cmo` · `x@k-….example`, 따옴표 앞부분 `"a b"@…` 등)
+ * 제출 **전체**를 `invalidProperties ["envelope"]` 로 거절하고, 설명에 그 가운데 **첫 주소 하나만** 적는다 —
+ * `Invalid e-mail address "<주소>".` (보낸 모양 그대로 · 대소문자도 그대로, 따옴표 · 역슬래시는 `\"` · `\\` 로 이스케이프). 붙잡든 아니든 같다.
+ * 그래서 어댑터는 설명에 적힌 우리 주소 하나(addressNamedIn)를 받지 않은 사람(not-found)으로 빼고 같은 메일을 다시 제출한다
+ * (jmap-mail.provider.ts 의 finishSubmission — 한 차례에 하나, 다시 내기는 mail.constants.ts 의 MAX_ENVELOPE_RESUBMITS 차례까지)
+ * - envelope 을 주지 않으면 같은 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 모두 그런 주소면 noRecipients (9단계 전의 길)
+ * - 국제화 도메인은 유니코드 · `xn--` 모두 받고 제출의 envelope 에는 `xn--` 로 둔다. 같은 주소(대소문자 무시)는 하나로 합친다
+ * - envelope 을 직접 줘도 · 주지 않아도 보내는 메일에서 Bcc 머리글은 뗀다 — 사내 배달 · 바깥(SMTP) 사본 모두 (보낸 사본에만 남는다).
+ * 개발 환경의 Mailpit 은 받은 envelope 의 받는 사람 가운데 머리글에 없는 주소를 `Bcc:` 머리글로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다
+ * (머리글이 없는 메일을 envelope 으로만 보내도 Mailpit 사본 첫 줄이 `Bcc:` — 같은 날 확인)
+ */
+
+/** envelope 을 받지 않았다는 SetError — 그 속성이 envelope(또는 그 안) 일 때 */
+const INVALID_PROPERTIES = 'invalidProperties';
+const ENVELOPE_PROPERTY = 'envelope';
+
+/**
+ * 주소를 맞출 때의 열쇠 — 앞뒤 공백을 빼고 대소문자를 무시하고, 도메인은 `xn--` 꼴로 (엔진이 제출의 envelope · deliveryStatus 에 두는 모양).
+ * 바꿀 수 없는 도메인(빈 값)은 소문자 그대로 본다
+ */
+export function envelopeKey(email: string): string {
+ const trimmed = email.trim();
+ const at = trimmed.lastIndexOf('@');
+ if (at < 0) return trimmed.toLowerCase();
+ const domain = trimmed.slice(at + 1);
+ const ascii = domainToASCII(domain);
+ return `${trimmed.slice(0, at).toLowerCase()}@${(ascii || domain).toLowerCase()}`;
+}
+
+/**
+ * envelope 의 받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소(envelopeKey)는 처음 쓴 것 하나.
+ * envelope 을 주지 않을 때 엔진이 머리글로 정하는 받는 사람과 같은 목록이다
+ */
+export function envelopeRecipients(recipients: RecipientFields): string[] {
+ const seen = new Set();
+ const addresses: string[] = [];
+ for (const { email } of [
+ ...recipients.to,
+ ...recipients.cc,
+ ...recipients.bcc,
+ ]) {
+ const key = envelopeKey(email);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ addresses.push(email.trim());
+ }
+ return addresses;
+}
+
+/** 제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(HOLDFOR · HOLDUNTIL — pending-send.ts) */
+export function submissionEnvelope(
+ from: string,
+ rcptTo: readonly string[],
+ parameters: Readonly> | null,
+): Record {
+ return {
+ mailFrom: {
+ email: from,
+ parameters: parameters ? { ...parameters } : null,
+ },
+ rcptTo: rcptTo.map((email) => ({ email, parameters: null })),
+ };
+}
+
+/** 엔진이 envelope 을 받지 않은 거절인가 (invalidProperties 의 속성이 envelope 이거나 그 안) */
+export function isEnvelopeRefusal(error: JmapError | undefined): boolean {
+ return (
+ error?.type === INVALID_PROPERTIES &&
+ (error.properties ?? []).some(
+ (property) =>
+ property === ENVELOPE_PROPERTY ||
+ property.startsWith(`${ENVELOPE_PROPERTY}/`),
+ )
+ );
+}
+
+/** 설명 안에서 주소의 앞 · 뒤에 올 수 있는 글자 — 따옴표 · 꺾쇠 · 괄호 · 쉼표 · 쌍점 · 쌍반점 · 빈칸 (주소 글자가 붙어 있으면 다른 주소의 일부다) */
+const BOUNDARY = /[\s"'<>()[\],:;]/;
+
+/** 주소의 끝 뒤 — 경계 글자, 또는 문장 끝의 마침표(뒤가 끝 · 경계) */
+function endsAt(text: string, end: number): boolean {
+ if (end >= text.length) return true;
+ const next = text.charAt(end);
+ if (BOUNDARY.test(next)) return true;
+ return (
+ next === '.' &&
+ (end + 1 >= text.length || BOUNDARY.test(text.charAt(end + 1)))
+ );
+}
+
+/** text 안에 needle 이 앞뒤 경계를 두고 들어 있는가 (둘 다 소문자) — 다른 주소의 일부(`a@x` 가 `ba@x` 안)는 맞지 않는다 */
+function containsBounded(text: string, needle: string): boolean {
+ if (needle === '') return false;
+ for (
+ let index = text.indexOf(needle);
+ index >= 0;
+ index = text.indexOf(needle, index + 1)
+ ) {
+ const before = index === 0 || BOUNDARY.test(text.charAt(index - 1));
+ if (before && endsAt(text, index + needle.length)) return true;
+ }
+ return false;
+}
+
+/** 설명에 주소가 적힐 수 있는 모양 — 보낸 그대로 · 따옴표 · 역슬래시를 이스케이프한 것 · 도메인을 `xn--` 로 바꾼 것 */
+function describedForms(address: string): string[] {
+ const escaped = address.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ return [...new Set([address, escaped, envelopeKey(address)])].map((form) =>
+ form.toLowerCase(),
+ );
+}
+
+const QUOTE = '"';
+const ESCAPE = '\\';
+
+/**
+ * 설명의 큰따옴표 토큰 — `"…"` 안의 글, `\"` · `\\` 이스케이프를 푼 것 (엔진은 거절한 주소를 이렇게 적는다 — 머리말). 닫히지 않은 따옴표는 버린다.
+ * 설명 길이에 비례한다 (한 번 훑는다)
+ */
+function quotedTokens(description: string): string[] {
+ const tokens: string[] = [];
+ let token: string[] | null = null;
+ for (let index = 0; index < description.length; index += 1) {
+ const char = description.charAt(index);
+ if (token === null) {
+ if (char === QUOTE) token = [];
+ continue;
+ }
+ if (char === ESCAPE && index + 1 < description.length) {
+ index += 1;
+ token.push(description.charAt(index));
+ } else if (char === QUOTE) {
+ tokens.push(token.join(''));
+ token = null;
+ } else {
+ token.push(char);
+ }
+ }
+ return tokens;
+}
+
+/**
+ * 엔진의 거절 설명에 적힌 우리 envelope 주소 **하나** — 설명 자체는 남기지 않는다(로그 · 응답 모두). 한 차례에 하나만 뺀다(9단계 리뷰 2 —
+ * 다른 받는 사람을 함께 빼지 않게 · 빼는 쪽이 틀리면 받을 사람이 못 받는다, 덜 빼면 한 차례 더 낼 뿐이다):
+ * 1. 큰따옴표 토큰(이스케이프를 푼 것)과 맞추는 열쇠(envelopeKey)가 정확히 같은 주소 — 토큰 차례로 처음 맞는 것.
+ * 그래서 `"Kim "@x.cmo` 가 거절돼도 그 안에 글자로 든 kim@example.com 은 빼지 않는다
+ * 2. 정확히 맞는 것이 없을 때만(설명의 모양이 다름 — 따옴표 없이 적는 등) 대소문자를 무시하고 앞뒤 경계를 두고 찾는다 —
+ * 여럿이 걸리면 가장 긴 것 하나(짧은 주소가 긴 주소의 글 안에 든 것이다), 길이가 같으면 envelope 의 앞 차례
+ * 하나도 없으면 null — 그때는 지금처럼 오류 표대로 답한다 (설명의 모양이 바뀌어도 받는 사람을 함부로 빼지 않는다)
+ */
+export function addressNamedIn(
+ description: string | undefined,
+ addresses: readonly string[],
+): string | null {
+ const text = description ?? '';
+ if (text === '') return null;
+
+ const byKey = new Map();
+ for (const address of addresses) {
+ const key = envelopeKey(address);
+ if (!byKey.has(key)) byKey.set(key, address);
+ }
+ for (const token of quotedTokens(text)) {
+ if (!token.includes('@')) continue;
+ const exact = byKey.get(envelopeKey(token));
+ if (exact !== undefined) return exact;
+ }
+
+ const lower = text.toLowerCase();
+ let longest: string | null = null;
+ for (const address of addresses) {
+ if (longest !== null && address.length <= longest.length) continue;
+ if (describedForms(address).some((form) => containsBounded(lower, form))) {
+ longest = address;
+ }
+ }
+ return longest;
+}
diff --git a/backend/src/modules/mail/jmap/filter-source.spec.ts b/backend/src/modules/mail/jmap/filter-source.spec.ts
index 1338e27..8a78161 100644
--- a/backend/src/modules/mail/jmap/filter-source.spec.ts
+++ b/backend/src/modules/mail/jmap/filter-source.spec.ts
@@ -1,11 +1,18 @@
import {
MAIL_FILTER_VERSION_PATTERN,
+ MAX_MAIL_BLOCKS,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
-import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
+import type {
+ BlockedSender,
+ MailFilterRule,
+ MailVacation,
+} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT,
+ FILTER_SOURCE_FORMAT_WITH_BLOCKS,
FILTER_SOURCE_MARKER,
+ LATEST_FILTER_SOURCE_FORMAT,
filterRulesVersion,
filterSourceLine,
readFilterSource,
@@ -44,6 +51,7 @@ const vacation: MailVacation = {
const source: FilterScriptSource = {
rules: [rule('r1'), rule('r2', { isEnabled: false, match: 'all' })],
vacation,
+ blocked: [],
};
/** 원본 줄을 스크립트 머리에 둔 글 — 생성기가 쓰는 모양 (설명 주석 → 원본 → Sieve) */
@@ -108,7 +116,11 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
} as unknown as MailFilterRule;
expect(
- filterSourceLine({ rules: [shuffled, source.rules[1]], vacation }),
+ filterSourceLine({
+ rules: [shuffled, source.rules[1]],
+ vacation,
+ blocked: [],
+ }),
).toBe(filterSourceLine(source));
});
@@ -163,6 +175,7 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
}),
],
vacation: { ...vacation, subject: '부재\ud83d중' },
+ blocked: [],
};
const line = filterSourceLine(lonely);
expect(line).toContain('반쪽\\ud800글자');
@@ -172,19 +185,25 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
expect(sourceOf(uploaded)).toEqual(lonely);
});
- it('새 판(이 서버의 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
- expect(
- readFilterSource(scriptWithJson(stored({ v: FILTER_SOURCE_FORMAT + 1 }))),
- ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 1 });
+ it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
+ expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
expect(
readFilterSource(
- scriptWithJson({ v: FILTER_SOURCE_FORMAT + 5, entries: '새 모양' }),
+ scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })),
),
- ).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 5 });
+ ).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 1 });
+ expect(
+ readFilterSource(
+ scriptWithJson({
+ v: LATEST_FILTER_SOURCE_FORMAT + 5,
+ entries: '새 모양',
+ }),
+ ),
+ ).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 5 });
// 정수가 아니거나 · 판이 아닌 값 · 옛 판(0 이하)은 깨진 원본이다 — 저장하면 덮어쓴다
for (const v of [
FILTER_SOURCE_FORMAT + 0.5,
- `${FILTER_SOURCE_FORMAT + 1}`,
+ `${LATEST_FILTER_SOURCE_FORMAT + 1}`,
0,
-1,
null,
@@ -299,6 +318,110 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
});
});
+describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => {
+ const blocked: BlockedSender[] = [
+ { address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' },
+ { address: '@ads.example.org', blockedAt: '2026-10-28T00:00:00.000Z' },
+ ];
+ const withBlocks: FilterScriptSource = { ...source, blocked };
+ const storedV2 = (overrides: Record = {}) =>
+ stored({ v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, blocked, ...overrides });
+
+ it('차단한 주소가 있을 때만 판 2 로 쓴다 — 없으면 판 1 그대로(blocked 칸 없이), 판 2 를 모르는 서버도 읽는다', () => {
+ const v1 = JSON.parse(
+ filterSourceLine(source).slice(FILTER_SOURCE_MARKER.length),
+ ) as Record;
+ expect(v1.v).toBe(FILTER_SOURCE_FORMAT);
+ expect('blocked' in v1).toBe(false);
+
+ const v2 = JSON.parse(
+ filterSourceLine(withBlocks).slice(FILTER_SOURCE_MARKER.length),
+ ) as Record;
+ expect(v2.v).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
+ expect(v2.blocked).toEqual(blocked);
+ });
+
+ it('판 2 를 그대로 읽는다 — 차례(최근 것부터)도 그대로, 판 1 은 차단 없음으로', () => {
+ expect(readFilterSource(scriptWith(filterSourceLine(withBlocks)))).toEqual({
+ status: 'ok',
+ source: withBlocks,
+ });
+ expect(sourceOf(scriptWithJson(stored()))?.blocked).toEqual([]);
+ // 판 1 에 blocked 가 적혀 있어도 보지 않는다 — 판 1 을 쓰는 서버는 그 칸을 모른다
+ expect(
+ sourceOf(scriptWithJson(stored({ blocked: [{ address: 'x' }] })))
+ ?.blocked,
+ ).toEqual([]);
+ // 판 2 에 빈 목록 — 읽는다(쓸 때는 판 1 로 돌아간다)
+ expect(
+ sourceOf(scriptWithJson(storedV2({ blocked: [] })))?.blocked,
+ ).toEqual([]);
+ });
+
+ it('차단한 시각은 읽을 때 ISO 8601 UTC(소수 초 3자리)로 맞추고, 칸은 주소 · 시각만 쓴다', () => {
+ const read = sourceOf(
+ scriptWithJson(
+ storedV2({
+ blocked: [{ address: 'a@b.co', blockedAt: '2026-10-29T01:02:03Z' }],
+ }),
+ ),
+ );
+ expect(read?.blocked).toEqual([
+ { address: 'a@b.co', blockedAt: '2026-10-29T01:02:03.000Z' },
+ ]);
+
+ const extra = [
+ { ...blocked[0], note: '버린다' },
+ ] as unknown as BlockedSender[];
+ expect(filterSourceLine({ ...source, blocked: extra })).toBe(
+ filterSourceLine({ ...source, blocked: [blocked[0]] }),
+ );
+ });
+
+ it.each<[string, unknown]>([
+ ['배열이 아님', { address: 'a@b.co' }],
+ ['칸 없음', undefined],
+ ['주소가 글이 아님', [{ address: 1, blockedAt: blocked[0].blockedAt }]],
+ ['시각 없음', [{ address: 'a@b.co' }]],
+ ['시각을 읽을 수 없음', [{ address: 'a@b.co', blockedAt: '어제' }]],
+ [
+ '대문자 — 저장 모양이 아님',
+ [{ address: 'A@b.co', blockedAt: blocked[0].blockedAt }],
+ ],
+ ['앞뒤 공백', [{ address: ' a@b.co', blockedAt: blocked[0].blockedAt }]],
+ ['주소 모양이 아님', [{ address: 'a b', blockedAt: blocked[0].blockedAt }]],
+ [
+ '${ 가 든 주소',
+ [{ address: '${x}@b.co', blockedAt: blocked[0].blockedAt }],
+ ],
+ ['겹친 주소', [blocked[0], { ...blocked[0] }]],
+ [
+ `${MAX_MAIL_BLOCKS + 1}개`,
+ Array.from({ length: MAX_MAIL_BLOCKS + 1 }, (_, i) => ({
+ address: `s${i}@example.net`,
+ blockedAt: blocked[0].blockedAt,
+ })),
+ ],
+ ])(
+ '판 2 의 차단 목록이 계약에 맞지 않으면 깨진 원본 — %s',
+ (_label, value) => {
+ expect(
+ readFilterSource(scriptWithJson(storedV2({ blocked: value }))),
+ ).toEqual(BROKEN);
+ },
+ );
+
+ it(`${MAX_MAIL_BLOCKS}개까지는 읽는다`, () => {
+ const many = Array.from({ length: MAX_MAIL_BLOCKS }, (_, i) => ({
+ address: `s${i}@example.net`,
+ blockedAt: blocked[0].blockedAt,
+ }));
+ expect(
+ sourceOf(scriptWithJson(storedV2({ blocked: many })))?.blocked,
+ ).toHaveLength(MAX_MAIL_BLOCKS);
+ });
+});
+
describe('filterRulesVersion — 규칙의 지문(version)', () => {
const rules = source.rules;
diff --git a/backend/src/modules/mail/jmap/filter-source.ts b/backend/src/modules/mail/jmap/filter-source.ts
index 57660e6..c7bcae1 100644
--- a/backend/src/modules/mail/jmap/filter-source.ts
+++ b/backend/src/modules/mail/jmap/filter-source.ts
@@ -1,13 +1,18 @@
import { createHash } from 'crypto';
+import { parseBlockTarget } from '@/modules/mail/mail-blocks';
import {
MAIL_FILTER_FIELDS,
MAIL_FILTER_MATCHES,
MAIL_FILTER_OPERATORS,
findFilterRulesProblem,
} from '@/modules/mail/mail-filters';
-import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants';
+import {
+ MAX_MAIL_BLOCKS,
+ MAX_MAIL_FILTER_RULES,
+} from '@/modules/mail/mail.constants';
import type {
+ BlockedSender,
MailFilterActions,
MailFilterCondition,
MailFilterRule,
@@ -30,10 +35,20 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
/**
* 원본 모양의 판 — 모양을 바꾸면 올린다.
- * 이보다 큰 판(새 판)은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 —
- * 읽기는 빈 규칙 · 동작하지 않음으로 보이고, 쓰기(필터 · 부재중 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게
+ * 이 서버가 아는 가장 새 판(LATEST_FILTER_SOURCE_FORMAT)보다 큰 판은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`):
+ * 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 — 읽기는 빈 규칙 · 동작하지 않음으로 보이고,
+ * 쓰기(필터 · 부재중 · 차단 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게.
+ *
+ * - 1: 규칙 · 부재중 (6단계). 차단한 주소가 없으면 9단계부터도 이 판으로 쓴다 — 판 2 를 모르는 옛 서버도 그대로 읽고 쓴다
+ * - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다.
+ * 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획:
+ * 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다)
*/
export const FILTER_SOURCE_FORMAT = 1;
+/** 차단 목록이 든 원본의 판 (9단계) */
+export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2;
+/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */
+export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS;
/**
* 스크립트 글에서 원본을 읽은 결과
@@ -47,10 +62,11 @@ export type FilterSourceReading =
| { readonly status: 'broken' }
| { readonly status: 'newer'; readonly format: number };
-/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로)과 부재중 자동 응답 */
+/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로) · 부재중 자동 응답 · 차단한 보낸 사람(9단계 — 최근에 차단한 것부터) */
export interface FilterScriptSource {
readonly rules: readonly MailFilterRule[];
readonly vacation: MailVacation;
+ readonly blocked: readonly BlockedSender[];
}
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
@@ -62,10 +78,11 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = {
textBody: '',
};
-/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
+/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
};
/** 정해진 키 순서의 사본 — 들어온 객체(DTO 등)의 키 순서 · 다른 속성과 상관없이 같은 규칙이면 같은 JSON 이 된다 */
@@ -112,13 +129,26 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
.digest('base64url');
}
-/** 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중) */
+/**
+ * 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다
+ */
export function filterSourceLine(source: FilterScriptSource): string {
- return `${FILTER_SOURCE_MARKER}${JSON.stringify({
- v: FILTER_SOURCE_FORMAT,
+ const base = {
rules: canonicalRules(source.rules),
vacation: canonicalVacation(source.vacation),
- })}`;
+ };
+ return `${FILTER_SOURCE_MARKER}${JSON.stringify(
+ source.blocked.length === 0
+ ? { v: FILTER_SOURCE_FORMAT, ...base }
+ : {
+ v: FILTER_SOURCE_FORMAT_WITH_BLOCKS,
+ ...base,
+ blocked: source.blocked.map(({ address, blockedAt }) => ({
+ address,
+ blockedAt,
+ })),
+ },
+ )}`;
}
/** 스크립트 맨 앞의 주석들에서 원본 줄을 찾는다 — 주석이 아닌 줄이 나오면 멈춘다 */
@@ -246,13 +276,45 @@ function toVacation(raw: unknown): MailVacation | null {
};
}
+/**
+ * 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각.
+ * 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다
+ */
+function toBlocked(raw: unknown): BlockedSender[] | null {
+ if (!Array.isArray(raw) || raw.length > MAX_MAIL_BLOCKS) return null;
+ const seen = new Set();
+ const blocked: BlockedSender[] = [];
+ for (const entry of raw) {
+ if (
+ !isRecord(entry) ||
+ typeof entry.address !== 'string' ||
+ typeof entry.blockedAt !== 'string'
+ ) {
+ return null;
+ }
+ const target = parseBlockTarget(entry.address);
+ const blockedAt = toUtcIso(entry.blockedAt);
+ if (
+ target === null ||
+ target.value !== entry.address ||
+ blockedAt === null ||
+ seen.has(target.value)
+ ) {
+ return null;
+ }
+ seen.add(target.value);
+ blocked.push({ address: target.value, blockedAt });
+ }
+ return blocked;
+}
+
const BROKEN: FilterSourceReading = { status: 'broken' };
/**
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
- * 판(`v`)이 이 서버의 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
- * 같은 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등) — 어긋나면 broken.
- * 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
+ * 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
+ * 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken.
+ * 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
*/
export function readFilterSource(script: string): FilterSourceReading {
const line = findSourceLine(script);
@@ -269,12 +331,13 @@ export function readFilterSource(script: string): FilterSourceReading {
if (
typeof format === 'number' &&
Number.isSafeInteger(format) &&
- format > FILTER_SOURCE_FORMAT
+ format > LATEST_FILTER_SOURCE_FORMAT
) {
return { status: 'newer', format };
}
if (
- format !== FILTER_SOURCE_FORMAT ||
+ (format !== FILTER_SOURCE_FORMAT &&
+ format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) ||
!Array.isArray(parsed.rules) ||
parsed.rules.length > MAX_MAIL_FILTER_RULES
) {
@@ -289,6 +352,14 @@ export function readFilterSource(script: string): FilterSourceReading {
}
if (findFilterRulesProblem(rules)) return BROKEN;
+ const blocked =
+ format === FILTER_SOURCE_FORMAT_WITH_BLOCKS
+ ? toBlocked(parsed.blocked)
+ : [];
+ if (blocked === null) return BROKEN;
+
const vacation = toVacation(parsed.vacation);
- return vacation ? { status: 'ok', source: { rules, vacation } } : BROKEN;
+ return vacation
+ ? { status: 'ok', source: { rules, vacation, blocked } }
+ : BROKEN;
}
diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts
index 8dfa1d3..68bdac7 100644
--- a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts
+++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts
@@ -197,7 +197,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
});
const HANBIT = ' "한빛" ';
- /** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */
+ /**
+ * 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) + 옮기는 규칙이면 같은 요청의 계정 기다리는 제출(9단계 — held, 없으면 빈 목록).
+ * 옮기지 않는 규칙의 요청에는 제출 조회가 없다 — 남는 응답은 쓰이지 않는다
+ */
const page = (
ids: string[],
list: Record[],
@@ -205,10 +208,22 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
state = 's1',
total = ids.length,
position = 0,
- }: { state?: string; total?: number; position?: number } = {},
+ held = [],
+ }: {
+ state?: string;
+ total?: number;
+ position?: number;
+ held?: Record[];
+ } = {},
): JmapInvocation[] => [
['Email/query', { ids, position, total }, 'q'],
['Email/get', { state, list, notFound: [] }, 'g'],
+ [
+ 'EmailSubmission/query',
+ { ids: held.map((submission) => submission.id), total: held.length },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list: held }, 'hg'],
];
const updated = (
ids: string[],
@@ -285,12 +300,18 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
m3: { 'mailboxIds/a': null, 'mailboxIds/j': true },
},
});
- // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간
+ // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간.
+ // 옮기는 규칙이라 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 (붙잡힌 메일은 옮기지 않는다 — 9단계)
expect(methodsOf(recorded)).toEqual([
'Email/query',
'Email/get',
+ 'EmailSubmission/query',
+ 'EmailSubmission/get',
'Email/set',
]);
+ expect(argsOf(recorded[0], 2)).toMatchObject({
+ filter: { after: expect.stringMatching(/Z$/) as unknown },
+ });
for (const record of recorded) {
for (const [, args] of record.calls) {
expect(args).not.toHaveProperty('destroy');
@@ -925,6 +946,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
[
['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
['Email/get', { list: [mail('m1', HANBIT)] }, 'g'],
+ ['EmailSubmission/query', { ids: [], total: 0 }, 'hq'],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
],
]);
expect(
@@ -932,6 +955,154 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
).toMatchObject({ code: 'MAIL_001', reason: 'inbox-state-unknown' });
});
+ it('붙잡힌 메일(9단계 — 받은편지함으로 옮겨 둔 것)은 옮기지 않고 표시만 한다 — 받은편지함에 남은 메일로 이어 간다', async () => {
+ const later = new Date(Date.now() + 3_600_000)
+ .toISOString()
+ .replace(/\.\d+Z$/, 'Z');
+ const { runner, recorded } = fakeRunner([
+ page(['m1', 'm2'], [mail('m1', HANBIT), mail('m2', HANBIT)], {
+ held: [
+ {
+ id: 'sb1',
+ emailId: 'm2',
+ undoStatus: 'pending',
+ sendAt: later,
+ },
+ ],
+ }),
+ updated(['m1', 'm2']),
+ ]);
+
+ await expect(
+ runner.run(ARA, request(), context()),
+ ).resolves.toMatchObject({
+ scanned: 2,
+ matched: 2,
+ remaining: 0,
+ cursor: null,
+ });
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: {
+ m1: {
+ 'keywords/$seen': true,
+ 'mailboxIds/a': null,
+ 'mailboxIds/j': true,
+ },
+ m2: { 'keywords/$seen': true },
+ },
+ });
+ });
+
+ it('계정의 기다리는 제출을 다 받지 못했으면(total 이 더 큼) 그 차례의 메일 id 로 다시 묻는다 — 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건으로 (9단계 리뷰 2)', async () => {
+ const later = new Date(Date.now() + 3_600_000)
+ .toISOString()
+ .replace(/\.\d+Z$/, 'Z');
+ const { runner, recorded } = fakeRunner([
+ [
+ ['Email/query', { ids: ['m1', 'm2'], position: 0, total: 2 }, 'q'],
+ [
+ 'Email/get',
+ {
+ state: 's1',
+ list: [mail('m1', HANBIT), mail('m2', HANBIT)],
+ notFound: [],
+ },
+ 'g',
+ ],
+ // 상한(500)까지 왔고 더 있다 — m2 의 제출은 그 뒤
+ [
+ 'EmailSubmission/query',
+ {
+ ids: Array.from({ length: 500 }, (_, i) => `s${i}`),
+ position: 0,
+ total: 1200,
+ },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ],
+ [
+ ['EmailSubmission/query', { ids: ['sb1'], total: 1 }, 'hq'],
+ [
+ 'EmailSubmission/get',
+ {
+ list: [
+ {
+ id: 'sb1',
+ emailId: 'm2',
+ undoStatus: 'pending',
+ sendAt: later,
+ },
+ ],
+ },
+ 'hg',
+ ],
+ ],
+ updated(['m1', 'm2']),
+ ]);
+
+ await expect(
+ runner.run(ARA, request(), context()),
+ ).resolves.toMatchObject({ scanned: 2, matched: 2 });
+ expect(argsOf(recorded[1])).toMatchObject({
+ filter: {
+ emailIds: ['m1', 'm2'],
+ after: (argsOf(recorded[0], 2).filter as { after: string }).after,
+ },
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: {
+ m1: {
+ 'keywords/$seen': true,
+ 'mailboxIds/a': null,
+ 'mailboxIds/j': true,
+ },
+ m2: { 'keywords/$seen': true },
+ },
+ });
+ });
+
+ it('옮기는 규칙인데 기다리는 제출을 읽지 못하면 아무것도 바꾸지 않고 MAIL_001 (fail closed) — 표시만 하는 규칙은 묻지 않는다', async () => {
+ const broken = fakeRunner([
+ [
+ ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
+ ['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'],
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ]);
+ expect(
+ await failureOf(broken.runner.run(ARA, request(), context())),
+ ).toMatchObject({ code: 'MAIL_001', reason: 'held-send-check-failed' });
+ expect(broken.recorded).toHaveLength(1);
+
+ const markOnly = fakeRunner([
+ [
+ ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
+ ['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'],
+ ],
+ updated(['m1']),
+ ]);
+ await expect(
+ markOnly.runner.run(
+ ARA,
+ request({
+ actions: { moveToMailboxId: null, markAsRead: true, flag: false },
+ }),
+ context(),
+ ),
+ ).resolves.toMatchObject({ scanned: 1, matched: 1 });
+ expect(methodsOf(markOnly.recorded)).toEqual([
+ 'Email/query',
+ 'Email/get',
+ 'Email/set',
+ ]);
+ });
+
it('엔진이 차례의 할 일을 모두 거절하면 — 확인한 것이 없으면 그 거절의 코드, 그사이 지워진 메일(notFound)은 거절로 보지 않는다', async () => {
const refused = fakeRunner([
page(['m1'], [mail('m1', HANBIT)]),
@@ -1044,6 +1215,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
readonly queries: SimQuery[] = [];
/** 엔진 요청 하나를 처리하기 전에 — 요청 도중에 일어나는 일(새 메일 · 다른 탭의 옮기기) */
readonly beforeCall: ((methods: readonly string[]) => void)[] = [];
+ /** 붙잡힌 메일(9단계) — 보낼 시각이 지금 뒤인 pending 제출이 있는 메일 */
+ readonly held = new Set();
private state = 1;
private nextDoc = 0;
@@ -1138,6 +1311,30 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
},
callId,
]);
+ } else if (method === 'EmailSubmission/query') {
+ const ids = [...this.held].map((emailId) => `sub-${emailId}`);
+ queried.set(callId, ids);
+ responses.push([
+ 'EmailSubmission/query',
+ { ids, total: ids.length },
+ callId,
+ ]);
+ } else if (method === 'EmailSubmission/get') {
+ const later = new Date(Date.now() + 3_600_000)
+ .toISOString()
+ .replace(/\.\d+Z$/, 'Z');
+ responses.push([
+ 'EmailSubmission/get',
+ {
+ list: [...this.held].map((emailId) => ({
+ id: `sub-${emailId}`,
+ emailId,
+ undoStatus: 'pending',
+ sendAt: later,
+ })),
+ },
+ callId,
+ ]);
} else if (method === 'Email/set') {
expect(args).not.toHaveProperty('destroy');
if (
@@ -1486,5 +1683,28 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
expect(left).toHaveLength(6);
expect(left.every((email) => email.receivedAt >= T0 + 20_000)).toBe(true);
});
+
+ it('붙잡힌 메일(9단계)은 모두 맞는 규칙에도 옮기지 않는다 — 표시만 하고 받은편지함에 남긴 채 끝까지 간다', async () => {
+ const engine = new FakeEngine();
+ fill(engine, 700);
+ const kept = [...engine.emails.keys()].filter(
+ (_, index) => index % 300 === 5,
+ );
+ for (const id of kept) engine.held.add(id);
+ const always = request({
+ match: 'always',
+ conditions: [],
+ actions: { moveToMailboxId: TARGET, markAsRead: true, flag: false },
+ });
+
+ const result = await runAll(engine, always);
+ expect(result).toMatchObject({ done: true, scanned: 700, matched: 700 });
+ const left = engine.sorted(INBOX).map(({ id }) => id);
+ expect(left.sort()).toEqual([...kept].sort());
+ for (const id of kept) {
+ expect(engine.emails.get(id)?.keywords.has('$seen')).toBe(true);
+ }
+ expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length);
+ });
});
});
diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts
index f636005..1e2243e 100644
--- a/backend/src/modules/mail/jmap/jmap-filter-run.ts
+++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts
@@ -45,6 +45,15 @@ import type {
JmapQueryResult,
JmapSetResult,
} from './jmap.types';
+import {
+ heldAmong,
+ heldEmailsFilter,
+ heldSubmissionFilter,
+ readHeldEmails,
+ submissionCalls,
+ type HeldEmails,
+ type HeldLookup,
+} from './pending-send';
/**
* 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지,
@@ -79,6 +88,12 @@ const NOT_FOUND = 'notFound';
const MS_PER_SECOND = 1_000;
+/** 붙잡힌 메일(9단계)을 묻는 호출 id 앞글자 — `hq` · `hg` (옮기는 규칙일 때만 쪽 읽기에 덧붙인다) */
+const HELD_GUARD_CALLS = 'h';
+
+/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */
+const NO_HELD: HeldEmails = { ids: new Set(), complete: true };
+
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
@@ -110,11 +125,12 @@ interface RunPlan {
readonly flag: boolean;
}
-/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */
+/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 · 붙잡힌 메일을 함께 묻는지(옮기는 규칙) */
interface PageRead {
readonly properties: readonly string[];
readonly needsBody: boolean;
readonly limit: number;
+ readonly withHeld: boolean;
}
/** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */
@@ -136,6 +152,8 @@ interface RoundPage {
readonly state: string | null;
/** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */
readonly from: RunState;
+ /** 같은 요청에서 물은 붙잡힌 메일(옮기는 규칙일 때만 — 아니면 없음) */
+ readonly held: HeldEmails;
}
/** 한 차례를 마친 결과 */
@@ -185,10 +203,14 @@ function runPlan(
};
}
-/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */
+/**
+ * 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null.
+ * 붙잡힌 메일(9단계 — held)은 옮기지 않는다 — 표시만 한다 (취소로만 보낸편지함을 떠난다)
+ */
function emailPatch(
plan: RunPlan,
email: JmapEmail | undefined,
+ held: boolean,
): Record | null {
const patch: Record = {};
if (plan.markAsRead && email?.keywords?.$seen !== true) {
@@ -197,7 +219,7 @@ function emailPatch(
if (plan.flag && email?.keywords?.$flagged !== true) {
patch['keywords/$flagged'] = true;
}
- if (plan.moveTo !== null) {
+ if (plan.moveTo !== null && !held) {
patch[`mailboxIds/${plan.inboxId}`] = null;
patch[`mailboxIds/${plan.moveTo}`] = true;
}
@@ -312,6 +334,8 @@ export class JmapFilterRunner {
properties: filterRunProperties(fields),
needsBody,
limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE,
+ // 옮기는 규칙이면 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 — 붙잡힌 메일은 옮기지 않는다(9단계)
+ withHeld: plan.moveTo !== null,
};
let scanned = 0;
@@ -408,35 +432,44 @@ export class JmapFilterRunner {
run: BulkRun,
): Promise {
if (from.anchor !== null) {
+ const now = Date.now();
const responses = await this.queryPage(
user,
inboxId,
{ before: from.before, anchor: from.anchor },
read,
run.callOptions(),
+ now,
);
const query = findResult(responses, 'q', 'Email/query');
if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) {
- return this.toPage(responses, from, read);
+ return this.toPage(responses, from, read, now);
}
}
const restart: RunState = { ...from, anchor: null };
const before =
restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before;
+ const now = Date.now();
const responses = await this.queryPage(
user,
inboxId,
{ before, fromTop: true },
read,
run.callOptions(),
+ now,
);
- return this.toPage(responses, restart, read);
+ return this.toPage(responses, restart, read, now);
}
+ /**
+ * 쪽 읽기 결과 — 옮기는 규칙이면 같은 요청의 붙잡힌 메일도 읽는다(읽은 메일이 있을 때만, 읽지 못하면 MAIL_001 — fail closed)
+ * @param now 쪽을 물은 때 (붙잡힘 판정의 지금)
+ */
private toPage(
responses: JmapInvocation[],
from: RunState,
read: PageRead,
+ now: number,
): RoundPage {
const query = expectResult(responses, 'q', 'Email/query');
const found = expectResult>(
@@ -447,6 +480,10 @@ export class JmapFilterRunner {
const ids = query.ids ?? [];
const start = query.position ?? 0;
const known = typeof query.total === 'number' ? query.total : null;
+ const held =
+ read.withHeld && ids.length > 0
+ ? readHeldEmails(responses, HELD_GUARD_CALLS, now)
+ : NO_HELD;
return {
start,
ids,
@@ -457,6 +494,7 @@ export class JmapFilterRunner {
emails: new Map((found.list ?? []).map((email) => [email.id, email])),
state: isState(found.state) ? found.state : null,
from,
+ held,
};
}
@@ -470,6 +508,7 @@ export class JmapFilterRunner {
start: PageStart,
read: PageRead,
options: JmapCallOptions,
+ now: number,
): Promise {
const filter: JmapEmailFilterCondition =
start.before === null
@@ -505,6 +544,14 @@ export class JmapFilterRunner {
},
'g',
],
+ // 옮기는 규칙 — 계정의 기다리는 제출(보낼 시각이 지금 뒤 — 엔진의 색인)을 같은 요청에서 묻는다 (pending-send.ts)
+ ...(read.withHeld
+ ? submissionCalls(
+ accountId,
+ heldSubmissionFilter(now),
+ HELD_GUARD_CALLS,
+ )
+ : []),
],
byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options,
);
@@ -565,13 +612,28 @@ export class JmapFilterRunner {
}
}
+ // 옮기는 규칙이면 맞은 메일 가운데 붙잡힌 메일(9단계)은 옮기지 않는다 — 표시만 한다
+ const matchedIds = page.ids.filter((id) => verdicts.get(id) === true);
+ const held =
+ plan.moveTo === null || matchedIds.length === 0
+ ? new Set()
+ : await heldAmong(
+ page.held,
+ matchedIds,
+ this.heldLookup(user, run.callOptions()),
+ );
const patches = new Map>();
+ /** 옮기기가 든 패치 — 이 메일은 바뀌면 받은편지함을 떠난다 */
+ const moving = new Set();
let unchanged = 0;
- for (const id of page.ids) {
- if (verdicts.get(id) !== true) continue;
- const patch = emailPatch(plan, page.emails.get(id));
- if (patch === null) unchanged += 1;
- else patches.set(id, patch);
+ for (const id of matchedIds) {
+ const patch = emailPatch(plan, page.emails.get(id), held.has(id));
+ if (patch === null) {
+ unchanged += 1;
+ continue;
+ }
+ patches.set(id, patch);
+ if (plan.moveTo !== null && !held.has(id)) moving.add(id);
}
const applied: Applied | null =
@@ -586,7 +648,7 @@ export class JmapFilterRunner {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
- const moved = plan.moveTo !== null && applied.updated.has(id);
+ const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
@@ -602,6 +664,27 @@ export class JmapFilterRunner {
};
}
+ /**
+ * 다 받지 못한 계정 전체 조회를 메일 id 로 보충하는 묻기 (pending-send.ts 의 heldAmong — 읽지 못하면 MAIL_001).
+ * 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id 를 더한다(heldEmailsFilter — 지난 제출이 상한을 채우지 않게)
+ */
+ private heldLookup(user: string, options: JmapCallOptions): HeldLookup {
+ return async (emailIds) => {
+ const now = Date.now();
+ const responses = await this.jmap.call(
+ user,
+ (accountId) =>
+ submissionCalls(
+ accountId,
+ heldEmailsFilter(emailIds, now),
+ HELD_GUARD_CALLS,
+ ),
+ options,
+ );
+ return readHeldEmails(responses, HELD_GUARD_CALLS, now);
+ };
+ }
+
/**
* 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState):
* 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다.
diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts
index 0573765..a0f48bf 100644
--- a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts
+++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts
@@ -1,7 +1,11 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
-import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
+import type {
+ BlockedSender,
+ MailFilterRule,
+ MailVacation,
+} from '@/modules/mail/mail.types';
import {
NEVER_CONFIGURED_VACATION,
filterRulesVersion,
@@ -22,6 +26,8 @@ import type {
import { buildFilterScript } from './sieve-script';
const ARA = 'ara.kim@arasystem.test';
+/** 대역 엔진의 스팸메일함 */
+const JUNK = { id: 'jk1', name: 'Junk Mail' };
const SIEVE = 'urn:ietf:params:jmap:sieve';
const VACATION = 'urn:ietf:params:jmap:vacationresponse';
@@ -88,6 +94,11 @@ class FakeSieveEngine {
missingDownloads = 0;
/** 저장 뒤 같은 요청의 SieveScript/get 을 메서드 오류로 */
failRereadAfterSet = false;
+ /** 메일함 목록 (Mailbox/get) — 차단이 있으면 스팸메일함을 찾는다 */
+ mailboxes: { id: string; name: string | null; role: string | null }[] = [
+ { id: 'in1', name: 'Inbox', role: 'inbox' },
+ { id: JUNK.id, name: JUNK.name, role: 'junk' },
+ ];
private sequence = 0;
readonly client = {
@@ -147,11 +158,24 @@ class FakeSieveEngine {
addOurs(source: FilterScriptSource, isActive = true): FakeScript {
return this.addScript(
FILTER_SCRIPT_NAME,
- buildFilterScript(source).text,
+ buildFilterScript(source, { junkMailbox: JUNK }).text,
isActive,
);
}
+ /** 우리 스크립트의 지금 글 */
+ ourText(): string | undefined {
+ const ours = this.ours();
+ return ours ? this.blobs.get(ours.blobId) : undefined;
+ }
+
+ /** 엔진이 받은 메서드 이름들 (차례대로) */
+ methods(): string[] {
+ return this.recorded.flatMap(({ calls }) =>
+ calls.map(([method]) => method),
+ );
+ }
+
/** 우리 스크립트의 지금 원본 */
ourSource(): FilterScriptSource | null {
const ours = this.scripts.find(
@@ -205,6 +229,12 @@ class FakeSieveEngine {
];
case 'SieveScript/set':
return this.set(args, callId);
+ case 'Mailbox/get':
+ return [
+ method,
+ { list: this.mailboxes.map((mailbox) => ({ ...mailbox })) },
+ callId,
+ ];
default:
return ['error', { type: 'unknownMethod' }, callId];
}
@@ -378,7 +408,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('우리 스크립트가 켜져 있으면 원본의 규칙 · 부재중 — 글은 엔진 한도(102,400바이트)까지만 내려받는다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
- const ours = engine.addOurs({ rules, vacation: vacationOn });
+ const ours = engine.addOurs({ rules, vacation: vacationOn, blocked: [] });
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules,
@@ -399,7 +429,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('엔진에서 다른 스크립트가 켜져 있으면 isActive false — 규칙 · 부재중은 원본 그대로', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
- engine.addOurs({ rules, vacation: vacationOn });
+ engine.addOurs({ rules, vacation: vacationOn, blocked: [] });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
@@ -442,7 +472,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('내려받는 사이 글이 없어졌으면(404 — 그사이 바뀜) 다시 묻는다 — 세 번 모두면 MAIL_001', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
+ engine.addOurs({
+ rules: [rule('r1')],
+ vacation: vacationOn,
+ blocked: [],
+ });
engine.missingDownloads = 2;
await expect(engine.store.getFilters(ARA)).resolves.toMatchObject({
isActive: true,
@@ -486,13 +520,19 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
}),
() => engine.store.setVacation(ARA, vacationOn),
+ () =>
+ engine.store.addBlock(ARA, {
+ address: 'kim@spam.example',
+ blockedAt: '2026-10-29T00:00:00.000Z',
+ }),
+ () => engine.store.removeBlock(ARA, 'kim@spam.example'),
]) {
expect(await failureOf(save())).toEqual({
code: 'MAIL_001',
context: {
reason: 'filter-source-newer',
format: 99,
- supportedFormat: 1,
+ supportedFormat: 2,
},
});
}
@@ -510,7 +550,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
);
await engine.store.setVacation(ARA, vacationOn);
- expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules: [],
+ vacation: vacationOn,
+ blocked: [],
+ });
});
});
@@ -540,7 +584,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
isActive: true,
});
- const expectedSource = { rules, vacation: vacationOn };
+ const expectedSource = { rules, vacation: vacationOn, blocked: [] };
expect(engine.client.upload).toHaveBeenCalledTimes(1);
const [, blob, type] = engine.client.upload.mock.calls[0];
expect(type).toBe('application/sieve');
@@ -575,7 +619,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
const engine = new FakeSieveEngine();
const before = [rule('r1')];
const ours = engine.addOurs(
- { rules: before, vacation: vacationOn },
+ { rules: before, vacation: vacationOn, blocked: [] },
false,
);
engine.addScript('other', 'keep;\r\n', true);
@@ -599,13 +643,21 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
onSuccessActivateScript: ours.id,
},
]);
- expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
expect(engine.ours()?.isActive).toBe(true);
});
it('그사이 다른 곳에서 규칙이 바뀌었으면(지문이 다름) MAIL_012 — 올리지도 쓰지도 않는다', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
+ engine.addOurs({
+ rules: [rule('r1')],
+ vacation: vacationOn,
+ blocked: [],
+ });
expect(
await failureOf(
@@ -625,7 +677,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('부재중 응답만 바뀐 것은 걸리지 않는다 — 다른 곳에서 바꾼 부재중을 그대로 둔다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
- engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
+ engine.addOurs({
+ rules: before,
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
+ });
const version = (await engine.store.getFilters(ARA)).version;
await engine.store.setVacation(ARA, vacationOn);
@@ -633,13 +689,17 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
await expect(
engine.store.setFilters(ARA, { rules, version }),
).resolves.toMatchObject({ rules });
- expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
});
it('저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 옛 지문이어도 그대로 저장한다 — 같은 요청을 다시 보낸 경우', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
- engine.addOurs({ rules, vacation: vacationOn }, false);
+ engine.addOurs({ rules, vacation: vacationOn, blocked: [] }, false);
engine.addScript('other', 'keep;\r\n', true);
await expect(
@@ -657,14 +717,22 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('쓰는 사이 상태가 바뀌면(stateMismatch) 다시 읽어 확인하고 쓴다 — 그사이 바뀐 부재중을 지킨다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
- engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
+ engine.addOurs({
+ rules: before,
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
+ });
// 다른 탭이 부재중을 켰다
engine.beforeSet.push(() => {
const ours = engine.ours()!;
const blobId = 'other-tab';
engine.blobs.set(
blobId,
- buildFilterScript({ rules: before, vacation: vacationOn }).text,
+ buildFilterScript({
+ rules: before,
+ vacation: vacationOn,
+ blocked: [],
+ }).text,
);
ours.blobId = blobId;
engine.state += 1;
@@ -679,12 +747,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
).resolves.toMatchObject({ rules, isActive: true });
expect(engine.setCalls()).toHaveLength(2);
expect(engine.client.upload).toHaveBeenCalledTimes(2);
- expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
});
it('다시 읽은 글이 같으면 올린 것을 다시 쓴다 — 엔진에 같은 글을 두 번 올리지 않는다', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [], vacation: vacationOn });
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] });
engine.beforeSet.push(() => {
engine.state += 1;
});
@@ -699,13 +771,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('다시 읽었더니 다른 곳에서 규칙을 저장했으면 MAIL_012', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [], vacation: vacationOn });
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] });
engine.beforeSet.push(() => {
const ours = engine.ours()!;
engine.blobs.set(
'other-tab',
- buildFilterScript({ rules: [rule('theirs')], vacation: vacationOn })
- .text,
+ buildFilterScript({
+ rules: [rule('theirs')],
+ vacation: vacationOn,
+ blocked: [],
+ }).text,
);
ours.blobId = 'other-tab';
engine.state += 1;
@@ -728,7 +803,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
const engine = new FakeSieveEngine();
// 상태 확인을 지나 만들기에서 겹쳤다 — 다른 요청이 같은 이름으로 먼저 만들었다
engine.setReplies.push(() => {
- engine.addOurs({ rules: [], vacation: vacationOn }, true);
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] }, true);
return [
'SieveScript/set',
{ notCreated: { script: { type: 'alreadyExists' } } },
@@ -746,7 +821,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.setCalls()[1]).toMatchObject({
update: { [engine.ours()!.id]: expect.any(Object) as object },
});
- expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
});
it('세 번 모두 어긋나면 MAIL_001 — 쓰지 않았다', async () => {
@@ -943,20 +1022,32 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('규칙을 그대로 두고 부재중만 바꾼다 — 지문을 보지 않고, 규칙의 지문은 그대로다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
- engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION });
+ engine.addOurs({
+ rules,
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
+ });
const before = await engine.store.getFilters(ARA);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
- expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
await expect(engine.store.getFilters(ARA)).resolves.toEqual(before);
});
it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
+ engine.addOurs({
+ rules: [rule('r1')],
+ vacation: vacationOn,
+ blocked: [],
+ });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({
isActive: false,
@@ -974,7 +1065,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
- expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules: [],
+ vacation: vacationOn,
+ blocked: [],
+ });
expect(engine.ours()?.isActive).toBe(true);
});
@@ -983,12 +1078,20 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addScript(FILTER_SCRIPT_NAME, 'keep;\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
- expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
+ expect(engine.ourSource()).toEqual({
+ rules: [],
+ vacation: vacationOn,
+ blocked: [],
+ });
});
it('쓰는 사이 다른 곳에서 필터를 저장했으면 다시 읽어 그 규칙을 지킨다', async () => {
const engine = new FakeSieveEngine();
- engine.addOurs({ rules: [], vacation: NEVER_CONFIGURED_VACATION });
+ engine.addOurs({
+ rules: [],
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
+ });
const theirs = [rule('theirs')];
engine.beforeSet.push(() => {
const ours = engine.ours()!;
@@ -997,6 +1100,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
buildFilterScript({
rules: theirs,
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
}).text,
);
ours.blobId = 'other-tab';
@@ -1007,6 +1111,292 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.ourSource()).toEqual({
rules: theirs,
vacation: vacationOn,
+ blocked: [],
+ });
+ });
+ });
+
+ describe('보낸 사람 차단 (getBlocks · addBlock · removeBlock — 9단계)', () => {
+ const at = '2026-10-29T01:00:00.000Z';
+ const kim: BlockedSender = { address: 'kim@spam.example', blockedAt: at };
+ const domain: BlockedSender = {
+ address: '@junk.example',
+ blockedAt: '2026-10-28T00:00:00.000Z',
+ };
+ const mailboxReads = (engine: FakeSieveEngine) =>
+ engine.methods().filter((method) => method === 'Mailbox/get').length;
+
+ it('우리 스크립트가 없으면 빈 목록 · 동작함, 있으면 저장한 차례(최근 것부터) · 필터와 같은 isActive', async () => {
+ const empty = new FakeSieveEngine();
+ await expect(empty.store.getBlocks(ARA)).resolves.toEqual({
+ senders: [],
+ isActive: true,
+ });
+
+ const engine = new FakeSieveEngine();
+ engine.addOurs(
+ { rules: [rule('r1')], vacation: vacationOn, blocked: [kim, domain] },
+ false,
+ );
+ await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
+ senders: [kim, domain],
+ isActive: false,
+ });
+ // 읽기는 메일함 목록을 묻지 않는다
+ expect(mailboxReads(engine)).toBe(0);
+ });
+
+ it('처음 차단 — 맨 앞에 더하고 규칙 · 부재중은 그대로, 스팸메일함(id · 이름)을 엔진에서 찾아 넣고 판 2 로 쓴다', async () => {
+ const engine = new FakeSieveEngine();
+ const rules = [rule('r1')];
+ engine.addOurs({ rules, vacation: vacationOn, blocked: [domain] });
+
+ await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({
+ sender: kim,
+ created: true,
+ });
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [kim, domain],
+ });
+ const text = engine.ourText() ?? '';
+ expect(text).toContain('# arasystem-source: {"v":2,');
+ expect(text).toContain('fileinto :mailboxid "jk1" "Junk Mail";');
+ expect(text).toContain('address :all :is "from" ["kim@spam.example"]');
+ expect(mailboxReads(engine)).toBe(1);
+ expect(engine.setCalls()).toHaveLength(1);
+ expect(engine.setCalls()[0]).toMatchObject({
+ ifInState: expect.any(String) as unknown,
+ onSuccessActivateScript: expect.any(String) as unknown,
+ });
+ });
+
+ it('이미 있는 주소면 쓰지 않고 그 항목을 돌려준다 (created: false — 처음 차단한 시각)', async () => {
+ const engine = new FakeSieveEngine();
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
+
+ await expect(
+ engine.store.addBlock(ARA, {
+ ...kim,
+ blockedAt: '2026-10-30T00:00:00.000Z',
+ }),
+ ).resolves.toEqual({ sender: kim, created: false });
+ expect(engine.client.upload).not.toHaveBeenCalled();
+ expect(engine.setCalls()).toEqual([]);
+ });
+
+ it('이미 있는 주소인데 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을 다시 써서 켠다 — 성공한 차단은 늘 "동작 중" (9단계 리뷰 뒤)', async () => {
+ const engine = new FakeSieveEngine();
+ const rules = [rule('r1')];
+ engine.addOurs({ rules, vacation: vacationOn, blocked: [kim] }, false);
+ // 다른 프로그램(ManageSieve 등)이 켠 스크립트가 있다
+ engine.addScript('other', 'keep;', true);
+
+ await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({
+ sender: kim,
+ created: false,
+ });
+ // 원본은 그대로(차단한 시각도 처음 것) — 다시 써서 켰다
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [kim],
+ });
+ expect(engine.setCalls()).toHaveLength(1);
+ expect(engine.setCalls()[0]).toMatchObject({
+ onSuccessActivateScript: expect.any(String) as unknown,
+ });
+ await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
+ senders: [kim],
+ isActive: true,
+ });
+ });
+
+ it('500개면 VAL_001 — 쓰지 않는다', async () => {
+ const engine = new FakeSieveEngine();
+ const many = Array.from({ length: 500 }, (_, i) => ({
+ address: `s${i}@spam.example`,
+ blockedAt: at,
+ }));
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: many });
+
+ expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({
+ code: 'VAL_001',
+ context: { reason: 'too-many-mail-blocks', stored: 500 },
+ });
+ expect(engine.setCalls()).toEqual([]);
+ });
+
+ it('만든 글이 한도(90,000바이트)를 넘으면 MAIL_013 — 올리지 않는다, context 에는 수만 (주소는 남기지 않는다)', async () => {
+ const engine = new FakeSieveEngine();
+ // 주소 모양을 지키는 긴 주소 — @ 앞 64자 · 이름 마디 63자 · 모두 254자까지 (읽을 때 다시 본다)
+ const filler = (i: number) => ({
+ address: `${`s${i}`.padEnd(40, 'x')}@${'d'.repeat(50)}.example`,
+ blockedAt: at,
+ });
+ const longest = `${'l'.repeat(60)}@${'d'.repeat(60)}.${'e'.repeat(60)}.${'f'.repeat(60)}.example`;
+ const sourceWith = (count: number): FilterScriptSource => ({
+ rules: [],
+ vacation: vacationOn,
+ blocked: Array.from({ length: count }, (_, i) => filler(i)),
+ });
+ const bytesOf = (source: FilterScriptSource) =>
+ buildFilterScript(source, { junkMailbox: JUNK }).bytes;
+ // 한도 바로 아래까지 채운다 — 하나 더 채우면 넘는다. 긴 주소는 채우는 주소보다 길어 더하면 넘는다
+ let count = 1;
+ while (bytesOf(sourceWith(count + 1)) <= 90_000) count += 1;
+ const stored = sourceWith(count);
+ expect(bytesOf(stored)).toBeLessThanOrEqual(90_000);
+ expect(
+ bytesOf({
+ ...stored,
+ blocked: [{ address: longest, blockedAt: at }, ...stored.blocked],
+ }),
+ ).toBeGreaterThan(90_000);
+ engine.addOurs(stored);
+ await expect(engine.store.getBlocks(ARA)).resolves.toMatchObject({
+ isActive: true,
+ });
+
+ const failure = await failureOf(
+ engine.store.addBlock(ARA, { address: longest, blockedAt: at }),
+ );
+ expect(failure.code).toBe('MAIL_013');
+ expect(failure.context).toMatchObject({
+ reason: 'filter-script-too-large',
+ blocked: count + 1,
+ });
+ expect(JSON.stringify(failure.context)).not.toContain('example');
+ expect(engine.client.upload).not.toHaveBeenCalled();
+ });
+
+ it('스팸메일함이 없으면 MAIL_001(junk-mailbox-missing) — 쓰지 않는다', async () => {
+ const engine = new FakeSieveEngine();
+ engine.mailboxes = [{ id: 'in1', name: 'Inbox', role: 'inbox' }];
+
+ expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({
+ code: 'MAIL_001',
+ context: { reason: 'junk-mailbox-missing' },
+ });
+ expect(engine.setCalls()).toEqual([]);
+ });
+
+ it.each<[string, string | null]>([
+ ['이름 없음', null],
+ ['빈 이름', ' '],
+ ['${ 가 든 이름(다른 프로그램이 바꾼 이름)', '스팸${x}'],
+ ])('스팸메일함의 대체 이름 — %s 이면 "Junk"', async (_label, name) => {
+ const engine = new FakeSieveEngine();
+ engine.mailboxes = [{ id: 'jk9', name, role: 'junk' }];
+
+ await engine.store.addBlock(ARA, kim);
+ expect(engine.ourText()).toContain('fileinto :mailboxid "jk9" "Junk";');
+ });
+
+ it('필터의 지문(version)을 묻지 않는다 — 그사이 다른 곳에서 바꾼 규칙을 다시 읽어 지키고 더한다', async () => {
+ const engine = new FakeSieveEngine();
+ engine.addOurs({
+ rules: [rule('r1')],
+ vacation: vacationOn,
+ blocked: [],
+ });
+ const theirs = [rule('theirs')];
+ engine.beforeSet.push(() => {
+ const ours = engine.ours()!;
+ engine.blobs.set(
+ 'other-tab',
+ buildFilterScript({
+ rules: theirs,
+ vacation: vacationOn,
+ blocked: [],
+ }).text,
+ );
+ ours.blobId = 'other-tab';
+ engine.state += 1;
+ });
+
+ await engine.store.addBlock(ARA, kim);
+ expect(engine.ourSource()).toEqual({
+ rules: theirs,
+ vacation: vacationOn,
+ blocked: [kim],
+ });
+ expect(engine.setCalls()).toHaveLength(2);
+ });
+
+ it('차단을 푼다 — 나머지는 그대로, 마지막 하나를 풀면 판 1 로 돌아간다(그때는 메일함 목록을 묻지 않는다)', async () => {
+ const engine = new FakeSieveEngine();
+ const rules = [rule('r1')];
+ engine.addOurs({ rules, vacation: vacationOn, blocked: [kim, domain] });
+
+ await engine.store.removeBlock(ARA, domain.address);
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [kim],
+ });
+ const readsBefore = mailboxReads(engine);
+
+ await engine.store.removeBlock(ARA, kim.address);
+ expect(engine.ourSource()).toEqual({
+ rules,
+ vacation: vacationOn,
+ blocked: [],
+ });
+ expect(engine.ourText()).toContain('# arasystem-source: {"v":1,');
+ expect(engine.ourText()).not.toContain('address :');
+ expect(mailboxReads(engine)).toBe(readsBefore);
+ });
+
+ it('없는 주소를 풀면 RES_001 — 쓰지 않는다', async () => {
+ const engine = new FakeSieveEngine();
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
+
+ expect(
+ await failureOf(engine.store.removeBlock(ARA, 'lee@spam.example')),
+ ).toEqual({
+ code: 'RES_001',
+ context: { reason: 'mail-block-not-found' },
+ });
+ expect(engine.setCalls()).toEqual([]);
+ });
+
+ it('필터 · 부재중 저장은 차단 목록을 그대로 둔다 — 쓸 때 스팸메일함을 다시 찾는다', async () => {
+ const engine = new FakeSieveEngine();
+ engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
+
+ await engine.store.setFilters(ARA, {
+ rules: [rule('r1')],
+ version: filterRulesVersion([]),
+ });
+ await engine.store.setVacation(ARA, NEVER_CONFIGURED_VACATION);
+ expect(engine.ourSource()).toEqual({
+ rules: [rule('r1')],
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [kim],
+ });
+ expect(engine.ourText()).toContain('fileinto :mailboxid "jk1"');
+ expect(mailboxReads(engine)).toBe(2);
+ });
+
+ it('깨진 원본에 차단을 더하면 덮어쓴다 — 필터 저장과 같다', async () => {
+ const engine = new FakeSieveEngine();
+ engine.addScript(
+ FILTER_SCRIPT_NAME,
+ '# arasystem-source: {"v":2,"rules":[],"blocked":"깨짐"}\r\nkeep;\r\n',
+ true,
+ );
+ await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
+ senders: [],
+ isActive: false,
+ });
+
+ await engine.store.addBlock(ARA, kim);
+ expect(engine.ourSource()).toEqual({
+ rules: [],
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [kim],
});
});
});
diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts
index 22a8391..29a3c34 100644
--- a/backend/src/modules/mail/jmap/jmap-filter-script.ts
+++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts
@@ -1,7 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
+import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants';
import type {
+ BlockedSender,
+ MailBlockAdded,
+ MailBlockList,
MailFilterSet,
MailFilterUpdate,
MailVacation,
@@ -11,7 +15,7 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
EMPTY_FILTER_SOURCE,
- FILTER_SOURCE_FORMAT,
+ LATEST_FILTER_SOURCE_FORMAT,
canonicalRules,
filterRulesVersion,
readFilterSource,
@@ -29,6 +33,7 @@ import {
import type {
JmapError,
JmapGetResult,
+ JmapMailbox,
JmapSetResult,
JmapSieveScript,
JmapVacationResponse,
@@ -38,6 +43,7 @@ import {
SIEVE_STRING_MAX_BYTES,
buildFilterScript,
type BuiltFilterScript,
+ type JunkMailboxTarget,
} from './sieve-script';
/** JMAP for Sieve (RFC 9661) — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-28 확인) */
@@ -115,7 +121,7 @@ interface ScriptSnapshot extends ScriptView {
readonly newerFormat: number | null;
}
-/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 수만 (규칙의 글은 남기지 않는다) */
+/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */
function scriptSummary(
script: BuiltFilterScript,
source: FilterScriptSource,
@@ -125,6 +131,7 @@ function scriptSummary(
rules: source.rules.length,
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
vacation: source.vacation.isEnabled,
+ blocked: source.blocked.length,
};
}
@@ -141,8 +148,20 @@ function toVacationState(view: ScriptView): MailVacationState {
return { ...view.source.vacation, isActive: view.isActive };
}
+/** 차단 목록의 응답 — 최근에 차단한 것부터(저장한 차례) + 필터와 같은 isActive (계약 2절 "보낸 사람 차단"의 MailBlockList) */
+function toBlockList(view: ScriptView): MailBlockList {
+ return {
+ senders: view.source.blocked.map(({ address, blockedAt }) => ({
+ address,
+ blockedAt,
+ })),
+ isActive: view.isActive,
+ };
+}
+
/**
- * 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
+ * 메일 필터 · 부재중 자동 응답 · 보낸 사람 차단(9단계)의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
+ * 셋 가운데 하나를 바꿀 때도 나머지 둘은 다시 읽은 그대로 함께 쓴다.
* Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면
* 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다 — 그래서 한 스크립트에 함께 둔다. `vacation` 스크립트는 지우지도 고치지도 않는다(엔진이 forbidden).
*
@@ -184,7 +203,8 @@ export class JmapFilterScriptStore {
},
});
}
- return { rules: update.rules, vacation: current.vacation };
+ // 부재중 · 차단 목록은 다시 읽은 그대로 둔다
+ return { ...current, rules: update.rules };
});
return toFilterSet(saved);
}
@@ -197,18 +217,68 @@ export class JmapFilterScriptStore {
return toVacationState(await this.snapshot(user));
}
- /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
+ /** 부재중 자동 응답만 바꾼다 — 규칙 · 차단 목록은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise {
const saved = await this.write(user, (current) => ({
- rules: current.rules,
+ ...current,
vacation,
}));
return toVacationState(saved);
}
+ /** 차단 목록 (계약 2절 "보낸 사람 차단" — 9단계) — 원본을 읽을 수 없으면(깨짐 · 새 판) 빈 목록 · 동작하지 않음 */
+ async getBlocks(user: string): Promise {
+ return toBlockList(await this.snapshot(user));
+ }
+
+ /**
+ * 차단을 더한다 — 맨 앞(최근)에. 이미 있는 주소면 그 항목을 돌려준다(created: false) — 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을
+ * 다시 써서 켠다(setFilters 가 같은 규칙을 다시 저장하는 것과 같다 — 성공한 차단은 늘 "동작 중"이다), 켜져 있으면 쓰지 않는다.
+ * 규칙 · 부재중은 다시 읽은 그대로 둔다 — 필터의 지문(version)을 보지 않는다(주소 하나를 더하는 일이라 그사이 바뀐 필터를 덮지 않는다).
+ * 이미 MAX_MAIL_BLOCKS 개면 VAL_001, 만든 글이 한도를 넘으면 MAIL_013
+ */
+ async addBlock(user: string, sender: BlockedSender): Promise {
+ let added: MailBlockAdded = { sender, created: true };
+ await this.write(user, (current, isActive) => {
+ const existing = current.blocked.find(
+ ({ address }) => address === sender.address,
+ );
+ if (existing) {
+ added = { sender: existing, created: false };
+ return isActive ? null : current;
+ }
+ if (current.blocked.length >= MAX_MAIL_BLOCKS) {
+ throw new BusinessException('VAL_001', {
+ context: {
+ reason: 'too-many-mail-blocks',
+ stored: current.blocked.length,
+ },
+ });
+ }
+ added = { sender, created: true };
+ return { ...current, blocked: [sender, ...current.blocked] };
+ });
+ return added;
+ }
+
+ /** 차단을 푼다 — 없는 주소면 RES_001(아무것도 쓰지 않는다). 규칙 · 부재중은 다시 읽은 그대로 */
+ async removeBlock(user: string, address: string): Promise {
+ await this.write(user, (current) => {
+ if (!current.blocked.some((entry) => entry.address === address)) {
+ throw new BusinessException('RES_001', {
+ context: { reason: 'mail-block-not-found' },
+ });
+ }
+ return {
+ ...current,
+ blocked: current.blocked.filter((entry) => entry.address !== address),
+ };
+ });
+ }
+
/**
* 지금 엔진 규칙을 읽는다 — 스크립트 목록과 엔진의 부재중 응답을 한 요청으로 묻고, 우리 스크립트가 있으면 글을 내려받는다.
* 내려받는 사이 글이 없어졌으면(그사이 바뀜) 다시 묻는다 — SCRIPT_ATTEMPTS 번을 넘으면 MAIL_001
@@ -248,7 +318,7 @@ export class JmapFilterScriptStore {
state,
scriptId: null,
isActive: true,
- source: { rules: [], vacation: toVacation(list[0]) },
+ source: { rules: [], vacation: toVacation(list[0]), blocked: [] },
newerFormat: null,
};
}
@@ -297,16 +367,22 @@ export class JmapFilterScriptStore {
}
/**
- * 다음 원본을 정해(change) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012) 그대로 던진다.
+ * 다음 원본을 정해(change — 지금 원본과 엔진에서 켜져 있는지를 받는다) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012 · VAL_001 · RES_001) 그대로 던진다.
+ * change 가 null 이면 바꿀 것이 없다 — 쓰지 않고 지금 것을 돌려준다(이미 있는 차단 · 켜져 있음). 같은 원본을 돌려주면 그대로 다시 쓰고 켠다.
* 만든 글이 한도를 넘으면 MAIL_013 (올리지 않는다). 같은 글을 다시 쓸 때는 올린 것을 다시 쓴다.
- * 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다)
+ * 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중 · 차단을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다).
+ * 차단한 주소가 있으면 스팸메일함(id · 이름)을 엔진의 메일함 목록에서 찾아 넣는다 — 없으면 MAIL_001
*/
private async write(
user: string,
- change: (current: FilterScriptSource) => FilterScriptSource,
+ change: (
+ current: FilterScriptSource,
+ isActive: boolean,
+ ) => FilterScriptSource | null,
): Promise {
let uploaded: { readonly text: string; readonly blobId: string } | null =
null;
+ let junkMailbox: JunkMailboxTarget | null = null;
for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) {
const current = await this.snapshot(user);
if (current.newerFormat !== null) {
@@ -314,7 +390,7 @@ export class JmapFilterScriptStore {
context: {
reason: 'filter-source-newer',
format: current.newerFormat,
- supportedFormat: FILTER_SOURCE_FORMAT,
+ supportedFormat: LATEST_FILTER_SOURCE_FORMAT,
},
});
}
@@ -324,8 +400,14 @@ export class JmapFilterScriptStore {
context: { reason: 'filter-script-state-unknown' },
});
}
- const next = change(current.source);
- const script = buildFilterScript(next);
+ const next = change(current.source, current.isActive);
+ if (next === null) {
+ return { source: current.source, isActive: current.isActive };
+ }
+ if (next.blocked.length > 0 && junkMailbox === null) {
+ junkMailbox = await this.junkMailbox(user);
+ }
+ const script = buildFilterScript(next, { junkMailbox });
if (
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
@@ -347,7 +429,7 @@ export class JmapFilterScriptStore {
const saved = await this.save(user, current, blobId, script, next);
if (saved !== null) {
this.logger.debug(
- `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, ${attempt}번째`,
+ `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`,
);
return { source: next, isActive: saved.isActive };
}
@@ -360,6 +442,36 @@ export class JmapFilterScriptStore {
});
}
+ /** 차단한 메일이 들어갈 스팸메일함 — 엔진의 메일함 목록에서 역할이 junk 인 것. 없으면 MAIL_001 (차단을 쓸 곳이 없다) */
+ private async junkMailbox(user: string): Promise {
+ const responses = await this.jmap.call(user, (accountId) => [
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: ['id', 'name', 'role'] },
+ 'm',
+ ],
+ ]);
+ const { list = [] } = expectResult>(
+ responses,
+ 'm',
+ 'Mailbox/get',
+ );
+ const junk = list.find(
+ (mailbox) => mailbox.role?.trim().toLowerCase() === 'junk',
+ );
+ if (!junk) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'junk-mailbox-missing' },
+ });
+ }
+ // 이름은 id 의 메일함이 없을 때만 쓰는 대체다. 다른 프로그램(IMAP)이 바꾼 이름에 `${` 가 있으면 엔진이 변수로 펼친다 — 그때는 기본 이름
+ const name = junk.name?.trim() ?? '';
+ return {
+ id: junk.id,
+ name: name !== '' && !name.includes('${') ? name : 'Junk',
+ };
+ }
+
private async upload(user: string, text: string): Promise {
const { blobId } = await this.jmap.upload(
user,
diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts
index ea9c184..2fdaf23 100644
--- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts
+++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts
@@ -6,7 +6,10 @@ import { join } from 'path';
import { Readable } from 'stream';
import { BusinessException } from '@/common/errors/business.exception';
-import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
+import {
+ MAX_BODY_VALUE_BYTES,
+ MAX_ENVELOPE_RESUBMITS,
+} from '@/modules/mail/mail.constants';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type {
DraftContent,
@@ -134,9 +137,12 @@ interface CallRecord {
options: unknown;
}
-/** JmapClient 대역 — call() 은 넘겨받은 build 로 실제 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다 */
+/**
+ * JmapClient 대역 — call() 은 넘겨받은 build 로 실제 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다.
+ * 응답 자리에 Error 를 두면 그 호출은 기록한 뒤 그 오류로 끝난다 (jmap.client.ts 가 던지는 시간 초과 · HTTP 거절 등)
+ */
function fakeClient(
- responses: JmapInvocation[][],
+ responses: (JmapInvocation[] | Error)[],
overrides: { session?: JmapSession; settings?: MailSettings } = {},
) {
const recorded: CallRecord[] = [];
@@ -155,6 +161,7 @@ function fakeClient(
recorded.push({ calls: build(session.accountId), options });
const next = queue.shift();
if (!next) throw new Error('준비한 응답이 없습니다');
+ if (next instanceof Error) return Promise.reject(next);
return Promise.resolve(next);
},
),
@@ -197,6 +204,23 @@ async function codeOf(promise: Promise): Promise {
const argsOf = (record: CallRecord, index = 0) => record.calls[index][1];
+/**
+ * 붙잡힌 메일을 막는 확인(9단계 — 호출 id `hq` · `hg`)의 응답 — 기다리는 제출(list)을 준다. 없으면 붙잡힌 메일이 없다
+ * (옮기기 · 지우기 경로가 같은 요청에 덧붙이거나 먼저 묻는다 — pending-send.ts 의 readHeldEmails)
+ */
+const heldGuard = (list: Record[] = []): JmapInvocation[] => [
+ [
+ 'EmailSubmission/query',
+ { ids: list.map((submission) => submission.id), total: list.length },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list }, 'hg'],
+];
+
+/** 지금부터 ms 뒤의 UTCDate (붙잡은 제출의 보낼 시각) */
+const sendTimeIn = (ms: number) =>
+ new Date(Date.now() + ms).toISOString().replace(/\.\d+Z$/, 'Z');
+
describe('JmapMailProvider', () => {
describe('프로필 (getProfile)', () => {
it('대리 접근 세션의 계정 주소와 발신 ID 의 이름을 쓴다', async () => {
@@ -473,6 +497,7 @@ describe('JmapMailProvider', () => {
isFlagged: true,
hasAttachment: true,
size: 1154,
+ pendingSend: null,
});
});
@@ -727,9 +752,12 @@ describe('JmapMailProvider', () => {
ids: null,
properties: ['id', 'role'],
});
+ // 같은 요청 끝에 붙잡은 발송(보낼 시각이 지금 뒤인 제출) — 9단계
expect(recorded[1].calls.map(([method]) => method)).toEqual([
'Email/query',
'Email/get',
+ 'EmailSubmission/query',
+ 'EmailSubmission/get',
]);
expect(argsOf(recorded[1])).toMatchObject({
filter: {
@@ -893,7 +921,20 @@ describe('JmapMailProvider', () => {
// 발송 때 정화한 서식 본문을 막는 바이트 수와 같은 상수 — 우리가 보낸 메일은 다시 읽어도 잘리지 않는다
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
});
- expect(recorded[0].calls.map(([name]) => name)).toEqual(['Email/get']);
+ // 같은 요청에 메일함 역할(스팸메일함에 든 메일인지 — 외부 이미지) · 그 메일의 붙잡은 발송 (9단계)
+ expect(recorded[0].calls.map(([name]) => name)).toEqual([
+ 'Email/get',
+ 'Mailbox/get',
+ 'EmailSubmission/query',
+ 'EmailSubmission/get',
+ ]);
+ expect(recorded[0].calls[2][1]).toEqual({
+ accountId: 'd',
+ filter: { emailIds: ['m1'] },
+ sort: [{ property: 'sentAt', isAscending: true }],
+ limit: 500,
+ calculateTotal: true,
+ });
expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
// 겹치는 속성 이름 없이 (attachments 는 요약·상세 둘 다에 있다)
const properties = (args as { properties: string[] }).properties;
@@ -1058,8 +1099,9 @@ describe('JmapMailProvider', () => {
});
describe('읽음·중요·이동 (updateMessage)', () => {
- it('키워드와 메일함을 JMAP 패치로 바꾼다', async () => {
+ it('키워드와 메일함을 JMAP 패치로 바꾼다 — 옮기기는 먼저 그 메일이 붙잡힌 메일인지 묻는다(메일 id 조건, 9단계)', async () => {
const { provider, recorded } = fakeClient([
+ heldGuard(),
[
['Email/set', { updated: { m1: null } }, 'u'],
[
@@ -1082,7 +1124,21 @@ describe('JmapMailProvider', () => {
mailboxId: 'b',
});
- expect(argsOf(recorded[0])).toEqual({
+ expect(recorded[0].calls.map(([method, , id]) => [method, id])).toEqual([
+ ['EmailSubmission/query', 'hq'],
+ ['EmailSubmission/get', 'hg'],
+ ]);
+ // 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id (9단계 리뷰 2), 다 받았는지는 total 로
+ expect(argsOf(recorded[0])).toMatchObject({
+ filter: {
+ emailIds: ['m1'],
+ after: expect.stringMatching(/Z$/) as unknown,
+ },
+ calculateTotal: true,
+ });
+ // 경로의 id 를 엔진이 해석하지 못하면 없는 메일과 같다
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
update: {
m1: {
@@ -1094,11 +1150,151 @@ describe('JmapMailProvider', () => {
});
expect(summary.mailboxIds).toEqual(['b']);
// 바꾼 뒤의 요약도 목록과 같은 방법으로 첨부 표시를 정한다
- expect(argsOf(recorded[0], 1)).toMatchObject({
+ expect(argsOf(recorded[1], 1)).toMatchObject({
bodyProperties: ['type', 'disposition', 'cid', 'size'],
});
});
+ it.each<[string, Record]>([
+ ['옮기기', { mailboxId: 'b' }],
+ ['보낸편지함 그대로여도', { mailboxId: 'e' }],
+ ['스팸 신고', { isJunk: true }],
+ ['스팸 아님', { isJunk: false }],
+ ])(
+ '붙잡힌 메일(9단계)의 %s 는 409 MAIL_015 — 아무것도 바꾸지 않는다',
+ async (_label, update) => {
+ const { provider, recorded } = fakeClient([
+ heldGuard([
+ {
+ id: 'sb1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ ]);
+
+ expect(await codeOf(provider.updateMessage(ARA, 'm1', update))).toBe(
+ 'MAIL_015',
+ );
+ expect(recorded).toHaveLength(1);
+ },
+ );
+
+ it('읽음 · 중요 표시만이면 붙잡힌 메일인지 묻지 않는다 (붙잡힌 메일도 표시는 된다)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ [
+ 'Email/get',
+ { list: [summaryEmail('m1', '2026-10-26T05:12:00Z')] },
+ 'g',
+ ],
+ ['EmailSubmission/query', { ids: [] }, 'pq'],
+ ['EmailSubmission/get', { list: [] }, 'pg'],
+ ],
+ ]);
+
+ await provider.updateMessage(ARA, 'm1', {
+ isFlagged: true,
+ isRead: true,
+ });
+ expect(recorded).toHaveLength(1);
+ expect(recorded[0].calls[0][0]).toBe('Email/set');
+ });
+
+ it('보낼 시각이 지난 pending(배달 중) · final · canceled 제출은 붙잡힌 것이 아니다 — 옮긴다', async () => {
+ const { provider, recorded } = fakeClient([
+ heldGuard([
+ {
+ id: 'a',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(-5_000),
+ },
+ {
+ id: 'b',
+ emailId: 'm1',
+ undoStatus: 'final',
+ sendAt: sendTimeIn(60_000),
+ },
+ {
+ id: 'c',
+ emailId: 'm1',
+ undoStatus: 'canceled',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ [
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ [
+ 'Email/get',
+ {
+ list: [
+ summaryEmail('m1', '2026-10-26T05:12:00Z', {
+ mailboxIds: { b: true },
+ }),
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+
+ await provider.updateMessage(ARA, 'm1', { mailboxId: 'b' });
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('붙잡힌 메일인지 읽지 못하면 아무것도 바꾸지 않고 MAIL_001 (fail closed)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ]);
+
+ expect(
+ await codeOf(provider.updateMessage(ARA, 'm1', { mailboxId: 'b' })),
+ ).toBe('MAIL_001');
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('pending 인데 보낼 시각을 읽지 못한 제출은 붙잡힌 것으로 — 409 MAIL_015, 옮기지 않는다 (9단계 리뷰 2 — 닫힌 쪽)', async () => {
+ for (const sendAt of [null, '모름']) {
+ const { provider, recorded } = fakeClient([
+ heldGuard([
+ { id: 'sb1', emailId: 'm1', undoStatus: 'pending', sendAt },
+ ]),
+ ]);
+
+ expect(
+ await codeOf(provider.updateMessage(ARA, 'm1', { mailboxId: 'b' })),
+ ).toBe('MAIL_015');
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('다 받았는지 응답으로 모르면(total 이 없음 · 줄인 limit 에 닿음) 아무것도 바꾸지 않고 MAIL_001 (9단계 리뷰 2 — 닫힌 쪽)', async () => {
+ for (const answer of [{ ids: [] }, { ids: ['sb1'], limit: 1 }]) {
+ const { provider, recorded } = fakeClient([
+ [
+ ['EmailSubmission/query', answer, 'hq'],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ],
+ ]);
+
+ const error = await provider
+ .updateMessage(ARA, 'm1', { mailboxId: 'b' })
+ .catch((caught: unknown) => caught as BusinessException);
+ expect((error as BusinessException).code).toBe('MAIL_001');
+ expect((error as BusinessException).context).toEqual({
+ reason: 'held-submissions-over-limit',
+ candidates: 1,
+ });
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
it('없는 메일은 RES_001, 잘못된 값은 VAL_001 이다', async () => {
const notFound = fakeClient([
[
@@ -1113,6 +1309,7 @@ describe('JmapMailProvider', () => {
).toBe('RES_001');
const invalid = fakeClient([
+ heldGuard(),
[
[
'Email/set',
@@ -1139,10 +1336,14 @@ describe('JmapMailProvider', () => {
});
describe('삭제 (deleteMessage)', () => {
- /** 메일 + 메일함 확인 — 그때의 Email 상태 's1' */
- const lookup = (mailboxIds: Record): JmapInvocation[] => [
+ /** 메일 + 메일함 확인 — 그때의 Email 상태 's1', 같은 요청에 그 메일의 제출(붙잡힌 메일 막기 — 9단계) */
+ const lookup = (
+ mailboxIds: Record,
+ submissions: Record[] = [],
+ ): JmapInvocation[] => [
['Email/get', { state: 's1', list: [{ id: 'm1', mailboxIds }] }, 'g'],
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ...heldGuard(submissions),
];
/** 지우기 전에 다시 확인한 메일 — 그때의 상태 */
const recheck = (
@@ -1162,7 +1363,7 @@ describe('JmapMailProvider', () => {
['Email/set', { destroyed: ['m1'] }, 'd'],
];
- it('휴지통이 아니면 휴지통으로 옮긴다', async () => {
+ it('휴지통이 아니면 휴지통으로 옮긴다 — 확인 요청에 그 메일의 제출을 함께 묻는다(메일 id 조건)', async () => {
const { provider, recorded } = fakeClient([
lookup({ a: true }),
[['Email/set', { updated: { m1: null } }, 'd']],
@@ -1170,12 +1371,94 @@ describe('JmapMailProvider', () => {
await provider.deleteMessage(ARA, 'm1');
+ expect(recorded[0].calls.map(([method]) => method)).toEqual([
+ 'Email/get',
+ 'Mailbox/get',
+ 'EmailSubmission/query',
+ 'EmailSubmission/get',
+ ]);
+ // 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id (9단계 리뷰 2)
+ expect(argsOf(recorded[0], 2)).toMatchObject({
+ filter: {
+ emailIds: ['m1'],
+ after: expect.stringMatching(/Z$/) as unknown,
+ },
+ calculateTotal: true,
+ });
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
update: { m1: { mailboxIds: { b: true } } },
});
});
+ it('붙잡힌 메일(9단계)은 409 MAIL_015 — 옮기지도 지우지도 않는다 (휴지통에 있어도)', async () => {
+ const places: Record[] = [{ e: true }, { b: true }];
+ for (const mailboxIds of places) {
+ const { provider, recorded } = fakeClient([
+ lookup(mailboxIds, [
+ {
+ id: 'sb1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(600_000),
+ },
+ ]),
+ ]);
+
+ expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe(
+ 'MAIL_015',
+ );
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('제출을 읽지 못하면 아무것도 하지 않고 MAIL_001 (fail closed)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ [
+ 'Email/get',
+ { state: 's1', list: [{ id: 'm1', mailboxIds: { a: true } }] },
+ 'g',
+ ],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ]);
+
+ expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe('MAIL_001');
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것(MAIL_015), 다 받았는지 모르면(total 이 없음) MAIL_001 — 아무것도 하지 않는다 (9단계 리뷰 2)', async () => {
+ const unreadable = fakeClient([
+ lookup({ e: true }, [
+ { id: 'sb1', emailId: 'm1', undoStatus: 'pending', sendAt: null },
+ ]),
+ ]);
+ expect(await codeOf(unreadable.provider.deleteMessage(ARA, 'm1'))).toBe(
+ 'MAIL_015',
+ );
+ expect(unreadable.recorded).toHaveLength(1);
+
+ const unknown = fakeClient([
+ [
+ [
+ 'Email/get',
+ { state: 's1', list: [{ id: 'm1', mailboxIds: { e: true } }] },
+ 'g',
+ ],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['EmailSubmission/query', { ids: [] }, 'hq'],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ],
+ ]);
+ expect(await codeOf(unknown.provider.deleteMessage(ARA, 'm1'))).toBe(
+ 'MAIL_001',
+ );
+ expect(unknown.recorded).toHaveLength(1);
+ });
+
it('휴지통에만 있으면 확인한 상태 그대로일 때만 영구 삭제한다 (ifInState)', async () => {
const { provider, recorded } = fakeClient([
lookup({ b: true }),
@@ -1293,6 +1576,7 @@ describe('JmapMailProvider', () => {
[
['Email/get', { list: [], notFound: ['nope'] }, 'g'],
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ...heldGuard(),
],
]);
@@ -1826,8 +2110,9 @@ describe('JmapMailProvider', () => {
expect(warnSpy).not.toHaveBeenCalled();
});
- it('이동은 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => {
+ it('이동은 먼저 계정의 기다리는 제출을 묻고(9단계), 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => {
const { provider, recorded } = fakeClient([
+ heldGuard(),
[
['Mailbox/get', { list: [{ id: 'b' }], notFound: [] }, 'mb'],
['Email/set', { updated: { m1: null, m2: null } }, 'u'],
@@ -1838,7 +2123,16 @@ describe('JmapMailProvider', () => {
mailboxId: 'b',
});
- expect(recorded[0].calls).toEqual([
+ // 보낼 시각이 지금 뒤인 제출(엔진의 색인) — 계정 전체를 한 번에
+ expect(recorded[0].calls.map(([method, , id]) => [method, id])).toEqual([
+ ['EmailSubmission/query', 'hq'],
+ ['EmailSubmission/get', 'hg'],
+ ]);
+ expect(argsOf(recorded[0])).toMatchObject({
+ filter: { after: expect.stringMatching(/Z$/) as unknown },
+ sort: [{ property: 'sentAt', isAscending: true }],
+ });
+ expect(recorded[1].calls).toEqual([
[
'Mailbox/get',
{ accountId: 'd', ids: ['b'], properties: ['id'] },
@@ -1859,8 +2153,176 @@ describe('JmapMailProvider', () => {
expect(result).toEqual({ updatedIds: ['m1', 'm2'], failedIds: [] });
});
+ it('붙잡힌 메일(9단계)은 옮기지도 스팸으로 바꾸지도 않고 failedIds — 나머지는 그대로 바꾼다(요청 순서), 개수만 경고', async () => {
+ for (const update of [
+ { mailboxId: 'b' },
+ { isJunk: true, isRead: true },
+ ]) {
+ const { provider, recorded } = fakeClient([
+ heldGuard([
+ {
+ id: 'sb1',
+ emailId: 'm2',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ {
+ id: 'sb2',
+ emailId: 'm9',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ [
+ ...(update.mailboxId !== undefined
+ ? ([
+ ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'],
+ ] as JmapInvocation[])
+ : []),
+ ['Email/set', { updated: { m1: null, m3: null } }, 'u'],
+ ],
+ ]);
+
+ await expect(
+ provider.updateMessages(ARA, ['m1', 'm2', 'm3'], update),
+ ).resolves.toEqual({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] });
+ const set = recorded[1].calls.find(
+ ([method]) => method === 'Email/set',
+ );
+ expect(Object.keys((set?.[1] as { update: object }).update)).toEqual([
+ 'm1',
+ 'm3',
+ ]);
+ }
+ const lines = warnSpy.mock.calls.map(([line]) => String(line));
+ expect(lines.every((line) => line.startsWith('[MAIL_015]'))).toBe(true);
+ expect(lines[0]).toContain('1통');
+ });
+
+ it('붙잡힌 메일뿐이면 엔진에 바꾸기를 보내지 않는다 — 모두 failedIds (옮길 메일함은 지금처럼 확인한다)', async () => {
+ const held = [
+ {
+ id: 'sb1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ];
+ const junk = fakeClient([heldGuard(held)]);
+ await expect(
+ junk.provider.updateMessages(ARA, ['m1'], { isJunk: true }),
+ ).resolves.toEqual({ updatedIds: [], failedIds: ['m1'] });
+ expect(junk.recorded).toHaveLength(1);
+
+ const move = fakeClient([
+ heldGuard(held),
+ [['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb']],
+ ]);
+ expect(
+ await codeOf(
+ move.provider.updateMessages(ARA, ['m1'], { mailboxId: 'zzzz' }),
+ ),
+ ).toBe('RES_001');
+ expect(move.recorded[1].calls.map(([method]) => method)).toEqual([
+ 'Mailbox/get',
+ ]);
+ });
+
+ it('계정의 기다리는 제출이 상한(500)에 닿으면 요청한 id 로 다시 묻는다 — 그것도 읽지 못하면 아무것도 바꾸지 않고 MAIL_001', async () => {
+ const full: JmapInvocation[] = [
+ [
+ 'EmailSubmission/query',
+ { ids: Array.from({ length: 500 }, (_, i) => `s${i}`), total: 1200 },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ];
+ const { provider, recorded } = fakeClient([
+ full,
+ heldGuard([
+ {
+ id: 'sb9',
+ emailId: 'm2',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ [
+ ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'],
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ ],
+ ]);
+
+ await expect(
+ provider.updateMessages(ARA, ['m1', 'm2'], { mailboxId: 'b' }),
+ ).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
+ // 다시 묻기도 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건 — 그 메일들의 지난 제출이 상한을 채우지 않게 (9단계 리뷰 2)
+ expect(argsOf(recorded[1])).toMatchObject({
+ filter: {
+ emailIds: ['m1', 'm2'],
+ after: (argsOf(recorded[0]).filter as { after: string }).after,
+ },
+ });
+
+ const broken = fakeClient([
+ [
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ]);
+ expect(
+ await codeOf(
+ broken.provider.updateMessages(ARA, ['m1'], { mailboxId: 'b' }),
+ ),
+ ).toBe('MAIL_001');
+ expect(broken.recorded).toHaveLength(1);
+ });
+
+ it('엔진이 limit 을 줄여(500 보다 작게) 다 주지 않았으면 다 본 것으로 보지 않는다 — 요청한 id 로 다시 묻는다 (9단계 리뷰 2)', async () => {
+ // 엔진의 쿼리 최대가 250 — 계정의 기다리는 제출 600개 가운데 앞의 250개만 왔다(m2 의 것은 그 뒤)
+ const reduced: JmapInvocation[] = [
+ [
+ 'EmailSubmission/query',
+ {
+ ids: Array.from({ length: 250 }, (_, i) => `s${i}`),
+ position: 0,
+ limit: 250,
+ total: 600,
+ },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ];
+ const { provider, recorded } = fakeClient([
+ reduced,
+ heldGuard([
+ {
+ id: 'sb9',
+ emailId: 'm2',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ [
+ ['Mailbox/get', { list: [{ id: 'b' }] }, 'mb'],
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ ],
+ ]);
+ const warn = jest
+ .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
+ .mockImplementation(() => undefined);
+
+ await expect(
+ provider.updateMessages(ARA, ['m1', 'm2'], { mailboxId: 'b' }),
+ ).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
+ expect(argsOf(recorded[2], 1)).toMatchObject({ update: { m1: {} } });
+ expect(argsOf(recorded[2], 1).update).not.toHaveProperty('m2');
+ expect(warn).toHaveBeenCalled();
+ });
+
it('옮길 메일함이 없으면 404 RES_001 — 엔진이 메일마다 이동을 거절해 아무것도 바뀌지 않았다', async () => {
const { provider, recorded } = fakeClient([
+ heldGuard(),
[
['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'],
[
@@ -1885,18 +2347,19 @@ describe('JmapMailProvider', () => {
),
).toBe('RES_001');
// 읽음 표시도 같은 패치라 메일마다 통째로 거절된다 — 이동만 빠지고 읽음만 바뀌는 일은 없다
- const set = argsOf(recorded[0], 1) as {
+ const set = argsOf(recorded[1], 1) as {
update: Record>;
};
expect(set.update.m1).toEqual({
'keywords/$seen': true,
mailboxIds: { zzzz: true },
});
- expect(recorded).toHaveLength(1);
+ expect(recorded).toHaveLength(2);
});
it('없는 메일함인데 엔진이 바꿨다고 답하면(있을 수 없는 경우) 사실대로 돌려주고 경고한다', async () => {
const { provider } = fakeClient([
+ heldGuard(),
[
['Mailbox/get', { list: [], notFound: ['k'] }, 'mb'],
['Email/set', { updated: { m1: null } }, 'u'],
@@ -1980,11 +2443,12 @@ describe('JmapMailProvider', () => {
/** 확인 요청이 돌려주는 메일 (id · 들어 있는 메일함) */
type TrashCandidate = { id: string; mailboxIds: Record };
- /** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다) */
+ /** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다), 같은 요청의 계정 기다리는 제출(9단계) */
const lookup = (
emails: TrashCandidate[],
state: string | null = 's1',
mailboxes: unknown[] = stalwartMailboxes,
+ held: Record[] = [],
): JmapInvocation[] => [
[
'Email/get',
@@ -1992,6 +2456,7 @@ describe('JmapMailProvider', () => {
'g',
],
['Mailbox/get', { list: mailboxes }, 'm'],
+ ...heldGuard(held),
];
const mismatch: JmapInvocation[] = [
['error', { type: 'stateMismatch' }, 'd'],
@@ -2016,7 +2481,7 @@ describe('JmapMailProvider', () => {
'i1',
]);
- expect(recorded[0].calls).toEqual([
+ expect(recorded[0].calls.slice(0, 2)).toEqual([
[
'Email/get',
{
@@ -2032,6 +2497,16 @@ describe('JmapMailProvider', () => {
'm',
],
]);
+ // 같은 요청 끝에 계정의 기다리는 제출(보낼 시각이 지금 뒤) — 붙잡힌 메일은 지우지 않는다 (9단계)
+ expect(
+ recorded[0].calls.slice(2).map(([method, , id]) => [method, id]),
+ ).toEqual([
+ ['EmailSubmission/query', 'hq'],
+ ['EmailSubmission/get', 'hg'],
+ ]);
+ expect(argsOf(recorded[0], 2)).toMatchObject({
+ filter: { after: expect.stringMatching(/Z$/) as unknown },
+ });
expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
@@ -2045,6 +2520,58 @@ describe('JmapMailProvider', () => {
});
});
+ it('붙잡힌 메일(9단계 — 다른 프로그램이 휴지통으로 옮겨 둔 것)은 지우지 않고 건너뛴다 — 개수만 경고', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(mixed, 's1', stalwartMailboxes, [
+ {
+ id: 'sb1',
+ emailId: 't1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(60_000),
+ },
+ ]),
+ [['Email/set', { updated: { t2: null } }, 'd']],
+ ]);
+
+ await expect(
+ provider.deleteFromTrash(ARA, ['t1', 't2']),
+ ).resolves.toEqual({
+ deletedIds: ['t2'],
+ skippedIds: ['t1'],
+ });
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: { t2: { 'mailboxIds/b': null } },
+ });
+ const [line] = warnSpy.mock.calls[0] as [string];
+ expect(line).toMatch(/^\[MAIL_015\].*1통/);
+ });
+
+ it('휴지통에 든 메일이 있는데 기다리는 제출을 읽지 못하면 아무것도 지우지 않고 MAIL_001 — 든 것이 없으면 그대로 건너뛴다', async () => {
+ const broken = (emails: TrashCandidate[]): JmapInvocation[] => [
+ ['Email/get', { state: 's1', list: emails, notFound: [] }, 'g'],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ];
+ const failing = fakeClient([broken(mixed)]);
+ expect(await codeOf(failing.provider.deleteFromTrash(ARA, ['t1']))).toBe(
+ 'MAIL_001',
+ );
+ expect(failing.recorded).toHaveLength(1);
+
+ const outside = fakeClient([
+ broken([{ id: 'i1', mailboxIds: { a: true } }]),
+ ]);
+ await expect(
+ outside.provider.deleteFromTrash(ARA, ['i1']),
+ ).resolves.toEqual({
+ deletedIds: [],
+ skippedIds: ['i1'],
+ });
+ });
+
it('휴지통 밖의 메일은 옮기지 않는다 — 지울 것이 없으면 지우는 요청 없이 모두 건너뛴다', async () => {
const { provider, recorded } = fakeClient([
lookup([{ id: 'i1', mailboxIds: { a: true } }]),
@@ -2287,6 +2814,7 @@ describe('JmapMailProvider', () => {
await expect(provider.sendMessage(ARA, outgoing)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
+ pendingSend: null,
});
const draft = (argsOf(recorded[2], 0).create as Record)
@@ -2312,9 +2840,22 @@ describe('JmapMailProvider', () => {
},
],
});
+ // envelope 은 늘 직접 준다(9단계 리뷰 뒤) — 받는 사람 · 참조 · 숨은참조, 붙잡지 않으면 MAIL FROM 매개변수 없음
expect(argsOf(recorded[2], 1)).toEqual({
accountId: 'd',
- create: { send: { identityId: 'b', emailId: '#draft' } },
+ create: {
+ send: {
+ identityId: 'b',
+ emailId: '#draft',
+ envelope: {
+ mailFrom: { email: ARA, parameters: null },
+ rcptTo: [
+ { email: DOYUN, parameters: null },
+ { email: SEOJUN, parameters: null },
+ ],
+ },
+ },
+ },
onSuccessUpdateEmail: {
'#send': {
'mailboxIds/d': null,
@@ -2651,16 +3192,22 @@ describe('JmapMailProvider', () => {
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
+ pendingSend: null,
});
expect(recorded[1].calls.map(([name]) => name)).toEqual([
'Email/set',
'EmailSubmission/set',
]);
+ // 결과와 함께 제출의 지금 상태(모두 거절된 붙잡은 발송을 취소할지 — 9단계 리뷰 뒤)
expect(recorded[2].calls).toEqual([
[
'EmailSubmission/get',
- { accountId: 'd', ids: ['b'], properties: ['deliveryStatus'] },
+ {
+ accountId: 'd',
+ ids: ['b'],
+ properties: ['deliveryStatus', 'undoStatus'],
+ },
'd',
],
]);
@@ -2679,6 +3226,7 @@ describe('JmapMailProvider', () => {
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [{ email: DOYUN, reason: 'not-found' }],
+ pendingSend: null,
});
// 지우지 않았다
@@ -2734,6 +3282,7 @@ describe('JmapMailProvider', () => {
{ email: 'd@example.com', reason: 'temporary' },
{ email: 'G.Kim@AraSystem.Test', reason: 'not-found' },
],
+ pendingSend: null,
});
});
@@ -2857,6 +3406,7 @@ describe('JmapMailProvider', () => {
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
+ pendingSend: null,
});
expect(recorded).toHaveLength(calls);
expect(logged(warn)).toContain(
@@ -2887,11 +3437,575 @@ describe('JmapMailProvider', () => {
const warn = spyLogger(fake.provider, 'warn');
await expect(fake.provider.sendMessage(ARA, textOnly)).resolves.toEqual(
- { id: 'maaaaad', rejectedRecipients: [] },
+ { id: 'maaaaad', rejectedRecipients: [], pendingSend: null },
);
expect(fake.recorded).toHaveLength(2);
expect(logged(warn)).toContain('reason=no-time');
});
+
+ describe('엔진이 envelope 의 주소를 받지 않음 (9단계 리뷰 뒤 — Stalwart 0.16.27: 첫 주소 하나만 설명에 적고 제출 전체를 거절)', () => {
+ const TYPO = 'typo@example.cmo';
+ const TYPO2 = 'Second.Typo@Example.CMO';
+ /** 엔진이 개발 환경에서 돌려준 설명 그대로 (2026-10-29) */
+ const refusal = (address: string): JmapInvocation => [
+ 'EmailSubmission/set',
+ {
+ notCreated: {
+ send: {
+ type: 'invalidProperties',
+ properties: ['envelope'],
+ description: `Invalid e-mail address "${address}".`,
+ },
+ },
+ },
+ 's',
+ ];
+ /** 처음 요청 — 초안은 만들어졌고 제출은 거절 */
+ const firstRefused = (address: string): JmapInvocation[] => [
+ sent[0],
+ refusal(address),
+ ];
+ /** 다시 낸 제출 — 성공(보낸편지함으로 옮김) */
+ const resubmitted: JmapInvocation[] = [sent[1], sent[2]];
+ const withTypos: OutgoingMessage = {
+ ...textOnly,
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [{ name: null, email: TYPO }],
+ bcc: [
+ { name: null, email: SEOJUN },
+ { name: null, email: TYPO2 },
+ ],
+ };
+ /** 그 요청의 제출(EmailSubmission/set create) — 처음 요청은 두 번째 호출, 다시 낸 요청은 첫 호출 */
+ type SubmittedSend = {
+ emailId: string;
+ envelope: { rcptTo: { email: string }[] };
+ };
+ const envelopeOf = (record: CallRecord): SubmittedSend => {
+ const args = argsOf(record, record.calls.length === 1 ? 0 : 1);
+ return (args.create as Record).send;
+ };
+
+ it('설명에 적힌 주소를 받지 않은 사람(not-found)으로 빼고 같은 메일을 나머지에게 다시 낸다 — 한 번에 하나씩, 요청의 차례로 싣는다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [refusal(TYPO2.toLowerCase())],
+ resubmitted,
+ deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }),
+ ]);
+ const log = spyLogger(provider, 'log');
+
+ await expect(provider.sendMessage(ARA, withTypos)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [
+ { email: TYPO, reason: 'not-found' },
+ { email: TYPO2, reason: 'not-found' },
+ ],
+ pendingSend: null,
+ });
+
+ expect(
+ envelopeOf(recorded[1]).envelope.rcptTo.map(({ email }) => email),
+ ).toEqual([DOYUN, TYPO, SEOJUN, TYPO2]);
+ // 다시 낼 때는 만든 메일 id 로 — 같은 메일, 성공하면 보낸편지함으로 옮기는 것도 같다
+ expect(recorded[2].calls.map(([method]) => method)).toEqual([
+ 'EmailSubmission/set',
+ ]);
+ expect(envelopeOf(recorded[2])).toMatchObject({ emailId: 'maaaaad' });
+ expect(
+ envelopeOf(recorded[2]).envelope.rcptTo.map(({ email }) => email),
+ ).toEqual([DOYUN, SEOJUN, TYPO2]);
+ expect(
+ envelopeOf(recorded[3]).envelope.rcptTo.map(({ email }) => email),
+ ).toEqual([DOYUN, SEOJUN]);
+ expect(argsOf(recorded[3])).toMatchObject({
+ onSuccessUpdateEmail: { '#send': { 'mailboxIds/e': true } },
+ });
+ expect(recorded).toHaveLength(5);
+ // 설명 글은 남기지 않는다 — 가린 주소만
+ const lines = logged(log);
+ expect(lines).toContain('t***@example.cmo');
+ expect(lines).not.toContain('Invalid e-mail address');
+ expect(lines).not.toContain('typo@example.cmo');
+ });
+
+ it('붙잡는 발송도 같은 붙잡기로 다시 낸다 (HOLDFOR 그대로)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [
+ [
+ 'EmailSubmission/set',
+ {
+ created: {
+ send: {
+ id: 'b',
+ undoStatus: 'pending',
+ sendAt: '2030-01-01T00:00:10Z',
+ },
+ },
+ },
+ 's',
+ ],
+ sent[2],
+ ],
+ deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }),
+ ]);
+ spyLogger(provider, 'log');
+
+ await expect(
+ provider.sendMessage(ARA, {
+ ...withTypos,
+ bcc: [{ name: null, email: SEOJUN }],
+ hold: { kind: 'undo', seconds: 10 },
+ }),
+ ).resolves.toMatchObject({
+ rejectedRecipients: [{ email: TYPO, reason: 'not-found' }],
+ pendingSend: { kind: 'undo', sendAt: '2030-01-01T00:00:10.000Z' },
+ });
+ expect(
+ (
+ argsOf(recorded[2]).create as Record<
+ string,
+ { envelope: { mailFrom: unknown } }
+ >
+ ).send.envelope.mailFrom,
+ ).toEqual({ email: ARA, parameters: { HOLDFOR: '10' } });
+ });
+
+ it('남은 사람이 없으면 보내지 않은 것이다 — 만든 메일을 조건 없이 지우고 400 MAIL_010 (제출은 하나도 없다)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [refusal(TYPO2)],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+ const warn = spyLogger(provider, 'warn');
+ spyLogger(provider, 'log');
+
+ const error = await provider
+ .sendMessage(ARA, {
+ ...textOnly,
+ to: [{ name: null, email: TYPO }],
+ bcc: [{ name: null, email: TYPO2 }],
+ })
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect((error as BusinessException).code).toBe('MAIL_010');
+ expect((error as BusinessException).context).toEqual({
+ reason: 'all-recipients-refused-in-envelope',
+ rejected: 2,
+ copyDiscarded: true,
+ });
+ expect(recorded[3].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(recorded).toHaveLength(4);
+ expect(logged(warn)).toContain('[MAIL_010]');
+ expect(logged(warn)).toContain('(엔진이 주소를 받지 않음)');
+ expect(logged(warn)).not.toContain('Invalid e-mail address');
+ });
+
+ it('설명에 우리 주소가 없으면 지금처럼 — 만든 메일을 지우고 오류 표대로(VAL_001), 받는 사람을 함부로 빼지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ [
+ sent[0],
+ [
+ 'EmailSubmission/set',
+ {
+ notCreated: {
+ send: {
+ type: 'invalidProperties',
+ properties: ['envelope'],
+ description:
+ 'Invalid e-mail address "someone@else.example".',
+ },
+ },
+ },
+ 's',
+ ],
+ ],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, withTypos))).toBe(
+ 'VAL_001',
+ );
+ expect(recorded[2].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(recorded).toHaveLength(3);
+ });
+
+ it('다시 낼 시간이 모자라면 만든 메일을 지우고 MAIL_001 — 아직 아무것도 제출하지 않았다', async () => {
+ const fake = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+ // 준비 20초, 처음 제출 30초 — 남은 5초는 다시 낼 최소 시간(10초)보다 적다
+ elapseOnCalls(fake, [20_000, 30_000], { now: 1_000_000 });
+ spyLogger(fake.provider, 'log');
+
+ const error = await fake.provider
+ .sendMessage(ARA, withTypos)
+ .catch((caught: unknown) => caught as BusinessException);
+ expect((error as BusinessException).code).toBe('MAIL_001');
+ expect((error as BusinessException).context).toMatchObject({
+ reason: 'send-budget-exhausted',
+ resubmits: 0,
+ });
+ expect(fake.recorded[2].calls[0][0]).toBe('Email/set');
+ // 지우기도 남은 시간(5초) 안에서 (9단계 리뷰 2)
+ expect(fake.recorded[2].options).toEqual({ timeoutMs: 5_000 });
+ expect(fake.recorded).toHaveLength(3);
+ });
+
+ it('다시 낸 차례가 남은 시간을 거의 다 쓰고 거절되면 만든 메일은 남은 시간 안에서 지운다 — 모자라도 1초, 발송 응답이 56초를 넘지 않게 (9단계 리뷰 2)', async () => {
+ const fake = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [['error', { type: 'serverFail' }, 's']],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+ // 준비 5초, 처음 제출 10초, 다시 낸 제출 39.5초 — 남은 시간 0.5초
+ elapseOnCalls(fake, [5_000, 10_000, 39_500], { now: 1_000_000 });
+ spyLogger(fake.provider, 'warn');
+
+ expect(await codeOf(fake.provider.sendMessage(ARA, withTypos))).toBe(
+ 'MAIL_001',
+ );
+ // 다시 낸 제출은 남은 시간(55 - 15 = 40초)까지 · 처음 제출과 같게 요청을 해석하지 못하면 VAL_001
+ expect(fake.recorded[2].options).toEqual({
+ invalidRequestCode: 'VAL_001',
+ timeoutMs: 40_000,
+ });
+ expect(fake.recorded[3].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(fake.recorded[3].options).toEqual({ timeoutMs: 1_000 });
+ });
+
+ it('받지 않는 주소가 많아 다시 내기가 20차례를 넘으면 더 내지 않는다 — 만든 메일을 지우고 400 VAL_001 (주소는 개수만, 9단계 리뷰 2)', async () => {
+ // 계약의 숫자 — 처음 제출 + 다시 내기 20차례
+ expect(MAX_ENVELOPE_RESUBMITS).toBe(20);
+ const typos = Array.from(
+ { length: 22 },
+ (_, index) => `typo${index}@example.cmo`,
+ );
+ const { provider, recorded } = fakeClient([
+ prepared,
+ firstRefused(typos[0]),
+ ...typos.slice(1, 21).map((typo) => [refusal(typo)]),
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+ const log = spyLogger(provider, 'log');
+ const warn = spyLogger(provider, 'warn');
+
+ const error = await provider
+ .sendMessage(ARA, {
+ ...textOnly,
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: typos.map((email) => ({ name: null, email })),
+ bcc: [],
+ })
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect((error as BusinessException).code).toBe('VAL_001');
+ // 처음 제출 하나 + 다시 낸 20차례에서 하나씩 뺐다 — 남은 사람은 도윤 · 마지막 오타
+ expect((error as BusinessException).context).toEqual({
+ reason: 'too-many-refused-addresses',
+ refused: 21,
+ resubmits: 20,
+ left: 2,
+ });
+ const resubmitted = recorded.filter(
+ (record) =>
+ record.calls.length === 1 &&
+ record.calls[0][0] === 'EmailSubmission/set',
+ );
+ expect(resubmitted).toHaveLength(20);
+ expect(recorded.at(-1)?.calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(recorded).toHaveLength(23);
+ // 주소 원문은 로그에 없다
+ expect(`${logged(log)}\n${logged(warn)}`).not.toContain(
+ '@example.cmo',
+ );
+ });
+
+ it('다시 낸 차례들은 차례 수만 한 줄로 남긴다 — 뺀 주소(가린 것)는 받는 사람별 결과의 로그에 (9단계 리뷰 2)', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ [refusal(TYPO2.toLowerCase())],
+ resubmitted,
+ deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED }),
+ ]);
+ const log = spyLogger(provider, 'log');
+
+ await provider.sendMessage(ARA, withTypos);
+
+ const lines = log.mock.calls.map(([message]) => String(message));
+ expect(
+ lines.filter((line) => line.includes('다시 제출했습니다')),
+ ).toEqual([
+ expect.stringContaining(
+ '엔진이 envelope 의 주소 2개를 받지 않아 빼고 2차례 다시 제출했습니다',
+ ) as string,
+ ]);
+ expect(lines.join('\n')).not.toContain('typo@example.cmo');
+ });
+
+ describe('차례마다 실패를 다루는 법은 처음 제출과 같다 — 남는 것이 같게 (9단계 리뷰 2)', () => {
+ type Failure = 'method-error' | 'request-refused' | 'timeout';
+ /** 그 차례의 엔진 답 — 메서드 오류는 요청 안의 답, 나머지는 호출이 던진다(jmap.client.ts) */
+ const answerFor = (
+ failure: Failure,
+ round: 'first' | 'second',
+ ): JmapInvocation[] | Error => {
+ if (failure === 'method-error') {
+ const error: JmapInvocation = [
+ 'error',
+ { type: 'serverFail' },
+ 's',
+ ];
+ return round === 'first' ? [sent[0], error] : [error];
+ }
+ return failure === 'request-refused'
+ ? // 엔진이 요청 전체를 받지 않았다(HTTP 400 — 메서드를 하나도 돌리지 않는다)
+ new BusinessException('MAIL_001', {
+ context: { reason: 'http-error', call: 'api', status: 400 },
+ })
+ : new BusinessException('MAIL_001', {
+ context: { reason: 'timeout', call: 'api' },
+ });
+ };
+
+ /**
+ * 발송이 끝난 뒤 남는 것 — 지웠거나 엔진이 실행하지 않아 만든 것이 없으면 '남는 것 없음',
+ * 결과를 몰라 지우지 않았으면 '재시도에 맡김'(같은 키의 재시도가 지운다), 엔진이 거절한 것을 알면서 남겼으면 '숨은 초안'
+ */
+ const outcome = async (
+ failure: Failure,
+ round: 'first' | 'second',
+ ) => {
+ const destroyed: JmapInvocation[] = [
+ ['Email/set', { destroyed: ['maaaaad'] }, 'x'],
+ ];
+ const fake = fakeClient(
+ round === 'first'
+ ? [prepared, answerFor(failure, round), destroyed]
+ : [
+ prepared,
+ firstRefused(TYPO),
+ answerFor(failure, round),
+ destroyed,
+ ],
+ );
+ spyLogger(fake.provider, 'warn');
+ spyLogger(fake.provider, 'log');
+
+ const code = await codeOf(
+ fake.provider.sendMessage(ARA, withTypos),
+ );
+ const discarded = fake.recorded.some((record) =>
+ record.calls.some(
+ ([method, args]) =>
+ method === 'Email/set' &&
+ Object.prototype.hasOwnProperty.call(args, 'destroy'),
+ ),
+ );
+ // 처음 차례의 요청을 엔진이 실행하지 않았으면 초안도 만들어지지 않았다
+ const created = !(
+ round === 'first' && failure === 'request-refused'
+ );
+ const left =
+ discarded || !created
+ ? '남는 것 없음'
+ : failure === 'timeout'
+ ? '재시도에 맡김'
+ : '숨은 초안';
+ return { code, left };
+ };
+
+ it.each<[Failure, string]>([
+ ['method-error', '남는 것 없음'],
+ ['request-refused', '남는 것 없음'],
+ ['timeout', '재시도에 맡김'],
+ ])(
+ '%s — 다시 낸 차례도 처음 차례와 같다: %s',
+ async (failure, left) => {
+ const first = await outcome(failure, 'first');
+ const second = await outcome(failure, 'second');
+ expect(second).toEqual(first);
+ expect(first).toEqual({ code: 'MAIL_001', left });
+ },
+ );
+ });
+
+ it('결과를 읽지 못해도 envelope 에서 뺀 사람은 받지 않은 사람으로 싣는다', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ resubmitted,
+ [['error', { type: 'serverFail' }, 'd']],
+ ]);
+ spyLogger(provider, 'warn');
+ spyLogger(provider, 'log');
+
+ await expect(
+ provider.sendMessage(ARA, { ...withTypos, bcc: [] }),
+ ).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [{ email: TYPO, reason: 'not-found' }],
+ pendingSend: null,
+ });
+ });
+
+ it('envelope 에서 뺀 사람과 제출 때 거절된 사람을 합쳐 모두면 모두 거절 — 보낸 사본을 지우고 MAIL_010', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ firstRefused(TYPO),
+ resubmitted,
+ deliveryOf({ [DOYUN]: MAILBOX_MISSING }),
+ discarded,
+ ]);
+ spyLogger(provider, 'warn');
+ spyLogger(provider, 'log');
+
+ expect(
+ await codeOf(provider.sendMessage(ARA, { ...withTypos, bcc: [] })),
+ ).toBe('MAIL_010');
+ expect(recorded[4].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ });
+ });
+
+ describe('모두 거절된 붙잡은 발송 (9단계 리뷰 뒤 — 풀리는 시각에 다시 보내지 않게)', () => {
+ const held: OutgoingMessage = {
+ ...textOnly,
+ hold: { kind: 'undo', seconds: 10 },
+ };
+ const heldSent: JmapInvocation[] = [
+ sent[0],
+ [
+ 'EmailSubmission/set',
+ { created: { send: { id: 'b', undoStatus: 'pending' } } },
+ 's',
+ ],
+ sent[2],
+ ];
+ const everyoneBusy = (undoStatus: string): JmapInvocation[] => [
+ [
+ 'EmailSubmission/get',
+ {
+ list: [
+ {
+ id: 'b',
+ undoStatus,
+ deliveryStatus: {
+ [DOYUN]: RATE_LIMITED,
+ [SEOJUN]: RATE_LIMITED,
+ },
+ },
+ ],
+ },
+ 'd',
+ ],
+ ];
+
+ it('제출이 아직 기다리고 있으면(pending) 같은 요청에서 먼저 취소하고 사본을 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ heldSent,
+ everyoneBusy('pending'),
+ [
+ ['EmailSubmission/set', { updated: { b: null } }, 'c'],
+ ['Email/set', { destroyed: ['maaaaad'] }, 'x'],
+ ],
+ ]);
+ const warn = spyLogger(provider, 'warn');
+
+ expect(await codeOf(provider.sendMessage(ARA, held))).toBe(
+ 'MAIL_011',
+ );
+ expect(recorded[3].calls).toEqual([
+ [
+ 'EmailSubmission/set',
+ { accountId: 'd', update: { b: { undoStatus: 'canceled' } } },
+ 'c',
+ ],
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(logged(warn)).not.toContain('취소하지 못했습니다');
+ });
+
+ it('취소를 엔진이 받지 않아도 사본은 지운다 — 경고만 (best effort)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ heldSent,
+ everyoneBusy('pending'),
+ [
+ [
+ 'EmailSubmission/set',
+ { notUpdated: { b: { type: 'invalidProperties' } } },
+ 'c',
+ ],
+ ['Email/set', { destroyed: ['maaaaad'] }, 'x'],
+ ],
+ ]);
+ const warn = spyLogger(provider, 'warn');
+
+ const error = await provider
+ .sendMessage(ARA, held)
+ .catch((caught: unknown) => caught as BusinessException);
+ expect((error as BusinessException).code).toBe('MAIL_011');
+ expect((error as BusinessException).context).toMatchObject({
+ copyDiscarded: true,
+ });
+ expect(recorded).toHaveLength(4);
+ expect(logged(warn)).toContain('붙잡은 발송을 취소하지 못했습니다');
+ });
+
+ it('이미 final 이면(모두 5xx — 엔진이 붙잡아도 바로 끝낸다) 취소하지 않고 사본만 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ heldSent,
+ everyoneBusy('final'),
+ discarded,
+ ]);
+ spyLogger(provider, 'warn');
+
+ expect(await codeOf(provider.sendMessage(ARA, held))).toBe(
+ 'MAIL_011',
+ );
+ expect(recorded[3].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ });
+
+ it('붙잡지 않은 발송은 취소하지 않는다 — 상태가 pending 이어도(대기열의 잠깐)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sent,
+ everyoneBusy('pending'),
+ discarded,
+ ]);
+ spyLogger(provider, 'warn');
+
+ expect(await codeOf(provider.sendMessage(ARA, textOnly))).toBe(
+ 'MAIL_011',
+ );
+ expect(recorded[3].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ });
+ });
});
describe('서명 그림 (inlineImages — multipart/related)', () => {
@@ -3111,6 +4225,21 @@ describe('JmapMailProvider', () => {
['Email/query', { ids: list.map((email) => email.id) }, 'q'],
['Email/get', { list }, 'g'],
];
+ /** 찾은 메일의 제출 (9단계 — 보냈는지 · 붙잡고 있는지 · 취소했는지) */
+ const submissions = (list: Record[]): JmapInvocation[] => [
+ [
+ 'EmailSubmission/query',
+ { ids: list.map((submission) => submission.id) },
+ 'pq',
+ ],
+ ['EmailSubmission/get', { list }, 'pg'],
+ ];
+ const RELEASED = {
+ id: 'sub1',
+ emailId: 'sent1',
+ undoStatus: 'final',
+ sendAt: '2026-10-29T00:00:00Z',
+ };
it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다 — 받는 사람별 결과는 읽지 않는다 (지문이 맞을 때 따로)', async () => {
const { provider, recorded } = fakeClient([
@@ -3124,11 +4253,14 @@ describe('JmapMailProvider', () => {
'header:X-Ara-Payload-Hash:asText': ' v1:feedface ',
},
]),
+ submissions([RELEASED]),
]);
await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
id: 'sent1',
fingerprint: 'v1:feedface',
+ canceled: false,
+ pendingSend: null,
});
expect(argsOf(recorded[1], 0)).toEqual({
accountId: 'd',
@@ -3147,7 +4279,14 @@ describe('JmapMailProvider', () => {
'header:X-Ara-Payload-Hash:asText',
],
});
- expect(recorded).toHaveLength(2);
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ filter: { emailIds: ['sent1'] },
+ sort: [{ property: 'sentAt', isAscending: true }],
+ limit: 500,
+ calculateTotal: true,
+ });
+ expect(recorded).toHaveLength(3);
});
it('없으면 null', async () => {
@@ -3165,11 +4304,14 @@ describe('JmapMailProvider', () => {
const { provider } = fakeClient([
mailboxes,
recent([{ id: 'sent1', messageId: [MESSAGE_ID], keywords: {} }]),
+ submissions([RELEASED]),
]);
await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
id: 'sent1',
fingerprint: null,
+ canceled: false,
+ pendingSend: null,
});
});
@@ -3183,7 +4325,7 @@ describe('JmapMailProvider', () => {
keywords: { $draft: true },
},
]),
- [['EmailSubmission/query', { ids: [] }, 's']],
+ submissions([]),
[['Email/set', { destroyed: ['draft1'] }, 'x']],
]);
@@ -3193,6 +4335,9 @@ describe('JmapMailProvider', () => {
expect(argsOf(recorded[2])).toEqual({
accountId: 'd',
filter: { emailIds: ['draft1'] },
+ sort: [{ property: 'sentAt', isAscending: true }],
+ limit: 500,
+ calculateTotal: true,
});
expect(argsOf(recorded[3])).toEqual({
accountId: 'd',
@@ -3211,15 +4356,90 @@ describe('JmapMailProvider', () => {
'header:X-Ara-Payload-Hash:asText': 'v1:feedface',
},
]),
- [['EmailSubmission/query', { ids: ['sub1'] }, 's']],
+ submissions([{ ...RELEASED, emailId: 'draft1' }]),
]);
await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
id: 'draft1',
fingerprint: 'v1:feedface',
+ canceled: false,
+ pendingSend: null,
});
expect(recorded).toHaveLength(3);
});
+
+ it('취소한 발송(제출이 모두 canceled) — canceled 로 알리고 지우지 않는다 (임시보관함으로 되돌린 초안이다)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ recent([
+ {
+ id: 'sent1',
+ messageId: [MESSAGE_ID],
+ keywords: { $draft: true, '$ara-canceled': true },
+ 'header:X-Ara-Payload-Hash:asText': 'v1:feedface',
+ },
+ ]),
+ submissions([{ ...RELEASED, undoStatus: 'canceled' }]),
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'sent1',
+ fingerprint: 'v1:feedface',
+ canceled: true,
+ pendingSend: null,
+ });
+ expect(recorded).toHaveLength(3);
+ });
+
+ it('취소한 발송 표시가 있으면 제출 기록이 없어졌어도(엔진 정리 · 손으로 지움) 지우지 않고 canceled 다 — 같은 키는 MAIL_005', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ recent([
+ {
+ id: 'sent1',
+ messageId: [MESSAGE_ID],
+ keywords: { $draft: true, '$ara-canceled': true },
+ 'header:X-Ara-Payload-Hash:asText': 'v1:feedface',
+ },
+ ]),
+ submissions([]),
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'sent1',
+ fingerprint: 'v1:feedface',
+ canceled: true,
+ pendingSend: null,
+ });
+ // 조건 없는 지우기를 보내지 않았다
+ expect(recorded).toHaveLength(3);
+ });
+
+ it('붙잡고 있는 발송 — 그 보낼 시각 · 종류를 돌려준다(같은 키의 재시도가 처음 결과를 그대로 준다), 취소와 함께 있으면 보낸 것', async () => {
+ const later = new Date(Date.now() + 3_600_000).toISOString();
+ const { provider } = fakeClient([
+ mailboxes,
+ recent([
+ {
+ id: 'sent1',
+ messageId: [MESSAGE_ID],
+ keywords: { $seen: true, '$ara-scheduled': true },
+ 'header:X-Ara-Payload-Hash:asText': 'v1:feedface',
+ },
+ ]),
+ submissions([
+ { ...RELEASED, id: 'sub0', undoStatus: 'canceled' },
+ { ...RELEASED, undoStatus: 'pending', sendAt: later },
+ ]),
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'sent1',
+ fingerprint: 'v1:feedface',
+ canceled: false,
+ pendingSend: { kind: 'scheduled', sendAt: later },
+ });
+ });
});
/** 계약 2절 "받는 사람별 결과" — 같은 키의 재시도가 처음 결과를 돌려줄 때 */
@@ -3271,13 +4491,46 @@ describe('JmapMailProvider', () => {
name: 'EmailSubmission/query',
path: '/ids',
},
- properties: ['deliveryStatus'],
+ properties: ['deliveryStatus', 'envelope'],
},
'g',
],
]);
});
+ it('제출의 envelope 에 없는 받는 사람(엔진이 envelope 에서 받지 않은 주소)은 not-found — 처음 발송의 답과 같다 (9단계 리뷰 뒤)', async () => {
+ const { provider } = fakeClient([
+ [
+ ['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
+ [
+ 'EmailSubmission/get',
+ {
+ list: [
+ {
+ id: 'sb1',
+ // 붙잡은 바깥 받는 사람의 결과는 아직 없을 수 있다 — 없는 것으로 정하지 않는다
+ deliveryStatus: { [DOYUN]: QUEUED_REPLY },
+ envelope: {
+ mailFrom: { email: ARA },
+ rcptTo: [{ email: DOYUN }, { email: SEOJUN }],
+ },
+ },
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+
+ await expect(
+ provider.getRejectedRecipients(ARA, 'sent1', {
+ to: [{ name: '이도윤', email: 'DoYun.Lee@AraSystem.Test' }],
+ cc: [{ name: null, email: 'typo@example.cmo' }],
+ bcc: [{ name: null, email: SEOJUN }],
+ }),
+ ).resolves.toEqual([{ email: 'typo@example.cmo', reason: 'not-found' }]);
+ });
+
it('모두 받았으면 빈 배열', async () => {
const { provider } = fakeClient([
submissionsOf({ [DOYUN]: QUEUED_REPLY, [SEOJUN]: QUEUED_REPLY }),
@@ -3377,6 +4630,24 @@ describe('JmapMailProvider', () => {
'g',
],
],
+ // 찾은 사본의 제출 — 보낸 것(final)
+ [
+ ['EmailSubmission/query', { ids: ['b'] }, 'pq'],
+ [
+ 'EmailSubmission/get',
+ {
+ list: [
+ {
+ id: 'b',
+ emailId: 'maaaaad',
+ undoStatus: 'final',
+ sendAt: '2026-10-29T00:00:00Z',
+ },
+ ],
+ },
+ 'pg',
+ ],
+ ],
[
['EmailSubmission/query', { ids: ['b'] }, 'q'],
[
@@ -3400,7 +4671,12 @@ describe('JmapMailProvider', () => {
await expect(
provider.findSentMessage(ARA, 'ara.0123abcd@arasystem.test'),
- ).resolves.toEqual({ id: 'maaaaad', fingerprint: 'v2:feedface' });
+ ).resolves.toEqual({
+ id: 'maaaaad',
+ fingerprint: 'v2:feedface',
+ canceled: false,
+ pendingSend: null,
+ });
await expect(
provider.getRejectedRecipients(ARA, 'maaaaad', outgoing),
).resolves.toEqual([{ email: DOYUN, reason: 'not-found' }]);
@@ -4021,6 +5297,7 @@ describe('JmapMailProvider', () => {
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-27T01:00:00.000Z',
+ isCanceledSend: false,
});
expect(argsOf(recorded[0], 1)).toMatchObject({
ids: ['dr1'],
@@ -4352,6 +5629,7 @@ describe('JmapMailProvider', () => {
describe('스팸 신고 · 스팸 아님 (isJunk)', () => {
it('true 면 $junk 를 붙이고 $notjunk 를 뗀다 — 이동과 같은 패치 하나로 (한 통)', async () => {
const { provider, recorded } = fakeClient([
+ heldGuard(),
[
['Email/set', { updated: { m1: null } }, 'u'],
[
@@ -4370,7 +5648,7 @@ describe('JmapMailProvider', () => {
await provider.updateMessage(ARA, 'm1', { mailboxId: 'c', isJunk: true });
- expect(argsOf(recorded[0])).toEqual({
+ expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
update: {
m1: {
@@ -4384,6 +5662,7 @@ describe('JmapMailProvider', () => {
it('false 면 반대 — 여러 통도 메일마다 같은 패치', async () => {
const { provider, recorded } = fakeClient([
+ heldGuard(),
[
['Mailbox/get', { list: [{ id: 'a' }] }, 'mb'],
['Email/set', { updated: { m1: null, m2: null } }, 'u'],
@@ -4400,7 +5679,7 @@ describe('JmapMailProvider', () => {
'keywords/$notjunk': true,
mailboxIds: { a: true },
};
- expect(argsOf(recorded[0], 1)).toEqual({
+ expect(argsOf(recorded[1], 1)).toEqual({
accountId: 'd',
update: { m1: patch, m2: patch },
});
@@ -4446,12 +5725,13 @@ describe('JmapMailProvider', () => {
['Mailbox/get', { list }, 'm'],
];
- /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태 */
+ /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태, 같은 요청의 계정 기다리는 제출(9단계) */
type Candidate = { id: string; mailboxIds: Record };
const batch = (
emails: Candidate[],
state: string | null = 's1',
total: number = emails.length,
+ held: Record[] = [],
): JmapInvocation[] => [
['Email/query', { ids: emails.map((email) => email.id), total }, 'q'],
[
@@ -4459,6 +5739,7 @@ describe('JmapMailProvider', () => {
{ ...(state === null ? {} : { state }), list: emails, notFound: [] },
'g',
],
+ ...heldGuard(held),
];
const only = (id: string, mailboxId: string): Candidate => ({
id,
@@ -4758,7 +6039,7 @@ describe('JmapMailProvider', () => {
movedToTrash: 0,
remaining: 0,
});
- expect(recorded[1].calls).toEqual([
+ expect(recorded[1].calls.slice(0, 2)).toEqual([
[
'Email/query',
{
@@ -4779,6 +6060,13 @@ describe('JmapMailProvider', () => {
'g',
],
]);
+ // 같은 요청 끝에 계정의 기다리는 제출 — 붙잡힌 메일은 옮기지 않는다 (9단계)
+ expect(
+ recorded[1].calls.slice(2).map(([method, , id]) => [method, id]),
+ ).toEqual([
+ ['EmailSubmission/query', 'hq'],
+ ['EmailSubmission/get', 'hg'],
+ ]);
expect(recorded[2].calls).toEqual([
[
'Mailbox/set',
@@ -4788,6 +6076,66 @@ describe('JmapMailProvider', () => {
]);
});
+ it('붙잡힌 메일(9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고, 다음 요청은 409 MAIL_015 (끝없이 다시 읽지 않는다)', async () => {
+ const held = [
+ {
+ id: 'sb1',
+ emailId: 'h1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(600_000),
+ },
+ ];
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4'), only('h1', 'u4')], 's1', 2, held),
+ [['Email/set', { updated: { m1: null } }, 's']],
+ batch([only('h1', 'u4')], 's2', 1, held),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: false,
+ processed: 1,
+ movedToTrash: 1,
+ remaining: 1,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: { m1: { 'mailboxIds/u4': null, 'mailboxIds/b': true } },
+ });
+ expect(recorded).toHaveLength(4);
+
+ const next = fakeClient([
+ mailboxList(),
+ batch([only('h1', 'u4')], 's2', 1, held),
+ ]);
+ expect(await codeOf(next.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_015',
+ );
+ expect(next.recorded).toHaveLength(2);
+ });
+
+ it('읽은 메일이 있는데 기다리는 제출을 읽지 못하면 옮기지 않고 MAIL_001 (fail closed)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ [
+ ['Email/query', { ids: ['m1'], total: 1 }, 'q'],
+ [
+ 'Email/get',
+ { state: 's1', list: [only('m1', 'u4')], notFound: [] },
+ 'g',
+ ],
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ]);
+
+ expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+
it('안의 메일은 확인한 상태 그대로일 때만 옮긴다 — 이 메일함에만 있으면 휴지통으로, 다른 메일함에도 있으면 이 메일함에서만 뺀다', async () => {
const { provider, recorded } = fakeClient([
mailboxList(),
@@ -5356,6 +6704,108 @@ describe('JmapMailProvider', () => {
});
});
+ it('붙잡힌 메일(9단계 — 다른 프로그램이 휴지통으로 옮겨 둔 것)은 지우지 않는다 — 남은 것이 그것뿐이면 지운 것까지, 지운 것이 없으면 409 MAIL_015', async () => {
+ const held = [
+ {
+ id: 'sb1',
+ emailId: 'h1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(600_000),
+ },
+ ];
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b'), only('h1', 'b')], 's1', 2, held),
+ [['Email/set', { destroyed: ['t1'] }, 's']],
+ batch([only('h1', 'b')], 's2', 1, held),
+ ]);
+
+ await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 1,
+ remaining: 1,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['t1'],
+ });
+ expect(recorded).toHaveLength(4);
+
+ const next = fakeClient([
+ mailboxList(),
+ batch([only('h1', 'b')], 's2', 1, held),
+ ]);
+ expect(await codeOf(next.provider.emptyMailbox(ARA, 'b'))).toBe(
+ 'MAIL_015',
+ );
+ expect(next.recorded).toHaveLength(2);
+ });
+
+ it('계정의 기다리는 제출이 상한(500)에 닿으면 그 차례의 메일 id 로 다시 묻는다', async () => {
+ const full: JmapInvocation[] = [
+ [
+ 'EmailSubmission/query',
+ {
+ ids: Array.from({ length: 500 }, (_, i) => `s${i}`),
+ total: 1200,
+ },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list: [] }, 'hg'],
+ ];
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ [
+ ['Email/query', { ids: ['t1', 'h1'], total: 2 }, 'q'],
+ [
+ 'Email/get',
+ {
+ state: 's1',
+ list: [only('t1', 'b'), only('h1', 'b')],
+ notFound: [],
+ },
+ 'g',
+ ],
+ ...full,
+ ],
+ heldGuard([
+ {
+ id: 'sb9',
+ emailId: 'h1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(600_000),
+ },
+ ]),
+ [['Email/set', { destroyed: ['t1'] }, 's']],
+ // 다음 차례 — 남은 것은 붙잡힌 메일뿐 (이번에는 계정 전체 조회가 다 봤다)
+ batch([only('h1', 'b')], 's2', 1, [
+ {
+ id: 'sb9',
+ emailId: 'h1',
+ undoStatus: 'pending',
+ sendAt: sendTimeIn(600_000),
+ },
+ ]),
+ ]);
+
+ await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 1,
+ remaining: 1,
+ });
+ // 다시 묻기도 "보낼 시각이 지금 뒤" 조건 — 보낸 사본의 지난 제출이 상한을 채우지 않게 (9단계 리뷰 2)
+ expect(argsOf(recorded[2])).toMatchObject({
+ filter: {
+ emailIds: ['t1', 'h1'],
+ after: expect.stringMatching(/Z$/) as unknown,
+ },
+ });
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['t1'],
+ });
+ });
+
it('스팸메일함도 같은 규칙으로 비운다', async () => {
const { provider, recorded } = fakeClient([
mailboxList(),
@@ -5820,6 +7270,857 @@ describe('JmapMailProvider', () => {
expect(client.download).not.toHaveBeenCalled();
});
});
+ /**
+ * 9단계 — 붙잡은 발송(보내기 취소 · 예약)과 스팸메일함 여부 (계약 2절 "보내기 취소 · 예약 발송" · "외부 이미지 보기").
+ * 엔진 동작(HOLDFOR · HOLDUNTIL · 취소 · 숨은참조 머리글 떼기)은 2026-10-29 개발 엔진으로 확인한 모양이다
+ */
+ describe('9단계 — 붙잡은 발송 · 스팸메일함 여부', () => {
+ const inFuture = (ms: number) =>
+ new Date(Date.now() + ms).toISOString().replace(/\.\d+Z$/, 'Z');
+ const heldOf = (
+ list: Record[],
+ prefix = 'p',
+ ): JmapInvocation[] => [
+ [
+ 'EmailSubmission/query',
+ { ids: list.map((submission) => submission.id) },
+ `${prefix}q`,
+ ],
+ ['EmailSubmission/get', { list }, `${prefix}g`],
+ ];
+
+ it('목록 — 같은 요청 끝에 보낼 시각이 지금 뒤인 제출을 묻고, 붙잡힌 메일에 pendingSend(예약 표시면 scheduled)를 싣는다', async () => {
+ const soon = inFuture(10_000);
+ const later = inFuture(3_600_000);
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'],
+ [
+ 'Email/query',
+ { ids: ['m1', 'm2', 'm3'], position: 0, total: 3 },
+ 'q',
+ ],
+ [
+ 'Email/get',
+ {
+ list: [
+ summaryEmail('m1', '2026-10-29T03:00:00Z'),
+ summaryEmail('m2', '2026-10-29T02:00:00Z', {
+ keywords: { $seen: true, '$ara-scheduled': true },
+ }),
+ summaryEmail('m3', '2026-10-29T01:00:00Z'),
+ ],
+ },
+ 'g',
+ ],
+ ...heldOf([
+ { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: soon },
+ { id: 's2', emailId: 'm2', undoStatus: 'pending', sendAt: later },
+ // 배달 중인 잠깐의 pending — 보낼 시각이 지났다
+ {
+ id: 's3',
+ emailId: 'm3',
+ undoStatus: 'pending',
+ sendAt: '2026-10-29T00:00:00Z',
+ },
+ ]),
+ ],
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'e',
+ limit: 20,
+ });
+
+ expect(
+ page.items.map(({ id, pendingSend }) => [id, pendingSend]),
+ ).toEqual([
+ ['m1', { kind: 'undo', sendAt: soon.replace('Z', '.000Z') }],
+ ['m2', { kind: 'scheduled', sendAt: later.replace('Z', '.000Z') }],
+ ['m3', null],
+ ]);
+ const [, filterArgs] = recorded[0].calls[3];
+ expect(filterArgs).toMatchObject({
+ filter: { after: expect.stringMatching(/^\d{4}-.+Z$/) as string },
+ limit: 500,
+ });
+ // 목록 표시로는 내보내지 않는다 (예약 표시는 화면이 pendingSend 로 안다)
+ expect(page.items[1]).not.toHaveProperty('keywords');
+ });
+
+ it('목록 · 예약 목록 — 기다리는 제출은 보낼 시각 차례(sentAt)로 묻고, 500개에 닿으면 경고한다 (취소한 예약도 7일 안이면 든다 — 9단계 리뷰 뒤)', async () => {
+ const fullIds = Array.from({ length: 500 }, (_, index) => `s${index}`);
+ const list = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'],
+ ['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
+ ['Email/get', { list: [] }, 'g'],
+ ['EmailSubmission/query', { ids: fullIds }, 'pq'],
+ ['EmailSubmission/get', { list: [] }, 'pg'],
+ ],
+ ]);
+ const warn = jest
+ .spyOn(
+ Reflect.get(list.provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+
+ await list.provider.listMessages(ARA, { mailboxId: 'e', limit: 20 });
+ expect(list.recorded[0].calls[3][1]).toMatchObject({
+ sort: [{ property: 'sentAt', isAscending: true }],
+ });
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('엔진이 다 주지 않아'),
+ );
+
+ const scheduled = fakeClient([
+ [
+ ['EmailSubmission/query', { ids: fullIds }, 'pq'],
+ ['EmailSubmission/get', { list: [] }, 'pg'],
+ ['Email/get', { list: [] }, 'g'],
+ ],
+ ]);
+ const scheduledWarn = jest
+ .spyOn(
+ Reflect.get(scheduled.provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+ await scheduled.provider.listScheduledMessages(ARA);
+ expect(scheduledWarn).toHaveBeenCalledTimes(1);
+ });
+
+ it('목록 — 제출 읽기가 거절되면 pendingSend 없이 목록은 그대로', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'e' }], notFound: [] }, 'mb'],
+ ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
+ [
+ 'Email/get',
+ { list: [summaryEmail('m1', '2026-10-29T03:00:00Z')] },
+ 'g',
+ ],
+ ['error', { type: 'forbidden' }, 'pq'],
+ ['error', { type: 'forbidden' }, 'pg'],
+ ],
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'e',
+ limit: 20,
+ });
+ expect(page.items.map(({ pendingSend }) => pendingSend)).toEqual([null]);
+ });
+
+ it('상세 — 스팸메일함에 든 메일인지(외부 이미지 판정용) · 붙잡힌 발송을 함께 읽는다', async () => {
+ const later = inFuture(3_600_000);
+ const email = {
+ ...summaryEmail('m1', '2026-10-29T03:00:00Z'),
+ mailboxIds: { c: true },
+ bodyValues: {},
+ textBody: [],
+ htmlBody: [],
+ attachments: [],
+ };
+ const { provider } = fakeClient([
+ [
+ ['Email/get', { list: [email] }, 'g'],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ...heldOf([
+ { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later },
+ ]),
+ ],
+ ]);
+
+ const detail = await provider.getMessage(ARA, 'm1');
+ expect(detail.isInJunkMailbox).toBe(true);
+ expect(detail.pendingSend).toEqual({
+ kind: 'undo',
+ sendAt: later.replace('Z', '.000Z'),
+ });
+
+ // 메일함 목록 · 제출을 읽지 못하면 아니다 (상세는 그대로)
+ const unknown = fakeClient([
+ [
+ ['Email/get', { list: [email] }, 'g'],
+ ['error', { type: 'serverFail' }, 'm'],
+ ['error', { type: 'serverFail' }, 'pq'],
+ ['error', { type: 'serverFail' }, 'pg'],
+ ],
+ ]);
+ const plain = await unknown.provider.getMessage(ARA, 'm1');
+ expect(plain.isInJunkMailbox).toBe(false);
+ expect(plain.pendingSend).toBeNull();
+ });
+
+ describe('붙잡는 발송 (sendMessage 의 hold)', () => {
+ const prepared: JmapInvocation[] = [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ [
+ 'Identity/get',
+ { list: [{ id: 'b', name: '김아라', email: ARA }] },
+ 'i',
+ ],
+ ];
+ const sentWith = (send: Record): JmapInvocation[] => [
+ [
+ 'Email/set',
+ { created: { draft: { id: 'maaaaad', blobId: 'x', size: 100 } } },
+ 'e',
+ ],
+ ['EmailSubmission/set', { created: { send } }, 's'],
+ ['Email/set', { updated: { maaaaad: null } }, 's'],
+ ];
+ const queued: JmapInvocation[] = [
+ [
+ 'EmailSubmission/get',
+ { list: [{ id: 'sb1', deliveryStatus: null }], notFound: [] },
+ 'd',
+ ],
+ ];
+ const base: OutgoingMessage = {
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [{ name: null, email: 'DOYUN.LEE@arasystem.test' }],
+ bcc: [{ name: null, email: SEOJUN }],
+ subject: '예약 메일',
+ body: '본문',
+ attachments: [],
+ };
+
+ it('예약 — 보낸 사본에 예약 표시, 제출에 envelope(HOLDUNTIL · 받는 사람 · 참조 · 숨은참조, 겹치지 않게), 응답에 엔진이 정한 보낼 시각', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sentWith({
+ id: 'sb1',
+ sendAt: '2030-01-01T09:00:00Z',
+ undoStatus: 'pending',
+ }),
+ queued,
+ ]);
+
+ await expect(
+ provider.sendMessage(ARA, {
+ ...base,
+ hold: { kind: 'scheduled', until: '2030-01-01T09:00:00.000Z' },
+ }),
+ ).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [],
+ pendingSend: {
+ kind: 'scheduled',
+ sendAt: '2030-01-01T09:00:00.000Z',
+ },
+ });
+
+ const draft = (argsOf(recorded[1], 0).create as Record)
+ .draft as { keywords: Record };
+ expect(draft.keywords).toEqual({
+ $draft: true,
+ $seen: true,
+ '$ara-sending': true,
+ '$ara-scheduled': true,
+ });
+ const submission = argsOf(recorded[1], 1) as {
+ create: { send: Record };
+ onSuccessUpdateEmail: Record;
+ };
+ expect(submission.create.send).toEqual({
+ identityId: 'b',
+ emailId: '#draft',
+ envelope: {
+ mailFrom: {
+ email: ARA,
+ parameters: { HOLDUNTIL: '2030-01-01T09:00:00Z' },
+ },
+ rcptTo: [
+ { email: DOYUN, parameters: null },
+ { email: SEOJUN, parameters: null },
+ ],
+ },
+ });
+ // 붙잡아도 보낸편지함으로 옮기는 것은 같다 (예약 표시는 남는다)
+ expect(submission.onSuccessUpdateEmail).toEqual({
+ '#send': {
+ 'mailboxIds/d': null,
+ 'mailboxIds/e': true,
+ 'keywords/$draft': null,
+ 'keywords/$ara-sending': null,
+ },
+ });
+ });
+
+ it('보내기 취소 — HOLDFOR 초, 예약 표시 없음. 엔진이 이미 보냈다(final)고 답하면 pendingSend null', async () => {
+ const held = fakeClient([
+ prepared,
+ sentWith({
+ id: 'sb1',
+ sendAt: inFuture(10_000),
+ undoStatus: 'pending',
+ }),
+ queued,
+ ]);
+ const result = await held.provider.sendMessage(ARA, {
+ ...base,
+ hold: { kind: 'undo', seconds: 10 },
+ });
+ expect(result.pendingSend).toMatchObject({ kind: 'undo' });
+ const send = (
+ argsOf(held.recorded[1], 1) as {
+ create: { send: { envelope: { mailFrom: unknown } } };
+ }
+ ).create.send;
+ expect(send.envelope.mailFrom).toEqual({
+ email: ARA,
+ parameters: { HOLDFOR: '10' },
+ });
+ const draft = (
+ argsOf(held.recorded[1], 0).create as Record<
+ string,
+ { keywords: Record }
+ >
+ ).draft;
+ expect(draft.keywords).not.toHaveProperty('$ara-scheduled');
+
+ const released = fakeClient([
+ prepared,
+ sentWith({ id: 'sb1', undoStatus: 'final' }),
+ queued,
+ ]);
+ await expect(
+ released.provider.sendMessage(ARA, {
+ ...base,
+ hold: { kind: 'undo', seconds: 10 },
+ }),
+ ).resolves.toMatchObject({ pendingSend: null });
+ });
+
+ it('붙잡지 않는 발송도 envelope 을 직접 준다 — MAIL FROM 매개변수 없이 (9단계 리뷰 뒤 — 엔진이 받지 않는 주소를 알리게, envelope.ts)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sentWith({ id: 'sb1' }),
+ queued,
+ ]);
+
+ await provider.sendMessage(ARA, base);
+ const send = (
+ argsOf(recorded[1], 1) as {
+ create: { send: Record };
+ }
+ ).create.send;
+ expect(send).toEqual({
+ identityId: 'b',
+ emailId: '#draft',
+ envelope: {
+ mailFrom: { email: ARA, parameters: null },
+ rcptTo: [
+ { email: DOYUN, parameters: null },
+ { email: SEOJUN, parameters: null },
+ ],
+ },
+ });
+ });
+
+ it('엔진이 붙잡을 시각을 받지 않으면(forbiddenMailFrom) 만든 초안을 지우고 400 VAL_001', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ [
+ [
+ 'Email/set',
+ { created: { draft: { id: 'maaaaad', blobId: 'x', size: 1 } } },
+ 'e',
+ ],
+ [
+ 'EmailSubmission/set',
+ { notCreated: { send: { type: 'forbiddenMailFrom' } } },
+ 's',
+ ],
+ ],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+
+ const failure = await provider
+ .sendMessage(ARA, {
+ ...base,
+ hold: { kind: 'scheduled', until: '2030-01-01T09:00:00.000Z' },
+ })
+ .catch((caught: unknown) => caught as BusinessException);
+ expect(failure).toBeInstanceOf(BusinessException);
+ expect((failure as BusinessException).code).toBe('VAL_001');
+ expect((failure as BusinessException).context).toMatchObject({
+ reason: 'send-hold-refused-by-engine',
+ hold: 'scheduled',
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ destroy: ['maaaaad'],
+ });
+ });
+ });
+
+ describe('예약한 메일 (listScheduledMessages)', () => {
+ it('보낼 시각이 지금 뒤인 제출 → 그 메일(#ids /list/*/emailId) — 예약 표시가 있는 것만, 보낼 시각 · id 차례', async () => {
+ const a = inFuture(7_200_000);
+ const b = inFuture(3_600_000);
+ const scheduled = { $seen: true, '$ara-scheduled': true };
+ const { provider, recorded } = fakeClient([
+ [
+ ...heldOf([
+ { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: a },
+ { id: 's2', emailId: 'm2', undoStatus: 'pending', sendAt: b },
+ { id: 's3', emailId: 'm3', undoStatus: 'pending', sendAt: b },
+ // 보내기 취소를 기다리는 메일은 들지 않는다
+ {
+ id: 's4',
+ emailId: 'm4',
+ undoStatus: 'pending',
+ sendAt: inFuture(5_000),
+ },
+ // 취소한 것 · 보낸 것은 들지 않는다
+ { id: 's5', emailId: 'm5', undoStatus: 'canceled', sendAt: a },
+ ]),
+ [
+ 'Email/get',
+ {
+ list: [
+ summaryEmail('m1', '2026-10-29T01:00:00Z', {
+ keywords: scheduled,
+ }),
+ summaryEmail('m3', '2026-10-29T01:00:00Z', {
+ keywords: scheduled,
+ }),
+ summaryEmail('m2', '2026-10-29T01:00:00Z', {
+ keywords: scheduled,
+ }),
+ summaryEmail('m4', '2026-10-29T01:00:00Z'),
+ summaryEmail('m5', '2026-10-29T01:00:00Z', {
+ keywords: scheduled,
+ }),
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+
+ const list = await provider.listScheduledMessages(ARA);
+
+ expect(list.map(({ id }) => id)).toEqual(['m2', 'm3', 'm1']);
+ expect(list[0].pendingSend).toEqual({
+ kind: 'scheduled',
+ sendAt: b.replace('Z', '.000Z'),
+ });
+ expect(recorded[0].calls[2][1]).toMatchObject({
+ '#ids': {
+ resultOf: 'pg',
+ name: 'EmailSubmission/get',
+ path: '/list/*/emailId',
+ },
+ });
+ });
+ });
+
+ describe('붙잡은 발송 취소 (cancelPendingSend)', () => {
+ const lookup = (
+ submissions: Record[],
+ keywords: Record = { $seen: true },
+ mailboxIds: Record = { e: true },
+ ): JmapInvocation[] => [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [{ id: 'm1', mailboxIds, keywords }] }, 'g'],
+ ...heldOf(submissions),
+ ];
+ const later = () => inFuture(3_600_000);
+
+ it('취소한 뒤 다른 요청으로 임시보관함의 초안으로 — $draft · 취소 표시를 붙이고 예약 · 발송 중 표시를 뗀다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(
+ [
+ {
+ id: 's1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: later(),
+ },
+ ],
+ { $seen: true, '$ara-scheduled': true },
+ ),
+ [['EmailSubmission/set', { updated: { s1: null } }, 'x']],
+ [['Email/set', { updated: { m1: null } }, 'u']],
+ ]);
+ const log = jest
+ .spyOn(Reflect.get(provider, 'logger') as { log: () => void }, 'log')
+ .mockImplementation(() => undefined);
+
+ await expect(
+ provider.cancelPendingSend(ARA, 'm1'),
+ ).resolves.toBeUndefined();
+
+ expect(recorded).toHaveLength(3);
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ update: { s1: { undoStatus: 'canceled' } },
+ });
+ expect(recorded[1].calls).toHaveLength(1);
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ update: {
+ m1: {
+ mailboxIds: { d: true },
+ 'keywords/$draft': true,
+ 'keywords/$ara-canceled': true,
+ 'keywords/$ara-scheduled': null,
+ 'keywords/$ara-sending': null,
+ },
+ },
+ });
+ expect(log).toHaveBeenCalledTimes(1);
+ });
+
+ it('붙잡은 제출이 없으면(보낸 제출(final)이 있음 · 배달 중 · 제출이 없는 메일) MAIL_014 — 아무것도 바꾸지 않는다', async () => {
+ for (const submissions of [
+ [],
+ [
+ {
+ id: 's1',
+ emailId: 'm1',
+ undoStatus: 'final',
+ sendAt: '2026-10-29T00:00:00Z',
+ },
+ ],
+ [
+ {
+ id: 's1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: '2026-10-29T00:00:00Z',
+ },
+ ],
+ // 취소한 것과 보낸 것이 함께 — 보낸 메일이다
+ [
+ {
+ id: 's0',
+ emailId: 'm1',
+ undoStatus: 'canceled',
+ sendAt: later(),
+ },
+ {
+ id: 's1',
+ emailId: 'm1',
+ undoStatus: 'final',
+ sendAt: '2026-10-29T00:00:00Z',
+ },
+ ],
+ ]) {
+ const { provider, recorded } = fakeClient([lookup(submissions)]);
+ expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe(
+ 'MAIL_014',
+ );
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ describe('이미 취소한 발송을 다시 취소 — 같은 요청을 다시 보내도 같은 결과 (9단계 리뷰 뒤)', () => {
+ const canceled = [
+ { id: 's1', emailId: 'm1', undoStatus: 'canceled', sendAt: later() },
+ ];
+
+ it('되돌리기까지 마쳤으면(임시보관함 · 취소 표시) 아무것도 하지 않고 성공 — 응답을 받지 못한 재시도', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(
+ canceled,
+ { $draft: true, $seen: true, '$ara-canceled': true },
+ { d: true },
+ ),
+ ]);
+
+ await expect(
+ provider.cancelPendingSend(ARA, 'm1'),
+ ).resolves.toBeUndefined();
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('되돌리기를 마친 뒤 사용자가 휴지통 · 다른 메일함으로 옮겼으면 그대로 성공 — 옮기지 않는다 (9단계 리뷰 2 — 늦게 온 취소가 버린 초안을 되살리지 않게)', async () => {
+ const places: Record[] = [{ b: true }, { u4: true }];
+ for (const mailboxIds of places) {
+ const { provider, recorded } = fakeClient([
+ lookup(
+ canceled,
+ { $draft: true, $seen: true, '$ara-canceled': true },
+ mailboxIds,
+ ),
+ ]);
+
+ await expect(
+ provider.cancelPendingSend(ARA, 'm1'),
+ ).resolves.toBeUndefined();
+ // 읽기만 했다 — 옮기는 요청이 없다
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('취소는 됐는데 보낸편지함에 남아 있으면(옮기지 못한 취소) 되돌리기만 다시 한다 — 엔진에 취소를 다시 보내지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(canceled, { $seen: true, '$ara-scheduled': true }),
+ [['Email/set', { updated: { m1: null } }, 'u']],
+ ]);
+ jest
+ .spyOn(
+ Reflect.get(provider, 'logger') as { log: () => void },
+ 'log',
+ )
+ .mockImplementation(() => undefined);
+
+ await expect(
+ provider.cancelPendingSend(ARA, 'm1'),
+ ).resolves.toBeUndefined();
+ expect(recorded).toHaveLength(2);
+ expect(recorded[1].calls[0][0]).toBe('Email/set');
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ update: {
+ m1: {
+ mailboxIds: { d: true },
+ 'keywords/$draft': true,
+ 'keywords/$ara-canceled': true,
+ 'keywords/$ara-scheduled': null,
+ 'keywords/$ara-sending': null,
+ },
+ },
+ });
+ });
+
+ it('되돌리기를 다시 해도 못 하면 MAIL_001 — 한 번 더 한 뒤', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(canceled),
+ [['error', { type: 'serverFail' }, 'u']],
+ [['error', { type: 'serverFail' }, 'u']],
+ ]);
+ jest
+ .spyOn(
+ Reflect.get(provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+
+ expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(3);
+ });
+ });
+
+ it('엔진이 거절하지도 확인하지도 않으면(updated 에 그 제출이 없음) MAIL_001 — 옮기지 않는다 (9단계 리뷰 2 — 보낼 메일이 "취소한 초안"이 되지 않게)', async () => {
+ const pending = (id: string): Record => ({
+ id,
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: later(),
+ });
+ const cases: [Record[], Record][] = [
+ [[pending('s1')], {}],
+ [[pending('s1')], { updated: {} }],
+ // 둘 가운데 하나만 확인했다
+ [[pending('s1'), pending('s2')], { updated: { s1: null } }],
+ ];
+ for (const [submissions, answer] of cases) {
+ const { provider, recorded } = fakeClient([
+ lookup(submissions),
+ [['EmailSubmission/set', answer, 'x']],
+ ]);
+
+ const error = await provider
+ .cancelPendingSend(ARA, 'm1')
+ .catch((caught: unknown) => caught as BusinessException);
+ expect((error as BusinessException).code).toBe('MAIL_001');
+ expect((error as BusinessException).context).toMatchObject({
+ reason: 'cancel-not-confirmed',
+ });
+ // 취소 요청까지만 — 임시보관함으로 옮기지 않았다
+ expect(recorded).toHaveLength(2);
+ }
+ });
+
+ it('엔진이 취소를 받지 않으면(그사이 풀림) MAIL_014 — 옮기지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([
+ { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later() },
+ ]),
+ [
+ [
+ 'EmailSubmission/set',
+ { notUpdated: { s1: { type: 'cannotUnsend' } } },
+ 'x',
+ ],
+ ],
+ ]);
+
+ expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe(
+ 'MAIL_014',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('없는 메일은 RES_001', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [], notFound: ['m1'] }, 'g'],
+ ...heldOf([]),
+ ],
+ ]);
+ expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe(
+ 'RES_001',
+ );
+ });
+
+ it('취소는 됐는데 옮기지 못하면 한 번 더, 그래도 못 하면 MAIL_001 (warn)', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([
+ { id: 's1', emailId: 'm1', undoStatus: 'pending', sendAt: later() },
+ ]),
+ [['EmailSubmission/set', { updated: { s1: null } }, 'x']],
+ [['Email/set', { notUpdated: { m1: { type: 'serverFail' } } }, 'u']],
+ [['error', { type: 'serverFail' }, 'u']],
+ ]);
+ const warn = jest
+ .spyOn(
+ Reflect.get(provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+
+ expect(await codeOf(provider.cancelPendingSend(ARA, 'm1'))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(4);
+ expect(warn).toHaveBeenCalled();
+ });
+ });
+
+ describe('취소해 되돌린 발송을 이어 쓴다 (getDraft)', () => {
+ const SIGNATURE_CID =
+ 'sig-0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f@arasystem.test';
+ const canceled = {
+ id: 'm1',
+ threadId: 't1',
+ mailboxIds: { d: true },
+ keywords: { $draft: true, $seen: true, '$ara-canceled': true },
+ // 발송의 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자의 초안이다
+ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface',
+ from: [{ name: '김아라', email: ARA }],
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [],
+ bcc: [],
+ subject: 'Re: 견적',
+ receivedAt: '2026-10-29T03:00:00Z',
+ inReplyTo: ['orig@arasystem.test'],
+ bodyValues: { '1': { value: '본문', isTruncated: false } },
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [{ partId: '1', type: 'text/plain' }],
+ attachments: [
+ {
+ partId: '2',
+ blobId: 'blob-sig',
+ name: 'logo.png',
+ type: 'image/png',
+ size: 512,
+ disposition: 'inline',
+ cid: SIGNATURE_CID,
+ },
+ {
+ partId: '3',
+ blobId: 'blob-xlsx',
+ name: '견적.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ size: 2048,
+ disposition: 'attachment',
+ cid: null,
+ },
+ ],
+ };
+
+ it('서명 그림(cid sig-…)은 첨부로 내보내지 않고, 답장 대상은 같은 스레드에서 In-Reply-To 로 찾는다', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [canceled] }, 'g'],
+ ],
+ [
+ [
+ 'Thread/get',
+ { list: [{ id: 't1', emailIds: ['orig', 'm1'] }] },
+ 't',
+ ],
+ [
+ 'Email/get',
+ {
+ list: [
+ { id: 'orig', messageId: ['orig@arasystem.test'] },
+ { id: 'm1', messageId: ['ara.1@arasystem.test'] },
+ ],
+ },
+ 'r',
+ ],
+ ],
+ ]);
+
+ const draft = await provider.getDraft(ARA, 'm1');
+
+ expect(draft.attachments.map(({ id }) => id)).toEqual(['blob-xlsx']);
+ expect(draft.inReplyTo).toBe('orig');
+ // 서비스가 본문의 cid:sig-… 를 서명 그림 주소로 되돌리게 알린다
+ expect(draft.isCanceledSend).toBe(true);
+ expect(recorded[1].calls.map(([method]) => method)).toEqual([
+ 'Thread/get',
+ 'Email/get',
+ ]);
+ });
+
+ it('취소 표시가 없는 초안의 sig-… 부분(다른 프로그램이 남의 메일을 전달 · 인용한 것)은 지금처럼 첨부다 — 스레드도 찾지 않는다 (9단계 리뷰 뒤)', async () => {
+ const otherClient = {
+ ...canceled,
+ keywords: { $draft: true, $seen: true },
+ 'header:X-Ara-Payload-Hash:asText': null,
+ };
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [otherClient] }, 'g'],
+ ],
+ ]);
+
+ const draft = await provider.getDraft(ARA, 'm1');
+
+ expect(draft.attachments.map(({ id }) => id)).toEqual([
+ 'blob-sig',
+ 'blob-xlsx',
+ ]);
+ expect(draft.isCanceledSend).toBe(false);
+ expect(draft.inReplyTo).toBeNull();
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('취소 표시가 없는 발송 사본(지문 머리글이 있는 메일)은 지금처럼 초안이 아니다 — RES_001', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ [
+ 'Email/get',
+ {
+ list: [
+ { ...canceled, keywords: { $draft: true, $seen: true } },
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+ expect(await codeOf(provider.getDraft(ARA, 'm1'))).toBe('RES_001');
+ });
+ });
+ });
});
describe('buildThreadHeaders', () => {
diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
index bec65c3..a8d4c23 100644
--- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts
+++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
@@ -5,20 +5,25 @@ import { openAsBlob } from 'fs';
import { BusinessException } from '@/common/errors/business.exception';
import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
+import { parseSignatureImageContentId } from '@/modules/mail/html/signature-images';
import {
MAIL_ID_PATTERN,
MAX_BODY_VALUE_BYTES,
+ MAX_ENVELOPE_RESUBMITS,
} from '@/modules/mail/mail.constants';
import type { MailProvider } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
AttachmentContent,
+ BlockedSender,
Correspondence,
DraftContent,
DraftSource,
MailAccountProfile,
MailAddress,
MailAttachment,
+ MailBlockAdded,
+ MailBlockList,
Mailbox,
MailboxBulkResult,
MailboxCreate,
@@ -50,6 +55,7 @@ import type {
RecipientFields,
RejectedRecipient,
SavedDraft,
+ SendHold,
SentMessage,
SentMessageRecord,
UploadedFile,
@@ -65,13 +71,20 @@ import {
type JmapSession,
} from './jmap.client';
import {
- DELIVERED_TO_ALL,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
+ submittedEnvelope,
toRejectedRecipients,
type DeliveryResult,
} from './delivery-status';
+import {
+ addressNamedIn,
+ envelopeKey,
+ envelopeRecipients,
+ isEnvelopeRefusal,
+ submissionEnvelope,
+} from './envelope';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
@@ -92,6 +105,7 @@ import {
import {
describeFailure,
isMissingAttachmentBlob,
+ isRequestRefused,
jmapFailure,
} from './jmap.errors';
import { BulkRun, CONDITIONAL_DESTROY_ATTEMPTS } from './bulk-run';
@@ -129,6 +143,26 @@ import type {
JmapSetResult,
} from './jmap.types';
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
+import {
+ CANCELED_SEND_KEYWORD,
+ HELD_SUBMISSIONS_MAX,
+ SCHEDULED_SEND_KEYWORD,
+ heldAmong,
+ heldEmailsFilter,
+ heldOverLimit,
+ heldQueryFull,
+ heldSendTimes,
+ heldSubmissionFilter,
+ holdParameters,
+ isHeld,
+ pendingSendOf,
+ readHeldEmails,
+ readHeldSendTimes,
+ submissionCalls,
+ submittedPendingSend,
+ type HeldEmails,
+ type HeldLookup,
+} from './pending-send';
import { searchTextConditions, withSearchConditions } from './search-terms';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
@@ -166,12 +200,16 @@ const DRAFT_CHECK_PROPERTIES = [
FINGERPRINT_PROPERTY,
];
-/** 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만) */
+/**
+ * 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만).
+ * In-Reply-To 는 취소해 되돌린 발송(9단계)의 답장 대상을 스레드에서 찾을 때 쓴다 — 보낸 메일에는 답장 대상 머리글(X-Ara-Reply-To-Id)이 없다
+ */
const DRAFT_DETAIL_PROPERTIES = [
...new Set([
...DETAIL_PROPERTIES,
...DRAFT_CHECK_PROPERTIES,
REPLY_TO_ID_PROPERTY,
+ 'inReplyTo',
]),
];
@@ -226,6 +264,11 @@ const SEND_RESULT_TIMEOUT_MS = 5_000;
const SEND_RESULT_MIN_WINDOW_MS = 1_000;
/** 받는 사람이 모두 거절했을 때 보낸 사본을 지울 시간 — 결과 읽기가 발송 전체 시간에서 이만큼 남겨 둔다 */
const SEND_DISCARD_RESERVE_MS = 3_000;
+/**
+ * 발송이 만든 메일을 지울 때(보내지 못한 초안 · 모두 거절된 사본) 남은 시간이 이보다 적어도 이만큼은 기다린다 — 지우기는 가벼운 요청 하나다.
+ * 발송 응답은 SEND_TOTAL_BUDGET_MS + 이만큼(56초)을 넘지 않는다 — 클라이언트의 발송 한도(60초) 안 (9단계 리뷰 2)
+ */
+const SEND_CLEANUP_MIN_MS = 1_000;
/** 사용자 초안을 조건부로 지운 결과 */
type DraftDestroyOutcome =
@@ -259,6 +302,24 @@ const MAX_REFERENCES = 20;
/** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */
const DOWNLOAD_TYPE = 'application/octet-stream';
+/** 기다리는 발송(9단계)을 묻는 호출 id 의 앞글자 — `pq`(EmailSubmission/query) · `pg`(EmailSubmission/get) */
+const HELD_CALLS = 'p';
+/**
+ * 붙잡힌 메일을 막는 확인(옮기기 · 지우기 — 9단계 리뷰 뒤)의 호출 id 앞글자 — `hq` · `hg`. 보이기 경로(HELD_CALLS)와 달리 읽지 못하면 바꾸지 않는다
+ * (pending-send.ts 의 readHeldEmails — fail closed)
+ */
+const HELD_GUARD_CALLS = 'h';
+
+/** 제출의 상태 — 붙잡고 있다 (pending-send.ts) */
+const UNDO_PENDING = 'pending';
+const UNDO_CANCELED = 'canceled';
+
+/** 엔진이 붙잡을 시각을 받지 않을 때의 SetError — Stalwart 는 FUTURERELEASE 한도(7일)를 넘으면 이것으로 거절한다 (2026-10-29 확인) */
+const FORBIDDEN_MAIL_FROM = 'forbiddenMailFrom';
+
+/** 붙잡은 발송을 취소한 뒤 그 메일을 임시보관함으로 옮기는 횟수 — 실패하면 한 번 더 (계약 2절 "보내기 취소 · 예약 발송") */
+const RETURN_TO_DRAFTS_ATTEMPTS = 2;
+
function toJmapAddress(address: MailAddress): {
name: string | null;
email: string;
@@ -273,11 +334,6 @@ function roleMailboxId(
return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id;
}
-/**
- * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것.
- * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다
- * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다
- */
/** 메일이 들어 있는 메일함 id */
function inMailboxes(email: JmapEmail): string[] {
return Object.entries(email.mailboxIds ?? {})
@@ -285,17 +341,25 @@ function inMailboxes(email: JmapEmail): string[] {
.map(([mailboxId]) => mailboxId);
}
+/**
+ * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것.
+ * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다
+ * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다
+ * - 취소해 되돌린 발송(9단계 — CANCELED_SEND_KEYWORD)은 지문 헤더가 남아 있어도 사용자 초안이다 (메일은 고칠 수 없다 — pending-send.ts)
+ */
function isUserDraft(email: JmapEmail, draftsId: string): boolean {
const fingerprint = (email as unknown as Record)[
FINGERPRINT_PROPERTY
];
const mailboxes = inMailboxes(email);
+ const isCanceledSend = email.keywords?.[CANCELED_SEND_KEYWORD] === true;
return (
mailboxes.length === 1 &&
mailboxes[0] === draftsId &&
email.keywords?.$draft === true &&
email.keywords?.[INTERNAL_SEND_KEYWORD] !== true &&
- !(typeof fingerprint === 'string' && fingerprint.trim() !== '')
+ (isCanceledSend ||
+ !(typeof fingerprint === 'string' && fingerprint.trim() !== ''))
);
}
@@ -322,6 +386,21 @@ function toEmailPatch(update: MessageUpdate): Record {
return patch;
}
+/**
+ * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)을 옮기거나 지우려 했다 — 409 MAIL_015, 아무것도 바꾸지 않았다.
+ * 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 — 옮기거나 지워도 엔진은 시간이 되면 보낸다 (계약 2절 "보내기 취소 · 예약 발송")
+ */
+function heldSendLocked(action: string): BusinessException {
+ return new BusinessException('MAIL_015', {
+ context: { reason: 'held-send-locked', action },
+ });
+}
+
+/** 메일 하나를 옮기거나 스팸으로 바꾸는 변경인가 — 붙잡힌 메일에는 하지 않는다(읽음 · 중요 표시는 된다) */
+function movesMessage(update: MessageUpdate): boolean {
+ return update.mailboxId !== undefined || update.isJunk !== undefined;
+}
+
/** 메일함에서 메일을 지우는 방법 — 영구 삭제 · 그 메일함에서만 빼기 */
type MailboxRemoval = 'destroy' | 'detach';
@@ -618,6 +697,58 @@ function bodyProperties(
};
}
+/** 발송 제출을 만들 재료 — 처음 제출과 envelope 을 고쳐 다시 내는 제출이 같다 (sendMessage · finishSubmission) */
+interface SubmissionPlan {
+ readonly identityId: string;
+ /** envelope 의 보내는 사람 — 발신 ID 의 주소 */
+ readonly from: string;
+ /** MAIL FROM 매개변수 — 붙잡을 때만 (HOLDFOR · HOLDUNTIL) */
+ readonly parameters: Readonly> | null;
+ readonly draftsId: string;
+ readonly sentId: string;
+}
+
+/** 엔진이 만든 제출 — id · 보낼 시각 · 상태 (EmailSubmission/set 의 created) */
+interface CreatedSubmission {
+ readonly id?: string;
+ readonly sendAt?: string | null;
+ readonly undoStatus?: string | null;
+}
+
+/**
+ * 제출 하나(EmailSubmission/set create, 호출 id 's') — 늘 envelope 을 직접 준다. 성공하면 초안 → 보낸편지함으로 옮기고 발송 중 표시를 뗀다
+ * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때)
+ */
+function submitCall(
+ accountId: string,
+ plan: SubmissionPlan,
+ emailId: string,
+ rcptTo: readonly string[],
+): JmapInvocation {
+ return [
+ 'EmailSubmission/set',
+ {
+ accountId,
+ create: {
+ send: {
+ identityId: plan.identityId,
+ emailId,
+ envelope: submissionEnvelope(plan.from, rcptTo, plan.parameters),
+ },
+ },
+ onSuccessUpdateEmail: {
+ '#send': {
+ [`mailboxIds/${plan.draftsId}`]: null,
+ [`mailboxIds/${plan.sentId}`]: true,
+ 'keywords/$draft': null,
+ [`keywords/${INTERNAL_SEND_KEYWORD}`]: null,
+ },
+ },
+ },
+ 's',
+ ];
+}
+
/** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */
export function buildThreadHeaders(
original: Pick | undefined,
@@ -796,7 +927,9 @@ export class JmapMailProvider implements MailProvider {
* 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다).
* 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다
* 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다).
- * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다)
+ * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다).
+ * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고(남은 수에 든다),
+ * 옮긴 것이 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게 — 클라이언트의 되풀이도 끝난다). 차례마다 제출을 읽지 못하면 MAIL_001
*/
async deleteMailbox(user: string, id: string): Promise {
const run = new BulkRun();
@@ -849,15 +982,28 @@ export class JmapMailProvider implements MailProvider {
});
}
+ // 붙잡힌 메일(9단계)은 그대로 둔다 — 취소로만 보낸편지함을 떠난다
+ const held = await heldAmong(
+ batch.held,
+ batch.emails.map((email) => email.id),
+ this.heldLookup(user, run.callOptions()),
+ );
const toTrash: string[] = [];
const detach: string[] = [];
for (const email of batch.emails) {
+ if (held.has(email.id)) continue;
if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') {
toTrash.push(email.id);
} else {
detach.push(email.id);
}
}
+ if (toTrash.length === 0 && detach.length === 0) {
+ // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 옮긴 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게)
+ this.warnHeldSkipped(user, held.size, '지우는 메일함에서 옮기지');
+ if (run.processed > 0) break;
+ throw heldSendLocked('mailbox-delete');
+ }
const set = await this.conditionalEmailSet(
user,
batch.state,
@@ -998,6 +1144,7 @@ export class JmapMailProvider implements MailProvider {
* 다른 메일함에도 있으면 그 메일함에서만 뺀다. 어긋나면 다시 읽는다. 한 요청의 시간 · 멈춘 차례는 BulkRun —
* 이 요청에서 지운 것이 없이 멈추면 MAIL_001 (지우지 않았다). 남은 수를 돌려준다.
* 없는 메일함은 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
+ * 붙잡힌 메일(9단계 — 다른 프로그램이 옮겨 둔 것)은 지우지 않는다 — 남은 것이 그것뿐이면 이 요청에서 지운 것까지로 답하고, 지운 것이 없으면 MAIL_015
*/
async emptyMailbox(user: string, id: string): Promise {
const run = new BulkRun();
@@ -1031,15 +1178,28 @@ export class JmapMailProvider implements MailProvider {
});
}
+ // 붙잡힌 메일(9단계)은 지우지 않는다 — 취소로만 보낸편지함을 떠난다
+ const held = await heldAmong(
+ batch.held,
+ batch.emails.map((email) => email.id),
+ this.heldLookup(user, run.callOptions()),
+ );
const destroy: string[] = [];
const detach: string[] = [];
for (const email of batch.emails) {
+ if (held.has(email.id)) continue;
if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') {
destroy.push(email.id);
} else {
detach.push(email.id);
}
}
+ if (destroy.length === 0 && detach.length === 0) {
+ // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 지운 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게)
+ this.warnHeldSkipped(user, held.size, '메일함을 비우며 지우지');
+ if (run.processed > 0) break;
+ throw heldSendLocked('mailbox-empty');
+ }
const set = await this.conditionalEmailSet(
user,
batch.state,
@@ -1111,6 +1271,21 @@ export class JmapMailProvider implements MailProvider {
return this.filterScripts.setFilters(user, update);
}
+ /** 차단한 보낸 사람 — 필터 · 부재중과 같은 스크립트에 둔다 (계약 2절 "보낸 사람 차단" — jmap-filter-script.ts) */
+ getMailBlocks(user: string): Promise {
+ return this.filterScripts.getBlocks(user);
+ }
+
+ /** 차단을 더한다 — 이미 있으면 그대로(created: false). 500개면 VAL_001, 만든 스크립트가 한도를 넘으면 MAIL_013 */
+ addMailBlock(user: string, sender: BlockedSender): Promise {
+ return this.filterScripts.addBlock(user, sender);
+ }
+
+ /** 차단을 푼다 — 없는 주소면 RES_001 */
+ removeMailBlock(user: string, address: string): Promise {
+ return this.filterScripts.removeBlock(user, address);
+ }
+
/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 차례 · 판정 · 이어 할 자리는 JmapFilterRunner (계약 2절 "이미 받은 메일에 적용") */
runMailFilter(
user: string,
@@ -1213,19 +1388,26 @@ export class JmapMailProvider implements MailProvider {
textConditions,
);
+ // 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts)
+ const now = Date.now();
let responses = await this.queryMessages(
user,
query,
filter,
resume ? { anchor: resume.anchor } : { position: 0 },
+ now,
);
// 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
const first = findResult(responses, 'q', 'Email/query');
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
- responses = await this.queryMessages(user, query, filter, {
- position: resume.position,
- });
+ responses = await this.queryMessages(
+ user,
+ query,
+ filter,
+ { position: resume.position },
+ now,
+ );
}
if (query.mailboxId !== undefined) {
@@ -1252,12 +1434,14 @@ export class JmapMailProvider implements MailProvider {
const ids = result.ids ?? [];
const start = result.position ?? 0;
const total = typeof result.total === 'number' ? result.total : null;
+ const held = readHeldSendTimes(responses, HELD_CALLS, now);
+ this.warnIfHeldQueryFull(responses, user);
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
const byId = new Map(list.map((email) => [email.id, email]));
const items = ids.flatMap((id) => {
const email = byId.get(id);
- return email ? [toSummary(email)] : [];
+ return email ? [toSummary(email, pendingSendOf(email, held))] : [];
});
const hasNext =
@@ -1278,7 +1462,12 @@ export class JmapMailProvider implements MailProvider {
};
}
+ /**
+ * 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다.
+ * 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다
+ */
async getMessage(user: string, id: string): Promise {
+ const now = Date.now();
const responses = await this.jmap.call(
user,
(accountId) => [
@@ -1297,19 +1486,58 @@ export class JmapMailProvider implements MailProvider {
},
'g',
],
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: ['id', 'role'] },
+ 'm',
+ ],
+ ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS),
],
{ invalidRequestCode: 'RES_001' },
);
- return toDetailSource(this.requireEmail(responses, 'g'));
+ const email = this.requireEmail(responses, 'g');
+ return toDetailSource(email, {
+ pendingSend: pendingSendOf(
+ email,
+ readHeldSendTimes(responses, HELD_CALLS, now),
+ ),
+ isInJunkMailbox: this.isInJunk(responses, email),
+ });
}
+ /** 같은 요청의 메일함 목록으로 본 스팸메일함에 든 메일인가 — 목록을 읽지 못하면 아니다 */
+ private isInJunk(responses: JmapInvocation[], email: JmapEmail): boolean {
+ let mailboxes: JmapMailbox[];
+ try {
+ const result = findResult(responses, 'm', 'Mailbox/get');
+ if (!result.ok) return false;
+ mailboxes = (result.args as JmapGetResult).list ?? [];
+ } catch {
+ return false;
+ }
+ const junkId = roleMailboxId(mailboxes, 'junk');
+ return junkId !== undefined && inMailboxes(email).includes(junkId);
+ }
+
+ /**
+ * 한 통 바꾸기 — 옮기기 · 스팸 표시(mailboxId · isJunk)는 먼저 붙잡힌 메일인지 묻는다(메일 id 조건 — 요청 하나 더): 붙잡혔으면 MAIL_015,
+ * 확인하지 못하면(읽지 못함 · 다 받았는지 모름) MAIL_001 — 어느 쪽이든 바꾸지 않는다. 읽음 · 중요 표시만이면 묻지 않는다
+ */
async updateMessage(
user: string,
id: string,
update: MessageUpdate,
): Promise {
+ if (movesMessage(update)) {
+ const held = await this.heldByEmailIds(user, [id], {
+ invalidRequestCode: 'RES_001',
+ });
+ if (held.ids.size > 0) throw heldSendLocked('update');
+ if (!held.complete) throw heldOverLimit(1);
+ }
const patch = toEmailPatch(update);
+ const now = Date.now();
const responses = await this.jmap.call(
user,
@@ -1325,6 +1553,8 @@ export class JmapMailProvider implements MailProvider {
},
'g',
],
+ // 바꾼 뒤의 요약도 목록과 같게 기다리는 발송을 싣는다 (9단계)
+ ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS),
],
{ invalidRequestCode: 'RES_001' },
);
@@ -1333,7 +1563,11 @@ export class JmapMailProvider implements MailProvider {
const failure = firstSetError(set.notUpdated);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
- return toSummary(this.requireEmail(responses, 'g'));
+ const email = this.requireEmail(responses, 'g');
+ return toSummary(
+ email,
+ pendingSendOf(email, readHeldSendTimes(responses, HELD_CALLS, now)),
+ );
}
/**
@@ -1343,6 +1577,8 @@ export class JmapMailProvider implements MailProvider {
* 같은 요청의 Email/set 도 아무것도 바꾸지 않는다. 메일 하나가 바뀐 것처럼 오면(있을 수 없는 경우) 사실대로 돌려준다
* - 메서드 단위 실패는 그 코드(엔진 장애 MAIL_001) — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내도 결과가 같다)
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 요청 전체를 거부하면 VAL_001 (아무것도 바꾸지 않았다)
+ * - 옮기기 · 스팸 표시면 먼저 붙잡힌 메일(9단계)을 묻는다(계정 전체 — 보낼 시각이 지금 뒤인 제출, 요청 하나 더) — 붙잡힌 메일은 바꾸지 않고 failedIds.
+ * 확인하지 못하면 아무것도 바꾸지 않고 MAIL_001
*/
async updateMessages(
user: string,
@@ -1351,6 +1587,17 @@ export class JmapMailProvider implements MailProvider {
): Promise {
const patch = toEmailPatch(update);
const { mailboxId } = update;
+ const held = movesMessage(update)
+ ? await this.heldAmongAccount(user, ids)
+ : new Set();
+ const targets = ids.filter((id) => !held.has(id));
+ if (held.size > 0) {
+ this.warnHeldSkipped(user, held.size, '옮기거나 스팸으로 바꾸지');
+ }
+ // 붙잡힌 메일뿐이고 옮길 메일함도 묻지 않는다 — 엔진에 보낼 것이 없다
+ if (targets.length === 0 && mailboxId === undefined) {
+ return { updatedIds: [], failedIds: [...ids] };
+ }
const responses = await this.jmap.call(
user,
@@ -1364,22 +1611,30 @@ export class JmapMailProvider implements MailProvider {
] as JmapInvocation,
]
: []),
- [
- 'Email/set',
- {
- accountId,
- update: Object.fromEntries(ids.map((id) => [id, patch])),
- },
- 'u',
- ],
+ ...(targets.length > 0
+ ? [
+ [
+ 'Email/set',
+ {
+ accountId,
+ update: Object.fromEntries(targets.map((id) => [id, patch])),
+ },
+ 'u',
+ ] as JmapInvocation,
+ ]
+ : []),
],
{ invalidRequestCode: 'VAL_001' },
);
- const set = expectResult(responses, 'u', 'Email/set');
+ const set: JmapSetResult =
+ targets.length > 0
+ ? expectResult(responses, 'u', 'Email/set')
+ : {};
const updated = new Set(Object.keys(set.updated ?? {}));
- const updatedIds = ids.filter((id) => updated.has(id));
- const failedIds = ids.filter((id) => !updated.has(id));
+ const updatedIds = targets.filter((id) => updated.has(id));
+ // 붙잡힌 메일은 보내지 않았으니 바뀐 것에 없다 — 바꾸지 못한 메일과 함께 failedIds
+ const failedIds = ids.filter((id) => held.has(id) || !updated.has(id));
if (mailboxId !== undefined) {
const mailbox = expectResult>(
@@ -1403,7 +1658,12 @@ export class JmapMailProvider implements MailProvider {
return { updatedIds, failedIds };
}
+ /**
+ * 한 통 삭제 — 휴지통이 아니면 휴지통으로, 휴지통에만 있으면 영구 삭제(조건부). 확인 요청에 그 메일의 제출을 함께 묻는다 —
+ * 붙잡힌 메일(9단계)이면 MAIL_015, 제출을 읽지 못하거나 다 받았는지 모르면 MAIL_001 (어느 쪽이든 아무것도 바꾸지 않는다)
+ */
async deleteMessage(user: string, id: string): Promise {
+ const now = Date.now();
const lookup = await this.jmap.call(
user,
(accountId) => [
@@ -1417,11 +1677,19 @@ export class JmapMailProvider implements MailProvider {
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
+ ...submissionCalls(
+ accountId,
+ heldEmailsFilter([id], now),
+ HELD_GUARD_CALLS,
+ ),
],
{ invalidRequestCode: 'RES_001' },
);
const email = this.requireEmail(lookup, 'g');
+ const held = readHeldEmails(lookup, HELD_GUARD_CALLS, now);
+ if (held.ids.size > 0) throw heldSendLocked('delete');
+ if (!held.complete) throw heldOverLimit(1);
const { list: mailboxes = [] } = expectResult>(
lookup,
'm',
@@ -1558,6 +1826,7 @@ export class JmapMailProvider implements MailProvider {
* - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고)
* - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001
* - 휴지통이 없으면 MAIL_001 (지우지 않는다)
+ * - 붙잡힌 메일(9단계)은 건너뛴다(skippedIds) — 확인 요청에 계정의 기다리는 제출을 함께 묻는다. 읽지 못하면 아무것도 지우지 않고 MAIL_001
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 확인 요청 전체를 거부하면 VAL_001 (지우지 않았다)
*/
async deleteFromTrash(
@@ -1569,6 +1838,7 @@ export class JmapMailProvider implements MailProvider {
attempt < CONDITIONAL_DESTROY_ATTEMPTS;
attempt += 1
) {
+ const now = Date.now();
const lookup = await this.jmap.call(
user,
(accountId) => [
@@ -1582,6 +1852,11 @@ export class JmapMailProvider implements MailProvider {
{ accountId, ids: null, properties: ['id', 'role'] },
'm',
],
+ ...submissionCalls(
+ accountId,
+ heldSubmissionFilter(now),
+ HELD_GUARD_CALLS,
+ ),
],
{ invalidRequestCode: 'VAL_001' },
);
@@ -1606,10 +1881,26 @@ export class JmapMailProvider implements MailProvider {
const byId = new Map(
(found.list ?? []).map((email) => [email.id, email]),
);
+ const inTrash = ids.filter(
+ (id) => byId.has(id) && byId.get(id)?.mailboxIds?.[trashId] === true,
+ );
+ // 휴지통에 든 것이 없으면 지울 것도 없다 — 제출을 읽지 못했어도 그대로 건너뛴다
+ const held =
+ inTrash.length === 0
+ ? new Set()
+ : await heldAmong(
+ readHeldEmails(lookup, HELD_GUARD_CALLS, now),
+ inTrash,
+ this.heldLookup(user),
+ );
+ if (held.size > 0) {
+ this.warnHeldSkipped(user, held.size, '휴지통에서 지우지');
+ }
const destroy: string[] = [];
const untrash: string[] = [];
for (const id of ids) {
const email = byId.get(id);
+ if (held.has(id)) continue;
const removal = email ? removalFrom(inMailboxes(email), trashId) : null;
if (removal === 'destroy') destroy.push(id);
else if (removal === 'detach') untrash.push(id);
@@ -1668,6 +1959,12 @@ export class JmapMailProvider implements MailProvider {
): Promise {
const startedAt = Date.now();
const budgetLeft = () => SEND_TOTAL_BUDGET_MS - (Date.now() - startedAt);
+ // 만든 메일을 지우는 제한 시간 — 남은 시간 안에서(기본 10초까지), 남지 않았어도 SEND_CLEANUP_MIN_MS 는 (응답이 56초를 넘지 않게)
+ const cleanupTimeoutMs = () =>
+ Math.max(
+ SEND_CLEANUP_MIN_MS,
+ Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()),
+ );
// 1) 준비 — 메일함 역할, 발신 ID, (답장이면) 원 메일의 스레드 헤더
const session = await this.jmap.getSession(user);
@@ -1743,10 +2040,20 @@ export class JmapMailProvider implements MailProvider {
}
// 3) 초안을 만들고 같은 요청에서 제출한다 — 성공하면 초안 → 보낸편지함으로 옮긴다.
- // 발송 중 키워드로 사용자의 임시 저장본 목록에 보이지 않게 하고, 옮길 때 뗀다
+ // 발송 중 키워드로 사용자의 임시 저장본 목록에 보이지 않게 하고, 옮길 때 뗀다.
+ // 제출에는 늘 envelope 을 직접 준다(받는 사람 · 참조 · 숨은참조 — 붙잡는 발송은 MAIL FROM 에 붙잡는 매개변수, 9단계).
+ // 예약은 사본에 예약 표시를 붙인다(옮겨도 남는다)
+ const { hold } = message;
const draft = {
mailboxIds: { [draftsId]: true },
- keywords: { $draft: true, $seen: true, [INTERNAL_SEND_KEYWORD]: true },
+ keywords: {
+ $draft: true,
+ $seen: true,
+ [INTERNAL_SEND_KEYWORD]: true,
+ ...(hold?.kind === 'scheduled'
+ ? { [SCHEDULED_SEND_KEYWORD]: true }
+ : {}),
+ },
// 중복 발송 방지 — 같은 키의 재시도는 같은 Message-ID 로 이 메일을 다시 찾는다
...(message.messageId ? { messageId: [message.messageId] } : {}),
...(message.fingerprint
@@ -1768,26 +2075,21 @@ export class JmapMailProvider implements MailProvider {
...bodyProperties(message, inline),
};
+ // 제출의 envelope — 받는 사람은 받는 사람 · 참조 · 숨은참조의 주소(엔진이 머리글로 정하는 것과 같은 목록).
+ // Bcc 머리글은 엔진이 보낼 때 뗀다 — 보낸 사본에만 남는다 (envelope.ts 머리말)
+ const plan: SubmissionPlan = {
+ identityId: identity.id,
+ from: identity.email ?? session.username,
+ parameters: hold ? holdParameters(hold) : null,
+ draftsId,
+ sentId,
+ };
+ const rcptTo = envelopeRecipients(message);
const responses = await this.jmap.call(
user,
(accountId) => [
['Email/set', { accountId, create: { draft } }, 'e'],
- [
- 'EmailSubmission/set',
- {
- accountId,
- create: { send: { identityId: identity.id, emailId: '#draft' } },
- onSuccessUpdateEmail: {
- '#send': {
- [`mailboxIds/${draftsId}`]: null,
- [`mailboxIds/${sentId}`]: true,
- 'keywords/$draft': null,
- [`keywords/${INTERNAL_SEND_KEYWORD}`]: null,
- },
- },
- },
- 's',
- ],
+ submitCall(accountId, plan, '#draft', rcptTo),
],
// 첨부를 붙여 메시지를 만드는 큰 요청이라 제한 시간을 늘린다 (발송 전체 시간 안에서)
{ invalidRequestCode: 'VAL_001', timeoutMs: sendTimeoutMs },
@@ -1813,49 +2115,67 @@ export class JmapMailProvider implements MailProvider {
});
}
- const submission = findResult(responses, 's', 'EmailSubmission/set');
- const submitted = submission.ok
- ? (submission.args as JmapSetResult)
- : undefined;
- const send = submitted?.created?.send;
- if (!send) {
- // 보내지 못한 초안이 임시보관함에 남지 않게 지운다
- await this.discardDraft(user, draftId);
- throw jmapFailure(
- submission.ok ? firstSetError(submitted?.notCreated) : submission.error,
- { method: 'EmailSubmission/set' },
- );
- }
-
- this.warnIfNotMovedToSent(responses, user);
-
- // 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 (계약 2절 "받는 사람별 결과")
- const delivery = await this.readSendResult(
+ const submitted = await this.finishSubmission(
user,
- message,
- send.id,
- Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS),
+ plan,
+ draftId,
+ rcptTo,
+ responses,
+ { hold, budgetLeft, cleanupTimeoutMs },
);
- if (delivery.isEveryoneRejected) {
- // 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고
- // (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다.
- // 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다
- const code = everyoneRejectedCode(delivery.rejected);
+ if (submitted === null) {
+ // 엔진이 받는 사람 주소를 모두 envelope 에서 받지 않았다 — 제출이 하나도 없다(아무에게도 가지 않았다). 만든 메일은 조건 없이 지운다
+ // (이 요청이 방금 만든 메일 — 제출하지 않았다). 지운 메일은 같은 키의 재시도에 걸리지 않는다 — 다시 보내면 새로 보낸다
+ const { rejected } = readDeliveryResult(message, [], []);
this.logger.warn(
- `[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
+ `[MAIL_010]${traceTag()} 엔진이 받는 사람 주소를 모두 받지 않아 보내지 않았습니다 — 만든 메일을 지웁니다: user=${maskEmail(user)}, ${describeRejections(rejected)}`,
);
const discarded = await this.discardQuietly(
user,
draftId,
- '받는 사람이 모두 받지 않은 보낸 사본',
+ '받는 사람 주소를 엔진이 모두 받지 않은 메일',
{
- remains: '보낸편지함에 한 통이 남습니다',
- timeoutMs: Math.max(
- 1,
- Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()),
- ),
+ remains:
+ '발송 중 표시가 붙은 초안 한 통이 남습니다(목록에는 보이지 않습니다)',
+ timeoutMs: cleanupTimeoutMs(),
},
);
+ throw new BusinessException('MAIL_010', {
+ context: {
+ reason: 'all-recipients-refused-in-envelope',
+ rejected: rejected.length,
+ copyDiscarded: discarded,
+ },
+ });
+ }
+ const { send } = submitted;
+ this.warnIfNotMovedToSent(submitted.responses, user);
+
+ // 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 · envelope 에서 받지 않은 주소 (계약 2절 "받는 사람별 결과")
+ const result = await this.readSendResult(
+ user,
+ message,
+ send.id,
+ Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS),
+ submitted.rcptTo,
+ );
+ const { delivery } = result;
+ if (delivery.isEveryoneRejected) {
+ // 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고
+ // (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다.
+ // 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다.
+ // 붙잡은 제출이 아직 기다리고 있으면(pending) 먼저 취소한다 — 풀리는 시각에 다시 보내지 않게
+ // (모두 5xx 거절이면 엔진이 붙잡아도 바로 final 이라 취소할 것이 없다 — 2026-10-29 확인)
+ const code = everyoneRejectedCode(delivery.rejected);
+ this.logger.warn(
+ `[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
+ );
+ const discarded = await this.discardRejectedSend(
+ user,
+ draftId,
+ hold && result.undoStatus === UNDO_PENDING ? (send.id ?? null) : null,
+ cleanupTimeoutMs(),
+ );
throw new BusinessException(code, {
context: {
reason: 'all-recipients-rejected',
@@ -1872,9 +2192,189 @@ export class JmapMailProvider implements MailProvider {
return {
id: draftId,
rejectedRecipients: toRejectedRecipients(delivery.rejected),
+ pendingSend: hold ? submittedPendingSend(hold, send, startedAt) : null,
};
}
+ /**
+ * 제출을 마친다 — 엔진이 envelope 에서 받지 않은 주소를 빼며 (envelope.ts 머리말). 처음 제출(초안을 만든 요청)의 결과에서 시작해,
+ * 거절 설명에 적힌 우리 주소 하나(addressNamedIn)를 받지 않은 사람으로 빼고 **같은 메일**을 나머지에게 다시 낸다 — 붙잡는 매개변수는 그대로.
+ * 엔진은 한 번에 주소 하나만 알려 주므로 차례마다 하나가 빠진다 — 다시 내기는 MAX_ENVELOPE_RESUBMITS 차례까지(9단계 리뷰 2 —
+ * 한 요청이 엔진 제출을 받는 사람 수만큼 시키지 않게).
+ * 차례마다 실패를 다루는 법은 처음 제출과 같다(9단계 리뷰 2 — 남는 것이 같게):
+ * - 엔진의 답이 거절(메서드 오류 · SetError)이면 만든 메일을 지우고 던진다
+ * - 엔진이 요청을 실행하지 않았다고 답했으면(HTTP 4xx · 429 — isRequestRefused) 아무것도 나가지 않았다 — 만든 메일을 지우고 던진다
+ * (처음 제출이면 초안도 만들어지지 않았다 — 남는 것이 없다)
+ * - 결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답)는 지우지 않고 던진다 — 나갔을 수 있다.
+ * 같은 키의 재시도가 맡는다(findSentMessage — 제출이 없는 초안은 지우고 새로 보낸다)
+ * 지우기는 발송 전체 시간 안에서(context.cleanupTimeoutMs — 응답이 56초를 넘지 않게)
+ * @returns 만든 제출 · 그 응답 · 실제로 낸 받는 사람. 남은 사람이 없으면 null (부른 쪽이 MAIL_010 — 만든 메일은 부른 쪽이 지운다)
+ * @throws 설명에 우리 주소가 없는 거절 · 그 밖의 거절은 지금처럼 오류 표대로(붙잡을 시각의 거절은 VAL_001).
+ * 받지 않는 주소가 상한보다 많으면 VAL_001, 다시 낼 시간이 모자라면 MAIL_001 — 둘 다 만든 메일을 지운다(아직 아무것도 제출하지 않았다 —
+ * 같은 키로 다시 보내면 새로 보낸다)
+ */
+ private async finishSubmission(
+ user: string,
+ plan: SubmissionPlan,
+ draftId: string,
+ firstRcptTo: readonly string[],
+ firstResponses: JmapInvocation[],
+ context: {
+ readonly hold?: SendHold;
+ readonly budgetLeft: () => number;
+ readonly cleanupTimeoutMs: () => number;
+ },
+ ): Promise<{
+ readonly send: CreatedSubmission;
+ readonly responses: JmapInvocation[];
+ readonly rcptTo: readonly string[];
+ } | null> {
+ let rcptTo = firstRcptTo;
+ let responses = firstResponses;
+ let resubmits = 0;
+ let refused = 0;
+ const discard = () =>
+ this.discardDraft(user, draftId, context.cleanupTimeoutMs());
+ for (;;) {
+ const result = findResult(responses, 's', 'EmailSubmission/set');
+ const set = result.ok ? (result.args as JmapSetResult) : undefined;
+ // 만든 제출 — 엔진이 정한 보낼 시각 · 상태를 함께 준다(id · sendAt · undoStatus — 2026-10-29 확인)
+ const send = set?.created?.send;
+ if (send) {
+ if (resubmits > 0) {
+ // 뺀 주소(가린 것)는 부른 쪽의 받는 사람별 결과 로그에 함께 남는다 — 여기서는 차례 수만 한 줄로
+ this.logger.log(
+ `${traceTag()} 엔진이 envelope 의 주소 ${refused}개를 받지 않아 빼고 ${resubmits}차례 다시 제출했습니다: user=${maskEmail(user)}, 받는 사람 ${rcptTo.length}명`,
+ );
+ }
+ return { send, responses, rcptTo };
+ }
+
+ const failure = result.ok ? firstSetError(set?.notCreated) : result.error;
+ const named = isEnvelopeRefusal(failure)
+ ? addressNamedIn(failure?.description, rcptTo)
+ : null;
+ if (named === null) {
+ // 보내지 못한 초안이 임시보관함에 남지 않게 지운다
+ await discard();
+ if (context.hold && failure?.type === FORBIDDEN_MAIL_FROM) {
+ // 엔진이 붙잡을 시각을 받지 않았다(한도 — 지금 7일) — 우리 검사(7일 안)를 지났으니 엔진 설정이 더 짧다
+ throw new BusinessException('VAL_001', {
+ context: {
+ reason: 'send-hold-refused-by-engine',
+ hold: context.hold.kind,
+ },
+ });
+ }
+ throw jmapFailure(failure, { method: 'EmailSubmission/set' });
+ }
+
+ // 엔진이 받지 않은 주소 — 받지 않은 사람(not-found)이다. 설명 글 · 주소는 여기서 남기지 않는다
+ const refusedKey = envelopeKey(named);
+ rcptTo = rcptTo.filter((address) => envelopeKey(address) !== refusedKey);
+ refused += 1;
+ if (rcptTo.length === 0) return null;
+
+ if (resubmits >= MAX_ENVELOPE_RESUBMITS) {
+ // 받지 않는 주소가 너무 많다 — 더 내지 않는다 (아직 제출한 것이 없어 만든 메일을 지워도 된다). 주소는 개수만
+ await discard();
+ throw new BusinessException('VAL_001', {
+ context: {
+ reason: 'too-many-refused-addresses',
+ refused,
+ resubmits,
+ left: rcptTo.length,
+ },
+ });
+ }
+ // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 다시 내지 않는다 (아직 제출한 것이 없어 만든 메일을 지워도 된다)
+ const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.budgetLeft());
+ if (timeoutMs < MIN_SEND_WINDOW_MS) {
+ await discard();
+ throw new BusinessException('MAIL_001', {
+ context: {
+ reason: 'send-budget-exhausted',
+ leftMs: timeoutMs,
+ resubmits,
+ },
+ });
+ }
+ resubmits += 1;
+ const remaining = rcptTo;
+ try {
+ responses = await this.jmap.call(
+ user,
+ (accountId) => [submitCall(accountId, plan, draftId, remaining)],
+ // 처음 제출과 같게 — 엔진이 요청을 해석하지 못하면 VAL_001
+ { invalidRequestCode: 'VAL_001', timeoutMs },
+ );
+ } catch (error) {
+ // 엔진이 실행하지 않았다고 답했다 — 만든 메일을 지운다. 결과를 모르는 실패는 남긴다 (머리말)
+ if (isRequestRefused(error)) await discard();
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * 받는 사람이 모두 받지 않은 발송의 보낸 사본을 지운다 — 붙잡은 제출이 아직 기다리고 있으면(submissionId) 같은 요청에서 **먼저** 취소한다
+ * (메서드는 차례대로 돈다). 취소를 엔진이 받지 않아도 사본은 지운다 — 둘 다 할 수 있는 만큼 하고 경고만 남긴다 (던지지 않는다).
+ * 조건 없이 지운다 — 이 요청이 방금 만든 메일이다 (discardQuietly 와 같다)
+ * @returns 사본을 지웠거나 이미 없으면 true
+ */
+ private async discardRejectedSend(
+ user: string,
+ draftId: string,
+ submissionId: string | null,
+ timeoutMs: number,
+ ): Promise {
+ if (submissionId === null) {
+ return this.discardQuietly(
+ user,
+ draftId,
+ '받는 사람이 모두 받지 않은 보낸 사본',
+ { remains: '보낸편지함에 한 통이 남습니다', timeoutMs },
+ );
+ }
+ try {
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'EmailSubmission/set',
+ {
+ accountId,
+ update: { [submissionId]: { undoStatus: UNDO_CANCELED } },
+ },
+ 'c',
+ ],
+ ['Email/set', { accountId, destroy: [draftId] }, 'x'],
+ ],
+ { timeoutMs },
+ );
+ const cancel = findResult(responses, 'c', 'EmailSubmission/set');
+ const cancelFailure = cancel.ok
+ ? firstSetError((cancel.args as JmapSetResult).notUpdated)
+ : cancel.error;
+ if (cancelFailure) {
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 붙잡은 발송을 취소하지 못했습니다 — 풀리는 시각에 엔진이 다시 보낼 수 있습니다: user=${maskEmail(user)}, jmapType=${cancelFailure.type ?? 'unknown'}`,
+ );
+ }
+ const set = expectResult(responses, 'x', 'Email/set');
+ const failure = firstSetError(set.notDestroyed);
+ if (failure && failure.type !== NOT_FOUND) {
+ throw jmapFailure(failure, { method: 'Email/set' });
+ }
+ return true;
+ } catch (error) {
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 붙잡은 발송의 보낸 사본을 지우지 못했습니다 — 보낸편지함에 한 통이 남습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
+ );
+ return false;
+ }
+ }
+
async findSentMessage(
user: string,
messageId: string,
@@ -1932,10 +2432,23 @@ export class JmapMailProvider implements MailProvider {
);
if (!email) return null;
- // 초안으로 남아 있으면 실제로 제출됐는지 확인한다 — 제출 기록이 없으면 보내지 못한 초안이다
+ // 그 메일의 제출 — 보냈는지 · 붙잡고 있는지 · 취소했는지 (9단계)
+ const now = Date.now();
+ const submissions = await this.submissionsOf(user, email.id);
+ // 취소해 임시보관함으로 되돌린 메일(취소한 발송 표시)은 늘 취소한 발송이다 — 제출 기록이 없어졌어도(엔진 정리 · 손으로 지움) 지우지 않는다.
+ // 사용자가 이어 쓰는 초안이다 (계약 2절 "중복 발송 방지와 함께")
+ const returnedToDrafts = email.keywords?.[CANCELED_SEND_KEYWORD] === true;
+ const sent =
+ !returnedToDrafts &&
+ submissions.some((submission) => submission.undoStatus !== UNDO_CANCELED);
+ const canceled = returnedToDrafts || (submissions.length > 0 && !sent);
+
+ // 초안으로 남아 있는데 제출 기록이 없으면 보내지 못한 초안이다 — 지운다(다시 보내도 된다).
+ // 취소한 발송(제출이 모두 canceled · 취소한 발송 표시)은 지우지 않는다
if (
+ !canceled &&
email.keywords?.$draft === true &&
- !(await this.wasSubmitted(user, email.id))
+ submissions.length === 0
) {
await this.discardDraft(user, email.id);
return null;
@@ -1950,6 +2463,10 @@ export class JmapMailProvider implements MailProvider {
typeof fingerprint === 'string' && fingerprint.trim() !== ''
? fingerprint.trim()
: null,
+ canceled,
+ pendingSend: canceled
+ ? null
+ : pendingSendOf(email, heldSendTimes(submissions, now)),
};
}
@@ -1957,7 +2474,8 @@ export class JmapMailProvider implements MailProvider {
* 이미 보낸 메일의 받지 않은 사람 — 같은 키의 재시도가 처음 결과를 돌려줄 때 (계약 2절 "받는 사람별 결과").
* 그 메일의 제출 기록을 찾아(EmailSubmission/query 의 emailIds) 같은 요청에서 결과를 읽고(get — #ids 참조, 발송 요청과 달리
* 엔진이 덧붙이는 응답이 없다) 이 요청의 받는 사람과 맞춘다. 제출 기록이 없거나 읽지 못하면 빈 배열 (읽지 못한 것은 경고).
- * 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다
+ * 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다.
+ * 제출의 envelope 도 읽는다 — 그 envelope 에 없는 받는 사람(엔진이 envelope 에서 받지 않은 주소)은 not-found 로, 처음 발송의 답과 같게 (delivery-status.ts)
*/
async getRejectedRecipients(
user: string,
@@ -1980,7 +2498,7 @@ export class JmapMailProvider implements MailProvider {
name: 'EmailSubmission/query',
path: '/ids',
},
- properties: ['deliveryStatus'],
+ properties: ['deliveryStatus', 'envelope'],
},
'g',
],
@@ -1991,7 +2509,7 @@ export class JmapMailProvider implements MailProvider {
'EmailSubmission/get',
);
return toRejectedRecipients(
- readDeliveryResult(recipients, list).rejected,
+ readDeliveryResult(recipients, list, submittedEnvelope(list)).rejected,
);
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
@@ -1999,6 +2517,215 @@ export class JmapMailProvider implements MailProvider {
}
}
+ /**
+ * 예약한 메일 — 보낼 시각이 지금 뒤인 제출(엔진의 sendAt 색인)과 그 메일을 한 요청으로 읽어, 붙잡고 있고(pending) 예약 표시가 있는 것만
+ * 보낼 시각 차례로 (계약 2절 "보내기 취소 · 예약 발송"). 메일이 지워졌으면(그사이 다른 프로그램) 빠진다
+ */
+ async listScheduledMessages(user: string): Promise {
+ const now = Date.now();
+ const responses = await this.jmap.call(user, (accountId) => [
+ ...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
+ [
+ 'Email/get',
+ {
+ accountId,
+ '#ids': {
+ resultOf: `${HELD_CALLS}g`,
+ name: 'EmailSubmission/get',
+ path: '/list/*/emailId',
+ },
+ properties: SUMMARY_PROPERTIES,
+ bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
+ },
+ 'g',
+ ],
+ ]);
+ const { list: submissions = [] } = expectResult<
+ JmapGetResult
+ >(responses, `${HELD_CALLS}g`, 'EmailSubmission/get');
+ const { list: emails = [] } = expectResult>(
+ responses,
+ 'g',
+ 'Email/get',
+ );
+ this.warnIfHeldQueryFull(responses, user);
+ const times = heldSendTimes(submissions, now);
+ return emails
+ .flatMap((email) => {
+ const pendingSend = pendingSendOf(email, times);
+ return pendingSend?.kind === 'scheduled'
+ ? [toSummary(email, pendingSend)]
+ : [];
+ })
+ .sort(
+ (a, b) =>
+ Date.parse(a.pendingSend?.sendAt ?? '') -
+ Date.parse(b.pendingSend?.sendAt ?? '') ||
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
+ );
+ }
+
+ /**
+ * 붙잡은 발송 취소 (계약 2절 "보내기 취소 · 예약 발송") — 1) 그 메일과 제출을 읽는다 2) 붙잡고 있는 제출을 취소한다 3) **다른 요청으로**
+ * 그 메일을 임시보관함의 초안으로 되돌린다(`$draft` · 취소한 발송 표시를 붙이고 발송 중 · 예약 표시를 뗀다).
+ * - 메일이 없으면 RES_001
+ * - 이미 취소했다(제출이 하나 이상이고 모두 canceled — 응답을 받지 못한 재시도 · 옮기지 못한 취소): 취소한 발송 표시가 있으면(되돌리기를 마쳤다)
+ * 어느 메일함에 있든 그대로 성공 — 옮기지 않는다(그 뒤 휴지통 등으로 옮긴 초안을 되살리지 않게). 표시가 없으면 되돌리기만 다시 한다(못 하면 MAIL_001)
+ * — 같은 요청을 다시 보내도 같은 결과다
+ * - 그 밖에 붙잡고 있는 제출이 없으면(보낸 제출(final) · 보낼 시각이 지나 배달 중 · 제출이 없는 메일) MAIL_014 — 아무것도 바꾸지 않는다
+ * - 엔진이 취소를 받지 않으면(그사이 풀림 등) MAIL_014. 거절도 확인(updated 에 그 제출)도 없으면 MAIL_001 — 옮기지 않는다
+ * - 취소는 됐는데 옮기지 못하면 한 번 더, 그래도 못 하면 MAIL_001 — 그 메일은 보내지 않은 채 보낸편지함에 남는다(warn). 다시 부르면 되돌리기만 한다
+ */
+ async cancelPendingSend(user: string, id: string): Promise {
+ const now = Date.now();
+ const lookup = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: ['id', 'role'] },
+ 'm',
+ ],
+ [
+ 'Email/get',
+ {
+ accountId,
+ ids: [id],
+ properties: ['id', 'mailboxIds', 'keywords'],
+ },
+ 'g',
+ ],
+ ...submissionCalls(accountId, { emailIds: [id] }, HELD_CALLS),
+ ],
+ { invalidRequestCode: 'RES_001' },
+ );
+ const draftsId = this.requireDraftsMailbox(lookup);
+ const email = this.requireEmail(lookup, 'g');
+ const { list: submissions = [] } = expectResult<
+ JmapGetResult
+ >(lookup, `${HELD_CALLS}g`, 'EmailSubmission/get');
+ const held = submissions.filter((submission) => isHeld(submission, now));
+ const alreadyCanceled =
+ submissions.length > 0 &&
+ submissions.every(
+ (submission) => submission.undoStatus === UNDO_CANCELED,
+ );
+ if (held.length === 0 && alreadyCanceled) {
+ // 이미 취소했다 — 되돌리기까지 마쳤으면(취소한 발송 표시 — 메일함과 표시를 한 번에 바꾼다) 어느 메일함에 있든 그대로 성공:
+ // 그 뒤 사용자가 휴지통 등으로 옮긴 초안을 늦게 온 · 다시 보낸 취소가 되살리지 않는다 (9단계 리뷰 2).
+ // 표시가 없으면(앞의 옮기기가 실패 — 보낸편지함에 남았다) 되돌리기만 다시 한다 (보내지 않은 메일이 보낸편지함에 남지 않게)
+ if (email.keywords?.[CANCELED_SEND_KEYWORD] === true) return;
+ await this.returnToDrafts(user, id, draftsId);
+ this.logger.log(
+ `${traceTag()} 이미 취소한 발송의 메일을 임시보관함으로 되돌렸습니다: user=${maskEmail(user)}, id=${id}`,
+ );
+ return;
+ }
+ if (held.length === 0) {
+ throw new BusinessException('MAIL_014', {
+ context: {
+ reason: 'not-a-pending-send',
+ submissions: submissions.map(
+ (submission) => submission.undoStatus ?? 'unknown',
+ ),
+ },
+ });
+ }
+ const kind =
+ email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo';
+
+ // 1) 취소 — 엔진이 받지 않으면(cannotUnsend 등 — 그사이 풀렸다) 보낸 것이다
+ const canceled = await this.jmap.call(user, (accountId) => [
+ [
+ 'EmailSubmission/set',
+ {
+ accountId,
+ update: Object.fromEntries(
+ held.map((submission) => [
+ submission.id,
+ { undoStatus: 'canceled' },
+ ]),
+ ),
+ },
+ 'x',
+ ],
+ ]);
+ const set = expectResult(
+ canceled,
+ 'x',
+ 'EmailSubmission/set',
+ );
+ const refused = firstSetError(set.notUpdated);
+ if (refused) {
+ throw new BusinessException('MAIL_014', {
+ context: {
+ reason: 'engine-refused-cancel',
+ jmapType: refused.type ?? 'unknown',
+ canceled: Object.keys(set.updated ?? {}).length,
+ },
+ });
+ }
+ // 엔진이 취소했다고 답한 것만 취소된 것이다(updated 에 그 제출 id) — 거절도 확인도 없으면 모른다: 옮기지 않고 MAIL_001 (9단계 리뷰 2).
+ // 옮기면 아직 기다리는 발송이 "취소한 초안"이 되어 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다
+ const confirmed = set.updated ?? {};
+ const unconfirmed = held.filter(
+ (submission) =>
+ !Object.prototype.hasOwnProperty.call(confirmed, submission.id),
+ );
+ if (unconfirmed.length > 0) {
+ throw new BusinessException('MAIL_001', {
+ context: {
+ reason: 'cancel-not-confirmed',
+ unconfirmed: unconfirmed.length,
+ held: held.length,
+ },
+ });
+ }
+
+ // 2) 다른 요청 — 임시보관함의 초안으로. 같은 요청에 두면 취소가 실패해도 옮겨진다
+ await this.returnToDrafts(user, id, draftsId);
+ this.logger.log(
+ `${traceTag()} 붙잡은 발송을 취소하고 임시보관함으로 되돌렸습니다: user=${maskEmail(user)}, id=${id}, ${kind === 'scheduled' ? '예약' : '보내기 취소'}, 보낼 시각=${held
+ .map((submission) => toUtcIso(submission.sendAt) ?? 'unknown')
+ .join(',')}`,
+ );
+ }
+
+ /** 취소한 발송의 메일을 임시보관함의 초안으로 — 실패하면 한 번 더, 그래도 못 하면 MAIL_001 */
+ private async returnToDrafts(
+ user: string,
+ id: string,
+ draftsId: string,
+ ): Promise {
+ const patch = {
+ mailboxIds: { [draftsId]: true },
+ 'keywords/$draft': true,
+ [`keywords/${CANCELED_SEND_KEYWORD}`]: true,
+ [`keywords/${SCHEDULED_SEND_KEYWORD}`]: null,
+ [`keywords/${INTERNAL_SEND_KEYWORD}`]: null,
+ };
+ let reason = 'unknown';
+ for (let attempt = 1; attempt <= RETURN_TO_DRAFTS_ATTEMPTS; attempt += 1) {
+ try {
+ const responses = await this.jmap.call(user, (accountId) => [
+ ['Email/set', { accountId, update: { [id]: patch } }, 'u'],
+ ]);
+ const set = expectResult(responses, 'u', 'Email/set');
+ const failure = firstSetError(set.notUpdated);
+ if (!failure) return;
+ reason = `jmap:${failure.type ?? 'unknown'}`;
+ } catch (error) {
+ reason = describeFailure(error);
+ }
+ }
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 발송은 취소했지만 메일을 임시보관함으로 옮기지 못했습니다 — 보내지 않은 채 보낸편지함에 남습니다: user=${maskEmail(user)}, id=${id}, reason=${reason}`,
+ );
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'canceled-send-not-returned-to-drafts' },
+ });
+ }
+
async uploadAttachment(
user: string,
file: UploadedFile,
@@ -2290,6 +3017,17 @@ export class JmapMailProvider implements MailProvider {
}
const detail = toDetailSource(email);
+ // 취소해 되돌린 발송(9단계)에 함께 실은 서명 그림(Content-ID `sig-…`)은 첨부가 아니다 — 본문이 서명 그림 주소로 다시 가리킨다
+ // (서비스의 restoreSignatureImageSources). 취소한 발송 표시가 있을 때만 — 다른 프로그램이 만든 초안의 `sig-…`(남의 메일을 전달 · 인용한 것)는
+ // 지금처럼 첨부다. 그 밖의 부분도 지금처럼 첨부다
+ const isCanceledSend = email.keywords?.[CANCELED_SEND_KEYWORD] === true;
+ const signatureParts = new Set(
+ isCanceledSend
+ ? detail.inlineParts
+ .filter(({ cid }) => parseSignatureImageContentId(cid) !== null)
+ .map(({ attachmentIndex }) => attachmentIndex)
+ : [],
+ );
return {
id: detail.id,
to: detail.to,
@@ -2298,9 +3036,14 @@ export class JmapMailProvider implements MailProvider {
subject: detail.subject,
textBody: detail.textBody,
rawHtmlBody: detail.rawHtmlBody,
- attachments: detail.attachments,
- inReplyTo: await this.existingReplyTarget(user, email),
+ attachments: detail.attachments.filter(
+ (_attachment, index) => !signatureParts.has(index),
+ ),
+ inReplyTo:
+ (await this.existingReplyTarget(user, email)) ??
+ (isCanceledSend ? await this.replyTargetInThread(user, email) : null),
updatedAt: detail.receivedAt,
+ isCanceledSend,
};
}
@@ -2623,13 +3366,21 @@ export class JmapMailProvider implements MailProvider {
/**
* 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다),
- * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다
+ * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다.
+ * 같은 요청에 계정의 기다리는 제출(보낼 시각이 지금 뒤)을 묻는다 — 붙잡힌 메일(9단계)은 옮기지도 지우지도 않는다.
+ * 읽은 메일이 있는데 제출을 읽지 못하면 MAIL_001 (fail closed — 비었으면 묻지 않은 것과 같다)
*/
private async readMailboxBatch(
user: string,
mailboxId: string,
options: JmapCallOptions,
- ): Promise<{ emails: JmapEmail[]; state: string | null; total: number }> {
+ ): Promise<{
+ emails: JmapEmail[];
+ state: string | null;
+ total: number;
+ held: HeldEmails;
+ }> {
+ const now = Date.now();
const responses = await this.jmap.call(
user,
(accountId) => [
@@ -2652,6 +3403,11 @@ export class JmapMailProvider implements MailProvider {
},
'g',
],
+ ...submissionCalls(
+ accountId,
+ heldSubmissionFilter(now),
+ HELD_GUARD_CALLS,
+ ),
],
options,
);
@@ -2671,6 +3427,10 @@ export class JmapMailProvider implements MailProvider {
emails,
state: isState(found.state) ? found.state : null,
total: queryTotal(query, ids.length),
+ held:
+ emails.length === 0
+ ? { ids: new Set(), complete: true }
+ : readHeldEmails(responses, HELD_GUARD_CALLS, now),
};
}
@@ -2784,6 +3544,7 @@ export class JmapMailProvider implements MailProvider {
query: MessageListQuery,
filter: JmapEmailFilter,
start: { anchor: string } | { position: number },
+ now: number,
): Promise {
const { mailboxId } = query;
return this.jmap.call(
@@ -2823,6 +3584,8 @@ export class JmapMailProvider implements MailProvider {
},
'g',
],
+ // 기다리는 발송 — 목록과 상관없이 그 계정의 것을 모두(몇 개뿐이다). 실패해도 목록은 그대로 준다 (readHeldSendTimes)
+ ...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
],
{ invalidRequestCode: mailboxId !== undefined ? 'RES_001' : 'VAL_001' },
);
@@ -2905,21 +3668,31 @@ export class JmapMailProvider implements MailProvider {
* 방금 한 제출의 받는 사람별 결과 (계약 2절 "받는 사람별 결과") — 제출 응답(created.send)에는 없어 제출 다음에 따로 묻는다.
* 발송 요청 안에서 참조(#ids)로 읽지 않는 이유: onSuccessUpdateEmail 이 같은 호출 id 로 Email/set 응답을 덧붙여
* 엔진이 참조를 풀지 못한다 (invalidResultReference — Stalwart 0.16.27, 2026-10-28 확인).
- * 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고).
+ * 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고). 다만 envelope 에서 빠진 주소(엔진이 받지 않았다)는
+ * 읽지 않아도 알므로 그대로 받지 않은 사람이다. 제출의 지금 상태(undoStatus)도 함께 읽는다 — 모두 거절된 붙잡은 발송을 취소할지 정한다
* @param timeoutMs 쓸 수 있는 시간 — SEND_RESULT_MIN_WINDOW_MS 보다 적으면 묻지 않는다
+ * @param envelope 실제로 낸 envelope 의 받는 사람
*/
private async readSendResult(
user: string,
recipients: RecipientFields,
submissionId: string | undefined,
timeoutMs: number,
- ): Promise {
+ envelope: readonly string[],
+ ): Promise<{
+ readonly delivery: DeliveryResult;
+ readonly undoStatus: string | null;
+ }> {
+ const unknown = () => ({
+ delivery: readDeliveryResult(recipients, [], envelope),
+ undoStatus: null,
+ });
if (!submissionId || timeoutMs < SEND_RESULT_MIN_WINDOW_MS) {
this.warnDeliveryUnknown(
user,
submissionId ? `no-time(${Math.max(0, timeoutMs)}ms)` : 'no-id',
);
- return DELIVERED_TO_ALL;
+ return unknown();
}
try {
@@ -2928,7 +3701,11 @@ export class JmapMailProvider implements MailProvider {
(accountId) => [
[
'EmailSubmission/get',
- { accountId, ids: [submissionId], properties: ['deliveryStatus'] },
+ {
+ accountId,
+ ids: [submissionId],
+ properties: ['deliveryStatus', 'undoStatus'],
+ },
'd',
],
],
@@ -2939,10 +3716,13 @@ export class JmapMailProvider implements MailProvider {
'd',
'EmailSubmission/get',
);
- return readDeliveryResult(recipients, list);
+ return {
+ delivery: readDeliveryResult(recipients, list, envelope),
+ undoStatus: list[0]?.undoStatus ?? null,
+ };
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
- return DELIVERED_TO_ALL;
+ return unknown();
}
}
@@ -2953,21 +3733,87 @@ export class JmapMailProvider implements MailProvider {
);
}
- /** 이 메일로 만든 제출(EmailSubmission)이 있는가 */
- private async wasSubmitted(user: string, emailId: string): Promise {
- const responses = await this.jmap.call(user, (accountId) => [
- [
- 'EmailSubmission/query',
- { accountId, filter: { emailIds: [emailId] } },
- 's',
- ],
- ]);
- const { ids = [] } = expectResult(
- responses,
- 's',
- 'EmailSubmission/query',
+ /**
+ * 메일 id 로 붙잡힌 메일을 묻는다 — 옮기기 · 지우기를 막는 확인(fail closed: 읽지 못하면 MAIL_001 — readHeldEmails).
+ * 계정 전체 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id 를 더한다(heldEmailsFilter — 지난 제출이 상한을 채우지 않게).
+ * 메일 id 조건은 엔진의 색인을 탄다. 엔진이 요청 전체를 거부하면 options 의 코드(한 통은 RES_001)
+ */
+ private async heldByEmailIds(
+ user: string,
+ emailIds: readonly string[],
+ options: JmapCallOptions = {},
+ ): Promise {
+ const now = Date.now();
+ const responses = await this.jmap.call(
+ user,
+ (accountId) =>
+ submissionCalls(
+ accountId,
+ heldEmailsFilter(emailIds, now),
+ HELD_GUARD_CALLS,
+ ),
+ options,
+ );
+ return readHeldEmails(responses, HELD_GUARD_CALLS, now);
+ }
+
+ /** 상한에 닿은 계정 전체 조회를 메일 id 로 보충하는 묻기 (pending-send.ts 의 heldAmong) */
+ private heldLookup(user: string, options: JmapCallOptions = {}): HeldLookup {
+ return (emailIds) => this.heldByEmailIds(user, emailIds, options);
+ }
+
+ /**
+ * 후보 가운데 붙잡힌 메일 — 계정의 기다리는 제출(보낼 시각이 지금 뒤 — 엔진의 색인, 요청 하나)로 가리고, 상한에 닿았으면 후보의 메일 id 로 다시 묻는다.
+ * 읽지 못하면 MAIL_001 (fail closed)
+ */
+ private async heldAmongAccount(
+ user: string,
+ candidates: readonly string[],
+ ): Promise> {
+ const now = Date.now();
+ const responses = await this.jmap.call(user, (accountId) =>
+ submissionCalls(accountId, heldSubmissionFilter(now), HELD_GUARD_CALLS),
+ );
+ return heldAmong(
+ readHeldEmails(responses, HELD_GUARD_CALLS, now),
+ candidates,
+ this.heldLookup(user),
+ );
+ }
+
+ /** 붙잡힌 메일이라 하지 않은 것 — 개수만 경고로 (여러 통 처리 · 메일함 처리는 실패 코드 대신 그 id 를 돌려주거나 거기서 멈춘다) */
+ private warnHeldSkipped(user: string, count: number, doing: string): void {
+ this.logger.warn(
+ `[MAIL_015]${traceTag()} 보내기를 기다리는 메일이라 ${doing} 않았습니다: user=${maskEmail(user)}, ${count}통`,
+ );
+ }
+
+ /**
+ * 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull)
+ * — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX)
+ */
+ private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void {
+ if (!heldQueryFull(responses, HELD_CALLS)) return;
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
+ );
+ }
+
+ /** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */
+ private async submissionsOf(
+ user: string,
+ emailId: string,
+ ): Promise {
+ const responses = await this.jmap.call(user, (accountId) =>
+ submissionCalls(accountId, { emailIds: [emailId] }, HELD_CALLS),
+ );
+ return (
+ expectResult>(
+ responses,
+ `${HELD_CALLS}g`,
+ 'EmailSubmission/get',
+ ).list ?? []
);
- return ids.length > 0;
}
/** 계정의 모든 메일 크기를 더한다 — 엔진이 limit 을 줄여 돌려줘도 실제로 받은 개수만큼 전진한다 */
@@ -3266,6 +4112,52 @@ export class JmapMailProvider implements MailProvider {
return list.length > 0 ? id : null;
}
+ /**
+ * 취소해 되돌린 발송(9단계)의 답장 대상 — 보낸 메일에는 답장 대상 머리글(X-Ara-Reply-To-Id)이 없어, 같은 스레드에서 In-Reply-To 의
+ * Message-ID 를 가진 메일을 찾는다. 찾지 못하거나 읽지 못하면 null (스레드 머리글 없이 이어 쓴다 — 던지지 않는다)
+ */
+ private async replyTargetInThread(
+ user: string,
+ draft: JmapEmail,
+ ): Promise {
+ const wanted = draft.inReplyTo?.[0];
+ if (!wanted || !draft.threadId) return null;
+ try {
+ const responses = await this.jmap.call(user, (accountId) => [
+ ['Thread/get', { accountId, ids: [draft.threadId] }, 't'],
+ [
+ 'Email/get',
+ {
+ accountId,
+ '#ids': {
+ resultOf: 't',
+ name: 'Thread/get',
+ path: '/list/*/emailIds',
+ },
+ properties: ['id', 'messageId'],
+ },
+ 'r',
+ ],
+ ]);
+ const { list = [] } = expectResult>(
+ responses,
+ 'r',
+ 'Email/get',
+ );
+ const original = list.find(
+ (candidate) =>
+ candidate.id !== draft.id &&
+ (candidate.messageId ?? []).includes(wanted),
+ );
+ return original?.id ?? null;
+ } catch (error) {
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 취소한 발송의 답장 대상을 찾지 못해 스레드 머리글 없이 이어 씁니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
+ );
+ return null;
+ }
+ }
+
/**
* 메일 안에 함께 실을 그림을 엔진에 올린다 (서명 그림 — 한 장 300KB 이하, 3장까지).
* 한꺼번에 올리고 짧게 기다린다 — 일반 첨부 업로드의 제한 시간(120초)을 쓰면 발송이 클라이언트 한도(60초)를 넘는다.
@@ -3310,11 +4202,21 @@ export class JmapMailProvider implements MailProvider {
return buildThreadHeaders(email);
}
- private async discardDraft(user: string, draftId: string): Promise {
+ /**
+ * 보내지 못한 초안을 지운다 — 실패해도 던지지 않고 경고만 남긴다.
+ * @param timeoutMs 제한 시간 — 발송 중에는 남은 시간 안에서(finishSubmission), 주지 않으면 기본 10초
+ */
+ private async discardDraft(
+ user: string,
+ draftId: string,
+ timeoutMs?: number,
+ ): Promise {
try {
- const responses = await this.jmap.call(user, (accountId) => [
- ['Email/set', { accountId, destroy: [draftId] }, 'x'],
- ]);
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [['Email/set', { accountId, destroy: [draftId] }, 'x']],
+ timeoutMs === undefined ? undefined : { timeoutMs },
+ );
const set = expectResult(responses, 'x', 'Email/set');
const failure = firstSetError(set.notDestroyed);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
diff --git a/backend/src/modules/mail/jmap/jmap.errors.spec.ts b/backend/src/modules/mail/jmap/jmap.errors.spec.ts
index 319e56a..8337fa2 100644
--- a/backend/src/modules/mail/jmap/jmap.errors.spec.ts
+++ b/backend/src/modules/mail/jmap/jmap.errors.spec.ts
@@ -2,6 +2,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import {
describeFailure,
isMissingAttachmentBlob,
+ isRequestRefused,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
@@ -147,3 +148,48 @@ describe('로그용 실패 요약 (describeFailure)', () => {
expect(describeFailure('문자열')).toBe('unknown');
});
});
+
+describe('엔진이 요청을 실행하지 않았다고 답한 실패 (isRequestRefused — 9단계 리뷰 2)', () => {
+ const failure = (code: 'MAIL_001' | 'VAL_001' | 'REQ_001', context = {}) =>
+ new BusinessException(code, { context });
+
+ it('HTTP 4xx(요청 전체 거절 · 그 밖의 4xx) · 429 는 실행하지 않은 것이다', () => {
+ expect(
+ isRequestRefused(
+ failure('VAL_001', {
+ reason: 'request-rejected',
+ jmapType: 'notRequest',
+ }),
+ ),
+ ).toBe(true);
+ expect(
+ isRequestRefused(
+ failure('MAIL_001', { reason: 'http-error', call: 'api', status: 400 }),
+ ),
+ ).toBe(true);
+ expect(
+ isRequestRefused(
+ failure('MAIL_001', { reason: 'http-error', call: 'api', status: 413 }),
+ ),
+ ).toBe(true);
+ expect(
+ isRequestRefused(
+ failure('REQ_001', { reason: 'mail-engine-rate-limit', call: 'api' }),
+ ),
+ ).toBe(true);
+ });
+
+ it('시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 · 코드 없는 오류는 아니다 — 엔진이 실행했는지 모른다', () => {
+ for (const context of [
+ { reason: 'timeout', call: 'api' },
+ { reason: 'network', call: 'api' },
+ { reason: 'http-error', call: 'api', status: 502 },
+ { reason: 'malformed-response', call: 'api' },
+ { reason: 'http-error', call: 'api' },
+ ]) {
+ expect(isRequestRefused(failure('MAIL_001', context))).toBe(false);
+ }
+ expect(isRequestRefused(new Error('terminated'))).toBe(false);
+ expect(isRequestRefused('문자열')).toBe(false);
+ });
+});
diff --git a/backend/src/modules/mail/jmap/jmap.errors.ts b/backend/src/modules/mail/jmap/jmap.errors.ts
index 16813a2..bd9784a 100644
--- a/backend/src/modules/mail/jmap/jmap.errors.ts
+++ b/backend/src/modules/mail/jmap/jmap.errors.ts
@@ -97,3 +97,25 @@ export function describeFailure(error: unknown): string {
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
+
+/** 요청 거절의 HTTP 상태 범위 — 4xx */
+const CLIENT_ERROR_MIN = 400;
+const SERVER_ERROR_MIN = 500;
+
+/**
+ * 엔진이 요청을 **실행하지 않았다고** 답한 실패인가 (jmap.client.ts 의 call · exchange 가 던진 것):
+ * HTTP 4xx — 요청 전체 거절(`request-rejected` — invalidRequestCode · 그 밖의 4xx `http-error`) · 429(REQ_001). JMAP 은 이런 요청의 메서드를 하나도 돌리지 않는다(RFC 8620 §3.6.1).
+ * 시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답은 아니다 — 엔진이 실행했는지 모른다 (발송이면 나갔을 수 있다)
+ */
+export function isRequestRefused(error: unknown): boolean {
+ if (!(error instanceof BusinessException)) return false;
+ if (error.code === 'REQ_001') return true;
+ const { reason, status } = error.context ?? {};
+ if (reason === 'request-rejected') return true;
+ return (
+ reason === 'http-error' &&
+ typeof status === 'number' &&
+ status >= CLIENT_ERROR_MIN &&
+ status < SERVER_ERROR_MIN
+ );
+}
diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts
index b9b2ba3..8bc77e3 100644
--- a/backend/src/modules/mail/jmap/jmap.mapper.ts
+++ b/backend/src/modules/mail/jmap/jmap.mapper.ts
@@ -10,6 +10,7 @@ import type {
MailVacation,
MessageDetailSource,
MessageSummary,
+ PendingSend,
} from '@/modules/mail/mail.types';
import type {
JmapBodyPart,
@@ -171,7 +172,13 @@ function trueKeys(record: Record | undefined): string[] {
.map(([key]) => key);
}
-export function toSummary(email: JmapEmail): MessageSummary {
+/**
+ * 요약 — 기다리는 발송(pendingSend — 9단계)은 같은 요청의 제출 기록으로 어댑터가 정해 넘긴다 (jmap/pending-send.ts, 없으면 null)
+ */
+export function toSummary(
+ email: JmapEmail,
+ pendingSend: PendingSend | null = null,
+): MessageSummary {
return {
id: email.id,
threadId: email.threadId ?? email.id,
@@ -185,6 +192,7 @@ export function toSummary(email: JmapEmail): MessageSummary {
isFlagged: email.keywords?.$flagged === true,
hasAttachment: hasRealAttachment(email.attachments ?? []),
size: email.size ?? 0,
+ pendingSend,
};
}
@@ -248,7 +256,16 @@ function joinBodyValues(
};
}
-export function toDetailSource(email: JmapEmail): MessageDetailSource {
+/**
+ * 상세 원본 — 기다리는 발송 · 스팸메일함에 들었는지는 같은 요청의 제출 · 메일함으로 어댑터가 정해 넘긴다 (없으면 null · false)
+ */
+export function toDetailSource(
+ email: JmapEmail,
+ extra: {
+ readonly pendingSend?: PendingSend | null;
+ readonly isInJunkMailbox?: boolean;
+ } = {},
+): MessageDetailSource {
const attachments = downloadableAttachments(email);
const inlineParts: InlinePart[] = attachments.flatMap((part, index) =>
typeof part.cid === 'string' && part.cid.trim() !== ''
@@ -267,7 +284,8 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource {
const text = joinBodyValues(email, email.textBody, 'text/plain');
return {
- ...toSummary(email),
+ ...toSummary(email, extra.pendingSend ?? null),
+ isInJunkMailbox: extra.isInJunkMailbox ?? false,
cc: toAddresses(email.cc),
bcc: toAddresses(email.bcc),
replyTo: toAddresses(email.replyTo),
diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts
index ff573c8..d96798a 100644
--- a/backend/src/modules/mail/jmap/jmap.types.ts
+++ b/backend/src/modules/mail/jmap/jmap.types.ts
@@ -82,6 +82,8 @@ export interface JmapEmail {
hasAttachment?: boolean;
size?: number;
messageId?: string[] | null;
+ /** In-Reply-To 머리글의 Message-ID (꺾쇠 없이) */
+ inReplyTo?: string[] | null;
references?: string[] | null;
bodyValues?: Record;
htmlBody?: JmapBodyPart[];
@@ -146,8 +148,25 @@ export interface JmapDeliveryStatus {
/** RFC 8621 §7 EmailSubmission 중 이 어댑터가 읽는 부분 */
export interface JmapEmailSubmission {
id: string;
+ /** 이 제출이 보내는 메일 */
+ emailId?: string;
+ /**
+ * pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7).
+ * 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계)
+ */
+ undoStatus?: string | null;
+ /** 엔진이 보낼(보낸) 시각 — UTCDate. 붙잡은 제출은 풀리는 시각 (HOLDFOR · HOLDUNTIL — 2026-10-29 확인) */
+ sendAt?: string | null;
/** 받는 사람 주소 → 결과 (엔진이 모르면 null) */
deliveryStatus?: Record | null;
+ /**
+ * 이 제출의 envelope — 받는 사람(rcptTo)은 엔진이 받은 주소뿐이다(받지 않는 주소가 있으면 제출 전체를 거절한다 — envelope.ts).
+ * 엔진은 주소를 소문자 · 국제화 도메인은 `xn--` 꼴로 둔다 (2026-10-29 확인)
+ */
+ envelope?: {
+ mailFrom?: { email?: string | null } | null;
+ rcptTo?: { email?: string | null }[] | null;
+ } | null;
}
export interface JmapGetResult {
@@ -168,12 +187,21 @@ export interface JmapChangesResult {
destroyed?: string[];
}
+/** set 의 created 항목 — 엔진이 만든 뒤 알려 주는 속성. id 는 늘 오고, 제출(EmailSubmission)은 보낼 시각 · 상태도 준다 (2026-10-29 확인) */
+export interface JmapCreatedObject {
+ id?: string;
+ /** EmailSubmission — 엔진이 보낼 시각 (붙잡은 제출은 풀리는 시각) */
+ sendAt?: string | null;
+ /** EmailSubmission — pending · final · canceled */
+ undoStatus?: string | null;
+}
+
export interface JmapSetResult {
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
oldState?: string | null;
/** 이 요청이 바꾼 뒤의 상태 */
newState?: string;
- created?: Record;
+ created?: Record;
updated?: Record;
destroyed?: string[];
notCreated?: Record;
@@ -208,7 +236,10 @@ export type JmapEmailFilter =
export interface JmapQueryResult {
ids?: string[];
position?: number;
+ /** calculateTotal 을 물었을 때만 */
total?: number;
+ /** 엔진이 정한 한도 — 우리가 준 limit 을 줄였거나(RFC 8620 §5.5) 그 한도에 닿아 잘랐을 때만 온다 (Stalwart 0.16.27, 2026-10-29 확인) */
+ limit?: number;
}
/** RFC 9425 Quota */
diff --git a/backend/src/modules/mail/jmap/pending-send.spec.ts b/backend/src/modules/mail/jmap/pending-send.spec.ts
new file mode 100644
index 0000000..c9868e1
--- /dev/null
+++ b/backend/src/modules/mail/jmap/pending-send.spec.ts
@@ -0,0 +1,357 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ CANCELED_SEND_KEYWORD,
+ HELD_SUBMISSIONS_MAX,
+ SCHEDULED_SEND_KEYWORD,
+ heldAmong,
+ heldEmailsFilter,
+ heldQueryFull,
+ heldSendTimes,
+ heldSubmissionFilter,
+ holdParameters,
+ isHeld,
+ pendingSendOf,
+ readHeldEmails,
+ readHeldSendTimes,
+ submissionCalls,
+ submittedPendingSend,
+} from './pending-send';
+import type { JmapEmailSubmission, JmapInvocation } from './jmap.types';
+
+const NOW = Date.parse('2026-10-29T06:00:00.000Z');
+const LATER = '2026-10-29T07:00:00Z';
+const EARLIER = '2026-10-29T05:00:00Z';
+
+const submission = (
+ extra: Partial = {},
+): JmapEmailSubmission => ({
+ id: 'sb1',
+ emailId: 'm1',
+ undoStatus: 'pending',
+ sendAt: LATER,
+ ...extra,
+});
+
+describe('pending-send — 붙잡아 둔 발송(9단계)의 순수 규칙', () => {
+ it('붙잡는 방법 → 엔진의 MAIL FROM 매개변수 — 보내기 취소는 HOLDFOR 초, 예약은 HOLDUNTIL(소수 초 없는 UTC)', () => {
+ expect(holdParameters({ kind: 'undo', seconds: 10 })).toEqual({
+ HOLDFOR: '10',
+ });
+ expect(
+ holdParameters({ kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' }),
+ ).toEqual({ HOLDUNTIL: '2026-11-02T00:30:00Z' });
+ });
+
+ it('기다리는 제출은 보낼 시각(after — 엔진의 색인)으로 찾는다 — 같은 요청에 query → get(#ids), 보낼 시각 차례(정렬 이름은 sentAt), 500개까지, total 도 묻는다', () => {
+ expect(heldSubmissionFilter(NOW)).toEqual({
+ after: '2026-10-29T06:00:00Z',
+ });
+ const calls = submissionCalls('d', { emailIds: ['m1'] }, 'p');
+ expect(calls).toEqual([
+ [
+ 'EmailSubmission/query',
+ {
+ accountId: 'd',
+ filter: { emailIds: ['m1'] },
+ // Stalwart 0.16.27 은 sendAt 을 unsupportedSort 로 거절하고 RFC 8621 §7.3 의 이름 sentAt 으로 sendAt 차례를 준다 (2026-10-29 확인)
+ sort: [{ property: 'sentAt', isAscending: true }],
+ limit: HELD_SUBMISSIONS_MAX,
+ // 다 받았는지는 응답의 total 로 정한다 (9단계 리뷰 2)
+ calculateTotal: true,
+ },
+ 'pq',
+ ],
+ [
+ 'EmailSubmission/get',
+ {
+ accountId: 'd',
+ '#ids': {
+ resultOf: 'pq',
+ name: 'EmailSubmission/query',
+ path: '/ids',
+ },
+ properties: ['emailId', 'undoStatus', 'sendAt'],
+ },
+ 'pg',
+ ],
+ ]);
+ });
+
+ it('메일 id 로 다시 묻는 조건 — 계정 전체 조회와 같은 "지금 뒤" 조건에 메일 id 를 더한다 (9단계 리뷰 2 — 지난 제출이 상한을 채우지 않게)', () => {
+ expect(heldEmailsFilter(['m1', 'm2'], NOW)).toEqual({
+ emailIds: ['m1', 'm2'],
+ after: '2026-10-29T06:00:00Z',
+ });
+ });
+
+ it('붙잡고 있는 제출 — pending 이고 보낼 시각이 아직 오지 않은 것만 (배달 중인 잠깐의 pending · final · canceled 는 아니다)', () => {
+ expect(isHeld(submission(), NOW)).toBe(true);
+ expect(isHeld(submission({ sendAt: EARLIER }), NOW)).toBe(false);
+ expect(isHeld(submission({ sendAt: '2026-10-29T06:00:00Z' }), NOW)).toBe(
+ false,
+ );
+ expect(isHeld(submission({ undoStatus: 'final' }), NOW)).toBe(false);
+ expect(isHeld(submission({ undoStatus: 'canceled' }), NOW)).toBe(false);
+ expect(isHeld(submission({ sendAt: null }), NOW)).toBe(false);
+ expect(isHeld(submission({ sendAt: '모름' }), NOW)).toBe(false);
+ });
+
+ it('메일 id → 보낼 시각(ISO 8601 UTC) — 붙잡은 것만, 한 메일에 여럿이면 가장 이른 것, 메일 id 없는 제출은 뺀다', () => {
+ const times = heldSendTimes(
+ [
+ submission({ id: 'a', emailId: 'm1', sendAt: '2026-10-29T08:00:00Z' }),
+ submission({ id: 'b', emailId: 'm1', sendAt: LATER }),
+ submission({ id: 'c', emailId: 'm2', undoStatus: 'final' }),
+ submission({ id: 'd', emailId: undefined }),
+ submission({ id: 'e', emailId: 'm3', sendAt: EARLIER }),
+ ],
+ NOW,
+ );
+ expect([...times]).toEqual([['m1', '2026-10-29T07:00:00.000Z']]);
+ });
+
+ it('같은 요청의 제출 읽기 결과 — 엔진이 거절했거나 응답이 없으면 빈 표(목록 · 상세를 실패로 만들지 않는다)', () => {
+ const ok: JmapInvocation[] = [
+ ['EmailSubmission/get', { list: [submission()] }, 'pg'],
+ ];
+ expect([...readHeldSendTimes(ok, 'p', NOW)]).toEqual([
+ ['m1', '2026-10-29T07:00:00.000Z'],
+ ]);
+ expect(
+ readHeldSendTimes([['error', { type: 'forbidden' }, 'pg']], 'p', NOW)
+ .size,
+ ).toBe(0);
+ expect(readHeldSendTimes([], 'p', NOW).size).toBe(0);
+ });
+
+ it('보이기 경로의 계정 전체 조회가 다 주지 못했는지 — 상한 · 엔진이 줄인 limit 에 닿았거나 total 보다 적으면 true(경고용), 다 받았거나 모르면 · 오류 · 응답 없음은 false', () => {
+ const ids = (count: number) =>
+ Array.from({ length: count }, (_, index) => `s${index}`);
+ const full = (answer: Record) =>
+ heldQueryFull([['EmailSubmission/query', answer, 'pq']], 'p');
+ expect(full({ ids: ids(HELD_SUBMISSIONS_MAX) })).toBe(true);
+ expect(full({ ids: ids(HELD_SUBMISSIONS_MAX - 1) })).toBe(false);
+ // 엔진이 limit 을 줄였다 · total 보다 적게 왔다 (9단계 리뷰 2)
+ expect(full({ ids: ids(250), limit: 250 })).toBe(true);
+ expect(full({ ids: ids(250), total: 600 })).toBe(true);
+ expect(full({ ids: ids(3), total: 3 })).toBe(false);
+ expect(heldQueryFull([['error', { type: 'serverFail' }, 'pq']], 'p')).toBe(
+ false,
+ );
+ expect(heldQueryFull([], 'p')).toBe(false);
+ });
+
+ describe('막기 경로 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (fail closed)', () => {
+ /** 조회 · 읽기 응답 — 조회의 ids 는 count 개, total 은 엔진이 준 값(기본: 받은 수 그대로 — Stalwart 0.16.27 은 calculateTotal 이면 준다) */
+ const responses = (
+ list: JmapEmailSubmission[],
+ count = list.length,
+ extra: Record = { total: count },
+ ): JmapInvocation[] => [
+ [
+ 'EmailSubmission/query',
+ {
+ ids: Array.from({ length: count }, (_, index) => `s${index}`),
+ position: 0,
+ ...extra,
+ },
+ 'hq',
+ ],
+ ['EmailSubmission/get', { list }, 'hg'],
+ ];
+
+ it('붙잡힌 제출의 메일만 — pending 이고 보낼 시각이 아직 오지 않은 것, 조회가 맞는 제출을 다 주었으면(total) complete', () => {
+ const held = readHeldEmails(
+ responses([
+ submission({ id: 'a', emailId: 'm1' }),
+ submission({ id: 'b', emailId: 'm2', undoStatus: 'canceled' }),
+ submission({ id: 'c', emailId: 'm3', sendAt: EARLIER }),
+ submission({ id: 'd', emailId: undefined }),
+ submission({ id: 'e', emailId: 'm4', undoStatus: 'final' }),
+ ]),
+ 'h',
+ NOW,
+ );
+ expect([...held.ids]).toEqual(['m1']);
+ expect(held.complete).toBe(true);
+ // 상한(500)을 받았고 더 있다 — 다 받지 못했다. 맞는 것이 꼭 500개였으면 다 받은 것이다
+ expect(
+ readHeldEmails(
+ responses([], HELD_SUBMISSIONS_MAX, {
+ total: HELD_SUBMISSIONS_MAX + 1,
+ }),
+ 'h',
+ NOW,
+ ).complete,
+ ).toBe(false);
+ expect(
+ readHeldEmails(responses([], HELD_SUBMISSIONS_MAX), 'h', NOW).complete,
+ ).toBe(true);
+ });
+
+ it.each<[string, number, Record]>([
+ // 엔진의 쿼리 최대가 우리 limit(500)보다 작다 — limit 을 줄여 알리고 그만큼만 준다 (RFC 8620 §5.5)
+ ['엔진이 limit 을 줄여 알림', 250, { limit: 250, total: 600 }],
+ // 줄였다고 알리지 않아도 total 이 더 크면 더 있다
+ ['엔진이 말없이 적게 줌 (total 이 더 큼)', 250, { total: 600 }],
+ // total 을 주지 않으면 다 받았는지 모른다 — 닫힌 쪽
+ ['total 이 없음', 3, {}],
+ ['total 은 없고 줄인 limit 에 닿음', 250, { limit: 250 }],
+ ['처음부터가 아님 (position)', 3, { total: 3, position: 2 }],
+ ])(
+ '다 받았는지를 요청한 limit 이 아니라 응답으로 정한다 — %s: complete 가 아니다 (9단계 리뷰 2)',
+ (_label, count, extra) => {
+ expect(readHeldEmails(responses([], count, extra), 'h', NOW)).toEqual({
+ ids: new Set(),
+ complete: false,
+ });
+ },
+ );
+
+ it('보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(닫힌 쪽) — 보이기 경로(heldSendTimes)는 그대로 뺀다 (9단계 리뷰 2)', () => {
+ const unreadable = [
+ submission({ id: 'a', emailId: 'm1', sendAt: null }),
+ submission({ id: 'b', emailId: 'm2', sendAt: '모름' }),
+ submission({ id: 'c', emailId: 'm3', sendAt: undefined }),
+ // 상태를 읽지 못한 것도 같은 쪽 — 끝난 제출(final · canceled)만 아니다
+ submission({ id: 'd', emailId: 'm4', undoStatus: undefined }),
+ submission({ id: 'e', emailId: 'm5', undoStatus: '?', sendAt: null }),
+ // 보낼 시각이 지난 pending(배달 중) · 끝난 제출은 막지 않는다
+ submission({ id: 'f', emailId: 'm6', sendAt: EARLIER }),
+ submission({
+ id: 'g',
+ emailId: 'm7',
+ undoStatus: 'final',
+ sendAt: null,
+ }),
+ submission({
+ id: 'h',
+ emailId: 'm8',
+ undoStatus: 'canceled',
+ sendAt: null,
+ }),
+ ];
+ expect([...readHeldEmails(responses(unreadable), 'h', NOW).ids]).toEqual([
+ 'm1',
+ 'm2',
+ 'm3',
+ 'm4',
+ 'm5',
+ ]);
+ expect(heldSendTimes(unreadable, NOW).size).toBe(0);
+ });
+
+ it.each<[string, JmapInvocation[]]>([
+ [
+ '조회가 오류',
+ [
+ ['error', { type: 'serverFail' }, 'hq'],
+ ['error', { type: 'serverFail' }, 'hg'],
+ ],
+ ],
+ [
+ '읽기가 오류',
+ [
+ ['EmailSubmission/query', { ids: [] }, 'hq'],
+ ['error', { type: 'forbidden' }, 'hg'],
+ ],
+ ],
+ ['응답 없음', []],
+ ])(
+ '읽지 못하면(%s) 던진다 — MAIL_001 (보이기 경로의 readHeldSendTimes 처럼 삼키지 않는다)',
+ (_label, broken) => {
+ let thrown: unknown;
+ try {
+ readHeldEmails(broken, 'h', NOW);
+ } catch (error) {
+ thrown = error;
+ }
+ expect(thrown).toBeInstanceOf(BusinessException);
+ expect((thrown as BusinessException).code).toBe('MAIL_001');
+ expect((thrown as BusinessException).context).toMatchObject({
+ reason: 'held-send-check-failed',
+ });
+ },
+ );
+
+ it('후보 가운데 붙잡힌 메일 — 계정 전체 조회가 다 봤으면 다시 묻지 않는다', async () => {
+ const lookup = jest.fn();
+ await expect(
+ heldAmong(
+ { ids: new Set(['m1', 'm9']), complete: true },
+ ['m1', 'm2'],
+ lookup,
+ ),
+ ).resolves.toEqual(new Set(['m1']));
+ expect(lookup).not.toHaveBeenCalled();
+ });
+
+ it('계정 전체 조회가 상한에 닿았으면 후보의 메일 id 로 다시 묻는다 — 그것도 닿으면 MAIL_001', async () => {
+ const lookup = jest
+ .fn()
+ .mockResolvedValueOnce({ ids: new Set(['m2']), complete: true });
+ await expect(
+ heldAmong(
+ { ids: new Set(['m1']), complete: false },
+ ['m1', 'm2', 'm3'],
+ lookup,
+ ),
+ ).resolves.toEqual(new Set(['m1', 'm2']));
+ expect(lookup).toHaveBeenCalledWith(['m1', 'm2', 'm3']);
+
+ lookup.mockResolvedValueOnce({ ids: new Set(), complete: false });
+ await expect(
+ heldAmong({ ids: new Set(), complete: false }, ['m4'], lookup),
+ ).rejects.toMatchObject({ code: 'MAIL_001' });
+ // 후보가 없으면 묻지 않는다
+ lookup.mockClear();
+ await expect(
+ heldAmong({ ids: new Set(), complete: false }, [], lookup),
+ ).resolves.toEqual(new Set());
+ expect(lookup).not.toHaveBeenCalled();
+ });
+ });
+
+ it('발송 응답의 pendingSend — 엔진이 만든 제출의 보낼 시각, 없으면 붙잡은 값으로 어림, 이미 final · canceled 면 null', () => {
+ const undo = { kind: 'undo', seconds: 10 } as const;
+ expect(
+ submittedPendingSend(undo, { sendAt: LATER, undoStatus: 'pending' }, NOW),
+ ).toEqual({ kind: 'undo', sendAt: '2026-10-29T07:00:00.000Z' });
+ expect(submittedPendingSend(undo, {}, NOW)).toEqual({
+ kind: 'undo',
+ sendAt: '2026-10-29T06:00:10.000Z',
+ });
+ expect(
+ submittedPendingSend(
+ { kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' },
+ { sendAt: null },
+ NOW,
+ ),
+ ).toEqual({ kind: 'scheduled', sendAt: '2026-11-02T00:30:00.000Z' });
+ expect(submittedPendingSend(undo, { undoStatus: 'final' }, NOW)).toBeNull();
+ expect(
+ submittedPendingSend(undo, { undoStatus: 'canceled' }, NOW),
+ ).toBeNull();
+ });
+
+ it('메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 undo, 붙잡은 제출이 없으면 null', () => {
+ const times = new Map([['m1', '2026-10-29T07:00:00.000Z']]);
+ expect(
+ pendingSendOf(
+ { id: 'm1', keywords: { [SCHEDULED_SEND_KEYWORD]: true } },
+ times,
+ ),
+ ).toEqual({ kind: 'scheduled', sendAt: '2026-10-29T07:00:00.000Z' });
+ expect(pendingSendOf({ id: 'm1', keywords: {} }, times)).toEqual({
+ kind: 'undo',
+ sendAt: '2026-10-29T07:00:00.000Z',
+ });
+ expect(pendingSendOf({ id: 'm2', keywords: {} }, times)).toBeNull();
+ });
+
+ it('표시 이름은 엔진 키워드 규칙($ · 영문 소문자 · -)을 지킨다', () => {
+ for (const keyword of [SCHEDULED_SEND_KEYWORD, CANCELED_SEND_KEYWORD]) {
+ expect(keyword).toMatch(/^\$[a-z-]+$/);
+ }
+ });
+});
diff --git a/backend/src/modules/mail/jmap/pending-send.ts b/backend/src/modules/mail/jmap/pending-send.ts
new file mode 100644
index 0000000..5024eff
--- /dev/null
+++ b/backend/src/modules/mail/jmap/pending-send.ts
@@ -0,0 +1,344 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import type { PendingSend, SendHold } from '@/modules/mail/mail.types';
+import { toJmapUtcDate, toUtcIso } from './jmap.mapper';
+import { expectResult, findResult } from './jmap-results';
+import type {
+ JmapEmail,
+ JmapEmailSubmission,
+ JmapGetResult,
+ JmapInvocation,
+ JmapQueryResult,
+} from './jmap.types';
+
+/**
+ * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수. 엔진의 FUTURERELEASE(Stalwart 0.16.27, 2026-10-29 확인):
+ * - 제출(EmailSubmission/set create)의 `envelope.mailFrom.parameters` 에 `HOLDFOR: "<초>"` · `HOLDUNTIL: ""` → `undoStatus: pending`,
+ * `update { undoStatus: 'canceled' }` 로 취소(배달되지 않는다), 시간이 되면 보낸다(`final`)
+ * - 붙잡는 매개변수는 직접 준 envelope 의 MAIL FROM 에 싣는다 — 발송은 붙잡든 아니든 늘 envelope 을 직접 준다(받는 사람 · 참조 · 숨은참조의 주소,
+ * 엔진이 받지 않는 주소 · Bcc 머리글은 envelope.ts 머리말)
+ * - 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — "기다리는 발송"은 pending 이고 보낼 시각이 아직 오지 않은 것으로 가린다
+ * - 기다리는 제출은 보낼 시각(sendAt)으로 찾는다(`filter.after` — 엔진의 색인을 탄다, 제출 기록 462개에 4ms). `undoStatus` 조건은 모든 제출을
+ * 훑어 같은 계정에 65~140ms 라 쓰지 않는다 (같은 날 잼). 차례는 보낼 시각 오름차순 — 정렬 이름은 `sentAt` 이다(RFC 8621 §7.3 의 이름 —
+ * Stalwart 는 `sendAt` 을 unsupportedSort 로 거절하고 `sentAt` 으로 sendAt 차례를 준다, 2026-10-29 확인)
+ * - 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 — 옮기기 · 지우기 경로는 readHeldEmails · heldAmong 으로 막는다(fail closed — 읽지 못하면 MAIL_001.
+ * 보낼 시각을 읽지 못한 기다리는 제출도 막고, 다 받았는지는 응답의 total 로 정한다 — 9단계 리뷰 2)
+ */
+
+/**
+ * 예약 발송 표시 (JMAP 키워드) — 보낸 사본에 붙여 보내기 취소를 기다리는 것과 가른다. 목록 · 화면에는 표시로 내보내지 않는다.
+ * 보낸 뒤에도 남는다(해가 없다 — 기다리는 발송인지는 제출로 정한다)
+ */
+export const SCHEDULED_SEND_KEYWORD = '$ara-scheduled';
+
+/**
+ * 취소한 발송을 임시보관함으로 되돌린 초안 표시 (JMAP 키워드) — 그 메일에는 발송의 지문 머리글이 남아 있어(메일은 고칠 수 없다)
+ * 이 표시가 있어야 사용자의 임시 저장본으로 다룬다 (jmap-mail.provider.ts 의 isUserDraft). 다시 저장하면 새 초안이 되어 표시도 지문도 없어진다
+ */
+export const CANCELED_SEND_KEYWORD = '$ara-canceled';
+
+/**
+ * 한 번에 읽는 기다리는 제출 수 — 엔진의 maxObjectsInGet(500). 보낼 시각이 지금 뒤인 제출에는 취소한 예약도 든다(보낼 시각이 7일 안에 남는다) —
+ * 넘으면(응답의 total 로 안다 — queryCoverage) 보이기 경로는 경고만 남기고(보낼 시각이 이른 것부터 500개), 막기 경로는 메일 id 로 다시 묻는다(heldAmong)
+ */
+export const HELD_SUBMISSIONS_MAX = 500;
+
+/** 제출에서 읽는 속성 */
+const SUBMISSION_PROPERTIES = ['emailId', 'undoStatus', 'sendAt'];
+
+/** 제출 조회의 차례 — 보낼 시각 오름차순 (정렬 이름은 `sentAt` — 머리말) */
+const BY_SEND_TIME = [{ property: 'sentAt', isAscending: true }];
+
+const PENDING = 'pending';
+const FINAL = 'final';
+const CANCELED = 'canceled';
+
+/** 붙잡는 방법 → 엔진의 MAIL FROM 매개변수 (HOLDUNTIL 은 초 단위 UTC — 소수 초 없이) */
+export function holdParameters(hold: SendHold): Record {
+ return hold.kind === 'undo'
+ ? { HOLDFOR: String(hold.seconds) }
+ : { HOLDUNTIL: toJmapUtcDate(hold.until) };
+}
+
+/** 지금 이후에 보낼 제출을 묻는 조건 (보낼 시각으로 — 엔진의 색인) */
+export function heldSubmissionFilter(nowMs: number): { after: string } {
+ return { after: toJmapUtcDate(new Date(nowMs).toISOString()) };
+}
+
+/**
+ * 메일 id 로 붙잡힌 제출을 묻는 조건 — 계정 전체 조회(heldSubmissionFilter)와 같은 "지금 뒤" 조건에 메일 id 를 더한다(한 조건 객체 — AND).
+ * 그 메일들의 지난 제출(final · 지난 취소)이 상한을 채우지 않게 (9단계 리뷰 2 — 엔진은 둘을 함께 받는다, 2026-10-29 확인)
+ */
+export function heldEmailsFilter(
+ emailIds: readonly string[],
+ nowMs: number,
+): { emailIds: string[]; after: string } {
+ return { emailIds: [...emailIds], ...heldSubmissionFilter(nowMs) };
+}
+
+/**
+ * 같은 요청에 넣는 제출 조회 · 읽기 — 호출 id 는 `${prefix}q` · `${prefix}g`.
+ * 조회는 total 도 묻는다(calculateTotal) — 다 받았는지를 요청한 limit 이 아니라 응답으로 정한다(queryCoverage)
+ */
+export function submissionCalls(
+ accountId: string,
+ filter: Record,
+ prefix: string,
+): JmapInvocation[] {
+ return [
+ [
+ 'EmailSubmission/query',
+ {
+ accountId,
+ filter,
+ sort: BY_SEND_TIME,
+ limit: HELD_SUBMISSIONS_MAX,
+ calculateTotal: true,
+ },
+ `${prefix}q`,
+ ],
+ [
+ 'EmailSubmission/get',
+ {
+ accountId,
+ '#ids': {
+ resultOf: `${prefix}q`,
+ name: 'EmailSubmission/query',
+ path: '/ids',
+ },
+ properties: SUBMISSION_PROPERTIES,
+ },
+ `${prefix}g`,
+ ],
+ ];
+}
+
+/** 붙잡고 있는 제출인가 — pending 이고 보낼 시각이 아직 오지 않음 (보이기 경로 — 읽지 못한 보낼 시각은 붙잡지 않은 것으로) */
+export function isHeld(
+ submission: JmapEmailSubmission,
+ nowMs: number,
+): boolean {
+ const sendAt = Date.parse(submission.sendAt ?? '');
+ return submission.undoStatus === PENDING && sendAt > nowMs;
+}
+
+/**
+ * 막기 경로의 판정 — 끝난 제출(final · canceled)이 아니고, 보낼 시각이 아직 오지 않았거나 **읽지 못한** 제출 (닫힌 쪽 — 9단계 리뷰 2).
+ * 보이기 경로의 isHeld 와 다르다: 화면에는 시각을 모르는 기다리는 발송을 싣지 않지만, 옮기기 · 지우기는 "읽지 못하면 바꾸지 않는다".
+ * 상태(undoStatus)를 읽지 못한 것도 같은 쪽이다. 배달 중인 잠깐의 pending 은 보낼 시각이 지난 값으로 읽히므로 막지 않는다
+ */
+function holdsForGuard(
+ submission: JmapEmailSubmission,
+ nowMs: number,
+): boolean {
+ const { undoStatus } = submission;
+ if (undoStatus === FINAL || undoStatus === CANCELED) return false;
+ const sendAt = Date.parse(submission.sendAt ?? '');
+ return Number.isNaN(sendAt) || sendAt > nowMs;
+}
+
+/** 메일 id → 그 메일의 붙잡힌 제출이 보낼 시각(ISO 8601 UTC) — 여럿이면 가장 이른 것 */
+export function heldSendTimes(
+ submissions: readonly JmapEmailSubmission[],
+ nowMs: number,
+): Map {
+ const times = new Map();
+ for (const submission of submissions) {
+ const emailId = submission.emailId;
+ if (!emailId || !isHeld(submission, nowMs)) continue;
+ const sendAt = toUtcIso(submission.sendAt);
+ if (sendAt === null) continue;
+ const known = times.get(emailId);
+ if (known === undefined || Date.parse(sendAt) < Date.parse(known)) {
+ times.set(emailId, sendAt);
+ }
+ }
+ return times;
+}
+
+/**
+ * 같은 요청의 제출 읽기 결과 — 엔진이 거절했으면(메서드 오류 · 응답 없음) 빈 표: 기다리는 발송 표시가 빠질 뿐 목록 · 상세를 실패로 만들지 않는다.
+ * **보이기 경로 전용**이다 — 옮기기 · 지우기를 막는 데는 쓰지 않는다(실패를 삼켜 통과시킨다 — readHeldEmails 를 쓴다)
+ */
+export function readHeldSendTimes(
+ responses: JmapInvocation[],
+ prefix: string,
+ nowMs: number,
+): Map {
+ try {
+ const result = findResult(responses, `${prefix}g`, 'EmailSubmission/get');
+ if (!result.ok) return new Map();
+ const { list = [] } = result.args as JmapGetResult;
+ return heldSendTimes(list, nowMs);
+ } catch {
+ return new Map();
+ }
+}
+
+/**
+ * 제출 조회가 맞는 제출을 다 주었나 — **응답으로** 정한다(요청한 limit 이 아니라 — 9단계 리뷰 2). 엔진은 자기 최대가 더 작으면 limit 을 줄인다
+ * (RFC 8620 §5.5 — 줄였으면 응답에 limit 이 온다. Stalwart 0.16.27 은 limit 에 닿아 잘랐을 때도 limit 을 싣고, calculateTotal 이면 total 을 준다 —
+ * 2026-10-29 확인).
+ * - total 이 있으면: 처음부터(position 0) total 만큼 받았으면 'all', 아니면 'partial'
+ * - total 이 없으면: 응답의 limit(없으면 우리 상한)에 닿았으면 'partial', 닿지 않았으면 'unknown' — 엔진이 말없이 줄였을 수 있다
+ */
+function queryCoverage(query: JmapQueryResult): 'all' | 'partial' | 'unknown' {
+ const count = (query.ids ?? []).length;
+ if (typeof query.total === 'number') {
+ return (query.position ?? 0) === 0 && count >= query.total
+ ? 'all'
+ : 'partial';
+ }
+ const limit =
+ typeof query.limit === 'number'
+ ? Math.min(query.limit, HELD_SUBMISSIONS_MAX)
+ : HELD_SUBMISSIONS_MAX;
+ return count >= limit ? 'partial' : 'unknown';
+}
+
+/**
+ * 같은 요청의 제출 조회가 다 주지 못했나(상한 · 엔진이 줄인 limit · total 보다 적음) — 더 있다. 보이기 경로가 경고를 남길 때 (던지지 않는다).
+ * 다 받았는지 모를 때(total 없음)는 경고하지 않는다 — 막기 경로는 그때도 닫힌 쪽이다(readHeldEmails)
+ */
+export function heldQueryFull(
+ responses: JmapInvocation[],
+ prefix: string,
+): boolean {
+ try {
+ const result = findResult(responses, `${prefix}q`, 'EmailSubmission/query');
+ if (!result.ok) return false;
+ return queryCoverage(result.args) === 'partial';
+ } catch {
+ return false;
+ }
+}
+
+/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */
+export interface HeldEmails {
+ /** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */
+ readonly ids: ReadonlySet;
+ /**
+ * 조회가 맞는 제출을 다 주었다고 응답으로 확인했다(total — queryCoverage) — 계정 전체 조회(heldSubmissionFilter)라면 붙잡힌 메일은 이것이 전부다.
+ * 모르면 false (닫힌 쪽)
+ */
+ readonly complete: boolean;
+}
+
+/** 메일 id 로 물었는데도 다 받지 못했다 — 가릴 수 없다: 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */
+export function heldOverLimit(candidates: number): BusinessException {
+ return new BusinessException('MAIL_001', {
+ context: { reason: 'held-submissions-over-limit', candidates },
+ });
+}
+
+/** 막기 경로가 판정하지 못했다 — 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */
+function heldCheckFailed(cause: unknown): BusinessException {
+ const code = cause instanceof BusinessException ? cause.code : 'unknown';
+ const jmapType =
+ cause instanceof BusinessException ? cause.context?.jmapType : undefined;
+ return new BusinessException('MAIL_001', {
+ context: {
+ reason: 'held-send-check-failed',
+ cause: code,
+ ...(jmapType !== undefined ? { jmapType } : {}),
+ },
+ });
+}
+
+/**
+ * 같은 요청의 제출 조회 · 읽기(submissionCalls)로 붙잡힌 메일 — **fail closed**: 조회 · 읽기를 엔진이 거절했거나 응답이 없으면 던진다(MAIL_001),
+ * 보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(holdsForGuard), 다 받았는지는 응답으로 정한다(queryCoverage — 모르면 complete 가 아니다).
+ * 옮기기 · 지우기 경로(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다")가 쓴다 — 보이기 경로는 readHeldSendTimes
+ */
+export function readHeldEmails(
+ responses: JmapInvocation[],
+ prefix: string,
+ nowMs: number,
+): HeldEmails {
+ let query: JmapQueryResult;
+ let list: JmapEmailSubmission[];
+ try {
+ query = expectResult(
+ responses,
+ `${prefix}q`,
+ 'EmailSubmission/query',
+ );
+ list =
+ expectResult>(
+ responses,
+ `${prefix}g`,
+ 'EmailSubmission/get',
+ ).list ?? [];
+ } catch (error) {
+ throw heldCheckFailed(error);
+ }
+ const ids = new Set();
+ for (const submission of list) {
+ if (submission.emailId && holdsForGuard(submission, nowMs)) {
+ ids.add(submission.emailId);
+ }
+ }
+ return { ids, complete: queryCoverage(query) === 'all' };
+}
+
+/**
+ * 메일 id 로 붙잡힌 메일을 다시 묻는다 (submissionCalls 의 heldEmailsFilter — 계정 전체 조회와 같은 "지금 뒤" 조건 + 메일 id, readHeldEmails)
+ * — 다 받지 못한 계정 전체 조회의 보충
+ */
+export type HeldLookup = (emailIds: readonly string[]) => Promise;
+
+/**
+ * 후보 가운데 붙잡힌 메일 — 같은 요청에 덧붙여 읽은 계정 전체 조회(account)로 가린다. 그 조회가 다 주지 못했으면(complete 가 아님) 후보의 메일 id 로 다시 묻는다(lookup).
+ * 다시 물은 것도 다 받지 못하면 가릴 수 없다 — MAIL_001 (fail closed)
+ */
+export async function heldAmong(
+ account: HeldEmails,
+ candidates: readonly string[],
+ lookup: HeldLookup,
+): Promise> {
+ const held = new Set(candidates.filter((id) => account.ids.has(id)));
+ if (account.complete || candidates.length === 0) return held;
+ const exact = await lookup(candidates);
+ if (!exact.complete) throw heldOverLimit(candidates.length);
+ for (const id of candidates) {
+ if (exact.ids.has(id)) held.add(id);
+ }
+ return held;
+}
+
+/** 1초 (밀리초) */
+const SECOND_MS = 1_000;
+
+/**
+ * 붙잡아 만든 제출의 기다리는 발송 (발송 응답의 pendingSend) — 보낼 시각은 엔진이 만든 제출에 알려 준 값(없으면 붙잡은 값으로 어림).
+ * 엔진이 이미 final · canceled 로 답했으면 null
+ */
+export function submittedPendingSend(
+ hold: SendHold,
+ created: {
+ readonly sendAt?: string | null;
+ readonly undoStatus?: string | null;
+ },
+ submittedAtMs: number,
+): PendingSend | null {
+ if (created.undoStatus && created.undoStatus !== PENDING) return null;
+ const sendAt =
+ toUtcIso(created.sendAt) ??
+ (hold.kind === 'undo'
+ ? new Date(submittedAtMs + hold.seconds * SECOND_MS).toISOString()
+ : toUtcIso(hold.until));
+ return sendAt === null ? null : { sendAt, kind: hold.kind };
+}
+
+/** 메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 보내기 취소를 기다리는 중(undo) */
+export function pendingSendOf(
+ email: Pick,
+ times: ReadonlyMap,
+): PendingSend | null {
+ const sendAt = times.get(email.id);
+ if (sendAt === undefined) return null;
+ return {
+ sendAt,
+ kind:
+ email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo',
+ };
+}
diff --git a/backend/src/modules/mail/jmap/sieve-script.spec.ts b/backend/src/modules/mail/jmap/sieve-script.spec.ts
index 876f5cd..b37822d 100644
--- a/backend/src/modules/mail/jmap/sieve-script.spec.ts
+++ b/backend/src/modules/mail/jmap/sieve-script.spec.ts
@@ -1,4 +1,8 @@
-import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
+import type {
+ BlockedSender,
+ MailFilterRule,
+ MailVacation,
+} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
@@ -82,6 +86,7 @@ const representative: FilterScriptSource = {
}),
],
vacation: vacationOn,
+ blocked: [],
};
/** 스크립트의 줄 — 머리 주석 두 줄(설명 · 원본)은 따로 본다 */
@@ -170,6 +175,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
}),
],
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
});
expect(text).toContain(
'if allof (header :contains "subject" "a\\"b\\\\c 가$나{}") {',
@@ -186,6 +192,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
}),
],
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
});
const uploaded = Buffer.from(built.text, 'utf8').toString('utf8');
@@ -200,6 +207,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
buildFilterScript({
rules: [rule('one')],
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
}).text,
);
expect(lines.slice(2)).toEqual([
@@ -218,6 +226,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
const { text } = buildFilterScript({
rules,
vacation: NEVER_CONFIGURED_VACATION,
+ blocked: [],
});
const lines = linesOf(text);
expect(lines).toHaveLength(3);
@@ -228,7 +237,9 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
describe('부재중', () => {
const vacationLines = (vacation: MailVacation) =>
- linesOf(buildFilterScript({ rules: [], vacation }).text).slice(2);
+ linesOf(
+ buildFilterScript({ rules: [], vacation, blocked: [] }).text,
+ ).slice(2);
it('날짜가 없으면 기간 검사 없이 — date 확장도 쓰지 않는다', () => {
const lines = vacationLines({
@@ -275,7 +286,11 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
it('꺼져 있으면 부재중이 없다 — 원본에는 남는다', () => {
const off = { ...vacationOn, isEnabled: false };
- const { text } = buildFilterScript({ rules: [rule('a')], vacation: off });
+ const { text } = buildFilterScript({
+ rules: [rule('a')],
+ vacation: off,
+ blocked: [],
+ });
expect(text).not.toContain('vacation :mime');
expect(linesOf(text)[2]).toBe('require ["imap4flags"];');
expect(sourceOf(text)?.vacation).toEqual(off);
@@ -289,6 +304,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
subject: '부재 ${x} "중" \\',
textBody: '${unicode:41} \\ "따옴표"',
},
+ blocked: [],
});
const sieve = linesOf(text).slice(2).join('\r\n');
expect(sieve).not.toContain('${');
@@ -304,6 +320,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
subject: '가'.repeat(150),
textBody: '나'.repeat(600),
},
+ blocked: [],
});
expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8'));
// 내용 1,800바이트 → base64 2,400자 + 줄바꿈 · MIME 머리 · 따옴표
@@ -311,3 +328,105 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
expect(built.longestStringBytes).toBeLessThan(4_096);
});
});
+
+describe('buildFilterScript — 보낸 사람 차단(9단계)', () => {
+ const at = '2026-10-29T00:00:00.000Z';
+ const blocked: BlockedSender[] = [
+ { address: 'kim@spam.example', blockedAt: at },
+ { address: '@junk.example', blockedAt: at },
+ { address: 'lee@spam.example', blockedAt: at },
+ ];
+ const junkMailbox = { id: 'jk1', name: 'Junk Mail' };
+
+ it('차단은 스팸 막기 감싸기 밖 맨 앞 — 주소(:all) · 도메인(:domain)을 anyof 로, 스팸메일함에 넣고 멈춘다. 그 뒤가 부재중 · 규칙', () => {
+ const source: FilterScriptSource = {
+ rules: [rule('r1')],
+ vacation: { ...vacationOn, fromDate: null, toDate: null },
+ blocked,
+ };
+ const { text } = buildFilterScript(source, { junkMailbox });
+ const lines = linesOf(text);
+
+ expect(lines[1]).toBe(filterSourceLine(source));
+ expect(lines.slice(2, 8)).toEqual([
+ 'require ["fileinto", "imap4flags", "mailbox", "mailboxid", "vacation"];',
+ 'if anyof (address :all :is "from" ["kim@spam.example", "lee@spam.example"], address :domain :is "from" ["junk.example"]) {',
+ ' fileinto :mailboxid "jk1" "Junk Mail";',
+ ' stop;',
+ '}',
+ 'if not header :matches "X-Spam-Score" "spam*" {',
+ ]);
+ expect(lines[8]).toMatch(/^ {2}vacation :mime /);
+ // 엔진에 없는 비교 방법(i;unicode-casemap)은 쓰지 않는다 — 기본 비교(ASCII 대소문자 무시)
+ expect(text).not.toContain(':comparator');
+ expect(sourceOf(text)?.blocked).toEqual(blocked);
+ });
+
+ it('주소만 · 도메인만이면 그 검사 하나 — anyof 없이', () => {
+ const only = (list: BlockedSender[]) =>
+ linesOf(
+ buildFilterScript(
+ { rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: list },
+ { junkMailbox },
+ ).text,
+ ).slice(2);
+
+ expect(only([blocked[0]])).toEqual([
+ 'require ["fileinto", "mailbox", "mailboxid"];',
+ 'if address :all :is "from" ["kim@spam.example"] {',
+ ' fileinto :mailboxid "jk1" "Junk Mail";',
+ ' stop;',
+ '}',
+ '',
+ ]);
+ expect(only([blocked[1]])[1]).toBe(
+ 'if address :domain :is "from" ["junk.example"] {',
+ );
+ });
+
+ it('차단만 있으면 스팸 막기 감싸기를 쓰지 않는다 — 켠 규칙 · 부재중이 없을 때', () => {
+ const { text } = buildFilterScript(
+ {
+ rules: [rule('off', { isEnabled: false })],
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked,
+ },
+ { junkMailbox },
+ );
+ expect(text).not.toContain('X-Spam-Score');
+ expect(text).toContain('stop;');
+ });
+
+ it('차단이 없으면 지금과 같다 — 판 1 원본, 차단 검사 · 스팸메일함 없이 (스팸메일함을 받아도)', () => {
+ const plain = buildFilterScript(representative);
+ const withJunk = buildFilterScript(representative, { junkMailbox });
+ expect(withJunk.text).toBe(plain.text);
+ expect(plain.text).not.toContain('address :');
+ });
+
+ it('차단이 있는데 스팸메일함을 받지 못하면 만들지 않는다 (어댑터가 그 앞에서 MAIL_001)', () => {
+ const source: FilterScriptSource = {
+ rules: [],
+ vacation: NEVER_CONFIGURED_VACATION,
+ blocked,
+ };
+ expect(() => buildFilterScript(source)).toThrow();
+ expect(() => buildFilterScript(source, { junkMailbox: null })).toThrow();
+ });
+
+ it('스팸메일함 이름의 따옴표 · 역슬래시는 이스케이프한다 — 바이트 수 · 가장 긴 문자열에 차단 목록도 든다', () => {
+ const many = Array.from({ length: 200 }, (_, i) => ({
+ address: `sender${i}@spam.example`,
+ blockedAt: at,
+ }));
+ const built = buildFilterScript(
+ { rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: many },
+ { junkMailbox: { id: 'jk1', name: '스팸 "메일" \\' } },
+ );
+ expect(built.text).toContain(
+ 'fileinto :mailboxid "jk1" "스팸 \\"메일\\" \\\\";',
+ );
+ expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8'));
+ expect(built.bytes).toBeGreaterThan(200 * 'sender0@spam.example'.length);
+ });
+});
diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts
index 890c9b9..4e92e1d 100644
--- a/backend/src/modules/mail/jmap/sieve-script.ts
+++ b/backend/src/modules/mail/jmap/sieve-script.ts
@@ -1,4 +1,5 @@
import type {
+ BlockedSender,
MailFilterCondition,
MailFilterField,
MailFilterRule,
@@ -15,12 +16,19 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* # 설명 한 줄
* # arasystem-source: {원본 JSON — filter-source.ts}
* require [쓰는 확장만];
+ * if anyof (address :all … :is "from" [차단한 주소들], address :domain … :is "from" [차단한 도메인들]) { ← 9단계 "보낸 사람 차단" — 있을 때만
+ * fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>";
+ * stop;
+ * }
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
* }
* ```
*
+ * - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다.
+ * 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고,
+ * id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절)
* - 스팸: 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로 막는다 — 엔진의 **분류 표시**를 본다 (SPAM_VERDICT_HEADER).
* `spamtest` 의 점수는 쓰지 않는다: 점수를 정수로 내린 값(10에서 멈춤)이라 기준(엔진 설정 `scoreSpam` — 지금 5)과 상관없는 문턱을
* 우리가 따로 정하게 된다 — 9.70점 스팸이 "10 이상"을 지나 규칙대로 옮겨지고 위조한 보낸 사람에게 부재중 응답까지 나갔다
@@ -70,7 +78,19 @@ const INDENT = ' ';
/** 첫 줄 — 엔진의 관리 화면 등에서 이 스크립트를 보는 사람에게 */
const DESCRIPTION_LINE =
- '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
+ '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 · 보낸 사람 차단 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
+
+/** 차단한 메일이 들어갈 스팸메일함 — id 로 가리키고 없으면 이름으로 (어댑터가 엔진의 메일함 목록에서 찾아 넘긴다) */
+export interface JunkMailboxTarget {
+ readonly id: string;
+ readonly name: string;
+}
+
+/** 스크립트를 만들 때 원본 밖에서 정하는 것 */
+export interface FilterScriptOptions {
+ /** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */
+ readonly junkMailbox?: JunkMailboxTarget | null;
+}
/**
* 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인).
@@ -233,11 +253,61 @@ function vacationBlock(
return lines;
}
-/** 규칙 · 부재중 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES) */
+/**
+ * 차단 — 머리글 From 의 주소(`:all`)나 도메인(`:domain`)이 차단 목록에 있으면 스팸메일함으로 넣고 멈춘다 (스팸 막기 감싸기 밖, 맨 앞).
+ * 주소 · 도메인이 한쪽만 있으면 그 검사 하나, 둘 다면 anyof.
+ * 비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이라 `:comparator` 를 쓰지 않는다. 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없다
+ * (모르는 비교 방법을 받아 주기는 하지만 기대지 않는다 — 2026-10-29 세션의 sieveExtensions 로 확인). 도메인은 ASCII(punycode)로만 받고
+ * 주소 · 도메인은 소문자로 저장해 두었다 — 머리글 쪽의 대소문자와 상관없이 맞는다 (실제 배달로 확인 — 계약 2절 "보낸 사람 차단")
+ */
+function blockSection(
+ writer: ScriptWriter,
+ blocked: readonly BlockedSender[],
+ junk: JunkMailboxTarget,
+): string[] {
+ const addresses = blocked
+ .filter(({ address }) => !address.startsWith('@'))
+ .map(({ address }) => address);
+ const domains = blocked
+ .filter(({ address }) => address.startsWith('@'))
+ .map(({ address }) => address.slice(1));
+ const list = (values: readonly string[]) =>
+ `[${values.map((value) => writer.quote(value)).join(', ')}]`;
+ const tests = [
+ ...(addresses.length > 0
+ ? [`address :all :is "from" ${list(addresses)}`]
+ : []),
+ ...(domains.length > 0
+ ? [`address :domain :is "from" ${list(domains)}`]
+ : []),
+ ];
+ writer.use('fileinto', 'mailbox', 'mailboxid');
+ return [
+ `if ${tests.length === 1 ? tests[0] : `anyof (${tests.join(', ')})`} {`,
+ indent(
+ 1,
+ `fileinto :mailboxid ${writer.quote(junk.id)} ${writer.quote(junk.name)};`,
+ ),
+ indent(1, 'stop;'),
+ '}',
+ ];
+}
+
+/**
+ * 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES).
+ * 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001)
+ */
export function buildFilterScript(
source: FilterScriptSource,
+ options: FilterScriptOptions = {},
): BuiltFilterScript {
const writer = new ScriptWriter();
+ let blocks: string[] = [];
+ if (source.blocked.length > 0) {
+ const junk = options.junkMailbox;
+ if (!junk) throw new Error('차단 목록을 쓰려면 스팸메일함이 있어야 한다');
+ blocks = blockSection(writer, source.blocked, junk);
+ }
const body: string[] = [];
if (source.vacation.isEnabled) {
body.push(...vacationBlock(writer, source.vacation, 1));
@@ -247,8 +317,15 @@ export function buildFilterScript(
}
const lines = [DESCRIPTION_LINE, filterSourceLine(source)];
- if (body.length > 0) {
- const spamCheck = `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`;
+ if (blocks.length > 0 || body.length > 0) {
+ const spamCheck =
+ body.length > 0
+ ? [
+ `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`,
+ ...body,
+ '}',
+ ]
+ : [];
const extensions = EXTENSION_ORDER.filter((extension) =>
writer.extensions.has(extension),
);
@@ -258,7 +335,7 @@ export function buildFilterScript(
`require [${extensions.map((extension) => writer.quote(extension)).join(', ')}];`,
);
}
- lines.push(spamCheck, ...body, '}');
+ lines.push(...blocks, ...spamCheck);
}
const text = `${lines.join(CRLF)}${CRLF}`;
diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts
new file mode 100644
index 0000000..6d9a922
--- /dev/null
+++ b/backend/src/modules/mail/mail-block.controller.ts
@@ -0,0 +1,110 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpStatus,
+ Param,
+ Post,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import type { Response } from 'express';
+
+import {
+ ApiErrorResponse,
+ ApiStandardResponse,
+} from '@/common/decorators/api-standard-response.decorator';
+import {
+ CurrentUser,
+ type AuthenticatedUser,
+} from '@/common/decorators/current-user.decorator';
+import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
+import {
+ BlockedSenderDto,
+ MailBlockCreateDto,
+ MailBlockListDto,
+ MailBlockRemovedDto,
+} from './dto/mail-block.dto';
+import { MailBlockService } from './mail-block.service';
+import {
+ MAIL_BLOCKS_ROUTE,
+ MAX_EMAIL_LENGTH,
+ MAX_MAIL_BLOCKS,
+} from './mail.constants';
+
+/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */
+const BLOCK_NOTE =
+ '차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' +
+ '이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
+
+// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계)
+@ApiTags('Mail')
+@ApiCookieAuth()
+@UseGuards(JwtAuthGuard)
+@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
+@ApiErrorResponse(
+ 'MAIL_001',
+ '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다',
+)
+@Controller(MAIL_BLOCKS_ROUTE)
+export class MailBlockController {
+ constructor(private readonly blocks: MailBlockService) {}
+
+ @Get()
+ @ApiOperation({
+ summary: '차단한 주소',
+ description: `${BLOCK_NOTE} 최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지. 한 번도 차단하지 않았으면 빈 목록이다.`,
+ })
+ @ApiStandardResponse(MailBlockListDto, {
+ description: '차단 목록 · 동작하는지',
+ })
+ list(@CurrentUser() user: AuthenticatedUser) {
+ return this.blocks.list(user);
+ }
+
+ @Post()
+ @ApiOperation({
+ summary: '보낸 사람 차단 (주소 또는 @도메인)',
+ description:
+ `${BLOCK_NOTE} 이미 차단한 주소면 바꾸지 않고 200 으로 그 항목을 돌려준다(새로 차단하면 201). ` +
+ `내 주소 · 사내 도메인(그 하위 포함) · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과는 400 VAL_001.`,
+ })
+ @ApiStandardResponse(BlockedSenderDto, {
+ status: HttpStatus.CREATED,
+ description: '차단한 항목 (이미 있었으면 200 으로 그 항목)',
+ })
+ @ApiErrorResponse(
+ 'VAL_001',
+ `VAL_001 형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과 · 모르는 필드 ` +
+ '/ MAIL_013 필터 · 부재중과 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
+ )
+ async add(
+ @CurrentUser() user: AuthenticatedUser,
+ @Body() dto: MailBlockCreateDto,
+ @Res({ passthrough: true }) res: Response,
+ ): Promise {
+ const { sender, created } = await this.blocks.add(user, dto);
+ // 새로 차단했으면 201(POST 의 기본), 이미 있던 주소면 200
+ if (!created) res.status(HttpStatus.OK);
+ return sender;
+ }
+
+ @Delete(':address')
+ @ApiOperation({
+ summary: '차단 풀기',
+ description:
+ '경로의 주소 · @도메인은 URL 인코딩한다(대소문자는 가리지 않는다). 이미 받은 메일은 옮기지 않는다.',
+ })
+ @ApiStandardResponse(MailBlockRemovedDto, {
+ description: '푼 주소 · 도메인',
+ })
+ @ApiErrorResponse('RES_001', '차단한 적 없는 주소 · 형식이 틀린 주소')
+ remove(
+ @CurrentUser() user: AuthenticatedUser,
+ @Param('address') address: string,
+ ) {
+ return this.blocks.remove(user, address);
+ }
+}
diff --git a/backend/src/modules/mail/mail-block.service.spec.ts b/backend/src/modules/mail/mail-block.service.spec.ts
new file mode 100644
index 0000000..dec18b4
--- /dev/null
+++ b/backend/src/modules/mail/mail-block.service.spec.ts
@@ -0,0 +1,161 @@
+import { Logger } from '@nestjs/common';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import { MailBlockService } from './mail-block.service';
+import type { MailProvider } from './mail.provider';
+import type { MailSettings } from './mail.settings';
+import type { BlockedSender } from './mail.types';
+
+const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
+
+const settings = {
+ domain: 'arasystem.test',
+} as unknown as MailSettings;
+
+function fakeProvider() {
+ return {
+ getMailBlocks: jest.fn().mockResolvedValue({ senders: [], isActive: true }),
+ addMailBlock: jest.fn((_user: string, sender: BlockedSender) =>
+ Promise.resolve({ sender, created: true }),
+ ),
+ removeMailBlock: jest.fn().mockResolvedValue(undefined),
+ };
+}
+
+async function failureOf(
+ run: () => Promise,
+): Promise<{ code?: string; reason?: unknown }> {
+ try {
+ await run();
+ } catch (error) {
+ return error instanceof BusinessException
+ ? { code: error.code, reason: error.context?.reason }
+ : { code: 'NOT_BUSINESS' };
+ }
+ return {};
+}
+
+describe('MailBlockService — 보낸 사람 차단 (9단계)', () => {
+ let provider: ReturnType;
+ let service: MailBlockService;
+ let logSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ provider = fakeProvider();
+ service = new MailBlockService(
+ provider as unknown as MailProvider,
+ settings,
+ );
+ logSpy = jest
+ .spyOn(Logger.prototype, 'log')
+ .mockImplementation(() => undefined);
+ });
+ afterEach(() => jest.restoreAllMocks());
+
+ const logged = () =>
+ logSpy.mock.calls.map(([line]) => String(line)).join('\n');
+
+ it('차단 — 앞뒤 공백을 빼고 소문자로, 차단한 시각은 서버 시계(UTC). 로그의 주소는 가린다', async () => {
+ const result = await service.add(user, { address: ' Kim@Spam.Example ' });
+
+ expect(result.created).toBe(true);
+ expect(result.sender.address).toBe('kim@spam.example');
+ expect(result.sender.blockedAt).toMatch(
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
+ );
+ expect(provider.addMailBlock).toHaveBeenCalledWith(user.email, {
+ address: 'kim@spam.example',
+ blockedAt: result.sender.blockedAt,
+ });
+ expect(logged()).not.toContain('kim@spam.example');
+ expect(logged()).not.toContain(user.email);
+ });
+
+ it('도메인 차단의 로그는 도메인을 그대로 — 이미 있던 주소면 로그를 남기지 않는다', async () => {
+ await service.add(user, { address: '@Ads.Example.org' });
+ expect(logged()).toContain('도메인=@ads.example.org');
+
+ logSpy.mockClear();
+ provider.addMailBlock.mockResolvedValueOnce({
+ sender: {
+ address: '@ads.example.org',
+ blockedAt: '2026-10-28T00:00:00.000Z',
+ },
+ created: false,
+ });
+ await expect(
+ service.add(user, { address: '@ads.example.org' }),
+ ).resolves.toMatchObject({ created: false });
+ expect(logSpy).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [
+ '내 주소(대소문자 무시)',
+ 'ARA.KIM@arasystem.test',
+ 'mail-block-own-address',
+ ],
+ ['사내 도메인', '@arasystem.test', 'mail-block-internal-domain'],
+ ['사내 하위 도메인', '@mail.AraSystem.test', 'mail-block-internal-domain'],
+ [
+ '사내 직원 주소',
+ 'doyun.lee@arasystem.test',
+ 'mail-block-internal-domain',
+ ],
+ [
+ '여러 사람이 쓰는 메일 서비스',
+ '@gmail.com',
+ 'mail-block-public-mail-domain',
+ ],
+ ['모양이 아님', 'not an address', 'malformed-mail-block'],
+ ])(
+ '막을 수 없는 대상은 VAL_001 — %s (엔진에 묻지 않는다)',
+ async (_label, address, reason) => {
+ expect(await failureOf(() => service.add(user, { address }))).toEqual({
+ code: 'VAL_001',
+ reason,
+ });
+ expect(provider.addMailBlock).not.toHaveBeenCalled();
+ },
+ );
+
+ it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소 · 여러 사람이 쓰는 서비스는 그대로 막는다', async () => {
+ const unconfigured = new MailBlockService(
+ provider as unknown as MailProvider,
+ { domain: null } as unknown as MailSettings,
+ );
+ await expect(
+ unconfigured.add(user, { address: '@arasystem.test' }),
+ ).resolves.toMatchObject({ created: true });
+ expect(
+ await failureOf(() => unconfigured.add(user, { address: user.email })),
+ ).toMatchObject({ code: 'VAL_001' });
+ expect(
+ await failureOf(() => unconfigured.add(user, { address: '@naver.com' })),
+ ).toMatchObject({ code: 'VAL_001' });
+ });
+
+ it('차단 풀기 — 경로의 주소를 차단 모양으로 맞춰 넘기고 그 값을 돌려준다. 모양이 틀리면 RES_001', async () => {
+ await expect(service.remove(user, ' LEE@Spam.Example ')).resolves.toEqual({
+ address: 'lee@spam.example',
+ });
+ expect(provider.removeMailBlock).toHaveBeenCalledWith(
+ user.email,
+ 'lee@spam.example',
+ );
+
+ expect(await failureOf(() => service.remove(user, 'a b'))).toEqual({
+ code: 'RES_001',
+ reason: 'malformed-mail-block',
+ });
+ expect(provider.removeMailBlock).toHaveBeenCalledTimes(1);
+ });
+
+ it('목록은 포트가 준 그대로', async () => {
+ await expect(service.list(user)).resolves.toEqual({
+ senders: [],
+ isActive: true,
+ });
+ expect(provider.getMailBlocks).toHaveBeenCalledWith(user.email);
+ });
+});
diff --git a/backend/src/modules/mail/mail-block.service.ts b/backend/src/modules/mail/mail-block.service.ts
new file mode 100644
index 0000000..372c25a
--- /dev/null
+++ b/backend/src/modules/mail/mail-block.service.ts
@@ -0,0 +1,91 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+
+import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
+import { BusinessException } from '@/common/errors/business.exception';
+import { traceTag } from '@/shared/logger/request-context';
+import { maskEmail } from '@/shared/utils/mask';
+import type { MailBlockCreateDto } from './dto/mail-block.dto';
+import {
+ findBlockProblem,
+ parseBlockTarget,
+ type BlockTarget,
+} from './mail-blocks';
+import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
+import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
+import type { MailBlockAdded, MailBlockList } from './mail.types';
+
+/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */
+function maskedTarget(target: BlockTarget): string {
+ return target.kind === 'domain' ? target.value : maskEmail(target.value);
+}
+
+/**
+ * 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다.
+ * - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001)
+ * - 500개 · 엔진 규칙 크기(MAIL_013)는 어댑터가 저장하는 그 판으로 본다
+ * - 차단한 시각은 서버 시계(UTC)
+ */
+@Injectable()
+export class MailBlockService {
+ private readonly logger = new Logger(MailBlockService.name);
+
+ constructor(
+ @Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
+ @Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
+ ) {}
+
+ list(user: AuthenticatedUser): Promise {
+ return this.provider.getMailBlocks(user.email);
+ }
+
+ /** 차단을 더한다 — 이미 있으면 그 항목(created: false — 컨트롤러가 200 으로 답한다) */
+ async add(
+ user: AuthenticatedUser,
+ dto: MailBlockCreateDto,
+ ): Promise {
+ const target = parseBlockTarget(dto.address);
+ if (target === null) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'malformed-mail-block' },
+ });
+ }
+ const problem = findBlockProblem(target, {
+ self: user.email,
+ internalDomain: this.settings.domain,
+ });
+ if (problem !== null) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: `mail-block-${problem}`, kind: target.kind },
+ });
+ }
+
+ const result = await this.provider.addMailBlock(user.email, {
+ address: target.value,
+ blockedAt: new Date().toISOString(),
+ });
+ if (result.created) {
+ this.logger.log(
+ `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
+ );
+ }
+ return result;
+ }
+
+ /** 차단을 푼다 — 경로의 주소(URL 인코딩을 푼 값). 모양이 틀리거나 없는 주소면 RES_001 */
+ async remove(
+ user: AuthenticatedUser,
+ raw: string,
+ ): Promise<{ address: string }> {
+ const target = parseBlockTarget(raw);
+ if (target === null) {
+ throw new BusinessException('RES_001', {
+ context: { reason: 'malformed-mail-block' },
+ });
+ }
+ await this.provider.removeMailBlock(user.email, target.value);
+ this.logger.log(
+ `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
+ );
+ return { address: target.value };
+ }
+}
diff --git a/backend/src/modules/mail/mail-blocks.spec.ts b/backend/src/modules/mail/mail-blocks.spec.ts
new file mode 100644
index 0000000..7588c18
--- /dev/null
+++ b/backend/src/modules/mail/mail-blocks.spec.ts
@@ -0,0 +1,172 @@
+import { PUBLIC_MAIL_DOMAINS } from './mail.constants';
+import {
+ findBlockProblem,
+ isBlockableDomain,
+ parseBlockTarget,
+} from './mail-blocks';
+
+const CONTEXT = {
+ self: 'Ara.Kim@arasystem.test',
+ internalDomain: 'arasystem.test',
+};
+
+describe('parseBlockTarget — 차단할 주소 · @도메인의 모양', () => {
+ it.each([
+ ['kim@spam.example', { kind: 'address', value: 'kim@spam.example' }],
+ [' Kim@SPAM.Example ', { kind: 'address', value: 'kim@spam.example' }],
+ [
+ 'news+promo@mail.shop.example',
+ { kind: 'address', value: 'news+promo@mail.shop.example' },
+ ],
+ // dot-atom 의 atext 기호는 모두 받는다 (프론트 isStrictEmail 과 같다)
+ [
+ "o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example",
+ {
+ kind: 'address',
+ value: "o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example",
+ },
+ ],
+ ['@spam.example', { kind: 'domain', value: '@spam.example' }],
+ ['@SPAM.Example', { kind: 'domain', value: '@spam.example' }],
+ [
+ '@mail.sub-domain.example',
+ { kind: 'domain', value: '@mail.sub-domain.example' },
+ ],
+ [
+ '@xn--vg1b002a.example',
+ { kind: 'domain', value: '@xn--vg1b002a.example' },
+ ],
+ ])('%j → %j', (raw, target) => {
+ expect(parseBlockTarget(raw)).toEqual(target);
+ });
+
+ it.each([
+ ['빈 글', ''],
+ ['@ 뒤가 없음', '@'],
+ ['점이 없는 도메인', '@localhost'],
+ ['IP 주소', '@192.168.0.1'],
+ ['IPv6', '@::1'],
+ ['숫자만인 최상위', '@spam.123'],
+ ['- 로 시작하는 낱말', '@-spam.example'],
+ ['- 로 끝나는 낱말', '@spam-.example'],
+ ['빈 낱말', '@spam..example'],
+ ['밑줄', '@spam_x.example'],
+ ['한글 도메인(xn-- 로 쓴다)', '@뉴스.example'],
+ ['도메인 끝 점', '@spam.example.'],
+ ['너무 긴 낱말', `@${'a'.repeat(64)}.example`],
+ ['너무 긴 도메인', `@${'a.'.repeat(130)}example`],
+ ['주소가 아님', 'kim'],
+ ['@ 가 둘', 'kim@spam@example.com'],
+ ['변수 표시', 'a${x}@spam.example'],
+ // 따옴표 앞부분 — IsEmail 은 받지만 Sieve 글에 줄바꿈 · 제어 문자가 들어가고 엔진은 따옴표를 뗀 주소와 견준다 (2026-10-29 리뷰)
+ ['따옴표 앞부분 안의 줄바꿈', '"a\nb"@spam.example'],
+ ['따옴표 앞부분 안의 CR LF', '"a\r\nb"@spam.example'],
+ ['따옴표 앞부분 안의 제어 문자', '"a\u0001b"@spam.example'],
+ ['따옴표 앞부분 안의 빈칸', '"a b"@spam.example'],
+ ['따옴표 앞부분 안의 탭', '"a\tb"@spam.example'],
+ ['따옴표 앞부분', '"ab"@spam.example'],
+ ['@ 앞의 점으로 시작', '.kim@spam.example'],
+ ['@ 앞의 점으로 끝남', 'kim.@spam.example'],
+ ['@ 앞의 연달은 점', 'k..im@spam.example'],
+ ['@ 앞 65자', `${'a'.repeat(65)}@spam.example`],
+ ['@ 앞의 한글', '김@spam.example'],
+ [
+ '254자 넘음',
+ `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(63)}.com`,
+ ],
+ ])('%s(%j)는 차단 대상이 아니다', (_label, raw) => {
+ expect(parseBlockTarget(raw)).toBeNull();
+ });
+
+ it('도메인 모양은 마지막 낱말이 숫자만이면(IP 꼴) 받지 않는다', () => {
+ expect(isBlockableDomain('spam.example')).toBe(true);
+ expect(isBlockableDomain('1.2.3.4')).toBe(false);
+ expect(isBlockableDomain('spam.4')).toBe(false);
+ expect(isBlockableDomain('4spam.example')).toBe(true);
+ });
+});
+
+describe('findBlockProblem — 막을 수 없는 대상', () => {
+ const target = (raw: string) => {
+ const parsed = parseBlockTarget(raw);
+ if (!parsed) throw new Error(`모양이 틀림: ${raw}`);
+ return parsed;
+ };
+
+ it('내 주소 · 사내 도메인(그 하위 포함)의 주소와 도메인은 막을 수 없다', () => {
+ expect(findBlockProblem(target('ara.kim@arasystem.test'), CONTEXT)).toBe(
+ 'own-address',
+ );
+ for (const raw of [
+ 'doyun.lee@arasystem.test',
+ 'noreply@mail.arasystem.test',
+ '@arasystem.test',
+ '@ARASYSTEM.test',
+ '@hr.arasystem.test',
+ ]) {
+ expect(findBlockProblem(target(raw), CONTEXT)).toBe('internal-domain');
+ }
+ });
+
+ it('사내 도메인과 끝만 같은 다른 도메인은 막을 수 있다 (점으로 나뉜 하위만 사내)', () => {
+ expect(findBlockProblem(target('@notarasystem.test'), CONTEXT)).toBeNull();
+ expect(findBlockProblem(target('kim@xarasystem.test'), CONTEXT)).toBeNull();
+ });
+
+ it('여러 사람이 쓰는 메일 서비스 목록 — 글자 · 차례 그대로 (짝: frontend src/shared/constants/mail-block.ts 의 PUBLIC_MAIL_DOMAINS)', () => {
+ expect(PUBLIC_MAIL_DOMAINS).toEqual([
+ 'gmail.com',
+ 'googlemail.com',
+ 'naver.com',
+ 'daum.net',
+ 'hanmail.net',
+ 'kakao.com',
+ 'nate.com',
+ 'empas.com',
+ 'hanmir.com',
+ 'korea.com',
+ 'dreamwiz.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'yahoo.com',
+ 'yahoo.co.kr',
+ 'icloud.com',
+ 'me.com',
+ 'mac.com',
+ 'aol.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'gmx.com',
+ 'gmx.net',
+ 'mail.com',
+ 'zoho.com',
+ 'yandex.com',
+ 'qq.com',
+ '163.com',
+ ]);
+ });
+
+ it('여러 사람이 쓰는 메일 서비스는 도메인으로는 막을 수 없고 주소로는 된다 (목록 그대로)', () => {
+ expect(PUBLIC_MAIL_DOMAINS).toHaveLength(30);
+ for (const domain of PUBLIC_MAIL_DOMAINS) {
+ expect(findBlockProblem(target(`@${domain}`), CONTEXT)).toBe(
+ 'public-mail-domain',
+ );
+ expect(findBlockProblem(target(`spammer@${domain}`), CONTEXT)).toBeNull();
+ }
+ // 하위 도메인은 목록에 없으면 막을 수 있다
+ expect(findBlockProblem(target('@mail.naver.com'), CONTEXT)).toBeNull();
+ });
+
+ it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소는 그대로 막는다', () => {
+ const unconfigured = { self: CONTEXT.self, internalDomain: null };
+ expect(
+ findBlockProblem(target('@arasystem.test'), unconfigured),
+ ).toBeNull();
+ expect(
+ findBlockProblem(target('ara.kim@arasystem.test'), unconfigured),
+ ).toBe('own-address');
+ });
+});
diff --git a/backend/src/modules/mail/mail-blocks.ts b/backend/src/modules/mail/mail-blocks.ts
new file mode 100644
index 0000000..d4aca3c
--- /dev/null
+++ b/backend/src/modules/mail/mail-blocks.ts
@@ -0,0 +1,121 @@
+import { isEmail } from 'class-validator';
+import { isIP } from 'net';
+
+import {
+ MAX_EMAIL_LENGTH,
+ PUBLIC_MAIL_DOMAINS,
+ SIEVE_VARIABLE_MARKER,
+} from './mail.constants';
+
+/**
+ * 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 메일 엔진과 무관한 순수 규칙.
+ * 요청 검증(dto/mail-block.dto.ts)이 모양을, 서비스가 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스를 이것으로 본다.
+ * 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 모양 규칙으로 본다 — 어긋나면 깨진 원본이다.
+ *
+ * - 주소: 발송의 주소와 같은 규칙(IsEmail · 254자)에 더해 @ 앞은 dot-atom 만(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~ 덩어리를 점으로 —
+ * 빈칸 · 제어 문자 · 따옴표 없음, 64자) — 소문자로 맞춰 둔다. 따옴표 앞부분(`"a b"@…`)은 IsEmail 이 받지만 Sieve 글에 줄바꿈이 그대로 들어가고
+ * 엔진은 따옴표를 뗀 주소와 견주므로 결코 맞지 않는다(2026-10-29 리뷰). 프론트의 isStrictEmail(shared/utils/mail-address.ts)과 같은 @ 앞 규칙이다
+ * - 도메인: `@` + 호스트 이름 — 점이 하나 이상, 253자까지, 낱말은 영문 · 숫자 · `-`(앞뒤는 아님) 1~63자, 마지막 낱말이 숫자만은 아님(IP 주소 아님)
+ * — 국제화 도메인은 `xn--` 꼴로 쓴다(머리글에 오는 모양). 그 도메인에서 오는 메일 모두이고 하위 도메인은 따로다
+ * - `${` 는 받지 않는다 — 엔진이 규칙 글 안의 `${…}` 를 늘 변수로 펼쳐 다른 주소를 막게 된다 (계약 2절 "만드는 Sieve")
+ */
+
+/** 차단 대상 — 주소 · 도메인 (value 는 소문자, 도메인은 `@` 를 붙인 모양 — 계약의 BlockedSender.address) */
+export interface BlockTarget {
+ readonly kind: 'address' | 'domain';
+ readonly value: string;
+}
+
+/** 서비스가 막는 까닭 — 로그용 */
+export type BlockProblem =
+ | 'own-address'
+ | 'internal-domain'
+ | 'public-mail-domain';
+
+/** 호스트 이름 최대 길이 (RFC 1035) */
+const MAX_HOSTNAME_LENGTH = 253;
+/** 호스트 이름 낱말 하나 — 영문 · 숫자 · '-', 앞뒤는 '-' 가 아님, 1~63자 */
+const HOSTNAME_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
+/** 숫자만인 낱말 — 마지막 낱말(최상위 도메인)이 이렇다면 IP 주소 꼴이다 */
+const NUMERIC_LABEL = /^\d+$/;
+/**
+ * 주소의 @ 앞 — RFC 5322 dot-atom (atext 덩어리를 점으로 잇는다 — 앞 · 뒤 · 연달은 점 없음, 64자). 덩어리 안에 점이 없어 입력 길이에 비례한다.
+ * 짝: frontend `src/shared/utils/mail-address.ts` 의 `DOT_ATOM_LOCAL_PART` · `LOCAL_PART_MAX_LENGTH` — 한쪽을 고치면 둘 다
+ */
+const DOT_ATOM_LOCAL_PART =
+ /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
+const LOCAL_PART_MAX_LENGTH = 64;
+
+const PUBLIC_DOMAINS: ReadonlySet = new Set(PUBLIC_MAIL_DOMAINS);
+
+/** 차단할 수 있는 도메인 모양인가 (소문자로 맞춘 값, `@` 없이) */
+export function isBlockableDomain(domain: string): boolean {
+ if (domain.length === 0 || domain.length > MAX_HOSTNAME_LENGTH) return false;
+ if (isIP(domain) !== 0) return false;
+ const labels = domain.split('.');
+ if (labels.length < 2) return false;
+ if (!labels.every((label) => HOSTNAME_LABEL.test(label))) return false;
+ return !NUMERIC_LABEL.test(labels[labels.length - 1]);
+}
+
+/** 차단할 수 있는 주소의 @ 앞인가 — dot-atom · 64자 (빈칸 · 제어 문자 · 따옴표 없음) */
+function isDotAtomLocalPart(local: string): boolean {
+ return (
+ local.length > 0 &&
+ local.length <= LOCAL_PART_MAX_LENGTH &&
+ DOT_ATOM_LOCAL_PART.test(local)
+ );
+}
+
+/**
+ * 주소 · `@도메인` 글 → 차단 대상 (앞뒤 공백을 빼고 소문자로) — 모양이 틀리면 null.
+ * 요청 검증 · 경로(DELETE) · 저장해 둔 원본 읽기(jmap/filter-source.ts 의 toBlocked)가 같이 쓴다 — 그래서 원본에 따옴표 앞부분이 있으면 깨진 원본이다
+ */
+export function parseBlockTarget(raw: string): BlockTarget | null {
+ const value = raw.trim().toLowerCase();
+ if (value.length === 0 || value.length > MAX_EMAIL_LENGTH) return null;
+ if (value.includes(SIEVE_VARIABLE_MARKER)) return null;
+ if (value.startsWith('@')) {
+ return isBlockableDomain(value.slice(1)) ? { kind: 'domain', value } : null;
+ }
+ const at = value.lastIndexOf('@');
+ if (at <= 0 || !isDotAtomLocalPart(value.slice(0, at))) return null;
+ return isEmail(value) ? { kind: 'address', value } : null;
+}
+
+/** 주소 · 도메인 대상의 도메인 (`@` 없이) */
+export function blockTargetDomain(target: BlockTarget): string {
+ return target.value.slice(target.value.lastIndexOf('@') + 1);
+}
+
+/** 사내 도메인이거나 그 하위 도메인인가 */
+function isInternalDomain(domain: string, internal: string): boolean {
+ return domain === internal || domain.endsWith(`.${internal}`);
+}
+
+/**
+ * 막을 수 없는 대상 — 내 주소, 사내 도메인(MAIL_DOMAIN 과 그 하위)의 주소 · 도메인, 여러 사람이 쓰는 메일 서비스의 도메인(주소로는 된다).
+ * 사내 도메인을 모르면(엔진 설정 없음 — 비운영) 사내 검사는 건너뛴다
+ */
+export function findBlockProblem(
+ target: BlockTarget,
+ context: { readonly self: string; readonly internalDomain: string | null },
+): BlockProblem | null {
+ const domain = blockTargetDomain(target);
+ if (
+ target.kind === 'address' &&
+ target.value === context.self.trim().toLowerCase()
+ ) {
+ return 'own-address';
+ }
+ if (
+ context.internalDomain !== null &&
+ isInternalDomain(domain, context.internalDomain)
+ ) {
+ return 'internal-domain';
+ }
+ if (target.kind === 'domain' && PUBLIC_DOMAINS.has(domain)) {
+ return 'public-mail-domain';
+ }
+ return null;
+}
diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts
index eaf0a65..f7208ad 100644
--- a/backend/src/modules/mail/mail.constants.ts
+++ b/backend/src/modules/mail/mail.constants.ts
@@ -11,6 +11,12 @@ export const MAIL_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;
/** 받는 사람(to + cc + bcc) 합계 상한 */
export const MAX_RECIPIENTS = 100;
+/**
+ * 발송 때 메일 서버가 받지 않는 주소(모르는 최상위 도메인 등 — 계약 2절 "받는 사람별 결과")를 빼며 같은 메일을 다시 내는 차례의 상한 (9단계 리뷰 2).
+ * 서버는 한 번에 주소 하나만 알려 주므로 처음 제출 + 이만큼 — 이보다 하나 많은 주소까지 뺀다. 더 빼야 하면 보내지 않는다(400 VAL_001)
+ */
+export const MAX_ENVELOPE_RESUBMITS = 20;
+
/** 제목 최대 길이 */
export const MAX_SUBJECT_LENGTH = 255;
@@ -129,6 +135,70 @@ export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
*/
export const SIEVE_VARIABLE_MARKER = '${';
+/**
+ * 붙잡아 둔 발송(보내기 취소 · 예약) 라우트 경로 (`/api/scheduled-messages`) — 계약 2절 "보내기 취소 · 예약 발송" (9단계)
+ */
+export const SCHEDULED_MESSAGES_ROUTE = 'scheduled-messages';
+
+/**
+ * 예약 발송 시각의 범위 — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안 (계약 2절 "보내기 취소 · 예약 발송"). 밖이면 400 VAL_001.
+ * 7일은 엔진 한도다(`x:MtaExtensions` 의 futureRelease — 넘기면 엔진이 forbiddenMailFrom 으로 거절, 2026-10-29 확인).
+ * 우리가 본 뒤 엔진이 보는 때까지 남은 시간은 줄기만 하므로 이 범위면 엔진 한도 안이다. 붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이다
+ */
+export const SCHEDULED_SEND_MIN_LEAD_MS = 5 * 60_000;
+export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000;
+
+/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */
+export const MAIL_BLOCKS_ROUTE = 'mail-blocks';
+
+/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */
+export const MAX_MAIL_BLOCKS = 500;
+
+/**
+ * 여러 사람이 쓰는 메일 서비스 — 도메인(`@gmail.com`)으로는 막을 수 없다(주소로는 된다). 계약 2절 "보낸 사람 차단".
+ * ⚠️ 짝: frontend `src/shared/constants/mail-block.ts` 의 `PUBLIC_MAIL_DOMAINS` — 클라이언트도 같은 목록을 먼저 본다.
+ * 한쪽을 고치면 둘 다 (차례도 같게 둔다 — mail-blocks.spec.ts 가 글자 · 차례를 그대로 본다)
+ */
+export const PUBLIC_MAIL_DOMAINS: readonly string[] = [
+ 'gmail.com',
+ 'googlemail.com',
+ 'naver.com',
+ 'daum.net',
+ 'hanmail.net',
+ 'kakao.com',
+ 'nate.com',
+ 'empas.com',
+ 'hanmir.com',
+ 'korea.com',
+ 'dreamwiz.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'yahoo.com',
+ 'yahoo.co.kr',
+ 'icloud.com',
+ 'me.com',
+ 'mac.com',
+ 'aol.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'gmx.com',
+ 'gmx.net',
+ 'mail.com',
+ 'zoho.com',
+ 'yandex.com',
+ 'qq.com',
+ '163.com',
+];
+
+/**
+ * 외부 이미지 중계 라우트 경로 (`/api/mail-images/{token}`) — 계약 2절 "외부 이미지 보기" (9단계).
+ * 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH`(`/api/mail-images/` — 본문 틀의 CSP · 정화가 남기는 주소) —
+ * 경로를 바꾸면 둘 다. 서버가 넣는 주소의 앞부분은 mail.service.ts 의 `MAIL_IMAGE_PATH_PREFIX`
+ */
+export const MAIL_IMAGES_ROUTE = 'mail-images';
+
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts
index 43ec754..e9eac09 100644
--- a/backend/src/modules/mail/mail.module.ts
+++ b/backend/src/modules/mail/mail.module.ts
@@ -9,12 +9,27 @@ import { BusinessException } from '@/common/errors/business.exception';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
+import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
+import { MailImageController } from './images/mail-image.controller';
+import {
+ MAIL_IMAGE_RESOLVER,
+ MAIL_IMAGE_TRANSPORT,
+ MailImageFetcher,
+} from './images/mail-image-fetcher';
+import { MailImageProxyService } from './images/mail-image-proxy.service';
+import { MailImageTokens } from './images/mail-image-token';
+import {
+ DnsMailImageResolver,
+ NodeMailImageTransport,
+} from './images/node-image-transport';
import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
+import { MailBlockController } from './mail-block.controller';
+import { MailBlockService } from './mail-block.service';
import { MailEventsController } from './mail-events.controller';
import {
MailFilterController,
@@ -39,6 +54,8 @@ import { MessageDeletionController } from './message-deletion.controller';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
+import { ScheduledMessageController } from './scheduled-message.controller';
+import { ScheduledMessagesService } from './scheduled-messages.service';
function readMailSettings(config: ConfigService): MailSettings {
return buildMailSettings((key) => config.get(key));
@@ -97,6 +114,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
ContactsModule,
// 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천")
MailDirectoryModule,
+ // 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계)
+ MailUserSettingsModule,
],
controllers: [
MailboxController,
@@ -106,13 +125,26 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailVacationController,
MailFilterController,
MailFilterRunController,
+ MailBlockController,
MessageController,
MessageDeletionController,
+ ScheduledMessageController,
MailAttachmentController,
+ MailImageController,
DraftController,
RecipientSuggestionController,
],
providers: [
+ // 보낸 사람 차단 (9단계) — 저장은 필터 · 부재중과 같은 엔진 규칙(포트)
+ MailBlockService,
+ // 붙잡아 둔 발송 — 예약한 메일 목록 · 취소 (9단계)
+ ScheduledMessagesService,
+ // 외부 이미지 중계 (9단계) — 표(서버가 뜰 때 만든 키로 암호화) · 바깥 받기(SSRF 막기) · 직원마다 동시 수
+ MailImageTokens,
+ MailImageFetcher,
+ MailImageProxyService,
+ { provide: MAIL_IMAGE_RESOLVER, useClass: DnsMailImageResolver },
+ { provide: MAIL_IMAGE_TRANSPORT, useClass: NodeMailImageTransport },
// 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다
JmapFilterScriptStore,
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts
index c0b35a7..72258cf 100644
--- a/backend/src/modules/mail/mail.provider.ts
+++ b/backend/src/modules/mail/mail.provider.ts
@@ -1,10 +1,13 @@
import type {
AttachmentContent,
+ BlockedSender,
Correspondence,
DraftContent,
DraftSource,
MailAccountProfile,
MailAttachment,
+ MailBlockAdded,
+ MailBlockList,
Mailbox,
MailboxBulkResult,
MailboxCreate,
@@ -96,6 +99,7 @@ export interface MailProvider {
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
+ * 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015
*/
deleteMailbox(user: string, id: string): Promise;
@@ -105,6 +109,7 @@ export interface MailProvider {
/**
* 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다.
* 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
+ * 붙잡힌 메일(9단계)은 지우지 않는다 — 남은 것이 그것뿐이고 이 요청에서 지운 것이 없으면 MAIL_015
*/
emptyMailbox(user: string, id: string): Promise;
@@ -123,6 +128,21 @@ export interface MailProvider {
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise;
+ /**
+ * 차단한 보낸 사람 (계약 2절 "보낸 사람 차단" — 9단계) — 최근에 차단한 것부터 · 동작하는지(isActive — 메일 필터의 것과 같은 값).
+ * 저장 자리는 메일 필터 · 부재중과 같은 엔진 규칙이다
+ */
+ getMailBlocks(user: string): Promise;
+
+ /**
+ * 차단을 더한다 — 이미 있으면 바꾸지 않고 그 항목(created: false). 필터 · 부재중은 그대로 두고 필터의 지문(version)을 보지 않는다.
+ * 이미 MAX_MAIL_BLOCKS(500)개면 VAL_001, 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 대상의 모양 · 막을 수 있는지는 서비스가 검사한 값이다
+ */
+ addMailBlock(user: string, sender: BlockedSender): Promise;
+
+ /** 차단을 푼다 — 없는 주소면 RES_001. 필터 · 부재중은 그대로 */
+ removeMailBlock(user: string, address: string): Promise;
+
/**
* 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 —
@@ -182,9 +202,15 @@ export interface MailProvider {
*/
listMessages(user: string, query: MessageListQuery): Promise;
- /** 조회만 한다 — 읽음 처리하지 않는다 */
+ /**
+ * 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
+ */
getMessage(user: string, id: string): Promise;
+ /**
+ * 한 통 바꾸기 — 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)의 이동 · 스팸 표시(mailboxId · isJunk)는 MAIL_015,
+ * 붙잡혔는지 확인하지 못하면 MAIL_001 (어느 쪽이든 아무것도 바꾸지 않는다). 읽음 · 중요 표시는 된다
+ */
updateMessage(
user: string,
id: string,
@@ -193,7 +219,8 @@ export interface MailProvider {
/**
* 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로.
- * 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001.
+ * 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001. 이동 · 스팸 표시는 붙잡힌 메일(9단계)에 하지 않는다 — 그 id 도 failedIds
+ * (붙잡혔는지 확인하지 못하면 아무것도 바꾸지 않고 MAIL_001)
*/
updateMessages(
user: string,
@@ -201,13 +228,13 @@ export interface MailProvider {
update: MessageUpdate,
): Promise;
- /** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
+ /** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 — 붙잡힌 메일(9단계)은 MAIL_015 (아무것도 바꾸지 않는다) */
deleteMessage(user: string, id: string): Promise;
/**
* 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다.
* 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다).
- * 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001.
+ * 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. 붙잡힌 메일(9단계)은 건너뛴다(skippedIds)
*/
deleteFromTrash(
user: string,
@@ -215,23 +242,43 @@ export interface MailProvider {
): Promise;
/**
- * @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다)
+ * @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다),
+ * 붙잡아 둔 발송이면 그 정보(pendingSend — 9단계)
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
* 받는 사람을 엔진이 모두 받지 않았으면 보내지 않은 것으로 다룬다 — 이 요청이 만든 보낸 사본을 지우고
- * MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음)
+ * MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음).
+ * `message.hold` 가 있으면 엔진이 그만큼 붙잡았다가 보낸다(보내기 취소 · 예약) — 예약은 사본에 예약 표시를 붙인다.
+ * 엔진이 붙잡을 시각을 받지 않으면(한도) VAL_001
*/
sendMessage(user: string, message: OutgoingMessage): Promise;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
* 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다).
+ * 취소한 발송(제출이 모두 canceled — 9단계)은 지우지 않고 canceled 로 돌려준다 (서비스가 MAIL_005 로 답한다)
*/
findSentMessage(
user: string,
messageId: string,
): Promise;
+ /**
+ * 예약한 메일 (계약 2절 "보내기 취소 · 예약 발송" — GET /api/scheduled-messages) — 예약 표시가 있고 아직 보내지 않은 것만,
+ * 보낼 시각 차례(같으면 id 차례). 보내기 취소를 기다리는 메일은 들지 않는다
+ */
+ listScheduledMessages(user: string): Promise;
+
+ /**
+ * 붙잡은 발송을 취소하고 그 메일을 임시보관함의 초안으로 되돌린다 (보내기 취소 · 예약 취소) — `id` 는 보낸 사본의 메일 id.
+ * 취소한 **뒤에** 다른 요청으로 옮긴다(같은 요청이면 취소가 실패해도 옮겨진다). 없는 메일 RES_001,
+ * 이미 보냈거나(엔진이 풀었음) 붙잡은 적이 없는 메일이면 MAIL_014(아무것도 바꾸지 않는다), 엔진이 취소를 확인해 주지 않으면 MAIL_001(옮기지 않는다),
+ * 취소는 됐는데 옮기지 못하면(한 번 더 한 뒤) MAIL_001.
+ * 이미 취소한 발송(제출이 모두 canceled)이면 성공 — 되돌리기를 마쳤으면(취소한 발송 표시) 어느 메일함에 있든 옮기지 않고, 마치지 못했으면 되돌리기만 다시 한다
+ * (같은 요청을 다시 보내도 같은 결과)
+ */
+ cancelPendingSend(user: string, id: string): Promise;
+
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts
index 62d709d..06b44ba 100644
--- a/backend/src/modules/mail/mail.service.spec.ts
+++ b/backend/src/modules/mail/mail.service.spec.ts
@@ -5,10 +5,15 @@ import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
+import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
+import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types';
+import type { ContactsService } from '@/modules/contacts/contacts.service';
+import type { DirectoryService } from '@/modules/contacts/directory.service';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
+import { MailImageTokens } from './images/mail-image-token';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
@@ -25,6 +30,7 @@ import type {
MailFilterRule,
MessageDetailSource,
SentMessage,
+ SentMessageRecord,
} from './mail.types';
import type { RecipientSuggestionService } from './recipient-suggestions.service';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
@@ -61,7 +67,7 @@ function mockOf(): ProviderMocks[K] {
}
/** 대역 엔진의 발송 결과 — 모두 받았다 */
-const SENT = { id: 'sent-1', rejectedRecipients: [] };
+const SENT = { id: 'sent-1', rejectedRecipients: [], pendingSend: null };
function fakeProvider(): ProviderMocks {
const sendMessage = mockOf<'sendMessage'>();
@@ -110,6 +116,11 @@ function fakeProvider(): ProviderMocks {
getDraft: mockOf<'getDraft'>(),
deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined),
listCorrespondents: mockOf<'listCorrespondents'>(),
+ getMailBlocks: mockOf<'getMailBlocks'>(),
+ addMailBlock: mockOf<'addMailBlock'>(),
+ removeMailBlock: mockOf<'removeMailBlock'>(),
+ listScheduledMessages: mockOf<'listScheduledMessages'>(),
+ cancelPendingSend: mockOf<'cancelPendingSend'>(),
};
}
@@ -172,6 +183,18 @@ async function failureOf(
return {};
}
+/** 같은 키로 이미 보낸 메일의 기록 — 취소하지 않았고 붙잡혀 있지 않다 */
+const sentRecord = (
+ fingerprint: string | null,
+ overrides: Partial = {},
+): SentMessageRecord => ({
+ id: 'sent-1',
+ fingerprint,
+ canceled: false,
+ pendingSend: null,
+ ...overrides,
+});
+
const source = (
overrides: Partial = {},
): MessageDetailSource => ({
@@ -199,6 +222,8 @@ const source = (
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
+ pendingSend: null,
+ isInJunkMailbox: false,
...overrides,
});
@@ -230,13 +255,67 @@ const draftSource = (overrides: Partial = {}): DraftSource => ({
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-27T01:00:00.000Z',
+ isCanceledSend: false,
...overrides,
});
+/** 9단계 — 보내기 · 읽기 설정과 외부 이미지 "주소록 · 사내 직원" 판정의 대역 */
+interface NinthStepMocks {
+ userSettings: {
+ get: jest.Mock, [string]>;
+ };
+ contacts: { hasEmail: jest.Mock, [string, string]> };
+ directory: { hasEmail: jest.Mock, [string]> };
+ imageTokens: MailImageTokens;
+}
+
+/**
+ * 기존 발송 테스트는 붙잡지 않는 발송(보내기 취소 0초)을 본다 — 보내기 취소 · 예약은 따로 본다.
+ * 외부 이미지는 처음 값('ask')
+ */
+function fakeNinthStep(): NinthStepMocks {
+ return {
+ userSettings: {
+ get: jest
+ .fn, [string]>()
+ .mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
+ },
+ contacts: {
+ hasEmail: jest
+ .fn, [string, string]>()
+ .mockResolvedValue(false),
+ },
+ directory: {
+ hasEmail: jest.fn, [string]>().mockResolvedValue(false),
+ },
+ imageTokens: new MailImageTokens(),
+ };
+}
+
+function createService(
+ provider: ProviderMocks,
+ mailSettings: MailSettings,
+ signatures: SignatureMocks,
+ suggestions: { invalidate: jest.Mock },
+ ninth: NinthStepMocks,
+): MailService {
+ return new MailService(
+ provider,
+ mailSettings,
+ signatures as unknown as MailSignatureService,
+ suggestions as unknown as RecipientSuggestionService,
+ ninth.userSettings as unknown as MailUserSettingsService,
+ ninth.contacts as unknown as ContactsService,
+ ninth.directory as unknown as DirectoryService,
+ ninth.imageTokens,
+ );
+}
+
describe('MailService', () => {
let provider: ProviderMocks;
let signatures: SignatureMocks;
let suggestions: { invalidate: jest.Mock };
+ let ninth: NinthStepMocks;
let service: MailService;
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
@@ -245,12 +324,8 @@ describe('MailService', () => {
provider = fakeProvider();
signatures = fakeSignatures();
suggestions = { invalidate: jest.fn() };
- service = new MailService(
- provider,
- settings,
- signatures as unknown as MailSignatureService,
- suggestions as unknown as RecipientSuggestionService,
- );
+ ninth = fakeNinthStep();
+ service = createService(provider, settings, signatures, suggestions, ninth);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
@@ -854,6 +929,7 @@ describe('MailService', () => {
isFlagged: false,
hasAttachment: false,
size: 1,
+ pendingSend: null,
});
provider.updateMessages.mockResolvedValue({
updatedIds: ['m1'],
@@ -1077,11 +1153,12 @@ describe('MailService', () => {
});
it('메일 도메인 설정이 없으면 사내 주소인지 알 수 없어 전달을 받지 않는다', async () => {
- const unconfigured = new MailService(
+ const unconfigured = createService(
provider,
{ ...settings, domain: null },
- signatures as unknown as MailSignatureService,
- suggestions as unknown as RecipientSuggestionService,
+ signatures,
+ suggestions,
+ ninth,
);
expect(
await failureOf(() =>
@@ -2164,10 +2241,7 @@ describe('MailService', () => {
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
- provider.findSentMessage.mockResolvedValue({
- id: 'sent-1',
- fingerprint,
- });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
await expect(send()).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
@@ -2179,12 +2253,16 @@ describe('MailService', () => {
const rejectedRecipients = [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
];
- provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
provider.getRejectedRecipients.mockResolvedValue(rejectedRecipients);
const result = await send();
- expect(result).toEqual({ id: 'sent-1', rejectedRecipients });
+ expect(result).toEqual({
+ id: 'sent-1',
+ rejectedRecipients,
+ pendingSend: null,
+ });
expect(result).not.toHaveProperty('fingerprint');
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.getRejectedRecipients).toHaveBeenCalledWith(
@@ -2204,7 +2282,7 @@ describe('MailService', () => {
const everyone = [
{ email: 'doyun.lee@arasystem.test', reason: 'not-found' as const },
];
- provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
provider.getRejectedRecipients.mockResolvedValue(everyone);
await expect(
@@ -2213,14 +2291,18 @@ describe('MailService', () => {
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
- ).resolves.toEqual({ id: 'sent-1', rejectedRecipients: everyone });
+ ).resolves.toEqual({
+ id: 'sent-1',
+ rejectedRecipients: everyone,
+ pendingSend: null,
+ });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('내용이 달라 MAIL_005 로 끝나면 처음 발송의 결과를 읽지 않는다 (제출 기록을 헛되이 읽지 않게)', async () => {
const fingerprint = await fingerprintOf(sendDto());
- provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
expect(await codeOf(() => send(sendDto({ subject: '고친 제목' })))).toBe(
'MAIL_005',
@@ -2236,10 +2318,7 @@ describe('MailService', () => {
expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
provider.sendMessage.mockClear();
- provider.findSentMessage.mockResolvedValue({
- id: 'sent-1',
- fingerprint,
- });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
// 정화로 지워지는 부분만 다르면 같은 메일이다
await expect(
@@ -2258,10 +2337,7 @@ describe('MailService', () => {
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
- provider.findSentMessage.mockResolvedValue({
- id: 'sent-1',
- fingerprint,
- });
+ provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
for (const changed of [
sendDto({ subject: '고친 제목' }),
@@ -2275,10 +2351,7 @@ describe('MailService', () => {
});
it('지문 없이 남은 메일(헤더가 지워진 경우 등)도 같은 내용인지 알 수 없으므로 MAIL_005', async () => {
- provider.findSentMessage.mockResolvedValue({
- id: 'sent-1',
- fingerprint: null,
- });
+ provider.findSentMessage.mockResolvedValue(sentRecord(null));
expect(await codeOf(() => send())).toBe('MAIL_005');
});
@@ -2299,10 +2372,11 @@ describe('MailService', () => {
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
).resolves.toEqual(SENT);
- finish({ id: 'sent-first', rejectedRecipients: [] });
+ finish({ id: 'sent-first', rejectedRecipients: [], pendingSend: null });
await expect(first).resolves.toEqual({
id: 'sent-first',
rejectedRecipients: [],
+ pendingSend: null,
});
await expect(send()).resolves.toEqual(SENT);
});
@@ -2702,6 +2776,319 @@ describe('MailService', () => {
});
});
+ /** 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 (계약 2절 "보내기 취소 · 예약 발송" · "외부 이미지 보기") */
+ describe('9단계 — 붙잡는 발송 · 외부 이미지', () => {
+ const MINUTE_MS = 60_000;
+ const DAY_MS = 24 * 60 * MINUTE_MS;
+ const sentMessage = () => provider.sendMessage.mock.calls.at(-1)?.[1];
+
+ describe('붙잡는 발송', () => {
+ it('sendAt 이 있으면 그 시각(소수 초를 버린 값)까지 — 설정은 읽지 않는다(예약에는 보내기 취소 시간을 더하지 않는다)', async () => {
+ const second =
+ Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000;
+
+ await service.sendMessage(
+ user,
+ sendDto({ sendAt: new Date(second + 999).toISOString() }),
+ );
+
+ expect(sentMessage()?.hold).toEqual({
+ kind: 'scheduled',
+ until: new Date(second).toISOString(),
+ });
+ expect(ninth.userSettings.get).not.toHaveBeenCalled();
+ });
+
+ it.each<[string, () => string]>([
+ [
+ '4분 59초 뒤',
+ () => new Date(Date.now() + 5 * MINUTE_MS - 1_000).toISOString(),
+ ],
+ [
+ '7일하고 1분 뒤',
+ () => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(),
+ ],
+ ['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()],
+ ])(
+ '예약 시각이 %s 이면 엔진에 묻기 전에 VAL_001',
+ async (_label, sendAt) => {
+ expect(
+ await failureOf(() =>
+ service.sendMessage(user, sendDto({ sendAt: sendAt() })),
+ ),
+ ).toEqual({ code: 'VAL_001', reason: 'send-at-out-of-range' });
+ expect(provider.sendMessage).not.toHaveBeenCalled();
+ expect(provider.findSentMessage).not.toHaveBeenCalled();
+ },
+ );
+
+ it('sendAt 이 없거나 null 이면 설정의 보내기 취소 초만큼 — 0초면 붙잡지 않는다(hold 없음)', async () => {
+ ninth.userSettings.get.mockResolvedValue({
+ undoSendSeconds: 20,
+ externalImages: 'ask',
+ });
+ await service.sendMessage(user, sendDto());
+ expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
+ await service.sendMessage(user, sendDto({ sendAt: null }));
+ expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
+ expect(ninth.userSettings.get).toHaveBeenCalledWith(user.email);
+
+ ninth.userSettings.get.mockResolvedValue({
+ undoSendSeconds: 0,
+ externalImages: 'ask',
+ });
+ await service.sendMessage(user, sendDto());
+ expect(sentMessage()).not.toHaveProperty('hold');
+ });
+
+ it('설정을 읽지 못하면(DB 장애) 처음 값(10초)으로 보내고 경고만 — 발송을 실패로 만들지 않는다', async () => {
+ ninth.userSettings.get.mockRejectedValue(
+ new Error('connect ECONNREFUSED'),
+ );
+
+ await expect(service.sendMessage(user, sendDto())).resolves.toEqual(
+ SENT,
+ );
+ expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 10 });
+ const warned = warnSpy.mock.calls
+ .map(([line]) => String(line))
+ .join('\n');
+ expect(warned).toContain('[SYS_001]');
+ expect(warned).not.toContain(user.email);
+ });
+
+ it('붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송(붙잡은 시각 포함)을 그대로 돌려준다', async () => {
+ ninth.userSettings.get.mockResolvedValue({
+ undoSendSeconds: 10,
+ externalImages: 'ask',
+ });
+ await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
+ const first = sentMessage();
+ const pendingSend = {
+ kind: 'undo' as const,
+ sendAt: '2026-10-29T06:00:10.000Z',
+ };
+ provider.sendMessage.mockClear();
+ provider.findSentMessage.mockResolvedValue(
+ sentRecord(first?.fingerprint ?? null, { pendingSend }),
+ );
+
+ await expect(
+ service.sendMessage(
+ user,
+ sendDto({
+ sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(),
+ }),
+ IDEMPOTENCY_KEY,
+ ),
+ ).resolves.toEqual({
+ id: 'sent-1',
+ rejectedRecipients: [],
+ pendingSend,
+ });
+ expect(provider.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('취소한 발송의 키로 다시 보내면 보내지 않고 409 MAIL_005 — 클라이언트는 새 키로 보낸다', async () => {
+ await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
+ const first = sentMessage();
+ provider.sendMessage.mockClear();
+ provider.findSentMessage.mockResolvedValue(
+ sentRecord(first?.fingerprint ?? null, { canceled: true }),
+ );
+
+ expect(
+ await failureOf(() =>
+ service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY),
+ ),
+ ).toEqual({
+ code: 'MAIL_005',
+ reason: 'idempotency-key-of-canceled-send',
+ });
+ expect(provider.sendMessage).not.toHaveBeenCalled();
+ expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
+ });
+
+ it('붙잡은 발송의 응답은 어댑터의 pendingSend 그대로 — 로그에 보낼 시각', async () => {
+ const pendingSend = {
+ kind: 'scheduled' as const,
+ sendAt: '2030-01-01T09:00:00.000Z',
+ };
+ provider.sendMessage.mockResolvedValue({ ...SENT, pendingSend });
+
+ await expect(
+ service.sendMessage(
+ user,
+ sendDto({
+ sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(),
+ }),
+ ),
+ ).resolves.toEqual({ ...SENT, pendingSend });
+ const logged = logSpy.mock.calls
+ .map(([line]) => String(line))
+ .join('\n');
+ expect(logged).toContain('보낼 시각=2030-01-01T09:00:00.000Z');
+ });
+ });
+
+ describe('외부 이미지', () => {
+ const EXTERNAL = 'https://news.example/track/abc123.png';
+ const imageMail = (overrides: Partial = {}) =>
+ source({
+ from: { name: '소식지', email: 'news@letters.example' },
+ rawHtmlBody: `안녕하세요
`,
+ ...overrides,
+ });
+ const settingsAre = (externalImages: 'ask' | 'trusted' | 'always') =>
+ ninth.userSettings.get.mockResolvedValue({
+ undoSendSeconds: 10,
+ externalImages,
+ });
+ /** 본문의 중계 주소 → 표를 푼 원래 주소 */
+ const proxied = (html: string | null) =>
+ [...(html ?? '').matchAll(/src="\/api\/mail-images\/([^"]+)"/g)].map(
+ ([, token]) => ninth.imageTokens.open(token, user.email),
+ );
+
+ it('늘 묻기(처음 값)면 지우고 hasBlockedImages — 주소록 · 사내 목록은 묻지 않는다', async () => {
+ provider.getMessage.mockResolvedValue(imageMail());
+
+ const detail = await service.getMessage(user, 'm1');
+
+ expect(detail.htmlBody).toBe('안녕하세요
');
+ expect(detail.hasBlockedImages).toBe(true);
+ expect(detail.imagesShown).toBe(false);
+ expect(ninth.contacts.hasEmail).not.toHaveBeenCalled();
+ expect(ninth.directory.hasEmail).not.toHaveBeenCalled();
+ });
+
+ it('images=show 면 이 직원의 표로 중계 주소를 넣는다 — 설정을 읽지 않고, 스팸메일함도', async () => {
+ provider.getMessage.mockResolvedValue(
+ imageMail({ isInJunkMailbox: true }),
+ );
+
+ const detail = await service.getMessage(user, 'm1', { images: 'show' });
+
+ expect(proxied(detail.htmlBody)).toEqual([EXTERNAL]);
+ expect(detail.htmlBody).not.toContain('news.example');
+ expect(detail.htmlBody).toContain('alt="로고"');
+ expect(detail.imagesShown).toBe(true);
+ expect(detail.hasBlockedImages).toBe(false);
+ expect(ninth.userSettings.get).not.toHaveBeenCalled();
+ // 스팸메일함에 들었는지는 응답에 싣지 않는다
+ expect(detail).not.toHaveProperty('isInJunkMailbox');
+ });
+
+ it('늘 보기(always)면 보인다 — 스팸메일함의 메일은 설정과 상관없이 지운다', async () => {
+ settingsAre('always');
+ provider.getMessage.mockResolvedValue(imageMail());
+ expect(
+ proxied((await service.getMessage(user, 'm1')).htmlBody),
+ ).toEqual([EXTERNAL]);
+
+ provider.getMessage.mockResolvedValue(
+ imageMail({ isInJunkMailbox: true }),
+ );
+ const junk = await service.getMessage(user, 'm1');
+ expect(junk.imagesShown).toBe(false);
+ expect(junk.hasBlockedImages).toBe(true);
+ });
+
+ it('주소록 · 사내 직원(trusted)이면 보낸 사람이 내 주소록이나 사내 목록에 있을 때만', async () => {
+ settingsAre('trusted');
+ provider.getMessage.mockResolvedValue(imageMail());
+
+ expect((await service.getMessage(user, 'm1')).imagesShown).toBe(false);
+ expect(ninth.contacts.hasEmail).toHaveBeenCalledWith(
+ user.email,
+ 'news@letters.example',
+ );
+ expect(ninth.directory.hasEmail).toHaveBeenCalledWith(
+ 'news@letters.example',
+ );
+
+ ninth.contacts.hasEmail.mockResolvedValueOnce(true);
+ expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
+
+ ninth.directory.hasEmail.mockResolvedValueOnce(true);
+ expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
+ });
+
+ it('trusted 인데 주소록 · 사내 목록을 읽지 못하면 없는 것으로 — 메일은 열린다(보이지 않을 뿐)', async () => {
+ settingsAre('trusted');
+ provider.getMessage.mockResolvedValue(imageMail());
+ ninth.contacts.hasEmail.mockRejectedValue(
+ new BusinessException('MAIL_001'),
+ );
+ ninth.directory.hasEmail.mockRejectedValue(
+ new BusinessException('MAIL_001'),
+ );
+
+ const detail = await service.getMessage(user, 'm1');
+ expect(detail.imagesShown).toBe(false);
+ expect(detail.hasBlockedImages).toBe(true);
+
+ // 한쪽만 읽지 못하면 다른 쪽으로 본다
+ ninth.directory.hasEmail.mockResolvedValueOnce(true);
+ expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
+ });
+
+ it('바깥 그림이 없으면 보이게 해도 imagesShown false — 텍스트만인 메일도', async () => {
+ provider.getMessage.mockResolvedValue(
+ source({ rawHtmlBody: '그림 없음
' }),
+ );
+ const plain = await service.getMessage(user, 'm1', { images: 'show' });
+ expect(plain.imagesShown).toBe(false);
+ expect(plain.hasBlockedImages).toBe(false);
+
+ provider.getMessage.mockResolvedValue(source());
+ const text = await service.getMessage(user, 'm1', { images: 'show' });
+ expect(text.imagesShown).toBe(false);
+ });
+
+ it('설정을 읽지 못하면(DB 장애) 처음 값(늘 묻기)으로 — 메일은 열린다', async () => {
+ ninth.userSettings.get.mockRejectedValue(new Error('db down'));
+ provider.getMessage.mockResolvedValue(imageMail());
+
+ const detail = await service.getMessage(user, 'm1');
+ expect(detail.hasBlockedImages).toBe(true);
+ expect(detail.imagesShown).toBe(false);
+ });
+ });
+
+ it('취소해 되돌린 발송을 이어 쓸 때 서명 그림(cid:sig-…)을 서명 그림 주소로 되돌린다 — 내 그림만 남는다', async () => {
+ signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
+ provider.getDraft.mockResolvedValue(
+ draftSource({
+ rawHtmlBody: `본문
}@arasystem.test)
`,
+ isCanceledSend: true,
+ }),
+ );
+
+ const draft = await service.getDraft(user, 'draft-1');
+
+ expect(draft.bodyHtml).toBe(
+ `본문
`,
+ );
+ });
+
+ it('취소한 발송이 아닌 초안의 cid:sig-… 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지운다, 9단계 리뷰 뒤)', async () => {
+ signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
+ provider.getDraft.mockResolvedValue(
+ draftSource({
+ rawHtmlBody: `본문
`,
+ isCanceledSend: false,
+ }),
+ );
+
+ const draft = await service.getDraft(user, 'draft-1');
+
+ expect(draft.bodyHtml).toBe('본문
');
+ // 서명 그림 주소가 생기지 않았으니 그림 목록을 읽지도 않는다
+ expect(signatures.imageIdsOf).not.toHaveBeenCalled();
+ });
+ });
+
/** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */
describe('보낸 뒤', () => {
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
@@ -2728,10 +3115,9 @@ describe('MailService', () => {
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
provider.sendMessage.mockClear();
- provider.findSentMessage.mockResolvedValue({
- id: 'sent-1',
- fingerprint: fingerprint ?? null,
- });
+ provider.findSentMessage.mockResolvedValue(
+ sentRecord(fingerprint ?? null),
+ );
await expect(
service.sendMessage(
@@ -2818,6 +3204,7 @@ describe('MailService', () => {
rejectedRecipients: [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
],
+ pendingSend: null,
};
provider.sendMessage.mockResolvedValue(partial);
diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts
index ee75cb0..d9fb371 100644
--- a/backend/src/modules/mail/mail.service.ts
+++ b/backend/src/modules/mail/mail.service.ts
@@ -6,9 +6,16 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
+import { ContactsService } from '@/modules/contacts/contacts.service';
+import { DirectoryService } from '@/modules/contacts/directory.service';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
+import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
+import {
+ DEFAULT_MAIL_USER_SETTINGS,
+ type MailUserSettings,
+} from '@/modules/mail-user-settings/mail-user-settings.types';
import { traceTag } from '@/shared/logger/request-context';
import { toWholeSecondIso } from '@/shared/utils/format';
import { maskEmail } from '@/shared/utils/mask';
@@ -31,6 +38,10 @@ import type {
MailboxMessagesPatchDto,
RenameMailboxDto,
} from './dto/mailbox.dto';
+import {
+ SHOW_IMAGES,
+ type MessageDetailQueryDto,
+} from './dto/message-detail-query.dto';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type {
ComposeContentDto,
@@ -53,15 +64,21 @@ import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import {
SIGNATURE_IMAGE_PATH_PREFIX,
findSignatureImageIds,
+ restoreSignatureImageSources,
rewriteSignatureImages,
+ signatureImageContentId,
} from './html/signature-images';
+import { MailImageTokens } from './images/mail-image-token';
import {
+ MAIL_IMAGES_ROUTE,
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
+ SCHEDULED_SEND_MAX_LEAD_MS,
+ SCHEDULED_SEND_MIN_LEAD_MS,
} from './mail.constants';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
@@ -97,6 +114,7 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
SavedDraft,
+ SendHold,
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
@@ -243,10 +261,37 @@ function assertMoveTargetsAllowed(
}
}
-/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
-function signatureImageCid(imageId: string, sender: string): string {
- const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
- return `sig-${imageId}@${domain}`;
+/** 1초 (밀리초) — 예약 시각을 초 단위로 맞출 때 */
+const SECOND_MS = 1_000;
+
+/**
+ * 외부 이미지 중계 주소 — 같은 서버의 상대 경로 (`/api/mail-images/<표>`, 계약 2절 "외부 이미지 보기").
+ * 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH` — 화면은 이 앞부분으로 시작하는 그림만 남긴다(한쪽을 고치면 둘 다)
+ */
+const MAIL_IMAGE_PATH_PREFIX = `/api/${MAIL_IMAGES_ROUTE}/`;
+
+/**
+ * 예약 발송 시각(sendAt) → 붙잡는 방법 — 소수 초를 버린 값(엔진의 HOLDUNTIL 은 초 단위)이 지금(서버 시계)부터 5분 뒤 이상 · 7일 안이어야 한다.
+ * 밖이면 엔진에 묻기 전에 VAL_001 (계약 2절 "보내기 취소 · 예약 발송")
+ */
+function scheduledHold(sendAt: string, nowMs: number): SendHold {
+ const until = Math.floor(Date.parse(sendAt) / SECOND_MS) * SECOND_MS;
+ const lead = until - nowMs;
+ if (
+ !Number.isFinite(until) ||
+ lead < SCHEDULED_SEND_MIN_LEAD_MS ||
+ lead > SCHEDULED_SEND_MAX_LEAD_MS
+ ) {
+ throw new BusinessException('VAL_001', {
+ context: {
+ reason: 'send-at-out-of-range',
+ leadSeconds: Number.isFinite(lead)
+ ? Math.round(lead / SECOND_MS)
+ : null,
+ },
+ });
+ }
+ return { kind: 'scheduled', until: new Date(until).toISOString() };
}
/**
@@ -283,6 +328,13 @@ export class MailService {
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
private readonly signatures: MailSignatureService,
private readonly suggestions: RecipientSuggestionService,
+ /** 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계) */
+ private readonly userSettings: MailUserSettingsService,
+ /** 외부 이미지 "주소록 · 사내 직원은 늘 보기" 판정 — 내 주소록 · 사내 목록 (9단계) */
+ private readonly contacts: ContactsService,
+ private readonly directory: DirectoryService,
+ /** 외부 이미지 중계 표 (9단계) */
+ private readonly imageTokens: MailImageTokens,
) {}
async listMailboxes(
@@ -618,10 +670,15 @@ export class MailService {
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
* 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다).
+ *
+ * 외부 이미지(9단계 "외부 이미지 보기"): `images=show` 이거나 설정이 보이라고 하면(always · trusted — 스팸메일함의 메일은 빼고)
+ * `
` 의 외부 주소를 중계 주소(`/api/mail-images/<표>`)로 바꿔 넣는다 — imagesShown 은 바꿔 넣은 것이 있으면 true,
+ * 보이게 한 본문은 hasBlockedImages 가 false 다(srcset · 배경 그림은 지금처럼 지운다 — 알려진 한계)
*/
async getMessage(
user: AuthenticatedUser,
id: string,
+ query: MessageDetailQueryDto = {},
): Promise {
const {
rawHtmlBody,
@@ -629,6 +686,8 @@ export class MailService {
isTextBodyTruncated,
inlineParts,
attachments,
+ // 스팸메일함에 들었는지는 이미지 판정에만 쓴다 — 응답에 싣지 않는다
+ isInJunkMailbox,
...detail
} = await this.provider.getMessage(user.email, id);
@@ -645,6 +704,7 @@ export class MailService {
attachments: markInline(NO_INLINE_ATTACHMENTS),
htmlBody: null,
hasBlockedImages: false,
+ imagesShown: false,
isHtmlBodyOmitted,
isBodyTruncated: isTextBodyTruncated,
});
@@ -661,12 +721,24 @@ export class MailService {
return withoutHtml(true);
}
- const inlineImages = await this.loadInlineImages(
- user.email,
+ const [inlineImages, showImages] = await Promise.all([
+ this.loadInlineImages(user.email, rawHtmlBody, inlineParts),
+ this.showsExternalImages(
+ user,
+ { sender: detail.from?.email ?? null, isInJunkMailbox },
+ query,
+ ),
+ ]);
+ const sanitized = sanitizeMailHtml(
rawHtmlBody,
- inlineParts,
+ inlineImages.dataUris,
+ showImages
+ ? {
+ externalImageSrc: (url) =>
+ `${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`,
+ }
+ : {},
);
- const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris);
const inlined = new Set(
[...sanitized.embeddedContentIds].flatMap((contentId) => {
const index = inlineImages.attachmentIndexes.get(contentId);
@@ -678,12 +750,71 @@ export class MailService {
...detail,
attachments: markInline(inlined),
htmlBody: sanitized.html,
- hasBlockedImages: sanitized.hasBlockedImages,
+ hasBlockedImages: showImages ? false : sanitized.hasBlockedImages,
+ imagesShown: sanitized.proxiedImageCount > 0,
isHtmlBodyOmitted: false,
isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated,
};
}
+ /**
+ * 외부 이미지를 보일까 (계약 2절 "외부 이미지 보기") — `images=show` 면 늘(스팸메일함도). 아니면 설정:
+ * ask 는 안 보이고, 스팸메일함의 메일은 설정과 상관없이 안 보인다, always 는 보이고, trusted 는 보낸 사람이 내 주소록이나 사내 목록에 있을 때.
+ * 주소록 · 사내 목록을 읽지 못하면 보이지 않는다(묻는 쪽으로 — 경고만)
+ */
+ private async showsExternalImages(
+ user: AuthenticatedUser,
+ message: {
+ readonly sender: string | null;
+ readonly isInJunkMailbox: boolean;
+ },
+ query: MessageDetailQueryDto,
+ ): Promise {
+ if (query.images === SHOW_IMAGES) return true;
+ const { externalImages } = await this.userSettingsOf(user);
+ if (externalImages === 'ask' || message.isInJunkMailbox) return false;
+ if (externalImages === 'always') return true;
+ return message.sender ? this.isTrustedSender(user, message.sender) : false;
+ }
+
+ /**
+ * 그 직원의 보내기 · 읽기 설정 — 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 남긴다. 발송 · 메일 읽기를 설정 때문에 실패로 만들지 않는다
+ */
+ private async userSettingsOf(
+ user: AuthenticatedUser,
+ ): Promise {
+ try {
+ return await this.userSettings.get(user.email);
+ } catch (error) {
+ this.logger.warn(
+ `[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`,
+ );
+ return DEFAULT_MAIL_USER_SETTINGS;
+ }
+ }
+
+ /** 보낸 사람이 내 주소록 · 사내 목록(직원 · 공용 주소)에 있는가 — 읽지 못한 쪽은 없는 것으로 본다 */
+ private async isTrustedSender(
+ user: AuthenticatedUser,
+ sender: string,
+ ): Promise {
+ const lookups: [string, () => Promise][] = [
+ ['contacts', () => this.contacts.hasEmail(user.email, sender)],
+ ['directory', () => this.directory.hasEmail(sender)],
+ ];
+ for (const [where, lookup] of lookups) {
+ try {
+ if (await lookup()) return true;
+ } catch (error) {
+ if (!(error instanceof BusinessException)) throw error;
+ this.logger.debug(
+ `${traceTag()} 외부 이미지의 보낸 사람 확인에서 ${where} 를 읽지 못해 없는 것으로 봅니다: user=${maskEmail(user.email)}, 원인=${error.code}`,
+ );
+ }
+ }
+ return false;
+ }
+
async updateMessage(
user: AuthenticatedUser,
id: string,
@@ -740,6 +871,10 @@ export class MailService {
*
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
+ *
+ * 붙잡기 (9단계 "보내기 취소 · 예약 발송"): `sendAt` 이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안 — 밖이면 엔진에 묻기 전에 VAL_001),
+ * 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 지금처럼 바로) 엔진이 붙잡았다가 보낸다 — 응답의 pendingSend.
+ * 예약에는 보내기 취소 시간을 더하지 않는다. 붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송을 그대로 돌려준다
*/
async sendMessage(
user: AuthenticatedUser,
@@ -752,8 +887,15 @@ export class MailService {
context: { reason: 'invalid-idempotency-key' },
});
}
+ // 예약 시각은 본문 준비 · 엔진보다 먼저 본다 — 범위 밖이면 아무것도 하지 않는다. null 은 없는 것과 같다(다른 선택 칸처럼)
+ const scheduled =
+ dto.sendAt === undefined || dto.sendAt === null
+ ? null
+ : scheduledHold(dto.sendAt, Date.now());
- const message = await this.prepareOutgoing(user, dto);
+ const prepared = await this.prepareOutgoing(user, dto);
+ const hold = scheduled ?? (await this.undoHold(user));
+ const message: OutgoingMessage = hold ? { ...prepared, hold } : prepared;
const result =
parsed.key === null
? await this.deliver(user, message)
@@ -762,6 +904,14 @@ export class MailService {
return result;
}
+ /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
+ private async undoHold(user: AuthenticatedUser): Promise {
+ const { undoSendSeconds } = await this.userSettingsOf(user);
+ return undoSendSeconds > 0
+ ? { kind: 'undo', seconds: undoSendSeconds }
+ : null;
+ }
+
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
async createDraft(
user: AuthenticatedUser,
@@ -910,6 +1060,16 @@ export class MailService {
messageId,
);
if (previous) {
+ // 취소한 발송의 키(9단계) — 보내지 않는다. 클라이언트는 취소 뒤 새 키로 보낸다 (계약 2절 "보내기 취소 · 예약 발송")
+ if (previous.canceled) {
+ throw new BusinessException('MAIL_005', {
+ context: {
+ reason: 'idempotency-key-of-canceled-send',
+ from: maskEmail(user.email),
+ sentId: previous.id,
+ },
+ });
+ }
if (previous.fingerprint !== fingerprint) {
throw new BusinessException('MAIL_005', {
context: {
@@ -927,6 +1087,7 @@ export class MailService {
previous.id,
message,
),
+ pendingSend: previous.pendingSend,
};
this.logger.log(
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`,
@@ -1095,7 +1256,7 @@ export class MailService {
const cids = new Map(
images.map((image) => [
image.id,
- signatureImageCid(image.id, user.email),
+ signatureImageContentId(image.id, user.email),
]),
);
const inlineImages = images.map(
@@ -1113,22 +1274,29 @@ export class MailService {
return { htmlBody, inlineImages };
}
- /** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */
+ /**
+ * 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null.
+ * 취소해 되돌린 발송(9단계 — isCanceledSend)만 서명 그림이 메일 안의 `cid:sig-…` 라 정화 전에 서명 그림 주소로 되돌린다(정화가 내 그림만 남긴다).
+ * 다른 초안의 `cid:sig-…` 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지우고 그 부분은 첨부로 남는다)
+ */
private async draftHtml(
user: AuthenticatedUser,
source: DraftSource,
): Promise {
- const raw = source.rawHtmlBody;
- if (!raw) return null;
+ if (!source.rawHtmlBody) return null;
- const budget = checkHtmlParseBudget(raw);
+ const budget = checkHtmlParseBudget(source.rawHtmlBody);
if (!budget.ok) {
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
this.logger.warn(
- `${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`,
+ `${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${source.rawHtmlBody.length}자`,
);
return null;
}
+ // 비용을 본 뒤에 되돌린다 — 되돌리기는 입력 길이에 비례하고 태그 · 중첩을 늘리지 않는다
+ const raw = source.isCanceledSend
+ ? restoreSignatureImageSources(source.rawHtmlBody)
+ : source.rawHtmlBody;
const html = sanitizeOutgoingHtml(raw, {
signatureImageIds: await this.signatureImageIdsFor(user, raw),
});
@@ -1183,11 +1351,15 @@ export class MailService {
): Promise {
const result = await this.provider.sendMessage(user.email, message);
- // 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다
+ // 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다.
+ // 붙잡은 발송은 메일 id · 보낼 시각만 (계약 2절 "보내기 취소 · 예약 발송"의 로그)
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
+ const held = result.pendingSend
+ ? `, ${result.pendingSend.kind === 'scheduled' ? '예약' : '보내기 취소 대기'} id=${result.id} 보낼 시각=${result.pendingSend.sendAt}`
+ : '';
this.logger.log(
- `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}`,
+ `메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}${held}`,
);
return result;
}
diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts
index 7a9c86b..3029d7a 100644
--- a/backend/src/modules/mail/mail.types.ts
+++ b/backend/src/modules/mail/mail.types.ts
@@ -32,6 +32,18 @@ export interface Mailbox {
readonly unreadCount: number;
}
+/**
+ * 엔진이 붙잡아 둔 발송의 종류 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 보내기 취소를 기다리는 중 · 예약 발송
+ */
+export type PendingSendKind = 'undo' | 'scheduled';
+
+/** 아직 보내지 않은 내 메일의 발송 — 엔진의 기다리는 제출(undoStatus: pending, 보낼 시각이 아직 오지 않음) */
+export interface PendingSend {
+ /** 엔진이 보낼 시각 (ISO 8601 UTC) */
+ readonly sendAt: string;
+ readonly kind: PendingSendKind;
+}
+
export interface MessageSummary {
readonly id: string;
readonly threadId: string;
@@ -48,6 +60,8 @@ export interface MessageSummary {
readonly hasAttachment: boolean;
/** 바이트 */
readonly size: number;
+ /** 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 발송 — 그 밖에는 null (9단계) */
+ readonly pendingSend: PendingSend | null;
}
export interface MailAttachment {
@@ -78,8 +92,10 @@ export interface MessageDetail extends MessageSummary {
/** 서버가 정화한 HTML — 정화 전 원본은 절대 내보내지 않는다 */
readonly htmlBody: string | null;
readonly textBody: string | null;
- /** 외부 이미지를 지웠으면 true */
+ /** 외부 이미지를 지웠으면 true — 보이게 했으면(imagesShown 을 정한 요청) false */
readonly hasBlockedImages: boolean;
+ /** 외부 이미지를 중계 주소(/api/mail-images/…)로 바꿔 넣었으면 true — images=show 또는 설정 (9단계 "외부 이미지 보기") */
+ readonly imagesShown: boolean;
/** HTML 이 있었지만 정화 전 사전 검사(html/html-parse-budget.ts)를 넘어 htmlBody 를 비웠으면 true */
readonly isHtmlBodyOmitted: boolean;
/**
@@ -111,12 +127,17 @@ export interface MessageDetailSource extends Omit<
| 'htmlBody'
| 'textBody'
| 'hasBlockedImages'
+ | 'imagesShown'
| 'isHtmlBodyOmitted'
| 'isBodyTruncated'
| 'attachments'
> {
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
readonly attachments: MailAttachment[];
+ /**
+ * 스팸메일함에 든 메일인가 — 외부 이미지를 설정(trusted · always)으로 보이지 않는다 (images=show 로만 — 계약 2절 "외부 이미지 보기")
+ */
+ readonly isInJunkMailbox: boolean;
readonly rawHtmlBody: string | null;
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
readonly isRawHtmlBodyTruncated: boolean;
@@ -446,8 +467,21 @@ export interface OutgoingMessage {
readonly messageId?: string;
/** 내용 지문 — 같은 키로 내용이 바뀌었는지 비교한다. 어댑터는 메일과 함께 남겨 `findSentMessage` 로 돌려준다 */
readonly fingerprint?: string;
+ /**
+ * 엔진이 붙잡았다가 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 없으면 지금처럼 바로 보낸다.
+ * 서비스가 정한다: 예약(sendAt)이면 그 시각까지, 아니면 그 직원 설정의 보내기 취소 초(0 이면 없음)
+ */
+ readonly hold?: SendHold;
}
+/**
+ * 발송을 붙잡는 방법 — 보내기 취소(초 — HOLDFOR) · 예약(UTC 시각, 초 단위 — HOLDUNTIL).
+ * 어댑터는 이것으로 발송 envelope 을 직접 적는다 (엔진의 FUTURERELEASE — Stalwart 0.16.27, 2026-10-29 확인)
+ */
+export type SendHold =
+ | { readonly kind: 'undo'; readonly seconds: number }
+ | { readonly kind: 'scheduled'; readonly until: string };
+
/** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */
export type RecipientFields = Pick;
@@ -491,6 +525,11 @@ export interface DraftSource {
/** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */
readonly inReplyTo: string | null;
readonly updatedAt: string;
+ /**
+ * 취소해 임시보관함으로 되돌린 발송인가 (9단계 — 취소한 발송 표시). 이때만 서식 본문의 `cid:sig-…`(우리가 실어 보낸 서명 그림)를
+ * 서명 그림 주소로 되돌린다 — 어댑터는 그 그림 부분을 첨부에서 이미 뺐다
+ */
+ readonly isCanceledSend: boolean;
}
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
@@ -558,6 +597,8 @@ export interface SentMessage {
readonly id: string;
/** 엔진이 받지 않은 사람 — 없으면 빈 배열. 같은 주소는 한 번만, 요청의 받는 사람 · 참조 · 숨은참조 순서 */
readonly rejectedRecipients: RejectedRecipient[];
+ /** 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null */
+ readonly pendingSend: PendingSend | null;
}
/**
@@ -569,6 +610,36 @@ export interface SentMessageRecord {
readonly id: string;
/** 발송 때 남긴 내용 지문 — 없으면 null */
readonly fingerprint: string | null;
+ /**
+ * 그 발송을 취소했다(9단계 — 제출이 모두 canceled) — 보낸 것이 아니다. 같은 키로 다시 보내면 서비스가 409 MAIL_005 로 답한다
+ * (클라이언트는 취소 뒤 새 키로 보낸다 — 계약 2절 "보내기 취소 · 예약 발송")
+ */
+ readonly canceled: boolean;
+ /** 아직 엔진이 붙잡고 있는 발송이면 그 정보 — 같은 키의 재시도가 처음 결과를 그대로 돌려줄 때 싣는다 */
+ readonly pendingSend: PendingSend | null;
+}
+
+/**
+ * 보낸 사람 차단 하나 (계약 2절 "보낸 사람 차단" — 9단계) — 주소("kim@spam.example") 또는 도메인("@spam.example") · 소문자
+ */
+export interface BlockedSender {
+ readonly address: string;
+ /** 차단한 시각 (ISO 8601 UTC) */
+ readonly blockedAt: string;
+}
+
+/** 차단 목록 (GET /api/mail-blocks) */
+export interface MailBlockList {
+ /** 최근에 차단한 것부터 */
+ readonly senders: readonly BlockedSender[];
+ /** 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다 */
+ readonly isActive: boolean;
+}
+
+/** 차단을 더한 결과 — 이미 있던 주소면 created: false (그 항목 그대로) */
+export interface MailBlockAdded {
+ readonly sender: BlockedSender;
+ readonly created: boolean;
}
/** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */
diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts
index 0121a54..07f29ca 100644
--- a/backend/src/modules/mail/mailbox.controller.ts
+++ b/backend/src/modules/mail/mailbox.controller.ts
@@ -137,6 +137,10 @@ export class MailboxController {
})
@ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
+ @ApiErrorResponse(
+ 'MAIL_015',
+ '남은 메일이 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)뿐이고 이 요청에서 옮긴 것이 없다 — 옮기지 않는다 (먼저 보내기를 취소한다)',
+ )
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
remove(
@@ -178,6 +182,10 @@ export class MailboxController {
})
@ApiErrorResponse('VAL_001', '휴지통 · 스팸메일함이 아님')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
+ @ApiErrorResponse(
+ 'MAIL_015',
+ '남은 메일이 붙잡힌 메일(9단계 — 다른 프로그램이 옮겨 둔 것)뿐이고 이 요청에서 지운 것이 없다 — 지우지 않는다',
+ )
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
empty(
diff --git a/backend/src/modules/mail/message-deletion.controller.ts b/backend/src/modules/mail/message-deletion.controller.ts
index eece9f4..9509ebd 100644
--- a/backend/src/modules/mail/message-deletion.controller.ts
+++ b/backend/src/modules/mail/message-deletion.controller.ts
@@ -41,7 +41,7 @@ export class MessageDeletionController {
summary: '휴지통에서 영구 삭제 (여러 통)',
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 휴지통에 있는 메일만 다룬다 — 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다. ` +
- '휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일은 지우지 않고 skippedIds 로 돌려준다. ' +
+ '휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일 · 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 지우지 않고 skippedIds 로 돌려준다. ' +
'대상을 확인한 뒤 그 상태 그대로일 때만 한 번에 지운다(그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다) — ' +
'계속 어긋나면 아무것도 지우지 않고 503 MAIL_001 (다시 시도하면 된다).',
})
@@ -54,7 +54,7 @@ export class MessageDeletionController {
)
@ApiErrorResponse(
'MAIL_001',
- '확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
+ '확인하는 사이 계속 바뀜 · 휴지통 없음 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
)
remove(
@CurrentUser() user: AuthenticatedUser,
diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts
index 9fc539b..95756bd 100644
--- a/backend/src/modules/mail/message.controller.ts
+++ b/backend/src/modules/mail/message.controller.ts
@@ -50,12 +50,14 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_BULK_MESSAGE_IDS,
+ MAX_ENVELOPE_RESUBMITS,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MESSAGES_ROUTE,
} from './mail.constants';
import { UpdateMessagesDto } from './dto/bulk-messages.dto';
+import { MessageDetailQueryDto } from './dto/message-detail-query.dto';
import { MessageListQueryDto } from './dto/message-list-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { UpdateMessageDto } from './dto/update-message.dto';
@@ -119,6 +121,7 @@ export class MessageController {
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만, isJunk = $junk · $notjunk). 엔진 요청 한 번으로 처리한다. ` +
'바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' +
+ '옮기기 · 스팸 표시는 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)에 하지 않는다 — 그 id 도 failedIds (확인하지 못하면 아무것도 바꾸지 않고 503 MAIL_001). ' +
'없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).',
})
@ApiStandardResponse(MessagesPatchedDto, {
@@ -146,27 +149,40 @@ export class MessageController {
description:
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' +
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
- `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다.`,
+ `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` +
+ 'images=show 면 외부 이미지(
의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' +
+ '없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
+ @ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리')
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
get(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
+ @Query() query: MessageDetailQueryDto,
) {
- return this.mail.getMessage(user, id);
+ return this.mail.getMessage(user, id, query);
}
@Patch(':id')
@ApiOperation({
summary: '읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)',
description:
- 'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다.',
+ 'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다. ' +
+ '붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 mailboxId · isJunk 를 받지 않는다(409 MAIL_015 — 취소로만 보낸편지함을 떠난다), 읽음 · 중요 표시는 된다.',
})
@ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' })
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동')
+ @ApiErrorResponse(
+ 'MAIL_015',
+ '붙잡힌 메일의 mailboxId · isJunk — 아무것도 바꾸지 않았다 (먼저 보내기를 취소한다)',
+ )
+ @ApiErrorResponse(
+ 'MAIL_001',
+ '메일 엔진 장애 — 옮기기 · 스팸 표시는 붙잡힌 메일인지 확인하지 못하면 바꾸지 않는다',
+ )
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@@ -186,6 +202,14 @@ export class MessageController {
description: '삭제(이동) 완료 — 본문 없음',
})
@ApiErrorResponse('RES_001', '없는 메일')
+ @ApiErrorResponse(
+ 'MAIL_015',
+ '붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계) — 옮기지도 지우지도 않았다 (먼저 보내기를 취소한다)',
+ )
+ @ApiErrorResponse(
+ 'MAIL_001',
+ '메일 엔진 장애 · 확인하는 사이 계속 바뀜 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다',
+ )
async remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@@ -209,7 +233,12 @@ export class MessageController {
`본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` +
`bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` +
'엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' +
- '이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
+ '이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002). ' +
+ '9단계 — 메일 서버가 붙잡았다가 보낸다: sendAt(UTC)이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안, 밖이면 400 VAL_001), 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 바로). ' +
+ '201 의 pendingSend 가 그 발송이다(바로 보냈으면 null) — 붙잡아도 사내 받는 사람 확인은 제출 순간이라 rejectedRecipients · MAIL_010 · MAIL_011 은 그대로다. ' +
+ '메일 서버가 받지 않는 주소(모르는 최상위 도메인 등)는 rejectedRecipients 의 not-found 로 빼고 나머지에게 보낸다 — 서버는 한 번에 하나만 알려 주므로 ' +
+ `${MAX_ENVELOPE_RESUBMITS + 1}개를 넘게 빼야 하면(다시 내기 ${MAX_ENVELOPE_RESUBMITS}차례) 보내지 않는다(400 VAL_001 — 만든 메일을 지웠다, 주소를 고쳐 같은 키로 다시 보내면 새로 보낸다). ` +
+ '취소(DELETE /api/scheduled-messages/{id})한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다.',
})
@ApiHeader({
name: 'Idempotency-Key',
@@ -236,7 +265,7 @@ export class MessageController {
)
@ApiErrorResponse(
'MAIL_005',
- '같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
+ '같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 · 그 키의 발송을 취소했다(9단계) — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
)
@ApiErrorResponse(
'FILE_002',
diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts
new file mode 100644
index 0000000..e325632
--- /dev/null
+++ b/backend/src/modules/mail/scheduled-message.controller.ts
@@ -0,0 +1,80 @@
+import {
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Query,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import {
+ ApiErrorResponse,
+ ApiStandardResponse,
+} from '@/common/decorators/api-standard-response.decorator';
+import {
+ CurrentUser,
+ type AuthenticatedUser,
+} from '@/common/decorators/current-user.decorator';
+import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
+import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
+import {
+ ScheduledMessageCanceledDto,
+ ScheduledMessagePageDto,
+} from './dto/mail-response.dto';
+import { MailIdPipe } from './mail-id.pipe';
+import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants';
+import { ScheduledMessagesService } from './scheduled-messages.service';
+
+// 붙잡아 둔 발송 — 예약한 메일 목록 · 보내기 취소 · 예약 취소 (계약 2절 "보내기 취소 · 예약 발송", 9단계)
+@ApiTags('Mail')
+@ApiCookieAuth()
+@UseGuards(JwtAuthGuard)
+@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
+@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
+@Controller(SCHEDULED_MESSAGES_ROUTE)
+export class ScheduledMessageController {
+ constructor(private readonly scheduled: ScheduledMessagesService) {}
+
+ @Get()
+ @ApiOperation({
+ summary: '예약한 메일 (보낼 시각 차례, 커서 페이지네이션)',
+ description:
+ '예약 발송(sendAt)으로 붙잡아 둔 메일 가운데 아직 보내지 않은 것 — 보내기 취소를 기다리는 메일은 들지 않는다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. ' +
+ '목록 · 상태는 늘 메일 엔진의 기다리는 제출에 묻는다(서버가 따로 기억하지 않는다).',
+ })
+ @ApiStandardResponse(ScheduledMessagePageDto, {
+ description: '예약한 메일 (total 포함)',
+ })
+ @ApiErrorResponse('VAL_001', '다른 목록의 커서 · 깨진 커서 · limit 범위 밖')
+ list(
+ @CurrentUser() user: AuthenticatedUser,
+ @Query() query: CursorPaginationQueryDto,
+ ) {
+ return this.scheduled.list(user, query);
+ }
+
+ @Delete(':id')
+ @ApiOperation({
+ summary: '붙잡은 발송 취소 — 보내기 취소 · 예약 취소 (임시보관함으로)',
+ description:
+ 'id 는 보낸 사본의 메일 id 다. 메일 서버에서 발송을 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨 초안으로 되돌린다 — 받는 사람 · 제목 · 본문 · 첨부는 그대로이고 ' +
+ 'draftId 는 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 취소한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다. ' +
+ '이미 취소한 발송을 다시 취소하면(응답을 받지 못한 재시도 · 옮기지 못한 취소) 같은 200 — 되돌리기를 마쳤으면 그 뒤 어느 메일함으로 옮겼든 그대로 두고, ' +
+ '마치지 못했으면(보낸편지함에 남음) 되돌리기만 다시 한다. 메일 서버가 취소를 확인해 주지 않으면 503 MAIL_001 — 옮기지 않는다(다시 취소하면 처음부터 다시 본다).',
+ })
+ @ApiStandardResponse(ScheduledMessageCanceledDto, {
+ description: '취소하고 임시보관함으로 되돌렸다',
+ })
+ @ApiErrorResponse('RES_001', '없는 메일 (형식이 틀린 id 포함)')
+ @ApiErrorResponse(
+ 'MAIL_014',
+ '이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다',
+ )
+ cancel(
+ @CurrentUser() user: AuthenticatedUser,
+ @Param('id', MailIdPipe) id: string,
+ ) {
+ return this.scheduled.cancel(user, id);
+ }
+}
diff --git a/backend/src/modules/mail/scheduled-messages.service.spec.ts b/backend/src/modules/mail/scheduled-messages.service.spec.ts
new file mode 100644
index 0000000..08baf9d
--- /dev/null
+++ b/backend/src/modules/mail/scheduled-messages.service.spec.ts
@@ -0,0 +1,118 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
+import type { MailProvider } from './mail.provider';
+import type { MessageSummary } from './mail.types';
+import { ScheduledMessagesService } from './scheduled-messages.service';
+
+const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
+
+const summary = (id: string, minute: number): MessageSummary => ({
+ id,
+ threadId: id,
+ mailboxIds: ['e'],
+ from: { name: '김아라', email: user.email },
+ to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
+ subject: `예약 ${id}`,
+ preview: '',
+ receivedAt: '2026-10-29T01:00:00.000Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend: {
+ kind: 'scheduled',
+ sendAt: new Date(Date.UTC(2030, 0, 1, 9, minute)).toISOString(),
+ },
+});
+
+async function failureOf(
+ run: () => Promise,
+): Promise<{ code?: string; reason?: unknown }> {
+ try {
+ await run();
+ } catch (error) {
+ return error instanceof BusinessException
+ ? { code: error.code, reason: error.context?.reason }
+ : { code: 'NOT_BUSINESS' };
+ }
+ return {};
+}
+
+describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취소 (9단계)', () => {
+ const all = ['s1', 's2', 's3', 's4', 's5'].map((id, i) => summary(id, i));
+ let provider: {
+ listScheduledMessages: jest.Mock;
+ cancelPendingSend: jest.Mock;
+ };
+ let service: ScheduledMessagesService;
+
+ beforeEach(() => {
+ provider = {
+ listScheduledMessages: jest.fn().mockResolvedValue(all),
+ cancelPendingSend: jest.fn().mockResolvedValue(undefined),
+ };
+ service = new ScheduledMessagesService(provider as unknown as MailProvider);
+ });
+
+ it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => {
+ const first = await service.list(user, { limit: 2 });
+ expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']);
+ expect(first.pageInfo).toEqual({
+ hasNext: true,
+ nextCursor: expect.any(String) as string,
+ total: 5,
+ });
+
+ const second = await service.list(user, {
+ limit: 2,
+ cursor: first.pageInfo.nextCursor ?? undefined,
+ });
+ expect(second.items.map(({ id }) => id)).toEqual(['s3', 's4']);
+
+ const last = await service.list(user, {
+ limit: 2,
+ cursor: second.pageInfo.nextCursor ?? undefined,
+ });
+ expect(last.items.map(({ id }) => id)).toEqual(['s5']);
+ expect(last.pageInfo).toEqual({
+ hasNext: false,
+ nextCursor: null,
+ total: 5,
+ });
+ });
+
+ it('예약한 메일이 없으면 빈 쪽 · total 0', async () => {
+ provider.listScheduledMessages.mockResolvedValue([]);
+ await expect(service.list(user, { limit: 20 })).resolves.toEqual({
+ items: [],
+ pageInfo: { hasNext: false, nextCursor: null, total: 0 },
+ });
+ });
+
+ it('다른 목록의 커서 · 깨진 커서는 엔진에 묻기 전에 VAL_001', async () => {
+ for (const cursor of [
+ encodeOffsetCursor(2, 'contacts'),
+ 'bm90LWEtY3Vyc29y',
+ '!!',
+ ]) {
+ expect(
+ await failureOf(() => service.list(user, { limit: 20, cursor })),
+ ).toMatchObject({ code: 'VAL_001' });
+ }
+ expect(provider.listScheduledMessages).not.toHaveBeenCalled();
+ });
+
+ it('취소 — 포트에 넘기고 같은 메일 id 를 draftId 로 돌려준다 (실패는 포트의 코드 그대로)', async () => {
+ await expect(service.cancel(user, 'm1')).resolves.toEqual({
+ draftId: 'm1',
+ });
+ expect(provider.cancelPendingSend).toHaveBeenCalledWith(user.email, 'm1');
+
+ provider.cancelPendingSend.mockRejectedValueOnce(
+ new BusinessException('MAIL_014', { context: { reason: 'x' } }),
+ );
+ expect(await failureOf(() => service.cancel(user, 'm1'))).toMatchObject({
+ code: 'MAIL_014',
+ });
+ });
+});
diff --git a/backend/src/modules/mail/scheduled-messages.service.ts b/backend/src/modules/mail/scheduled-messages.service.ts
new file mode 100644
index 0000000..2439d8f
--- /dev/null
+++ b/backend/src/modules/mail/scheduled-messages.service.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
+import {
+ type CursorPaginationQueryDto,
+ type PaginatedResult,
+ toPaginatedResult,
+} from '@/common/dto/pagination.dto';
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ decodeOffsetCursor,
+ encodeOffsetCursor,
+} from '@/shared/utils/offset-cursor';
+import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
+import type { MessageSummary } from './mail.types';
+
+/** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */
+const SCHEDULED_SCOPE = 'scheduled-messages';
+
+/**
+ * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소.
+ * 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다).
+ * 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다
+ */
+@Injectable()
+export class ScheduledMessagesService {
+ constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {}
+
+ /**
+ * 예약한 메일 — 보낼 시각 차례, total 포함 (화면의 「예약됨」 통 수). 커서는 위치다 — 차례 사이에 보내지거나 취소되면 한두 개가 겹치거나 빠질 수 있다.
+ * 커서는 엔진에 묻기 전에 본다 — 다른 목록 · 깨진 커서는 VAL_001
+ */
+ async list(
+ user: AuthenticatedUser,
+ query: CursorPaginationQueryDto,
+ ): Promise> {
+ let start = 0;
+ if (query.cursor !== undefined) {
+ const reading = decodeOffsetCursor(query.cursor, SCHEDULED_SCOPE);
+ if ('problem' in reading) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: `invalid-scheduled-cursor:${reading.problem}` },
+ });
+ }
+ start = reading.offset;
+ }
+
+ const all = await this.provider.listScheduledMessages(user.email);
+ const items = all.slice(start, start + query.limit);
+ const next = start + items.length;
+ const hasNext = next < all.length;
+ return toPaginatedResult(items, {
+ hasNext,
+ nextCursor: hasNext ? encodeOffsetCursor(next, SCHEDULED_SCOPE) : null,
+ total: all.length,
+ });
+ }
+
+ /**
+ * 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId).
+ * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다)
+ */
+ async cancel(
+ user: AuthenticatedUser,
+ id: string,
+ ): Promise<{ draftId: string }> {
+ await this.provider.cancelPendingSend(user.email, id);
+ return { draftId: id };
+ }
+}
diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts
index 0160da3..263ee52 100644
--- a/backend/src/shared/utils/mask.spec.ts
+++ b/backend/src/shared/utils/mask.spec.ts
@@ -57,6 +57,32 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
// 값 안의 = 도 값이다
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
});
+
+ it('외부 이미지 중계의 표(경로)는 길이만 남긴다 — 쿼리가 붙어도, 다른 경로는 그대로 (9단계)', () => {
+ const token = 'AbC_-1'.repeat(20);
+ expect(maskUrlForLog(`/api/mail-images/${token}`)).toBe(
+ '/api/mail-images/<120자>',
+ );
+ expect(maskUrlForLog(`/api/mail-images/${token}?q=abc&x=1`)).toBe(
+ '/api/mail-images/<120자>?q=<3자>&x=1',
+ );
+ expect(maskUrlForLog('/api/mail-images/')).toBe('/api/mail-images/');
+ expect(maskUrlForLog('/api/mail-imagesx/abc')).toBe(
+ '/api/mail-imagesx/abc',
+ );
+ expect(maskUrlForLog('/api/messages/abc?images=show')).toBe(
+ '/api/messages/abc?images=show',
+ );
+ });
+
+ it('외부 이미지 중계의 표는 경로의 대소문자와 상관없이 길이만 남긴다 — 라우팅이 대소문자를 가리지 않는다 (앞부분은 들어온 그대로)', () => {
+ expect(maskUrlForLog('/API/MAIL-IMAGES/AbCdef')).toBe(
+ '/API/MAIL-IMAGES/<6자>',
+ );
+ expect(maskUrlForLog('/Api/Mail-Images/AbCdef?q=x')).toBe(
+ '/Api/Mail-Images/<6자>?q=<1자>',
+ );
+ });
});
describe('maskEmail', () => {
diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts
index 1bbadd0..8179613 100644
--- a/backend/src/shared/utils/mask.ts
+++ b/backend/src/shared/utils/mask.ts
@@ -17,6 +17,14 @@ const MASK = '***';
*/
const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
+/**
+ * 값을 남기지 않는 경로 조각 — 외부 이미지 중계의 표(`/api/mail-images/<표>` — 9단계).
+ * 표는 원래 주소(추적 번호가 든다)를 서버 비밀로 암호화한 글이라 풀리지는 않지만, 4,000자까지 길고 로그에 둘 까닭이 없다 — 길이만 남긴다
+ * (계약 2절 "외부 이미지 보기"의 로그). 경로의 대소문자는 가리지 않는다 — Express 의 라우팅이 가리지 않아(`/API/MAIL-IMAGES/<표>` 도 처리기에 닿는다)
+ * 그 요청이 401 · 429 로 끝나면 전역 필터의 로그에 표가 남는다 (2026-10-29 리뷰). 앞부분은 들어온 모양 그대로 둔다
+ */
+const REDACTED_PATH = /^(\/api\/mail-images\/)([^/?#]+)/i;
+
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
try {
@@ -43,11 +51,16 @@ export function maskEmail(email: string): string {
/**
* 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
- * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
+ * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). 외부 이미지 중계의 표(경로)도 길이만 (REDACTED_PATH).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>'
+ * '/api/mail-images/AbC…(120자)' → '/api/mail-images/<120자>'
*/
-export function maskUrlForLog(url: string): string {
+export function maskUrlForLog(rawUrl: string): string {
+ const url = rawUrl.replace(
+ REDACTED_PATH,
+ (_whole, prefix: string, token: string) => `${prefix}<${token.length}자>`,
+ );
const queryStart = url.indexOf('?');
if (queryStart < 0) return url;
diff --git a/backend/src/shared/utils/public-address.spec.ts b/backend/src/shared/utils/public-address.spec.ts
new file mode 100644
index 0000000..9e4c9f2
--- /dev/null
+++ b/backend/src/shared/utils/public-address.spec.ts
@@ -0,0 +1,89 @@
+import { isPublicIpAddress } from './public-address';
+
+describe('isPublicIpAddress — 바깥 이미지 중계가 연결해도 되는 주소', () => {
+ it.each([
+ '8.8.8.8',
+ '1.1.1.1',
+ '93.184.216.34',
+ '172.15.255.255',
+ '172.32.0.1',
+ '100.63.255.255',
+ '100.128.0.1',
+ '192.0.1.1',
+ '198.17.255.255',
+ '198.20.0.1',
+ '223.255.255.255',
+ '2606:4700:4700::1111',
+ '2001:4860:4860::8888',
+ '2a00:1450:4001:80b::200e',
+ ])('공인 주소 %s 는 받는다', (address) => {
+ expect(isPublicIpAddress(address)).toBe(true);
+ });
+
+ it.each([
+ ['이 망', '0.0.0.0'],
+ ['이 망', '0.1.2.3'],
+ ['사설', '10.0.0.1'],
+ ['사설', '172.16.0.1'],
+ ['사설 — 도커 앱 망(APP_NETWORK_SUBNET 기본)', '172.16.71.10'],
+ ['사설 — 도커 edge 망(EDGE_NETWORK_SUBNET 기본)', '172.16.72.3'],
+ ['사설', '172.31.255.255'],
+ ['사설', '192.168.0.1'],
+ ['CGNAT', '100.64.0.1'],
+ ['CGNAT', '100.127.255.255'],
+ ['루프백', '127.0.0.1'],
+ ['루프백', '127.255.255.254'],
+ ['링크 로컬(클라우드 메타데이터)', '169.254.169.254'],
+ ['IETF 프로토콜용', '192.0.0.8'],
+ ['문서용', '192.0.2.1'],
+ ['6to4 릴레이', '192.88.99.1'],
+ ['벤치마크', '198.18.0.1'],
+ ['벤치마크', '198.19.255.255'],
+ ['문서용', '198.51.100.7'],
+ ['문서용', '203.0.113.9'],
+ ['멀티캐스트', '224.0.0.1'],
+ ['멀티캐스트', '239.255.255.250'],
+ ['예약', '240.0.0.1'],
+ ['브로드캐스트', '255.255.255.255'],
+ ])('IPv4 %s 대역(%s)은 막는다', (_label, address) => {
+ expect(isPublicIpAddress(address)).toBe(false);
+ });
+
+ it.each([
+ ['루프백', '::1'],
+ ['지정 안 됨', '::'],
+ ['IPv4 대응 — 루프백', '::ffff:127.0.0.1'],
+ ['IPv4 대응 — 공인 주소여도 막는다', '::ffff:8.8.8.8'],
+ ['IPv4 대응(16진)', '::ffff:7f00:1'],
+ ['IPv4 호환(옛)', '::127.0.0.1'],
+ ['NAT64', '64:ff9b::7f00:1'],
+ ['NAT64 로컬', '64:ff9b:1::1'],
+ ['버리는 대역', '100::1'],
+ ['고유 로컬', 'fc00::1'],
+ ['고유 로컬', 'fd12:3456:789a::1'],
+ ['링크 로컬', 'fe80::1'],
+ ['사이트 로컬(옛)', 'fec0::1'],
+ ['멀티캐스트', 'ff02::1'],
+ ['Teredo', '2001:0:4136:e378:8000:63bf:3fff:fdd2'],
+ ['IETF 프로토콜용', '2001:2::1'],
+ ['문서용', '2001:db8::1'],
+ ['문서용(새)', '3fff::1'],
+ ['6to4 — 안에 사설 IPv4', '2002:c0a8:101::1'],
+ ])('IPv6 %s 대역(%s)은 막는다', (_label, address) => {
+ expect(isPublicIpAddress(address)).toBe(false);
+ });
+
+ it.each([
+ '',
+ 'localhost',
+ 'example.com',
+ '[2001:4860:4860::8888]',
+ '8.8.8',
+ '256.1.1.1',
+ '08.8.8.8',
+ 'fe80::1%eth0',
+ '2001:4860:4860::8888%eth0',
+ ])('IP 주소가 아닌 글 · 영역이 붙은 주소 %j 은 막는다', (value) => {
+ expect(isPublicIpAddress(value)).toBe(false);
+ });
+});
diff --git a/backend/src/shared/utils/public-address.ts b/backend/src/shared/utils/public-address.ts
new file mode 100644
index 0000000..08cc7e6
--- /dev/null
+++ b/backend/src/shared/utils/public-address.ts
@@ -0,0 +1,68 @@
+import { BlockList, isIP } from 'net';
+
+/**
+ * 공인(인터넷에서 닿는) IP 주소인가 — 순수 함수. 바깥 이미지 중계(계약 2절 "외부 이미지 보기")가 연결하기 전에 본다.
+ * 이 서버가 바깥으로 대신 묻는 곳이 사내 망 · 자기 자신 · 도커 망(메일 엔진 관리 API · DB)이 되지 않게 하는 막이다 (SSRF).
+ *
+ * - IPv4: 아래 특수 대역이 아니면 공인 — 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) ·
+ * "이 망"(0/8) · IETF 프로토콜용(192.0.0/24) · 문서용 셋 · 6to4 릴레이(192.88.99/24) · 벤치마크(198.18/15) · 멀티캐스트 · 예약(240/4, 브로드캐스트 포함)
+ * - IPv6: 전역 유니캐스트(2000::/3) 안에서만 공인으로 본다(허용 목록) — 그 밖(::1 · :: · ::ffff:0:0/96(IPv4 대응) · 64:ff9b::/96 · 64:ff9b:1::/48(NAT64) ·
+ * 100::/64 · fc00::/7 · fe80::/10 · fec0::/10 · ff00::/8 …)은 모두 막는다. 2000::/3 안의 특수 대역 — IETF 프로토콜용(2001::/23 — Teredo 포함) ·
+ * 문서용(2001:db8::/32 · 3fff::/20) · 6to4(2002::/16 — 안에 IPv4 를 담아 사설 대역으로 닿을 수 있다)도 막는다
+ * - 우리 도커 망(APP_NETWORK_SUBNET · EDGE_NETWORK_SUBNET)은 사설 대역이라 함께 막힌다 (계약 2절)
+ * - IP 주소가 아닌 글은 공인이 아니다
+ */
+
+/** IPv4 특수 대역 [주소, 접두 길이] — RFC 6890 의 표와 같다 */
+const IPV4_SPECIAL: readonly (readonly [string, number])[] = [
+ ['0.0.0.0', 8],
+ ['10.0.0.0', 8],
+ ['100.64.0.0', 10],
+ ['127.0.0.0', 8],
+ ['169.254.0.0', 16],
+ ['172.16.0.0', 12],
+ ['192.0.0.0', 24],
+ ['192.0.2.0', 24],
+ ['192.88.99.0', 24],
+ ['192.168.0.0', 16],
+ ['198.18.0.0', 15],
+ ['198.51.100.0', 24],
+ ['203.0.113.0', 24],
+ ['224.0.0.0', 4],
+ ['240.0.0.0', 4],
+];
+
+/** IPv6 전역 유니캐스트 — 이 안에서만 공인으로 본다 */
+const IPV6_GLOBAL_UNICAST: readonly [string, number] = ['2000::', 3];
+
+/** 전역 유니캐스트 안의 특수 대역 */
+const IPV6_SPECIAL: readonly (readonly [string, number])[] = [
+ ['2001::', 23],
+ ['2001:db8::', 32],
+ ['2002::', 16],
+ ['3fff::', 20],
+];
+
+const blockedV4 = new BlockList();
+for (const [network, prefix] of IPV4_SPECIAL) {
+ blockedV4.addSubnet(network, prefix, 'ipv4');
+}
+
+const globalV6 = new BlockList();
+globalV6.addSubnet(IPV6_GLOBAL_UNICAST[0], IPV6_GLOBAL_UNICAST[1], 'ipv6');
+
+const blockedV6 = new BlockList();
+for (const [network, prefix] of IPV6_SPECIAL) {
+ blockedV6.addSubnet(network, prefix, 'ipv6');
+}
+
+/** IP 주소 글(대괄호 없이)이 공인 주소인가 — IP 가 아니면 false. 영역 표시(`%eth0`)가 붙은 IPv6 도 막는다(링크 안에서만 닿는 주소) */
+export function isPublicIpAddress(address: string): boolean {
+ if (address.includes('%')) return false;
+ const family = isIP(address);
+ if (family === 4) return !blockedV4.check(address, 'ipv4');
+ if (family === 6) {
+ return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
+ }
+ return false;
+}
diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts
index 46f397d..94b9451 100644
--- a/backend/test/app.e2e-spec.ts
+++ b/backend/test/app.e2e-spec.ts
@@ -40,9 +40,17 @@ import {
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
+import {
+ MAIL_IMAGE_RESOLVER,
+ MAIL_IMAGE_TRANSPORT,
+ type MailImageRequest,
+ type MailImageResponse,
+ type ResolvedAddress,
+} from '@/modules/mail/images/mail-image-fetcher';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
+ BlockedSender,
Correspondence,
DraftContent,
DraftSource,
@@ -50,6 +58,8 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
+ MailBlockAdded,
+ MailBlockList,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -60,6 +70,7 @@ import type {
MailWatch,
MailWatchListener,
MessageDeletionResult,
+ MessageDetailSource,
MessageListQuery,
MessageSource,
MessagesPatchResult,
@@ -67,6 +78,7 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
+ PendingSend,
RecipientRejectionReason,
SavedDraft,
SentMessage,
@@ -224,6 +236,25 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
textBody: '',
};
+/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
+const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
+
+/**
+ * 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
+ * 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
+ */
+const HELD_MESSAGE = 'eheldbyengine';
+
+/** 9단계 — 외부 이미지가 든 메일 (대역 엔진의 GET /api/messages/{id}) · 그 바깥 주소 */
+const IMAGE_MESSAGE = 'imagemessage';
+const PUBLIC_IMAGE_URL = 'https://images.e2e.example/logo.png';
+const INTERNAL_IMAGE_URL = 'https://intranet.e2e.example/secret.png';
+/** 대역 이름 풀기 — 공인 주소 · 사설 주소 (그 밖의 이름은 풀지 못한다) */
+const IMAGE_HOSTS = new Map([
+ ['images.e2e.example', [{ address: '93.184.216.34', family: 4 }]],
+ ['intranet.e2e.example', [{ address: '10.0.0.7', family: 4 }]],
+]);
+
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
const RAW_MESSAGE =
@@ -413,6 +444,37 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate;
}[] = [];
+ /** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
+ const mailBlocks = new Map();
+ const scheduledMessages = new Map();
+ const canceledSends: { user: string; id: string }[] = [];
+ /** 대역 엔진이 받은 한 통 삭제 (DELETE /api/messages/{id}) */
+ const deletedMessages: { user: string; id: string }[] = [];
+ const imageRequests: MailImageRequest[] = [];
+ const fakeImageResolver = {
+ resolve: (hostname: string): Promise => {
+ const addresses = IMAGE_HOSTS.get(hostname);
+ return addresses
+ ? Promise.resolve(addresses)
+ : Promise.reject(new Error('e2e-unknown-host'));
+ },
+ };
+ const fakeImageTransport = {
+ get: (imageRequest: MailImageRequest): Promise => {
+ imageRequests.push(imageRequest);
+ const data = pngBytes(48, 16);
+ return Promise.resolve({
+ status: 200,
+ location: null,
+ contentLength: data.length,
+ body: (async function* () {
+ yield await Promise.resolve(new Uint8Array(data));
+ })(),
+ close: () => undefined,
+ });
+ },
+ };
+
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
const liveWatches: {
user: string;
@@ -636,9 +698,21 @@ describe('응답 계약 (e2e)', () => {
);
}
sentMessages.push({ user, message });
+ // 9단계 — 엔진이 붙잡았으면 보낼 시각과 종류 (실제 계산은 jmap/pending-send.ts)
+ const hold = message.hold;
+ const pendingSend: PendingSend | null = hold
+ ? {
+ kind: hold.kind,
+ sendAt:
+ hold.kind === 'scheduled'
+ ? hold.until
+ : new Date(Date.now() + hold.seconds * 1_000).toISOString(),
+ }
+ : null;
return Promise.resolve({
id: `sent${sentMessages.length}`,
rejectedRecipients,
+ pendingSend,
});
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
@@ -689,6 +763,7 @@ describe('응답 계약 (e2e)', () => {
attachments: [],
inReplyTo: null,
updatedAt,
+ isCanceledSend: false,
});
},
deleteDraft: (user: string, id: string) => {
@@ -744,9 +819,14 @@ describe('응답 계약 (e2e)', () => {
}),
);
}
+ // 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 failedIds 로 (9단계)
+ const moving =
+ update.mailboxId !== undefined || update.isJunk !== undefined;
+ const fails = (id: string) =>
+ id === GONE_MESSAGE || (moving && id === HELD_MESSAGE);
return Promise.resolve({
- updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
- failedIds: ids.filter((id) => id === GONE_MESSAGE),
+ updatedIds: ids.filter((id) => !fails(id)),
+ failedIds: ids.filter(fails),
});
},
deleteFromTrash: (
@@ -964,6 +1044,16 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate,
): Promise