docs: 8단계(주소록) 계약과 디자인

주소록(내 연락처 · 그룹 · 사내 목록)과 메일 연동의 계약(3절)을 정하고, 확정한 캔버스 보드 셋(Contacts · ContactsEdit · ContactsMail)을 둔다.
리뷰(서버 · 화면)로 정한 것도 함께 적었다 — 되돌리기는 서버가 원본을 5분 기억했다가 그대로 다시 만들고,
고치기는 PATCH(바꾼 칸만 · 칸 안의 남의 속성을 지킨다), 받는 사람 추천의 q 는 쓰기 화면과 같은 맞추기,
주소록 크기 한도 800만(캐시가 통째로 기억할 수 있게), excludeGroupId · 사내 항목 하나 · q 254자, 쓰기 화면 채우기는 연 직원에게 묶는다.
엔진이 한 요청의 destroy 에 같은 id 가 두 번 있으면 통째로 serverUnavailable 로 거절하는 것도 적었다(2026-10-29 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:05:36 +09:00
parent 7bf95c1335
commit 7615329c32
6 changed files with 1403 additions and 10 deletions
+198 -7
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `SYS_001`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -515,23 +515,31 @@ interface SignatureImage {
interface RecipientSuggestion {
name: string | null
email: string
source: 'directory' | 'recent' // directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소
source: 'contact' | 'directory' | 'recent' // contact = 내 주소록(8단계), directory = 사내(8단계부터 엔진의 사내 목록 — 3절), recent = 최근 주고받은 주소
company: string | null // contact 만 — 그 연락처의 회사 (그 밖에는 null)
emailLabel: 'work' | 'personal' | 'other' | null // contact 만 — 그 주소의 종류 (그 밖에는 null)
}
```
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 8단계부터 쓰기 화면과 **같은 맞추기**다 — 이름은 초성 · 조합 중 글자를 섞어도 맞고
(`ㄷㅇ` · `도ㅇ` → 이도윤, 윤을 치는 중인 `도유` · `동` → 도윤), 주소는 그 글자가 든 것(완전한 주소를 쳤으면 같은 아이디도). 규칙의 짝은 frontend `shared/utils/recipient-match.ts` 이고
두 쪽 테스트가 같은 표본 표를 쓴다. 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`).
- **순서**: 사내 → 최근 주고받은 주소, 각 묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다.
- **순서**: 주소록(8단계) → 사내 → 최근 주고받은 주소. 주소록은 이름 차례(3절), 사내 · 최근은 주고받은 횟수와 최근성으로.
여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 — 3절 "메일과 함께".
- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 —
다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
- **사내 목록**은 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다 — 메일 엔진의 계정 목록 권한은 쓰지 않는다(일부러 막아 두었다).
- **사내 목록**은 8단계부터 엔진의 사내 목록(3절 "사내 목록")이다. 그것을 읽지 못할 때만 아래 옛 규칙으로 대신한다 —
로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다(직원 역할에는 엔진의 계정 목록 권한이 없다).
한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다.
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자라고 더 있을 때만 `q` 로 다시 묻는다(250ms 기다린 뒤) —
8단계부터는 초성 · 조합 중 글자도 묻는다(주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다). 맞추기가 같아서 받아 둔 것에서 8개를 찾았으면 서버에 물어도 같은 8개다.
두 쪽 모두 유니코드 정규형(NFC)으로 맞추지 않는다 — 화면이 원래 글자 위치로 강조하기 때문이다(다른 정규형으로 저장된 이름은 그 글자로 맞지 않는다).
### 새 메일 확인 (`/api/mail-state`) — 3단계
@@ -972,5 +980,188 @@ interface MailFilterRun {
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
---
## 3. 주소록 — 8단계 (모두 로그인 필요 — 없으면 401 `AUTH_001`)
직원마다의 **내 주소록**(연락처 · 그룹)과 모두가 함께 보는 **사내 목록**(직원 · 공용 주소)이다. 화면은 디자인 README "8단계"(디자인 원본 `Contacts` · `ContactsEdit` · `ContactsMail`).
- **내 주소록은 메일 엔진의 주소록에 둔다** — 직원마다 엔진의 기본 주소록 하나(JMAP Contacts · JSContact 카드). 서버는 게이트웨이 대리 접근으로 그 직원의 주소록만 읽고 쓴다.
같은 주소록을 나중에 휴대폰 · 메일 프로그램(CardDAV)에서도 쓸 수 있다 — 그래서 서버는 우리가 다루지 않는 칸(주소 · 생일 · 사진 등)을 고칠 때 그대로 둔다.
- **사내 목록은 엔진의 계정 목록(JMAP Principal)에서 이름 · 주소만 읽는다**. 직원 역할은 계정 목록을 볼 수 없게 막아 두었으므로(1단계 setup)
setup 이 만든 전용 계정으로만 읽는다(아래 "사내 목록").
### 타입
```ts
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_001` 409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).
`CONTACT_002` 409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다
(사전 문구에는 앞의 세 한도가 나온다 — 크기는 보통 쓰임으로는 닿지 않는다).
- `cursor` 는 불투명 문자열이고 받은 그 `q` · `groupId` · `excludeGroupId` · `kind` 로만 쓴다(다르면 400 `VAL_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` 에 새 그룹을 넣으면 400 `VAL_001`, 구성원 넣기에서는 건너뛰고 세지 않는다(CardDAV 프로그램은 늘 UID 를 넣는다 — 드물다).
그룹 구성원을 바꿀 때는 `members` 를 통째로 쓴다(엔진이 구성원 없는 그룹의 `members/<uid>` 패치를 받지 않는다).
칸: `name.full` · `emails`(종류는 `contexts` — work · private, 대표 주소는 `pref: 1`) · `phones`(휴대전화는 `features.mobile`, 회사 · 집은 `contexts` work · 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`), 그사이 바뀌면 다시 읽어 다시 정한다 — 세 번 어긋나면 503 `MAIL_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`)을 남기고, 종류를 바꾼 것만 우리 값(`contexts` work · 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` 로 만든다(되돌린 뒤에는 그룹에 넣을 수 있다). 엔진이 다시 만들기를 받지 않으면 503 `MAIL_001` 이다(드묾 — 엔진은 공급자 전용 칸(`example.com:custom` 꼴)이 든 카드를 만들 때 거절한다). 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 500명이 찬 그룹은 건너뛴다),
그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다(같은 이름의 그룹이 그사이 생겼으면 409 `CONTACT_001`, 그룹 100개 · 연락처 3,000개 · 크기는 409 `CONTACT_002`).
id 는 새로 받는다. 표는 한 번만 쓴다(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). 표가 없거나 지났거나 이미 썼거나 다른 직원의 것이면 404 `RES_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분 기억한다(모든 직원이 함께 — 서버 메모리). 사내 목록 계정이 없거나 읽지 못하면 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=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.