Compare commits

...

4 Commits

Author SHA1 Message Date
ttipo 30b3d28996 docs(rules): 9단계 부품 — 붙잡힌 메일 막기 · envelope 직접 · 외부 이미지 중계 · 차단 · 설정, 확장 때 옮길 키 · 동시 수 · 설정 기억
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m7s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 7m9s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m51s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 22s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m36s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
nestjs: 보내기 취소 · 예약 발송 · 외부 이미지 중계 · 차단과 설정 행, 체크리스트에 "붙잡힌 메일을 옮기거나 지우는 새 경로는 readHeldEmails 로 먼저 막는다"와
"발송 제출은 envelope 을 직접 준다"(주지 않으면 엔진이 받지 않는 주소를 말없이 뺀다), 인스턴스를 늘릴 때 옮길 것에 이미지 표의 암호 키 · 이미지 동시 수 · 설정 60초 기억.
vue3: 9단계 행 셋(보내기 취소 · 예약, 외부 이미지 · 인쇄, 차단 · 설정)과 밀도 예외의 쓰임(새 토큰 없음).
docker: 외부 이미지 중계 때문에 백엔드가 바깥 80 · 443 으로 나가야 하는 것, 도커 망 대역은 사설로, 같은 외부 프록시 뒤에 "내부 주소면 허용" 사이트를 두지 않는 것,
그 경로만의 요청 수 한도(IP 마다 1분 600회 — 뉴스레터 한 통에 이미지가 수십 개).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:08:07 +09:00
ttipo b99ccfbf6c feat(frontend): 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 화면
쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]),
인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절.

리뷰로 고친 것:
- 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다).
  읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다.
- 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도).
- 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다.
- 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도).
- 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다.
- 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게),
  차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:08:06 +09:00
ttipo b0412ac453 feat(backend): 보내기 취소 · 예약 발송 · 외부 이미지 중계 · 보낸 사람 차단 · 보내기 · 읽기 설정 API
계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다.
외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화.
차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings).

리뷰로 고친 것:
- 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds).
  읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다.
- 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다
  (한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다.
- 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게.
- 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다.
- 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시.
- 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정).

에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:07:56 +09:00
ttipo fa6799d9f7 docs: 9단계(보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단) 계약과 디자인
보내기 취소 · 예약 발송(엔진이 제출을 붙잡는다 — FUTURERELEASE), 외부 이미지 중계, 인쇄, 보낸 사람 차단, 보내기 · 읽기 설정의 계약(2절)을 정하고,
확정한 캔버스 보드 넷(MailReadTools · MailPrint · MailSendLater · SettingsSendRead)을 둔다. 디자인 README 에 구현하며 정한 보완을 적었다.
리뷰(화면 한 번 · 서버 두 번)로 정한 것도 함께 적었다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(MAIL_015 — 모든 옮기기 · 지우기 길, 확인하지 못하면 닫힌 쪽),
발송은 늘 envelope 을 직접 준다(엔진이 받지 않는 주소를 not-found 로 빼고 다시 제출 — 전에는 말없이 빠졌다), 다시 취소해도 200,
이미지 중계의 동시 수(서버 16 · 직원 6 — 받는 수가 적은 직원부터), 차단 주소는 dot-atom 만 · 다시 POST 하면 꺼진 규칙을 켠다.
엔진 사실(2026-10-29): envelope 의 거절은 제출 전체 · 첫 주소 하나, 내보내는 사본에서 Bcc 를 뗀다(개발의 Mailpit 이 보이는 Bcc 줄은 Mailpit 이 붙인다), 제출 조회의 정렬 이름은 sentAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:07:45 +09:00
195 changed files with 23560 additions and 700 deletions
+6
View File
@@ -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절 "실시간 알림"):
+10 -1
View File
@@ -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회). 본문의 `<img src>` 를 중계 주소로 바꾸는 것은 `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` 로 로드).
+4 -1
View File
@@ -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)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `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 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/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]`) |
@@ -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);
+17 -2
View File
@@ -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절)
@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE "mail_settings"`);
}
}
@@ -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());
@@ -867,6 +867,16 @@ export class ContactsService {
return (await this.freshBook(userEmail)).suggestionLines;
}
/**
* 그 주소(대소문자 무시)의 연락처가 내 주소록에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 "외부 이미지 보기" — 9단계).
* 판을 확인한 주소록에서 본다 (연락처를 바꾸면 바로 맞는다)
*/
async hasEmail(userEmail: string, address: string): Promise<boolean> {
return (await this.freshBook(userEmail)).contactsByEmail.has(
emailKey(address),
);
}
/** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */
private async groupMemberEmails(
userEmail: string,
@@ -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());
@@ -18,6 +18,8 @@ import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy';
interface DirectoryListing {
readonly entries: readonly DirectoryEntry[];
readonly byId: ReadonlyMap<string, DirectoryEntry>;
/** 주소(소문자) — 외부 이미지의 "사내 직원" 판정 (9단계) */
readonly emails: ReadonlySet<string>;
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<boolean> {
return (await this.listing()).emails.has(address.trim().toLowerCase());
}
/** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */
async findById(id: string): Promise<DirectoryEntry> {
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;
@@ -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<string[]> {
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',
]);
});
});
@@ -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;
}
@@ -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,
});
}
}
@@ -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;
}
@@ -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 {}
@@ -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<Record<keyof MailUserSettings, string>> = {
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<MailUserSettingsEntity>,
) {}
find(userEmail: string): Promise<MailUserSettingsEntity | null> {
return this.rows.findOneBy({ userEmail });
}
/**
* 보낸 칸만 바꾼다 — 한 문장(INSERT … ON CONFLICT DO UPDATE SET <보낸 칸>)이라 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다.
* 줄이 없으면 보내지 않은 칸은 `base`(처음 값)로 넣는다.
* @returns 저장한 뒤의 줄
*/
async upsert(
userEmail: string,
patch: MailUserSettingsPatch,
base: MailUserSettings,
now: Date,
): Promise<MailUserSettingsEntity> {
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;
}
}
@@ -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<string, MailUserSettingsEntity>();
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<unknown>,
): 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);
});
});
@@ -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<string, CacheEntry>();
/** 기억에 넣은 차례 — 읽는 사이 바뀐 값을 옛 읽기가 덮지 않게 */
private sequence = 0;
constructor(private readonly repository: MailUserSettingsRepository) {}
async get(userEmail: string): Promise<MailUserSettings> {
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<MailUserSettings> {
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);
}
}
}
@@ -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<MailUserSettings>;
/** 저장한 적 없는 직원의 값 (계약 — 처음 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);
}
@@ -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<string[]> {
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']);
});
});
@@ -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;
}
@@ -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 */
@@ -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,
@@ -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<string[]> {
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']);
});
});
@@ -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;
}
@@ -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([]);
}
});
});
@@ -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 없음) */
@@ -110,6 +110,7 @@ describe('sanitizeMailHtml', () => {
html: '<b>y</b>',
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('<img> 의 외부 src(http · https · //)만 중계 주소로 바꾼다 — 바꾼 수를 알려 준다', () => {
const result = show(
'<p>안녕</p><img src="https://news.example.com/a.png" alt="그림" width="600">' +
'<img src="http://news.example.com/b.gif"><img src="//cdn.example.com/c.webp">',
);
expect(result.html).toBe(
'<p>안녕</p>' +
`<img alt="그림" width="600" src="${proxied(new URL('https://news.example.com/a.png'))}" />` +
`<img src="${proxied(new URL('http://news.example.com/b.gif'))}" />` +
`<img src="${proxied(new URL('https://cdn.example.com/c.webp'))}" />`,
);
expect(result.proxiedImageCount).toBe(3);
expect(result.hasBlockedImages).toBe(false);
});
it('srcset · style 의 url() · background 속성은 지금처럼 지운다 (알려진 한계) — 지운 것이 있으면 hasBlockedImages', () => {
const result = show(
'<img srcset="https://cdn.example.com/a.png 2x">' +
'<div style="background:url(https://t.example/bg.png)">배경</div>' +
'<table><tr><td background="https://t.example/bg.png">a</td></tr></table>',
);
expect(result.html).toBe(
'<div>배경</div><table><tr><td>a</td></tr></table>',
);
expect(result.proxiedImageCount).toBe(0);
expect(result.hasBlockedImages).toBe(true);
});
it('중계할 수 없는 주소(기본 포트가 아님 · 사용자 정보 · 2,048자 초과)는 지운다', () => {
const result = show(
'<img src="https://news.example.com:8443/a.png">' +
'<img src="https://user:pw@news.example.com/a.png">' +
`<img src="https://news.example.com/${'a'.repeat(2_100)}">`,
);
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(
'<img src="https://t.example/s.gif">'.repeat(150),
new Map(),
{ externalImageSrc: counting },
);
expect(issued).toBe(1);
expect(same.proxiedImageCount).toBe(1);
expect(same.html.match(/<img /g)).toHaveLength(150);
issued = 0;
const many = sanitizeMailHtml(
Array.from(
{ length: 120 },
(_, index) => `<img src="https://t.example/${index}.gif">`,
).join(''),
new Map(),
{ externalImageSrc: counting },
);
expect(issued).toBe(100);
expect(many.proxiedImageCount).toBe(100);
expect(many.html.match(/<img /g)).toHaveLength(100);
expect(many.hasBlockedImages).toBe(true);
});
it('버린 영역(svg · template) 안의 이미지에는 중계 주소를 만들지 않는다 · cid · data: 이미지는 그대로', () => {
let issued = 0;
const result = sanitizeMailHtml(
'<svg><img src="https://t.example/hidden.png"></svg>' +
'<template><img src="https://t.example/t.png"></template>' +
`<img src="cid:logo@x"><img src="${PNG_DATA_URI}">`,
new Map([['logo@x', PNG_DATA_URI]]),
{
externalImageSrc: (url) => {
issued += 1;
return proxied(url);
},
},
);
expect(issued).toBe(0);
expect(result.html).toBe(
`<img src="${PNG_DATA_URI}" /><img src="${PNG_DATA_URI}" />`,
);
});
it('보이게 하지 않으면(옵션 없음) 바꾼 수는 0 이다', () => {
expect(
sanitizeMailHtml('<img src="https://t.example/a.png">')
.proxiedImageCount,
).toBe(0);
});
});
describe('위험한 요소', () => {
it.each([
[
@@ -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단계 "외부 이미지 보기")는 `<img>` 의 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<string, string>;
readonly markBlocked: () => void;
readonly markEmbedded: (contentId: string) => void;
/** 외부 이미지 주소 → 중계 주소 — 보이게 할 때만 있고, 넣지 못하면(주소 · 개수 한도) null */
readonly proxy: ((src: string) => string | null) | null;
}
function resolveImage(
attribs: sanitizeHtml.Attributes,
inlineImages: ReadonlyMap<string, string>,
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<string, string> = 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<string, string>();
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,
};
}
@@ -601,6 +601,7 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
html: '<div><p>서명</p></div>',
hasBlockedImages: false,
embeddedContentIds: new Set(),
proxiedImageCount: 0,
});
});
@@ -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)', () => {
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="로고" />`,
);
});
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 =
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="" />` +
`<img src='cid:SIG-${OTHER.toUpperCase()}@arasystem.test'>` +
'<img src="cid:logo@partner.example" />';
expect(restoreSignatureImageSources(sent)).toBe(
`<p>서명</p><img src="${signatureImagePath(ID)}" width="144" alt="" />` +
`<img src="${signatureImagePath(OTHER)}">` +
'<img src="cid:logo@partner.example" />',
);
});
it('되돌리기는 입력 길이에 비례한다 — src= 뒤 공백이 아주 길어도', () => {
const cpuMs = startCpuTimer();
const html = `<img src=${' '.repeat(500_000)}"x">`.repeat(4);
expect(restoreSignatureImageSources(html)).toBe(html);
// CPU 시간 (test/cpu-time.ts) — 지금 수십 ms. 공백을 여러 \s* 가 나눠 가지면 공백 수의 제곱이다
expect(cpuMs()).toBeLessThan(2_000);
});
});
@@ -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);
@@ -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<string, ResolvedAddress[] | Error>,
replies: (
| MailImageResponse
| Error
| ((request: MailImageRequest) => Promise<MailImageResponse>)
)[],
) {
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('<svg><script>alert(1)</script></svg>')],
}),
]);
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);
});
});
@@ -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<ResolvedAddress[]>;
}
export const MAIL_IMAGE_RESOLVER = Symbol('MAIL_IMAGE_RESOLVER');
/** 한 번 묻는 것 — 확인한 주소로만 연결한다 */
export interface MailImageRequest {
readonly url: URL;
/** 이 주소들로만 연결한다 (이름을 다시 풀지 않는다) — 모두 공인 주소로 확인한 것 */
readonly addresses: readonly ResolvedAddress[];
readonly headers: Readonly<Record<string, string>>;
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<Uint8Array>;
close(): void;
}
/** 전송 — GET 한 번. 연결하지 못하면 던진다 */
export interface MailImageTransport {
get(request: MailImageRequest): Promise<MailImageResponse>;
}
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<number> = new Set([
301, 302, 303, 307, 308,
]);
const HTTP_OK = 200;
/** 바깥으로 보내는 머리글 — 이것뿐이다 (쿠키 · Referer · 브라우저의 머리글은 넘기지 않는다) */
const REQUEST_HEADERS: Readonly<Record<string, string>> = 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<MailImageFetchResult> {
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<ResolvedAddress[]> {
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<MailImageResponse> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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);
}
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
),
],
['HTML', Buffer.from('<!doctype html><html><body>hi</body></html>')],
['빈 바이트', 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();
});
});
@@ -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<string> = 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;
}
@@ -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<MailImageFetchResult>((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<number> {
const settled = new Set<number>();
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);
});
});
@@ -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<string, number>();
/** 기다리는 줄 — 직원마다 온 차례 (빈 줄은 두지 않는다) */
private readonly lines = new Map<string, Waiter[]>();
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<ProxiedImage | null> {
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<string, number>, 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();
}
@@ -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);
});
});
@@ -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<Record<keyof TokenPayload, unknown>>;
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;
}
}
@@ -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();
});
});
@@ -0,0 +1,46 @@
import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants';
/**
* 중계할 수 있는 바깥 이미지 주소 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 메일의 `<img src>` 를 중계 주소로 바꿀 때와,
* 바깥이 다시 가라고 한 주소(redirect)를 따라갈 때 같은 검사를 한다.
* - http · https 만 — `//host/…`(프로토콜 상대)는 https 로 본다
* - 기본 포트만(80 · 443) — WHATWG URL 은 기본 포트를 빈 글로 둔다(`http://a:80` → 포트 없음). 그 밖의 포트는 사내 서비스 포트 찌르기에 쓰인다
* - 사용자 정보(`user:pass@`) 없음
* - 길이: 원래 글 · 정규화한 주소 모두 2,048자 이하
* 이름(호스트)이 공인 주소로 풀리는지는 받을 때 본다 (mail-image-fetcher.ts — 이름은 그때그때 다른 주소로 풀릴 수 있다)
*/
const ALLOWED_PROTOCOLS: ReadonlySet<string> = 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;
}
/** `<img src>` 의 글 → 중계할 주소 (정규화한 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;
}
@@ -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";
@@ -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<string, string>();
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);
});
});
@@ -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}`.
* 받은 메일의 외부 이미지를 보일 때 서버가 그 메일의 `<img src>` 를 이 주소로 바꿔 넣는다(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<void> {
// 클라이언트가 끊으면 바깥에서 받던 것도 멈춘다 (기다리던 차례에서도 빠진다) — 응답의 '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 } : {}),
},
});
}
}
@@ -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<Received>;
}> {
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<void>((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<void> {
server.closeAllConnections();
return new Promise((resolve) => server.close(() => resolve()));
}
/** 소켓이 닫힐 때 풀리는 약속 */
function closed(request: IncomingMessage): Promise<void> {
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<Uint8Array>): Promise<Buffer> {
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<ReturnType<typeof openServer>>;
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<void>;
beforeEach(async () => {
queries = 0;
dns = createSocket('udp4');
firstQuery = new Promise((resolve) =>
dns.on('message', () => {
queries += 1;
resolve();
}),
);
await new Promise<void>((resolve) => dns.bind(0, '127.0.0.1', resolve));
port = dns.address().port;
});
afterEach(async () => {
jest.restoreAllMocks();
await new Promise<void>((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);
});
});
@@ -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<ResolvedAddress[]> {
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<TransportPorts>): NodeMailImageTransport {
const transport = new NodeMailImageTransport();
transport.ports = { ...DEFAULT_PORTS, ...ports };
return transport;
}
get(request: MailImageRequest): Promise<MailImageResponse> {
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();
});
}
}
@@ -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 상태 코드는 로그 전용', () => {
@@ -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<string, JmapDeliveryStatus>();
const accepted = new Set<string>();
@@ -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<string>();
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}명` : ''}`;
@@ -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 <kim@example.com>"@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 <kim@example.com>"@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);
});
});
});
+187
View File
@@ -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<string>();
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<Record<string, string>> | null,
): Record<string, unknown> {
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 <kim@example.com>"@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<string, string>();
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;
}
@@ -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<string, unknown> = {}) =>
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<string, unknown>;
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<string, unknown>;
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;
+86 -15
View File
@@ -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<string>();
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;
}
@@ -197,7 +197,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
});
const HANBIT = ' "한빛" <sales@hanbit-partners.co.kr>';
/** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */
/**
* 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) + 옮기는 규칙이면 같은 요청의 계정 기다리는 제출(9단계 — held, 없으면 빈 목록).
* 옮기지 않는 규칙의 요청에는 제출 조회가 없다 — 남는 응답은 쓰이지 않는다
*/
const page = (
ids: string[],
list: Record<string, unknown>[],
@@ -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<string, unknown>[];
} = {},
): 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<string>();
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);
});
});
});
@@ -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<string, unknown> | null {
const patch: Record<string, unknown> = {};
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<RoundPage> {
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<JmapQueryResult>(responses, 'q', 'Email/query');
const found = expectResult<JmapGetResult<JmapEmail>>(
@@ -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<JmapInvocation[]> {
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<string>()
: await heldAmong(
page.held,
matchedIds,
this.heldLookup(user, run.callOptions()),
);
const patches = new Map<string, Record<string, unknown>>();
/** 옮기기가 든 패치 — 이 메일은 바뀌면 받은편지함을 떠난다 */
const moving = new Set<string>();
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 이 멈춘다.
@@ -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],
});
});
});
@@ -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<MailVacationState> {
const saved = await this.write(user, (current) => ({
rules: current.rules,
...current,
vacation,
}));
return toVacationState(saved);
}
/** 차단 목록 (계약 2절 "보낸 사람 차단" — 9단계) — 원본을 읽을 수 없으면(깨짐 · 새 판) 빈 목록 · 동작하지 않음 */
async getBlocks(user: string): Promise<MailBlockList> {
return toBlockList(await this.snapshot(user));
}
/**
* 차단을 더한다 — 맨 앞(최근)에. 이미 있는 주소면 그 항목을 돌려준다(created: false) — 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을
* 다시 써서 켠다(setFilters 가 같은 규칙을 다시 저장하는 것과 같다 — 성공한 차단은 늘 "동작 중"이다), 켜져 있으면 쓰지 않는다.
* 규칙 · 부재중은 다시 읽은 그대로 둔다 — 필터의 지문(version)을 보지 않는다(주소 하나를 더하는 일이라 그사이 바뀐 필터를 덮지 않는다).
* 이미 MAX_MAIL_BLOCKS 개면 VAL_001, 만든 글이 한도를 넘으면 MAIL_013
*/
async addBlock(user: string, sender: BlockedSender): Promise<MailBlockAdded> {
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<void> {
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<ScriptView> {
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<JunkMailboxTarget> {
const responses = await this.jmap.call(user, (accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'name', 'role'] },
'm',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
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<string> {
const { blobId } = await this.jmap.upload(
user,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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);
});
});
@@ -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
);
}
+21 -3
View File
@@ -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<string, boolean> | 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),
+32 -1
View File
@@ -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<string, JmapBodyValue>;
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<string, JmapDeliveryStatus | null> | null;
/**
* 이 제출의 envelope — 받는 사람(rcptTo)은 엔진이 받은 주소뿐이다(받지 않는 주소가 있으면 제출 전체를 거절한다 — envelope.ts).
* 엔진은 주소를 소문자 · 국제화 도메인은 `xn--` 꼴로 둔다 (2026-10-29 확인)
*/
envelope?: {
mailFrom?: { email?: string | null } | null;
rcptTo?: { email?: string | null }[] | null;
} | null;
}
export interface JmapGetResult<T> {
@@ -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<string, { id?: string }>;
created?: Record<string, JmapCreatedObject>;
updated?: Record<string, unknown>;
destroyed?: string[];
notCreated?: Record<string, JmapError>;
@@ -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 */
@@ -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> = {},
): 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<string, unknown>) =>
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<string, unknown> = { 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<string, unknown>]>([
// 엔진의 쿼리 최대가 우리 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-]+$/);
}
});
});
@@ -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: "<RFC 3339 UTC>"` → `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<string, string> {
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<string, unknown>,
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<string, string> {
const times = new Map<string, string>();
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<string, string> {
try {
const result = findResult(responses, `${prefix}g`, 'EmailSubmission/get');
if (!result.ok) return new Map();
const { list = [] } = result.args as JmapGetResult<JmapEmailSubmission>;
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<string>;
/**
* 조회가 맞는 제출을 다 주었다고 응답으로 확인했다(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<JmapQueryResult>(
responses,
`${prefix}q`,
'EmailSubmission/query',
);
list =
expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
`${prefix}g`,
'EmailSubmission/get',
).list ?? [];
} catch (error) {
throw heldCheckFailed(error);
}
const ids = new Set<string>();
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<HeldEmails>;
/**
* 후보 가운데 붙잡힌 메일 — 같은 요청에 덧붙여 읽은 계정 전체 조회(account)로 가린다. 그 조회가 다 주지 못했으면(complete 가 아님) 후보의 메일 id 로 다시 묻는다(lookup).
* 다시 물은 것도 다 받지 못하면 가릴 수 없다 — MAIL_001 (fail closed)
*/
export async function heldAmong(
account: HeldEmails,
candidates: readonly string[],
lookup: HeldLookup,
): Promise<ReadonlySet<string>> {
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<JmapEmail, 'id' | 'keywords'>,
times: ReadonlyMap<string, string>,
): PendingSend | null {
const sendAt = times.get(email.id);
if (sendAt === undefined) return null;
return {
sendAt,
kind:
email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo',
};
}
@@ -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);
});
});
+82 -5
View File
@@ -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}`;
@@ -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<BlockedSenderDto> {
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);
}
}
@@ -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<unknown>,
): 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<typeof fakeProvider>;
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);
});
});
@@ -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<MailBlockList> {
return this.provider.getMailBlocks(user.email);
}
/** 차단을 더한다 — 이미 있으면 그 항목(created: false — 컨트롤러가 200 으로 답한다) */
async add(
user: AuthenticatedUser,
dto: MailBlockCreateDto,
): Promise<MailBlockAdded> {
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 };
}
}
@@ -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');
});
});
+121
View File
@@ -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<string> = 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;
}
@@ -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;
+32
View File
@@ -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<string>(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단계) — 어댑터 안에서만 쓴다
+53 -6
View File
@@ -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<MailboxDeletion>;
@@ -105,6 +109,7 @@ export interface MailProvider {
/**
* 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다.
* 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
* 붙잡힌 메일(9단계)은 지우지 않는다 — 남은 것이 그것뿐이고 이 요청에서 지운 것이 없으면 MAIL_015
*/
emptyMailbox(user: string, id: string): Promise<MailboxBulkResult>;
@@ -123,6 +128,21 @@ export interface MailProvider {
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
/**
* 차단한 보낸 사람 (계약 2절 "보낸 사람 차단" — 9단계) — 최근에 차단한 것부터 · 동작하는지(isActive — 메일 필터의 것과 같은 값).
* 저장 자리는 메일 필터 · 부재중과 같은 엔진 규칙이다
*/
getMailBlocks(user: string): Promise<MailBlockList>;
/**
* 차단을 더한다 — 이미 있으면 바꾸지 않고 그 항목(created: false). 필터 · 부재중은 그대로 두고 필터의 지문(version)을 보지 않는다.
* 이미 MAX_MAIL_BLOCKS(500)개면 VAL_001, 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 대상의 모양 · 막을 수 있는지는 서비스가 검사한 값이다
*/
addMailBlock(user: string, sender: BlockedSender): Promise<MailBlockAdded>;
/** 차단을 푼다 — 없는 주소면 RES_001. 필터 · 부재중은 그대로 */
removeMailBlock(user: string, address: string): Promise<void>;
/**
* 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 —
@@ -182,9 +202,15 @@ export interface MailProvider {
*/
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/** 조회만 한다 — 읽음 처리하지 않는다 */
/**
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
*/
getMessage(user: string, id: string): Promise<MessageDetailSource>;
/**
* 한 통 바꾸기 — 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 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<MessagesPatchResult>;
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 — 붙잡힌 메일(9단계)은 MAIL_015 (아무것도 바꾸지 않는다) */
deleteMessage(user: string, id: string): Promise<void>;
/**
* 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다.
* 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다).
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001.
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. 붙잡힌 메일(9단계)은 건너뛴다(skippedIds)
*/
deleteFromTrash(
user: string,
@@ -215,23 +242,43 @@ export interface MailProvider {
): Promise<MessageDeletionResult>;
/**
* @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<SentMessage>;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
* 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다).
* 취소한 발송(제출이 모두 canceled — 9단계)은 지우지 않고 canceled 로 돌려준다 (서비스가 MAIL_005 로 답한다)
*/
findSentMessage(
user: string,
messageId: string,
): Promise<SentMessageRecord | null>;
/**
* 예약한 메일 (계약 2절 "보내기 취소 · 예약 발송" — GET /api/scheduled-messages) — 예약 표시가 있고 아직 보내지 않은 것만,
* 보낼 시각 차례(같으면 id 차례). 보내기 취소를 기다리는 메일은 들지 않는다
*/
listScheduledMessages(user: string): Promise<MessageSummary[]>;
/**
* 붙잡은 발송을 취소하고 그 메일을 임시보관함의 초안으로 되돌린다 (보내기 취소 · 예약 취소) — `id` 는 보낸 사본의 메일 id.
* 취소한 **뒤에** 다른 요청으로 옮긴다(같은 요청이면 취소가 실패해도 옮겨진다). 없는 메일 RES_001,
* 이미 보냈거나(엔진이 풀었음) 붙잡은 적이 없는 메일이면 MAIL_014(아무것도 바꾸지 않는다), 엔진이 취소를 확인해 주지 않으면 MAIL_001(옮기지 않는다),
* 취소는 됐는데 옮기지 못하면(한 번 더 한 뒤) MAIL_001.
* 이미 취소한 발송(제출이 모두 canceled)이면 성공 — 되돌리기를 마쳤으면(취소한 발송 표시) 어느 메일함에 있든 옮기지 않고, 마치지 못했으면 되돌리기만 다시 한다
* (같은 요청을 다시 보내도 같은 결과)
*/
cancelPendingSend(user: string, id: string): Promise<void>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
+423 -36
View File
@@ -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<K extends keyof MailProvider>(): 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> = {},
): SentMessageRecord => ({
id: 'sent-1',
fingerprint,
canceled: false,
pendingSend: null,
...overrides,
});
const source = (
overrides: Partial<MessageDetailSource> = {},
): 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> = {}): DraftSource => ({
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-27T01:00:00.000Z',
isCanceledSend: false,
...overrides,
});
/** 9단계 — 보내기 · 읽기 설정과 외부 이미지 "주소록 · 사내 직원" 판정의 대역 */
interface NinthStepMocks {
userSettings: {
get: jest.Mock<Promise<MailUserSettings>, [string]>;
};
contacts: { hasEmail: jest.Mock<Promise<boolean>, [string, string]> };
directory: { hasEmail: jest.Mock<Promise<boolean>, [string]> };
imageTokens: MailImageTokens;
}
/**
* 기존 발송 테스트는 붙잡지 않는 발송(보내기 취소 0초)을 본다 — 보내기 취소 · 예약은 따로 본다.
* 외부 이미지는 처음 값('ask')
*/
function fakeNinthStep(): NinthStepMocks {
return {
userSettings: {
get: jest
.fn<Promise<MailUserSettings>, [string]>()
.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
},
contacts: {
hasEmail: jest
.fn<Promise<boolean>, [string, string]>()
.mockResolvedValue(false),
},
directory: {
hasEmail: jest.fn<Promise<boolean>, [string]>().mockResolvedValue(false),
},
imageTokens: new MailImageTokens(),
};
}
function createService(
provider: ProviderMocks,
mailSettings: MailSettings,
signatures: SignatureMocks,
suggestions: { invalidate: jest.Mock<void, [string]> },
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<void, [string]> };
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<void, [string]>() };
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<MessageDetailSource> = {}) =>
source({
from: { name: '소식지', email: 'news@letters.example' },
rawHtmlBody: `<p>안녕하세요</p><img src="${EXTERNAL}" alt="로고">`,
...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('<p>안녕하세요</p>');
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: '<p>그림 없음</p>' }),
);
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: `<p>본문</p><img src="cid:sig-${MY_IMAGE_ID.toUpperCase()}@arasystem.test" width="96"><img src='cid:sig-${OTHER_IMAGE_ID}@arasystem.test'>`,
isCanceledSend: true,
}),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.bodyHtml).toBe(
`<p>본문</p><img src="${imageSrc(MY_IMAGE_ID)}" width="96" />`,
);
});
it('취소한 발송이 아닌 초안의 cid:sig-… 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지운다, 9단계 리뷰 뒤)', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
provider.getDraft.mockResolvedValue(
draftSource({
rawHtmlBody: `<p>본문</p><img src="cid:sig-${MY_IMAGE_ID}@arasystem.test" width="96">`,
isCanceledSend: false,
}),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.bodyHtml).toBe('<p>본문</p>');
// 서명 그림 주소가 생기지 않았으니 그림 목록을 읽지도 않는다
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);
+190 -18
View File
@@ -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 — 스팸메일함의 메일은 빼고)
* `<img>` 의 외부 주소를 중계 주소(`/api/mail-images/<표>`)로 바꿔 넣는다 — imagesShown 은 바꿔 넣은 것이 있으면 true,
* 보이게 한 본문은 hasBlockedImages 가 false 다(srcset · 배경 그림은 지금처럼 지운다 — 알려진 한계)
*/
async getMessage(
user: AuthenticatedUser,
id: string,
query: MessageDetailQueryDto = {},
): Promise<MessageDetail> {
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<boolean> {
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<MailUserSettings> {
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<boolean> {
const lookups: [string, () => Promise<boolean>][] = [
['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<SendHold | null> {
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<string | null> {
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<SentMessage> {
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;
}
+72 -1
View File
@@ -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<OutgoingMessage, 'to' | 'cc' | 'bcc'>;
@@ -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;
}
/** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */
@@ -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(
@@ -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,
+34 -5
View File
@@ -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 면 외부 이미지(<img> 의 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',
@@ -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);
}
}
@@ -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<unknown>,
): 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',
});
});
});
@@ -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<PaginatedResult<MessageSummary>> {
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 };
}
}
+26
View File
@@ -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', () => {
+15 -2
View File
@@ -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;
@@ -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);
});
});
@@ -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;
}
+781 -2
View File
@@ -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<string, ResolvedAddress[]>([
['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<string, BlockedSender[]>();
const scheduledMessages = new Map<string, MessageSummary[]>();
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<ResolvedAddress[]> => {
const addresses = IMAGE_HOSTS.get(hostname);
return addresses
? Promise.resolve(addresses)
: Promise.reject(new Error('e2e-unknown-host'));
},
};
const fakeImageTransport = {
get: (imageRequest: MailImageRequest): Promise<MailImageResponse> => {
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<MessageSummary> => {
singleUpdates.push({ user, id, update });
if (
id === HELD_MESSAGE &&
(update.mailboxId !== undefined || update.isJunk !== undefined)
) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
@@ -977,8 +1067,125 @@ describe('응답 계약 (e2e)', () => {
isFlagged: false,
hasAttachment: false,
size: 1,
pendingSend: null,
});
},
// 9단계 — 메일 읽기 (외부 이미지 확인용 한 통만). 정화 · 그림 판정은 html/*.spec.ts · mail.service.spec.ts 가 본다
getMessage: (
user: string,
id: string,
): Promise<MessageDetailSource> => {
if (id !== IMAGE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
mailboxIds: ['a'],
from: { name: '바깥 보낸 사람', email: 'news@letters.e2e.example' },
to: [{ name: null, email: user }],
subject: '그림이 든 메일',
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2_000,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: 'image@e2e',
references: [],
rawHtmlBody: `<p>안녕하세요</p><img src="${PUBLIC_IMAGE_URL}" alt="로고"><img src="${INTERNAL_IMAGE_URL}">`,
isRawHtmlBodyTruncated: false,
textBody: '안녕하세요',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
isInJunkMailbox: false,
});
},
// 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다)
getMailBlocks: (user: string): Promise<MailBlockList> =>
Promise.resolve({
senders: [...(mailBlocks.get(user) ?? [])],
isActive: true,
}),
addMailBlock: (
user: string,
sender: BlockedSender,
): Promise<MailBlockAdded> => {
const current = mailBlocks.get(user) ?? [];
const existing = current.find(
({ address }) => address === sender.address,
);
if (existing) {
return Promise.resolve({ sender: existing, created: false });
}
mailBlocks.set(user, [sender, ...current]);
return Promise.resolve({ sender, created: true });
},
removeMailBlock: (user: string, address: string): Promise<void> => {
const current = mailBlocks.get(user) ?? [];
if (!current.some((entry) => entry.address === address)) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mail-block-not-found' },
}),
);
}
mailBlocks.set(
user,
current.filter((entry) => entry.address !== address),
);
return Promise.resolve();
},
// 한 통 삭제 — 붙잡힌 메일은 409 MAIL_015 (9단계), 없으면 RES_001 (조건부 영구 삭제는 jmap-mail.provider.spec.ts)
deleteMessage: (user: string, id: string): Promise<void> => {
if (id === HELD_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
deletedMessages.push({ user, id });
return Promise.resolve();
},
// 9단계 — 붙잡은 발송 (엔진의 기다리는 제출 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
listScheduledMessages: (user: string): Promise<MessageSummary[]> =>
Promise.resolve(scheduledMessages.get(user) ?? []),
cancelPendingSend: (user: string, id: string): Promise<void> => {
if (id === ALREADY_RELEASED_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_014', {
context: { reason: 'e2e-not-a-pending-send' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
canceledSends.push({ user, id });
return Promise.resolve();
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -986,6 +1193,11 @@ describe('응답 계약 (e2e)', () => {
.useValue(fakeContactsProvider)
.overrideProvider(DIRECTORY_PROVIDER)
.useValue(fakeDirectoryProvider)
// 9단계 — 바깥 이미지 받기는 실제 네트워크에 나가지 않는다 (SSRF 검사 · 다시 가기 · 크기는 images/*.spec.ts)
.overrideProvider(MAIL_IMAGE_RESOLVER)
.useValue(fakeImageResolver)
.overrideProvider(MAIL_IMAGE_TRANSPORT)
.useValue(fakeImageTransport)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1023,6 +1235,7 @@ describe('응답 계약 (e2e)', () => {
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
'DELETE FROM mail_directory WHERE email LIKE $1',
'DELETE FROM mail_settings WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
@@ -3189,6 +3402,8 @@ describe('응답 계약 (e2e)', () => {
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
// 9단계 — 처음 설정은 보내기 취소 10초: 엔진이 붙잡았다가 보낸다
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
},
});
});
@@ -3209,6 +3424,7 @@ describe('응답 계약 (e2e)', () => {
{ email: NOBODY, reason: 'not-found' },
{ email: BUSY, reason: 'temporary' },
],
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
});
expect(deletedDrafts).toContain(draftId);
});
@@ -4376,6 +4592,565 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 보낸 사람 차단 · 보내기와 읽기 설정 (계약 2절).
* 엔진 쪽(붙잡기 · 취소 · Sieve · 바깥 받기의 SSRF 검사)은 jmap/*.spec.ts · images/*.spec.ts 가 본다 —
* 여기서는 HTTP 형식 · 요청 검사 · 설정(DB 의 mail_settings)이 발송과 읽기에 닿는지를 본다
*/
describe('9단계 — 보내기 취소 · 예약 · 외부 이미지 · 차단 · 설정', () => {
const patchSettings = (email: string, payload: object) =>
request(server)
.patch('/api/mail-settings')
.set('Cookie', cookieOf(email))
.send(payload);
describe('보내기 · 읽기 설정 (/api/mail-settings)', () => {
const ME = 'settings.me@e2e.arasystem.test';
it('로그인하지 않았으면 401 AUTH_001', async () => {
for (const res of [
await request(server).get('/api/mail-settings'),
await request(server)
.patch('/api/mail-settings')
.send({ undoSendSeconds: 5 }),
]) {
expect(res.status).toBe(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('처음에는 처음 값 — 보내기 취소 10초 · 외부 이미지 늘 묻기 (DB 에 줄이 없어도)', async () => {
const res = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: { undoSendSeconds: 10, externalImages: 'ask' },
});
});
it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => {
const first = await patchSettings(ME, {
externalImages: 'always',
}).expect(200);
expect(dataOf<unknown>(first)).toEqual({
undoSendSeconds: 10,
externalImages: 'always',
});
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
200,
);
expect(dataOf<unknown>(second)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const read = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const rows: {
undo_send_seconds: number;
external_images: string;
}[] = await dataSource.query(
'SELECT undo_send_seconds, external_images FROM mail_settings WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([
{ undo_send_seconds: 30, external_images: 'always' },
]);
});
it.each<[string, object]>([
['빈 본문(하나 이상)', {}],
['고를 수 없는 초', { undoSendSeconds: 7 }],
['글로 쓴 초', { undoSendSeconds: '10' }],
['null 초', { undoSendSeconds: null }],
['모르는 외부 이미지 값', { externalImages: 'never' }],
['null 외부 이미지', { externalImages: null }],
['모르는 칸', { theme: 'dark' }],
])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => {
const res = await patchSettings(ME, payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
});
describe('보내기 취소 · 예약 발송 (POST /api/messages · /api/scheduled-messages)', () => {
const ME = 'schedule.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * MINUTE_MS;
const send = (payload: object = {}) =>
request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '제목', body: '본문', ...payload });
const lastHold = () =>
sentMessages[sentMessages.length - 1]?.message.hold;
const pendingOf = (res: Response) =>
dataOf<{ pendingSend: PendingSend | null }>(res).pendingSend;
const summary = (id: string, sendAt: string): MessageSummary => ({
id,
threadId: id,
mailboxIds: ['e'],
from: { name: '나', email: ME },
to: [DOYUN],
subject: `예약 ${id}`,
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: { kind: 'scheduled', sendAt },
});
it('sendAt 이 있으면 그 시각(소수 초는 버린다)까지 붙잡는다 — 201 의 pendingSend 는 예약, 보내기 취소 시간을 더하지 않는다', async () => {
const second =
Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000;
const until = new Date(second).toISOString();
const res = await send({
sendAt: new Date(second + 750).toISOString(),
}).expect(201);
expect(pendingOf(res)).toEqual({ kind: 'scheduled', sendAt: until });
expect(lastHold()).toEqual({ kind: 'scheduled', until });
});
it.each<[string, () => string]>([
[
'4분 뒤 — 5분보다 이르다',
() => new Date(Date.now() + 4 * MINUTE_MS).toISOString(),
],
[
'7일하고 1분 뒤',
() => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(),
],
['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()],
['UTC 가 아님(+09:00)', () => '2030-12-01T09:00:00+09:00'],
['날짜가 아님', () => '내일 아침'],
])(
'예약 시각이 %s 이면 400 VAL_001 — 엔진에 보내지 않는다',
async (_label, sendAt) => {
const before = sentMessages.length;
const res = await send({ sendAt: sendAt() }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(before);
},
);
it('sendAt 이 없으면 설정의 보내기 취소 초만큼 붙잡는다 — 0초면 붙잡지 않고 pendingSend 는 null', async () => {
const undo = await send().expect(201);
expect(pendingOf(undo)).toEqual({
kind: 'undo',
sendAt: expect.any(String) as string,
});
expect(lastHold()).toEqual({ kind: 'undo', seconds: 10 });
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
const now = await send().expect(201);
expect(pendingOf(now)).toBeNull();
expect(lastHold()).toBeUndefined();
await patchSettings(ME, { undoSendSeconds: 20 }).expect(200);
await send().expect(201);
expect(lastHold()).toEqual({ kind: 'undo', seconds: 20 });
});
it('예약한 메일 — { items, pageInfo }(total 포함), 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => {
const at = (minutes: number) =>
new Date(Date.UTC(2030, 0, 1, 9, minutes)).toISOString();
scheduledMessages.set(ME, [
summary('s1', at(0)),
summary('s2', at(10)),
summary('s3', at(20)),
]);
const first = await request(server)
.get('/api/scheduled-messages?limit=2')
.set('Cookie', cookieOf(ME))
.expect(200);
const page = dataOf<Page<MessageSummary>>(first);
expect(page.items.map(({ id }) => id)).toEqual(['s1', 's2']);
expect(page.items[0].pendingSend).toEqual({
kind: 'scheduled',
sendAt: at(0),
});
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: expect.any(String) as string,
total: 3,
});
const second = await request(server)
.get(
`/api/scheduled-messages?limit=2&cursor=${encodeURIComponent(page.pageInfo.nextCursor ?? '')}`,
)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<Page<MessageSummary>>(second)).toEqual({
items: [expect.objectContaining({ id: 's3' }) as MessageSummary],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
});
const broken = await request(server)
.get('/api/scheduled-messages?cursor=bm90LWEtY3Vyc29y')
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(broken).error?.code).toBe('VAL_001');
await request(server).get('/api/scheduled-messages').expect(401);
});
it('취소 — 200 { draftId }(같은 메일 id), 이미 보냈으면 409 MAIL_014, 없거나 id 형식이 틀리면 404 RES_001 (엔진에 묻지 않는다)', async () => {
const cancel = (id: string) =>
request(server)
.delete(`/api/scheduled-messages/${id}`)
.set('Cookie', cookieOf(ME));
const ok = await cancel('eheldmsg1').expect(200);
expect(body(ok)).toEqual({
success: true,
data: { draftId: 'eheldmsg1' },
});
expect(canceledSends).toContainEqual({ user: ME, id: 'eheldmsg1' });
const released = await cancel(ALREADY_RELEASED_MESSAGE).expect(409);
expect(body(released).error?.code).toBe('MAIL_014');
const gone = await cancel(GONE_MESSAGE).expect(404);
expect(body(gone).error?.code).toBe('RES_001');
const before = canceledSends.length;
const malformed = await cancel('a"b').expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
expect(canceledSends).toHaveLength(before);
});
it('같은 발송을 두 번 취소해도 둘 다 200 { draftId } — 응답을 받지 못한 재시도 (멱등은 jmap-mail.provider.spec.ts)', async () => {
const cancel = () =>
request(server)
.delete('/api/scheduled-messages/etwicecanceled')
.set('Cookie', cookieOf(ME));
for (const res of [await cancel(), await cancel()]) {
expect(res.status).toBe(200);
expect(body(res)).toEqual({
success: true,
data: { draftId: 'etwicecanceled' },
});
}
});
describe('붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (409 MAIL_015)', () => {
const patchOne = (payload: object) =>
request(server)
.patch(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.send(payload);
it.each<[string, object]>([
['옮기기', { mailboxId: 'b' }],
['스팸 신고', { isJunk: true }],
['옮기기와 읽음 함께', { mailboxId: 'b', isRead: true }],
])(
'한 통 %s 는 409 MAIL_015 — 표준 실패 형식',
async (_label, payload) => {
const res = await patchOne(payload).expect(409);
expect(body(res)).toEqual({
success: false,
error: {
code: 'MAIL_015',
message:
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
traceId: expect.any(String) as string,
},
});
},
);
it('한 통의 읽음 · 중요 표시는 된다 — 200', async () => {
const res = await patchOne({ isFlagged: true, isRead: true }).expect(
200,
);
expect(dataOf<MessageSummary>(res).id).toBe(HELD_MESSAGE);
});
it('여러 통 옮기기는 200 — 붙잡힌 메일만 failedIds, 나머지는 옮긴다', async () => {
const res = await request(server)
.patch('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ ids: ['eaaaaab', HELD_MESSAGE], mailboxId: 'b' })
.expect(200);
expect(dataOf<MessagesPatchResult>(res)).toEqual({
updatedIds: ['eaaaaab'],
failedIds: [HELD_MESSAGE],
});
});
it('한 통 삭제(DELETE)는 409 MAIL_015 — 지우지 않는다', async () => {
const before = deletedMessages.length;
const res = await request(server)
.delete(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.expect(409);
expect(body(res).error?.code).toBe('MAIL_015');
expect(deletedMessages).toHaveLength(before);
// 붙잡히지 않은 메일은 지금처럼 204
await request(server)
.delete('/api/messages/eaaaaab')
.set('Cookie', cookieOf(ME))
.expect(204);
expect(deletedMessages).toContainEqual({ user: ME, id: 'eaaaaab' });
});
});
});
describe('외부 이미지 (GET /api/messages/{id}?images=show · GET /api/mail-images/{token})', () => {
const ME = 'images.me@e2e.arasystem.test';
const OTHER = 'images.other@e2e.arasystem.test';
interface ReadDetail {
htmlBody: string | null;
hasBlockedImages: boolean;
imagesShown: boolean;
isInJunkMailbox?: unknown;
}
const read = (query = '', who = ME) =>
request(server)
.get(`/api/messages/${IMAGE_MESSAGE}${query}`)
.set('Cookie', cookieOf(who));
/** 본문의 중계 주소들 (sanitize-html 이 & 를 &amp; 로 쓰지 않는 base64url 표) */
const proxiedSources = (html: string | null) =>
[...(html ?? '').matchAll(/src="(\/api\/mail-images\/[^"]+)"/g)].map(
(match) => match[1],
);
it('images 의 모르는 값 · 모르는 쿼리는 400 VAL_001', async () => {
for (const query of ['?images=1', '?images=SHOW', '?show=1']) {
const res = await read(query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('묻기(처음 설정)면 바깥 그림을 지우고 hasBlockedImages · imagesShown false — 스팸메일함 여부는 응답에 싣지 않는다', async () => {
const res = await read().expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.hasBlockedImages).toBe(true);
expect(detail.imagesShown).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
expect(proxiedSources(detail.htmlBody)).toEqual([]);
expect(detail).not.toHaveProperty('isInJunkMailbox');
});
it('images=show 면 그림 주소를 이 서버의 중계 주소로 바꾼다 — 받은 그림은 표준 래퍼 없이 그대로, 머리글은 계약대로', async () => {
const res = await read('?images=show').expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.imagesShown).toBe(true);
expect(detail.hasBlockedImages).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
const [publicImage, internalImage] = proxiedSources(detail.htmlBody);
expect(internalImage).toBeDefined();
const before = imageRequests.length;
// superagent 는 image/* 를 Buffer 로 받는다
const image = await request(server)
.get(publicImage)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(Buffer.isBuffer(image.body)).toBe(true);
expect(image.body as Buffer).toEqual(pngBytes(48, 16));
expect(image.headers['content-type']).toBe('image/png');
expect(image.headers['content-length']).toBe(
String(pngBytes(48, 16).length),
);
expect(image.headers['x-content-type-options']).toBe('nosniff');
expect(image.headers['cache-control']).toBe('private, max-age=3600');
expect(image.headers['content-disposition']).toBe('inline');
expect(image.headers['content-security-policy']).toBe(
"default-src 'none'; sandbox",
);
// 바깥으로는 확인한 주소로만 · 쿠키 · Referer 없이
expect(imageRequests).toHaveLength(before + 1);
const sent = imageRequests[imageRequests.length - 1];
expect(sent.url.href).toBe(PUBLIC_IMAGE_URL);
expect(sent.addresses).toEqual([
{ address: '93.184.216.34', family: 4 },
]);
expect(Object.keys(sent.headers).sort()).toEqual([
'accept',
'accept-encoding',
'user-agent',
]);
// 사설 주소로 풀리는 이름은 묻지 않고 404 RES_001 (까닭을 가리지 않는다)
const blocked = await request(server)
.get(internalImage)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(blocked)).toEqual({
success: false,
error: {
code: 'RES_001',
message: expect.any(String) as string,
traceId: blocked.headers['x-request-id'],
},
});
expect(imageRequests).toHaveLength(before + 1);
});
it('표는 그 직원 것만 — 다른 직원 · 고친 표 · 없는 표는 404 RES_001, 로그인하지 않았으면 401 AUTH_001', async () => {
const [source] = proxiedSources(
dataOf<ReadDetail>(await read('?images=show').expect(200)).htmlBody,
);
const token = source.slice('/api/mail-images/'.length);
const tampered = `${token.slice(0, -2)}${token.endsWith('AA') ? 'BB' : 'AA'}`;
for (const [path, who] of [
[source, OTHER],
[`/api/mail-images/${tampered}`, ME],
['/api/mail-images/not-a-token', ME],
] as const) {
const res = await request(server)
.get(path)
.set('Cookie', cookieOf(who))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
const anonymous = await request(server).get(source).expect(401);
expect(body(anonymous).error?.code).toBe('AUTH_001');
});
it('설정이 늘 보기(always)면 images 없이도 보인다', async () => {
await patchSettings(ME, { externalImages: 'always' }).expect(200);
const detail = dataOf<ReadDetail>(await read().expect(200));
expect(detail.imagesShown).toBe(true);
expect(proxiedSources(detail.htmlBody)).toHaveLength(2);
await patchSettings(ME, { externalImages: 'ask' }).expect(200);
});
});
describe('보낸 사람 차단 (/api/mail-blocks)', () => {
const ME = 'blocks.me@e2e.arasystem.test';
const add = (address: unknown, extra: object = {}) =>
request(server)
.post('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.send({ address, ...extra });
const list = async () =>
dataOf<{ senders: BlockedSender[]; isActive: boolean }>(
await request(server)
.get('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.expect(200),
);
it('처음에는 빈 목록 · 동작함, 로그인하지 않았으면 401 AUTH_001', async () => {
await expect(list()).resolves.toEqual({ senders: [], isActive: true });
const res = await request(server).get('/api/mail-blocks').expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('차단 — 앞뒤 공백을 빼고 소문자로, 새로 차단하면 201 · 이미 있으면 200(처음 차단한 시각) · 목록은 최근 것부터', async () => {
const created = await add(' Kim@Spam.Example ').expect(201);
const kim = dataOf<BlockedSender>(created);
expect(kim).toEqual({
address: 'kim@spam.example',
blockedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.+Z$/) as string,
});
const again = await add('kim@spam.example').expect(200);
expect(dataOf<BlockedSender>(again)).toEqual(kim);
await add('@Ads.Example.org').expect(201);
// 여러 사람이 쓰는 메일 서비스도 주소로는 막는다
await add('friend@gmail.com').expect(201);
expect((await list()).senders.map(({ address }) => address)).toEqual([
'friend@gmail.com',
'@ads.example.org',
'kim@spam.example',
]);
});
it.each<[string, unknown]>([
['내 주소(대소문자 무시)', ME.toUpperCase()],
['사내 도메인', '@e2e.arasystem.test'],
['사내 하위 도메인', '@mail.e2e.arasystem.test'],
['사내 직원 주소', 'doyun.lee@e2e.arasystem.test'],
['여러 사람이 쓰는 메일 서비스의 도메인', '@Naver.com'],
['주소 모양이 아님', 'not an address'],
['${ 가 든 주소', '${x}@spam.example'],
['IP 주소 도메인', '@127.0.0.1'],
['점 없는 도메인', '@localhost'],
['255자', `${'a'.repeat(240)}@spam.example`],
['빈 글', ' '],
['글이 아님', 42],
])(
'막을 수 없거나 모양이 틀리면 400 VAL_001 — %s',
async (_label, address) => {
const res = await add(address).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
},
);
it('모르는 칸이 있으면 400 VAL_001', async () => {
const res = await add('lee@spam.example', { note: '메모' }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('차단 풀기 — URL 인코딩한 주소(대소문자 무시)는 200 { address }, 없거나 모양이 틀리면 404 RES_001', async () => {
await add('lee@spam.example').expect(201);
const removed = await request(server)
.delete(`/api/mail-blocks/${encodeURIComponent('LEE@spam.example')}`)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(removed)).toEqual({
success: true,
data: { address: 'lee@spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('lee@spam.example');
// 도메인 차단도 같은 길 — `@` 를 인코딩한 경로(%40…)를 풀어 도메인으로 맞춘다 (대소문자 무시)
await add('@Domain-Spam.example').expect(201);
const domainRemoved = await request(server)
.delete('/api/mail-blocks/%40DOMAIN-spam.example')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(domainRemoved)).toEqual({
success: true,
data: { address: '@domain-spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('@domain-spam.example');
for (const path of [
encodeURIComponent('lee@spam.example'),
encodeURIComponent('not an address'),
]) {
const res = await request(server)
.delete(`/api/mail-blocks/${path}`)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -4406,6 +5181,10 @@ describe('응답 계약 (e2e)', () => {
['post', '/api/mail-attachments'],
['patch', '/api/contacts/abc'],
['post', '/api/contact-restorations'],
['patch', '/api/mail-settings'],
['post', '/api/mail-blocks'],
['delete', '/api/mail-blocks/kim%40spam.example'],
['delete', '/api/scheduled-messages/eaaaaab'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
async (method, path) => {
+10
View File
@@ -73,3 +73,13 @@ export function jpegBytes(
Buffer.from([0xff, 0xd9]),
]);
}
/** WebP 머리 — 'RIFF' 크기 'WEBP' 첫 덩어리(VP8 · VP8L · VP8X). `size` 바이트가 되게 0 으로 채운다 (바깥 이미지 중계 — 9단계) */
export function webpBytes(chunk = 'VP8 ', size = 30): Buffer {
const data = Buffer.alloc(Math.max(size, 20));
data.write('RIFF', 0, 'ascii');
data.writeUInt32LE(data.length - 8, 4);
data.write('WEBP', 8, 'ascii');
data.write(chunk, 12, 'ascii');
return data;
}
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>인쇄 화면</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
</style>
</helmet>
<div style="width: 1440px; height: 1060px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 980px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">인쇄 화면</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 새 탭에 메일 한 통만 담은 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄웁니다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않습니다. 긴 메일은 여러 쪽으로 이어집니다.</p>
</div>
<div style="display: grid; grid-template-columns: 820px minmax(0, 1fr); gap: 28px; align-items: start">
<section aria-label="인쇄 화면 (새 탭)" style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 12px; overflow: hidden; background: #E9ECEF">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 16px; background: #FFFFFF; border-bottom: 1px solid #E6E8EC">
<span style="flex: 1 1 auto; font-weight: 600; color: #344054">인쇄 화면 — 화면에만 보이는 줄</span>
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg><span>인쇄</span></button>
<button type="button" class="ghost" style="min-height: 40px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">닫기</button>
</div>
<div style="display: flex; justify-content: center; padding: 28px 0 32px">
<article aria-label="A4 한 쪽" style="width: 595px; min-height: 760px; box-sizing: border-box; padding: 48px 52px; background: #FFFFFF; box-shadow: 0 4px 18px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column; gap: 14px; color: #111827">
<div style="display: flex; align-items: baseline; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #D0D5DD; font-size: 10.5px; color: #4A5363">
<span>ARA SYSTEM 메일</span>
<span>김아라 &lt;ara.kim@arasystem.it.kr&gt;</span>
</div>
<h2 style="margin: 4px 0 0; font-size: 17px; line-height: 1.4; font-weight: 700">Re: 메일 서버 이전 일정 공유</h2>
<dl style="display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 3px 10px; margin: 0; font-size: 11.5px">
<dt style="color: #4A5363">보낸 사람</dt><dd style="margin: 0">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</dd>
<dt style="color: #4A5363">받는 사람</dt><dd style="margin: 0">김아라 &lt;ara.kim@arasystem.it.kr&gt;</dd>
<dt style="color: #4A5363">참조</dt><dd style="margin: 0">인프라팀 &lt;infra@arasystem.it.kr&gt;</dd>
<dt style="color: #4A5363">날짜</dt><dd style="margin: 0">2026년 10월 26일 (월) 오전 10:42</dd>
<dt style="color: #4A5363">첨부</dt><dd style="margin: 0">메일서버_전환_체크리스트.xlsx (48 KB)</dd>
</dl>
<hr style="margin: 2px 0; border: 0; border-top: 1px solid #E6E8EC">
<div style="display: flex; flex-direction: column; gap: 10px; font-size: 12.5px; line-height: 1.75">
<p style="margin: 0">김아라님, 안녕하세요.</p>
<p style="margin: 0">말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다. 전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.</p>
<p style="margin: 0">전환 체크리스트를 첨부했으니 확인 부탁드립니다.</p>
<p style="margin: 0">이도윤 드림</p>
</div>
</article>
</div>
</section>
<div style="display: flex; flex-direction: column; gap: 20px">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">인쇄되는 것</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>머리 줄 — 「ARA SYSTEM 메일」과 인쇄한 직원의 이름 · 주소</li>
<li>제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것과 같습니다 — 숨은참조는 넣지 않습니다)</li>
<li>첨부 — 파일 이름과 크기만(내용은 인쇄하지 않습니다)</li>
<li>본문 — 읽기 화면과 같은 정화를 거친 그대로. 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따릅니다</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">이미지 · 긴 메일</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>외부 이미지는 읽기 화면에서 보이고 있던 대로 인쇄합니다(막혀 있었으면 인쇄에도 없습니다)</li>
<li>본문은 인쇄할 폭(A4)에 맞춰 다시 잰 뒤 인쇄합니다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않습니다</li>
<li>인쇄 창을 닫아도 인쇄 화면 탭은 남습니다 — [인쇄]로 다시, [닫기]로 탭을 닫습니다</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">못 열 때</h2>
<p style="margin: 0; color: #344054">메일을 찾을 수 없으면 인쇄 화면에 "메일을 찾을 수 없습니다"와 [닫기], 받지 못하면 [다시 시도]가 보이고 인쇄 창은 띄우지 않습니다. 세션이 끝났으면 로그인 화면으로 갑니다.</p>
</section>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1060}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.danger-solid{background:#B42318;color:#FFFFFF}
.danger-solid:hover{background:#912018}
.tool{background:transparent;color:#344054}
.tool:hover{background:#F2F4F7;color:#171C26}
.notice-btn{background:#FFFFFF;color:#7A5200;border:1px solid #F0D9A8}
.notice-btn:hover{background:#FFF1D6}
.menu-item{background:transparent;color:#344054}
.menu-item:hover{background:#EEF0F3}
.toast-action{background:transparent;color:#9ED6DB}
.toast-action:hover{background:rgba(255,255,255,0.1)}
</style>
</helmet>
<div style="width: 1440px; height: 1220px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 980px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">외부 이미지는 지금처럼 막아 두고, 원하면 그 메일만 보이게 합니다. 이미지는 사내 서버가 대신 받아 보여 주므로 보낸 사람에게 이 PC 의 주소가 드러나지 않습니다. 인쇄 · 차단은 ⋯ 메뉴에서 합니다.</p>
</div>
<div style="display: grid; grid-template-columns: 860px minmax(0, 1fr); gap: 20px; align-items: start">
<section aria-label="읽기 화면 — 외부 이미지를 막은 메일" style="display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; overflow: hidden">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 24px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg><span>답장</span></button>
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>전체 답장</span></button>
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 17 20 12 15 7"></polyline><path d="M4 18v-2a4 4 0 0 1 4-4h12"></path></svg><span>전달</span></button>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 8px; background: #E6E8EC"></span>
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
<button type="button" class="tool" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; color: #B42318; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>삭제</span></button>
<button type="button" class="tool" aria-label="더 보기" aria-haspopup="menu" aria-expanded="true" style="display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 0; border-radius: 8px; background: #EEF0F3; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
</div>
<article aria-labelledby="rt-subject" style="display: flex; flex-direction: column; gap: 18px; box-sizing: border-box; padding: 20px 32px 32px">
<h2 id="rt-subject" style="margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">[클라우드 브리프] 10월 넷째 주 — 서버 비용 줄이는 다섯 가지</h2>
<div style="display: flex; align-items: flex-start; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">클</span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
<span style="font-size: 13.5px; font-weight: 600">클라우드 브리프</span>
<span style="font-size: 12.5px; color: #667085">news@cloudbrief.example</span>
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">2026. 10. 29. (목) 오전 8:00</span>
</div>
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 &lt;ara.kim@arasystem.it.kr&gt;</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #FFF7E6; color: #7A5200; font-size: 12.5px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
<span style="flex: 1 1 auto">외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
<button type="button" class="notice-btn" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">이미지 보기</button>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 640px; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">안녕하세요, 이번 주 클라우드 브리프입니다. 서버 비용을 줄이는 다섯 가지 방법을 정리했습니다.</p>
<p style="margin: 0">1. 쓰지 않는 디스크 정리하기 — 스냅숏만 남기고 붙어 있지 않은 볼륨을 지우면 …</p>
<p style="margin: 0">2. 예약 인스턴스로 바꾸기 — 1년 약정이면 …</p>
<p style="margin: 0; color: #667085">(이미지가 있던 자리는 비어 있습니다 — 글은 그대로 읽힙니다.)</p>
</div>
</article>
</section>
<div style="display: flex; flex-direction: column; gap: 20px">
<div role="menu" aria-label="더 보기" style="display: flex; flex-direction: column; width: 300px; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg><span style="flex: 1 1 auto">인쇄</span><span style="font-size: 12px; color: #667085">Ctrl+P</span></button>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg><span>원문 내려받기 (.eml)</span></button>
<span role="separator" style="height: 1px; margin: 4px 6px; background: #EEF0F3"></span>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg><span>보낸 사람을 주소록에 추가</span></button>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg><span>이런 메일로 필터 만들기</span></button>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; color: #B42318; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg><span>보낸 사람 차단</span></button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363; max-width: 380px">「인쇄」는 새 탭에 인쇄 화면을 열고 바로 인쇄 창을 띄웁니다(다음 보드). 읽기 화면에서 Ctrl+P 를 눌러도 같습니다. 이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」입니다. 사내 직원 · 내 주소는 차단 항목이 없습니다.</p>
<div role="dialog" aria-modal="true" aria-labelledby="blk-title" style="width: 400px; box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 14px">
<h2 id="blk-title" style="margin: 0; font-size: 15px; font-weight: 700">보낸 사람 차단</h2>
<p style="margin: 0; font-size: 13px; color: #344054">앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.</p>
<div role="radiogroup" aria-label="차단할 범위" style="display: flex; flex-direction: column; gap: 4px">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F2FAFA; cursor: pointer">
<input type="radio" name="blk-scope" checked style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">이 주소만</span><span style="font-size: 12.5px; color: #4A5363">news@cloudbrief.example</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="blk-scope" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">이 도메인 전체</span><span style="font-size: 12.5px; color: #4A5363">@cloudbrief.example 에서 오는 메일 모두</span></span>
</label>
</div>
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: pointer">
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span>이 메일도 스팸메일함으로 옮기기</span>
</label>
<p style="margin: 0; font-size: 12.5px; color: #667085">차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">[이미지 보기]를 누른 뒤</h2>
<p style="margin: 0; font-size: 12px; color: #667085">그 메일만 · 다시 열면 설정대로</p>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid #EEF0F3; border-radius: 8px">
<div style="display: flex; align-items: center; justify-content: center; height: 120px; border-radius: 6px; background: linear-gradient(135deg, #DDEFF1, #F3E9D8); color: #4A5363; font-size: 12.5px">(받아 온 이미지)</div>
<p style="margin: 0; font-size: 13.5px; color: #1F2733">안녕하세요, 이번 주 클라우드 브리프입니다. …</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">안내 줄은 사라집니다. 이미지를 받지 못한 자리(없어진 주소 · 너무 큰 파일 · 사내망 주소)는 비어 있습니다. 메일 안의 배경 그림은 보이지 않습니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">설정에 따라 처음부터 보일 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">설정 › 메일 › 보내기 · 읽기 › 외부 이미지</p>
</div>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li><strong>늘 묻기</strong>(처음 값) — 메일마다 [이미지 보기]</li>
<li><strong>주소록 · 사내 직원의 메일은 늘 보기</strong> — 보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다. 나머지는 [이미지 보기]</li>
<li><strong>늘 보기</strong> — 모든 메일에서 바로 보입니다</li>
</ul>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">스팸메일함의 메일은 설정과 상관없이 늘 묻습니다. 답장 · 전달에 인용한 본문에는 이미지가 실리지 않습니다(지금과 같음).</p>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">차단한 뒤</h2>
<p style="margin: 0; font-size: 12px; color: #667085">알림 · 스팸메일함에서 읽을 때</p>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<span style="flex: 1 1 auto; font-weight: 500">news@cloudbrief.example 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다.</span>
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #F2F4F7; color: #344054; font-size: 12.5px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
<span style="flex: 1 1 auto">차단한 보낸 사람의 메일입니다.</span>
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 6px; background: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">[되돌리기]는 차단을 풀고 메일을 받은편지함으로 돌려놓습니다. 「이 도메인 전체」는 gmail.com · naver.com 처럼 여러 사람이 쓰는 메일 서비스에서는 고를 수 없습니다(까닭을 칸 아래에).</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1220}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+203
View File
@@ -0,0 +1,203 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>보내기 취소 · 예약 발송</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.menu-item{background:transparent;color:#344054}
.menu-item:hover{background:#EEF0F3}
.toast-action{background:transparent;color:#9ED6DB}
.toast-action:hover{background:rgba(255,255,255,0.1)}
.row:hover{background:#F6F7F9}
</style>
</helmet>
<div style="width: 1440px; height: 1500px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 1000px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">보내기 취소 · 예약 발송</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">[보내기]를 누르면 메일 서버가 몇 초(설정 — 처음 10초) 기다렸다가 보냅니다. 그동안 [보내기 취소]로 쓰기 화면에 돌아옵니다. 기다리는 곳이 서버라 창을 닫아도 그대로 보내집니다. 예약 발송은 같은 방식으로 7일 안의 시각까지 기다립니다.</p>
</div>
<div style="display: grid; grid-template-columns: 480px 420px minmax(0, 1fr); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">쓰기 화면 — 보내기 ▾</h2>
<p style="margin: 0; font-size: 12px; color: #667085">단추를 둘로 나눕니다 — 오른쪽 ▾ 는 메뉴</p>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 64px; box-sizing: border-box; padding: 10px 12px; border: 1px solid #EEF0F3; border-radius: 10px">
<div style="display: inline-flex; align-items: stretch; border-radius: 8px; overflow: hidden">
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px 0 18px; border: 0; font: inherit; font-weight: 600; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기</span></button>
<span aria-hidden="true" style="width: 1px; background: rgba(255, 255, 255, 0.35)"></span>
<button type="button" class="primary" aria-label="보내기 방법 더 보기" aria-haspopup="menu" aria-expanded="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; min-height: 44px; border: 0; font: inherit; cursor: pointer; background: #0A5F67"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
<span style="margin-left: auto; font-size: 12px; color: #667085">2분 전에 저장함</span>
</div>
<div role="menu" aria-label="보내기 방법" style="display: flex; flex-direction: column; width: 260px; margin-top: -4px; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span>예약 발송…</span></button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">메뉴는 「예약 발송…」 하나로 시작합니다. 단축키 Ctrl+Enter 는 그대로 [보내기]입니다. 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추가 함께 꺼집니다(지금의 [보내기]와 같은 규칙).</p>
</section>
<div role="dialog" aria-modal="true" aria-labelledby="sch-title" style="box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 14px">
<h2 id="sch-title" style="margin: 0; font-size: 15px; font-weight: 700">예약 발송</h2>
<div role="radiogroup" aria-label="보낼 시각" style="display: flex; flex-direction: column; gap: 4px">
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F2FAFA; cursor: pointer">
<input type="radio" name="sch" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span style="flex: 1 1 auto; font-weight: 600">내일 오전</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">10월 30일 (금) 오전 9:00</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span style="flex: 1 1 auto; font-weight: 600">내일 오후</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">10월 30일 (금) 오후 1:00</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
<span style="flex: 1 1 auto; font-weight: 600">다음 주 월요일</span><span style="font-size: 12.5px; color: #4A5363; font-variant-numeric: tabular-nums">11월 2일 (월) 오전 9:00</span>
</label>
<label style="display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; padding: 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<span style="display: flex; align-items: center; gap: 10px"><input type="radio" name="sch" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86"><span style="font-weight: 600">직접 고르기</span></span>
<span style="display: flex; gap: 8px; padding-left: 28px">
<input class="field" type="date" value="2026-10-30" aria-label="날짜" style="height: 40px; padding: 0 10px; flex: 1 1 auto">
<select class="field" aria-label="시각" style="height: 40px; padding: 0 8px; flex: 0 0 130px"><option>오전 9:00</option></select>
</span>
</label>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">예약</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">예약 시각 규칙</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>골라 둔 시각이 이미 지났거나 5분 안이면 칸 아래 "지금부터 5분 뒤 이후의 시각을 고르세요."</li>
<li>7일을 넘으면 "7일 안의 시각만 고를 수 있습니다." — 메일 서버의 한도입니다</li>
<li>시각은 30분 단위로 고릅니다. 오늘 오후가 남았으면 「오늘 오후 6:00」도 첫 줄에 보입니다</li>
<li>예약한 메일도 받는 사람 확인은 지금 합니다 — 없는 주소면 지금과 같이 칸 아래에서 막습니다</li>
</ul>
</section>
</div>
<div style="display: grid; grid-template-columns: 520px minmax(0, 1fr); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">보낸 뒤 알림</h2>
<p style="margin: 0; font-size: 12px; color: #667085">[보내기 취소]는 기다리는 시간 동안만 — 그 뒤 알림은 저절로 닫힙니다</p>
</div>
<div role="status" style="display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; padding: 8px 6px 10px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<div style="display: flex; align-items: center; gap: 10px">
<span style="flex: 1 1 auto; font-weight: 500">메일을 보냈습니다.</span>
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">보내기 취소</button>
</div>
<span aria-hidden="true" style="display: block; height: 3px; margin-right: 8px; border-radius: 2px; background: rgba(255, 255, 255, 0.18)"><span style="display: block; width: 62%; height: 100%; border-radius: 2px; background: #7FD3DA"></span></span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #912018; color: #FFFFFF">
<span style="flex: 1 1 auto; font-weight: 500">보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)</span>
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; color: #FFD8D3; font: inherit; font-weight: 600; cursor: pointer">보내기 취소</button>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.</span>
</div>
<div role="alert" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #912018; color: #FFFFFF">
<span style="flex: 1 1 auto; font-weight: 500">이미 보낸 메일이라 취소할 수 없습니다.</span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">10월 30일 (금) 오전 9:00 에 보냅니다.</span>
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">예약됨 보기</button>
</div>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">보내기 취소의 흐름</h2>
<ol style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>[보내기] → 쓰기 화면을 닫고 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(설정한 초 — 마우스를 올려도 시간은 멈추지 않습니다)</li>
<li>[보내기 취소] → 서버가 보내지 않고 그 메일을 임시보관함으로 돌려놓습니다 → 쓰기 화면이 그 메일로 다시 열립니다(받는 사람 · 제목 · 본문 · 첨부 그대로)</li>
<li>시간이 지나면 알림이 닫히고 메일은 보낸 것입니다 — 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."</li>
<li>받는 사람 확인은 [보내기]를 누른 순간에 합니다 — 일부가 받지 못하면 그 알림에도 [보내기 취소]가 있어 주소를 고쳐 다시 보낼 수 있습니다</li>
<li>설정 › 메일 › 보내기 · 읽기에서 끔 · 5 · 10 · 20 · 30초 — 끄면 지금처럼 바로 보냅니다</li>
</ol>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">기다리는 동안에도 보낸편지함에 보입니다(날짜 칸에 "보내는 중"). 다른 화면으로 옮겨 다녀도 알림은 그대로입니다. 로그아웃 · 다른 계정 로그인 때는 알림을 닫습니다 — 메일은 그대로 보내집니다.</p>
</section>
</div>
<div style="display: grid; grid-template-columns: 280px 560px minmax(0, 1fr); gap: 20px; align-items: start">
<section aria-label="메일함 패널 — 예약됨" style="display: flex; flex-direction: column; gap: 2px; padding: 12px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg><span style="flex: 1 1 auto">받은편지함</span><span style="font-size: 12px; font-weight: 600; color: #344054">5</span></a>
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span style="flex: 1 1 auto">보낸편지함</span></a>
<a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span style="flex: 1 1 auto">예약됨</span><span style="font-size: 12px; font-weight: 600">2</span></a>
<a href="#" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border-radius: 8px; color: #344054; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg><span style="flex: 1 1 auto">임시보관함</span></a>
<p style="margin: 6px 4px 0; font-size: 12px; color: #667085">「예약됨」은 예약한 메일이 있을 때만 보낸편지함 아래에 보입니다. 숫자는 예약한 통 수입니다.</p>
</section>
<section aria-label="예약됨 목록" style="display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; overflow: hidden">
<div style="display: flex; align-items: baseline; gap: 10px; padding: 14px 20px; border-bottom: 1px solid #EEF0F3">
<h2 style="margin: 0; font-size: 16px; font-weight: 700">예약됨</h2>
<span style="font-size: 12.5px; color: #667085">2통 · 보낼 시각 차례</span>
</div>
<a href="#" class="row" style="display: grid; grid-template-columns: 150px minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 52px; padding: 0 20px; border-bottom: 1px solid #EEF0F3; color: #171C26; text-decoration: none">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받는 사람: 이도윤</span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">11월 첫 주 배포 일정 확인 부탁드립니다</span>
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600; font-variant-numeric: tabular-nums">10월 30일 (금) 오전 9:00</span>
</a>
<a href="#" class="row" style="display: grid; grid-template-columns: 150px minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 52px; padding: 0 20px; color: #171C26; text-decoration: none">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받는 사람: 인프라팀 외 2명</span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">[공지] 메일 서버 전환 완료 안내</span>
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600; font-variant-numeric: tabular-nums">11월 2일 (월) 오전 9:00</span>
</a>
<p style="margin: 0; padding: 12px 20px; font-size: 12px; color: #667085; border-top: 1px solid #EEF0F3">고르는 칸 · 여러 통 처리는 없습니다(한 통씩 엽니다). 보낸 메일은 저절로 이 목록에서 빠지고 보낸편지함에 남습니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">예약한 메일을 열면</h2>
<p style="margin: 0; font-size: 12px; color: #667085">읽기 화면 머리 아래 안내 줄</p>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 12px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-size: 12.5px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">10월 30일 (금) 오전 9:00 에 보낼 메일입니다.</span>
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #9ED6DB; border-radius: 6px; background: #FFFFFF; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">고치기</button>
<button type="button" class="ghost" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 1px solid #9ED6DB; border-radius: 6px; background: #FFFFFF; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">예약 취소</button>
</div>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>[예약 취소] → 보내지 않고 임시보관함으로 옮깁니다 — 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]</li>
<li>[고치기] → 예약을 취소하고 쓰기 화면으로 엽니다 — 다시 [보내기] 또는 「예약 발송…」</li>
<li>답장 · 전달 · 스팸 신고 단추는 이 메일에는 없습니다. [삭제]는 예약을 취소한 뒤 휴지통으로 옮깁니다(확인 창)</li>
</ul>
<div style="display: grid; grid-template-columns: minmax(0, 1fr) 150px; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; border: 1px solid #EEF0F3; border-radius: 8px">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap"><span style="color: #4A5363">이도윤</span> · <span style="font-weight: 500">11월 첫 주 배포 일정 확인 부탁드립니다</span></span>
<span style="text-align: right; font-size: 12.5px; color: #0A5F67; font-weight: 600">예약 · 10월 30일 오전 9:00</span>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">보낸편지함에서도 보입니다 — 날짜 칸이 "예약 · 보낼 시각"(보내기 취소를 기다리는 동안은 "보내는 중")입니다.</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1500}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+109
View File
@@ -466,6 +466,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록).
사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄.
## 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 (2026-10-29 확정 — `MailReadTools` · `MailPrint` · `MailSendLater` · `SettingsSendRead`)
작고 자주 쓰는 다섯 가지를 한 번에 더한다. 계약은 `docs/mail/api-contract.md` 2절의 9단계 절들. 새 아이콘: 인쇄(printer) · 시계(clock) · 차단(원 + 빗금).
### 읽기 화면 — 외부 이미지 · ⋯ 메뉴 · 차단 (`MailReadTools`)
- **외부 이미지 줄**: 지금의 노란 안내 줄("외부 이미지를 차단했습니다. …") 오른쪽에 [이미지 보기](36 — 밀도 예외 `--size-notice-action`). 누르면 그 메일만 이미지를 보이고 안내 줄은 사라진다.
다시 열면 설정대로다. 받지 못한 이미지 자리는 비어 있다. 메일 안의 배경 그림은 보이지 않는다(알려진 한계).
- **설정에 따라**(설정 › 메일 › 보내기 · 읽기 › 외부 이미지): 늘 묻기(처음 값) · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음부터 보이면 안내 줄이 없다. **스팸메일함의 메일은 늘 묻는다.**
- **⋯ 메뉴**: 「인쇄」(Ctrl+P 표시) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」 → 「이런 메일로 필터 만들기」 → 「보낸 사람 차단」(빨간 글).
이미 차단한 보낸 사람이면 마지막 항목이 「보낸 사람 차단 풀기」. 사내 직원 · 내 주소에는 차단 항목이 없다. 차단한 주소가 500개면 항목을 끄고 이름 아래에 까닭.
- **차단 확인 창**(모달 400): 제목 "보낸 사람 차단", 설명 "앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다."
고르기 둘 — 「이 주소만」(주소) · 「이 도메인 전체」(@도메인) — 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인을 고를 수 없게 끄고 그 아래에 까닭.
체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐), 안내 "차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다." — [차단](처음 초점 — 되돌릴 수 있는 일) [취소].
- **차단한 뒤**: 알림 "‘주소’ 을 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 받은편지함으로). 스팸메일함에서 차단한 보낸 사람의 메일을 열면
회색 안내 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기].
### 인쇄 화면 (`MailPrint`)
- ⋯ › 인쇄(또는 읽기 화면에서 Ctrl+P)는 **새 탭**에 인쇄 화면을 열고 브라우저의 인쇄 창을 바로 띄운다. 앱의 레일 · 메일함 · 목록 · 도구 줄은 인쇄하지 않는다.
- 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다.
- 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(읽기 화면에 보이는 것 — 숨은참조는 넣지 않는다) →
첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다.
- 외부 이미지는 읽기 화면에서 보이던 대로. 본문은 인쇄 폭(A4)에 맞춰 높이를 다시 잰 뒤 인쇄한다 — 긴 메일은 여러 쪽으로 이어지고 끝에 빈 쪽이 생기지 않는다
(2026-10-29 확인: 지금 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다).
- 못 열 때: 메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 [다시 시도] — 인쇄 창은 띄우지 않는다. 세션이 끝났으면 로그인 화면으로.
### 보내기 취소 · 예약 발송 (`MailSendLater`)
- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). Ctrl+Enter 는 그대로 [보내기].
보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙).
- **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각.
오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소].
칸 아래 오류: "지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." 받는 사람 확인은 지금처럼 보내는 순간(없는 주소면 받는 사람 칸의 띠).
- **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다.
일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게).
[보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다."
예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다.
- **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례,
고르는 칸 · 여러 통 처리는 없다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다.
- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 —
알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]). 답장 · 전달 · 스팸 신고 단추는 없다. [삭제]는 예약을 취소한 뒤 휴지통으로(확인 창).
- **보낸편지함**: 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00"(청록) — 보내기 취소를 기다리는 동안은 "보내는 중".
### 설정 · 보내기 · 읽기 · 차단한 주소 (`SettingsSendRead`)
- **메뉴**: 메일 묶음에 「보내기 · 읽기」(필터 아래) · 「차단한 주소」를 더한다 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림.
- **보내기 · 읽기**: 고르는 대로 저장한다(저장 단추 없음 — 머리 오른쪽에 "저장했습니다"). 서버에 저장되어 어느 PC 에서나 같다.
- 「보내기 취소」 — 눌림 전환(36 — `.segmented`) 끔 · 5초 · 10초(처음) · 20초 · 30초 + "[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다."
- 「외부 이미지」 — 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기, 줄마다 아래 설명) + 회색 상자
"외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다."
- **차단한 주소**: 머리 "차단한 주소" + "3 / 500" + 설명 "이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다."
- 더하기 줄: 칸 "차단할 주소"(placeholder "kim@example.com 또는 @example.com") + [차단]. 칸 아래 오류: "메일 주소나 @도메인 형식으로 써 주세요." · "이미 차단한 주소입니다." ·
"내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다."
- 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기].
이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -692,3 +749,55 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다).
- **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구.
다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다).
### 9단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다)
- **외부 이미지**: [이미지 보기]는 받는 동안 꺼진다(`aria-disabled` · `aria-busy`). 받지 못하면 인터셉터가 알리고 줄은 그대로다. 그 메일의 본문 칸만 바꾸고(받는 사이 다른 메일을 열었으면 버린다),
줄이 사라져 초점을 잃으면 본문 틀로 옮긴다. 본문 틀의 CSP 는 보일 때만 그림에 우리 출처의 중계 경로(`/api/mail-images/`)를 연다 — 경로까지 좁힌다(2026-10-29 리뷰).
중계 주소로 인정하는 모양은 `/api/mail-images/<base64url 한 칸>` 뿐이다(짝: backend `TOKEN_PATTERN`). 답장 · 전달 인용에는 중계 그림도 실리지 않는다.
- **인쇄**: 라우트 `mail-print`(앱 틀 없음 · 로그인 필요), 탭 제목은 메일 제목(인쇄 머리글 · PDF 이름으로 쓰인다). `window.open` 은 누름 처리 안에서 바로 부른다(팝업 차단을 피하려고).
- Ctrl+P(⌘P)는 메일이 다 열려 있을 때만 가로챈다 — 본문 틀 안에서 눌러도 같고, 한글 조합 중 · 다른 수식키 · 불러오는 중이면 브라우저 인쇄 그대로다. 라틴 글자가 아닌 배열은 `code === 'KeyP'` 로도 잡는다.
- 종이 210mm · 여백 16mm 15mm 는 @page 와 같은 값이다(둘을 함께 고친다). 본문 틀의 load(그림까지) · 글꼴 준비 · 한 프레임 뒤에 인쇄 창을 한 번만 띄운다 —
그사이 다시 받기 시작했으면 띄우지 않고, [인쇄]는 다 잰 뒤에만 눌린다. [닫기]로 닫히지 않는 탭(주소로 바로 들어온 탭)은 메일 화면으로 간다.
- 첨부는 목록에 보이는 것만 "이름 (크기)", 날짜는 "2026년 10월 26일 (월) 오전 10:42". 받기 실패는 화면이 그린다(알림 없음). 문구: "(제목 없음)" · "(보낸 사람 없음)" · 받는 중 "메일을 불러오는 중".
- **보내기 취소 알림**: 마감은 설정한 초를 알면 "지금 + 그 초 − 1초", 모르면 min(sendAt, 지금 + 30초)이다(설정은 메일 화면에 들어올 때 받아 둔다 — 아는 값이 0 인데 서버가 붙잡았으면 sendAt).
이 PC 시계로 이미 지났으면 지금처럼 [보낸편지함 보기] 알림이다. 마감이 있는 알림은 마우스 · 초점으로 멈추지 않고, 알림이 넘쳐도 먼저 닫히지 않는다. 막대는 `<progress>`(aria-hidden)다.
- 알림 단추는 한 번만 돈다(닫히는 전환 중에 두 번 눌러도 — `takeAction`). 같은 메일의 취소는 요청 하나로 묶는다. 초점이 든 알림이 닫히면 알림에 들어오기 전 요소로(없으면 본문 영역) 돌려준다.
- 쓰기 화면으로 가지 못하면(그 화면의 나가기 확인에서 머묾 등) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기].
- 일부 거절 문장은 붙잡은 발송이면 "받지 못합니다"(현재형)다. 예약에서 일부 거절: "예약했지만 N명은 받지 못합니다 — 주소 (까닭)" + [예약됨 보기].
- **[보내기 ▾]**: ▾ 이름 "보내기 방법 더 보기", 메뉴 이름 "보내기 방법". 메뉴는 아래가 모자라면 위로 연다(`usePopupPlacement` — 메일함 ⋯ 과 같은 규칙). 「예약 발송…」은 [보내기]와 같은 사전 검사를 먼저 한다(걸리면 창을 열지 않고 그 칸으로).
- **예약 창**: 「오늘 오후」 6:00 은 5분 뒤 이후일 때만, 「다음 주 월요일」은 오늘 다음의 첫 월요일(오늘이 월요일이면 7일 뒤)이다. 예약할 수 없는 시각은 빼고, 같은 시각이 겹치면 앞의 것만 둔다.
직접 고르기는 오늘 ~ 7일 뒤 · 30분 단위, 처음 값은 내일 오전 9:00, 검사는 [예약]을 누를 때(role=alert). 시간대 이름을 모르면 "이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.".
창이 열린 동안 보낼 수 없게 되거나 세션이 바뀌면 닫는다. 서버가 `sendAt` 을 `VAL_001` 로 거절하면 창을 다시 열어 그 시각을 「직접 고르기」에 채우고 까닭(5분 · 7일 문구)을 보인다 —
이 화면의 검사로는 맞는 시각(PC 시계가 어긋남)이면 가까운 한도로 본다. 쓰기 화면을 이미 떠났으면 알림으로.
- **「예약됨」**: 수는 `GET /scheduled-messages?limit=1` 의 total 이다 — 메일 화면에 들어올 때 · 예약하거나 취소한 뒤 · 새 메일 확인이나 실시간 알림이 바뀜을 알릴 때 다시 묻는다.
예약됨을 보는 동안은 0통이어도 패널 줄을 남긴다(숫자 없이). 패널 줄은 보낸편지함(과 그 하위) 바로 아래이고 ⋯ 는 없다.
- 목록: 분할 보기 2줄(받는 사람 · 시각 / 제목), 목록 보기 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150). 고르는 칸 · 별 · 여러 통 처리 · 비우기 · 알림 권하기 줄은 없다.
빈 상태 "예약한 메일이 없습니다" / "쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.".
- [고치기] · [예약 취소]는 하는 동안 두 단추를 함께 막고, 성공해 줄이 사라지면 초점을 옮긴다. [고치기] 뒤 "예약을 취소했습니다. 고친 뒤 다시 보내 주세요." —
쓰기 화면으로 가지 못하면 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]. [예약 취소] 뒤에는 삭제처럼 다음 메일(분할) · 목록(목록 보기)으로.
- **엔진이 발송을 풀 때**: 새 메일 확인 · 실시간 알림의 상태는 바뀌지 않으므로(계약 2절) 보이는 목록 · 열린 메일 · 가장 이른 예약의 시각 + 2초에 셋을 알림 없이 다시 받고,
시각이 지난 채 남으면 5초마다 3번 더 본다(`usePendingSendRefresh`). 검색 결과는 기다리는 메일이 보일 때만, 열린 메일은 `pendingSend` 만 고친다. `MAIL_014` 를 받아도 바로 같은 셋을 다시 받는다.
- **붙잡힌 메일(보내는 중 · 예약)** — 취소로만 보낸편지함을 떠난다(2026-10-29 리뷰, 계약 2절 `MAIL_015`):
- 목록(분할 · 표 · 검색)의 고르기 칸은 꺼지고(`aria-disabled`) title 이 "보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요." — 하나 · 범위 · 모두 고르기가 건너뛰고
여러 통 처리에도 들지 않는다(고른 뒤 붙잡히면 고른 것에서 뺀다). 보낸편지함 표의 날짜 칸은 title 에도 같은 글.
- 읽기 화면은 두 종류 모두 스팸 신고 · 이동 · ⋯ 의 차단 항목이 없다. [삭제]는 확인 "보내지 않은 메일을 삭제할까요?" / "보내지 않고 휴지통으로 옮깁니다." [삭제] [취소](처음 초점 「취소」) →
발송 취소 → 휴지통 → "보내지 않고 휴지통으로 옮겼습니다.". 창을 연 채 발송이 풀리면 창을 닫는다(보낸 메일의 영구 삭제 확인으로 바뀌지 않게).
- 서버도 막는다(`MAIL_015` — 화면이 막지 못한 때만 보인다): 여러 통 처리는 그 메일만 남기고(`failedIds` · `skippedIds` — 지금처럼), 메일함 지우기 · 비우기는 붙잡힌 메일만 남으면
진행 알림이 멈추고 인터셉터 알림 "보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요."(IMAP 으로 옮긴 메일에서만 생긴다 — 4단계 처리의 실패와 같은 모양).
- **기다린 뒤 다음 메일로**: 삭제 · 스팸 · 차단 · 예약 취소처럼 응답을 기다린 뒤 옮기는 일은 그사이에도 같은 목록 · 같은 메일을 보고 있을 때만 한다(3 · 4단계의 단추도 함께 고쳤다 — 2026-10-29 리뷰).
- **차단**:
- 항목이 없는 때: 보낸 사람이 없을 때 · 내 주소 · 사내 도메인(그 하위 포함). 차단 목록은 메일 화면에 들어올 때 알림 없이 받고, 못 받았으면 ⋯ 를 열 때 다시 받는다(모르는 동안은 「보낸 사람 차단」).
500개면 항목을 끄고(`aria-disabled`) "차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.".
- 창: 고르기 묶음 이름 "차단할 범위", 도메인을 고를 수 없는 까닭 "여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.". 「이 메일도 스팸메일함으로 옮기기」는 이미 스팸메일함이면 숨긴다.
- 옮겼으면 삭제처럼 다음으로 간다. [되돌리기]는 차단을 풀고 **원래 메일함**으로 되돌린다(보드의 "받은편지함" 대신 — 받은편지함 밖에서 막았을 수 있다).
도메인으로 막힌 보낸 사람의 「차단 풀기」는 그 도메인 차단을 푼다. 풀기가 404 면(이미 풀림) 알림 없이 푼 것으로 본다. 차단 · 풀기 뒤 세션이 바뀌었으면 옮기기 · 알림을 하지 않는다.
- 조사는 `objectParticle` 로 — 영문 주소는 "을(를)", 따옴표 뒤에 붙여 쓴다(보드의 "‘주소’ 을" 과 다르다). 문구: 옮기지 않은 차단 "‘주소’을(를) 차단했습니다." ·
되돌리기 뒤 "‘주소’ 차단을 풀고 메일을 되돌렸습니다."(옮기지 않았던 차단이면 "‘주소’ 차단을 풀었습니다.") · 풀기의 되돌리기 뒤 "‘주소’을(를) 다시 차단했습니다.".
- **설정 › 보내기 · 읽기**: 머리 설명 "고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.", "저장했습니다"는 머리 오른쪽(`SettingsPageFrame` 의 `title-aside` 슬롯).
칸마다 저장은 한 번에 하나 — 보내는 동안 고른 값은 기억했다가 앞 저장이 끝난 뒤 보낸 값 · 서버의 답과 다르면 보낸다. "저장했습니다"는 다 끝나고 마지막이 성공했을 때. 끝내 실패하면 서버가 확인한 값으로 되돌린다.
- **설정 › 차단한 주소**: 검사 차례는 형식 → 내 주소 · 사내 도메인 → 메일 서비스 도메인 → 이미 차단 → 500개. 서버 거절(`VAL_001` · `MAIL_013`)은 칸 아래 사전 문구(알림 없음).
성공하면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다."(`role=status` — 비어 있어도 영역은 늘 둔다). Enter 는 [차단]과 같다(`isImeComposing`). 줄을 풀면 초점은 다음 줄 → 앞 줄 → 칸.
엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." —
차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절).
- **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금).
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 보내기 · 읽기 · 차단한 주소</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-error{border-color:#B42318}
.nav-item{color:#344054}
.nav-item:hover{background:#F2F4F7}
.seg{background:transparent;color:#344054}
.seg:hover{background:#F2F4F7}
.unblock{background:#FFFFFF;color:#344054;border:1px solid #D0D5DD}
.unblock:hover{background:#F2F4F7}
</style>
</helmet>
<div style="width: 1440px; height: 1000px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 1000px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">설정 · 보내기 · 읽기 · 차단한 주소</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">메일 묶음에 두 화면을 더합니다. 「보내기 · 읽기」는 고르는 대로 저장합니다(저장 단추 없음 — 알림 설정과 같은 결). 이 설정은 서버에 저장되어 어느 PC 에서나 같습니다.</p>
</div>
<div style="display: grid; grid-template-columns: 220px 560px minmax(0, 1fr); gap: 20px; align-items: start">
<nav aria-label="설정" style="display: flex; flex-direction: column; gap: 2px; padding: 12px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<span style="padding: 6px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">계정</span>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">비밀번호</a>
<span style="padding: 10px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">메일</span>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">서명</a>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">부재중 자동 응답</a>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">필터</a>
<a href="#" aria-current="page" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">보내기 · 읽기</a>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">차단한 주소</a>
<a href="#" class="nav-item" style="display: flex; align-items: center; min-height: 40px; padding: 0 10px; border-radius: 8px; text-decoration: none">알림</a>
</nav>
<section aria-labelledby="sr-title" style="display: flex; flex-direction: column; gap: 22px; padding: 24px 28px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; align-items: baseline; gap: 12px">
<h2 id="sr-title" style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">보내기 · 읽기</h2>
<span role="status" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #067647"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>저장했습니다</span>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<h3 id="undo-label" style="margin: 0; font-size: 14px; font-weight: 700">보내기 취소</h3>
<div role="radiogroup" aria-labelledby="undo-label" style="display: inline-flex; align-self: flex-start; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px">
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">끔</button>
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">5초</button>
<button type="button" role="radio" aria-checked="true" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">10초</button>
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">20초</button>
<button type="button" role="radio" aria-checked="false" class="seg" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer">30초</button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.</p>
</div>
<hr style="margin: 0; border: 0; border-top: 1px solid #EEF0F3">
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-size: 14px; font-weight: 700">외부 이미지</legend>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
<input type="radio" name="img" checked style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">늘 묻기</span><span style="font-size: 12.5px; color: #4A5363">메일마다 [이미지 보기]를 눌러 봅니다</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
<input type="radio" name="img" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">주소록 · 사내 직원의 메일은 늘 보기</span><span style="font-size: 12.5px; color: #4A5363">보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 40px; padding: 8px 0; cursor: pointer">
<input type="radio" name="img" style="width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column"><span style="font-weight: 600">늘 보기</span><span style="font-size: 12.5px; color: #4A5363">스팸메일함의 메일은 이 설정과 상관없이 묻습니다</span></span>
</label>
<p style="margin: 4px 0 0; padding: 10px 12px; border-radius: 8px; background: #F6F7F9; font-size: 12.5px; color: #4A5363">외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.</p>
</fieldset>
</section>
<section aria-labelledby="bl-title" style="display: flex; flex-direction: column; gap: 16px; padding: 24px 28px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 4px">
<div style="display: flex; align-items: baseline; gap: 10px">
<h2 id="bl-title" style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">차단한 주소</h2>
<span style="font-size: 12.5px; color: #667085">3 / 500</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="bl-add" style="font-size: 12.5px; font-weight: 600; color: #4A5363">차단할 주소</label>
<div style="display: flex; gap: 8px">
<input id="bl-add" class="field is-error" type="text" inputmode="email" value="kim@" placeholder="kim@example.com 또는 @example.com" aria-invalid="true" aria-describedby="bl-add-err" style="flex: 1 1 auto; height: 44px; padding: 0 12px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
</div>
<p id="bl-add-err" style="margin: 0; font-size: 12.5px; color: #B42318">메일 주소나 @도메인 형식으로 써 주세요.</p>
</div>
<ul style="list-style: none; margin: 0; padding: 0; border: 1px solid #EEF0F3; border-radius: 8px">
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600; overflow: hidden; text-overflow: ellipsis">news@cloudbrief.example</span><span style="font-size: 12px; color: #667085">10월 29일 차단</span></span>
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
</li>
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600">@promo-mail.example</span><span style="font-size: 12px; color: #667085">도메인 전체 · 10월 21일 차단</span></span>
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
</li>
<li style="display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 8px 0 14px">
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35"><span style="font-weight: 600">sales@unknown-shop.example</span><span style="font-size: 12px; color: #667085">10월 18일 차단</span></span>
<button type="button" class="unblock" style="flex: 0 0 auto; min-height: 36px; padding: 0 12px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">차단 풀기</button>
</li>
</ul>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 8px; background: #F6F7F9; color: #4A5363; font-size: 12.5px">
<span>칸 아래 안내(보드에 없던 경우): "이미 차단한 주소입니다." · "내 주소 · 사내 도메인은 차단할 수 없습니다." · "gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요." · "500개까지 차단할 수 있습니다."</span>
<span>[차단 풀기]는 바로 풀고 알림 "‘news@cloudbrief.example’ 차단을 풀었습니다." + [되돌리기]. 이미 받은 메일은 옮기지 않습니다.</span>
<span>차단한 주소가 없으면 목록 자리에 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."</span>
</div>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1000}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+8 -3
View File
@@ -29,9 +29,13 @@
"MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" },
"Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true },
"ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" },
"ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" }
"ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" },
"MailReadTools.dc.html": { "x": 0, "y": 16820, "w": 1440, "h": 1220, "title": "읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단" },
"MailPrint.dc.html": { "x": 1520, "y": 16820, "w": 1440, "h": 1060, "title": "인쇄 화면" },
"MailSendLater.dc.html": { "x": 3040, "y": 16820, "w": 1440, "h": 1500, "title": "보내기 취소 · 예약 발송" },
"SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "title": "설정 · 보내기 · 읽기 · 차단한 주소" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -40,7 +44,8 @@
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
"phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
"phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 },
"phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }
"phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 },
"phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 }
},
"designSystems": []
}
+212 -15
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `SYS_001`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -152,6 +152,7 @@ interface MessageSummary {
isFlagged: boolean
hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다
size: number // 바이트
pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null
}
interface MessageDetail extends MessageSummary {
@@ -163,7 +164,8 @@ interface MessageDetail extends MessageSummary {
references: string[]
htmlBody: string | null // 서버가 정화한 HTML (아래 참고)
textBody: string | null
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 (보이게 했으면 false)
imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정)
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
attachments: MessageAttachment[]
@@ -180,7 +182,7 @@ interface MessageAttachment extends Attachment {
**`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거,
링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`),
외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`.
외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true` (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계).
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다.
넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다
(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다.
@@ -205,24 +207,24 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과") | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
@@ -234,6 +236,14 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` |
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
| `PATCH /api/mail-settings` | `Partial<MailSettings>` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` |
| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` |
| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` |
| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` |
표에 없는 실패 (구현에서 확정):
@@ -303,7 +313,8 @@ interface MailQuota {
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
"draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
"draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
"sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
}
```
@@ -313,6 +324,7 @@ interface MailQuota {
interface SentMessage {
id: string // 보낸편지함의 메일 id
rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열
pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null
}
interface RejectedRecipient {
email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번)
@@ -332,6 +344,23 @@ interface RejectedRecipient {
- 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다.
결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다.
- 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로.
- **엔진이 받지 않는 주소**(9단계 리뷰 뒤 — 2026-10-29 실엔진): 발송은 늘 envelope(받는 사람 · 참조 · 숨은참조, 같은 주소는 하나)을 직접 준다.
엔진은 envelope 에 받지 않는 주소(모르는 최상위 도메인 `x@example.cmo` · 따옴표 앞부분 `"a b"@…` 등)가 있으면 제출 **전체**를 거절하고 그 가운데 첫 주소 하나만 알려 준다.
서버는 그 주소를 `not-found` 로 빼고 같은 메일을 나머지에게 다시 제출한다 — 붙잡기(9단계)는 그대로이고, 차례마다 **하나씩** 뺀다.
거절 글이 따옴표로 싼 주소와 정확히 같은 받는 사람부터 찾는다 — 따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다(2026-10-29 리뷰 2).
- 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400 `VAL_001`(21번째를 뺀 뒤 남은 사람이 없으면 `MAIL_010`).
- 남는 사람이 없으면 만든 사본을 지우고 400 `MAIL_010`(아래 "모두 거절"과 같다).
- 다시 낼 시간(발송 55초 안에서 10초 이상)이 모자라면 사본을 지우고 503 `MAIL_001` — 아직 아무것도 제출하지 않았으니 같은 키로 다시 보내면 된다.
발송 응답은 사본 정리까지 56초 안이다(정리는 남은 시간 안에서, 남은 시간이 없어도 1초까지는 기다린다).
- 다시 낸 제출이 요청째 거절되면(메서드 오류 · HTTP 4xx — 엔진이 실행하지 않았다) 사본을 지우고 첫 제출과 같은 오류로 끝난다.
결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx)는 지우지 않는다 — 나갔을 수 있어 같은 키의 재시도가 맡는다(첫 제출과 같다).
- 거절 글에 우리 주소가 없으면 지금처럼 400 `VAL_001`.
- 전에는 envelope 을 주지 않아 엔진이 그런 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 오타 도메인으로 간 사람이 응답에 없었다.
- 결과(`deliveryStatus`)를 읽지 못해도 envelope 에서 뺀 사람은 `rejectedRecipients` 에 싣는다. 같은 키의 재시도는 제출 기록의 envelope 에 없는 받는 사람을 `not-found` 로 다시 만든다.
- 주소는 대소문자를 무시하고 국제화 도메인은 `xn--` 꼴로 맞춘다 — 엔진은 유니코드 · `xn--` 모두 받고 제출 기록에는 `xn--` 로 둔다.
엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서).
- **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다).
개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다).
- **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`.
같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다).
@@ -977,11 +1006,179 @@ interface MailFilterRun {
"창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기].
탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.
### 보내기 취소 · 예약 발송 (`POST /api/messages` 의 기다리기 · `/api/scheduled-messages`) — 9단계
메일 서버(엔진)가 제출을 잠시 붙잡았다가 보낸다 — `EmailSubmission` 의 FUTURERELEASE(`envelope.mailFrom.parameters` 의 `HOLDFOR` 초 · `HOLDUNTIL` 시각).
붙잡힌 동안은 `undoStatus: pending` 이고, 취소하면(`canceled`) 배달되지 않고, 시간이 되면 보낸다(`final`) — Stalwart 0.16.27, 2026-10-29 확인.
기다리는 곳이 서버라 창을 닫아도 보내진다. 화면은 디자인 README "9단계"(디자인 원본 `MailSendLater`).
```ts
interface PendingSend {
sendAt: string // 엔진이 보낼 시각(UTC)
kind: 'undo' | 'scheduled' // 보내기 취소를 기다리는 중 · 예약 발송
}
```
- **보내기 취소**: `sendAt` 없이 보내면 서버가 그 직원의 설정(`undoSendSeconds` — 아래 "보내기 · 읽기 설정", 처음 10초)만큼 붙잡는다(`HOLDFOR`). 0 이면 지금처럼 바로 보낸다.
응답 `pendingSend: { kind: 'undo', sendAt }` — 클라이언트는 남은 시간을 이 시각으로 센다. 다만 PC 시계가 어긋나면 알림이 설정보다 오래 남으므로,
설정한 초를 알면 "지금 + 그 초 − 조금"을 넘기지 않는다(구현하며 정함 — 2026-10-29 리뷰).
- **예약 발송**: 발송 본문의 `sendAt`(UTC) 까지 붙잡는다(`HOLDUNTIL`) — 지금부터 5분 뒤 이상 · 7일 안. 밖이면 400 `VAL_001`.
`sendAt` 은 `Z` 로 끝나는 UTC 만 받고 소수 초는 버린다. `null` 은 없는 것과 같다(바로 보내거나 보내기 취소 설정대로).
엔진이 그 시각을 거절하면(`forbiddenMailFrom` — 엔진 한도가 바뀐 경우 등) 400 `VAL_001` 이고 만든 보낸 사본은 지운다(구현하며 정함).
7일은 엔진 한도다(`x:MtaExtensions` 의 `futureRelease` — 로그인 세션 `7d`. JMAP 능력의 `maxDelayedSend` 는 30일로 보이지만 엔진이 7일 넘게는 거절한다 — 2026-10-29 확인).
붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이라 7일 안은 안전하다(같은 날 확인). 예약에는 보내기 취소 시간을 더하지 않는다. 응답 `pendingSend: { kind: 'scheduled', sendAt }`.
- **받는 사람 확인은 제출 순간에 한다** — 붙잡아도 엔진이 사내 받는 사람을 그 자리에서 묻는다(없는 사내 주소는 곧바로 `delivered: no` · `550 5.1.2`, 반송 없음 — 2026-10-29 확인).
그래서 위 "받는 사람별 결과"(일부 거절 `rejectedRecipients` · 모두 거절 `MAIL_010` · `MAIL_011`)는 그대로다. 바깥 주소는 풀린 뒤 배달되고, 실패하면 지금처럼 반송 메일이 온다.
붙잡은 제출의 결과는 사내 · 바깥 모두 곧바로 `250 2.1.5 Queued` 다(거절된 사람만 `delivered: no`). 모두 거절인데 제출이 아직 기다리고 있으면(모두 4xx 등)
같은 요청에서 먼저 취소하고 사본을 지운다 — 풀리는 시각에 다시 보내지 않게(취소를 엔진이 받지 않아도 사본은 지우고 warn). 모두 5xx 면 엔진이 붙잡아도 곧바로 `final` 이라 지우기만 한다(같은 날 확인).
- **보낸 사본의 자리**: 제출하면서 지금처럼 보낸편지함으로 옮긴다 — 보내기 전에도 보낸편지함에 보이고 `pendingSend` 로 "보내는 중" · "예약 · 시각"을 보인다.
예약은 사본에 내부 표시(키워드 `$ara-scheduled` — 목록 · 화면에는 표시로 내보내지 않는다. 보낸 뒤에도 남는다 — 해가 없다)를 붙여 보내기 취소를 기다리는 것과 가른다.
`pendingSend` 는 엔진의 기다리는 제출(`undoStatus: pending`)이면서 **보낼 시각이 아직 오지 않은 것**에만 붙는다(구현하며 정함) — 붙잡지 않은 발송도 대기열에서 배달하는 동안 잠깐 pending 이고,
보낼 시각이 지나 배달 중인 메일은 `null` 이다(그때 취소하면 `MAIL_014`). 기다리는 제출은 보낼 시각으로 찾는다(`EmailSubmission/query` 의 `filter.after: 지금` — 엔진의 색인을 탄다.
`undoStatus` 조건은 모든 제출을 훑어 계정마다 65~140ms 라 쓰지 않는다). 차례는 보낼 시각 오름차순 — 엔진의 정렬 이름은 `sentAt` 이다(`sendAt` 은 `unsupportedSort` — 2026-10-29 확인).
한 번에 500개까지 읽고, 닿으면 warn 을 남긴다. 목록 · 상세 · 한 통 바꾸기 응답에 엔진 호출(찾기 · 읽기)이 붙고, 읽지 못하면 `pendingSend: null` 로 준다(표시만 — 옮기기 · 지우기를 막는 확인은 닫힌 쪽으로, 아래).
- **붙잡힌 메일은 취소로만 보낸편지함을 떠난다**(구현하며 정함 — 2026-10-29 리뷰): 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다(지운 메일도 나간다) — 우리 API 로는 그렇게 되지 않게 막는다.
- 한 통 `PATCH /api/messages/{id}` 의 `mailboxId` · `isJunk` 와 `DELETE /api/messages/{id}` 는 409 `MAIL_015`(아무것도 바꾸지 않는다), 여러 통 `PATCH /api/messages` 는 그 id 를 `failedIds` 로,
`POST /api/message-deletions` 는 `skippedIds` 로. 읽음 · 중요 표시는 바꿀 수 있다.
- 메일함 지우기 · 비우기(4단계 "메일함 관리")는 붙잡힌 메일을 그대로 두고 나머지를 처리한다 — 붙잡힌 메일은 `remaining` 에 들고,
한 요청에서 처리한 것이 없으면(남은 것이 붙잡힌 메일뿐) 409 `MAIL_015` 로 끝난다(지우기는 메일함을 지우지 않는다). 우리 API 로는 붙잡힌 메일이 보낸편지함 밖에 있을 수 없어 IMAP 으로 옮긴 메일에서만 생긴다.
- 이미 받은 메일에 필터 적용(7단계)은 붙잡힌 메일을 옮기지 않고 표시만 한다(`matched` 에는 센다).
- 막을지 볼 때 엔진에 묻지 못하면(오류 · 시간) 바꾸지 않고 503 `MAIL_001` 이다 — 위의 모든 길이 닫힌 쪽으로 실패한다.
다 받았는지는 응답의 `total`(`calculateTotal`)로 정하고, 모르면 다 보지 못한 것으로 본다(2026-10-29 리뷰 2 — 요청한 limit 으로 정하지 않는다).
기다리는 제출을 찾는 조회가 500개에 닿으면 그 메일 id 로 다시 묻고(같은 "지금 뒤" 조건), 그것도 넘치면 `MAIL_001`.
보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다(화면 표시 `pendingSend` 는 그때 null).
- **알려진 한계**: IMAP · 다른 JMAP 프로그램이 옮기거나 지우는 것은 막지 못한다 — 엔진은 그래도 시간이 되면 보낸다.
- 클라이언트는 붙잡힌 메일을 목록에서 고르지 못하게 하고(고르기 칸 `aria-disabled` + 까닭 — 모두 고르기 · 범위도 건너뛴다),
읽기 화면에는 옮기기 · 스팸 단추를 두지 않는다 — [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 옮긴다(보내기 취소를 기다리는 메일도 같다).
`MAIL_015` 는 인터셉터 알림(메일함 처리는 진행 알림도 거기서 멈춘다 — 4단계와 같은 결).
- `GET /api/scheduled-messages` — 예약한 메일(`kind: 'scheduled'` 이고 아직 보내지 않은 것)을 보낼 시각 차례로. 화면은 `pageInfo.total` 로 메일함 패널의 「예약됨」(있을 때만)을 보인다 —
메일 화면에 들어올 때 · 보내거나 취소한 뒤 · 새 메일 확인(3단계) · 실시간 알림(5단계)이 바뀜을 알릴 때 다시 묻는다. 보낸 메일은 저절로 빠진다(엔진의 제출이 `final`).
엔진이 풀어도 새 메일 확인 · 실시간 알림의 상태(메일함 · 메일)는 바뀌지 않는다 — 그래서 화면은 보이는 목록 · 열린 메일 · 「예약됨」 첫 항목 가운데 가장 이른 `sendAt` + 2초에
보이는 목록 · 열린 메일 · 「예약됨」 수를 다시 맞추고, 지난 시각이 아직 보이면 5초마다 몇 번 더 본다. 취소 · 고치기가 `MAIL_014` 를 받았을 때도 같다(구현하며 정함 — 2026-10-29 리뷰).
- `DELETE /api/scheduled-messages/{id}`(`id` 는 보낸 사본의 메일 id) — 붙잡은 발송을 취소한다(보내기 취소 · 예약 취소 모두). 엔진에서 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨
초안으로 되돌린다(`$draft` 와 취소 표시 `$ara-canceled` 를 붙이고 예약 표시를 뗀다 — 발송 지문 머리글이 남아 있어도 취소 표시가 있으면 사용자 초안이다) →
200 `{ draftId }`(같은 메일 id — 쓰기 화면이 이 id 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다.
이어 쓸 때(구현하며 정함): 서명 그림 부분(Content-ID `sig-…`)은 첨부에서 빼고 본문의 `cid:sig-…` 는 서명 그림 주소로 되돌린다(보낼 때 다시 `cid:` 로 — "서명"),
답장 대상은 같은 스레드에서 `In-Reply-To` 로 찾아 초안의 `inReplyTo` 로 준다.
- 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409 `MAIL_014` — 아무것도 바꾸지 않는다. 없는 메일은 404 `RES_001`.
- **다시 취소해도 된다**(2026-10-29 리뷰 — 응답이 끊겨 다시 누른 경우): 그 메일의 제출이 모두 `canceled` 이고 취소 표시(`$ara-canceled`)가 있으면 **어느 메일함에 있든** 그대로 200 `{ draftId }`(같은 id —
옮기지 않는다: 사용자가 그 초안을 휴지통에 버린 뒤 늦게 온 취소가 되살리지 않게), 표시가 없으면(앞의 옮기기가 실패) 되돌리기를 다시 한다 — 그래도 못 하면 503 `MAIL_001`.
- 엔진이 취소를 확인하지 않으면(응답의 `updated` 에 그 제출이 없음 — 거절도 아님) 옮기지 않고 503 `MAIL_001` — 옮기면 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다.
- 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다).
- 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` —
그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다.
서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다.
붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다.
같은 키의 메일을 찾으면 그 제출을 늘 읽는다(엔진 호출 1회 더 — 제출이 모두 `canceled` 면 위의 `MAIL_005`).
- 보낸 뒤 이어 쓴 저장본(`draftId`) 지우기는 지금처럼 한다 — 취소하면 붙잡혀 있던 사본이 새 초안이 된다.
- 붙잡은 메일은 엔진을 다시 띄워도 남는다(엔진의 대기열은 저장소에 있다). 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진에 묻는다.
- **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다.
- **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다).
일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=<draftId>` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.".
`MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다.
### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계
받은 메일의 외부 이미지는 지금처럼 지운 채 준다(`hasBlockedImages`). 보이게 할 때는 서버가 그 메일의 이미지 주소를 **중계 주소**로 바꿔 넣고, 그 주소로 오는 요청에
서버가 바깥 이미지를 대신 받아 준다 — 보낸 쪽 서버에는 직원 PC 의 주소 · 브라우저가 드러나지 않고(우리 서버의 주소만), 화면의 CSP(`img-src 'self' data:`)도 그대로다.
보낸 쪽은 이미지를 받은 시각으로 메일을 열어 본 것을 알 수 있다 — 그래서 처음 값은 "늘 묻기"다. 화면은 디자인 README "9단계"(디자인 원본 `MailReadTools` · `SettingsSendRead`).
- `GET /api/messages/{id}?images=show` — 외부 이미지를 보이는 본문(`hasBlockedImages: false` — srcset · 배경 그림 · 한도 밖처럼 지운 것이 있어도).
`imagesShown` 은 실제로 바꿔 넣은 그림이 있을 때만 true 다 — 외부 이미지가 없는 메일은 `images=show` 여도 false(구현하며 정함).
쿼리 없이 물으면 설정(아래 `externalImages`)을 따른다: `ask` 는 지금처럼 지우고, `trusted` 는 보낸 사람이 내 주소록(3절)이나 사내 목록(직원 · 공용 주소 — 3절 "사내 목록")에 있으면 보이고,
`always` 는 늘 보인다. **스팸메일함의 메일은 설정과 상관없이 지운다**(`images=show` 로만 보인다). `MessageDetail.imagesShown` 이 그 결과다.
`trusted` 에서 주소록 · 사내 목록을 읽지 못하면 보이지 않는 쪽으로 다룬다. 머리글 From 은 꾸밀 수 있다(알려진 한계 — 꾸며도 중계라 직원 PC 의 주소는 드러나지 않는다).
- 바꾸는 것은 `<img>` 의 `src`(http · https · `//`)뿐이다 — `srcset` 은 지금처럼 지우고, style · 속성의 배경 그림(`url()`)도 지금처럼 지운다(알려진 한계).
주소 2,048자 이하만, 한 통에 100개까지(그 뒤는 지운다). 같은 주소는 한 통 안에서 같은 중계 주소를 쓴다(브라우저가 한 번만 받는다). 중계 주소는 `/api/mail-images/<표>` — 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 **암호화한**(인증 암호 AES-GCM —
고친 표는 풀리지 않는다) 불투명 글이다. 서명만 하면 표를 풀어 원래 주소(추적 번호가 든다)를 읽을 수 있어 접근 로그 · 주소창에 남는다 — 그래서 암호화한다.
키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 404 이고, 메일을 다시 열면 새 표를 받는다. ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다.
- `GET /api/mail-images/{token}` — 로그인 필요(쿠키), 표의 직원과 같아야 한다. 서버가 바깥 이미지를 받아 그대로 준다(표준 래퍼 없음):
- 주소: http · https, 기본 포트(80 · 443)만. 이름을 풀어 **모든 주소가 공인 주소일 때만**, 푼 그 주소로 연결한다(다시 풀지 않는다 — DNS 바꿔치기를 막는다).
막는 곳: 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) · 멀티캐스트 · 예약 · 문서용 대역, IPv6 의 같은 것(::1 · fc00::/7 · fe80::/10 · ff00::/8 · IPv4 대응 · NAT64) —
우리 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)도 사설이라 막힌다(메일 엔진 관리 API · DB 를 부르게 만드는 공격).
점 없는 이름(도커 서비스 이름 등)은 묻지 않고 막는다. 이름은 c-ares(`dns.Resolver`)로 푼다 — libuv 스레드 풀을 막지 않게(구현하며 정함).
- 다시 가기(redirect)는 3번까지, 매번 위 검사. 전체 10초, 받는 크기 5MB(넘으면 끊는다).
- 형식은 받은 바이트의 앞부분으로 정한다 — PNG · JPEG · GIF · WebP 만(SVG · 그 밖은 거절). 바깥이 보낸 `Content-Type` 은 믿지 않는다.
- 바깥으로는 쿠키 · Referer 없이, 고정한 User-Agent 로 묻는다.
- 응답: 받은 바이트 + 판정한 `Content-Type` · `Cache-Control: private, max-age=3600` · `X-Content-Type-Options: nosniff` · `Content-Disposition: inline` · `Content-Security-Policy: default-src 'none'; sandbox`.
- 실패는 모두 404 `RES_001` — 까닭을 주지 않는다(표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다.
이 404 는 전역 필터를 거치지 않고 컨트롤러가 직접 쓴다 — 형식 · `traceId` 는 같고, 로그는 debug 만(바깥 탓의 실패가 잦아 warn 이면 알림이 쌓인다 — 구현하며 정함).
- 동시에 받는 수는 서버 전체 16개 · 한 직원 6개까지(넘으면 기다린다 — 30초 · 200개까지, 넘으면 404). 기다리는 줄은 직원마다이고, 서버 자리가 나면
자기 6개에 막히지 않은 직원 가운데 지금 받는 수가 가장 적은 직원의 맨 앞 요청부터 든다(같으면 먼저 기다린 쪽 — 몇 사람이 서버 자리를 다 차지하지 못하게, 2026-10-29 리뷰 2).
자리는 응답을 다 보낼(또는 브라우저가 끊을) 때까지 쥔다 — 메모리에 쥐는 그림은 많아야 16 × 5MB(구현하며 정함 — 2026-10-29 리뷰).
요청 수 한도는 이 경로만 넉넉하게 — IP 마다 1분 600회(뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT).
- 로그: 주소는 남기지 않는다(추적 번호 · 개인 정보가 들어 있다) — 실패 까닭과 바깥 호스트 이름만 debug. 요청 주소를 남기는 로그의 `/api/mail-images/<표>` 는 글자 수만 남긴다(`maskUrlForLog`).
- 화면: 본문 틀의 내부 CSP 를 보일 때만 `img-src data: <화면 주소>/api/mail-images/` 로 넓히고(중계 경로만 — `'self'` 면 정화를 빠져나간 CSS `url()` 이 같은 출처의 다른 API 를 부를 수 있다, 2026-10-29 리뷰),
클라이언트 정화(DOMPurify)도 `/api/mail-images/` 로 시작하는 주소만 남긴다(그 밖의 바깥 주소는 지금처럼 지운다).
답장 · 전달 인용에는 이미지가 실리지 않는다(보내는 정화가 서명 그림 밖의 `img` 를 지운다 — 중계 주소가 바깥으로 나가지 않는다).
- **운영 확인**: 백엔드 컨테이너가 바깥 80 · 443 으로 나갈 수 있어야 한다(호스트 방화벽 · 프록시) — 막혀 있으면 이미지 자리가 비어 보인다.
막기는 사설 대역에 기대므로 도커 망(`APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET`)은 사설 대역으로 둔다. 서버 자신의 공인 주소로 풀리는 이름은 공인 주소라 막지 않는다 —
같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다(2026-10-29 리뷰, 운영 절차는 docker 스킬).
### 인쇄 — 9단계
API 는 새로 없다. 인쇄 화면(새 탭 — 메일 한 통, 디자인 원본 `MailPrint`)은 `GET /api/messages/{id}` 로 그린다 — 읽기 화면에서 외부 이미지를 보이고 있었으면 `images=show`.
### 보낸 사람 차단 (`/api/mail-blocks`) — 9단계
```ts
interface BlockedSender {
address: string // 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로)
blockedAt: string // 차단한 시각(UTC)
}
interface MailBlockList {
senders: BlockedSender[] // 최근에 차단한 것부터, 500개까지
isActive: boolean // 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다
}
```
- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다.
이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`).
- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰),
도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다).
이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`).
내 주소와 사내 도메인(`MAIL_DOMAIN` 과 그 하위)은 막을 수 없다.
여러 사람이 쓰는 메일 서비스(gmail.com · naver.com · daum.net · hanmail.net · kakao.com · nate.com · outlook.com · hotmail.com · live.com · yahoo.com · icloud.com 등 — 목록은 서버 상수)는
도메인으로 막을 수 없다(주소로는 된다). 클라이언트도 같은 목록을 먼저 본다(짝 — 한쪽을 고치면 둘 다). 500개를 넘으면 400 `VAL_001`.
- **저장 자리**: 필터 · 부재중과 같은 Sieve 스크립트(`arasystem` — 6단계)의 맨 앞. 머리 주석의 원본(JSON)에 `blocked`(주소 · 차단한 시각)를 더하고, 원본 판은 **차단한 주소가 있을 때만 2** 로 쓴다(없으면 지금처럼 1).
판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. 판 2 를 모르는 옛 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 로 막힌다
(옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 되돌리기 계획: 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다.
만든 Sieve(스팸 표시 감싸기 밖 · 필터 · 부재중 앞): `if anyof (address :all :is "from" [주소들], address :domain :is "from" [도메인들]) { fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>"; stop; }`.
비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이다 — 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없어 `:comparator` 를 뺐다(구현하며 정함 —
주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다, 한계). `fileinto` 의 대체 이름은 실제 스팸메일함 이름이다(비었거나 `${` 가 들어 있으면 "Junk"). 스팸메일함이 없으면 503 `MAIL_001`.
실제 배달로 확인했다(2026-10-29, 38가지 — 대소문자를 섞은 주소 · 도메인은 스팸메일함으로, 하위 도메인 · 이름만 같은 다른 도메인 · 차단하지 않은 주소는 손대지 않음,
차단이 있어도 사내 직원의 메일은 받은편지함. `SieveScript/validate` 는 모르는 test 이름을 걸러 주지 않아 배달로 본다).
- 스크립트 크기 한도(90,000바이트)를 필터와 함께 쓴다 — 넘으면 400 `MAIL_013`(사전 문구가 필터 · 차단 목록을 함께 말한다).
- 쓰기는 필터처럼 읽은 상태 그대로일 때만(`ifInState`) · 어긋나면 다시 읽어 세 번까지. 차단은 필터의 `version` 을 묻지 않는다(주소 하나를 더하고 빼는 일이라 그사이 바뀐 필터를 덮지 않는다).
- 로그: 주소는 가린다(`maskEmail` — 도메인 차단은 도메인만).
- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체,
「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다.
스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다.
### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계
직원마다의 설정을 서버 DB 에 둔다(표 `mail_settings` — 직원 주소마다 한 줄, 없으면 처음 값). 어느 PC 에서나 같다.
```ts
interface MailSettings {
undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10
externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask'
}
```
- 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다).
서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함).
- 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음).
칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰).
"저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다.
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
---
+3 -1
View File
@@ -25,7 +25,9 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.',
'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
};
@@ -0,0 +1,74 @@
<script setup lang="ts">
// 알림 아래의 남은 시간 막대 (디자인 원본 MailSendLater "보낸 뒤 알림" — 높이 3 · 밝은 청록) — 마감 시각이 있는 알림(toast.store 의 expiresAt)에만
// 시간은 마감 시각까지 줄어들고, 마우스를 올려도 멈추지 않는다(알림도 그 시각에 닫힌다). 남은 시간을 글로 읽어 주지는 않는다 — 장식이다(aria-hidden).
// 막대는 인라인 스타일 대신 <progress> 를 꾸민다 (첨부 올리기 막대와 같은 결 — ComposeAttachmentList)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
interface Props {
/** 띄운 시각(ms — Date.now) — 막대의 처음 길이 */
startsAt: number
/** 닫히는 시각(ms — Date.now) */
endsAt: number
}
const props = defineProps<Props>()
/** 막대를 다시 그리는 간격 — 30초 막대도 매끄럽게 줄어 보이는 만큼 */
const TICK_MS = 100
const now = ref<number>(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
const total = computed(() => Math.max(1, props.endsAt - props.startsAt))
const remaining = computed(() => Math.min(total.value, Math.max(0, props.endsAt - now.value)))
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now()
}, TICK_MS)
})
onBeforeUnmount(() => {
if (timer !== null) clearInterval(timer)
})
</script>
<template>
<progress
class="toast-countdown"
aria-hidden="true"
:max="total"
:value="remaining"
/>
</template>
<style scoped>
/* 높이 3 · 안쪽 오른쪽 여백 8 (디자인 원본 MailSendLater) — 알림의 둘째 줄을 다 채운다 */
.toast-countdown {
appearance: none;
display: block;
flex: 0 0 100%;
width: calc(100% - var(--space-8));
height: var(--space-3);
margin: var(--space-2) 0 var(--space-2);
border: 0;
border-radius: var(--space-2);
background: var(--toast-countdown-track);
overflow: hidden;
}
.toast-countdown::-webkit-progress-bar {
background: var(--toast-countdown-track);
border-radius: var(--space-2);
}
.toast-countdown::-webkit-progress-value {
background: var(--toast-countdown-bar);
border-radius: var(--space-2);
}
.toast-countdown::-moz-progress-bar {
background: var(--toast-countdown-bar);
border-radius: var(--space-2);
}
</style>
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ToastHost from './ToastHost.vue'
// 화면 오른쪽 아래 알림 — 단추는 한 번만 움직이고(리뷰 #9), 초점이 든 알림이 닫히면 초점을 돌려준다(리뷰 #8)
describe('ToastHost', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
useToastStore().clear()
document.body.innerHTML = ''
})
/** 화면 — main 안의 입력칸 하나 + 알림 틀 */
async function mountHost() {
const main = document.createElement('main')
const field = document.createElement('input')
main.append(field)
document.body.append(main)
wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
await flushPromises()
return { main, field }
}
function showUndo(onRun: () => void, expiresAt?: number): number {
return useToastStore().show({
kind: 'success',
message: '메일을 보냈습니다.',
action: { label: '보내기 취소', run: onRun },
expiresAt: expiresAt ?? null,
})
}
const actionButton = () => document.querySelector<HTMLButtonElement>('.toast__action')
it('단추를 누르면 알림을 닫고 할 일을 한 번 한다 — 닫히는 동안 한 번 더 눌러도 또 하지 않는다', async () => {
await mountHost()
let runs = 0
showUndo(() => {
runs += 1
})
await flushPromises()
const button = actionButton()
if (!button) throw new Error('단추가 없다')
button.click()
// 퇴장 전환 동안 남아 있는 같은 단추를 한 번 더 누른다
button.click()
await flushPromises()
expect(runs).toBe(1)
expect(useToastStore().toasts).toEqual([])
})
it('초점이 든 알림이 닫히면(마감 시각 · 시간이 다 됨) 알림에 들어오기 전 자리로 초점을 돌려준다', async () => {
const { field } = await mountHost()
const id = showUndo(() => undefined, Date.now() + 10_000)
await flushPromises()
field.focus()
actionButton()?.focus()
expect(document.activeElement).toBe(actionButton())
// 마감이 있는 알림은 초점이 있어도 닫힌다 (그 시각이 지나면 되돌릴 수 없다)
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(field)
})
it('그 자리가 사라졌으면 화면의 main 으로 — 초점을 받게 잠깐 단 tabindex 는 초점이 떠나면 뗀다', async () => {
const { main, field } = await mountHost()
const id = showUndo(() => undefined)
await flushPromises()
field.focus()
actionButton()?.focus()
field.remove()
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(main)
expect(main.getAttribute('tabindex')).toBe('-1')
expect(main.hasAttribute('data-focus-fallback')).toBe(true)
main.blur()
expect(main.hasAttribute('tabindex')).toBe(false)
expect(main.hasAttribute('data-focus-fallback')).toBe(false)
})
it('단추를 눌러 닫혀도 초점을 돌려준다 — 할 일이 초점을 옮기면 그쪽이 이긴다', async () => {
const { field } = await mountHost()
const other = document.createElement('button')
document.body.append(other)
showUndo(() => other.focus())
await flushPromises()
field.focus()
actionButton()?.focus()
actionButton()?.click()
await flushPromises()
expect(document.activeElement).toBe(other)
})
it('초점이 알림 밖에 있으면 닫혀도 초점을 건드리지 않는다', async () => {
const { field } = await mountHost()
const id = showUndo(() => undefined)
await flushPromises()
field.focus()
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(field)
})
it('알림 사이를 옮겨 다녀도 처음 들어온 자리를 기억한다', async () => {
const { field } = await mountHost()
const first = showUndo(() => undefined)
const second = showUndo(() => undefined)
await flushPromises()
const buttons = () => [...document.querySelectorAll<HTMLButtonElement>('.toast__action')]
field.focus()
buttons()[0]?.focus()
buttons()[1]?.focus()
useToastStore().dismiss(second)
await flushPromises()
expect(document.activeElement).toBe(field)
useToastStore().dismiss(first)
})
})

Some files were not shown because too many files have changed in this diff Show More