쿼리 일곱 · 합친 낱말 8개 · 찾는 규칙(부분 일치 · 구절 · 한 글자) · 목록과 읽기 화면의 searchHighlight · hasNext 인데 빈 쪽 · total null 이면 화면이 통 수를 숨기는 것 · 강조 응답은 보이기만(인용 · 인쇄에 쓰지 않음)을 적고, 3단계의 알려진 한계는 색인을 채우는 동안에만 남는다고 고쳤다. nestjs 스킬의 mail-search 줄도 2차에 맞췄다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 KiB
인증·메일 API 계약 (1단계)
백엔드와 프론트엔드를 병렬로 만들기 위한 계약이다. 구현이 끝나면 Swagger(
/api-docs)와 프론트 타입이 기준이며, 이 문서와 어긋나면 이 문서를 고친다. 바꿔야 할 일이 생기면 한쪽에서 조용히 바꾸지 말고 이 문서부터 고친 뒤 양쪽에 알린다.
공통 규칙은 그대로 따른다 — 표준 응답 래퍼(response-format), 에러 코드(error-codes), API 규약(api-design).
- 성공
{ "success": true, "data": … }/ 실패{ "success": false, "error": { "code", "message", "traceId" } } - 날짜·시각은 ISO 8601 UTC 문자열, 목록은
{ items, pageInfo } - 이 단계에서 쓰는 에러 코드:
AUTH_001AUTH_002(클라이언트 전용)AUTH_003AUTH_004AUTH_005VAL_001RES_001REQ_001FILE_001FILE_002MAIL_001MAIL_002MAIL_003MAIL_004MAIL_005MAIL_006MAIL_007MAIL_008MAIL_009MAIL_010MAIL_011MAIL_012MAIL_013MAIL_014MAIL_015CONTACT_001CONTACT_002SYS_001(11단계 — 4절 "대용량 첨부"가FILE_003~FILE_008·MAIL_016을 더한다. 12단계 — 5절 "계정 보안"이AUTH_006~AUTH_009를, 13단계 — 6절 "전환 준비"가AUTH_010을 더한다) - 적용 범위: 1단계는 웹(브라우저) 전용이다. 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·
flutter_secure_storage)은 앱 착수 때 이 문서에 추가한다. - 배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다. 운영은 프론트 nginx 가
/api를 백엔드로 넘기고, 개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSPconnect-src 'self'로 충분하다. - 요청 대상에 날
\·#· 제어 문자 · 공백 · 0x80 이상 바이트가 있으면 라우팅 전에 400VAL_001이다(12단계 — 백엔드request-target미들웨어, 앞단 nginx 도 막는다). Express 가 이런 대상을 다르게 읽어 앞단이 닫은 경로가 백엔드에서 열렸다(2026-10-31 리뷰). 제어 문자 · 날 UTF-8 은 Node 의 HTTP 파서가 앱 전에 거절한다(JSON 이 아닌 400). 브라우저는 이런 요청을 보내지 않는다. - 경로는 대소문자를 가린다 — 이 문서의 경로는 모두 소문자이고, 대소문자 변형(
/API/…·/api/Messages/…·…/LARGE-FILES/…·/api/public-links/…/FILES/…)은 처리기에 닿지 않고 404RES_001이다(2026-10-30 — 앞단 nginx 의 받기 location 이 놓친 변형이 동시 연결 상한을 비켜 갔다, 4절 "운영 확인"). - 출처 검사(CSRF): 상태를 바꾸는 요청(POST·PATCH·PUT·DELETE)은 브라우저가 붙이는
Sec-Fetch-Site·Origin을 검사한다. 다른 사이트에서 온 요청은 403AUTH_003. 출처 정보가 아예 없는 요청(브라우저가 아닌 클라이언트)은 통과한다. - 프록시가 직접 내는 오류: 프론트 nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 —
본문 한도 초과 413
FILE_002, 백엔드 응답 없음 502·시간 초과 504SYS_001.traceId는 nginx 요청 ID 이고message는 비어 있을 수 있으니 클라이언트는 코드로 사전 문구를 쓴다.
1. 인증 (쿠키 기반 — 토큰은 응답 본문에 없다)
| 쿠키 | 내용 | 속성 |
|---|---|---|
access_token |
액세스 JWT (JWT_ACCESS_EXPIRES_IN, 기본 1h) |
HttpOnly · SameSite=Lax · Path=/api · 운영 Secure |
refresh_token |
리프레시 JWT (JWT_REFRESH_EXPIRES_IN, 기본 14d) |
HttpOnly · SameSite=Strict · Path=/api/auth · 운영 Secure |
직원 계정 = 메일 계정이다. 백엔드는 로그인할 때만 비밀번호를 메일 엔진(Stalwart)에 확인하고 저장하지 않는다.
POST /api/auth/login
12단계: 응답에
status가 붙고, 2단계 인증을 켠 직원은 코드 단계를 거친다 — 5절 "로그인".
// 요청
{ "email": "ara.kim@arasystem.it.kr", "password": "…" }
// 200 + Set-Cookie(access_token, refresh_token)
{ "email": "ara.kim@arasystem.it.kr", "name": "김아라" }
| 실패 | 코드 |
|---|---|
| 계정 없음 · 비밀번호 틀림 · 사내 도메인이 아닌 주소 · 메일 엔진 관리자 계정(웹메일 로그인 대상이 아니다) (구분하지 않는다) | 401 AUTH_004 |
| 형식 오류 | 400 VAL_001 |
시도 과다 — 같은 IP 에서 1분에 실패 20회, 같은 주소로 15분에 실패 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 요청은 1분에 60회까지. "같은 IP" 는 IPv6 를 /64 단위로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다. 비밀번호 변경(PUT /api/auth/password)의 지금 비밀번호 틀림도 같은 한도로 함께 센다 |
429 REQ_001 |
| 같은 주소의 로그인 · 비밀번호 변경이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 REQ_001 |
| 대량 공격으로 서버가 기억하는 시도 기록이 상한에 닿았고 밀어낼 수 있는 기록이 없음 — 새 IP·주소의 로그인 (잠금 기록을 지키려고 막는 쪽을 택한다) | 429 REQ_001 |
| 메일 엔진 장애 | 503 MAIL_001 |
GET /api/auth/me
앱 시작 시 세션 복원용. 200 { "email", "name" } / 로그인 안 됨 401 AUTH_001.
POST /api/auth/refresh
리프레시 쿠키로 두 쿠키를 새로 발급한다(회전). 200 { "email", "name" }.
| 실패 | 응답 | 세션·쿠키 |
|---|---|---|
| 쿠키 없음·만료·끊긴 세션, 메일 엔진이 계정을 찾지 못함(대리 접근 401), 그 계정이 엔진 관리 권한을 얻음 | 401 AUTH_001 |
쿠키 삭제·세션 폐기 |
| 메일 엔진 장애, 게이트웨이의 대리 접근 권한 문제(엔진 403 — 설정 실수) | 503 MAIL_001 |
유지 — 설정을 고치면 그대로 이어 쓴다 |
| 서버 쪽 드문 불일치(회전 충돌) | 500 SYS_001 |
유지 — 같은 쿠키로 다시 시도할 수 있다 |
세션 규칙 (서버에 저장한다)
- 로그인하면 서버에 세션이 하나 생긴다. 리프레시 토큰은 1회용이다 — 갱신할 때마다 새 토큰을 주고 이전 토큰은 폐기한다.
- 이미 쓴 리프레시 토큰이 다시 오면 탈취로 보고 그 세션 전체를 끊는다 → 401
AUTH_001.- 예외(유예): 회전 직후 30초 안에 바로 이전 토큰이 오면(탭 여러 개가 동시에 갱신한 경우) 세션을 끊지 않고
200
{ "email", "name" }으로 답하되 쿠키는 새로 주지 않는다 — 먼저 갱신한 요청이 받은 쿠키를 같이 쓴다. 두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다.
- 예외(유예): 회전 직후 30초 안에 바로 이전 토큰이 오면(탭 여러 개가 동시에 갱신한 경우) 세션을 끊지 않고
200
- 세션은 로그인 후 최대 30일(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다.
- 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다. → 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 브라우저").
POST /api/auth/logout
항상 200 (data: null). 두 쿠키를 지우고 서버의 세션을 폐기한다.
PUT /api/auth/password — 4단계
12단계: 2단계 인증을 켠 직원은
otpCode를 함께 보낸다 — 5절 "비밀번호 바꾸기".
// 요청 (로그인 필요)
{ "currentPassword": "…", "newPassword": "…" }
// 200 (data: null)
| 실패 | 코드 |
|---|---|
| 지금 비밀번호가 틀림 | 400 AUTH_005 — 세션은 그대로다. 401 이 아닌 까닭: 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 |
| 새 비밀번호가 8자 미만 · 128자 초과 · 지금 비밀번호와 같음, 엔진 규칙에 어긋남 · 지금 비밀번호가 비었거나 256자 초과(로그인과 같은 한도) — 길이는 JS 문자열 길이 | 400 VAL_001 |
| 지금 비밀번호 틀림이 잦음 — 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)를 함께 센다. 같은 주소의 로그인 · 변경이 처리 중 | 429 REQ_001 |
| 웹메일로 다루지 않는 계정(메일 엔진 관리자 · 게이트웨이 · 막은 계정 — 엔진에 묻지 않는다) | 401 AUTH_001 |
| 메일 엔진 장애 — 바꾸는 요청의 결과를 알 수 없는 실패(시간 초과 · 연결 끊김 · 5xx)도 | 503 MAIL_001 — 결과를 모를 때는 바뀌었을 수 있으므로 다른 세션을 끊어 둔다 |
- 서버는 메일 엔진에 그 직원 본인의 자격으로(지금 비밀번호) 본인 비밀번호 변경(Stalwart
x:AccountPassword/set에currentSecret·secret)을 보낸다 — 게이트웨이 대리 접근이나 관리자 권한을 쓰지 않는다. 엔진은 지금 비밀번호 없이는 바꾸지 않고 8자 미만을 거절한다 (2026-10-28 개발 엔진에서 확인). 세션은 같은 값으로 열렸는데 엔진이 바꾸기를 거절하면(forbidden·currentSecret의invalidProperties· 바꾸기 요청의 401 — 앱 비밀번호를 넣은 경우 · 역할의 권한 설정)AUTH_005로 답하되 실패 횟수로 세지 않고 세션도 끊지 않는다(추측이 아니라서). 결과를 알 수 없는 실패도 실패 횟수로 세지 않는다. - 바뀌면 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=
/api/auth라 이 요청에도 온다)가 가리키는 세션만 남긴다. 쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙). 휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다. → 12단계부터: 끊긴 기기는 바로 막히고, 앱 비밀번호도 모두 지운다(5절 "비밀번호 바꾸기"). - 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다.
- 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다.
- 클라이언트: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고,
AUTH_005는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다. 요청 한도는 30초다 — 서버가 엔진을 두 번 부르고 세션을 끊으므로, 기본 한도(10초)로는 서버가 바꾼 뒤 화면만 실패로 볼 수 있다.
클라이언트 처리 규칙
- 보호 API 가 401
AUTH_001을 주면 한 번만/auth/refresh를 시도하고, 성공하면 원래 요청을 다시 보낸다. 실패하면 기존 규칙대로AUTH_002로 승격하고 로그인 화면으로 보낸다./auth/login·/auth/refresh·/auth/me·/auth/logout은 이 재시도 대상이 아니다. /auth/refresh의 200 은Set-Cookie가 없어도 성공으로 다룬다 (위 유예 응답).- 세션이 끝난 것은
/auth/refresh의 401AUTH_001뿐이다. 5xx(MAIL_001·SYS_001)·네트워크 오류는 일시 장애로 알리고 로그아웃으로 다루지 않는다. - 로그인 버튼은 응답이 올 때까지 다시 누를 수 없게 한다 — 같은 주소로 동시에 보낸 두 번째 요청은 429
REQ_001이다. - 로그아웃하면 같은 브라우저의 다른 탭에도 알린다(BroadcastChannel). 알림을 받은 탭은 로그인 화면으로 간다.
- 로그아웃(어느 탭에서든) 뒤에 도착한
/auth/refresh성공은 쓰지 않고/auth/logout을 한 번 더 부른다. 갱신과 로그아웃이 겹치면 서버가 로그아웃 뒤에 새 쿠키를 구울 수 있기 때문이다 (세션은 끊겼어도 액세스 토큰은 만료까지 유효). - 앱 시작 시
/auth/me로 세션을 복원한다. 이 호출의AUTH_001은 로그인 전 정상 상태이므로 알림을 띄우지 않는다. - 복원 중
/auth/me가AUTH_001이면 액세스 토큰만 만료됐을 수 있으므로/auth/refresh를 알림 없이 한 번 부르고, 성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다. → 백엔드는 액세스 쿠키가 없거나 만료돼도 리프레시 쿠키만으로/auth/refresh가 동작해야 한다.
2. 메일 (모두 로그인 필요 — 없으면 401 AUTH_001)
타입
interface Address { name: string | null; email: string }
interface Mailbox {
id: string
name: string // 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름
role: 'inbox' | 'drafts' | 'sent' | 'junk' | 'trash' | 'archive' | null
parentId: string | null
sortOrder: number
totalCount: number
unreadCount: number
}
interface MessageSummary {
id: string
threadId: string
mailboxIds: string[]
from: Address | null
to: Address[]
subject: string // 제목이 없으면 빈 문자열
preview: string
receivedAt: string
isRead: boolean
isFlagged: boolean
hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다
size: number // 바이트
pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null
}
interface MessageDetail extends MessageSummary {
cc: Address[]
bcc: Address[]
replyTo: Address[]
sentAt: string | null
messageId: string | null // Message-ID 헤더 (답장 스레드 연결용)
references: string[]
htmlBody: string | null // 서버가 정화한 HTML (아래 참고)
textBody: string | null
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 (보이게 했으면 false)
imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정)
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
attachments: MessageAttachment[]
largeFiles: MessageLargeFile[] // 우리 서버가 넣은 대용량 첨부 덩이의 파일 — 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열 (11단계 — 4절 "우리 웹메일에서 읽기")
}
interface Attachment { id: string; name: string; type: string; size: number }
interface MessageAttachment extends Attachment {
isInline: boolean // 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다. 전달할 때는 함께 붙인다
}
역할별 한국어 이름: inbox 받은편지함 · drafts 임시보관함 · sent 보낸편지함 · junk 스팸메일함 · trash 휴지통 · archive 보관함.
htmlBody 정화 규칙 (서버 책임): 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거,
링크는 http · https · mailto 만 남기고 새 창(target="_blank" rel="noopener noreferrer"),
외부(http/https) 이미지는 제거하고 hasBlockedImages = true (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계).
본문 안 인라인 이미지(cid:)는 작은 것만 data: URI 로 바꿔 넣고 나머지는 지운다.
넣은 그림은 attachments 에 isInline: true 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다
(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 isInline: false 로 첨부 목록에 보인다.
클라이언트는 그래도 격리된 iframe 에만 그린다 (스크립트 비허용 sandbox + 내부 CSP — 이중 방어).
hasAttachment 는 엔진 값이 아니라 서버가 첨부 부분의 머리로 정한다 — "본문 그림"이 아닌 첨부가 하나라도 있으면 true.
본문 그림 = 본문에 넣을 수 있는 그림 형식(PNG·JPEG·GIF·WebP) · Content-ID 있음 · Content-Disposition 이 attachment 가 아님(inline 이거나 없음) ·
크기 1바이트~300KB. 목록은 본문을 읽지 않으므로 이 판정은 "실제로 본문에 들어갔나"(isInline)와 드물게 다를 수 있다
(본문이 가리키지 않는 그런 그림, 20장·합계 2MB 를 넘는 본문 그림 — 목록은 클립이 없는데 상세의 첨부 목록에는 보인다).
읽기 한도: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 오고, 보여 줄 본문이 잘렸으면 isBodyTruncated: true 다.
HTML 은 정화하기 전에 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 —
약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다.
넘으면 정화하지 않고 htmlBody: null·hasBlockedImages: false·isHtmlBodyOmitted: true 로 내려준다. textBody 는 그대로라 클라이언트는 그것을 보여 주고
답장·전달 인용도 그것으로 만든다 (HTML 만 있는 메일이면 둘 다 null). 수백 단의 인용 스레드·2MB 짜리 큰 표·Outlook 조건부 주석은 한도 안이다.
클라이언트는 두 표시를 읽기 화면에 알리고(서식 없이 표시 · 표시할 수 없음 · 앞부분만 표시), 잘린 본문을 인용할 때는 인용 끝에 생략 표시를 둔다.
엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|---|---|---|---|
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, 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, 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 — 붙잡힌 메일(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} |
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(받는 사람 모두 거절 — 없는 주소 등)·MAIL_016(대용량 첨부 — 11단계, 4절)·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·HEAD /api/messages/{id}/large-files/{fileId} |
Range(선택) |
그 메일의 대용량 첨부 파일 (표준 래퍼 없음, 200 · 206 · 416 — 11단계, 4절 "우리 웹메일에서 읽기") | 404 RES_001, 409 FILE_008(검사 중), 410 FILE_007(더 받을 수 없음), 503 MAIL_001 |
GET /api/messages/{id}/source |
— | 받은 그대로의 메일 message/rfc822 (표준 래퍼 없음, Content-Disposition: attachment; filename="<제목>.eml") — 4단계 |
404 RES_001, 503 MAIL_001 |
GET /api/drafts/{id} |
— | 200 Draft (아래 "임시 저장") |
404 RES_001(없음·초안이 아님), 503 MAIL_001 |
POST /api/drafts |
초안 본문 | 201 DraftSaved |
400 VAL_001·MAIL_002·MAIL_003·MAIL_004·MAIL_006·MAIL_007·MAIL_016(대용량 첨부 — 11단계, 4절)·FILE_001, 413 FILE_002, 503 MAIL_001 |
PUT /api/drafts/{id} |
초안 본문 (전체 교체) | 200 DraftSaved — 새 id |
위와 같음 + 404 RES_001(초안이 아닌 id — 지우지 않음) + 409 MAIL_008(다른 곳에서 이미 바뀜 — 저장 안 함) |
GET /api/mail-signature |
— | 200 MailSignature (아래 "서명") |
— |
PUT /api/mail-signature |
{ bodyHtml, body, isForNew, isForReply } |
200 MailSignature |
400 VAL_001 |
POST /api/mail-signature/images |
multipart file 1개 |
201 SignatureImage |
400 FILE_001·VAL_001(올려 둔 그림 10장 초과), 413 FILE_002(300KB 초과) |
GET /api/mail-signature/images/{id} |
— | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 RES_001 |
GET /api/recipient-suggestions |
q(선택, 1~100자) · limit(기본 20, 최대 100) · cursor |
200 { items: RecipientSuggestion[], pageInfo } (아래 "받는 사람 추천") |
400 VAL_001, 503 MAIL_001 |
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 |
표에 없는 실패 (구현에서 확정):
| 상황 | 응답 |
|---|---|
없는 mailboxId, 엔진이 해석하지 못하는 mailboxId |
404 RES_001 |
엔진이 해석하지 못하는 id — 여러 통 처리의 ids(PATCH /api/messages · POST /api/message-deletions), 메일함 없이 하는 검색 |
400 VAL_001 (본문에서 온 값이라서 — 메일함 하나의 목록은 위처럼 404 RES_001) |
| 형식이 틀린 경로 id | 404 RES_001 |
받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 VAL_001) |
400 MAIL_002 |
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 MAIL_004 — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
| 첨부 20개 초과 | 400 MAIL_006 — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 MAIL_007 — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
JSON 본문 1MB 초과 (발송 POST /api/messages 와 임시 저장 POST /api/drafts·PUT /api/drafts/{id} 만 4MB — 서식 본문과 긴 인용 때문) |
413 FILE_002 |
| JSON 본문의 모양이 한도를 넘음 — 배열 하나 1,000개 · 값(객체 · 배열 · 원시 값) 10,000개 · 깊이 32단 | 400 VAL_001 |
JSON 본문이 UTF-8 이 아님 — charset 이 utf-16 · utf-32 · utf-7 등 (charset 을 붙이지 않거나 utf-8 이면 된다) |
400 VAL_001 |
JSON 본문 모양 한도 (2026-10-28 — 6단계 리뷰에서, 전부터 있던 경로도 같은 원인): 본문 크기 한도 안에서도 작은 객체 수만~수십만 개면 DTO 변환 · 검사가 몇 초씩 서버를 멈추고 수백 MB 를 썼다 (리뷰 측정 — 1MB 에 필터 규칙 8만 개 7.8초, 조건 15만 개는 500). 그래서 모든 JSON 본문을 DTO 에 닿기 전에 본다 — 읽기 전에 글자를 한 번 훑고, 읽은 뒤 한 번 더. 한도는 지금 계약의 어떤 본문보다 넉넉하다(가장 긴 배열 100 · 가장 깊은 본문 5단 · 값이 가장 많은 필터 50개 저장이 약 1,600개). 이 검사와 UTF-8 검사는 본문 크기 한도처럼 인증보다 앞이라, 로그인하지 않은 요청도 401 이 아니라 400 이다. JSON 은 UTF-8 만 받는다(RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다).
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(sortOrder, 이름 순).
// GET /api/mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다
interface MailQuota {
usedBytes: number
limitBytes: number | null // 한도가 없으면 null → "사용량 0.9GB" 처럼 사용량만 표시
attachmentMaxBytes: number // 서버의 MAIL_ATTACHMENT_MAX_SIZE_MB — 클라이언트는 상수 대신 이 값으로 사전 검사·안내한다
attachmentTotalMaxBytes: number // 메일 한 통의 첨부 합계 한도 — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값 (발송 때 검사하는 값과 같다)
}
cursor 는 불투명 문자열이다. 클라이언트는 해석하지 말고 pageInfo.nextCursor 를 그대로 다시 보낸다.
발송 본문 (POST /api/messages)
중복 발송 방지 — Idempotency-Key 요청 헤더: 클라이언트는 쓰기 화면마다 무작위 키(UUID) 하나를 만들어 보내고,
같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 같은 키를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면
다시 보내지 않고 그 결과(201 SentMessage — 아래 "받는 사람별 결과")를 돌려준다. 응답이 늦어 MAIL_001 을 받았어도 실제로는 나갔을 수 있으므로,
재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 VAL_001. 키가 없으면 중복 방지 없이 보낸다.)
서버는 발송 요청에 55초 안에 답한다 — 앞 단계(첨부 확인 · 서명 그림 올리기)가 늦어 보낼 시간이 10초도 남지 않으면 메일을 만들지 않고 503 MAIL_001
(아무것도 나가지 않았으니 같은 키로 다시 보내면 된다). 보낸 뒤 이어 쓴 저장본 지우기는 5초까지만 기다리고, 넘으면 먼저 답한 뒤 마저 지운다.
- 같은 키의 요청이 아직 처리 중이면 기다리지 않고 429
REQ_001— 같은 메일이 동시에 두 번 나가지 않게 한다. 엔진 장애(MAIL_001)로 끝난 키도 30초 동안은 처리 중으로 본다 (시간 초과라면 엔진이 아직 보내는 중일 수 있다). 클라이언트는 이 429 에 같은 키로 즉시 자동 재시도하지 않고, 잠시 뒤 사용자가 다시 보내게 한다(키는 그대로). - 같은 키인데 내용이 다르면(실패 뒤 받는 사람·제목·본문·첨부를 고친 경우) 보내지 않고 409
MAIL_005. 첫 메일이 실제로 나갔을 수 있으므로, 클라이언트는 사용자에게 보낸편지함을 확인하게 하고 새 키로 다시 보낼 수 있게 한다. 서버는 첫 발송 때 내용 지문(Idempotency-Key를 키로 한 HMAC — 받는 사람은 키를 모른다)을 함께 남겨 비교한다.- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 주소(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시),
inReplyTo. - 들어가지 않는 것: 표시 이름, 첨부 이름 — 이것만 바꾼 재시도는 "같은 내용"으로 보고 처음 결과를 돌려준다.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 주소(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시),
- 알려진 한계 (1단계는 발송 기록을 따로 저장하지 않고 보낸편지함에서 찾는다):
- 첫 발송 뒤 보낸 사본을 다른 메일함으로 옮기거나 지웠으면, 또는 그사이 보낸편지함·임시보관함에 200통 넘게 쌓였으면 같은 키의 재시도가 다시 나갈 수 있다.
- "처리 중" 표시는 서버 메모리에 있어 서버가 재시작되면 사라진다. 엔진 장애 직후의 드문 경합에서도 두 번 나갈 수 있다.
- 서식 본문의 지문은 정화한 결과로 만든다 — 첫 발송과 재시도 사이에 서버의 정화 규칙이 바뀌면(배포) 같은 키의 재시도가
MAIL_005로 막힐 수 있다. 그때도 위 409 처리(보낸편지함 확인 → 새 키)를 따른다. - 이 한계를 없애려면 (사용자, 키) → (지문, 메일 id) 를 서버 DB 에 저장한다 — 서버를 여러 대로 늘릴 때 함께 검토한다.
{
"to": [{ "name": "이도윤", "email": "doyun.lee@arasystem.it.kr" }], // 1개 이상
"cc": [], // 선택
"bcc": [], // 선택 (받는 사람 합계 100 이하)
"subject": "Re: 메일 서버 이전 일정 공유", // 255자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
"draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
"sendAt": "2026-10-30T00:00:00.000Z", // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
"largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일(링크로 보낸다) (11단계 — 4절 "발송 · 임시 저장과 함께")
"bodyQuoteStart": 1234 // 선택 — body 에서 인용이 시작하는 자리 = 덩이를 넣을 자리 (4절)
}
받는 사람별 결과 (발송 응답) — 6단계 앞 수정
interface SentMessage {
id: string // 보낸편지함의 메일 id
rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열
pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null
}
interface RejectedRecipient {
email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번)
reason: 'not-found' | 'refused' | 'temporary' // 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등)
}
- 엔진은 제출(
EmailSubmission/set)을 만들면서 받는 사람마다 그 자리에서 SMTP 로 묻는다. 거절된 사람은 제출 기록의deliveryStatus에delivered: "no"와 SMTP 응답(550 5.1.2 Mailbox does not exist·452 4.4.5 Rate limit exceeded등)으로만 남고 다시 보내지도 반송하지도 않는다 (Stalwart 0.16.27 — 2026-10-28 확인. 전에는 서버가 이것을 보지 않아, 없는 사내 주소로 보낸 메일도 "보냈습니다"로 끝났다). 그래서 서버가 제출한 뒤 결과를 읽어 알린다.- 결과는 제출 다음 따로 읽는다(
EmailSubmission/get) — 제출 응답(created)에는 없고, 같은 요청 안의 참조(#ids)는onSuccessUpdateEmail이 덧붙이는 응답 때문에 엔진이 풀지 못한다(같은 날 확인). - 분류 — SMTP 응답 맨 앞의 기본 코드(
550)와 확장 상태(5.1.2)만 읽는다(응답 글 안의 숫자는 보지 않는다). 4xx 면temporary, 5xx 이고 확장 상태가5.1.x(받는 사람 주소 문제)면not-found, 그 밖의 5xx 와 읽을 수 없는 응답은refused— 두 값이 어긋나면 기본 코드를 따른다(450 5.1.1은 temporary).5.1.7·5.1.8(보낸 사람 주소 문제)도not-found로 보이는데, 엔진의 제출 때 거절에는 나오지 않고 같은 키 재시도가 바깥 반송까지 읽을 때만 생길 수 있다.delivered가"no"가 아니거나 결과가 없는 사람은 받은 것으로 본다 — 바깥 주소는 대기열에 들어가250 Queued이고, 나중에 실패하면 지금처럼 반송 메일이 온다. - 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고
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 으로만 보내도 그렇다).
- 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400
- 파일럿 공존 동안(10단계 — mail/README.md "파일럿 공존"): 새 서버에 없는 사내 주소는 거절되지 않는다 — 엔진이 옛 서버(mailcow)로 중계하고,
그쪽이 받지 않으면 몇 초 뒤 반송 메일(
MAILER-DAEMON@<도메인>)이 받은편지함에 온다(바깥 주소와 같은 모양). 그래서 그런 주소는rejectedRecipients·MAIL_010·MAIL_011에 나오지 않고, 제출 기록도 대기열에서 빠지면250 2.1.5 Queued로 돌아가 나중의 실패를 남기지 않는다. 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 그대로not-found. setup 이 받는 쪽 가드를 확인하지 못하면 중계를 켜지 않거나 다시 닫는다(종료 코드 1 — 열린 중계를 막으려고). 그동안은 평소처럼 곧바로not-found다. - 모두 거절(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
이어 쓴 저장본(
draftId)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400MAIL_010, 모두 4xx 면 503MAIL_011. 같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다). 사본을 지우지 못해도(엔진 오류 — warn) 코드는 그대로다. 알려진 한계: 그때 보낸편지함에 사본 한 통이 남고, 같은 키 · 같은 내용의 재시도는 그 사본을 찾아 201 + 받는 사람 전원의rejectedRecipients로 답한다(이어 쓴 저장본도 지운다), 내용을 고친 재시도는 409MAIL_005다 — 지금 남은 결과만으로는 "제출 때 거절"과 "보낸 뒤 바깥에서 반송"을 가를 수 없어 다시 보내지 않는다. 제출 응답을 받지 못해 503MAIL_001로 끝났는데 엔진에서는 제출되어 모두 거절된 경우도 같다(사본을 지우는 데까지 가지 못했다) — 30초 잠금이 풀린 뒤 같은 키의 재시도가 그 사본을 찾는다. - 일부 거절 — 보낸 것이다. 201 에
rejectedRecipients를 싣고, 보낸 사본은 보낸편지함에 남고,draftId는 지운다. - 같은 키의 재시도가 처음 메일을 찾아 그대로 돌려줄 때도 그 메일의 제출 기록으로
rejectedRecipients를 다시 만든다(찾지 못하면 빈 배열). - 로그 — 모두 거절은 warn(
[MAIL_010]·[MAIL_011]), 일부 거절은 log. 주소는 가리고(maskEmail) SMTP 상태 코드(550 5.1.2등)만 남긴다.MAIL_011은 503 이라 전역 예외 필터가 5xx 규칙대로 error(스택 포함)로 한 줄 더 남긴다(MAIL_001과 같다) — 웹메일 발송은 엔진 한도에 걸리지 않게 했으므로 드물다. - 엔진 기본 한도 중 받는 사람 단위의 것("보낸 도메인 → 한 받는 사람" 시간당 25통)은 로그인한 직원의 발송에는 걸지 않는다 — 사내 메일은 보낸 도메인이 모두 같아, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤가 거절되던 문제(같은 날 확인). mail setup 이 맞춘다(mail/README.md "엔진 한도"). 바깥에서 들어오는 메일(인증 없는 SMTP)에는 그대로 건다. 다른 기본 한도 "보낸 IP 마다 초당 연결 5개"는 연결할 때 세는 것이라 웹메일 발송에는 원래 걸리지 않고, 그대로 둔다 (조건을 붙이면 엔진이 그 한도를 연결이 아닌 MAIL FROM 단계로 옮겨 바깥 접속을 더는 세지 않는다 — Stalwart 0.16.27 소스로 확인).
- 클라이언트
- 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로
"보냈지만 N명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
까닭 문구는
not-found"없는 주소" ·refused"받는 쪽이 거절" ·temporary"지금은 받을 수 없음". MAIL_010— 받는 사람 칸의 안내 띠(MAIL_002와 같은 자리 · 같은 초점)로만 알린다(토스트 없음). 쓰던 메일 · 키는 그대로.MAIL_011— 인터셉터 알림(사전 문구). 쓰던 메일 · 키는 그대로 — 잠시 뒤 다시 누르면 같은 키로 간다.
- 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로
"보냈지만 N명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
까닭 문구는
전달: 원본 메일 attachments[].id 를 발송 본문의 attachments 에 그대로 넣으면 원본 첨부가 다시 붙는다
(같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 Fwd: 와 인용 본문은 클라이언트가 만들고, inReplyTo 는 보내지 않는다.
발송 본문의 첨부 이름(attachments[].name)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 FILE_001) — 원본 첨부를 그대로 전달할 때도 같다.
답장·전달 초안의 길이: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 텍스트 100,000자·서식 본문 500,000자.
넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (본문을 넘긴 채 보내면 400 MAIL_007, 제목은 VAL_001).
처음 맞출 때는 쓸 글 몫으로 한도의 5%를 남기고(텍스트 95,000자·서식 475,000자), 보내기 직전에 실제로 보낼 본문을 다시 재서 넘으면 인용을 더 줄인다.
인용 없이 쓴 글만으로 넘으면 보내지 않고 쓰기 화면의 본문 아래에 MAIL_007 문구로 알린다 (서버가 MAIL_007 을 돌려줘도 같다).
서식 본문 (bodyHtml): 쓰기 화면의 서식 편집기가 만든 HTML 이다.
- 클라이언트는 같은 내용의 일반 텍스트를
body로 함께 보낸다 (받는 쪽 프로그램이 텍스트만 볼 때 쓰는 대체 본문 — 목록은-/1., 인용은>를 붙인다). 서버는 둘을multipart/alternative로 보낸다.bodyHtml이 없으면 텍스트 메일이다. - 서버는 보내기 전에 허용 목록으로 정화한다 — 편집기가 만드는 것만 남긴다:
문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기(span 의 style), 정렬·들여쓰기·줄 간격, 목록, 인용, 가로줄,
표(칸 폭을 적는
colgroup·col포함), 링크(http·https·mailto). 그 밖의 태그·속성·스타일·스크립트는 지운다. 지문에는 정화한 결과가 들어간다.- 남는 속성:
style(a를 뺀 모든 태그),a의href(링크에는 이것만 남는다 — 링크 글자 색은 안쪽 span 의 style 로),ol의start,td·th의colspan·rowspan(숫자만). - 남는 style:
color·background-color·font-family·font-size·font-weight·line-height·text-align·margin·padding(각 변 포함)·border(각 변·border-color·border-style·border-width·border-collapse)·width·min-width·vertical-align·white-space. - style 은 값의 형식까지 맞아야 남는다 — 형식이 틀린 선언은 그 선언만 지운다:
글자 크기는
px만 899.99px, 색은3(소수 둘째 자리까지), 글꼴은 쓰기 화면의 글꼴 목록과 흔한 대체 이름(#rgb·#rrggbb·rgb()·rgba()(색 이름·hsl()은 지운다) — 글자 색(color)의 투명도(알파)는 0.3 이상, 여백은0또는px(음수 없음), 줄 간격은 단위 없는 숫자 1Helvetica·Times·Courier·AppleGothic·AppleMyungjo)·일반 이름(sans-serif·serif·monospace)으로만 이뤄진 목록. - 형광 표시는
mark가 아니라 span 의background-color로 보낸다 (mark·h1같은 모르는 태그는 태그만 지우고 글자는 남긴다). - 정화하고 남는 것이 없으면
body만으로 텍스트 메일을 보낸다. - 그림은 보내는 사람 자신의 서명 그림만 남는다 —
<img src="/api/mail-signature/images/{id}">이고 그 id 가 보내는 사람의 그림일 때. 속성은src·width(1~600, 숫자만)·alt(200자 이하)만 남는다. 서버가 그 그림을 메일 안에 함께 실어(multipart/related,cid:) 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않는다. 그 밖의img·찾지 못한 서명 그림(지운 그림)은 태그째 지운다. 클라이언트 편집기도 이 그림만 받는다. - 보이지 않을 만큼 작은 글자(8px 미만)·거의 투명한 글자 색(알파 0.3 미만)은 그 선언을 지운다 — 붙여 넣거나 인용한 글에 그렇게 숨긴 글이 그대로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다 (받은 메일을 읽을 때의 정화는 이 제한을 두지 않는다).
- 한도 — 넘으면 400
MAIL_007. 엔진·첨부 조회 전이라 메일은 나가지 않으므로, 고친 뒤 같은 키로 다시 보내면 된다. 정화 전에는 받은 메일과 같은 파서 비용 한도(위 "읽기 한도")를, 정화 뒤에는 결과가 UTF-8 2,000,000바이트 이하인지 본다 (우리 웹메일이 다시 읽을 때 잘리지 않게). 500,000자 안이어도 넘을 수 있다 — 정화가&를&(5바이트)로 바꾸고, 한글은 글자당 3바이트·이모지는 4바이트다.
- 남는 속성:
- 기본 글꼴은 맑은 고딕 이다 — 클라이언트가 본문 전체를
font-family·font-size:14px·line-height:1.75를 가진div하나로 감싸 보낸다. 편집기 화면의 CSS 는 메일에 따라가지 않으므로 문단 여백(0)·인용 막대·표 테두리도 각 요소의 인라인style로 보낸다. - 클라이언트도 같은 값 형식만 읽는다 — 붙여 넣은 글과 원문 인용을 편집기로 옮길 때 위 형식 밖의 서식(색 이름·
font-size:0·목록에 없는 글꼴 등)은 버린다 (글자 크기의pt는px로 바꾼다). 서버만 지우면 편집기에서 숨은 글이 받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다. - 여러 칸 띄어쓰기와 줄 앞 공백은 받는 쪽이 합치거나 지우므로 nbsp 로 바꿔 보낸다 (보통 공백과 번갈아 — 긴 공백에서도 줄은 바뀐다).
탭은 다음 8칸 자리까지의 공백으로 바꾼다. 텍스트 대체 본문(
body)은 그대로 둔다. - 우리 웹메일이 다시 읽을 때(보낸편지함·받은 사람)도 이 서식이 남아야 한다 — 받은 메일 정화(
htmlBody)의 허용 목록이 위 서식을 포함한다. - 답장·전달의 원문 인용은 두 경우 모두 "원본 메일" 머리(보낸 사람·날짜·제목, 전달은 받는 사람·참조도)와 회색 인용 막대로 만든다.
원문은
htmlBody(없으면textBody)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다. - 본문 안 이미지는 이번 단계에서 넣지 않는다 — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로). 서명 그림만 예외다 (아래 "서명").
첨부 업로드 한도
- 한도를 넘는 파일은 일반 첨부가 아니라 대용량 첨부(링크) 로 보낸다 — 4절(11단계). 아래 일반 첨부의 한도 · 흐름은 그대로다.
- 파일당
MAIL_ATTACHMENT_MAX_SIZE_MB(기본 20MB) — 넘으면 413FILE_002 - 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다)
attachmentTotalMaxBytes(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB, 기본 25MB) — 넘으면 발송이 413FILE_002. 클라이언트는 첨부를 추가할 때 이 값으로 미리 막고 안내한다 (다 쓰고 보낼 때 알게 하지 않는다). - 엔진의 임시 업로드 한도(7단계에 찾음): 계정마다 1시간 동안 올린 크기 합계 · 개수(Stalwart
uploadQuota·maxUploadCount·uploadTtl). 엔진 기본값은 50MB · 1,000개 — 넘으면 올린 것이 만료될 때까지(1시간) 그 직원의 첨부 올리기와 필터 · 부재중 저장(엔진에 규칙 글을 올린다) · 서명 그림이 든 발송이 429REQ_001로 막힌다 (2026-10-29 개발 엔진: 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째 업로드가 429 — 크기 · 개수 어느 쪽이든 같은REQ_001, 백엔드 로그"call":"upload"). 그래서 mail setup 이 크기 합계를 첨부 합계 한도의 20배(기본 25MB × 20 = 524,288,000바이트)로 둔다 — 개수(1,000)는 그대로(mail/README.md "엔진 한도"). - 허용 형식(확장자 + MIME 둘 다 검사): pdf · doc/docx · xls/xlsx · ppt/pptx · hwp/hwpx · txt · csv · png · jpg/jpeg · gif · webp · zip
- 다운로드는
<a href>이동으로 받는다 — 같은 출처라 쿠키가 함께 간다. 이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, 이동하기 전에 세션을 확인한다 (/auth/me가AUTH_001이면/auth/refresh한 번 → 실패하면 세션 만료 처리).
임시 저장 (/api/drafts)
interface Draft {
id: string // 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다
to: Address[]
cc: Address[]
bcc: Address[]
subject: string
body: string // 텍스트 대체 본문
bodyHtml: string | null // 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null). 다른 메일 프로그램이 만든 초안도 같은 정화를 거친다
attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다
inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null)
updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC)
largeFiles: (LargeFile | MissingLargeFile)[] // 대용량 첨부 — 내 파일(올리는 중 포함). id 는 저장해도 바뀌지 않는다 (11단계 — 4절 "임시 저장")
}
interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string }
- 초안 본문은 발송 본문과 같은 필드·한도다(본문 길이
MAIL_007, 첨부 20개MAIL_006, 첨부 합계FILE_002, JSON 본문 4MB …). 다만 받는 사람은 0명이어도 되고 (합계 100명 이하),draftId는 없다. - 저장: 처음은
POST, 그다음은PUT /api/drafts/{직전 id}. 메일은 고칠 수 없어 서버가 새 초안을 만들고, 다시 읽어 확인한 뒤 옛것을 지운다 — 클라이언트는 응답의id·attachments로 바꿔 쓴다. 서버는 초안에 발송 중복 방지용 Message-ID·지문 헤더를 넣지 않는다. 만들기와 지우기를 한 요청에 넣지 않는다 — 엔진은 같은 요청에서 만들기가 실패해도 지우기를 그대로 해서, 저장에 실패한 순간 저장본이 사라진다.- "임시보관함의 초안" = 임시보관함에만 있고
$draft이며 보내기 중에 생긴 내부 초안이 아닌 메일. PUT 의 id 가 그렇지 않으면(휴지통·보낸 메일 등) 아무것도 만들거나 지우지 않고 404RES_001. - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 409
MAIL_008— 처음 확인에서 없으면 아무것도 만들지 않고, 만든 뒤 지우는 사이에 없어졌으면 방금 만든 것을 지운다. - 클라이언트는
MAIL_008과 PUT 의 404RES_001(다른 창에서 휴지통으로 옮긴 경우 등)을 모두 막지 않고, 지금 내용을POST로 새로 남긴다. - 옛것은 확인한 뒤로 메일함이 바뀌지 않았을 때만 지운다(JMAP
ifInState, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다. 그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공).
- "임시보관함의 초안" = 임시보관함에만 있고
- 목록·버리기·되돌리기는 기존 API 다 — 목록
GET /api/messages?mailboxId=<임시보관함>(보내기 중에 생긴 내부 초안은 나오지 않는다), 버리기는 휴지통으로 옮기기PATCH /api/messages/{id}{ mailboxId: <휴지통> }(웹은DELETE를 쓰지 않는다 — 이미 휴지통인 메일은DELETE가 영구 삭제한다), 되돌리기PATCH /api/messages/{id}{ mailboxId: <임시보관함> }. - 보내기: 이어 쓴 초안은 발송 본문에
draftId를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤 (같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 · 확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공).draftId는 지문에 넣지 않는다. - 클라이언트 저장 주기: 바꾼 내용이 있을 때 입력이 3초 멈추면 · 쉬지 않고 쓰는 중에도 30초마다 · 10초에 한 번까지 · 화면을 떠날 때, 한 번에 하나씩.
찾지 못한 첨부(
MAIL_004)가 남은 동안과 본문이 한도를 넘는 동안은 저장하지 않는다 (첨부가 빠진 판으로 저장본을 덮지 않게). 다른 메일 프로그램이 만든 저장본에 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면 첫 저장은 PUT 이 아니라 POST 다 — 원본을 지우지 않는다. 보내기 전에는 진행 중인 저장을 기다린다 (그래야draftId가 최신이다). - 알려진 한계:
- 저장할 때마다 첨부 내용이 새 초안으로 복사돼 큰 첨부가 있으면 저장이 무겁다. 메일함 용량이 거의 차면 교체하는 순간 두 판이
함께 있어
MAIL_003이 날 수 있다. - 답장 대상은 초안의 사용자 머리
X-Ara-Reply-To-Id(메일 id)에 둔다. 우리 서버는 보낼 때 새 메일을 만들어 이 머리가 나가지 않지만, 다른 메일 프로그램이 그 초안을 그대로 보내면 함께 나갈 수 있다. - 보내기에서 제출은 됐지만 보낸편지함으로 옮기지 못한 메일은 임시보관함에 보이지 않는 채로 남는다 (같은 키의 재시도는 그 메일을 찾는다).
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은
MAIL_008이 되어 클라이언트가 새로 남기므로 임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
- 저장할 때마다 첨부 내용이 새 초안으로 복사돼 큰 첨부가 있으면 저장이 무겁다. 메일함 용량이 거의 차면 교체하는 순간 두 판이
함께 있어
서명 (/api/mail-signature)
interface MailSignature {
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
isForNew: boolean // 새 메일에 넣는다 (기본 true)
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
images: SignatureImage[] // 서명이 쓰는 그림
updatedAt: string | null // 저장한 적 없으면 null
}
interface SignatureImage {
id: string
type: 'image/png' | 'image/jpeg' | 'image/gif'
size: number // 바이트
width: number // 원본 픽셀
height: number
}
- 사용자마다 하나다. 저장한 적 없으면 기본값(
bodyHtml: null·body: null·둘 다true·images: [])을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다). PUT의 한도 — 넘으면 400VAL_001: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자, 서명 그림 3장(서로 다른 그림 기준).- 서명 그림:
POST /api/mail-signature/images(multipartfile) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다), 한 장 300KB 이하(넘으면 413FILE_002— 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다), 가로·세로 각각 4096px 이하(넘으면 400FILE_001— 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게). 서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400VAL_001). 올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400VAL_001). 서명 HTML 은 그림을<img src="/api/mail-signature/images/{id}" width="144" alt="">로 가리킨다(width1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하). GET /api/mail-signature/images/{id}는 그 사람의 그림만 내준다(남의 것은 404RES_001) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다. 저장할 때 확인한 형식 그대로,Content-Disposition: inline·X-Content-Type-Options: nosniff·Cache-Control: private, max-age=86400(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).PUT은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다 (그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.- 보낼 때: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
(
multipart/related의 그림 부분 —Content-Disposition: inline,Content-ID: <sig-{그림 id}@{보내는 사람 도메인}>, 이름signature-1.png…, 첨부가 있으면 그 묶음을multipart/mixed안에 둔다). 우리 웹메일에서는 위hasAttachment·isInline규칙으로 첨부로 보이지 않는다. 텍스트 대체 본문에는 그림이 없다. 임시 저장본도 같은 그림 주소를 쓴다. - 알려진 한계: 서명 그림을 지운 뒤 그 그림을 쓴 임시 저장본을 보내면 그 그림은 빠진다. 답장·전달로 인용한 원문 속 그림(남의 서명 로고 포함)은 인용에서 빠진다.
받는 사람 추천 (/api/recipient-suggestions)
interface RecipientSuggestion {
name: string | null
email: string
source: 'contact' | 'directory' | 'recent' // contact = 내 주소록(8단계), directory = 사내(8단계부터 엔진의 사내 목록 — 3절), recent = 최근 주고받은 주소
company: string | null // contact 만 — 그 연락처의 회사 (그 밖에는 null)
emailLabel: 'work' | 'personal' | 'other' | null // contact 만 — 그 주소의 종류 (그 밖에는 null)
}
q가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 8단계부터 쓰기 화면과 같은 맞추기다 — 이름은 초성 · 조합 중 글자를 섞어도 맞고 (ㄷㅇ·도ㅇ→ 이도윤, 윤을 치는 중인도유·동→ 도윤), 주소는 그 글자가 든 것(완전한 주소를 쳤으면 같은 아이디도). 규칙의 짝은 frontendshared/utils/recipient-match.ts이고 두 쪽 테스트가 같은 표본 표를 쓴다. 응답은 커서 페이지네이션 규칙 그대로({ items, pageInfo }) — 커서는 받은 그q로만 쓴다(다른q와 함께 보내면 400VAL_001).- 순서: 주소록(8단계) → 사내 → 최근 주고받은 주소. 주소록은 이름 차례(3절), 사내 · 최근은 주고받은 횟수와 최근성으로. 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 — 3절 "메일과 함께".
- 최근 주고받은 주소는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 — 다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
- 사내 목록은 8단계부터 엔진의 사내 목록(3절 "사내 목록")이다. 그것을 읽지 못할 때만 아래 옛 규칙으로 대신한다 — 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다(직원 역할에는 엔진의 계정 목록 권한이 없다). 한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다 (퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
- 알려진 한계: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다. 추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
- 응답은
Cache-Control: private, no-store. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다. 같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다. - 클라이언트: 쓰기 화면을 열 때
q없이limit=100으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자라고 더 있을 때만q로 다시 묻는다(250ms 기다린 뒤) — 8단계부터는 초성 · 조합 중 글자도 묻는다(주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다). 맞추기가 같아서 받아 둔 것에서 8개를 찾았으면 서버에 물어도 같은 8개다. 두 쪽 모두 유니코드 정규형(NFC)으로 맞추지 않는다 — 화면이 원래 글자 위치로 강조하기 때문이다(다른 정규형으로 저장된 이름은 그 글자로 맞지 않는다).
새 메일 확인 (/api/mail-state) — 3단계
// GET /api/mail-state — 새 메일 · 다른 창(메일 프로그램)의 변화를 알아채는 데 쓴다
interface MailState {
state: string // 불투명 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다
}
- 서버는 엔진의 메일함 · 메일 상태 문자열을 이어 붙여 돌려준다 (JMAP
Mailbox/get·Email/get에ids: []— 메일을 읽지 않는 가벼운 조회). 새 메일이 오거나 다른 창 · 메일 프로그램이 읽음 · 이동 · 삭제를 하면 값이 바뀐다 (2026-10-27 개발 엔진에서 확인). 응답은Cache-Control: private, no-store. - 클라이언트: 로그인한 동안, 화면이 보이는 동안(
visibilityState === 'visible')만 60초마다 묻는다. 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번. 한 번에 하나씩.- 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 메일함 개수 · 사용량을 다시 받고, 보이는 메일함 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 열린 메일 · 스크롤 위치는 그대로. 100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다. 검색 결과는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게).
- 이 탭이 바꾼 것(읽음 · 이동 등)도 값을 바꾸므로 다음 확인에서 한 번 더 받게 된다 — 해롭지 않다.
- 실패(
MAIL_001· 네트워크)는 알리지 않고(silent) 다음 주기에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 그대로 탄다. - 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에
(N)을 붙인다 (디자인 README "3단계").
- 요청 수 한도(
THROTTLE_LIMIT, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때 1분 확인이 429REQ_001로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(GET /api/messages·/api/mailboxes·/api/mail-quota)는 평소 사용과 같은 칸을 쓴다 —GET /api/messages는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다(3단계). 5단계부터는 실시간 연결이 살아 있는 동안 1분 확인 대신 "바뀔 때마다(1초 모아서) 보이는 탭 수 × 1번"이다 — 사무실이 바쁠수록 늘 수 있다. 운영 전환 때 직원 수에 맞춰 정한다. - 5단계부터는 실시간 연결(아래 "실시간 알림")이 살아 있는 동안 1분 확인을 멈추고, 끊기면 1분 확인으로 돌아간다. 이 API 와 규칙은 그대로다.
검색 (GET /api/messages 의 q) — 3단계
mailboxId가 있으면 그 메일함에서, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. 둘 다 없으면 400VAL_001.q는 앞뒤 공백을 뺀 1~200자(JS 문자열 길이 — UTF-16 단위), 낱말(띄어쓰기로 나눈 것) 8개까지(넘으면 400VAL_001). 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문에서 찾고, 여러 낱말이면 모두 들어간 메일만 찾는다. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. 낱말 앞뒤의 문장부호는 떼고 찾는다('회의,' · '(회의)' → '회의' — 엔진도 문장부호에서 낱말을 나눈다, 가운데의 문장부호는 둔다). 글자 · 숫자가 하나도 없는 낱말(-· 따옴표뿐 등)은 찾지 않고, 찾을 낱말이 하나도 남지 않으면 엔진에 묻지 않고 빈 결과(200)다.- 한국어 — 서버가 보완한다: 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기 · 문장부호로 나눈 낱말이 통째로 같을 때만 맞는다 —
'회의'로는 '회의를'을 찾지 못한다 (2026-10-27 개발 엔진에서 확인). 그래서 서버는 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다
(회의 → 회의가 · 회의를 · 회의는 · 회의에서 · 회의입니다 … / 일정 → 일정이 · 일정을 · 일정은 …, 낱말 안에서는 OR · 낱말끼리는 AND).
영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다.
조사 목록은 서버 상수 한 곳(
jmap/search-terms.ts)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고, 개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다. - 알려진 한계 (엔진 색인 때문 — 14단계부터는 그 직원의 자체 색인을 채우는 동안(
building)의 검색에만 남는다, 아래 "검색 — 14단계"): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'), 끝이 바뀐 말('보냈습니다'의 '보내'), 제목 맨 앞의 말머리('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다). 화면은 결과가 없을 때 이 한계를 한 줄로 알린다. - 결과는 받은 시각 내림차순,
total포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400VAL_001). 발송 중인 내부 초안은 지금처럼 빠진다. - 검색어는 접근 로그 · 백엔드 로그에 남기지 않는다 — 백엔드는 지금처럼
maskUrlForLog()(q·cursor는 길이만). 화면 주소에도 검색어가 들어가므로 nginx 는/api뿐 아니라 화면 요청의 접근 로그도 쿼리 없이 남기고(Referer 도?앞까지), 화면 응답의Referrer-Policy를strict-origin으로 둔다 — 검색 화면에서 나가는 요청(1분 확인 등)의 Referer 에 검색어가 실리지 않게. 오류 로그는 장애 원인 줄을 잃지 않게 지금처럼 요청 줄을 쿼리째 남긴다 (docker 스킬 "로그"). - 클라이언트: 상단 검색 칸(메일 화면에서는 "메일 검색")에서 Enter 로 찾는다 — 치는 동안에는 묻지 않는다.
서버와 같은 한도(200자 · 낱말 8개)를 먼저 보고, 낱말이 8개를 넘으면 보내지 않고
VAL_001사전 문구로 알린다. 검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않고 주소를 바꿔 끼운다(메일함에서 시작한 첫 검색만 기록에 쌓인다). 주소는/mail/search?q=…&in=<메일함 id | all>, 결과에서 연 메일은/mail/search/<메일 id>?q=…&in=…(뒤로 가기: 메일 → 결과 → 검색 전 메일함). 범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다). ✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
검색 색인 (GET /api/search-index) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 다음 차례)
14단계는 엔진 검색 대신 서버의 자체 색인(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다.
- 색인에 드는 것: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계(Message-ID · In-Reply-To · References) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름 · 본문 글(앞 2만 자 — 잰 뒤 정한다). 메일 글이 DB 와 매일 백업에 한 벌 더 들어간다(사용자가 받아들였다). 퇴사 마무리는 이 표도 지운다. 언제나 로그인한 직원 주소로만 읽고 쓴다.
- 만드는 때: 직원이 웹메일에 처음 로그인할 때(비밀번호가 만료된 로그인은 빼고 — 새 비밀번호를 정하면 만든다) 또는 아래 경로를 처음 물을 때 그 직원의 상태 줄을
building으로 만든다. 로그인한 적 없는 계정은 색인하지 않는다. - 지난 메일 채우기: 밤에만 한 사람씩(KST 22:00 ~ 02:45 · 05:00 ~ 07:30 — 03:00 · 04:00 · 04:30 엔진 정리 · 03:30 백업을 피한다). 다 채우면
ready. - 새로 온 · 바뀐 · 지운 메일은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다. 비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다.
GET /api/search-index→{ status, done, total }status:building(지난 메일을 채우는 중 — 그동안 그 직원의 검색은 지금 방식(엔진)이고, 화면은 결과 머리 아래에 "검색 준비 중" 줄을 보인다) ·ready.done: 채운 메일 수(total을 넘지 않는다) ·total: 채우기를 시작할 때의 메일 수(시작 전이면null,ready면done과 같다).- 조회지만 상태 줄을 만들고 멈춤을 푼다 — 줄이 없던 직원은 이때
building으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등). - 로그인 필요(401
AUTH_001) · 제한된 세션은 403AUTH_010(6절).
- 로그 가림: 검색 조건의 이름
from·to·subject·body도q·cursor처럼 길이만 남긴다(maskUrlForLog). 개발 환경도 DB 쿼리 로그(매개변수)를 남기지 않는다 — 메일 글이 DB 에 있다.
검색 — 14단계 (자체 색인 · 조건 검색 · 강조)
-
방식: 그 직원의 색인이
ready면 색인으로, 아니면(building) 엔진으로 찾는다(위 3단계 절의 규칙 · 한계 그대로 — 조건은 엔진 필터로, 낱말이 통째로 같을 때만). 방식은 첫 쪽에서 정해지고 커서가 그 방식을 잇는다 — 쪽을 넘기는 중에 준비가 끝나도 그 검색은 처음 방식대로다. 색인 방식은 찾기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안 — 방금 온 메일도 찾아지게). -
쿼리 (
GET /api/messages— 3단계의mailboxId·q·cursor·limit에 더한다)이름 찾는 곳 검사 q보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 · 첨부 이름 3단계와 같다 from보낸 사람(이름 · 주소) 넷 모두 q와 같은 검사 — 앞뒤 공백을 지우고 공백뿐이면 없는 것, 1~200자(UTF-16), 낱말 8개, 길이를 먼저 본다to받는 사람과 참조 subject제목 body본문 after·before받은 시각이 after이상 ·before미만ISO 8601 UTC( Z· 소수 초 3자리까지 · 있는 날짜)hasAttachment진짜 첨부가 있는 메일만(목록의 hasAttachment와 같은 판정)true만 받는다q나 조건 하나라도 있으면 검색이다(검색 칸이 비어도 조건만으로). 메일함 범위는 3단계와 같고(mailboxId가 없으면 휴지통 · 스팸메일함을 뺀 모두),mailboxId도 검색도 없으면 400VAL_001.q와 조건 칸 넷(from·to·subject·body)을 합친 낱말은 8개까지다(넘으면 400VAL_001— 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻기 때문에, 3단계에서 잰 비용(낱말 8개 · 조건 472개 · 0.16초) 안에 두려고. 화면은 보내기 전에 같은 수를 본다). 검색 글의 NUL(U+0000)은 색인과 같이 U+FFFD 로 바꿔 찾는다. 화면은 고른 날짜를 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시로 바꿔 보낸다(부재중과 같은 방식). -
찾는 규칙 (색인 방식)
- 부분 일치 — 낱말이 그 칸의 글 어디에 있어도 맞는다('계약' → 계약서 · 계약은 · 계약직, 제목 맨 앞의 [공지] · Re: 도). 영문 대소문자 · 전각 영숫자는 가리지 않는다(NFC).
- 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(3단계와 같다). 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 — 띄어쓰기 두 칸에는 맞지 않는다.
- 한 글자 낱말: 두 글자 넘는 낱말과 함께면 모두 찾는다. 한 글자 낱말뿐이면 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾고 본문은 찾지 않는다(2026-11-05 사용자 확정 — 본문을 다 훑으면 메일이 많은 직원은 수 초. 화면은 결과 머리에 한 줄 알린다).
- 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계).
-
응답 (목록)
- 항목(MessageSummary)에
searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null— 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은null. 위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면subject도 NFC 로 싣는다.preview.text는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다. hasNext: true인데 항목이limit보다 적거나 0개일 수 있다(색인 방식) — 한 요청이 확인하는 메일 수에 상한이 있어(3,000 — 1초 안에 답하려고) 그때까지 찾은 것과 이어 찾을 커서를 준다. 화면은 커서가 있으면 이어 묻는다(빈 쪽이 이어지면 몇 번까지만 — 화면 상수).total: 첫 쪽에서 셀 수 있으면 0~1,000, 1,000 을 넘거나 이 요청에서 다 세지 못했으면null, 다음 쪽은 늘null(엔진 방식은 3단계와 같다). 화면은null이면 통 수를 보이지 않는다 — "1,000통 넘게"라고 쓰지 않는다(다 세지 못한 것일 수도 있다).- 커서는 불투명한 문자열이다. 방식 · 메일함 ·
q· 모든 조건이 지문에 들어가 다른 검색에 쓰면 400VAL_001. 3단계의 옛 커서는 그대로 통한다.
- 항목(MessageSummary)에
-
읽기 화면 (
GET /api/messages/{id})images에 더해q·from·to·subject·body를 받는다(검사는 목록과 같다 —from·to는 받기만 하고 강조하지 않는다).after·before·hasAttachment·cursor·limit은 받지 않는다(400VAL_001) — 화면은 검색 주소의 조건 가운데 이 다섯만 넘긴다.- 강조할 말이 있으면
searchHighlight: { subject: Range[], attachments: { [첨부 id]: Range[] }(맞은 첨부만), textBody: Range[] }를 싣고,subject· 첨부 이름 ·textBody를 NFC 로 싣는다. 없으면null. htmlBody는 정화한 뒤 맞은 글 조각에 속성 없는<mark>를 넣는다 — 태그를 건너는 말 · 속성 · 주석 ·style안은 건드리지 않고, 한 통에 1,000개까지.- 강조할 말:
q는 제목 · 본문 · 첨부 이름에,subject는 제목에,body는 본문에.from·to는 강조하지 않는다. 화면은<mark>를 노랑#FCE7A4· 글자색 그대로로 그린다(디자인 README 14단계). - 강조가 든 응답은 보이기만 한다 — 답장 · 전달의 원문 인용 · 인쇄 · 다른 화면의 메일 기억(목록 · 스토어)에는 검색 쿼리 없이 받은 응답을 쓴다(보내는 정화는
mark를 지우지만, 인용 · 인쇄 모양이 검색에 따라 달라지지 않게). - 한 글자 낱말뿐이라 본문을 찾지 않았다는 표시는 응답에 없다 — 화면은 서버와 같은 낱말 나누기(짝 — backend
shared/utils/search-text.ts· 표본 표backend/test/search-text-samples.ts)로 알아보고 결과 머리에 한 줄 알린다.
-
화면은 조건도 화면 주소에 싣는다(검색어처럼 — 앞단 nginx 의 접근 로그는 쿼리를 남기지 않는다). 새 에러 코드는 없다(
VAL_001·RES_001·MAIL_001).
여러 통 한꺼번에 (PATCH /api/messages · POST /api/message-deletions) — 3단계
// PATCH /api/messages — 읽음 · 중요 표시 · 이동을 여러 통에
interface MessagesPatch {
ids: string[] // 1~100, 같은 id 를 두 번 넣지 않는다
isRead?: boolean
isFlagged?: boolean
mailboxId?: string // 이동 = 그 메일함 하나에만 속하게 (단건 PATCH 와 같다)
} // 바꿀 것이 하나 이상
interface MessagesPatched {
updatedIds: string[]
failedIds: string[] // 없음 · 그사이 지워짐 · 엔진이 거절함 — 나머지는 그대로 처리한다
}
// POST /api/message-deletions — 휴지통에서 영구 삭제, 여러 통
interface MessageDeletionRequest { ids: string[] } // 1~100, 같은 id 를 두 번 넣지 않는다
interface MessageDeletionResult {
deletedIds: string[] // 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)
skippedIds: string[] // 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다
}
PATCH /api/messages는 엔진 요청 한 번으로 처리한다. 없는mailboxId는 404RES_001이고 아무것도 바꾸지 않는다. 503MAIL_001이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).POST /api/message-deletions는 휴지통에 있는 메일만 다룬다 —DELETE /api/messages/{id}와 달리 휴지통 밖의 메일을 휴지통으로 옮기지 않는다. 대상을 확인한 뒤 확인한 상태 그대로일 때만 한 번에 지운다(ifInState— 그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다). 어긋나면 다시 확인해 합계CONDITIONAL_DESTROY_ATTEMPTS번, 계속 어긋나면 아무것도 지우지 않고 503MAIL_001. 휴지통이 없어도 503MAIL_001. 지우는 요청이 엔진 시간 초과로 끝난 503MAIL_001만은 일부가 지워졌을 수 있다 — 같은 요청을 다시 보내면 이미 지운 메일은skippedIds로 온다. 한 번에 지우기(destroy)와 휴지통에서 빼기(update)를 함께 보내고, 옛 상태면 엔진이 둘 다 거절한다 (2026-10-27 개발 엔진에서 확인).- 한 요청의
ids는 100개까지다 (엔진의 한 번 처리 한도 500 보다 작게). - 클라이언트:
- 고를 수 있는 것은 불러온 메일까지다 (표 머리의 고르기 = 불러온 메일 모두). 100통이 넘으면 100통씩 차례로 보낸다.
- 휴지통 밖의 「삭제」는
PATCH { mailboxId: 휴지통 }이다 — 「되돌리기」는 원래 메일함별로 묶어 다시PATCH.POST /api/message-deletions는 휴지통에서 「영구 삭제」를 확인한 뒤에만 부른다. - 웹 클라이언트는
DELETE /api/messages/{id}를 쓰지 않는다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다. 읽기 화면의 「삭제」도 그 메일의mailboxIds를 보고 휴지통 밖이면 휴지통으로PATCH(+ 되돌리기), 휴지통이면 확인 대화상자 뒤POST /api/message-deletions다. 휴지통 목록 밖(검색 결과 등)에서는 휴지통에만 있는 메일만 「영구 삭제」다 — 휴지통과 다른 메일함에 함께 있는 메일은 「삭제」(휴지통으로 옮기기)로 다룬다 (영구 삭제를 확인했는데 서버가 휴지통에서만 빼고 다른 메일함에 남기는 일이 없게).mailboxIds가 낡았어도 해가 없다 — 이미 휴지통인 메일을 휴지통으로 옮기면 그대로이고, 휴지통 밖으로 돌아간 메일은 서버가skippedIds로 건너뛴다. - 알려진 한계: 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌린다 — 그사이 다른 창이 옮긴 메일(저절로 바뀌지 않는 검색 결과 등)은 그 전 메일함으로, 여러 메일함에 함께 있던 메일(다른 메일 프로그램의 복사)은 첫 메일함으로만 돌아간다.
- 「되돌리기」 알림은 만든 세션에서만 움직인다 — 로그아웃 · 세션 만료 · 다른 계정 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
- 읽음 · 중요 표시는 화면을 먼저 바꾸고
failedIds만 되돌린다. 이동 · 삭제는 응답을 받은 뒤updatedIds·deletedIds만 목록에서 빼고 개수를 다시 맞춘다 (전체 메일함 검색 결과에서 범위 안의 다른 메일함으로 옮긴 메일은 여전히 찾은 메일이라 빼지 않고 메일함 표시만 바꾼다 — 휴지통 · 스팸메일함으로 옮기면 뺀다). 요청이 통째로 실패해 하나도 처리하지 못하면 고른 것을 그대로 둔다(다시 시도할 수 있게). failedIds·skippedIds가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
메일함 관리 (/api/mailboxes) — 4단계
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
remaining: number // 남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다
}
- 이름: 앞뒤 공백을 뺀 1~64자(JS 문자열 길이),
/는 쓰지 않는다(경로 구분자로 읽는 메일 프로그램이 있다). 제어 문자도 쓰지 않는다 — C0(U+0000–U+001F, 탭 · 줄바꿈 포함) · DEL(U+007F) · C1(U+0080–U+009F). 그 밖의 문자(NBSP · 이모지 등)는 받는다. 같은 상위 안에 같은 이름은 409MAIL_009(엔진이alreadyExists로 거절한다 — 2026-10-28 확인). 지금 이름으로 바꾸면 엔진에 묻지 않고 그대로 돌려준다. 만든 메일함은 구독 상태(isSubscribed)라 IMAP 메일 프로그램에도 보인다. - 만들 수 있는 자리: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400
VAL_001. 웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다). - 기본 메일함(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400
VAL_001. 화면은 메뉴에 그 항목을 두지 않는다. - 지우기: 하위 메일함이 있으면 400
VAL_001(화면이 먼저 막는다). 안의 메일은 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다 — 엔진은 메일이 든 메일함을mailboxHasEmail로 거절하므로(onDestroyRemoveEmails: false— 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다. 메일이 많으면 한 요청에 다 옮기지 못한다 —deleted: false면 클라이언트가 같은 요청을 다시 보낸다. - 전체 읽음(
PATCH /api/mailboxes/{id}/messages {isRead:true}): 안 읽은 메일을 500통씩 읽음으로 바꾼다. 비우기(DELETE /api/mailboxes/{id}/messages): 휴지통 · 스팸메일함만, 500통씩 확인한 상태 그대로일 때만(ifInState) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(POST /api/message-deletions와 같은 규칙). - 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다)
remaining을 돌려준다. 클라이언트는 0(또는deleted: true)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429
REQ_001(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다. - 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(
remaining> 0 ·deleted: false), 하나도 처리하지 못했으면 503MAIL_001(클라이언트가 끝없이 다시 보내지 않게): 아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김. 엔진이 한 차례를 모두 거절하면 진행한 것이 없을 때 그 거절의 코드(권한 없음 403AUTH_003등)다. - 비우기는 차례마다 그 시점의 메일함을 다시 읽는다 — 비우는 동안 들어온 메일(새 스팸, 다른 탭에서 휴지통으로 옮긴 메일)도 지운다(흔한 메일 프로그램과 같다). 그래서 화면은 휴지통 비우기와 메일함 지우기(안의 메일이 휴지통으로 간다)를 함께 돌리지 않는다.
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429
- 클라이언트: 메일함 패널의 각 메일함 ⋯ 메뉴 — 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기 · 이름 바꾸기 · 지우기(내 메일함만) · 휴지통 / 스팸메일함 비우기.
휴지통 · 스팸메일함 목록 머리에도 「비우기」. 비우기 · 지우기는 확인 대화상자(처음 초점 「취소」) 뒤에만 하고, 확인한 그 메일함만 처리한다.
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404
RES_001은 지운 것으로 본다. 메일함 지우기의 진행은processed로 센다(movedToTrash는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
부재중 자동 응답 (/api/mail-vacation) — 4단계
interface MailVacation {
isEnabled: boolean
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
// 메일 필터의 isActive 와 같은 값(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다(넣으면 400 VAL_001 — 모르는 필드)
}
-
길이 한도의 근거 (6단계 앞 수정): 엔진이 UTF-8 바이트로 막는다 — 제목 511바이트(한글 170자 · 영문 511자), 본문 2,047바이트(한글 682자 · 영문 2,047자) (Stalwart 0.16.27, 2026-10-28 확인 — 넘으면 엔진이
invalidProperties로 거절해 400VAL_001이었다. 4단계 계약의 200자 · 4,000자는 한글로는 맞지 않았다). 글자마다 바이트가 달라(한글 3 · 이모지 4바이트/2자) 어떤 글이어도 넘지 않는 150자 · 600자로 둔다(JS 문자열 길이). 이 한도는 켤 때만 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면 화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다. -
한 번도 설정하지 않은 계정은
{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다. 제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JStrim()— NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다. -
저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트
arasystem)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAPVacationResponse(singleton)였다. 서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다. 필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(
isActive: false) 부재중도 동작하지 않는다 —isEnabled는 저장한 값이라 그때도true다. 7단계부터 응답의isActive로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다). - 저장해 둔 원본이 깨진 계정은 꺼짐(
isEnabled: false, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503MAIL_001이다("메일 필터"의 "저장 자리").
-
fromDate·toDate는Z로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다). 둘 다 있으면 버린 값으로fromDate < toDate(아니면 400VAL_001). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다. -
같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다. 6단계부터는 Sieve
vacation(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다. -
클라이언트: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 — 메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
스팸 · 원문 — 4단계
- 스팸 신고 · 스팸 아님:
PATCH /api/messages/{id}·PATCH /api/messages의isJunk—true면 키워드$junk를 붙이고$notjunk를 뗀다,false면 반대(RFC 8621 4.1.1). 화면은 이동과 함께 보낸다 — 스팸 신고{ mailboxId: <스팸메일함>, isJunk: true }, 스팸 아님{ mailboxId: <받은편지함>, isJunk: false }. 엔진의 스팸 학습에 쓰이는지는 엔진 몫이다. 「되돌리기」는 원래 메일함으로 옮기며 보낸 값의 반대를 보낸다 — 표시를 지우는 값은 두지 않아서, 원래 아무 표시도 없던 메일도 되돌린 뒤에는$notjunk(스팸 아님)가 남는다. - 원문 내려받기:
GET /api/messages/{id}/source— 엔진의 원본(blob)을 그대로 흘려보낸다(message/rfc822,Cache-Control: private, no-store). 파일 이름은 제목(없으면 "메일")에.eml— 다운로드 헤더는 첨부와 같은 규칙(shared/utils/content-disposition.ts). 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다(confirmSession()).
실시간 알림 (GET /api/mail-events) — 5단계
서버가 메일 엔진(Stalwart)의 JMAP EventSource(RFC 8620 7.3)를 직원마다 하나 열고, 그 소식을 브라우저에 SSE(text/event-stream)로 흘려보낸다. 표준 응답 래퍼는 없다.
event: state
data: {"state":"<GET /api/mail-state 의 state 와 같은 값>","inboxUnread":3}
event: new-mail
data: {"count":2,"messages":[{"id":"…","from":{"name":"박서준","email":"seojun.park@arasystem.it.kr"},"subject":"…","receivedAt":"2026-10-28T05:42:00.000Z"}]}
event: ping
data: {"upstream":true}
event: end
data: {"reason":"token-expiring"}
| 이벤트 | 언제 · 뜻 |
|---|---|
state |
연결 직후 한 번, 그 뒤 메일함 · 메일이 바뀔 때마다(엔진 신호를 0.3초 모아서). state 는 GET /api/mail-state 와 같은 값, inboxUnread 는 받은편지함의 안 읽은 수 |
new-mail |
받은편지함에 새로 배달된 메일이 있을 때 — $draft 아님 · $seen 아님 · 보낸 사람이 나 아님. 새것부터 최대 5통(count 는 전체 수). 같은 변화의 state 다음에 온다 |
ping |
25초마다(직원마다 한 간격 — 새 연결의 첫 ping 은 더 빨리 올 수 있다), 그리고 upstream 이 바뀌면 그때 바로. upstream: false 면 서버와 엔진 사이가 끊겨 다시 잇는 중이다 — 그동안 클라이언트는 1분 확인으로 돌아간다. 다시 잇는 중에 새로 연결하면 마지막으로 알던 state 와 ping {upstream:false} 가 바로 온다 |
end |
서버가 곧 닫는다 — token-expiring(액세스 토큰이 끝나기 30초 전 — 남은 시간이 30초 이하인 토큰(세션이 끝나기 30초 안에 갱신한 토큰)이면 토큰이 실제로 끝날 때) · server-shutdown(서버 종료 · 재시작) |
large-file |
내가 올린 대용량 파일이 악성 검사에서 위험으로 판정됐을 때 — {"fileId","name","scanStatus":"infected"} (11단계 — 4절 "악성 검사"). 지금 클라이언트는 모르는 이벤트를 버린다 |
| 연결 전 실패 (표준 실패 형식 JSON) | 코드 |
|---|---|
| 로그인 안 됨 · 토큰 만료 | 401 AUTH_001 |
| 한 직원의 연결이 5개를 넘음(브라우저마다 하나라 기기 · 브라우저 5개) · 요청 수 한도(경로마다 IP 당) · 엔진 한도 | 429 REQ_001 |
| 엔진에 연결할 수 없음(처음 연결 10초 상한) · 기다리는 사이 엔진 연결이 끝남 · 서버 종료 중 | 503 MAIL_001 |
스트림이 열린 뒤 계정을 더 쓸 수 없게 되면(엔진이 대리 접근을 거절) end 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다.
- 서버: 직원마다 엔진 연결 하나를 그 직원의 모든 연결에 나눠 쓴다(마지막 연결이 닫히고 30초 뒤 닫는다 — 새로 고침에 다시 열지 않게).
엔진 연결은 대리 접근 자격 ·
types=Mailbox,Email·ping=30이다 — 엔진은 30초보다 짧게 주지 않는다(10초를 달라고 해도 30초, 2026-10-28 확인). 75초 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다(1 · 2 · 5 · 10 · 30초 간격에 ±20% — 엔진이 다시 뜰 때 모든 직원이 같은 순간에 붙지 않게). 그동안 클라이언트에는ping의upstream: false로 알린다. 같은 기준으로 이어서 3번 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다(그 차례는new-mail없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다). 엔진이 묻기를 한도(429)로 막으면 엔진 연결은 두고 5초(±20%) 뒤 다시 묻는다(upstream은 true 그대로 — 다시 잇는 중이면 다시 잇기 간격대로). 5분 안전판: 엔진 신호가 없어도 마지막으로 물은 지 5분이 지나면 한 번 묻고, 달라졌으면state(와new-mail)를 보낸다 — 연결이 살아 있는 동안 클라이언트는 1분 확인을 멈추므로, 엔진이 신호를 빠뜨려도 낡은 값이 5분 넘게 남지 않게. - 새 메일 판단: 엔진의
Email/changes(지난 state 부터 생긴 메일) →Email/get으로 한다. 엔진의EmailDelivery신호는 초안을 만들어도 바뀌므로 쓰지 않는다(2026-10-28 확인). 배달에서 신호까지 0.5초 남짓이다(같은 날 개발 엔진). 다음 경우에는new-mail없이state만 보내고 기준을 다시 잡는다: 바뀐 것을 셀 수 없음(cannotCalculateChanges), 엔진이 옛 기준을 거부함(Stalwart 0.16.27 은 해석하지 못하는sinceState에 HTTP 400notRequest로 요청 전체를 거부한다 — 2026-10-28 확인), 한 번에 2,000건(500 × 4) 넘게 바뀜.Email/changes의 생성은 "배달"이 아니라 "만들어짐"이다 — 받은 시각(receivedAt)이 변화를 본 때보다 10분 넘게 앞선 메일은 알리지 않는다 (메일 프로그램의 올리기 · mailcow 에서 옮길 때의 IMAP 가져오기(imapsync)가 옛 메일을 넣어도 알림이 쏟아지지 않게). 받은 시각을 읽지 못한 메일도 빼고, 엔진 시계가 앞서 미래 시각인 메일은 넣는다. 그래서 서버와 엔진 사이가 10분 넘게 끊겼다 다시 이으면, 그동안 온 메일은state로만 알린다(목록에는 나타나고 바탕화면 알림은 없다). "보낸 사람이 나"는 토큰의 주소와 발신 ID(별칭) 주소로 본다(대소문자 무시). - 오래 열린 엔진 연결은 엔진의 동시 요청 한도(4)를 잡아먹지 않는다 — 한 직원의 스트림 8개를 연 채 그 직원 · 다른 직원의 보통 요청과 동시 요청 6개가 모두 됐다(2026-10-28 확인).
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에
end). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때end없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 브라우저"). 서버가 종료될 때는 열린 연결을end로 먼저 닫는다 — 오래 열린 연결이 종료를 붙잡지 않게. 알려진 한계: 잠자기 · VPN 재연결 · 망 바꿈으로 반쯤 열린 연결은 서버가 ping 을 쓰다 실패할 때(OS 기본 15분 안팎)까지 자리를 잡는다 — 그사이 그 직원의 연결이 5개에 닿으면 새 연결은 429 이고 화면은 1분 확인으로 이어진다. - 응답 머리글:
Content-Type: text/event-stream,Cache-Control: no-cache, no-transform,X-Accel-Buffering: no. 서버는 스트림을 열기로 정하면 머리글을 바로 보낸다. 앞단 nginx 는 이 경로만 버퍼링 · 캐시 · 압축을 끄고, 읽기 사이 시간 제한을 90초로 둔다(응답 전체가 아니라 두 번 읽는 사이 — ping 25초 · 클라이언트 감시 75초보다 길게, 머리글이 오지 않으면 90초에 504). 외부 리버스 프록시도 버퍼링을 끄고, 응답 전체 시간에 상한이 있으면 1시간 넘게 둔다(운영 확인 항목 — docker 스킬). 앱 로그에는 열고 닫는 것을 남기지 않는다(반복되는 정상 흐름) — 엔진 연결이 이어서 실패하면 warn. nginx 접근 로그에는 연결이 끝날 때 한 줄이 남는다(rt=가 이어진 시간). - 클라이언트
- 브라우저마다 연결 하나 — 탭이 여러 개면 한 탭만 연결하고(Web Locks) 다른 탭에 소식을 나눈다(BroadcastChannel). 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 쓴다. 연결한 탭을 닫으면 다른 탭이 이어받는다. Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다. 연결은 로그인한 동안 앱 전체에서 유지한다(설정 등 메일 밖 화면에서도 바탕화면 알림 · 연결 상태가 이어지게). 목록 맞추기 · 1분 확인 멈춤은 메일 화면이 있을 때만 한다.
fetch로 읽는다 — 브라우저의EventSource는 401 을 알 수 없어 세션을 갱신할 수 없다. 401 이면 세션을 한 번 갱신하고 다시 잇는다(갱신도 실패하면 지금의 세션 만료 흐름).- 연결이 살아 있는 동안(75초 안에
ping·state가 오고upstream: true)은 1분 확인을 멈춘다. 75초는 요청을 시작한 때부터 센다(머리글이 오지 않는 연결도 끊게). 끊기면(읽기 오류 포함 — 서버의end가 아닌 끝) 1분 확인으로 돌아가고 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다. 연결이 자리 잡으면(첫state뒤upstream: true인 ping 이 오거나 20초가 지나면) 처음 간격부터 다시 센다. 자리 잡은 연결의end는 바로 다시 잇는다 (token-expiring은 세션을 갱신한 뒤,server-shutdown은 0~3초 흔들어 — 배포 때 모든 브라우저가 같은 순간에 붙지 않게). 그동안은 끊김으로 보지 않는다. 자리 잡기 전에 온end는 까닭과 상관없이 실패처럼 간격을 둔다 — 열리자마자 끝나는 연결(세션 끝 직전의 짧은 토큰 · 응답을 짧게 끊는 프록시)에서 갱신 · 다시 잇기를 왕복 시간 속도로 되풀이하지 않게(갱신 한도는 IP 마다라 같은 사무실 직원까지 막는다). 다시 잇는 간격의 흔들림은 ±20%. - 세션을 갱신한 바로 뒤의 다시 잇기가 또 401 이면 세션 만료로 본다(axios 의 401 재시도와 같다 — 그 시도가 다른 까닭으로 끝나면 이 표시는 지운다). 갱신이 장애(5xx · 네트워크)로 실패하면 위 간격으로 기다렸다 다시 갱신해 본다. 한 이벤트가 1MB 를 넘으면 실패로 끊는다.
- 연결한 탭이 그만둘 때(닫힘 · 로그아웃 ·
pagehide)와 잠금을 이어받은 탭이 연결하기 전에 다른 탭에 끊김을 알린다 — 이어받은 탭의 연결이 열릴 때까지 모든 탭이 1분 확인으로 돌아간다 (탭 닫기 · 탭 폐기처럼 그만두는 탭이 알리지 못한 경우도 덮는다). 살아 있던 연결이 끊기면 1분 확인이 바로 한 번 묻고, 그 뒤 60초마다 묻는다. state가 바뀌면 보이는 탭은 지금의 1분 확인이 하는 일(개수 · 보이는 목록 맞추기)을 그대로 하고, 숨은 탭은inboxUnread로 탭 제목만 맞춘 뒤 다시 보일 때 맞춘다. 실시간이 부른 맞추기는 1초 모아서 한 번 한다(한 통을 읽어도 state 가 바뀐다 — 연달은 바뀜에 목록을 여러 번 받지 않게).- 바탕화면 알림(설정 › 메일 › 알림 — 브라우저 · 사용자마다 localStorage): 켜져 있고 브라우저가 허락했을 때,
new-mail을 받았는데 웹메일을 보고 있는 탭(보이고 초점이 있는 탭 —visibilityState === 'visible' && document.hasFocus())이 하나도 없으면 알린다. 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일할 때도 알린다("다른 창을 보는 동안에도"). 한 통이면 제목 = 보낸 사람(이름, 없으면 주소) · 내용 = 메일 제목(「메일 제목도 보이기」를 끄면 "새 메일이 왔습니다"), 여러 통이면 "새 메일 N통" · "박서준 · 이도윤 외 1명". 같은 tag 로 앞 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 그 탭으로 와서 메일을 연다(여러 통이면 받은편지함). 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다. 허락은 사용자가 누른 뒤에만 묻는다(목록 위 한 번 권하기 · 설정의 켜기 칸).new Notification()을 막는 브라우저(휴대폰 Chrome)는 처음 실패할 때 "지원하지 않음"으로 바꾼다.
메일 필터 (/api/mail-filters) — 6단계
interface MailFilterSet {
rules: MailFilterRule[] // 위에서부터 차례로 적용한다 — 50개까지
version: string // 지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다(그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012). 영문 · 숫자 · - · _ 128자까지. 규칙만 본다(부재중이 바뀌어도 그대로)
isActive: boolean // false = 우리 규칙이 엔진에서 꺼져 있거나(다른 규칙이 켜짐) 저장해 둔 원본을 읽을 수 없어 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다
// (아래 "저장 자리"의 새 판 원본은 예외). 한 번도 저장하지 않은 계정은 true(막힌 것이 없다)
}
interface MailFilterRule {
id: string // 클라이언트가 만든다(UUID — 영문 · 숫자 · - · _ 64자까지) — 목록 안에서 겹치지 않게
name: string // 0~50자(앞뒤 공백은 지워 저장한다), 제어 문자는 안 된다 — 비면 화면이 조건으로 이름을 보인다
isEnabled: boolean
match: 'all' | 'any' | 'always' // 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: MailFilterActions // 하나 이상
}
interface MailFilterCondition {
field: 'from' | 'to' | 'subject' | 'body' // 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분)
operator: 'contains' | 'notContains'
value: string // 1~200자(앞뒤 공백을 지운 값 — 그 값으로 저장한다), 제어 문자 · `${` 는 안 된다
}
interface MailFilterActions {
moveToMailboxId: string | null // 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통 (보낸편지함 · 임시보관함은 400 VAL_001). 없는 id 도 받는다 — 지운 메일함을 가리키는 규칙이 남아 있을 수 있다
markAsRead: boolean
flag: boolean
forwardTo: string | null // 사내 주소(도메인이 `MAIL_DOMAIN` 과 똑같은 주소 — 하위 도메인은 안 된다)만, 자기 주소는 안 된다(대소문자 무시).
// 앞뒤 공백은 지워 저장한다. `${` 는 안 된다
}
-
PUT 본문은
{ rules, version }뿐이다 —isActive같은 다른 키를 보내면 400VAL_001(모르는 필드). 응답은 저장한 규칙(앞뒤 공백을 지운 값)과 새version이다. -
적용 순서 — 새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). 메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다 — 아래 규칙은 보지 않는다. 받은편지함으로 옮기는 규칙을 위에 두면 "늘 받은편지함에"가 된다. 전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(
redirect :copy). 스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다. 판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기). 꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 저절로 적용하지 않는다 — 저장할 때 고르면 아래 "이미 받은 메일에 적용"(7단계). -
저장 자리 — 엔진의 Sieve 스크립트 하나(
arasystem)에 필터와 부재중 자동 응답을 함께 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(vacation)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).- 4단계
/api/mail-vacation은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다. 스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의vacation스크립트는 고칠 수 없다(엔진이forbidden). - 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호
v를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).- 스크립트가 아직 없으면 빈 규칙 +
isActive: true, 부재중은 엔진의 VacationResponse 값(위 이어받기). - 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 +
isActive: false, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다. - 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 쓰기는 거절한다(503
MAIL_001) — 옛 서버가 새 판의 규칙을 지우지 않게.
- 스크립트가 아직 없으면 빈 규칙 +
- 쓸 때는 읽은 상태 그대로일 때만(
ifInState) 쓰고 켠다(onSuccessActivateScript). 그사이 바뀌면 다시 읽어 확인한다 (쓰기 3번까지 — 시도마다 읽는 사이 글이 사라지면 그 읽기도 3번까지 다시 한다. 그래도 어긋나면 503MAIL_001). 필터 저장은 다시 읽은 규칙의 지문이version과 다르면 409MAIL_012(부재중 응답만 바뀐 것은 걸리지 않는다). 부재중 저장은 규칙을 그대로 두고 부재중 부분만 바꾼다. 다만 저장해 둔 규칙이 보내는 규칙과 이미 같으면 지문이 달라도 200 으로 저장한다 — 결과를 모르는 실패 뒤에 같은 요청을 다시 보낸 경우가 409 로 끝나지 않게 (꺼져 있었으면 이때 다시 켜진다).
- 4단계
-
만드는 Sieve (엔진 확인 사실 — 2026-10-28):
- 조건:
from→header :contains "from",to→header :contains ["to", "cc"],subject→header :contains "subject",body→body :text :contains. 한국어 제목(인코딩된 머리글) · 본문 · 표시 이름 모두 맞는다. 대소문자는 ASCII 만 무시한다(엔진 기본 비교 —i;unicode-casemap은 없다). - 한 규칙은
if하나: 표시(addflag— 옮기기보다 먼저, 엔진은 옮기는 순간의 표시만 붙인다) → 전달 → 옮기기(fileinto :mailboxid "<id>" "INBOX"+stop). 옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다. - 스팸 막기는 엔진의 분류 표시로 한다 —
X-Spam-Score머리글(spam, score=9.70·ham, score=2.20)이spam으로 시작하면 아무 규칙도 · 부재중도 하지 않는다 (header :matches "X-Spam-Score" "spam*"). 엔진이 우리 스크립트를 돌리기 전에 붙인다.X-Spam-Status: Yes|No는 스크립트가 끝난 뒤 실제로 들어간 곳에 따라 붙어 쓸 수 없다 (우리 규칙이 옮긴 스팸은 "No" 였다). 엔진 설정의 기준 점수(scoreSpam— 지금 5)를 바꿔도 이 표시가 따라간다.spamtest는 쓰지 않는다 — 엔진의 값은 "스팸이면 10"이 아니라 점수를 정수로 내린 값(10에서 멈춤)이라, 10 으로 막으면 5~10점 스팸을 놓친다 (2026-10-28 실엔진: 2.20점 → 2, 9.70점 → 10 미만, 11.2점 → 10. 처음 구현은 10 으로 막아 9.70점 스팸을 규칙대로 옮기고 부재중 응답까지 보냈다).- 바깥에서 같은 이름의 머리글을 넣어 와도 엔진의 머리글이 함께 남는다(하나라도 맞으면 참). 그래서 위조로 스팸을 숨길 수는 없고, 자기 메일에 규칙이 적용되지 않게만 할 수 있다 (그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로
vacation :mime(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의${…}를 늘 변수로 펼쳐서 그대로 넣으면 지워진다(같은 날 확인). 기간은currentdate :zone "+0000"(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다). - 규칙 값에
${는 받지 않는다(400VAL_001) — 펼쳐져 빈 값이 되면:contains가 모든 메일에 맞는다. 문자열은"·\를 이스케이프한다.%{…}(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 —%{"a"}·%{}·%{1+1}·%{subject}모두 글자 그대로 견주었다(같은 날 확인). - 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400
MAIL_013.
- 조건:
-
로그 — 규칙의 글(조건 값 · 전달 주소)은 남기지 않는다(검색어처럼 직원의 내용이다). 규칙 수 · 크기 · 결과만. 엔진이 스크립트를 거절하면(
invalidScript→ 500SYS_001) 엔진 설명의 줄 · 칸만 남긴다 — 설명 문장에는 만난 문자열(규칙의 글)이 그대로 들어갈 수 있다. -
클라이언트: 설정 › 메일 › 필터(
/settings/mail/filters, 탭 제목 "메일 필터") — 메뉴는 서명 · 부재중 자동 응답 · 필터 · 알림.- 켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두
PUT전체 교체(받은version으로). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(오류 알림). 지우기는 확인 없이 하고 알림의 되돌리기로 지운 필터를 지운 자리에 다시 넣어 저장한다 — 그사이 바꾼 다른 필터(켜기 · 순서 등)는 그대로 둔다. 저장은 한 번에 하나다 — 앞 저장이 도는 동안 누른 [되돌리기]는 차례로 보낸다. 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." MAIL_012— 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 — 창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에 그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).isActive: false— 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장).- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 →
/settings/mail/filters?from=<보낸 사람 주소>— 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸). 50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때GET을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이). - 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 ·
${· 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다. 전달 주소의 앞부분은 서버(IsEmail)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게. 규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두
-
이 단계에서 하지 않는 것: 바깥 주소로 전달 — 전달한 메일은 보낸 사람(From)이 원래의 바깥 도메인인데 우리 서버가 건넨다. 엔진은 봉투 보낸 사람을 필터 주인으로 바꿔 SPF 는 통과하지만(SRS 는 없다 — 거절되면 반송은 원래 보낸 사람이 아니라 필터 주인에게 온다), 받는 쪽의 DMARC 는 From 기준이라 원래 보낸 쪽의 DKIM 서명에 기댄다. 서명이 없거나 깨진 메일은 그 도메인이 엄격하면(p=reject) Gmail 등이 거절하거나 스팸으로 보낸다 — 전달 메일의 일반적인 한계라 SRS 가 있어도 같다. 그리고 회사 메일이 개인 메일로 자동으로 빠지는 통로다 — 회사 정책으로 허용하지 않는다(2026-10-29 결정 — 전달은 사내 주소로만). 보통 발송(직원이 바깥 주소로 보내는 메일)과는 상관없다. 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
이미 받은 메일에 적용 (/api/mail-filter-runs) — 7단계
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다. 엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다.
interface MailFilterRunRequest {
match: 'all' | 'any' | 'always' // 필터 규칙과 같은 검사(MailFilterRule 의 match · conditions)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: { // 하나 이상 — 전달은 없다(이미 받은 메일은 전달하지 않는다)
moveToMailboxId: string | null // 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400). 받은편지함이거나 없는 메일함이면 옮기지 않는다
markAsRead: boolean
flag: boolean
}
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다)
}
interface MailFilterRun {
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 조건에 맞아 할 일을 한 메일 수(이미 읽음 · 중요였던 메일도 센다)
remaining: number // 아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다)
cursor: string | null // null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다
}
- 받은편지함만, 받은 시각이 최근인 메일부터 본다. 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(클라이언트는 방금 저장한 그 규칙을 보낸다) — 그래서 적용하는 동안 다른 필터를 켜고 끄거나 저장해도 멈추지 않는다.
- 한 요청은 25초 안에 끝내고
cursor를 돌려준다 — 4단계 "오래 걸리는 처리"와 같은 상한 · 같은 규칙(다음 차례는 남은 시간이 5초보다 많을 때만, 엔진 호출마다 남은 시간만큼, 진행한 것이 있으면 거기까지 200, 하나도 확인하지 못했으면 503MAIL_001). 같은 직원의 적용은 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429REQ_001(다른 탭 · PC). - 판정은 서버가 한다 — 새로 오는 메일의 Sieve(위 "메일 필터"의 "만드는 Sieve")와 같은 규칙을 따라 한다. 엔진 검색(
Email/query의 글자 조건)은 쓰지 않는다 — 낱말이 통째로 같을 때만 맞고('회의'로 '회의를' · '아라'로 '김아라'를 못 찾는다)header조건은 아무것도 찾지 못한다(Stalwart 0.16.27, 2026-10-28 확인).- 보낸 사람: From 머리글의 원래 글 — 접힌 줄(줄바꿈 + 공백 · 탭)은 띄어쓰기 하나로 펴고(접지 않은 탭 · 이어진 공백은 그대로), 인코딩된 글(RFC 2047 — 문자셋 그대로)은
홀로 선 것만 풀고(앞이 처음 · 공백 ·
"·,, 뒤가 끝 · 공백 ·"—abc=?…?=·=?…?=<a@b>·(=?…?=)는 그대로. 공백만 사이에 둔 인코딩된 글끼리는 붙인다), 앞뒤 공백을 뺀 것 — 따옴표 · 꺾쇠까지 견준다("Kim, Ara" <kim@…>에"Kim이 맞는다 — 엔진과 같다). 받는 사람: To · Cc 머리글 각각(하나라도 맞으면). 제목: 풀어 쓴 제목. - 본문: 텍스트 · HTML 부분과 텍스트 첨부(text/* — 파일로 붙인 것도. 엔진이 그렇게 본다). 한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지 본다
(글자 부분이 많아 넘으면 부분마다 같은 몫만큼 앞쪽만 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게). HTML 은 글자 부분만 — 엔진과 같게:
- 태그는
<부터 다음>까지다 —<뒤가 무엇이든 태그이고(a < b·<광고>도), 따옴표 안의>에서도 끝난다. 닫히지 않은<는 끝까지 삼킨다. 태그는 속성까지 빈자리 없이 뺀다(회<b>의</b>록→ "회의록",div·li·h1· 표 칸 · 표 줄 ·hr· 여는p사이도 이어 붙는다). 주석(<!-- … -->)은 안의 글까지 뺀다 </p>(닫는 것만) ·<br>(<br/>·<br />·</br>· 대문자도)은 줄바꿈이다 — 찾을 글(줄바꿈이 없다)은 이 자리를 건너 맞지 않는다(<p>앞</p><p>뒤</p>에 "앞뒤" · "앞 뒤" 모두 안 맞음)<style>·<script>·<head>·<template>은 안의 글까지 뺀다(<title>·<noscript>등은 글이 남는다). 그 안의</p>·<br>도 줄바꿈이다- 태그 이름 뒤에 속성이 붙으면 엔진은 그 태그를 알아보지 못한다 —
<br clear="all">은 줄바꿈이 아니고<style type="text/css">·<head lang="ko">는 안의 글이 남는다. 서버도 그렇게 본다 - 원문의 이어진 공백(띄어쓰기 · 탭 · 줄바꿈)은 태그를 건너서도 띄어쓰기 하나. 문자 참조를 푼다(이름 · 10진 · 16진 —
;로 끝나는 것만, 는 U+00A0) — 푼 글자는 줄이지 않고 그대로 넣는다(  는 띄어쓰기 둘, 은 줄바꿈)
- 태그는
- 대소문자는 ASCII 만 무시한다. "포함하지 않음"은 그 머리글 · 본문 부분이 없을 때도 참이다(엔진과 같다).
- 확인(2026-10-29): 실배달 20통 — 본문 14가지(HTML 만 · 속성 · style · 문자 참조 · 대안 두 쪽 · 텍스트 첨부 · 주석 · script · 인라인 부분 · QP · EUC-KR ·
· 블록 사이)와 머리글 6가지(따옴표 이름 · 받는 사람 여럿 · 인코딩된 참조 · 인코딩된 제목 · EUC-KR 제목), 인증 발송 25통 — HTML 글자 뽑기(인라인 태그 · 원문 줄바꿈 · 공백 겹침 · p · br · div · li · h1 · 표 · blockquote · hr · title · 숫자 참조 · 대문자 태그 등), 인증 발송 69통 — 속성이 붙은 태그 · 글을 빼는 태그(head · template 등) · 글자 그대로의<· 따옴표 안>· 참조로 쓴 공백 · 머리글의 접힌 줄 · 인코딩된 글의 경계에서 엔진과 같은 판정이 되게 맞췄다(스팸으로 분류된 1통은 판정 불가). - 알려진 한계(드묾): 첨부한 메일(.eml) 안, 256KiB · 1MiB 를 넘는 뒤쪽, HTML5 에만 있는 이름 참조(
.· 대문자&등 — 엔진은 푼다)는 엔진과 다를 수 있다. - 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다("스팸 아님"으로 되돌린 메일 포함).
- 보낸 사람: From 머리글의 원래 글 — 접힌 줄(줄바꿈 + 공백 · 탭)은 띄어쓰기 하나로 펴고(접지 않은 탭 · 이어진 공백은 그대로), 인코딩된 글(RFC 2047 — 문자셋 그대로)은
홀로 선 것만 풀고(앞이 처음 · 공백 ·
- 할 일: 읽음 · 중요 표시를 붙이고, 옮기기는 받은편지함에서 빼고 그 메일함에 넣는다(맞은 메일을 차례마다 엔진 요청 한 번으로). 확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다. 옮길 메일함이 없거나(지워짐) 받은편지함이면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다).
- 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 요청이 받은편지함의 가장 최근 받은 시각을 위 경계로 잡고(cursor 에 담는다) 그 뒤에 온 메일은 보지 않는다 (새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다).
cursor는 서버가 만든 글이다(위 경계 · 마지막으로 받은편지함에 남긴 메일과 그 받은 시각 · 규칙의 지문). 그 메일이 그사이 빠졌으면(다른 탭에서 옮김 등) 그 받은 시각의 메일부터 다시 읽는다 — 건너뛰지 않는다(그 초의 메일을 다시 판정해 맞은 수를 두 번 셀 수는 있다). 엔진은 받은 시각이 같은 메일을 늘 같은 차례로 준다(만든 차례 — 2026-10-29 확인). 다른 규칙의 cursor · 깨진 cursor 는 400VAL_001.- 엔진 한도:
Email/query는 한 번에 5,000통까지(queryMaxResults)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고 450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다. - 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만.
- 클라이언트: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다).
PUT /api/mail-filters가 성공한 뒤 저장한 그 규칙 (응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을cursor가 null 이 될 때까지 보낸다.- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 이미 받은 메일에 할 일이 없는 필터 — 읽음 · 중요 표시가 없고 옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
- 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서 "‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
- 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + 다시 시도.
429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 다시 시도도 붙인다.
(문구는 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 화면 상수
MAIL_FILTER_RUN_TEXT가 원본.) 확인 0 인데cursor가 있는 응답이 세 번 이어지면 멈춘다(같은 요청을 끝없이 보내지 않게 — 멈춤 문구와 같다). - 그동안 다른 화면으로 가도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다.
- 고치기 창의 나가기 확인(6단계 한계를 고친다): 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — "저장하지 않은 필터가 있습니다" / "창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · 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).
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일 안. 밖이면 400VAL_001.sendAt은Z로 끝나는 UTC 만 받고 소수 초는 버린다.null은 없는 것과 같다(바로 보내거나 보내기 취소 설정대로). 엔진이 그 시각을 거절하면(forbiddenMailFrom— 엔진 한도가 바뀐 경우 등) 400VAL_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}는 409MAIL_015(아무것도 바꾸지 않는다), 여러 통PATCH /api/messages는 그 id 를failedIds로,POST /api/message-deletions는skippedIds로. 읽음 · 중요 표시는 바꿀 수 있다. - 메일함 지우기 · 비우기(4단계 "메일함 관리")는 붙잡힌 메일을 그대로 두고 나머지를 처리한다 — 붙잡힌 메일은
remaining에 들고, 한 요청에서 처리한 것이 없으면(남은 것이 붙잡힌 메일뿐) 409MAIL_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-IDsig-…)은 첨부에서 빼고 본문의cid:sig-…는 서명 그림 주소로 되돌린다(보낼 때 다시cid:로 — "서명"), 답장 대상은 같은 스레드에서In-Reply-To로 찾아 초안의inReplyTo로 준다.- 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409
MAIL_014— 아무것도 바꾸지 않는다. 없는 메일은 404RES_001. - 다시 취소해도 된다(2026-10-29 리뷰 — 응답이 끊겨 다시 누른 경우): 그 메일의 제출이 모두
canceled이고 취소 표시($ara-canceled)가 있으면 어느 메일함에 있든 그대로 200{ draftId }(같은 id — 옮기지 않는다: 사용자가 그 초안을 휴지통에 버린 뒤 늦게 온 취소가 되살리지 않게), 표시가 없으면(앞의 옮기기가 실패) 되돌리기를 다시 한다 — 그래도 못 하면 503MAIL_001. - 엔진이 취소를 확인하지 않으면(응답의
updated에 그 제출이 없음 — 거절도 아님) 옮기지 않고 503MAIL_001— 옮기면 사용자는 취소된 줄 알지만 엔진은 시간이 되면 보낸다. 다시 부르면 처음부터 다시 본다. - 서명 그림 부분 빼기 ·
cid:sig-…되돌리기는 취소 표시($ara-canceled)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다). - 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503
MAIL_001— 그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
- 이미 보냈거나(엔진이 풀었음 · 보낼 시각이 지나 배달 중) 기다리는 발송이 아닌 메일(제출이 없음)이면 409
- 중복 발송 방지와 함께: 취소한 발송의
Idempotency-Key로 다시 보내면 409MAIL_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).
- 주소: 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) —
우리 도커 망(
- 화면: 본문 틀의 내부 CSP 를 보일 때만
img-src data: <화면 주소>/api/mail-images/로 넓히고(중계 경로만 —'self'면 정화를 빠져나간 CSSurl()이 같은 출처의 다른 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단계
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개를 넘으면 400VAL_001. - 저장 자리: 필터 · 부재중과 같은 Sieve 스크립트(
arasystem— 6단계)의 맨 앞. 머리 주석의 원본(JSON)에blocked(주소 · 차단한 시각)를 더하고, 원본 판은 차단한 주소가 있을 때만 2 로 쓴다(없으면 지금처럼 1). 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. 판 2 를 모르는 옛 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 · 차단 저장이 503MAIL_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"). 스팸메일함이 없으면 503MAIL_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 에서나 같다.
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 — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
3. 주소록 — 8단계 (모두 로그인 필요 — 없으면 401 AUTH_001)
직원마다의 내 주소록(연락처 · 그룹)과 모두가 함께 보는 사내 목록(직원 · 공용 주소)이다. 화면은 디자인 README "8단계"(디자인 원본 Contacts · ContactsEdit · ContactsMail).
- 내 주소록은 메일 엔진의 주소록에 둔다 — 직원마다 엔진의 기본 주소록 하나(JMAP Contacts · JSContact 카드). 서버는 게이트웨이 대리 접근으로 그 직원의 주소록만 읽고 쓴다. 같은 주소록을 나중에 휴대폰 · 메일 프로그램(CardDAV)에서도 쓸 수 있다 — 그래서 서버는 우리가 다루지 않는 칸(주소 · 생일 · 사진 등)을 고칠 때 그대로 둔다.
- 사내 목록은 엔진의 계정 목록(JMAP Principal)에서 이름 · 주소만 읽는다. 직원 역할은 계정 목록을 볼 수 없게 막아 두었으므로(1단계 setup) setup 이 만든 전용 계정으로만 읽는다(아래 "사내 목록").
타입
type ContactEmailLabel = 'work' | 'personal' | 'other' // 회사 · 개인 · 기타
type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other' // 휴대전화 · 회사 · 집 · 기타
interface ContactEmail { address: string; label: ContactEmailLabel }
interface ContactPhone { number: string; label: ContactPhoneLabel }
interface Contact {
id: string
name: string | null // 표시 이름(엔진 카드의 name.full — 없으면 이름 조각을 이어 붙인 것)
emails: ContactEmail[] // 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다
phones: ContactPhone[]
company: string | null
department: string | null
title: string | null // 직책
note: string | null // 메모 (줄바꿈 있음)
groupIds: string[] // 속한 그룹 — 이 주소록의 그룹만
}
interface ContactInput { // POST 본문(고치기는 아래 ContactPatch) — 서버는 앞뒤 공백을 빼고 저장한다(빈 글은 null)
name: string | null // 100자까지
emails: ContactEmail[] // 20개까지(화면은 5개까지 더한다 — 다른 프로그램이 넣은 것은 그대로 보이고 저장된다), 주소 254자 · 메일 주소 형식 · 같은 연락처 안에서 겹치지 않게(대소문자 무시)
phones: ContactPhone[] // 20개까지, 번호 40자 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만
company: string | null // 100자
department: string | null // 100자
title: string | null // 100자
note: string | null // 1,000자 — 줄바꿈(\n)은 되고 그 밖의 제어 문자는 안 된다
groupIds: string[] // 100개까지, 겹치지 않게
}
// 이름이나 이메일 가운데 하나는 있어야 한다(둘 다 없으면 400 VAL_001). 글자 수는 JS 문자열 길이(UTF-16) — 웹의 .length 와 같다.
type ContactPatch = Partial<ContactInput> // PATCH 본문 — 바꾼 칸만(하나 이상 — 비면 400 VAL_001). 보낸 칸만 위 규칙으로 보고, "이름이나 이메일"은 합친 결과로 본다.
// emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다. 보내지 않은 칸은 검사하지도 바꾸지도 않는다 — 아래 "고치기".
interface ContactDeleted { // 연락처 지우기의 응답
contact: Contact // 지운 연락처
restoreToken: string | null // [되돌리기] 표 — 아래 "되돌리기" (서버가 기억하지 못했으면 null — 화면은 [되돌리기] 없이 알린다)
}
interface ContactGroup {
id: string
name: string // 1~50자, 이 주소록 안에서 겹치지 않는다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다)
memberCount: number // 이 주소록에 있는 연락처 가운데 구성원 수
}
interface ContactGroupDeleted { // 그룹 지우기의 응답
id: string
name: string
memberIds: string[]
restoreToken: string | null // [되돌리기] 표 — 아래 "되돌리기"
}
type ContactRestored = // 되돌리기의 응답 — 다시 만든 것(id 는 새로 받는다)
| { kind: 'contact'; contact: Contact }
| { kind: 'group'; group: ContactGroup }
interface ContactGroupMembersAdded {
group: ContactGroup
addedCount: number // 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다)
createdCount: number // 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수
}
interface DirectoryEntry {
id: string // 엔진의 계정 id
name: string // 엔진의 표시 이름 — 없으면 주소의 @ 앞
email: string
kind: 'person' | 'shared' // 직원(개인 계정) · 공용 주소(그룹 계정)
}
엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|---|---|---|---|
GET /api/contacts |
q(선택, 1~254자) · groupId(선택 — 그 그룹의 구성원만) · excludeGroupId(선택 — 그 그룹의 구성원을 빼고. groupId 와 함께 쓰지 않는다) · cursor · limit(기본 20, 최대 100) |
200 { items: Contact[], pageInfo } — 이름 차례(아래), total 포함 |
400 VAL_001, 404 RES_001(없는 그룹), 503 MAIL_001 |
GET /api/contacts/{id} |
— | 200 Contact |
404 RES_001, 503 MAIL_001 |
POST /api/contacts |
ContactInput |
201 Contact |
400 VAL_001, 404 RES_001(없는 groupId), 409 CONTACT_002(연락처 3,000개 · 그룹 구성원 500명 · 크기), 503 MAIL_001 |
PATCH /api/contacts/{id} |
ContactPatch — 바꾼 칸만(보내지 않은 칸 · 엔진 카드의 다른 칸은 그대로 — 아래 "고치기") |
200 Contact |
400 VAL_001, 404 RES_001, 409 CONTACT_002(그룹 구성원 500명 · 크기), 503 MAIL_001 |
DELETE /api/contacts/{id} |
— | 200 ContactDeleted — 지운 연락처(그룹에서도 뺀다)와 되돌리기 표 |
404 RES_001, 503 MAIL_001 |
GET /api/contact-groups |
cursor · limit(기본 20, 최대 100) |
200 { items: ContactGroup[], pageInfo } — 이름 차례 |
503 MAIL_001 |
POST /api/contact-groups |
{ name } |
201 ContactGroup |
400 VAL_001, 409 CONTACT_001(같은 이름) · CONTACT_002(그룹 100개), 503 MAIL_001 |
PATCH /api/contact-groups/{id} |
{ name } |
200 ContactGroup |
400 VAL_001, 404 RES_001, 409 CONTACT_001, 503 MAIL_001 |
DELETE /api/contact-groups/{id} |
— | 200 ContactGroupDeleted — 연락처는 그대로 |
404 RES_001, 503 MAIL_001 |
POST /api/contact-restorations |
{ token } — 지우기 응답의 restoreToken |
201 ContactRestored — 지운 그대로 다시 만든 연락처 · 그룹(아래 "되돌리기") |
400 VAL_001, 404 RES_001(표가 없음 · 지남 · 이미 씀), 409 CONTACT_001 · CONTACT_002, 503 MAIL_001 |
POST /api/contact-groups/{id}/members |
{ contactIds?, directoryIds? } — 합쳐 1~100개 |
200 ContactGroupMembersAdded |
400 VAL_001, 404 RES_001(없는 그룹 · 연락처 · 사내 목록 id), 409 CONTACT_002, 503 MAIL_001 |
GET /api/directory-entries |
q(선택, 1~254자) · kind(선택 — person · shared) · excludeGroupId(선택 — 그 그룹 구성원의 주소를 뺀다. 구성원 넣기 창) · cursor · limit(기본 20, 최대 100) |
200 { items: DirectoryEntry[], pageInfo } — 이름 차례, total 포함 |
400 VAL_001, 404 RES_001(없는 그룹), 503 MAIL_001(사내 목록을 읽지 못함 — 아래 "사내 목록") |
GET /api/directory-entries/{id} |
— | 200 DirectoryEntry |
404 RES_001(없거나 빼는 계정), 503 MAIL_001 |
- 에러 코드(8단계에 더함):
CONTACT_001409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).CONTACT_002409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다 (사전 문구에는 앞의 세 한도가 나온다 — 크기는 보통 쓰임으로는 닿지 않는다). cursor는 불투명 문자열이고 받은 그q·groupId·excludeGroupId·kind로만 쓴다(다르면 400VAL_001). 차례 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다.- 차례: 이름(한국어 사전 차례 —
Intl.Collator('ko')), 이름이 없으면 대표 주소로 견준다. 같으면 대표 주소 → id. - 찾기(
q): 초성만 친 글(ㄱ~ㅎ, 겹자음 포함)이면 이름의 초성과 견준다(ㅂㅈㅎ → 박지훈). 그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 그 글이 들어 있으면 맞다 (ASCII 대소문자 무시). 전화번호는 숫자만 견준다(01031925540·3192가010-3192-5540에 맞는다). 사내 목록은 이름 · 주소로 같은 규칙. 엔진의 주소록 검색은 낱말 단위이고 이름 정렬을 하지 못해서(2026-10-29 확인 — '김아라'로 못 찾고name정렬은 unsupportedSort) 서버가 주소록 전체를 읽어 찾고 정렬한다. - 엔진에 두는 모양(서버 책임): 연락처는 JSContact
kind: individual, 그룹은kind: group(구성원은members— 카드의uid로 잇는다). 만들 때 서버가uid(urn:uuid:…)를 넣는다 — 넣지 않으면 엔진이uid를 주지 않아 그룹에 이을 수 없다(2026-10-29 확인). 엔진은 만든 뒤uid를 바꾸지 못하게 한다("You cannot change the UID of a contact.") — 그래서 다른 프로그램이uid없이 만든 카드는 그룹에 넣을 수 없다:PATCH의groupIds에 새 그룹을 넣으면 400VAL_001, 구성원 넣기에서는 건너뛰고 세지 않는다(CardDAV 프로그램은 늘 UID 를 넣는다 — 드물다). 그룹 구성원을 바꿀 때는members를 통째로 쓴다(엔진이 구성원 없는 그룹의members/<uid>패치를 받지 않는다). 칸:name.full·emails(종류는contexts— work · private, 대표 주소는pref: 1) ·phones(휴대전화는features.mobile, 회사 · 집은contextswork · private) ·organizations(이름 ·units첫째가 부서) ·titles(kind: title) ·notes. 고치기는 이 칸만 패치한다 — 다른 칸(주소 · 생일 · 사진 · 다른 프로그램의 칸)은 그대로 둔다. 칸 안에서도 우리가 정하지 않는 것은 그대로 둔다(아래 "고치기"). 엔진에 주소록이 여럿이면 기본 주소록(isDefault)만 쓴다. 그룹 구성원 수는 이 주소록에 있는 연락처만 센다(지워진 카드를 가리키는uid는 세지 않는다). - 캐시: 주소록 전체 읽기는 300장에 0.4초 남짓이라(개발 엔진) 서버는 직원마다 주소록을 엔진의 상태(
state)와 함께 기억하고, 요청마다 상태만 물어(ContactCard/get의 빈 ids — 3ms) 바뀌지 않았으면 다시 읽지 않는다 (서버 메모리 — 200명 · 모두 합쳐 2,400만, 한 사람 800만까지(아래 "크기"와 같은 잣대) — "크기" 안의 주소록은 늘 통째로 기억할 수 있다. 그보다 큰 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않고 요청마다 다시 읽는다. 30분 쓰지 않으면 잊는다. 다른 프로그램에서 바꾼 것도 상태가 바뀌어 곧 보인다 — 그때는 주소록 전체를 다시 읽는다, 3,000장에 3초 남짓). 엔진에서 한 번에 읽는 상한은 카드 5,000장 · 계정 10,000개다(넘으면 앞쪽만 — warn 로그). - 쓰기: 같은 직원의 바꾸기는 차례로 한다. 엔진에는 읽은 상태 그대로일 때만 쓰고(
ifInState), 그사이 바뀌면 다시 읽어 다시 정한다 — 세 번 어긋나면 503MAIL_001. 한도 · 같은 이름 검사도 같은 판으로 본다. 엔진에는 바뀐 칸만 보낸다(회사 · 부서는 한 조직이라 하나만 바뀌어도 함께). 엔진이serverUnavailable로 거절하면(메서드 오류든 항목 오류든) 판을 다시 읽어 다시 한다(위 세 번 안에서 — 일부만 들어갔을 수 있어 만들기 · 되돌리기는 같은 uid 로 이미 들어간 카드를 찾는다). ⚠️ 엔진은 한 요청의destroy에 같은 id 가 두 번 있으면 요청 전체를serverUnavailable(store.assert-value-failed)로 거절한다(2026-10-29 확인 — 몇 번을 다시 해도 같다) — 서버는 한 요청에 같은 카드를 두 번 넣지 않는다. 판은 읽기 전에 물은 것으로 잡는다 — 읽는 사이 다른 곳에서 만든 카드를 빠뜨린 채 "최신"으로 기억하지 않게. - 크기: 서버가 재는 주소록 크기(연락처 · 그룹에서 우리가 다루는 칸의 글자 수 + 항목마다 조금 — 위 캐시가 쓰는 잣대)를 800만 넘게 하는 쓰기
(만들기 · 고치기 · 구성원 넣기 · 되돌리기)는 409
CONTACT_002다 — 줄이는 쓰기와 지우기는 늘 된다. 연락처 3,000개에 그룹 100개를 구성원 500명씩 다 채워도 연락처마다 1,000자 넘게 남아 보통 쓰임으로는 닿지 않는다. 캐시가 주소록 하나를 통째로 기억할 수 있게 둔 한도다 (2026-10-29 리뷰: 칸마다 최대인 연락처 3,000장은 2,400만이라 기억하지 못하고, 요청마다 엔진에서 다시 읽어 1초씩 멈췄다). - 고치기(
PATCH): 화면은 창을 열 때 그 연락처를 새로 받고(GET /api/contacts/{id}), 연 때와 달라진 칸만 보낸다 — 창을 연 사이 다른 탭 · 휴대폰에서 바꾼 다른 칸을 덮지 않는다. 같은 칸을 양쪽에서 바꿨으면 나중에 저장한 쪽이 이긴다(판을 묻지 않는다). 보내지 않은 칸은 검사하지 않는다 — 다른 프로그램이 우리 규칙 밖의 값(21번째 주소 · "내선"이 든 번호 · 1,000자 넘는 메모)을 넣었어도 다른 칸은 고칠 수 있다(그 칸을 고칠 때는 그 값도 규칙에 맞춰야 한다). 칸 안의 남의 정보도 지킨다 — 서버는 보낸 주소를 지금 카드의 같은 주소(대소문자 무시)와, 번호는 보인 글이 같은 것 먼저 · 그다음 숫자가 같은 것과 짝지어 그 항목의 다른 속성(label· 다른contexts·fax같은features·pref)을 남기고, 종류를 바꾼 것만 우리 값(contextswork · private,features.mobile)으로 고친다. 대표 주소(pref: 1)는 첫 주소에만 둔다 — 다른 항목의pref는 남기되 1 이거나 읽는 차례(pref→ 넣은 차례)를 보낸 차례와 다르게 만들면 뺀다. 직책은role이 아닌 첫 항목만 고치고 지우면 그 항목만 뺀다(다른 직책 항목이 있으면 그것이 보인다). 회사 · 부서는 첫 조직의 이름과 첫units만 고친다 (다른 조직 · 다른 부서는 그대로 — 둘 다 비워 그 조직에 남는 것이 없으면 조직째 뺀다). 메모는 하나로 쓰고(여러 개였으면 이어 보인 글을 고친 것이라) 첫 항목의 다른 속성은 남긴다. 이름을 고치면{ full }로 쓴다 — 이름 조각(name.components)과 거기 딸린 칸은 지운다(옛 조각이 다른 프로그램에 옛 이름으로 보이지 않게). 엔진은 받은 값을 조금 손봐 저장한다(객체 안 키 차례 ·title을role앞에 ·role에organizationId· 주소에isOrdered— 2026-10-29 확인) — 응답과 캐시는 서버가 보낸 카드를 다시 읽은 모양이다. - 응답:
GET응답에는Cache-Control: private, no-store를 붙인다. 그룹 목록의pageInfo.total은 그룹 수다. - 지우기: 연락처를 지우면 그 연락처를 가리키는 그룹 구성원도 함께 뺀다. 영구 삭제다(엔진 주소록에는 휴지통이 없다) — 화면은 확인 없이 지우고 10초 [되돌리기]로 다시 만든다.
- 되돌리기(
POST /api/contact-restorations): 서버는 지우기 직전에 엔진 카드 전체(다른 프로그램의 칸 ·uid포함)와 그때 속한 그룹(연락처) · 구성원(그룹)을 읽어 직원마다 5분 기억하고(서버 메모리 — 직원마다 최근 20개 · 모두 합쳐 32MB, 카드 하나가 256KB 를 넘으면 기억하지 않는다 — 표null) 지우기 응답에 표를 준다. 되돌리기는 그 원본을 그대로 다시 만든다 — 같은uid(그사이 이 주소록의 다른 카드가 그uid를 쓰면 새uid— 엔진은 같은 주소록 안의 같은uid를 거절한다, 다른 주소록이면 받는다), 우리 규칙 밖의 값도 그대로.uid가 없던 원본은 새uid로 만든다(되돌린 뒤에는 그룹에 넣을 수 있다). 엔진이 다시 만들기를 받지 않으면 503MAIL_001이다(드묾 — 엔진은 공급자 전용 칸(example.com:custom꼴)이 든 카드를 만들 때 거절한다). 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 500명이 찬 그룹은 건너뛴다), 그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다(같은 이름의 그룹이 그사이 생겼으면 409CONTACT_001, 그룹 100개 · 연락처 3,000개 · 크기는 409CONTACT_002). id 는 새로 받는다. 표는 한 번만 쓴다(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). 표가 없거나 지났거나 이미 썼거나 다른 직원의 것이면 404RES_001(서버를 다시 띄우면 표는 사라진다). ⚠️ 서버를 여러 대로 늘리면 표도 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404). - 로그: 연락처의 이름 · 주소 · 전화번호 · 메모는 남기지 않는다(개수 · id 만).
q는 요청 URL 로그에서 길이만 남는다(maskUrlForLog). 되돌리기 표도 남기지 않는다. - 알려진 한계 · CardDAV 를 열기 전에 할 일: 지금은 공개 443 이 잠겨(1단계 setup) 엔진 주소록에 쓰는 길이 우리 API 뿐이다. 휴대폰 · 메일 프로그램(CardDAV)에 열기 전에 엔진 응답에 바이트 상한을 두고(지금은 카드 수 5,000장만 막는다 — 사진이 든 카드 500장 한 쪽이 수백 MB 일 수 있다) 위 "크기"를 넘는 주소록의 읽기 비용을 다시 본다.
사내 목록 (/api/directory-entries)
- 엔진의 계정 목록 — 개인 계정은 직원(
person), 그룹 계정은 공용 주소(shared). 이름은 엔진의 표시 이름이다(계정의 설명 — 관리 화면에서 넣는 이름). - 빼는 계정: 엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정(
MAIL_BLOCKED_ACCOUNTS) · setup 이 만든 계정(설명에[setup.mjs]) — 웹메일 로그인의 정적 목록과 같다. 로그인 때 엔진에 묻는 관리 권한 확인은 여기에 없다(엔진의 계정 목록에는 역할이 없다) — 엔진 관리자 아이디 밖의 다른 관리자 계정은MAIL_BLOCKED_ACCOUNTS에 넣어야 빠진다. 개인 · 그룹이 아닌 계정(자원 등)도 뺀다. 주소가MAIL_DOMAIN이 아닌 계정과 주소가 없는 계정도 뺀다. - 읽는 길: setup 이 만든 사내 목록 계정(
<MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN>— 기본mail-directory). 그 계정의 역할에는 로그인과 계정 목록 읽기 (authenticate·jmapPrincipalGet·jmapPrincipalQuery)만 있고 비밀번호가 없다 — 서버만 게이트웨이 대리 접근으로 쓴다. 엔진이 주는 칸은 id · 종류 · 로그인 이름 · 표시 이름 · 주소뿐이다 (비밀번호 같은 칸은 없다 — 2026-10-29 확인). 이 권한은 엔진 관리자가 내주지도 빼지도 못해 setup 이 복구 관리자로 실행될 때 만든다(개발은 늘, 운영은 첫 배포의--recovery— mail/README.md "사내 목록 계정"). 이 계정은 메일을 받지 않는다 — 그 주소(+태그 꼴 포함)로 오는 메일은 받는 쪽 RCPT 단계에서 없는 주소처럼 550 이다(setup 이 관리자 · 게이트웨이와 함께 맞춘다 — 받아 놓고 보낸 사람에게 반송하지 않게, mail/README.md "보안"). 그 뒤의 막으로 역할에 메일 받기 권한이 없고 저장 한도도 1MiB 다. - 파일럿 공존 동안(10단계)에는 새 서버의 계정 — 파일럿 사용자 — 만 나온다. 목록을 읽을 수는 있으므로 "로그인한 직원 기록" 대신 쓰기는 켜지지 않는다 — 동료는 내 주소록 · 최근 주고받은 주소(옛 메일을 옮기면 채워진다)로만 추천에 나온다. 파일럿 동안 새 서버에 동료 계정을 만들지 않는다(만들면 그 사람에게 가는 메일이 새 서버에 갇힌다).
- 서버는 목록을 10분 기억한다(모든 직원이 함께 — 서버 메모리). 사내 목록 계정이 없거나 읽지 못하면 503
MAIL_001이고, 받는 사람 추천의 "사내"는 전처럼 로그인한 직원 기록으로 대신한다. - 한 항목(
GET /api/directory-entries/{id})은 그 기억한 목록에서 찾는다 — 화면이 주소로 연 사내 항목.excludeGroupId는 그 직원 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다.
메일과 함께 (2절과 이어지는 것)
- 받는 사람 추천(2절
/api/recipient-suggestions)에 내 주소록을 더한다 —source: 'contact'(주소마다 한 줄, 대표 주소가 먼저. 나와 시스템 계정(엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정)과 주소 모양(아이디@도메인)이 아닌 것만 빼고, noreply 류도 직원이 넣은 것이라 그대로 둔다). 순서는 주소록 → 사내 → 최근 주고받은 주소이고, 여러 묶음에 있는 주소는 앞 묶음에 한 번만 나온다. "사내"는 사내 목록(위)의 직원 · 공용 주소다 — 사내 목록을 읽지 못하면 전처럼 로그인한 직원 기록(최근 90일). 주소록 묶음은 추천 캐시(10분)에 넣지 않고 요청마다 그 직원의 주소록(위의 상태로 확인한 캐시)에서 붙인다 — 연락처를 바꾸면 다음 추천에 바로 보인다. 주소록을 읽지 못하면 그 묶음만 빼고 답한다(warn 로그). - 그룹은 추천 응답에 넣지 않는다 — 클라이언트가 쓰기 화면을 열 때
GET /api/contact-groups를 한 번 받아 그룹 이름으로 찾고 "주소록" 다음 묶음으로 보인다. 그룹을 고르면GET /api/contacts?groupId=…&limit=100으로 구성원을 받아(100명 넘으면 다음 쪽까지) 대표 주소를 받는 사람에 넣는다 — 이메일이 없는 구성원 · 이미 넣은 주소는 뺀다. - 쓰기 화면을 받는 사람을 채워 연다(연락처 · 그룹 · 사내의 [메일 쓰기]): 주소는 화면 주소(URL)에 싣지 않고 라우터 상태로 넘긴다 — 브라우저 기록 · 서버 로그에 남지 않게. 상태에는 연 직원의 주소를 함께 싣고, 지금 로그인한 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기로 돌아온 경우 — 2026-10-29 리뷰).
- 읽기 화면 ⋯: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
판단은 메뉴를 열 때
GET /api/contacts?q=<보낸 사람 주소>로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
4. 대용량 첨부 — 11단계 (2026-10-30 확정)
2026-10-30 에 확정한 제품 · 기술 결정(사용자 "추천대로")과 디자인 보드를 그리며 정한 것(같은 날 — 이쪽이 앞선다)을 계약으로 옮겼고, 같은 날 사용자가 캔버스와 함께 확정했다(아래 "정한 것"의 추천도 그대로). 백엔드 · 프론트가 이 절로 함께 만들고, 구현하며 바뀌면 이 절부터 고친다. "구현하며 정함"은 구현 때 값을 정해 여기에 적을 곳이다. 화면 모양 · 글은 디자인 README "11단계"가 정한다 — 이 절은 데이터와 규칙만 다룬다.
일반 첨부 한도(파일 20MB · 합계 25MB — 2절 "첨부 업로드 한도")를 넘는 파일은 메일 엔진이 아니라 우리 서버에 올려 두고, 메일 본문에는 내려받기 링크를 넣는다(이하 "덩이").
받는 사람은 로그인 없이 공개 화면 <SHARE_PUBLIC_URL>/s/{token} 에서 받는다. 보관은 보낸 날(한국)부터 30일째 날 끝까지다.
- 엔진에 두지 않는다 — 엔진(Stalwart 0.16.27)은 올리기 본문을 통째로 메모리에 받고(한 번 50,000,000바이트), 파일이 그 직원의 메일함 용량 · 메일 백업과 묶이며, 로그인 없는 링크가 없다(2026-10-30 개발 엔진 · 소스 확인).
파일은 백엔드의 볼륨(
<PROJECT_NAME>_files→/app/files), 기록은 PostgreSQL 이다. 새 의존성은 없다(백엔드 · 프론트 모두). - 개념 셋 — 파일(내용 · 주인 · 이름 · 크기 · 검사 결과), 공유 링크(메일 한 통의 파일 묶음 하나 — 토큰 · 끝나는 때 · 끊김), 쓰임(임시 저장본 · 보낸 메일). 2부 파일 저장소가 같은 파일 · 링크 · 올리기 API 를 쓴다.
- 링크는 메일 한 통에 하나다 — 그 메일에 새로 실은 내 파일의 묶음이다. 보낸 사본과 받는 사람의 사본은 같은 메일이라(어댑터가 메일 하나를 만들어 같은 요청에서 제출한다 —
jmap-mail.provider.ts:2042-2075) 받는 사람별 링크 · 기록은 없고, 끊기도 링크(메일) 단위다. - 답장 · 전달의 인용 속 원래 링크는 글(링크)로 그대로 간다 — 서버가 다시 싣거나 우리 링크로 바꾸지 않는다. 전달하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(전달하는 사람 것)다.
- 공개 경로는 로그인이 없다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다(머리말의 "웹 전용" · 출처 검사는 로그인한 API 의 규칙이다). 모바일 앱은 이 단계 범위 밖이다.
공유 계약을 바꾸는 곳 (한데 모음)
| 곳 | 바뀌는 것 | 호환 |
|---|---|---|
발송 POST /api/messages 본문 (2절) |
largeFileIds · bodyQuoteStart 추가, 서식 본문의 인용 경계 표시 <div data-ara-quote-start></div> |
필드 추가 — 쓰지 않으면 지금과 같다(지문도 같다) |
| 발송 실패 | 400 MAIL_016 추가. MAIL_007 은 덩이를 넣은 뒤에도 잰다 |
새 코드 |
임시 저장 POST·PUT /api/drafts 본문 · GET /api/drafts/{id} 응답 (2절) |
같은 두 필드(올리는 중인 파일도 된다), 초안 머리글 X-Ara-Large-Files, Draft.largeFiles, 실패 MAIL_016 |
필드 추가 |
읽기 GET /api/messages/{id} (2절) |
MessageDetail.largeFiles(링크 주인이 그 메일의 보낸 사람일 때만), htmlBody 안 그 덩이의 바깥 요소에 data-ara-large-files |
필드 · 속성 추가 — 지금 클라이언트는 data-* 를 지우고 덩이를 보통 본문으로 그린다 |
받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (2절 표에 한 줄) |
새 경로 — 우리 웹메일에서 대용량 첨부 받기 | 새 경로 |
실시간 알림 GET /api/mail-events (2절) |
large-file 이벤트(위험 판정) |
지금 클라이언트는 모르는 이벤트를 버린다(frontend/src/shared/utils/mail-events.ts:97-110) |
| 이 절의 새 API | /api/file-uploads · /api/file-quota · /api/share-links · /api/share-link-lookups · /api/public-links, 화면 /s/{token} |
새 경로 — /api/files 는 템플릿 업로드 예제가 쓰고 있어 피했다 |
| 에러 코드 | FILE_003 ~ FILE_008 · MAIL_016 (아래 "에러 코드") |
레지스트리에 더하고 npm run sync:error-codes |
한도
| 한도 | 값(기본) | 넘으면 | 바꾸는 곳 |
|---|---|---|---|
| 한 파일 | 2,047MB (2,146,435,072바이트 — ClamAV 가 검사할 수 있는 2GiB 아래) | 413 FILE_002(만들 때) |
MAIL_LARGE_FILE_MAX_SIZE_MB |
| 형식 | 일반 첨부 허용 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z — 실행 형식(exe · msi · bat · js · vbs · scr 등) · 디스크 이미지(iso · img · vhd)를 비롯해 그 밖은 받지 않는다 | 400 FILE_001 |
상수 — 백엔드 정책과 프론트 상수가 짝(한쪽을 고치면 둘 다) |
| 메일 한 통 | 대용량 파일 10개 — 그 메일에 새로 싣는 내 파일(인용 속 원래 링크는 세지 않는다). 일반 첨부(20개)와 따로 센다 | 400 VAL_001 |
MAIL_LARGE_FILE_MAX_COUNT |
| 직원당 보관량 | 10GB (10,737,418,240바이트) — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 | 409 FILE_005(만들 때) |
MAIL_LARGE_FILE_USER_QUOTA_GB |
| 직원당 보내지 않은 파일 수 | 50개(올리는 중 포함) | 409 FILE_005 |
상수 |
| 서버 총량 | 없음(비움) — 모든 직원의 보관량 합 | 507 FILE_006(만들 때) |
FILE_STORAGE_MAX_TOTAL_GB |
| 디스크 여유 | 만든 뒤에도 10GB 가 남아야 한다(진행 중인 올리기의 아직 받지 않은 바이트를 빼고) | 507 FILE_006(만들 때 · 쓰다 ENOSPC) |
FILE_STORAGE_MIN_FREE_GB |
| 조각 | 16MiB (16,777,216바이트) 고정 — 2,047MB 면 128개 | 400 VAL_001(길이) · 413 FILE_002(앞단 17MB) |
계약 상수 |
| 내려받기 | 파일당 500회(받기 시작한 수 — 아래 "파일 응답"의 횟수) | 410 FILE_007 |
SHARE_DOWNLOAD_LIMIT |
| 보관 | 보낸 날(한국)부터 30일째 날 끝 | 410 FILE_007 |
MAIL_LARGE_FILE_RETENTION_DAYS |
- MB · GB 는 1,048,576 · 1,073,741,824바이트다(일반 첨부 한도와 같은 셈 —
mail.settings.ts:74). 한 파일 한도는 env 로 2,047MB 를 넘길 수 없다(검사기 한도 — 아래 "악성 검사"). - 보관량 · 총량 · 여유는 만들 때 선언한 크기로 미리 잡는다 — 조각을 보내는 동안에는 한도를 다시 보지 않는다. 같은 직원의 만들기는 하나씩 처리한다(동시에 둘이 한도를 넘지 않게).
구현(2026-10-30): 만들기는 서버 전체에서 하나씩이다 — 서버 총량 · 디스크 여유를 함께 보므로 DB 의 트랜잭션 잠금(
pg_advisory_xact_lock) 안에서 세고 만든다(인스턴스를 늘려도 그대로 맞다). - 기간이 지나거나 링크를 끊거나 보내지 않은 파일을 지우면 보관량은 정리 작업을 기다리지 않고 바로 줄어든다(요청 때 상태로 계산한다).
타입
type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable' // 검사 중 · 깨끗함 · 위험 · 검사하지 못함
/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */
interface LargeFile {
id: string // 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) — 발송 · 임시 저장의 largeFileIds 에 쓴다
name: string // 서버가 정리한 이름 — 일반 첨부와 같은 정리(경로 · 제어 · 방향 제어 문자를 지우고 255자, 확장자는 남긴다 — attachment.policy.ts:220-239)
size: number // 바이트
status: 'uploading' | 'ready' // 올리는 중 · 다 올림
chunkSize: number // 16,777,216 — 마지막 조각만 짧다
chunkCount: number // ceil(size / chunkSize)
receivedChunks: number[] // 받은 조각 번호(0 부터, 오름차순) — ready 면 모두
firstChunkSha256: string | null // 받은 맨 앞 조각(0번)의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다
scanStatus: ScanStatus | null // ready 일 때만 (uploading 은 null)
createdAt: string
expiresAt: string // 이대로 두면 지워지는 때 — 아래 "기간 · 보관 · 정리"
lastModified: number | null // 만들 때 받은 File.lastModified (ms)
}
/** 임시 저장본이 가리키는데 이제 없는 파일 */
interface MissingLargeFile {
id: string
status: 'missing'
reason: 'deleted' | 'expired' // 지운 파일(설정 · 쓰기 화면에서 지움) · 기간이 지남(올리다 멈춘 지 24시간 · 임시 저장 30일 등)
name: string | null // 기록이 남아 있으면(지운 뒤 30일) 이름 · 크기
size: number | null
}
/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */
type SharedFileStatus =
| 'available' // 받을 수 있다 (검사에서 깨끗함)
| 'unscannable' // 받을 수 있다 — "검사하지 못함" (암호 압축 · 검사 한도 밖 · 검사기를 쓰지 않는 개발)
| 'scanning' // 검사 중 — 끝나면 받는다 (그동안 받기는 409 FILE_008)
| 'infected' // 위험 — 막았다
| 'limit-reached' // 이 파일의 내려받기 상한에 닿았다
| 'revoked' // 보낸 사람(또는 운영)이 링크를 끊었다
| 'expired' // 기간이 지났다
| 'missing' // 파일을 찾을 수 없다 (저장소 복원 등 — 드묾)
/** 메일 속 대용량 첨부 하나 — MessageDetail.largeFiles */
interface MessageLargeFile {
linkId: string
fileId: string
name: string // 서버 기록의 이름 — 메일 글이 아니다
size: number
status: SharedFileStatus
expiresAt: string // 링크가 끝나는 때 (아래 "기간 · 보관 · 정리")
revokedAt: string | null // 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null (2026-10-30 더함 — 화면의 "…에 끊었습니다")
url: string // 공개 화면 주소 `<SHARE_PUBLIC_URL>/s/<token>` — 본문 덩이의 그 주소. 같은 링크의 파일은 같은 주소
downloads: { // 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null
count: number // 받은 수 (받기 시작한 수)
lastDownloadedAt: string | null
} | null
}
/** GET /api/file-quota */
interface FileQuota {
usedBytes: number // 지금 잡고 있는 양 — 위 "한도"의 보관량
limitBytes: number // 직원당 보관량 한도
maxFileBytes: number // 한 파일
maxFilesPerMessage: number // 메일 한 통에 새로 싣는 대용량 파일 수
chunkSize: number // 16,777,216 — 올리기 응답의 chunkSize 와 같다
retentionDays: number // 보낸 날부터 받을 수 있는 날 수 (30)
}
/** GET /api/share-links 의 항목 — 내가 만든 링크 (보낸 메일 한 통) */
interface ShareLink {
id: string
sentEmailId: string | null // 보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null (아래 "링크를 만드는 때")
subject: string | null // 보낸 메일 제목 — 엔진에서 읽는다 (지웠거나 읽지 못하면 null)
sentAt: string // 보낸 때 (예약은 보낼 시각)
expiresAt: string
status: 'active' | 'expired' | 'revoked' // revoked = 보낸 사람(또는 운영)이 끊었다
revokedAt: string | null // 끊은 때 — revoked 일 때만 값이 있다 (2026-10-30 더함 — 설정의 "10월 22일에 끊음")
files: SharedFile[] // 링크의 파일 (보낸 차례)
}
interface SharedFile {
id: string
name: string
size: number
status: SharedFileStatus
downloadCount: number
lastDownloadedAt: string | null
}
/** POST /api/share-link-lookups — 인용 속 링크의 상태 (누구의 링크든 상태 · 끝나는 날만) */
interface ShareLinkLookupRequest {
urls: string[] // 1~20개, 겹치지 않게 — 본문(인용)에서 찾은 공개 화면 주소, 한 개 300자까지
}
interface ShareLinkState {
url: string // 보낸 그대로
status: 'active' | 'expired' | 'revoked' | 'unknown' // unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크
expiresAt: string | null // unknown 이면 null
}
/** GET /api/public-links/{token} — 로그인 없음 */
interface PublicLink {
status: 'active' | 'expired' | 'revoked'
sender: { name: string | null; email: string } // 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다
sentAt: string
expiresAt: string
files: PublicLinkFile[] // active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다)
reportAddress: string // 신고 · 문의 주소 (SHARE_REPORT_ADDRESS)
}
interface PublicLinkFile {
id: string
name: string
size: number
status: Exclude<SharedFileStatus, 'revoked' | 'expired'>
}
엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|---|---|---|---|
POST /api/file-uploads |
FileUploadCreate (아래 "올리기") |
201 LargeFile |
400 VAL_001 · FILE_001, 409 FILE_005, 413 FILE_002, 429 REQ_001, 507 FILE_006 |
GET /api/file-uploads |
cursor · limit(기본 20, 최대 100) |
200 { items: LargeFile[], pageInfo } — 보내지 않은 내 파일(살아 있는 링크에 실리지 않은 것 — 올리는 중 포함), 만든 때 최근부터, total 포함 |
400 VAL_001 |
GET /api/file-uploads/{id} |
— | 200 LargeFile |
404 RES_001 |
PUT /api/file-uploads/{id}/chunks/{index} |
조각 바이트 (application/octet-stream) |
200 LargeFile |
400 VAL_001 · FILE_004, 404 RES_001, 409 FILE_003, 413 FILE_002, 429 REQ_001, 507 FILE_006 |
POST /api/file-uploads/{id}/completion |
— | 200 LargeFile (ready) |
404 RES_001, 409 FILE_003(빠진 조각 · 진행 중) |
DELETE /api/file-uploads/{id} |
— | 204 — 올리기를 그만두거나 보내지 않은 파일을 지운다 | 404 RES_001, 409 FILE_003(살아 있는 링크에 실린 파일) |
GET /api/file-quota |
— | 200 FileQuota |
— |
GET /api/share-links |
cursor · limit(기본 20, 최대 100) |
200 { items: ShareLink[], pageInfo } — 보낸 때 최근부터, total 포함 |
400 VAL_001 |
POST /api/share-links/{id}/revocation |
— | 200 ShareLink — 그 링크(메일)를 끊는다 |
404 RES_001 |
POST /api/share-link-lookups |
ShareLinkLookupRequest |
200 { links: ShareLinkState[] } — 요청 차례 그대로 |
400 VAL_001 |
GET /api/public-links/{token} |
— (로그인 없음) | 200 PublicLink |
404 RES_001, 429 REQ_001 |
GET·HEAD /api/public-links/{token}/files/{fileId} |
Range(선택) — 로그인 없음 |
파일 200 · 206 · 416 (표준 래퍼 없음) | 404 RES_001, 409 FILE_008, 410 FILE_007, 429 REQ_001 |
GET·HEAD /api/messages/{id}/large-files/{fileId} |
Range(선택) |
파일 200 · 206 · 416 (표준 래퍼 없음) | 404 RES_001, 409 FILE_008, 410 FILE_007, 503 MAIL_001 |
/api/public-links밖은 모두 로그인 필요(쿠키 — 없으면 401AUTH_001)이고, 상태를 바꾸는 요청은 출처 검사를 받는다(머리말).- 경로의 id 는 UUID 모양이 아니면 404
RES_001. 남의 것 · 지운 것 · 없는 것을 가리지 않고 404 다. - 공개 경로 밖의 응답은
Cache-Control: private, no-store.
올리기 (/api/file-uploads)
interface FileUploadCreate {
name: string // 1~255자 (JS 문자열 길이) — 서버가 정리한 이름(응답의 name)으로 둔다
size: number // 바이트 — 1 이상 · 한 파일 한도 이하의 정수
purpose: 'mail' // 쓰임 — 11단계는 'mail' 만 (2부 파일 저장소가 더한다)
lastModified?: number | null // 선택 — 브라우저 File.lastModified (ms, 0 이상 정수)
}
- 만들기
POST /api/file-uploads— 형식은 정리한 이름의 확장자로만 본다(밖이면 400FILE_001). 브라우저가 주는 형식(MIME)은 받지 않는다 — 이 경로에서는 믿을 근거가 없고, 내려줄 때 늘octet-stream+attachment+nosniff+ sandbox 이며 내용은 악성 검사가 본다(보안 규칙 "확장자와 MIME 둘 다"의 예외 — 열린 질문). 그다음 크기(413FILE_002) → 직원 한도(409FILE_005) → 서버 총량 · 디스크 여유(507FILE_006) 차례로 본다. 201LargeFile(uploading,receivedChunks: []). - 조각
PUT /api/file-uploads/{id}/chunks/{index}—index는 0 ~chunkCount − 1, 본문은 그 조각의 바이트 그대로.Content-Type: application/octet-stream만 받는다(JSON 이면 본문 파서의 1MB 한도에 먼저 걸려 413, 그 밖은 400VAL_001). 조각은 JSON 본문 파서 · 모양 한도를 지나지 않고(두 파서 모두application/json만 읽는다 —app.setup.ts:164-170, 208-220) 처리기가 요청 스트림을 직접 읽는다.Content-Length필수 =chunkSize(마지막 조각만size − chunkSize × (chunkCount − 1)). 없거나(청크 전송) 다르면 읽기 전에 400VAL_001. 압축(Content-Encoding)도 400VAL_001.- 선택
Content-Digest: sha-256=:<base64>:(RFC 9530) — 웹은 늘 보낸다. 받은 바이트와 다르면 400FILE_004(그 조각을 받지 않았다 — 같은 조각을 다시 보낸다). sha-256 이 없으면(다른 알고리즘만) 없는 것으로 본다. sha-256 값의 모양이 틀리면(base64 로 32바이트가 아니면) 읽기 전에 400VAL_001(구현 — 2026-10-30). - 조각 번호는 앞자리 0 없는 10진수(0 ~ 999,999)다 —
01·+1·1·1.0·1e1·0x1·0b1등은 400VAL_001(숫자로 바꿔 보지 않는다 — 2026-10-30 리뷰). 본문을 다 받지 못했으면(길이가 다름 · 클라이언트가 끊음) 400VAL_001, 디스크 등 그 밖의 오류는 500SYS_001(error 로그). - 다 받아 디스크에 쓰고(
datasync) 나서야 받은 것으로 적는다 — 중간에 끊기면 적지 않는다. 서버는 조각마다 SHA-256 을 계산해 둔다(맨 앞 조각의 값이firstChunkSha256). - 다시 보내기: 받지 않은 조각은 쓴다. 이미 받은 조각은 쓰지 않고 내용을 견준다 — 같으면 200 그대로, 다르면 409
FILE_003(아무것도 바꾸지 않는다). 같은 조각을 동시에 보내면 뒤의 것, 이미 끝낸 올리기에 보내면 409FILE_003. - 차례는 자유다(빠진 조각만 다시). 웹은 파일마다 한 번에 한 조각.
- 디스크가 차면(
ENOSPC) 507FILE_006(error 로그) — 그 조각은 받지 않았다. - 200
LargeFile(받은 조각 반영).
- 끝내기
POST /api/file-uploads/{id}/completion(본문 없음) — 조각이 모두 있으면 파일로 만들고(ready) 곧바로 악성 검사 차례에 넣는다 → 200LargeFile(scanStatus: 'pending'— 검사기를 쓰지 않으면'unscannable'). 빠진 조각이 있으면 409FILE_003(클라이언트는GET으로 빠진 번호를 보고 보낸다), 끝내기가 진행 중이어도 409FILE_003. 이미 끝냈으면 그대로 200(응답을 못 받은 재시도). 파일 전체의 해시는 따로 보지 않는다(조각마다 봤다). - 상태
GET /api/file-uploads/{id}— 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 다(아래 "기간 · 보관 · 정리"). - 보내지 않은 파일 목록
GET /api/file-uploads— 살아 있는 링크(보낸 메일)에 실리지 않은 내 파일(올리는 중 · 다 올림)이다. 보내기를 취소해 되돌린 파일도 여기 든다. 설정의 "보내지 않은 파일"과 [지우기]가 쓴다. - 그만두기 · 지우기
DELETE /api/file-uploads/{id}— 올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다 — 보관량에서 바로 빠진다. 그 파일을 싣고 있던 임시 저장본을 열면MissingLargeFile(reason: 'deleted'— "지운 파일")로 온다. 살아 있는 링크에 실린 파일은 409FILE_003(링크 끊기로 — 아래 "보낸 사람 관리").
요청 수 · 동시 수
| 대상 | 한도 | 넘으면 |
|---|---|---|
조각 PUT |
IP 마다 1분 1,200회 (이 경로만 @Throttle) · 직원마다 동시 4개 · 서버 전체 동시 32개 |
429 REQ_001 |
| 만들기 · 끝내기 · 상태 · 목록 · 지우기 | 전역 한도 (경로마다 IP 당 — THROTTLE_*, 기본 1분 100회) |
429 REQ_001 |
- 조각 경로만 크게 둔다 — 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다(전역 한도에 걸리면 큰 파일이 429 로 멈춘다).
두 번째 limiter 가 아니라 라우트의
@Throttle이다(외부 이미지 중계와 같은 결 —mail-image.controller.ts:79). - 동시 수는 기다리지 않는다 — 넘으면 429, 클라이언트가 잠시 뒤 같은 조각을 다시 보낸다. 잠금 · 셈은 서버 메모리다(한 인스턴스 전제).
- 앞단 nginx 는 이 경로만 본문 17MB · 읽기 90초이고, 요청 본문을 다 받아 둔 뒤 백엔드에 넘긴다(느린 연결은 nginx 가 받는다 — 아래 "운영 확인").
클라이언트
- 조각 요청은
silent로 보내고 실패는 그 파일 줄에 보인다(첨부의FILE_001·FILE_002처럼). 네트워크 오류 · 시간 초과 · 429 · 502 · 503 · 504 는 같은 조각을 저절로 다시 보내고(간격을 늘리며 — 구현하며 정함), 넘으면 멈추고 [다시 시도](받은 조각은 남는다). 401 은 지금 규칙(갱신 한 번 → 같은 조각을 다시).FILE_004는 그 조각을 다시,FILE_003은GET으로 상태를 보고 잇는다.FILE_001·FILE_002·FILE_005·FILE_006· 404 는 멈추고 알린다. 구현하며 정함(2026-10-30 — 웹):- 만들기는 파일을 고르자마자 모두 보낸다(임시 저장본에 바로 실린다). 조각은 파일 3개까지만 함께 보내고 나머지는 차례를 기다린다(위 직원당 동시 4개는 조각
PUT만 센다). - 저절로 다시 보내는 간격은 2 · 5 · 15 · 30초, 1분 넘게 잇지 못하면 멈추고 [다시 시도]. 요청 시간 한도는 조각 5분 · 그 밖 30초.
- 조각이
FILE_003인데 상태가 "받음"이면 그 조각을 한 번 더 보내 200 을 확인한다(같은 내용이면 200) — 또FILE_003이면 멈춘다(내용이 다른 조각). - 끝내기가 진행 중(
FILE_003)이면 2 · 5 · 15 · 30초 간격으로 다시 묻고 1분 넘게 그대로면 멈추고 [다시 시도](끝내기는 거듭해도 같다). - 404(서버가 올리기를 잊음)는 멈추고 [다시 시도]를 누를 때만 새로 만들어 처음부터 올린다 — 저절로 다시 올리지 않는다.
- 검사 상태 다시 묻기: 쓰기 화면 · 읽기 화면은 15 · 30 · 60초 간격 최대 30번, 공개 화면은 20번(공개 경로의 IP 한도 안).
- 만들기는 파일을 고르자마자 모두 보낸다(임시 저장본에 바로 실린다). 조각은 파일 3개까지만 함께 보내고 나머지는 차례를 기다린다(위 직원당 동시 4개는 조각
- 진행은 받은 바이트로 보인다("420MB / 1.2GB · 약 3분"), 멈춤 · 빼기. 빼면
DELETE(알림 없이 — 404 는 지운 것으로 본다). 쓰기 화면을 버리면(휴지통) 지우지 않는다 — 되돌리면 다시 쓴다. - 새로고침 · 창 닫힘 뒤 이어 올리기(처음 판에 넣는다): 올리는 중인 파일도 임시 저장본 머리글에 실린다(아래 "임시 저장") — 다시 열면
uploading으로 오고 "올리다 멈춤 · {N} 올림 · 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다). 다시 고른 파일은 이름 · 크기 ·lastModified가 같고 그 파일의 맨 앞 조각 SHA-256 이firstChunkSha256과 같을 때 같은 파일로 보고 빠진 조각부터 잇는다. 다르면 새로 만든다(앞 올리기는DELETE). 맨 앞 조각을 받기 전(firstChunkSha256: null)이면 이을 것이 없다 — 새로 만든다. 올리는 중은 마지막 조각 뒤 24시간 남는다 — 그 뒤에 열면MissingLargeFile(reason: 'expired')이라 다시 올린다. - 올리는 동안은 보내기를 막는다(아래 "보내기를 막는 까닭"). 나가기 확인에 올리는 중인 파일을 넣는다(2절 "임시 저장"의 나가기 규칙).
쓰기 화면이 고르는 것 (클라이언트)
- 링크로 바뀌는 때: 파일 하나가
attachmentMaxBytes(20MB —GET /api/mail-quota)를 넘거나, 일반 첨부 합계가attachmentTotalMaxBytes(25MB)를 넘게 만드는 파일부터 자동으로 대용량(링크)이다. 일반 첨부와 한 메일에 섞는다. 서버는 바꾸지 않는다 — 보낸 대로 다룬다. - **손으로 「링크로 보내기」**는 이 쓰기 화면에서 고른 파일(브라우저에 내용이 있는 것)에만 있다. 전달하는 원본 메일의 첨부 · 임시 저장본의 첨부(엔진에 있는 것)는 처음 판에서 바꾸지 않는다. 링크 → 일반 첨부로 되돌리기도 처음 판에는 없다.
- 올리기 전에 막는다(지금 첨부 사전 검사와 같은 결): 형식(대용량 허용 목록 — 짝 상수), 크기(
maxFileBytes), 한 통의 개수(maxFilesPerMessage), 보관량(usedBytes+ 이 파일 >limitBytes). 값은GET /api/file-quota로 받는다(못 받으면 위 기본값). - 받는 사람이 사내뿐이어도 같은 규칙이다.
보내기를 막는 까닭 — 일반 첨부의 까닭(올리는 중 · 찾지 못함)에 더한다. 단추는 aria-disabled + 까닭 글로 막는다(글 · 모양은 디자인 README). 서버도 같은 것을 막는다.
| 까닭 코드 (클라이언트) | 상태 | 서버가 받으면 |
|---|---|---|
large-file-uploading — 올리는 중 · 멈춘 올리기 · 다시 골라야 이어지는 파일 |
LargeFile.status: 'uploading' |
400 MAIL_016 |
large-file-missing — 지운 파일 · 기간이 지난 파일 |
MissingLargeFile(reason) |
400 MAIL_016 |
large-file-infected — 위험 판정 |
scanStatus: 'infected' |
400 MAIL_016 |
large-file-count — 한 통의 개수 초과 |
largeFileIds 가 maxFilesPerMessage 보다 많음 |
400 VAL_001 |
- 검사 중(
pending) · 검사하지 못함(unscannable)은 막지 않는다 — 받는 쪽이 "검사 중"을 보고 끝나면 받는다. 보관량은 보내기가 아니라 올리기 전에 막는다(만들기의 409FILE_005).
발송 · 임시 저장과 함께
본문 필드 (발송 · 임시 저장 같다 — 2절 "발송 본문"에 더한다)
{
// … 2절 그대로 …
"largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일, 겹치지 않게, 20개까지 (남용 방지 — 실제 한도는 maxFilesPerMessage)
"bodyQuoteStart": 1234 // 선택 — body(텍스트)에서 인용이 시작하는 자리 = 덩이를 넣을 자리
}
- 인용 경계 — 서식 본문: 쓰기 화면은 인용("원본 메일" 머리) 바로 앞에 빈 표시
<div data-ara-quote-start></div>하나를 둔다. 보내는 정화는 이 속성을div에만 남기고(그 밖의data-*는 지금처럼 지운다 —sanitize-outgoing-html.ts:75-82), 서버는 첫 표시 자리를 덩이 자리로 쓴 뒤 표시를 모두 지운다 — 받는 사람에게 가지 않고, 임시 저장본에도 남지 않고, 지문에도 들지 않는다. 지금 쓰기 화면이 보내기 전에 지우는 인용 머리 표시(data-quote-header—mail-serialize.ts:202-206)와는 따로다. - 인용 경계 — 텍스트:
bodyQuoteStart— 0 ~body.length(JS 문자열 위치)이고 줄의 처음이어야 한다(0 이거나 바로 앞이 줄바꿈). 아니면 400VAL_001. 서버는 텍스트 본문을 고치지 않고 넘기므로(mail.service.ts:1192) 클라이언트가 센 자리 그대로다. 지문에 넣지 않는다. - 표시 ·
bodyQuoteStart가 없으면 덩이는 맨 끝(서명 아래)이다. 대용량 파일이 없으면 둘 다 쓰지 않는다(표시는 지운다).
보내기 전 확인 (POST /api/messages — 엔진에 묻기 전이라 메일은 나가지 않는다)
largeFileIds의 파일은 있고 · 내 것이고 · 다 올렸고(ready) · 위험이 아니어야 한다. 아니면(없음 · 남의 것 · 지움 · 기간 지남 · 올리는 중 · 위험) 400MAIL_016— 다 받았는지 서버가 확인한 뒤에만 보낸다. 검사 중(pending) · 검사하지 못함은 보낸다(보내는 사람을 기다리게 하지 않는다).- 개수가
maxFilesPerMessage를 넘으면 400VAL_001. - 클라이언트는
MAIL_016이면 파일마다GET /api/file-uploads/{id}로 다시 물어(404 면 없는 파일) 쓸 수 없는 것을 보이고 빼게 한다 —MAIL_004와 같은 흐름. 쓰던 메일 · 키는 그대로다.
덩이 — 서버가 본문에 넣는 안내
- 보낼 때만 만든다(임시 저장본에는 넣지 않는다). 한 통에 하나 — 이번 발송의 새 링크(내 파일) 묶음이다.
- 자리: 서식 본문은 첫 경계 표시 자리, 텍스트는
bodyQuoteStart— 없으면 맨 끝. 그래서 내 글 · 서명 아래, 인용 위다. - 내용 — HTML 과 텍스트에 같은 내용(글 · 모양은 디자인 README "11단계", 구현하며 맞춘다):
개수 · 합계 · 끝나는 날("한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다."), 파일마다 이름 · 크기, 내려받기 주소(주소 글자가 보이는 링크 — 단축 주소 없음),
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files: download by Nov 29, 2026 (KST). No login or password needed."). 받는 사람 · 제목은 넣지 않는다.
(덩이의 글은 2026-11-05 「화면 문구 — 사용자에게 말하듯」으로 고쳤다 — 그 전에 붙잡아 둔 메일의 옛 글 덩이도 서버가 알아보고 뗀다:
large-file-block.ts의LEGACY_BLOCK_WORDINGS.) 크기는 화면과 같은 표기(1024 단위 —1.2 GB·612 MB, 짝: frontendshared/utils/format.ts의formatFileSize), 날짜는 한국 날짜. - HTML 모양: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 만든다(2절 "서식 본문" — div · p · span · a · br · table, 색 hex · 여백 px · 글자 8~99px …) — 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게. 그림 · 뱃지 · 알약 없이 테두리 상자와 글자.
- 표시: HTML 덩이의 앞뒤에 주석
<!--ara-large-files:v1-->·<!--/ara-large-files-->를 둔다. 보내는 정화는 HTML 주석을 지우므로(sanitize-html 2.18 —<div>a<!--x-->b</div>→<div>ab</div>, 2026-10-30 확인) 사용자가 쓴 본문(인용 포함)에는 이 표시가 들어갈 수 없다 — 서버만 넣는다. 받는 쪽 메일 프로그램에는 보이지 않는다. - 텍스트 덩이: 같은 내용을 줄로 — 앞뒤에 빈 줄.
- 한도: 넣은 뒤 다시 잰다 — 텍스트 100,000자 · 서식 본문 UTF-8 2,000,000바이트. 넘으면 400
MAIL_007(메일도 링크도 만들지 않는다). 덩이는 파일 10개 · 이름 255자여도 HTML 수십 KB 안이다 — 클라이언트는 지금처럼 보내기 직전에 재고 넘으면 인용을 줄인다(2절 "답장·전달 초안의 길이"). 인용 위라 다시 읽기 한도에서 잘리는 일도 드물다.
지문 · 같은 키 재시도
- 덩이는 정화 · 서명 그림 · 지문 · 중복 확인을 마친 뒤, 엔진에 보내기 바로 앞에 넣는다(
mail.service.ts:1036-1117의deliverOnce— 지문:1056· 이미 보냈는지:1058· 보내기:1099). 앞에 넣으면 시도마다 새 토큰이 지문에 들어가 같은 키의 재시도가MAIL_005가 된다. - 지문에는 덩이 대신
largeFileIds(정렬)를 있을 때만 넣는다 — 대용량 파일이 없는 발송의 지문은 지금과 같다(send-idempotency.ts:109-128— 서식 본문을 있을 때만 붙이는 것과 같은 결). 경계 표시 ·bodyQuoteStart는 넣지 않는다. - 같은 키의 재시도가 처음 메일을 찾으면 처음 결과를 그대로 준다 — 링크를 새로 만들지 않는다. 대용량 파일을 바꾼 재시도는 409
MAIL_005.
링크를 만드는 때 · 발송이 실패하면
- 이번 발송에 내 파일이 있으면 링크 하나를 만든다 — 중복 확인 뒤 · 엔진에 보내기 바로 앞. 토큰(아래 "공개 내려받기"), 보낸 사람 이름 · 주소(로그인한 계정의 그때 값), 보낸 때, 끝나는 때를 둔다. 보내기에 성공하면 보낸 사본의 메일 id 를 잇는다. DB 쓰기 한 번이라 발송 55초 예산에 영향이 거의 없다.
- 보내지 않은 것이 확실한 실패(받는 사람 모두 거절
MAIL_010·MAIL_011, 엔진이 요청을 거절한VAL_001·MAIL_004·FILE_002·MAIL_003·AUTH_003·REQ_001(구현 때 더함 — 엔진의 요청 수 한도 거절도 제출 전이다), 시간이 모자라 시작하지 않은MAIL_001— 어댑터가 "시작하지 않음"으로 표시한 것만(SendNotStartedException— 로그 전용인 context 의 글로 가르지 않는다))면 그 링크를 취소한 링크로 둔다 — 공개 경로는 404(없는 링크)이고 보낸 사람 목록에 나오지 않는다. - 결과를 모르는 실패(제출 중 시간 초과 · 연결 끊김 —
MAIL_001)는 그대로 둔다(나갔을 수 있다). 같은 키의 재시도가 그 메일을 찾으면 그 링크에 보낸 사본을 잇는다. 찾지 못해 새로 보내면 새 링크가 생기고, 앞 링크는 보낸 메일을 모르는 채(sentEmailId: null) 기간까지 산다 — 목록에서 끊을 수 있다(드묾 — 2절 중복 방지의 알려진 한계와 같은 경우). - 일부 받는 사람만 거절(201
rejectedRecipients)은 보낸 것이다 — 링크는 산다.
임시 저장
- 저장 본문의
largeFileIds는 내 파일이면 올리는 중이어도 된다(새로고침 뒤 이어 올리기). 없음 · 남의 것 · 지움 · 기간 지남은 400MAIL_016— 첨부의MAIL_004처럼 클라이언트는 그런 파일(MissingLargeFile)이 남은 동안 저장하지 않는다(사용자가 빼면 다시 저장한다). 위험 판정 파일도 저장은 된다(보낼 때MAIL_016). - 서버는 초안에 머리글로 둔다 —
X-Ara-Large-Files: <파일 id>, …(올리는 중인 올리기 id 도 — 같은 id 다. 답장 대상 머리글X-Ara-Reply-To-Id와 같은 길 —jmap-mail.provider.ts:193, 2946). 덩이는 넣지 않는다. 파일 id 는 저장해도 바뀌지 않는다 — 일반 첨부처럼 응답으로 바꿔 쓸 일이 없다(DraftSaved는 그대로다). - 저장하면 그 파일들을 "임시 저장에 실린 파일"로 적는다 — 다 올린 파일의 남는 기간이 24시간에서 다 올린 날부터 30일로 는다(아래 "기간 · 보관 · 정리"). 올리는 중인 파일은 그대로 마지막 조각 뒤 24시간이다.
GET /api/drafts/{id}: 머리글의 파일 가운데 그 직원의 것만largeFiles로 준다 — 있으면LargeFile, 지웠거나 기간이 지났으면MissingLargeFile(reason). 남의 id 와 기록까지 지워진 id 는 버린다(다른 프로그램이 만든 초안일 수 있다 — 둘은 가릴 수 없다).- 알려진 한계(답장 대상 머리글과 같다): 다른 메일 프로그램이 그 초안을 그대로 보내면 머리글만 나가고 덩이는 없다.
보내기 취소 · 예약 발송 (2절, 9단계)
- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다.
- 링크의 보낸 때: 예약은
sendAt(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시). 예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다). - 취소(
DELETE /api/scheduled-messages/{id})가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다). - 되돌린 초안(취소 표시
$ara-canceled)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 전에 떼고(mail.service.ts:1282-1304의draftHtml— 취소한 발송의 서명 그림을 되돌리는 그 자리), 텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을largeFiles로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다. 덩이를 떼지 못하면 다시 보낼 때 덩이가 둘이 된다 — 테스트로 지킨다. 인용 속 남의 덩이(표시 없는 글)는 건드리지 않는다. - 붙잡힌 메일은 우리 API 로 옮기거나 지울 수 없다(
MAIL_015) — 그동안 링크 · 파일도 그대로다. ⚠️ 구현 전 확인: 엔진이 보낸 사본의 HTML 주석을 그대로 두는지— 확인함(2026-10-30, 개발 엔진 Stalwart 0.16.27): 만든 메일을 다시 읽으면 HTML 주석 · 텍스트 본문(줄바꿈 그대로)과X-Ara-Large-Files머리글(header:X-Ara-Large-Files:asText)이 그대로 돌아온다. e2e 가 취소 → 되돌린 초안 → 다시 보내기에서 덩이가 하나인지 본다.
답장 · 전달
- 원본의 대용량 첨부를 다시 싣지 않는다 — 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다(편집기가
data-ara-large-files를 버린다 — 링크는 보통 링크다). 서버는 인용 속 링크를 다시 싣거나 우리 링크로 바꾸지 않는다. 받는 사람은 그 링크로 원래 공개 화면에서 받는다 — 원래 파일의 받은 수 · 상한 · 끝나는 날을 따른다. - 전달 · 답장하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(그 사람 것)이고, 새 덩이는 인용 위에 들어간다.
- 쓰기 화면은 인용 속 원래 링크의 상태(끝나는 날 · 기간 지남 · 공유 멈춤)를 첨부 칸 아래 글자로만 알린다 — 아래
POST /api/share-link-lookups.
인용 속 링크의 상태 (POST /api/share-link-lookups)
- 쓰기 화면(전달 · 답장)이 인용에서 찾은 공개 화면 주소를 보내면 링크마다 상태 · 끝나는 날만 준다 — 누구의 링크든 같다(이름 · 보낸 사람 · 받은 수는 주지 않는다. 주소를 가진 사람은 공개 화면에서 더 많이 본다).
- 클라이언트는 주소의 경로가
/s/+ 43자([A-Za-z0-9_-])인 링크를 고른다. 서버는<SHARE_PUBLIC_URL>/s/<토큰>모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크면unknown이다. 이 길에서는 주소 뒤의 쿼리 ·#은 보지 않는다(받는 쪽 보안 장비가 붙이기도 한다 — 구현). 읽기 화면의 덩이 알아보기는 쿼리 ·#이 없는 주소만 받는다. - 상태:
active(받을 수 있는 기간 —expiresAt),expired,revoked. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다 — 받는 사람이 공개 화면에서 본다. - 주소(토큰)를 쿼리에 싣지 않으려고
POST본문으로 받는다(요청 주소가 로그에 남는다 — 아래 "로그"). 상태를 바꾸지 않는다. 본문 검사(1~20개 · 겹침 · 300자)에 걸리면 400VAL_001. - 클라이언트: 첨부 칸 아래에 "인용한 대용량 첨부 링크 — 11월 29일까지 받을 수 있습니다" · "기간이 지났습니다" · "보낸 사람이 공유를 멈췄습니다"(글은 디자인 README).
unknown은 알리지 않는다. 알림 없이(silent) 부르고 실패하면 글자를 두지 않는다.
우리 웹메일에서 읽기 (MessageDetail.largeFiles · GET /api/messages/{id}/large-files/{fileId})
- 첨부처럼 올리는 것은 셋이 모두 맞을 때만이다:
- 원문 HTML(정화 전)의 표시 주석 사이에 있는 링크다 — 표시가 없으면 DB 를 보지 않는다.
href가<SHARE_PUBLIC_URL>/s/<토큰>꼴이고 토큰이 우리 기록에 있다(보내기를 취소한 링크는 빼고 — 한 통에 10개까지 본다).- 그 링크를 만든 직원이 그 메일의 보낸 사람(머리글 From 의 주소 — 대소문자 무시)이다.
- 맞으면 그 링크의 파일을
largeFiles로 준다 — 이름 · 크기 · 상태는 메일 글이 아니라 서버 기록이다.htmlBody에서는 그 덩이의 바깥 요소에data-ara-large-files를 붙인다. 받은 HTML 에 있던 같은 속성은 정화가 지운다(받은 메일 정화의 속성 허용 목록에data-*가 없다 —sanitize-mail-html.ts:165-) — 그래서 서버만 붙일 수 있다. - 맞지 않으면 본문 글로 둔다 — 바깥이 흉내 낸 덩이, 바깥 사람 답장의 인용 속 링크, 전달된 원래 링크(인용 속 덩이에는 표시가 없고 보낸 사람도 다르다), 다른 주소. 속성도
largeFiles도 없고 링크는 다른 링크처럼 새 창이다. - 텍스트만 있는 메일과, HTML 이 정화 한도를 넘어 비운 메일(
isHtmlBodyOmitted)의 덩이는 알아보지 않는다(알려진 한계). downloads(받은 수 · 마지막으로 받은 때)는 보는 사람이 그 링크를 만든 사람일 때만 붙는다 — 보낸편지함의 사본, 나에게도 보낸 메일.- 목록의
hasAttachment(클립)는 지금처럼 일반 첨부만 본다 — 대용량 첨부만 있는 메일은 클립이 없다(처음 판에서 뺀다). - 받기
GET·HEAD /api/messages/{id}/large-files/{fileId}— 그 메일에서 첨부처럼 올린 파일만 준다(아니면 404RES_001). 머리글 · 범위 · 상태별 실패는 아래 "공개 내려받기"와 같고(FILE_007·FILE_008), 엔진을 읽지 못하면 503MAIL_001. 요청 수는 IP 마다 1분 300회. 보는 사람이 링크를 만든 사람이면 세지 않는다(보낸 사람 자신의 확인). 다른 직원(사내 받는 사람)은 공개 내려받기처럼 센다. 공개 경로에서는 보낸 사람을 알아볼 수 없다 — 포털 쿠키는 호스트 한정(Domain 없음 —auth-cookies.ts:29-36)이라 공개 호스트로 가지 않는다. - 클라이언트: 첨부 칸에 첨부처럼 — 이름 · 크기 · "11월 29일까지". 받을 수 없으면 흐리게 + 글자("검사 중" · "기간 지남" · "공유 멈춤" · "위험" · "횟수 초과" · "찾을 수 없음"), "검사하지 못함"은 받을 수 있는 채 글자만.
덩이(
data-ara-large-files요소)는 접어 둔다 — 그리기 전에 떨어진 문서에서 처리한다(클라이언트 정화가data-*를 지우므로 그 전에 —frontend/src/shared/utils/mail-html.ts:85). 받기는 첨부처럼<a href>이동이고, 이동 전에 세션을 확인한다(confirmSession()). 보낸 사람 자신에게는 "3회 받음 · 11월 29일까지" · [링크 복사](덩이의 주소 —url) · [링크 끊기](그 메일의 링크 — 확인 대화상자, 처음 초점 「취소」).
보낸 사람 관리 (/api/share-links · /api/file-uploads · /api/file-quota)
- 보낸 링크 목록
GET /api/share-links— 내가 만든 링크(취소한 링크는 빼고), 보낸 때 최근부터. 기록을 지우기 전(끝나거나 끊긴 뒤 90일)까지 기간 지남 · 끊음도 나온다.subject는 그 쪽의 보낸 사본을 엔진에서 한 번에 읽는다 — 지웠거나 엔진을 읽지 못하면 null 이고 목록은 그대로 준다(warn). - 링크 끊기
POST /api/share-links/{id}/revocation— 그 링크(메일)의 파일 모두의 공유를 멈춘다(파일 하나만 끊지 않는다). 되돌릴 수 없다. 200ShareLink(revoked). 다음 요청부터 공개 화면은 "공유 멈춤"이고 받기는 410FILE_007이다 — 이미 시작한 내려받기는 끝까지 간다. 그 파일들이 다른 살아 있는 링크 · 임시 저장에 없으면 보관량에서 바로 빠지고 정리 때 지운다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 200(지금 상태). 남의 링크 · 없는 것은 404RES_001. - 링크 복사는 목록에 두지 않는다 — 토큰은 SHA-256 만 두어 되살릴 수 없다. 보낸 사람은 보낸 메일 본문의 덩이에서 복사한다(읽기 화면 —
largeFiles[].url). - 보내지 않은 파일
GET /api/file-uploads· [지우기]DELETE /api/file-uploads/{id}— 보관량이 바로 준다. 그 파일을 싣고 있던 임시 저장본을 열면 "지운 파일"(MissingLargeFile·reason: 'deleted')로 보인다. - 사용량
GET /api/file-quota. - 클라이언트: 설정 › 메일 › 대용량 첨부 —
- 맨 위 사용량("6.2 / 10 GB" —
formatQuotaUsage). - 보낸 링크는 메일(링크)마다 한 덩이: 메일 제목 · 보낸 날 · 끝나는 날("n일 남음") · 상태(사용 중 · 만료 · 회수), 파일마다 이름 · 크기 · 받은 수 · 마지막으로 받은 때 · 파일 상태(검사 중 · 위험 · 검사하지 못함 · 횟수 초과),
[보낸 메일 보기](
sentEmailId— null 이면 없다) · [링크 끊기](확인 — "이 메일로 보낸 대용량 파일 N개를 더는 받을 수 없게 됩니다", 처음 초점 「취소」). - 보내지 않은 파일: 이름 · 크기 · 상태(올리는 중 · 다 올림 · 검사 상태) · 지워지는 때 · [지우기].
- 맨 위 사용량("6.2 / 10 GB" —
공개 내려받기 (로그인 없음)
- 누구나(바깥 거래처 · 내려받기 관리자 · 받는 쪽 보안 장비) 부른다. 로그인 · 쿠키를 쓰지 않는다(읽지도 않는다). 상태를 바꾸지 않는
GET이라 출처 검사도 해당 없다. - 응답에 실리는 메일 정보는 보낸 사람 이름 · 주소뿐이다 — 제목 · 본문 · 받는 사람 · 메일 id 는 싣지 않는다(링크가 전달되면 그 사람도 본다 — 숨은참조가 드러나지 않게). 링크에 받는 사람 정보를 넣지도 않는다.
토큰
- 링크마다
crypto.randomBytes(32)의 base64url(43자,=없음 — 256비트). 서버 서명 키(JWT_SECRET)에서 만들지 않는다(보안 규칙). - DB 에는 SHA-256 만 두고 그것으로 찾는다 — 원문은 메일 본문에만 있고, 서버는 링크를 다시 만들 수 없다. 맞혀 볼 수 없는 값이라 느린 해시는 필요 없다.
- 경로에 둔다(
/s/<토큰>·/api/public-links/<토큰>) — 받는 쪽 보안 장비의 링크 바꿔 쓰기와 잘 맞는다. 로그에서는 가린다(아래 "로그"). - 모양(
^[A-Za-z0-9_-]{43}$)이 아니면 DB 에 묻지 않고 404.
상태별 응답
| 상황 | 링크 정보 GET /api/public-links/{token} |
파일 GET /api/public-links/{token}/files/{fileId} |
|---|---|---|
| 모양이 틀린 · 모르는 토큰, 보내기를 취소한 링크, 기록을 지운 링크(끝난 뒤 90일), 그 링크에 없는 파일 | 404 RES_001 |
404 RES_001 |
| 기간 지남 | 200 status: 'expired' (files: []) |
410 FILE_007 |
| 링크를 끊음(보낸 사람 · 운영) | 200 status: 'revoked' (files: []) |
410 FILE_007 |
| 검사 중 | 200 — scanning |
409 FILE_008 |
| 위험 · 횟수 초과 · 찾을 수 없음 | 200 — infected · limit-reached · missing |
410 FILE_007 |
| 받을 수 있음 · 검사하지 못함 | 200 — available · unscannable |
200 · 206 · 416 |
| 요청 수 한도 | 429 REQ_001 |
429 REQ_001 |
- 없는 링크와 끝난 링크를 가려 알려도 된다 — 토큰을 가진 사람에게만 보이는 구분이다.
- 404 · 409 · 410 은 표준 실패 형식이지만 전역 필터를 거치지 않고 컨트롤러가 쓴다 — 로그는 debug 만(끝난 링크를 누르는 받는 사람 · 봇이 잦아 warn 이면 알림이 쌓인다 —
외부 이미지 중계와 같은 결
mail-image.controller.ts:64-67). 5xx 는 지금처럼 error. 구현(2026-10-30): 공개 컨트롤러에만 건 같은 예외 필터(HttpExceptionFilter의quietStatuses— 404 · 409 · 410 은 debug)가 쓴다 — 형식 · traceId 는 다른 API 와 같다. - 링크 정보 응답도
Cache-Control: no-store·X-Robots-Tag: noindex·Referrer-Policy: no-referrer.
파일 응답 (공개 받기 · 우리 웹메일 받기 공용)
- 머리글:
Content-Type: application/octet-stream(늘 — 형식은 서버가 정한다) ·Content-Disposition: attachment; filename=…; filename*=UTF-8''…(일반 첨부와 같은buildAttachmentDisposition—shared/utils/content-disposition.ts:39) ·X-Content-Type-Options: nosniff·Content-Security-Policy: default-src 'none'; sandbox·Cache-Control: private, no-store·Referrer-Policy: no-referrer·X-Robots-Tag: noindex·Accept-Ranges: bytes·Content-Length·ETag: "<fileId>"(강한 — 파일 내용은 id 마다 바뀌지 않는다) ·Last-Modified(다 올린 때) ·X-Accel-Buffering: no. - 범위(Range):
- 없으면 200 전체.
bytes=범위 하나면 206 +Content-Range: bytes <처음>-<끝>/<크기>(끝이 크기를 넘으면 맞춰 자르고,bytes=-N은 끝 N 바이트). - 범위가 여럿이거나 형식이 틀리면 범위를 무시하고 200 전체(RFC 9110 이 허락한다).
If-Range가 ETag · Last-Modified 와 다르면 200 전체. - 처음이 크기 이상이면 416 +
Content-Range: bytes */<크기>— 본문은 비운다(내려받기 관리자가 읽는 응답이라 표준 실패 형식이 아니다 — 2절 첨부 받기처럼 래핑 예외). HEAD는 같은 머리글만 준다(파일을 열지 않고 세지 않는다).
- 없으면 200 전체.
- 횟수:
- 센다:
GET이고 0번째 바이트부터 보내는 응답(범위 없음 · 무시 ·bytes=0-…). 머리글을 보내기 직전에 원자적으로 더한다 — 상한에 닿았으면 더하지 않고 410FILE_007. - 세지 않는다: 이어 받기(범위의 처음 > 0),
HEAD, 링크 정보(화면 열기), 실패 응답, 링크를 만든 사람이 우리 웹메일 경로로 받은 것. - 상한(
SHARE_DOWNLOAD_LIMIT, 파일당 500)에 닿으면 그 파일은 이어 받기도 410FILE_007이다. 남은 횟수는 어디에도 내세우지 않는다. - 받은 수 · 마지막으로 받은 때는 보낸 사람만 본다(목록 · 읽기 화면의
downloads). 누가 · IP 는 남기지 않는다.
- 센다:
- 내려받는 동안 링크를 끊거나 기간이 지나도 이미 시작한 응답은 끝까지 간다(다음 요청부터 막힌다). 서버 종료가 시작되면 진행 중인 내려받기를 바로 끊고(종료 유예 30초를 붙잡지 않게 — 실시간 알림의 종료와 같은 결) 그 뒤에 온 받기는 시작하지 않는다 — 브라우저가 범위로 이어 받는다.
- ⚠️ Nest 의
StreamableFile로는 206 · 416 을 보낼 수 없다(@Res({ passthrough: true })면 라우트 상태로 덮어쓴다 —@nestjs/platform-express11.2.5) — 처리기가@Res()로 상태 · 머리글을 직접 쓰고 흘려보낸다(2절 첨부 받기message.controller.ts:357-381과 다른 길).
요청 수 한도: IP 마다(clientIpKey() — IPv6 는 /64) 링크 정보 1분 60회, 파일 1분 300회(HEAD 포함 — 내려받기 관리자가 범위를 나눠 여러 번 부른다). 넘으면 429 REQ_001.
요청 수일 뿐 바이트는 세지 않는다 — 퍼진 링크의 대역폭은 횟수 상한 · 기간 · 끊기가 막는다(앞단 동시 연결 — IP 마다 공개 10 · 앱 안 30, 전체 400 · 200 — 아래 "운영 확인").
화면 /s/{token} — SPA 의 공개 라우트
- 셸 · 로그인 없이 그린다 — 라우트의 공개 표시(
meta.public)로 세션 복원(/auth/me·/auth/refresh— 지금 가드는 모든 이동 전에 부른다frontend/src/router/index.ts:26-28)과 로그인 화면 보내기를 건너뛴다. API 는 세션 갱신 · 세션 만료 대화상자를 타지 않는 따로 된 요청(쿠키 없이)으로 부른다. - 보이는 것: 회사 표시, 보낸 사람 이름 · 주소, 보낸 날 · 끝나는 날(한국 시간), 파일 목록(이름 · 크기 · 검사 상태 · [내려받기]), "이 화면은 로그인이나 비밀번호를 묻지 않습니다", 신고 · 문의 주소, 영어 한 줄. 상태: 정상 · 검사 중 · 위험 · 검사하지 못함 · 만료 · 회수 · 횟수 초과 · 없는 링크 · 요청이 너무 많음, 좁은 폭(휴대폰). 글 · 모양은 디자인 README "11단계".
- [내려받기]는
<a href>이동이다(큰 파일을fetch로 받으면 메모리에 다 오른다) — 누르기 직전에 링크 정보를 한 번 더 묻고, 받을 수 없게 됐으면 화면에서 알린다(이동 뒤 JSON 오류가 뜨지 않게). 끊기면 브라우저의 다시 시도로 이어 받는다. 검사 중이면 화면이 잠시 뒤 다시 묻는다(간격은 구현하며 정함 — 위 요청 수 안에서).
공개 호스트 (SHARE_PUBLIC_URL)
- 운영은
https://files.arasystem.it.kr— 포털과 다른 호스트라 털린 계정이 퍼뜨린 악성 파일로 포털까지 차단 목록에 오르는 위험을 어느 정도 떼어 놓고, 포털 쿠키도 가지 않는다 (외부 리버스 프록시가 포털과 같은 사설 주소:8080 으로 넘긴다). - 공개 호스트에서는 화면
/s/{token}(과 그 자산) ·/api/public-links/…만 연다 — 그 밖은 404. 포털 호스트에서는/s/…·/api/public-links/…를 열지 않는다(404). 둘이 같은 주소면(개발) 둘 다 연다. 앞단 nginx(호스트마다 server)와 백엔드(요청의Host— nginx 가 원래 값을 넘긴다,frontend/nginx.conf의proxy_set_header Host $host)가 함께 막는다(구현하며 정함). 구현(2026-10-30 — 백엔드): 요청 추적 ID 바로 뒤의 미들웨어(modules/file-sharing/public-host.ts)가Host의 호스트 이름(포트 없이 · 소문자 · 끝의 점 없이)을SHARE_PUBLIC_URL의 호스트 이름과 견준다. 공개 호스트이면/api/public-links/만, 아니면 그 경로만 404RES_001(표준 실패 형식 · 로그 없음 — 경로의 대소문자는 가리지 않는다). 경로는 스택이 읽을 수 있는 모든 모양 — 원문(?앞) · 라우터가 쓰는 경로(Express 의req.path: 절대 주소 꼴은 스킴 · 호스트를 떼고,#이 섞이면\를/로 읽는다) · WHATWG URL(\→/,%2e조각) · 퍼센트 인코딩을 한 번 푼 원문(nginx 처럼%2F도) — 을 정규화(겹친/·.·..조각 · 대소문자)해 본다. 공개 호스트는 모든 모양이 공개 경로일 때만 열고(그래서 절대 주소 꼴은 받지 않는다), 포털은 어느 모양이라도 공개 경로면 닫는다. 읽지 못한 모양이 있으면 어느 쪽도 열지 않는다 (nginx 의 정규화에 기대지 않는다 — 2026-10-30, 처음 판은 포털 Host 의 절대 주소 꼴 ·\…#요청이 공개 컨트롤러에 닿았다). 공개 호스트 이름이 포털의 출처(CORS_ORIGIN, 비운영은 개발 기본 주소 포함)의 호스트 이름과 같으면(개발) 가르지 않는다 — 포트만 다른 두 주소는 nginx 가 포트 없는Host를 넘겨 가를 수 없기 때문이다. - 공개 주소는
CORS_ORIGIN에 넣지 않는다 — 공개 화면은 같은 호스트의 공개 API 만 부른다(connect-src 'self'그대로). 포털 화면이 링크의 상태를 볼 때는 포털의POST /api/share-link-lookups를 쓴다. - 덩이 ·
url은 늘 지금의SHARE_PUBLIC_URL로 만든다. 바꾸면 그 전 메일의 덩이를 알아보지 않고, 옛 주소는 옛 호스트가 남아 있어야 열린다 — 바꾸지 않는다.
기간 · 보관 · 정리
- 링크가 끝나는 때 = 보낸 때(예약은
sendAt)의 한국 날짜에 30일을 더한 날의 23:59:59.999(한국 시간 — UTC+9, 일광 절약 없음). 예: 2026-10-30 에 보내면expiresAt은2026-11-29T14:59:59.999Z(11월 29일(일) 끝까지). 받는 사람 모두 같은 날짜이고, 덩이 · 화면은 그 한국 날짜 하나로 적는다. 요청마다지금 ≤ expiresAt으로 본다 — 정리 작업이 늦어도 정확하다.
| 대상 | 남는 기간 | 그 뒤 |
|---|---|---|
| 올리는 중 | 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다 | 지운다 — GET 404, 임시 저장본에서는 MissingLargeFile(expired) |
| 다 올렸지만 어디에도 실리지 않은 파일 | 다 올린 뒤 24시간 | 지운다 |
| 임시 저장에 한 번이라도 실린 파일 | 다 올린 날(한국)부터 위와 같은 셈으로 30일째 날 끝 | 지운다 — 임시 저장본에서는 MissingLargeFile(expired — "기간이 지나 다시 올려야 하는 파일") |
| 사용자가 지운 보내지 않은 파일 | 곧(보관량에서는 그때 바로 빠진다) | 임시 저장본에서는 MissingLargeFile(deleted — "지운 파일") |
| 보낸 파일 | 그 파일을 실은 살아 있는 링크가 끝나는 때 가운데 가장 늦은 것 (임시 저장 기간보다 늦으면) | 지운다 |
| 끊은 링크의 파일 · 위험 판정 파일 | 다른 쓰임이 없으면 곧 | 지운다 — 보관량에서는 그때 바로 빠진다 |
| 링크 기록 | 끝나거나 끊긴 뒤 90일 — 그동안 공개 화면은 "기간 지남" · "공유 멈춤"이고 목록에 남는다 | 지운다 → "없는 링크"(404), 목록에서 사라진다 |
| 취소한 링크 (보내기 취소 · 보내지 못함) | 되돌린 초안이 쓰는 동안 — 90일 | 지운다 |
| 파일 기록 (이름 · 크기) | 그 파일을 가리키는 링크 기록이 다 지워질 때까지. 보낸 적 없는 파일은 지운 뒤 30일 | 지운다 — 그 뒤 임시 저장본은 그 id 를 버린다 |
- 임시 저장본을 영구 삭제해도 파일은 위 기간까지 남는다 — 초안은 우리 API 밖(IMAP · 자동 비우기)에서도 지워져 서버가 다 알 수 없다(알려진 한계 — 보관량에 잡히고, 보내지 않은 파일 목록에서 지울 수 있다).
- 받은 기록은 받은 수 · 마지막으로 받은 때만 둔다 — 누가 · IP · 브라우저는 남기지 않는다.
- 정리 작업(cron)이 바이트를 지운다 — 기록을 먼저 바꾸고 실제로 바꾼 것만 지운다(서명 그림 정리와 같은 결). 기록에 없는 파일은 하루 지난 것만 지운다(구현하며 정함).
구현(2026-10-30): 파일 정리는 한 시간마다(
file-storage.cleanup.ts) — 기간이 지난 파일을 지움(기간 지남)으로 바꾸고 바이트를 지운다, 지웠거나 위험 판정인데 바이트가 남은 것을 지운다, 끝내기를 시작한 지 10분 넘게 멈춘 올리기를 되살린다(시작한 때completion_started_at로 고른다 — 다 옮겼으면 다 올림, 아니면 올리는 중), 지운 지 30일 지난 파일 기록을 지운다, 기록에 없는 저장소 파일 가운데 하루 지난 것을 지운다. 링크 정리는 하루 한 번 05:00(서버 시각)(file-sharing.cleanup.ts) — 끝나거나 끊기거나 취소한 지 90일 지난 링크 기록을 지운다. 링크 기록은 파일 이름 · 크기를 따로 두므로 파일 기록과 따로 지운다. - 백업: 파일 바이트는 백업하지 않는다(30일짜리 전송물) — 기록은 DB 백업에 든다. 복원한 뒤 바이트가 없는 파일은
missing이다.
악성 검사 (ClamAV)
-
언제: 끝내기 직후 검사 차례에 넣는다 — 서버 전체에서 한 번에 1
2개, 먼저 끝낸 것부터(구현하며 정함). 2GB 는 분 단위가 걸릴 수 있다. 구현(2026-10-30): 한 번에 2개, 먼저 끝낸 것부터. 차례는 DB(3배 느리면 20~30분이다. 1MB 마다 1초는 파일 자체의 몫(실측의 약 7배)이다. 판정이 끝내 오지 않아 같은 파일을 끝없이 다시 검사하는 것보다 한 번 오래 기다리는 편을 골랐다(2026-10-30). 연결에는 TCP keepalive 를 켠다(60초 뒤부터 1초 간격 10번 — Node 24 · libuv 1.52): 컨테이너째 사라져 RST 도 오지 않는 clamd 를 약 70초 안에scan_status = 'pending'·scan_next_at)라 서버를 다시 띄워도 잇는다 — 다 올린 직후 · 30초마다 · 검사 하나가 끝날 때 깨운다. clamd 연결은 10초, 흘려보내는 동안 무소식 10분, 다 보낸 뒤 판정까지 30분 + 보낸 1MB 마다 1초(2,047MB 면 3,847초 ≈ 64분 —file-storage.constants.ts의clamavVerdictTimeoutMs)를 넘으면 끊고 미뤄서 다시 한다. 30분은 크기와 상관없는 몫이다 — 7z · tar.gz 는 MaxScanTime 없이 MaxScanSize(4,094MB)까지 풀리고, 실측 약 7MB/s 로 10분 · 운영 CPU 가 2read ETIMEDOUT으로 끊고 다른 끊김처럼 미뤄서 다시 한다. 살아 있는 채 멈춘 clamd 는 keepalive 로 알 수 없어 판정 대기까지 검사 자리를 잡는다. -
검사 중에도 보낸다 — 받는 쪽은
scanning(받기 409FILE_008)이고, 끝나면 받는다. -
판정 — 백엔드가 clamd 에
INSTREAM으로 흘려보낸다(규약을 직접 구현 — 새 의존성 없음):OK→clean(받을 수 있음).FOUND가운데 암호 · 검사 한도 표시(Heuristics.Encrypted.*·Heuristics.Limits.Exceeded.*) →unscannable(받을 수 있음 · "검사하지 못함"). ClamAV 1.4.6 실측(2026-10-30): 암호 zip(ZipCrypto · AES)Heuristics.Encrypted.Zip· 암호 7z.7Zip· 사용자 암호 pdf.PDF(소유자 암호만 있는 pdf 는OK), 20겹 zipHeuristics.Limits.Exceeded.MaxRecursion· 파일 12,000개 zip.MaxFiles· 풀면 수 GB 인 zip.MaxScanTime· 한 파일 한도를 넘는 파일.MaxFileSize. 같은 압축 안에 진짜 서명과 휴리스틱(암호 · 한도)이 함께 있으면 진짜 서명이 나온다(infected).- 그 밖의
FOUND→infected(EICAR 는Eicar-Test-Signature FOUND— 압축 안에 있어도 같다). - 스트림 크기 한도 오류(
INSTREAM size limit exceeded. ERROR— clamd 의StreamMaxLength가 파일보다 작다) →pending그대로 + error 로그(설정 오류 — 아래 "닿지 못함"과 같이 다시 시도). 처음 초안의unscannable+ warn 은 2026-10-30 리뷰로 바꿨다 — 운영에서StreamMaxLength를 올리는 것을 잊으면 20MB 를 넘는 모든 파일이 검사 없이 열리고 흔적은 warn 한 줄뿐이었다. clamd 는 이 글을 보낸 뒤 연결을 닫고(FIN), 백엔드가 계속 보내면 RST 다 — 백엔드가 글을 읽기 전에 RST 가 오면 판정 글 없이EPIPE로 끝나 아래 "끊음"으로 다룬다(세 번째부터 error). 운영 설정은StreamMaxLength= 한 파일 한도의 최대값(2047M)이라 정상에서는 일어나지 않는다. - clamd 가 판정 글을 보내기 전에 연결을 끊음(RST · 빈 응답) → 다시 시도(
pending).
-
검사기에 닿지 못함 · 시간 초과 · 그 밖의 오류 →
pending그대로 두고 간격을 늘려 다시 한다(1시간마다까지 — 구현하며 정함). 이어서 실패하면 error 로그(운영 알림). 구현(2026-10-30): 1분부터 두 배씩 1시간까지, 세 번째 실패부터 error(그 전은 warn). 판정 이름은Heuristics.Encrypted…·Heuristics.Limits.Exceeded…로 시작하면unscannable이다 — 판정 글 견주기는modules/file-storage/clamav-client.ts의CLAMAV_REPLY·classifyClamavReply한 곳이고,\0까지 받은 글만 판정으로 본다(끝나지 않은 글은 끊김 — 다시 시도). 그 로그에는 단계와 보낸 바이트 수가 남는다 — 시도마다 같은 "흘려보내는 중(보낸 N바이트)"이면 크기 한도를 의심한다. 판정 글이 없으면 크기 한도와 clamd 재시작을 가를 수 없어 설정 오류로 단정하지 않는다(2026-10-30 확인). 2026-10-30 release-ops 가 실제 clamd(ClamAV 1.4.6 LTS · 운영 설정)로 위 이름들을 재어 이 견주기와 맞음을 확인했다 — 판 · 설정을 바꾸면 다시 잰다(실측표는 mail/README.md "악성 검사 (ClamAV)"). 판정 없이 받을 수 있게 되는 일은 없다(닫힌 쪽). -
위험: 받기를 막고(410
FILE_007) · 보내기를 막고(MAIL_016) · 보낸 사람에게 알린다 — 목록 상태 + 실시간 알림large-file이벤트(아래). 바이트는 곧 지우고(보관량에서 바로 빠진다) 기록(이름 · 판정 이름)은 남긴다. -
검사하지 못함: 막지 않는다 — 공개 화면 · 목록 · 읽기 화면에 "검사하지 못함" 글자.
-
검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(알려진 한계).
-
검사기를 쓰지 않는 개발(
CLAMAV_HOST비움)은 끝내자마자unscannable이다. 운영은CLAMAV_HOST가 없으면 기동하지 않는다. -
실시간 알림(2절
GET /api/mail-events에 더한다) — 위험 판정 때 그 직원의 연결에 보낸다:event: large-file data: {"fileId":"…","name":"견적서.zip","scanStatus":"infected"}클라이언트는 알림("올린 대용량 파일 ‘견적서.zip’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기])을 띄우고, 쓰기 화면에 그 파일이 있으면 "위험 — 보낼 수 없음"으로 바꾼다. 연결이 없던 직원은 목록 상태로만 안다(정한 것 — 2026-10-30, 다음에 들어올 때 따로 알리지 않는다).
-
운영(release-ops): clamd 의
StreamMaxLength·MaxFileSize·MaxScanSize를 한 파일 한도 이상으로,AlertEncrypted·AlertExceedsMax를 켠다. 메모리 3~4GiB(운영 메일 서버 8GB), 서명 갱신(freshclam)의 바깥 연결, backend 와만 잇는 새 내부 네트워크(app-network가 아니다 — mail setup 의 구성원 검사에 걸리고 메일 엔진의 신뢰 대역에 든다). 구현(2026-10-30): 컨테이너 둘 —clamav(clamd ·<PROJECT_NAME>_clamavinternal 네트워크에만 · 서명 볼륨 읽기 전용)과clamav-updater(freshclam · 자기 바깥 길clamav-egress에만).StreamMaxLength=MaxFileSize= 2047M ·MaxScanSize4094M ·MaxScanTime5분(1.4 에서는 ZIP 에만 걸린다). clamd 메모리 1GB 안팎(한도 2g — 서명을 다시 읽을 때 두 벌을 들지 않게ConcurrentDatabaseReload no, 그동안 30초 안팎 검사가 기다린다). 2,047MB 무작위 파일도 끝까지 검사한다(OK— 다 보낸 뒤 296초, 코어 하나 · 개발 PC). 설정mail/clamav/, 운영 절차 · 메모리 예산 mail/README.md "대용량 첨부 (11단계 — 인프라)".
환경 변수 (루트 env — 넣는 일은 release-ops, 검증 · 테스트는 backend env.validation.ts)
| 키 | 기본값 | 범위 · 검사 | 뜻 |
|---|---|---|---|
SHARE_PUBLIC_URL |
비운영에서 비우면 CORS_ORIGIN 의 첫 주소 |
출처만(스킴 · 호스트 · 포트 — 경로 · 끝 / 없음). 운영 필수 · https · 로컬 주소 · 예시 도메인 금지(ALLOW_LOCALHOST_URL 예외 — CORS_ORIGIN · BACKEND_API_URL 과 같은 검사). 모든 환경: 앞단 nginx(frontend/nginx-env.envsh 의 nginx_env_origin_host)가 받는 모양만 — 스킴 소문자, 호스트는 영문 · 숫자 · . · -(점 · 하이픈으로 시작하거나 끝나지 않고 점이 이어지지 않게 — 끝 점 없이), 포트는 0 으로 시작하지 않는 숫자, 사용자 정보 · 대괄호(IPv6) · 앞뒤 공백 없이(다르면 백엔드만 뜨는 반쪽 배포) |
링크 · 덩이 · 덩이 알아보기 · 공개 호스트 막기. 운영 https://files.arasystem.it.kr |
SHARE_REPORT_ADDRESS |
postmaster@<MAIL_DOMAIN> |
메일 주소 | 공개 화면의 신고 · 문의 |
SHARE_DOWNLOAD_LIMIT |
500 | 1 ~ 100,000 | 파일당 내려받기 상한(받기 시작한 수) |
MAIL_LARGE_FILE_MAX_SIZE_MB |
2047 | 1 ~ 2047, MAIL_ATTACHMENT_MAX_SIZE_MB 이상 |
한 파일 |
MAIL_LARGE_FILE_MAX_COUNT |
10 | 1 ~ 20 | 메일 한 통 |
MAIL_LARGE_FILE_USER_QUOTA_GB |
10 | 1 ~ 1,000 | 직원당 보관량 |
MAIL_LARGE_FILE_RETENTION_DAYS |
30 | 1 ~ 90 | 보낸 뒤 · 임시 저장의 보관 날 수 |
FILE_STORAGE_MAX_TOTAL_GB |
비움 = 한도 없음(여유만 본다) | 1 이상 | 서버 전체 보관량 |
FILE_STORAGE_MIN_FREE_GB |
10 | 1 이상 | 만든 뒤에도 남길 디스크 여유 |
CLAMAV_HOST |
비움 = 검사하지 않음(개발) | 운영 필수 | clamd 주소 |
CLAMAV_PORT |
3310 | 1 ~ 65,535 | clamd 포트 |
FILE_STORAGE_DIR |
/app/files |
compose 가 고정해 넣는다(루트 env 아님) | 저장 위치 — named volume <PROJECT_NAME>_files |
- 숫자는 앞자리 0 없는 10진수다(
isPositiveIntString— 지금 규칙). compose 가 넣는 빈 문자열은 기본값이다(parsePositiveInt). nginx 는 이 값들을 읽지 않는다(조각 17MB 는 계약 상수). - 구현 때 더한 검사(2026-10-30 —
env.validation.ts):MAIL_LARGE_FILE_USER_QUOTA_GB× 1,024 는MAIL_LARGE_FILE_MAX_SIZE_MB이상이어야 한다(한도까지 큰 파일 하나는 올릴 수 있게).MAIL_LARGE_FILE_MAX_SIZE_MB와MAIL_ATTACHMENT_MAX_SIZE_MB는 앞의 값을 정했을 때만 견준다.CLAMAV_HOST는 호스트 이름만(스킴 · 포트 없이).SHARE_REPORT_ADDRESS는 메일 주소. 운영에서SHARE_PUBLIC_URL의 호스트 이름이CORS_ORIGIN의 어느 호스트 이름과 같으면(포트만 다르거나 대소문자 · 끝의 점만 달라도) 기동하지 않는다 — 공개 호스트 막기가 소리 없이 꺼지므로(2026-10-30 리뷰). 공개 주소는CORS_ORIGIN에 넣지 않는다. 로컬에서 운영 이미지를 검증할 때도 이름을 나눈다(예:localhost와127.0.0.1).SHARE_PUBLIC_URL의 모양은 모든 환경에서 앞단 nginx 와 같은 규칙으로 본다(위 표 — 2026-10-30, 대문자 스킴 · 끝 점 · 앞뒤 공백도 거절).FILE_STORAGE_DIR를 compose 가 넣지 않으면 작업 폴더의files(컨테이너에서는/app/files, 호스트 개발에서는backend/files— gitignore)다.
로그
- 토큰 · 링크 주소는 남기지 않는다: 백엔드는 요청 주소의
/api/public-links/<토큰>을 길이만 남긴다(maskUrlForLog에 더한다 — 지금은/api/mail-images/<표>만,mask.ts:26). 경로의 대소문자는 가리지 않는다(라우팅이 가리지 않는다). 구현(2026-10-30): 경로 어디서든(//api/…·/api//…·/api/./…도)public-links조각 뒤의 조각을 길이만 남긴다 —files와 파일 id(UUID)는 남긴다.POST /api/share-link-lookups의 본문(주소)도 남기지 않는다 — 개수만. 앞단 nginx 접근 로그도 요청 줄 · Referer 의 토큰을 가린다(쿼리는 원래 빼고 —frontend/nginx.conf의log_format main_rid앞 map 들. 2026-10-30 리뷰로 경로 모양이 아니라 토큰 모양(43자 base64url)으로도 가린다 —//s/·%73같은 변형. 요청 줄에서는 둘까지 가리고, 43자가 아닌 값(UUID · 메일 id)은 그대로다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다 — 한계). nginx 오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다 — 공개 내려받기의 그 줄에는 토큰이 남는다(운영 담당만 읽는다). 외부 리버스 프록시도 같게(운영 확인). - 파일 이름은 남기지 않는다(직원의 글이다) — 파일 · 링크 id 와 크기만. 직원 주소는 가린다(
maskEmail). - 받는 쪽 IP 는 남기지 않는다 — 요청 수 한도의 셈(메모리)에만 쓴다. 앞단 nginx 접근 로그의 IP 는 지금 운영 로그 정책 그대로 — 바깥 사람의 개인 정보일 수 있어 보관 기간을 운영에서 정한다.
- 레벨: 링크 만들기 · 끊기 · 다 올림 · 보내지 않은 파일 지우기는 log, 위험 판정은 warn(판정 이름 · 파일 id), 조각 · 내려받기 성공은 남기지 않는다(반복되는 정상 흐름), 공개 경로의 404 · 409 · 410 은 debug, 검사기 장애가 이어지거나 디스크가 모자라면 error.
에러 코드 (11단계에 더함 — 제안, 문구는 레지스트리에서 정한다)
| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) |
|---|---|---|---|---|
FILE_003 |
409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요." |
FILE_004 |
400 | 조각의 Content-Digest 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) |
조각 | "파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요." |
FILE_005 |
409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." |
FILE_006 |
507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 ENOSPC |
만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
FILE_007 |
410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." |
FILE_008 |
409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." |
MAIL_016 |
400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." |
- 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(
MAIL_006·CONTACT_002처럼 상수와 견주는 테스트를 둘 수 없다). - 그 밖은 지금 코드를 쓴다 — 형식 400
FILE_001, 한 파일 · 조각 본문 413FILE_002, 개수 · 모양 400VAL_001, 없음 404RES_001, 요청 수 · 동시 수 429REQ_001, 엔진 503MAIL_001. - 410 · 507 은 상태 역매핑(
error-codes.ts의STATUS_TO_ERROR_CODE)에 넣지 않는다 — 이 코드들은BusinessException으로만 나온다. 앞단 nginx 는 백엔드의 4xx · 5xx 를 손대지 않는다(proxy_intercept_errors꺼짐). - 세 클라이언트의 목록 테스트에는 클라이언트가 실제로 분기하는 코드만 더한다(웹:
FILE_003·FILE_004·FILE_005·FILE_006·MAIL_016— 2026-10-30.FILE_008은 웹이 분기하지 않는다 — 받기는<a href>이동이고 검사 중이면 화면이 먼저 막는다).
운영 확인 (이 계약이 기대하는 것 — 고치는 일은 release-ops)
- 앞단 nginx:
/api/file-uploads본문 17MB · 읽기 90초./api/public-links/·/api/messages/*/large-files/는 응답 버퍼링을 끄고X-Accel-Buffering을 외부로 넘긴다 (기본값이면 느린 받는 사람마다 임시 파일을 1GB 까지 만든다 — 실시간 알림과 같은 방법)./s/는index.html+Cache-Control: no-cache·X-Robots-Tag: noindex, nofollow·Referrer-Policy: no-referrer. 접근 로그의 경로 토큰 가리기, 공개 호스트의 server(위 "공개 호스트"). 내려받기 동시 연결 상한(2026-10-30 리뷰 — 버퍼링을 끈 느린 받기는 연결 하나가 nginx 연결 둘과 백엔드 fd 를 잡아, 토큰 하나로 프론트 nginx 를 바닥낼 수 있다):limit_conn으로 공개 받기 IP 마다 10 · 앱 안 받기 IP 마다 30(사무실 NAT) · 서버 전체 공개 받기 400 · 앱 안 받기 200, 넘으면 429REQ_001(nginx 오류 JSON).worker_connections는 이 수와 SSE · 포털을 함께 버티게. 전체 상한을 경로마다 따로 둔다(2026-10-30 리뷰 — 처음 판은 두 경로가 500 을 함께 써, 바깥 IP 50개(IPv6 면 /64 하나)로 직원의 앱 안 받기가 모두 429 가 됐다). 경로의 대소문자 변형(/FILES/·/api/Messages/…)도 같은 상한 · 같은 흘려보내기를 탄다(2026-10-30 리뷰 — 처음 판은 글자 하나로 상한 ·X-Accel-Buffering넘김을 비켜 갔다). 구현(2026-10-30): 클라이언트 주소는X-Forwarded-For의 마지막 칸(외부 프록시가 이어 붙인 값 — 없으면 접속 주소). 워커마다worker_connections4096 · 열린 파일 8192. 받기 location 은/api/블록 안의~*정규식(id · 토큰 칸.+) — 600개를 붙잡아 401번째 공개 · 201번째 앱 안이 429 이고 그때도 다른 쪽 · SSE 는 200 임을 확인했다. 받기 머리글 버퍼 16k(한글 255자 이름이면 3.5KB). 공개 server 의 그 밖/api· 포털이 닫은 공개 API(대소문자 변형도)는 nginx 의 404RES_001JSON, 1m 넘는 본문은 413FILE_002JSON. 조각 경로는 오류 로그를error부터 남긴다(본문을 임시 파일에 받는다는 warn 이 조각마다 남았다). 백엔드는 종료할 때 진행 중인 파일 응답을 먼저 끊는다(BeforeApplicationShutdown— 실시간 알림과 같은 결, 브라우저는 Range 로 잇는다). 백엔드의 공개 호스트 판정은 nginx 가 넘기는Host($host— 소문자 · 포트 · 끝 점을 뗀 값)와 origin-form 요청 줄에 기댄다 — files 호스트의 server 도proxy_set_header Host $host를 둔다. - 외부 리버스 프록시:
files.호스트(같은 사설 주소:8080 · TLS), 조각 경로 본문 17MB 이상 · 읽기 150초, 내려받기 응답의 버퍼링 · 임시 파일 끔(또는X-Accel-Buffering따르기), 공개 경로는 경로까지 접근 로그에 남기지 않기, 받기 응답 머리글 버퍼 16k(NPM 은proxy_buffer_size·proxy_busy_buffers_size두 줄). - 볼륨
<PROJECT_NAME>_files— 크기 · 디스크(DB · 엔진과 다른 디스크 검토) · 감시, 백업에서 빼기,down -v가 지운다는 경고. Dockerfile 이/app/files를 node 소유로 만든다. - ClamAV 컨테이너(위 "악성 검사" — clamd 는 띄울 때
/tmp/clamav-*를 지운다: 검사 중에 죽으면 임시 파일이 쓰기 계층에 쌓였다). 사내 도메인을 꾸민 바깥 메일(From)은 DMARC 로 막는다(읽기 화면의 "보낸 사람 = 링크 주인" 판정이 기댄다 — 아래 "알려진 한계"). - 확인(2026-10-30 — 운영 nginx 설정): 버퍼링을 끈 내려받기에서
proxy_read_timeout은 돌지 않는다. 받는 쪽이 느려도 조금씩 받으면 이어지고(초당 120KB 로 80초), 60초 동안 하나도 받지 않으면send_timeout(기본 60초)이 끊고 동시 연결 수를 돌려준다. - 남은 확인(시험): 브라우저(Chrome · Edge · Firefox · Safari)가 우리 머리글로 끊긴 내려받기를 이어 받는지.
알려진 한계
- 같은 사본이라 받는 사람별 링크 · 기록 · 끊기가 없다 — 받은 수는 "링크를 가진 누군가"의 수다.
- 횟수는 0번째 바이트부터 받기 시작한 수다 — 범위 요청만으로 받으면 세지 않는다(열린 질문 1).
거꾸로 토큰을 가진 사람이
Range: bytes=0-0같은 작은 요청으로 상한을 빨리 써 버리면 같은 링크의 다른 받는 사람이 410 이 된다(IP 하나로 몇 분) — 닫힌 쪽이라 새는 것은 없고, 보낸 사람이 다시 보내면 된다(2026-10-30 리뷰 — 보낸 바이트 기준 상한은 처음 판에 넣지 않는다). - 끊어도 이미 시작한 내려받기는 끝까지 간다. 받아 간 파일은 되돌릴 수 없다.
- 인용 속 원래 링크는 글이다 — 우리 웹메일도 첨부처럼 보이지 않고, 원래 링크의 받은 수 · 상한 · 기간을 따른다(전달받은 사람이 받아도 원래 보낸 사람의 받은 수로 센다).
- 읽기 화면의 "보낸 사람 = 링크 주인" 판정은 머리글 From 을 믿는다 — 사내 도메인을 꾸민 메일이 그 직원의 진짜 토큰을 담으면 첨부처럼 보인다(파일은 진짜다. DMARC 가 막는다 — 운영 확인).
- 다른 메일 프로그램이 우리 임시 저장본을 그대로 보내면 머리글만 나가고 덩이는 없다. 우리 API 밖에서 지운 임시 저장본의 파일은 기간까지 보관량을 잡는다(보내지 않은 파일 목록에서 지울 수 있다).
- 텍스트만 있는 메일 · HTML 이 정화 한도를 넘은 메일의 덩이는 알아보지 않는다.
SHARE_PUBLIC_URL을 바꾸면 그 전 메일의 덩이를 알아보지 않는다.- 경로 토큰이 nginx 오류 로그 · 외부 프록시 로그에 남을 수 있다.
- 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다.
- ClamAV 1.4.6 은 zip 안 항목이 크기 한도(
MaxFileSize·MaxScanSize)를 넘을 때AlertExceedsMax를 켜도OK로 줄 때가 있다(한도를 작게 둔 시험 clamd 에서 확인 — 운영 값에서는MaxScanTime이 먼저 걸렸다). 7z · tar.gz 처럼 zip 이 아닌 압축은 시간 상한 없이MaxScanSize까지 풀어 검사가 오래 걸릴 수 있다. - 내려받기 동시 연결 상한은
X-Forwarded-For의 마지막 칸으로 센다 — 외부 프록시를 건너뛰어 8080 에 바로 닿는 길이 있으면 주소마다 상한을 피한다(경로마다 서버 전체 상한은 그대로). IPv6 는 주소 그대로 센다(/64 로 묶지 않는다). - 한 인스턴스 전제 — 올리기의 동시 수 · 만들기 차례 · 같은 조각 잠금은 서버 메모리, 파일은 로컬 볼륨이다. 인스턴스를 늘리면 공유 저장(S3 호환 — 같은 저장 포트의 새 어댑터)과 잠금으로 옮긴다 (nestjs 스킬의 "확장 때 옮길 것"에 더한다).
이 단계에서 하지 않는 것
기간 고르기 · 연장, 다시 보내기(다시 올리지 않고 새 링크), 받는 사람 확인 코드 · 비밀번호, 사내만(로그인해야 받기), 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 파일 하나만 끊기(끊기는 링크 단위), 설정 목록의 [링크 복사](토큰을 되살릴 수 없다 — 보낸 메일의 덩이에서), 대용량 첨부만 있는 메일의 목록 클립, 링크 → 일반 첨부로 되돌리기, 전달 원본 · 임시 저장본의 엔진 첨부를 링크로 바꾸기, 인용 속 원래 링크를 다시 싣기, 관리자 화면 · 퇴사자 처리(신고를 받은 링크 끊기 · 퇴사자 잠금은 13단계의 운영 도구 — 6절), 보낸 메일을 지울 때 파일도 지우기, 모바일 앱, 파일 저장소 연동, 다른 PC · 다른 브라우저에서 이어 올리기(같은 브라우저에서 같은 파일을 다시 고르면 이어진다).
정한 것 (열린 질문이었던 것 — 2026-10-30 추천대로)
- 횟수 상한의 범위 요청 우회 — 0번째 바이트를 받지 않는 범위 요청은 세지 않아, 도구를 쓰면 상한을 피할 수 있다. 추천: 처음 판은 그대로(브라우저 · 내려받기 관리자는 0번부터 받는다)이고 한계로 적는다. 남용이 보이면 "보낸 바이트 ÷ 파일 크기" 상한을 함께 둔다.
- 위험 판정 알림 — 추천: 실시간 이벤트 + 목록 상태(처음 판). 연결이 없던 직원까지 알리는 받은편지함 시스템 메일은 털린 계정을 알아채는 데 좋지만 엔진에 메일을 넣는 길이 새로 든다 — 13단계(관리자 절차)와 함께 다시 본다.
- 검사기 장애가 길 때 — 추천: 받기를 막은 채(검사 중) 두고 error 로그로 운영이 고친다(닫힌 쪽). 대안 — 몇 시간 뒤 "검사하지 못함"으로 연다(받는 쪽이 오래 기다리지 않지만 검사 없이 퍼진다).
- ClamAV 크기 한도 — 기본 2,047MB 가 운영에 쓸 clamd 판에서 끝까지 검사되는지 실측한다(자료는 "2GB 넘는 파일은 검사하지 못함" — 판마다 다를 수 있다). 안 되면 기본값을 낮춘다.
실측(2026-10-30, ClamAV 1.4.6): 2,047MB 는 끝까지 검사된다(
OK) — 기본값 그대로 둔다. 1.4.6 은 2GiB − 1 바이트 넘게는 검사하지 못한다. - 위험 파일의 바이트 — 추천: 곧 지운다(디스크에 악성 파일을 두지 않고 보관량에서 바로 뺀다). 오탐을 다시 보려면 서명 갱신 뒤 다시 올린다.
- 일반 첨부 목록 밖 · 대용량 목록 안 형식의 작은 파일(5MB 짜리 mp4 · dwg 등) — 지금 일반 첨부로는 보낼 수 없는 형식이다. 추천: 크기와 상관없이 링크로(쓰기 화면이 자동으로 — 이 쓰기 화면에서 고른 파일만이라 위 "손으로 「링크로 보내기」" 규칙과 맞는다).
- 보내지 않은 파일 목록에 올리는 중인 것도 넣을지 — 추천: 넣는다(이 계약). 보관량을 잡고 있어, 창을 닫아 멈춘 올리기를 24시간 전에 지울 길이 된다.
- 공개 호스트의 화면 묶음 — 추천: 처음 판은 같은 SPA 묶음 + nginx 가
/s/· 자산 ·/api/public-links/만 연다. 공개 화면에 앱 코드를 싣지 않으려면 Vite 다중 입구(share.html)를 쓴다. FILE_006의 HTTP 상태 — 추천: 507(저장 공간을 정확히 말하고, 클라이언트의 자동 다시 시도 대상(502 · 503 · 504)과 상태로 갈린다). 대안 503.- 형식 검사의 MIME — 추천: 확장자 허용 목록만(위 "올리기" 1). 보안 규칙(
.claude/rules/security.md"확장자와 MIME 을 둘 다")의 예외라 규칙 문서에 까닭과 함께 적는다(rules 담당). - 인용 속 링크 상태를 묻는 길 — 추천: 주소 목록을 보내는
POST /api/share-link-lookups(이 계약 — 저장하지 않은 쓰기 화면의 인용에도 쓴다). 대안 — 원본 메일 id 로 서버가 본문에서 찾기(GET /api/messages/{id}/…) — 클라이언트가 주소를 고르지 않아도 되지만 임시 저장본 · 고친 인용은 따로 다뤄야 한다.
5. 계정 보안 — 12단계 (2026-10-31 확정)
2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 근거는 Stalwart 0.16.27 개발 엔진 실험(2026-10-31 — 엔진 TOTP · 앱 비밀번호 · 웹훅 · 자동 차단).
- 엔진이 하는 것: 코드 확인(앞뒤 30초까지), 2단계를 켠 계정의 비밀번호만 하는 IMAP · SMTP 로그인 막기, 앱 비밀번호 만들기(값은 엔진이 정한다), 로그인 이벤트 보내기(웹훅).
- 서버가 하는 것(엔진이 하지 않는 것): 등록 때 첫 코드 확인, 같은 코드 다시 쓰기 막기, 코드 실패 한도, 웹메일 로그인에서 앱 비밀번호 거절, 로그인 기록 저장 · 보존.
- 웹(브라우저)만이다. 모바일 앱은 앱 착수 때 이 절에 더한다.
공유 계약을 바꾸는 곳 (한데 모음)
POST /api/auth/login: 요청에otpCode(선택), 응답에status(signedIn·twoFactorRequired).twoFactorRequired는 200 이고 쿠키가 없다 —email·name은null.PUT /api/auth/password: 요청에otpCode— 2단계 인증을 켠 직원만 필수.- 새 API:
/api/two-factor·/api/two-factor/enrollments·/api/two-factor/activation·/api/two-factor/deactivation·/api/app-passwords·/api/auth/sessions·/api/login-events. - 엔진만 부르는 길
POST /api/internal/mail-engine-events— 앞단 nginx 가 막는다(404RES_001). - 에러 코드
AUTH_006~AUTH_009. - 루트 env
MAIL_WEBHOOK_SECRET(setup · backend 공용 — 운영 필수).
한도 · 규칙
| 항목 | 값 |
|---|---|
| 코드 | 숫자 6자리 · 30초 · SHA-1(인증 앱 기본값). 엔진은 앞뒤 한 단계(±30초)까지 받는다 |
| 같은 코드 다시 쓰기 | 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다(코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두) → AUTH_006. 같은 코드가 다른 요청에서 확인되는 중이어도 AUTH_006(코드 실패로 센다) |
| 코드 실패 | 같은 주소 15분 5회 · 24시간 20회. 같은 IP 는 로그인 실패 한도(1분 20회)에 함께 센다 → 429 REQ_001 |
| 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) |
| 앱 비밀번호 | 엔진 상한(기본 5개 — x:Authentication.maxAppPasswords). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 |
| 로그인 기록 | 90일 보존(날마다 정리) |
| 로그인된 브라우저 | 끝나지 않은 웹메일 세션 — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일) |
로그인 (POST /api/auth/login) — 1절에 더하는 것
// 1) 비밀번호
{ "email": "ara.kim@arasystem.it.kr", "password": "…" }
// 2단계 인증을 켜지 않은 직원 → 200 + Set-Cookie (지금과 같다)
{ "status": "signedIn", "email": "ara.kim@arasystem.it.kr", "name": "김아라" }
// 켠 직원이고 비밀번호가 맞음 → 200, 쿠키 없음
{ "status": "twoFactorRequired", "email": null, "name": null }
// 2) 코드 — 비밀번호를 한 번 더 싣는다(서버는 비밀번호를 들고 있지 않는다)
{ "email": "ara.kim@arasystem.it.kr", "password": "…", "otpCode": "123456" }
// → 200 + Set-Cookie { "status": "signedIn", "email": "…", "name": "…" }
| 실패 | 코드 |
|---|---|
비밀번호 틀림(코드 단계에서도 — 그사이 바뀜) · 앱 비밀번호 모양(app + 1자 + 36자 · 40자 — 엔진에 묻지 않고 실패로 센다) · 그 밖의 1절 규칙 |
401 AUTH_004 |
| 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 AUTH_006 — 401 이 아닌 까닭은 AUTH_005 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다) |
otpCode 가 숫자 6자리가 아님 |
400 VAL_001 |
| 코드 실패 한도 · 1절의 한도 | 429 REQ_001 |
- 서버의 차례
- 앱 비밀번호 모양이면 거절한다.
- 1절처럼 실패 한 번을 차지하고 엔진에 본인 Basic 으로 묻는다(지금의
verifyCredentials). - 200 → 2단계 인증이 꺼진 직원이다. 로그인한다(코드가 와도 보지 않는다).
- 402(비밀번호는 맞고 엔진이 2단계를 요구) → 대리 접근 프로필로 정식 주소를 얻는다(엔진은 별칭 권한보다 MFA 를 먼저 판정한다).
막는 계정(관리자 등)이면
AUTH_004로 답하고 비밀번호 실패로 센다 — 맞는 비밀번호만 세지 않으면 그 차이로 비밀번호가 드러난다. 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다. 그다음 차지한 비밀번호 실패를 돌려준다.otpCode가 없으면twoFactorRequired로 답한다. 있으면: 다시 쓴 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진POST /api/auth(accountSecret+mfaToken)에 묻는다.authenticated면 로그인한다(차지한 것을 돌려주고 코드를 2분 기억).failure면AUTH_006. - 401 →
AUTH_004.
- 12단계 전에는 402 를 엔진 장애(503
MAIL_001)로 다뤘다 — 12단계에서 고쳤다. 그때는 운영자가 엔진 관리 화면에서 OTP 를 켠 직원이 웹메일에 들어오지 못했고, 4단계 비밀번호 바꾸기도 같았다. - 코드 단계에서도 비밀번호를 엔진 Basic 으로 먼저 보는 까닭:
/api/auth의failure는 비밀번호 틀림과 코드 틀림을 가르지 않는다. 비밀번호를 추측하는 요청이 코드 실패로 세지지 않게 한다. twoFactorRequired는 비밀번호가 맞았다는 뜻이다(2단계 로그인의 성질 — 코드 없이는 들어오지 못한다). 비밀번호 추측은 1절의 실패 한도가 그대로 막는다.- 엔진
/api/auth가 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(쓰지 않으면 10분 뒤 사라진다) — 쓰지 않는다.redirectUri는 https 주소여야 한다(https://<MAIL_DOMAIN>/).clientId는 고정 이름(arasystem-webmail)이다. - 클라이언트
- 코드 단계는 비밀번호를 화면 메모리에만 둔다. 성공 · [다른 계정으로 로그인] · 화면을 떠날 때 지운다. 코드 단계에 10분 넘게 머물면 비밀번호를 지우고 첫 단계로 돌아간다(새로고침도 첫 단계).
- 코드 칸은
inputmode="numeric"·autocomplete="one-time-code"이다. 붙여 넣은 글에서 숫자만 남긴다(공백 ·-). AUTH_006은 칸 아래에 보이고(알림 없음) 칸을 비운다. 6자리가 아니면 보내지 않고 칸 아래에AUTH_007사전 문구를 보인다.AUTH_004는 첫 단계로 돌아가 위쪽 오류 상자에 보인다(지금 로그인 화면과 같은 자리 — 주소는 남기고 비밀번호는 비우며 칸에aria-invalid).- 10분이 되면 비밀번호를 바로 지운다. [확인]을 누르면 첫 단계로 돌아가 위쪽 상자에 "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요."를 보인다.
비밀번호 바꾸기 (PUT /api/auth/password) — 4단계에 더하는 것
{ "currentPassword": "…", "newPassword": "…", "otpCode": "123456" } // otpCode — 2단계 인증을 켠 직원만
- 비밀번호를 먼저 본인 Basic 으로 확인한다 — 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은
forbidden으로 거절하기 때문이다.- 200(2단계 꺼짐) → 지금처럼 본인 자격으로 바꾼다.
- 402(2단계 켜짐, 비밀번호 맞음) → 게이트웨이 대리 접근으로
x:AccountPassword/set(currentSecret+secret+otpAuth.otpCode)을 보낸다. 2단계 인증은 그대로 남는다.
otpCode가 없으면 400AUTH_007(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다). 틀리거나 다시 쓴 코드면 400AUTH_006(코드 실패로 센다).otpCode: null은 400VAL_001(로그인도 같다).- 새 비밀번호가 엔진 규칙에 어긋나 거절되면 그 코드는 쓰지 않은 것으로 둔다. 바뀌었거나 결과를 모르면 쓴 것으로 본다(2분 다시 받지 않는다).
- 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고
AUTH_005다(세지 않는다 — 4단계 규칙과 같다). - 바뀌면 그 직원의 앱 비밀번호를 모두 지운다(2단계 인증과 상관없이 — 대리 접근
x:AppPassword/set의 destroy, Google 과 같다).- 비밀번호가 샌 동안 누군가 웹메일에서 만든 앱 비밀번호가 남지 않게 한다.
- 지우기에 실패해도 200 이고 오류 로그를 남긴다. 결과를 모르는 실패(503)에도 지운다 — 다른 세션을 끊는 규칙과 같다.
- 화면이 미리 안내한다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다).
- 구현 때 확인: 2단계를 켠 채 대리 접근으로 바꾸기의 성공 경로(조사 때는 실패 경로만 돌렸다), 바꾼 뒤
otpUrl이 남는지.
2단계 인증 (/api/two-factor)
// GET /api/two-factor → 200
{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null
// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store)
{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)",
"otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30",
"expiresAt": "2026-10-31T00:22:00.000Z" }
// POST /api/two-factor/activation → 200 (GET 과 같은 모양)
{ "enrollmentId": "…", "otpCode": "123456", "currentPassword": "…" }
// POST /api/two-factor/deactivation → 200 (GET 과 같은 모양)
{ "currentPassword": "…", "otpCode": "123456" }
- 엔진이 기준이다. 켜짐은 엔진
x:AccountPassword/get에otpAuth.otpUrl이 있는지로 본다(대리 접근). 서버는 켠 때(enabledAt)만 둔다 — 엔진에서 꺼졌으면 지운다. - 비밀(
secret): 서버가 만든 160비트를 base32(RFC 4648, 채움 없음 — 32자)로 적는다. 등록을 기다리는 동안(10분)만 서버 메모리에 둔다. 켠 뒤에는 엔진에만 있다 — 서버 · DB · 로그 어디에도 두지 않는다. - QR 은 화면이
otpauthUrl로 그린다. 새 프론트 의존성이 든다 — 작은 MIT QR 부호기 하나,v-html없이 SVG 로 그린다(고르는 것은 구현 때). - 켜기
- 받은 코드를 등록의 비밀로 먼저 확인한다(±1 단계). 엔진은 등록 때 코드를 보지 않는다 — 잘못 등록하면 직원이 끄지도 못한다.
- 맞으면 지금 비밀번호를 본인 Basic 으로 본다 — 틀리면
AUTH_005(로그인 실패 한도로 센다 — 비밀번호 바꾸기와 같다. 그 코드는 쓰지 않은 것으로 남아 비밀번호만 고쳐 같은 코드로 다시 보낼 수 있다), 402 면 그사이 다른 곳에서 켜진 것이라AUTH_008. - 대리 접근으로
currentSecret+otpAuth.otpUrl을 보낸다. 켠 코드는 2분 기억하고 등록의 비밀은 바로 지운다(enabledAt기록이 실패해도 성공 — 엔진이 기준).
- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로
currentSecret+otpAuth: { otpCode, otpUrl: null }. 이미 꺼져 있으면 200 이다(같은 결과). 엔진에서 꺼진 뒤enabledAt기록 지우기가 실패해도 200 이다(error 로그 — 엔진이 기준). - 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429
REQ_001. - 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다).
- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README).
| 실패 | 코드 |
|---|---|
| 코드 틀림 · 다시 쓴 코드 | 400 AUTH_006 |
| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 AUTH_005 |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 AUTH_008 — 화면은 처음부터 다시 |
| 형식 오류 | 400 VAL_001 |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 REQ_001 |
| 웹메일로 다루지 않는 계정 | 401 AUTH_001 |
| 엔진 장애 · 결과를 모르는 실패 | 503 MAIL_001 — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) |
앱 비밀번호 (/api/app-passwords)
// GET /api/app-passwords → 200 (한 쪽 — 엔진 상한이 작다)
{ "items": [ { "id": "b", "name": "회사 PC 아웃룩", "createdAt": "2026-10-31T00:20:00.000Z",
"lastUsed": { "at": "2026-10-31T00:41:00.000Z", "method": "imap", "ip": "211.186.95.252" } } ],
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 1 } }
// POST /api/app-passwords → 201 (Cache-Control: no-store) — password 는 이 응답에만
{ "name": "회사 PC 아웃룩" }
→ { "id": "c", "name": "회사 PC 아웃룩", "createdAt": "…", "lastUsed": null, "password": "app_…(40자)" }
// DELETE /api/app-passwords/{id} → 200 (data: null) — 이미 없거나 모양이 틀린 id 도 200(아무것도 하지 않는다)
- 만들기는 대리 접근
x:AppPassword/set의 create 다:{ description: 이름, permissions: { "@type": "Replace", "permissions": { "authenticate": true, "imapAuthenticate": true, "emailSend": true } } }. 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다. 만료 · 허용 IP 는 두지 않는다. - 값(
password)은 엔진이 만들어 만들 때 한 번만 준다. 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음). - 상한을 넘으면 엔진이
overQuota로 거절한다 → 409AUTH_009. 이름이 비었거나 40자를 넘거나 이미 있는 이름(앞뒤 공백을 지운 값이 같음)이면 400VAL_001— 화면이 먼저 보고 칸 아래에 알린다. - 목록은 최근에 만든 것이 위다.
화면의 "n / 5개"는 엔진 기본값 5 를 상수로 쓴다 — 엔진 상한을 바꾸면 화면 상수도 함께 바꾼다.
같은 직원의 만들기는 한 번에 하나다 — 처리 중이면 429
REQ_001(같은 이름 두 요청이 함께 만들어지지 않게). - 2단계 인증을 켜지 않은 직원도 만든다(기기마다 따로 끊기 편하다).
- 쓰는 곳: IMAP 993 · SMTP 465(사용자 이름 = 메일 주소).
- 쓰이지 않는 곳: 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기.
- 웹메일 비밀번호를 바꾸면 모두 지워진다(위 "비밀번호 바꾸기").
- 이름 바꾸기 · 만료 · 허용 IP 는 처음 판에 없다.
- 마지막 사용: 엔진에 칸이 없어 서버가 웹훅의
auth.success(앱 비밀번호 —id가 자격 번호)로 만든다. 엔진은 지운 번호를 다시 쓴다 — 서버 기록은 같은 번호 · 같은createdAt일 때만 잇는다(만들 때 서버가 기록을 새로 둔다). - 지운 앱 비밀번호로 이미 열린 IMAP 연결은 엔진이 끊지 않는다(알려진 한계 — 다음 연결부터 막힌다).
로그인된 브라우저 (/api/auth/sessions)
경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다).
// GET /api/auth/sessions → 200 (지금 세션이 맨 앞, 그다음 최근 활동 차례)
{ "items": [ { "id": "…", "createdAt": "…", "lastActiveAt": "…", "ip": "211.186.95.252",
"device": { "browser": "Chrome", "os": "Windows" }, "isCurrent": true } ],
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 3 } }
// DELETE /api/auth/sessions/{id} → 200 (data: null)
// 지금 세션 → 400 VAL_001 (로그아웃을 쓴다) · 남의 것 · 없는 것 · 끝난 것 → 404 RES_001
// DELETE /api/auth/sessions → 200 { "ended": 2 } — 이 요청의 세션만 남기고 모두 끊는다
lastActiveAt은 마지막 갱신(회전) 때다 — 없으면 로그인 때.ip·device는 로그인 · 갱신 때 서버가 둔다.- IP 는
TRUST_PROXY기준 클라이언트 IP 다. - 브라우저는 서버가 User-Agent 를 짧은 이름(
Edge·Chrome·Firefox·Safari·Whale·Samsung Internet/Windows·macOS·iOS·Android·Linux·ChromeOS)으로 바꾼다 — 서버 순수 함수 하나, 모르면null. - 12단계 전에 연 세션은 둘 다
null이다.
- IP 는
- 끊은 세션은 바로 막는다. 액세스 토큰에 세션 id(
sid)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401AUTH_001로 막는다 — DB 를 묻지 않는다.- 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다.
- 로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 감지로 세션을 끊을 때도 같다 — 1절의 "끊어도 만료까지 유효"를 바꾼다.
- 서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다. 12단계 전에 발급한 토큰(
sid없음)은 지금처럼 만료까지 쓰인다.
- 리프레시 쿠키가 없거나 남의 것이면
DELETE /api/auth/sessions는 모두 끊는다(비밀번호 바꾸기와 같다). 그때isCurrent는 모두 false 다. - 페이지: 기본 20 · 최대 100(
limit), 위치 커서(shared/utils/offset-cursor.ts).
로그인 기록 (GET /api/login-events)
// GET /api/login-events?limit=20&cursor=…&isSuccess=false → 200 (최근 차례)
{ "items": [
{ "id": "…", "occurredAt": "…", "method": "webmail", "isSuccess": true, "isTwoFactor": true, "failureReason": null,
"ip": "211.186.95.252", "device": { "browser": "Edge", "os": "Windows" }, "appPassword": null },
{ "id": "…", "occurredAt": "…", "method": "imap", "isSuccess": true, "isTwoFactor": false, "failureReason": null,
"ip": "…", "device": null, "appPassword": { "id": "b", "name": "회사 PC 아웃룩" } },
{ "id": "…", "occurredAt": "…", "method": "smtp", "isSuccess": false, "isTwoFactor": false, "failureReason": "appPasswordRequired",
"ip": "…", "device": null, "appPassword": null } ],
"pageInfo": { "hasNext": true, "nextCursor": "…", "total": 143 } }
| 칸 | 값 |
|---|---|
method |
webmail · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기) |
isTwoFactor |
웹메일 성공이 코드까지 거쳤는지 |
failureReason |
wrongPassword(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · wrongCode(코드 틀림 · 다시 쓴 코드) · appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 auth.mfa-required) |
appPassword |
메일 프로그램이 앱 비밀번호로 들어온 경우. name 은 그때의 이름이다(지운 뒤에도 남는다) |
- 남기는 것
- 웹메일: 서버가 판정한 로그인 시도(성공 ·
AUTH_004·AUTH_006). 한도로 막은 요청(429) · 사내 도메인이 아닌 주소 · 막은 계정은 남기지 않는다. 사내 목록에 없는 주소의AUTH_004도 남기지 않는다(아무 주소나 뿌려 표를 채우지 못하게 — 웹훅과 같은 규칙, 사내 목록을 읽지 못하면 남긴다). 2단계 로그인의 한도 · 기록은 엔진이 알려 준 정식 주소로 잡는다(별칭으로 들어와도). - 메일 프로그램: 엔진 웹훅(아래)의 IMAP · SMTP 이벤트.
- 웹메일: 서버가 판정한 로그인 시도(성공 ·
- 남기지 않는 것: 갱신(
/auth/refresh), 게이트웨이 대리 접근, 엔진 HTTP 리스너의 이벤트(웹메일이 백엔드 IP 로 남기는 것 — 서버 기록과 겹친다). - 90일 보존, 커서 페이지(기본 20 · 최대 100).
isSuccess=false면 실패만 준다. - 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만).
엔진 웹훅 받기 (POST /api/internal/mail-engine-events)
- 엔진만 부른다. 세 겹으로 막는다:
- 앞단 nginx 가
/api/internal/을 404RES_001로 닫는다(두 server 모두). - 서버는 접속 주소(
X-Forwarded-For가 아니라 소켓)가 앱 네트워크(APP_NETWORK_SUBNET)인지 본다. - 본문 원문의 HMAC-SHA256(
X-Signature, base64)이MAIL_WEBHOOK_SECRET으로 맞는지 본다(상수 시간 비교). 하나라도 아니면 401AUTH_001이다 — 본문을 쓰지 않고, warn 로그에 접속 주소만 남긴다.
- 앞단 nginx 가
- 본문은
{"events":[{ "id", "createdAt", "type", "data": {…} }]}이다. 서버가 쓰는 것:type:auth.success·auth.failed·auth.mfa-requireddata.listenerId:imap…→ imap,submission…·smtp→ smtp. 그 밖(http등)은 버린다.data.remoteIp·data.accountName(주소, 앱 비밀번호면 로컬 이름) ·data.detailsdata.id: 앱 비밀번호 번호.details가 앱 비밀번호 표시일 때만 쓴다 — IMAP 실패의id는 명령 태그다.
- 사내 도메인의 직원만 남긴다. 사내 목록에 없는 주소의 실패와 막은 계정은 버린다(엔진은 서버 전체의 이벤트를 보낸다).
- 겹침은 엔진 이벤트
id가 아니라 내용 지문(type + createdAt + data)의 유일 인덱스로 거른다 — 엔진은 다시 보낼 때마다 id 를 새로 붙이고, 실패한 묶음에 새 이벤트를 합쳐 보낸다. createdAt 이 초 단위라 같은 초 · 같은 연결의 똑같은 이벤트(명령 태그가 없는 SMTP 실패 둘 등)는 하나로 합쳐진다(알려진 한계). - DB 에 넣은 뒤 200 으로 답한다(1,000행 안팎으로 나눠 넣는다 — 한 문장의 매개변수 상한). DB 가 실패하면 5xx 로 답해 엔진이 다시 보내게 한다(지문 덕에 겹치지 않는다). 빈 묶음
{"events":[]}도 200 이다 — setup 이 받는 곳을 이것으로 확인한다. - setup 이 엔진 웹훅에 붙이는 머리글
X-Arasystem-Setup(setup 표시 · 소금 · 키 지문)은 쓰지 않고 남기지 않는다. 거절 warn 은 같은 까닭이면 1분에 한 번만(그사이 거절한 수와 함께) 남긴다. - 이 경로만 다르게 둔다
- 요청 수 한도가 없다(
@SkipThrottle). 출처 검사는Origin이 없어 통과한다. - JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000) — 공격이 오면 1초 묶음이 커진다.
- 요청 수 한도가 없다(
- 엔진의 대기열은 메모리에 있다. 서버가 5분 넘게 받지 못하거나 엔진이 다시 뜨면 그동안의 메일 프로그램 기록은 빠진다(알려진 한계). 한도(10,000개 · 4MB)를 넘는 묶음도 거절하므로, 실패 이벤트가 그만큼 쏟아지는 공격 동안에는 엔진이 묶음을 버릴 때까지(5분) 기록이 빠질 수 있다.
엔진 설정 (setup — release-ops)
x:WebHook하나를 둔다. 0.16.27 웹훅에는 이름 칸이 없어 머리글X-Arasystem-Setup: [setup.mjs] key=<소금>.<지문>으로 찾는다(엔진이 서명 키를****로만 돌려주므로 키가 바뀌었는지도 이 지문으로 안다 — 소금은 쓸 때마다 새로). 엔진 DNS 가 Custom(운영 — 리졸버)이어도backend를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS 다(2026-10-31 확인).- url
http://backend:<BACKEND_PORT>/api/internal/mail-engine-events,signatureKey=MAIL_WEBHOOK_SECRET eventsPolicy: include+auth.success·auth.failed·auth.mfa-required— 기본(exclude+ 빈 목록)은 모든 이벤트를 보낸다.level: trace— 이 판은 웹훅에 수준을 쓰지 않지만 새 판 문서는 쓴다(기본 info 면 실패 이벤트가 빠진다).- throttle 1000ms · timeout 5000ms · discardAfter 300000ms · lossy false. 바꾼 뒤 리로드한다.
- url
MAIL_WEBHOOK_SECRET이 비면(개발) 웹훅을 만들지 않고[주의]를 낸다(있던 것은 끈다). 운영은 비면 setup 이 멈춘다.- 엔진
/api/auth는 백엔드가 앱 네트워크로(MAIL_JMAP_URL의/api/auth) 묻는다. 공개 443 은 지금처럼 403 이다. - 앱 비밀번호 상한(
maxAppPasswords)은 엔진 기본 5 그대로 둔다.
환경 변수
MAIL_WEBHOOK_SECRET— 루트 env. 영문 · 숫자 32자 이상(Node--env-file이#부터 자른다).- setup 과 backend(compose
environment)가 읽는다. 운영 필수이고 예시 값은 거부한다(env.validation.ts). - 바꾸면 setup 을 다시 돌리고 backend 를 다시 띄운다.
로그
- 코드 · 비밀(
secret·otpauthUrl) · 앱 비밀번호 값 · 비밀번호는 어떤 수준에도 남기지 않는다. 앱 비밀번호 이름(사용자 글)도 남기지 않는다(id 만). - info: 2단계 켜기 · 끄기, 앱 비밀번호 만들기 · 지우기, 다른 세션 끊기 — 가린 주소 + id.
- warn: 코드 틀림, 서명 · 주소가 맞지 않는 웹훅.
- 웹훅 수신은 남기지 않는다(반복되는 정상 흐름). 버린 수 · 남긴 수는 debug.
- 엔진 오류의 설명 문장은 남기지 않는다(지금 규칙).
에러 코드 (12단계에 더함 — 제안, 문구는 레지스트리에서 정한다)
| 코드 | HTTP | 상황 | 문구(제안) |
|---|---|---|---|
AUTH_006 |
400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." |
AUTH_007 |
400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." |
AUTH_008 |
409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
AUTH_009 |
409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
웹이 분기하는 코드: 넷 모두.
확장 때 옮길 것 (nestjs 스킬의 Redis 목록에 더한다)
기다리는 등록(비밀 10분), 다시 쓴 코드 기억(2분), 코드 실패 카운터, 같은 직원의 2단계 바꾸기 잠금, 끊은 세션 id 기억(1시간) — 모두 프로세스 메모리다. 인스턴스를 늘리면 공유 저장소로 옮긴다.
운영 확인
- setup 뒤
[확인]줄에 웹훅이 켜졌는지 본다. 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다. - 휴대폰을 잃은 직원: 엔진 관리 화면 › 그 계정 › 자격 증명(Password)의 OTP 를 지운다(mail/README 에 절차 — 구현 때 실물로 확인).
- 직원에게 알릴 것: 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다.
알려진 한계
- IMAP · SMTP 에서 2단계를 켠 계정에 맞는 비밀번호를 넣으면 "2단계 필요"가 나와 비밀번호가 맞았는지 알 수 있다. 엔진 자동 차단은 틀린 시도만 센다(들어오지는 못한다).
- 2단계를 켜거나 앱 비밀번호를 지워도 이미 열린 IMAP 연결은 엔진이 끊지 않는다(지운 앱 비밀번호의 연결은 다음 명령에서 멈춘다).
- 웹훅이 잃는 기록이 있다(엔진 재시작 · 서버가 5분 넘게 받지 못함 · 한도를 넘는 묶음). 같은 초 · 같은 연결의 똑같은 이벤트는 하나로 합쳐진다.
- 성공에 쓴 코드는 2분 동안 다시 받지 않으므로, 30초 안에 두 곳에서 연달아 로그인하면 두 번째는
AUTH_006이고 다음 코드를 기다려야 한다. - 로그인 기록의
appPassword.name은 웹메일 밖(엔진 관리 화면)에서 만든 앱 비밀번호면null이다.device는 웹메일 기록에만 있고, User-Agent 가 없거나 알아보지 못하면null이다. - 끊은 세션
DELETE /api/auth/sessions/{id}의 모양이 틀린 id 는 404RES_001(없는 것과 같다). - 엔진 관리 화면에서 켠 2단계 인증은
enabledAt이 없다. - 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
이 단계에서 하지 않는 것
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기, 앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱.
정한 것 (2026-10-31 추천대로)
- 인증 앱에 보일 이름은 "ARA SYSTEM" 이다(앱에는 「ARA SYSTEM (주소)」). 엔진이 받는지 구현 때 확인한다 — 안 되면 "ARASYSTEM".
- 코드 실패 한도는 같은 주소 15분 5회 · 24시간 20회다(위 "한도 · 규칙"). 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% 다.
- 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다.
- 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기").
- 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
6. 전환 준비 — 13단계 (2026-10-31 확정)
운영 도구 · 계정 잠금 · 비밀번호 바꾸기 강제 · 2단계 인증 필수 · 받은편지함 알림 · 문의 연락처. 백업 · 감시 · 휴지통 비우기 시각 · 메일 프로그램 자동 설정 · 관리자 계정 단단히는 인프라 몫이다(mail/README). 근거: 개발 엔진 실험(2026-10-31) — 계정 막기 변형 표, 비밀번호 만료 동작, 관리자 대리 로그인, 엔진 변경과 웹메일 세션.
공유 계약을 바꾸는 곳 (한데 모음)
POST /api/auth/login(signedIn) ·GET /api/auth/me·POST /api/auth/refresh응답에restriction(null·passwordChange·twoFactorSetup)과twoFactorDeadline(2단계 필수의 유예 중이고 이 직원이 아직 2단계를 켜지 않았으면 시작일 0시(KST)의 시각 — ISO 8601 UTC, 아니면null)을 더한다.- 두 칸은 늘 싣는다(refresh 의 유예 응답 — 쿠키 없는 200 — 도). 칸이 없으면 클라이언트는 제한 없음으로 읽는다.
- me · refresh 의
restriction은 액세스 토큰이 아니라 세션 표의 지금 값이다 — 제한을 푼 직후 옛 토큰으로 새로고침해도 마친 단계가 다시 보이지 않게.
- 제한된 세션으로 다른 API 를 부르면 403
AUTH_010이다. GET /api/two-factor응답에isRequired(이 직원에게 필수인가) ·deadline.- 새 공개 API
GET /api/support-contact(로그인 없음). - 운영 도구(서버 안 명령)는 API 가 아니다 — 아래 "운영 도구".
- 에러 코드
AUTH_010.
엔진 사실 (이 절이 기대는 것 — 0.16.27)
- 엔진 계정에는 "끄기" 칸이 없다. 로그인 권한(
authenticate)을 빼면 비밀번호 · 앱 비밀번호 · 대리 접근(웹메일)이 모두 막히고 메일은 계속 받는다. 되돌리기 쉽다. - 비밀번호 자격의 만료(
expiresAt을 지난 날로)는 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다: 본인 Basic 은 200 이지만 권한이 다섯 (authenticate·authenticateWithAlias·sysAccountPasswordGet·sysAccountPasswordUpdate·emailReceive)뿐이고, 본인이 바꾸면 엔진이 만료를 스스로 푼다. 앱 비밀번호는 영향이 없다(자체 만료).- 웹메일(대리 접근)도 세션은 열리지만 메일 메서드가
forbidden이다 — 그대로면 로그인 뒤 메일 오류만 보인다. 그래서 서버가 로그인 · 갱신 때 알아채 새 비밀번호 단계로 보낸다(아래 "비밀번호 바꾸기 강제"). 그 밖의 메일 API 가 만료를 만나면(운영 도구가 재설정한 직후 등) 401AUTH_001이다 — 화면이 바로 갱신해 그 단계로(세션이 끊겼으면 로그인 화면으로) 간다. - 만료된 동안은 대리 접근으로 메일을 넣거나 앱 비밀번호를 지우는 일도 막힌다(개발 엔진 확인 — 세션에 메일 계정이 없어
Mailbox/get·Email/import·SieveScript를 부를 수 없고x:AppPassword는forbidden, 되는 것은 blob 올리기 ·x:AccountPassword/get·set·/api/account) — 그래서 운영 도구는 그 일을 만료를 걸기 전에 한다(아래 "운영 도구"). - 만료된 비밀번호로는 IMAP 로그인이 응답 없이 끊기고 SMTP 인증은 550 이다 — 메일 옮기기(imapsync)가 새 엔진에 들어가지 못한다.
- 만료 시각을 미래로 두면 그때까지 IMAP · SMTP · JMAP 이 모두 정상이고, 그 시각이 지나면 엔진이 스스로 만료로 다룬다.
null은 만료 없음이다(2026-11-02 개발 엔진 확인 — 20초 뒤로 걸고 지나기 전 · 뒤를 보았다). 운영 도구의 "이전 중"은 이것과 실행 기록으로 가른다(아래 "메일 이전"). - 엔진의 비밀번호 기본 만료(
x:Authentication의passwordDefaultExpiry)가 켜져 있으면 직원이 비밀번호를 바꿀 때 엔진이 "지금 + 기본 만료"를 저장하고, 끈 뒤에도 그 만료가 지날 때까지 남긴다(0.16.27 소스registry/mapping/account.rs). 그래서 미래 만료만으로는 이전 중을 가를 수 없다. 기본 만료는 켜지 않는다(켜면 직원 비밀번호가 주기적으로 만료된다).
- 웹메일(대리 접근)도 세션은 열리지만 메일 메서드가
- 엔진에서 비밀번호를 바꾸거나 막아도 웹메일 세션은 끊기지 않는다. 갱신이 게이트웨이로 계정이 있는지만 보고, 엔진에는 바뀐 것을 알리는 칸이 없다.
- 그래서 운영 도구가 세션을 직접 끊는다.
- 엔진에는 2단계 인증을 강제하는 설정이 없다 — 서버 정책으로 둔다.
- 엔진은 잠긴 계정(
authenticate뺌)에도 2단계를 먼저 본다 — 2단계를 켠 잠긴 계정의 맞는 비밀번호는 402 이고, 그 뒤 대리 접근이 403 이다. 서버는 그 403 을 게이트웨이 자신의 권한(/api/account에impersonate— 1분 기억)으로 가른다: 게이트웨이에 권한이 있으면 그 계정이 잠긴 것 → 로그인은 401AUTH_004(실패 한도에 센다 — 틀린 비밀번호와 같게, 잠겼는지 · 비밀번호가 맞는지 드러내지 않는다), 없거나 읽지 못하면 게이트웨이 설정 문제 → 503MAIL_001(갱신 · 메일 API 는 지금처럼 503 · 세션 유지). - 대리 접근으로 직원 받은편지함에 메일을 넣을 수 있다(
Email/import). 안 읽음 · 실시간 알림이 가고, 필터 · 스팸 판정은 거치지 않는다.
제한된 세션
- 로그인은 성공해 쿠키를 받지만 세션에 제한이 붙는다. 제한은 서버(세션 표)에 있고, 액세스 토큰에도 싣는다.
- 제한된 세션으로 부를 수 있는 것
/api/auth/*(me · refresh · logout · password · sessions)/api/two-factor·/api/two-factor/enrollments·/api/two-factor/activation/api/support-contact- 그 밖은 403
AUTH_010이다.
- 차례:
passwordChange가 먼저, 그다음twoFactorSetup. - 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 성공하면 서버가 그 요청의 세션(액세스 토큰의
sid)의 제한을 지운다. 클라이언트는 곧바로/auth/refresh로 새 토큰을 받는다.- 다음 제한이 남아 있으면 지우지 않고 그 제한으로 바꾼다(
passwordChange→twoFactorSetup) — 옛 토큰으로도 다음 단계 밖은 막힌다. - 2단계를 켜면 그 직원의 다른 세션 가운데
twoFactorSetup제한이 붙은 것은 끊는다(2단계를 거친 적이 없는 세션 — 비밀번호만 아는 다른 사람의 세션일 수 있다). 갱신에서도 세션 표가twoFactorSetup인데 계정은 이미 켜짐이면 제한을 지우지 않고 끊는다. 비밀번호 바꾸기는 5절 규칙대로 다른 세션을 모두 끊는다.
- 다음 제한이 남아 있으면 지우지 않고 그 제한으로 바꾼다(
- 새로고침 · 다른 탭에서도 같은 제한이다(서버가 막는다). 화면은
me의restriction을 보고 로그인 틀의 그 단계로 간다. - 가드는 제한이 실린 액세스 토큰만 요청마다 세션 표의 지금 값을 읽는다(제한된 동안만 DB 한 번). 제한이 없는 토큰은 묻지 않는다 — 그래서 시작일이 지난 뒤의 2단계 필수는 다음 갱신 때 붙는다.
- 클라이언트는 403
AUTH_010을 받으면 알림 없이me를 다시 받아 그 제한의 단계로 간다. 제한된 동안은 실시간 알림(/api/mail-events)도 열지 않는다 — 제한이 풀린 뒤 연다.
// POST /api/auth/login → 200 + Set-Cookie — 제한된 세션도 쿠키를 받는다
{ "status": "signedIn", "email": "ara.kim@arasystem.it.kr", "name": "김아라", "restriction": "passwordChange", "twoFactorDeadline": null }
// GET /api/auth/me → 200 — 2단계 필수의 유예 중(시작일 2026-12-01)
{ "email": "ara.kim@arasystem.it.kr", "name": "김아라", "restriction": null, "twoFactorDeadline": "2026-11-30T15:00:00.000Z" }
비밀번호 바꾸기 강제 (restriction: passwordChange)
- 언제: 엔진의 비밀번호가 만료됐을 때다. 운영 도구의 입사 · 비밀번호 재설정이 건다.
- 알아채는 법: 로그인 · 갱신 때 게이트웨이 대리 접근 세션을 본다 — 만료된 계정은 세션에 메일 계정이 없고 권한(
/api/account)이 위 다섯 안이다(본인 Basic 도 200 — 402 가 아니다).- 만료를 건 직후 0.5초쯤은 엔진 세션 캐시가 옛 모양이라, 메일 메서드가
forbidden이면 권한으로 다시 본다. - 갱신 때마다 엔진을 기준으로 다시 본다 — 만료면
passwordChange를 붙이고, 다른 곳에서 바꿔 풀렸으면 지운다. 세션 표에도 남긴다.
- 만료를 건 직후 0.5초쯤은 엔진 세션 캐시가 옛 모양이라, 메일 메서드가
- 푸는 법:
PUT /api/auth/password— 지금 비밀번호는 임시 비밀번호, 새 비밀번호, 2단계를 켰으면otpCode.- 성공하면 엔진의 만료가 풀리고 세션 제한을 지운다.
- 다른 세션 끊기 · 앱 비밀번호 지우기는 5절 규칙 그대로다.
- 클라이언트: 로그인 직후면 방금 넣은 비밀번호를 화면 메모리(반응형이 아닌 클로저 — 5절 코드 단계와 같다)에서 지금 비밀번호로 쓴다. 10분이 지났거나 새로고침 · 다른 탭이면 「임시 비밀번호」 칸을 보인다.
- 2단계를 켠 직원은 비밀번호 → 코드 → 이 단계이고, 이 단계에서 코드를 한 번 더 받는다(
otpCode— 엔진이 비밀번호를 바꿀 때 코드를 요구한다). 로그인 때 쓴 코드는 2분 동안 다시 받지 않으므로(5절) 앱의 다음 코드를 넣는다. 서버가 로그인 코드를 기억해 다시 쓰지 않는다 — 새 비밀번호를 치는 사이 코드의 유효 시간(앞뒤 30초)을 지날 수 있다. - 엔진은 2단계를 먼저 본다(개발 엔진 확인) — 만료 + OTP 계정의 Basic 은 402, 코드 로그인 뒤 대리 접근이 만료 모양이라 흐름은 비밀번호 → 코드 → 이 단계다.
비밀번호 바꾸기는 대리 접근의
x:AccountPassword/set+otpCode로 되고 만료도 풀린다. 관리자의 재설정(secret + expiresAt)은 OTP 를 지우지 않는다.
2단계 인증 필수 (restriction: twoFactorSetup)
- 정책은 서버 설정이고 운영 도구로 바꾼다: 꺼짐 / 켜짐 + 시작일 + 예외 주소. 처음 값은 꺼짐이다.
- 시작일 전(유예): 로그인은 그대로다.
twoFactorDeadline에 시작일이 오고, 화면이 메일함 위에 안내 줄을 보인다(날짜는 그 1ms 앞 — 시작일 전날, 부재중의 끝나는 날과 같은 규칙). - 시작일부터: 2단계를 켜지 않은 직원(예외 주소 빼고)의 로그인에
restriction: twoFactorSetup이 붙는다. 화면은 로그인 틀의 켜기 단계로 간다.- 로그인은 엔진의 답(402 면 켜짐)으로, 갱신은 정책과 우리 기록(
two_factor_settings)으로 다시 본다 — 시작일이 지나면 이미 로그인한 세션도 다음 갱신(최대 1시간) 때 제한이 붙는다. - 켜기는 12단계 API(
enrollments·activation) 그대로다. 지금 비밀번호는 위 "클라이언트"와 같은 규칙이다. - 켜면 세션 제한을 지운다. 화면은 받은편지함으로 가며 [앱 비밀번호 만들기] 알림을 띄운다(디자인 README 13단계).
- 로그인은 엔진의 답(402 면 켜짐)으로, 갱신은 정책과 우리 기록(
GET /api/two-factor의isRequired·deadline으로 설정 화면도 같은 안내를 보인다(deadline은twoFactorDeadline과 같은 규칙 — 켜기 · 끄기 응답에도 싣는다). 필수인 직원은 끄기 단추가 없다 — 서버도 400VAL_001로 거절한다.- 정책을 꺼도 시작일 값은 DB 에 남는다 — 꺼짐이면 쓰지 않고, 다시 켤 때 운영 도구가 시작일을 다시 묻는다.
- 필수인 직원이 휴대폰을 바꿀 때는 관리자가 운영 도구의 「2단계 해제」를 하고, 다음 로그인 때 새 휴대폰으로 켠다(직원이 켜진 채 새 휴대폰을 등록하는 길은 이 단계에 없다).
- 메일 프로그램은 앱 비밀번호로 연결한다(12단계 그대로).
문의 연락처 (GET /api/support-contact)
// GET /api/support-contact → 200 (로그인 없음)
{ "text": "IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it" } // 정하지 않았으면 null
- 운영 도구로 바꾼다(서버 설정). 200자 이하 · 한 줄이다.
- 화면은 글자로만 보인다(링크 · HTML 없음): 로그인 화면(「비밀번호를 잊으셨나요?」 아래), 코드 단계 · 2단계 켜기(「인증 앱을 쓸 수 없나요?」 아래), 받은편지함 알림 메일의 마지막 줄.
정하지 않았으면 지금 글 그대로다(디자인 README 13단계). 잠긴 계정도 비밀번호 틀림과 같은
AUTH_004라 같은 줄로 안내한다.
받은편지함 알림 메일 (시스템 메일)
- 서버가 대리 접근으로 그 직원의 받은편지함에 넣는다(
Email/import).- 안 읽음으로, 받은 시각은 지금이다.
- 필터 · 스팸 판정을 거치지 않고, 실시간 알림이 간다.
- 보낸 사람은
ARA SYSTEM <postmaster@<MAIL_DOMAIN>>이다. 엔진 안이라 SPF · DKIM 과 상관없다. - 보내는 때
- 운영 도구의 비밀번호 재설정 · 2단계 해제
- 위험 파일 판정 — 11단계에서 미룬 것. 그 파일을 올린 직원에게 보낸다.
- 넣기에 실패해도 원래 일은 성공으로 둔다(warn 로그).
- 본문은 짧은 HTML(보내는 정화 허용 목록 안) + 같은 글의 텍스트다. 제목 · 문구는 디자인 README 13단계를 그대로 쓴다.
- 링크를 넣지 않는다 — 할 일은 메뉴 경로를 글로 적는다("웹메일의 [설정] → [앱 비밀번호]" 등 — 화면 글과 같은 꼴). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다.
- 위험 파일 알림은 파일마다 한 번 — 위험 판정을 처음 기록할 때만 보낸다.
운영 도구 (서버 안 명령)
- 실행:
sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js. 운영 이미지에 들어 있다.- 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다.
- 엔진 관리 API(관리자 Basic —
MAIL_JMAP_URL)와 우리 DB · 서비스를 함께 쓴다.
- 처음에 묻는 것
- 운영자 이름 — 실행 기록에 남긴다.
- 관리자 비밀번호 — 저장하지 않는다. 화면에 보이지 않게 넣는다.
- 실행 기록 표(
ops_audit_log): 언제 · 누가 · 명령 · 대상 · 결과. 비밀은 남기지 않는다. - 표준 입력이 터미널(TTY)이 아니면 운영자 이름 · 비밀번호를 묻기 전에 멈춘다(
docker exec -it로 실행). 입력 기록(↑)에 비밀번호를 남기지 않는다. 자동 시험만OPS_ALLOW_NO_TTY=1로 파이프 입력을 받는다 — 사람은 쓰지 않는다. - 명령
| 명령 | 하는 일 |
|---|---|
| 입사 | 주소 · 이름 · 메일함 용량 → 엔진 계정(직원 역할) + 임시 비밀번호(무작위 — 한 번만 보여 줌) + 만료(첫 로그인 때 바꾸기). "mailcow 에서 옮겨 올 메일이 있습니까?"에 y 면 임시 비밀번호 대신 이전용 비밀번호(만료 30일 뒤 — 아래 "메일 이전") |
| 비밀번호 재설정 | 임시 비밀번호 + 만료, 웹메일 세션 모두 끊기, 앱 비밀번호 지우기, 받은편지함 알림. 2단계는 그대로다(따로 해제). 이전 중인 계정은 하지 않고 「메일 이전」으로 안내한다 |
| 2단계 해제 | 휴대폰 분실 — 엔진 OTP 지우기 + enabledAt 정리 + 받은편지함 알림 |
| 잠금 | 퇴사 · 털림 — 엔진 authenticate 빼기, 웹메일 세션 모두 끊기. 묻고: 그 직원의 공개 링크 끊기, 받는 메일 넘기기(Sieve redirect :copy), 자동 응답. 메일은 계속 받는다 |
| 잠금 풀기 | authenticate 되돌리기. 비밀번호 재설정을 함께 권한다(이전 중인 계정이면 권하지 않는다) |
| 퇴사 마무리 | 잠긴 계정만. 엔진 계정 지우기(메일 영구 삭제 — 주소를 다시 쳐서 확인) + 우리 DB 의 그 주소 행 정리 — 같은 주소를 다시 쓸 때 섞이지 않게 |
| 링크 끊기 | 신고된 공개 링크 주소로 → revoked_by = operator |
| 공용 주소 | MailingList 만들기 · 구성원 넣기 / 빼기 · 목록(구성원 받은편지함으로 복사) |
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 · 이전 중(끝나는 날) |
| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 |
| 메일 이전 (2026-11-02 더함) | 이전 현황(이전 중인 계정 · 끝나는 때 · 메일함 사용량) · 이전용 비밀번호 다시 만들기(한 사람 또는 이전 중인 계정 모두 — 잃었거나 30일을 늘릴 때) · 이전 마치기(한 사람 또는 이전 중인 계정 모두 → 직원용 임시 비밀번호) |
- 차례가 있는 명령
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. 만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고, 엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다.
- 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 →
authenticate빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크,
그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다).
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 브라우저 목록은 살아 있는 세션만 보인다).
디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(
ops_audit_log)은 남기고, 읽기만 하는 현황은 기록하지 않는다. - 비밀번호 재설정은 잠긴 계정을 거절한다(대리 접근이 막혀 있다 — 잠금 풀기가 재설정을 함께 권한다). 이미 만료된 계정을 다시 재설정하면 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다
(엔진이 막는다 — 엔진 관리 화면에서 만료를 걸어 앱 비밀번호가 남은 드문 경우는
[못 함]으로 알리고 이어 간다). - 잠금의 넘기기 · 자동 응답은 그 직원의 Sieve 스크립트
arasystem(필터 · 부재중과 같은 스크립트 — 엔진은 계정마다 스크립트 하나만 켠다)에 넣는다: 넘기기는 맨 앞 필터 규칙redirect :copy(이름 "잠금 — 받는 메일 넘기기 (운영 도구)"), 자동 응답은 부재중 칸. 잠금 풀기는 걸려 있는 것을 뗄지 묻는다(부재중은 직원이 건 것일 수 있다). - 잠금 풀기는
authenticate빼기만 되돌리고 남은 것이 없으면 상속(Inherit)으로 돌린다 — 직접 정한 권한(Replace)인 계정은 바꾸지 않는다.
- 엔진의 답을 받지 못해 결과를 모르는 실패(시간 초과 · 네트워크 · 5xx)는 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알린다 — 재설정은 넣은 알림을 지우지 않고, 퇴사 마무리는 DB 를 건드리지 않는다(다시 하면 남은 것만). 퇴사 마무리에서 그 직원이 공용 주소의 마지막 구성원이면 뺄지 먼저 묻는다(빼면 받는 사람이 없다). 2단계 필수 예외 주소 넣기 · 빼기는 y/N 뒤 퇴사 마무리와 같은 행 잠금 안에서 읽고 쓴다.
- 메일 이전(2026-11-02 더함 — 전 직원 전환의 imapsync, 차례 · 명령은 mail/README "imapsync"):
- 왜: imapsync 는 새 엔진에 IMAP 으로 들어가야 하는데 임시 비밀번호는 만료라 끊긴다(위 "엔진 사실"). 직원이 첫 로그인에서 비밀번호를 바꾸면 우리는 모르고 앱 비밀번호도 지워져 마지막 차이분을 옮길 길이 없다. 그래서 비밀번호를 나눠 주기 전에 옮기기를 모두 끝낸다 — 계정 만들기(이전용) → imapsync 1차 → MX 바꾸기 → 마지막 imapsync → 이전 마치기 → 임시 비밀번호 나눠 주기.
- 이전 중 = 엔진 비밀번호 만료가 미래(지금 + 31일 안)이고, 실행 기록(
ops_audit_log)에서 그 주소의 판정 기록 가운데 마지막이 이전용(hire-migration·migration-password)인 계정이다. 판정 기록 = 비밀번호를 거는 명령(hire·hire-migration·password-reset·migration-password·migration-finish)의 success · partial, 그리고 이전용 명령의 failure — 결과를 모르는 실패는 엔진에 걸렸을 수 있어서다(걸렸으면 「다시 만들기」(그 주소)로 바로 되살린다. 실제로 거절됐으면 계정이 없거나 옛 만료 그대로라 만료 조건이 가른다). 이전용이 아닌 명령의 failure 는 건너뛴다. 판정 함수는 하나다(modules/ops/migration-state.ts) — 현황 · 재설정 가드 · 잠금 풀기 · 「메일 이전」이 모두 쓴다. 새 표는 두지 않는다 — 엔진의 만료만 보면 기본 만료의 찌꺼기(위 "엔진 사실")를 이전 중으로 읽어 직원의 비밀번호 · 앱 비밀번호를 지울 수 있다(2026-11-02 재리뷰). 실행 기록을 읽지 못하면 「메일 이전」은 멈추고, 재설정 · 잠금 풀기는 엔진 만료만 보고 보수적으로 막는다(잘못 덮지 않게). 이전용 비밀번호는 만든 때부터 30일이고, 잊어도 그때 엔진이 막는다(그 뒤는 첫 로그인 때 바꾸기와 같은 상태). - 이전용 비밀번호는 imapsync 에만 쓰고 직원에게 주지 않는다. 한 번만 보이고 실행 기록 · 로그에 없다(임시 비밀번호와 같다). 운영 도구 한 번 실행 안에서는 같은 값이다 — 처음 만들 때 한 번 보이고, 그 실행의 다음 입사 · 다시 만들기는 그 값을 건다(다시 보이지 않는다). imapsync 의 대상 비밀번호 파일 하나로 모든 계정을 옮기게 하려는 것이다. 값은 그 프로세스 메모리에만 있다(관리자 비밀번호처럼). 만료는 바뀔 것을 보일 때 정한 시각부터 30일이다(모두면 모두 같은 시각). 같은 실행 안에서 값을 잃었으면 도구를 끝내고 다시 시작한 뒤 「다시 만들기」(빈 값 = 모두)를 한다 — 같은 실행의 다시 만들기는 그 실행의 값을 다시 걸 뿐 새 값을 보이지 않는다.
- 도구는 관리자로 들어온 직후 엔진의 비밀번호 기본 만료를 한 번 읽어, 켜져 있으면 첫 화면에 알리기만 한다(이전 중 판정은 실행 기록으로 가르므로 막지 않는다). 읽지 못하면 경고 로그만 남긴다.
- 다시 만들기: 한 사람, 또는 주소를 비우면 이전 중인 계정 모두(잃어버렸거나 30일이 다가올 때 — 그 실행의 값으로 맞춘다). 비밀번호를 바꾸기 전에 그 주소의 웹메일 세션을 끊는다 — 임시 비밀번호(만료)로 들어와 새 비밀번호 단계에 묶인 세션이, 만료가 미래로 바뀌면 다음 갱신에서 제한이 풀리기 때문이다(끊지 못하면 바꾸지 않는다). 한 사람일 때는 이전 중이거나 비밀번호가 만료된 계정(30일이 지난 이전 계정 · 아직 바꾸지 않은 임시 비밀번호 — 엔진에서는 가를 수 없다 — 바뀔 것에 "지금 비밀번호는 더 쓰지 못합니다")만 받는다. 만료된 계정은 "모두"에 넣지 않는다. 직원이 이미 비밀번호를 정한 계정(만료 없음)은 막는다 — 걸면 직원이 로그인하지 못한다. 잠긴 계정 · 2단계 인증을 켠 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다.
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 세션 끊기 → 이전 중 로그인 기록 지우기(우리 DB
login_events— imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게) → 엔진에 직원용 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. 받은편지함 알림 메일은 넣지 않는다(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다). 한 계정이 실패해도 나머지를 이어 하고, 끝에 주소 · 임시 비밀번호 표를 한 번 보인다(앞에서 만든 것은 실패가 나도 반드시 보인다). 로그인 기록을 지우지 못한 계정은 비밀번호를 바꾸지 않고 멈춘다(넘긴 뒤에는 지울 명령이 없다). 잠긴 계정은 마치지 않는다. y/N 뒤 계정마다 다시 읽어 그사이 비밀번호 처지가 바뀌었으면(다른 운영자가 먼저 마침 등) 하지 않는다. 세션 끊는 사유는 운영 도구의ended-by-operator그대로다. 실행 기록은 계정마다 — commandmigration-finish(이전 마치기) ·migration-password(다시 만들기), 이전용 입사는hire-migration(임시 입사는hire그대로), detail 에이전용(이번 실행 공통 · ~날짜). 마지막 imapsync 가 끝나고 5분쯤 뒤에 마친다 — 엔진 웹훅이 실패한 묶음을 5분까지 다시 보내므로, 바로 마치면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남을 수 있다. 끝의 표를 잃었으면(터미널이 끊김 등) 실행 기록에서migration-finish가 success 인 주소를 찾아 「비밀번호 재설정」으로 새 임시 비밀번호를 만든다(이미 마친 계정은 이전 중이 아니라 재설정이 받는다).
- 관리자 계정에서 대리 접근 권한(
impersonate)을 빼므로(인프라 — mail/README) 도구가 직원의 메일함 · Sieve · 앱 비밀번호를 다룰 때는 웹메일과 같은 게이트웨이 대리 접근을 쓴다. 관리자 Basic 은 계정 관리 API(x:Account·x:MailingList)에만 쓴다. - 도구로 끊은 세션도 15초 안에 막힌다. 세션 끊기는 DB 로 하고, 서버는 끊은 세션 기억을 15초마다 DB 에서 채운다(5절의 기억에 더한다).
- 잠금은 도구로 한다. 엔진 관리 화면에서
authenticate만 빼면 웹메일 세션이 남는다 — 갱신이 503MAIL_001을 내며 세션을 그대로 둔다(엔진의 대리 접근 403 을 설정 문제로 본다).
에러 코드 (13단계에 더함)
| 코드 | HTTP | 상황 | 문구 |
|---|---|---|---|
AUTH_010 |
403 | 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)으로 다른 API | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
휴지통 · 스팸 30일 · 메일 프로그램 연결
- 휴지통 · 스팸메일함의 메일은 엔진이 지운다: 그 메일함에 들어간 날부터 30일, 매일 03:00 KST(setup). API 는 없고 화면 안내 줄만 둔다.
- 메일 프로그램 연결 화면은 화면 상수(
MAIL_CLIENT_SETTINGS)로만 그린다. API 는 없다. 2단계 인증 상태는GET /api/two-factor로 보고, 받지 못하면 켠 직원의 안내(앱 비밀번호 꼭)로 둔다.- 앱 비밀번호 화면(5절)의 설정값 상자 아래에 이 화면으로 가는 바로가기를 둔다.
- 새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받으므로 권하지 않는다고 안내한다(회사가 허용하면 그 줄을 지운다).
확장 때 옮길 것
- 2단계 필수 정책 · 문의 연락처는 DB 라 그대로 맞다.
- 끊은 세션 기억은 15초 DB 동기화라 인스턴스를 늘려도 15초 안에 맞는다.
이 단계에서 하지 않는 것
웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다), 공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱.