diff --git a/docs/design/webmail/Contacts.dc.html b/docs/design/webmail/Contacts.dc.html new file mode 100644 index 0000000..9073e7a --- /dev/null +++ b/docs/design/webmail/Contacts.dc.html @@ -0,0 +1,491 @@ + + + + +주소록 + + + + + + + +
+ + + +
+ +
+

주소록

+
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+ +
+
+

{{ listTitle }}

+ {{ countText }} + + + +
+ +

{{ directoryNote }}

+
+ + +
    + +
  • + +
  • +
    +
+
+ + +
+ +

{{ emptyText }}

+

이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.

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

연락처를 고르세요

+

목록에서 고르면 주소 · 전화번호가 여기에 보입니다.

+
+
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/ContactsEdit.dc.html b/docs/design/webmail/ContactsEdit.dc.html new file mode 100644 index 0000000..c4b940b --- /dev/null +++ b/docs/design/webmail/ContactsEdit.dc.html @@ -0,0 +1,371 @@ + + + + +연락처 추가 · 고치기 · 그룹 · 상태 + + + + + + + +
+ +
+

연락처 추가 · 고치기 · 그룹 · 상태

+

내 주소록은 메일 서버(엔진)의 주소록에 저장됩니다. 창의 모양 · 단추 · 나가기 확인은 필터 고치기 창과 같습니다. 오류 문구는 에러 코드 사전에서 가져옵니다.

+
+ +
+ +
+

연락처 추가

+ +
+ + +
+ +
+ 이메일 +
+ + + + + + +
+
+ + 5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다 +
+
+ +
+ 전화번호 +
+ + + +
+
+ + 5개까지 +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + 38 / 1,000 +
+ +
+ +
+ 그룹 +
+ + +
+
+ +

이름이나 이메일 가운데 하나는 있어야 합니다.

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

연락처 고치기 — 칸 아래의 안내

+

[저장]을 누를 때 보고, 첫 잘못된 칸에 초점을 둡니다(알림 없음)

+
+
+ 이메일 +
+ + +
+

이메일 형식이 올바르지 않습니다.

+
+
+
+ + +
+

이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기

+
+
+ +
+ + +
+
+

지우기는 확인 없이 하고 알림의 [되돌리기]로 돌립니다(10초). 고친 채 [취소] · Esc · 다른 화면으로 가면 "저장하지 않은 연락처가 있습니다" — 필터 창의 나가기 확인과 같습니다.

+
+ +
+
+

⋯ 메뉴 — 그룹 · 연락처

+

왼쪽 그룹 줄의 ⋯ · 연락처 상세의 ⋯

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

그룹을 지워도 연락처는 그대로 남습니다. 그룹 넣기 · 빼기는 연락처 고치기 창의 「그룹」이나 그룹의 [구성원 넣기]로 합니다.

+
+
+
+ +
+ +
+
+

새 그룹

+
+ + +

같은 이름의 그룹이 있습니다.

+
+
+ + +
+
+

이름 바꾸기도 같은 창입니다(제목 "그룹 이름 바꾸기", 단추 [저장]). 그룹은 100개까지, 이름은 50자까지입니다.

+
+ +
+

‘거래처’에 구성원 넣기

+
+
+ + +
+
+ + +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

이미 이 그룹에 있는 연락처는 보이지 않습니다. 「사내 직원」에서 고르면 그 직원을 내 주소록에도 추가합니다(이름 · 주소).

+
+ 2명 고름 +
+ + +
+
+
+ +
+
+

알림

+

오른쪽 아래 · 단추가 있으면 10초

+
+
+ + 연락처를 저장했습니다. +
+
+ ‘박지훈’ 연락처를 지웠습니다. + +
+
+ ‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다. + +
+
+ + 2명을 ‘거래처’ 그룹에 넣었습니다. +
+
+
+ +
+ +
+ 처음 — 연락처가 없을 때 + +

아직 연락처가 없습니다

+

자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.

+ +

메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.

+
+ +
+ 그룹이 비었을 때 + +

‘협력사 담당’ 그룹에 아직 아무도 없습니다

+

구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다.

+ +
+ +
+ 사내 목록을 받지 못했을 때 · 한도 +
+

사내 목록을 불러오지 못했습니다

+

잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다.

+ +
+
+ +

연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요.

+
+
+
+
+
+ + + diff --git a/docs/design/webmail/ContactsMail.dc.html b/docs/design/webmail/ContactsMail.dc.html new file mode 100644 index 0000000..06073c7 --- /dev/null +++ b/docs/design/webmail/ContactsMail.dc.html @@ -0,0 +1,231 @@ + + + + +메일과 함께 — 받는 사람 추천 · 주소록에 추가 + + + + + + + +
+ +
+

메일과 함께 — 받는 사람 추천 · 주소록에 추가

+

주소록은 메일 쓰기 · 읽기 화면과 이어집니다. 받는 사람 추천의 사내 묶음은 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 모두입니다.

+
+ +
+ +
+
+

받는 사람 추천 — 네 묶음

+

주소록 → 그룹 → 사내 → 최근 주고받은 주소 · 최대 8개

+
+
+
+ + +
+
+
+ +
+ 박지훈 + jihoon.park@hanbit.example · (주)한빛상사 +
+
+ 박지훈 + jihoon.p@mail.example · 개인 +
+
+
+ +
+ 박준호 + junho.park@arasystem.it.kr +
+
+ 박서준 + seojun.park@arasystem.it.kr +
+
+ 박민수 + minsu.park@arasystem.it.kr +
+
+
+ +
+ 박소영 + soyoung.park@print.example +
+
+

↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기

+
+
+

주소가 여럿인 연락처는 주소마다 한 줄입니다(맨 위 주소가 먼저). 두 묶음에 다 있는 주소는 앞 묶음에 한 번만 나옵니다 — 사내 직원을 내 주소록에도 넣었으면 주소록에 나옵니다.

+
+ +
+
+

그룹으로 한꺼번에

+

그룹 이름을 치면 그룹 줄이 나옵니다

+
+
+
+ + +
+
+
+ +
+ + 거래처 + 4명 · 고르면 한꺼번에 넣습니다 +
+
+
+
+
+ 고른 뒤 +
+ 받는 사람 + 강서윤 + 박지훈 + 정민호 + 최유진 +
+

‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다.

+

이메일이 없는 구성원은 빼고, 이미 넣은 주소는 다시 넣지 않습니다 — "주소가 없는 1명은 뺐습니다"처럼 함께 알립니다. 구성원마다 맨 위 주소를 씁니다.

+
+
+ +
+
+

주소록에서 메일 쓰기

+

연락처 · 그룹 · 사내 어디에서나

+
+
    +
  • 연락처 상세의 [메일 쓰기] · 주소 링크 → 새 메일, 받는 사람에 그 주소(주소마다 링크가 따로 있다).
  • +
  • 그룹의 [그룹에 메일 쓰기] → 받는 사람에 구성원 모두(맨 위 주소) — 위 「그룹으로 한꺼번에」와 같은 규칙.
  • +
  • 사내 직원 · 공용 주소의 [메일 쓰기]도 같다.
  • +
  • 쓰다 만 메일이 있어도 새 쓰기 화면을 연다 — 쓰던 메일은 임시 저장 규칙대로 남는다.
  • +
+
+ 쓰기 화면이 열린 모습 +
+ 받는 사람 + 박지훈 +
+
제목
+
+
+
+ +
+ +
+
+

읽기 화면 ⋯ — 보낸 사람을 주소록에

+

⋯ 메뉴의 둘째 묶음 · 「이런 메일로 필터 만들기」 위

+
+
+
+
+ +
+ 박지훈 jihoon.park@hanbit.example + 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+ +
+
+ + + + +
+
+ +
+

연락처 추가

+
+ + +
+
+ 이메일 +
+ + +
+
+

보낸 사람의 표시 이름과 주소를 채워 엽니다 — 나머지 칸은 「연락처 추가」 창과 같습니다(보드 「연락처 추가 · 고치기」).

+
+ + +
+
+
+
+ +
+
+

⋯ 항목이 바뀌는 때

+

보낸 사람 주소로 판단합니다

+
+
+
내 주소록에 없는 주소
+
「보낸 사람을 주소록에 추가」 → 채운 창
+
내 주소록에 있는 주소
+
「주소록에서 보기」 → 주소록의 그 연락처(목록에서 고른 채)
+
사내 직원의 주소
+
「주소록에 추가」로 그대로 둔다 — 사내 목록은 이름 · 주소만이라 전화번호 · 메모를 적으려면 내 주소록에 넣는다
+
보낸 사람 주소가 없는 메일
+
항목이 없다(필터 만들기와 같다)
+
연락처가 3,000개
+
항목을 끄고(aria-disabled) 이름 아래에 까닭 한 줄
+
+

메뉴를 열 때 주소록을 알림 없이 받아 판단합니다(받아 둔 것이 있으면 그것으로). 결과가 오기 전에는 「보낸 사람을 주소록에 추가」로 둡니다 — 저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보입니다.

+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 0028889..3a4384e 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -409,6 +409,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다." - 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다. +## 8단계 — 주소록 (2026-10-29 확정 — `Contacts` · `ContactsEdit` · `ContactsMail`) + +레일의 주소록 모듈을 켠다(`app-modules.ts` 의 `contacts`). 내 주소록(연락처 · 그룹)과 사내 목록(직원 · 공용 주소)이 있고, 메일 쓰기 · 읽기 화면과 이어진다. +규칙은 계약 3절 "주소록". 셸 · 색 · 단추 · 창 · 알림은 메일 모듈과 같다. + +### 주소록 화면 (`Contacts`) + +- 자리: 레일 · 상단 바 · 모듈 패널(208) · 목록(384) · 상세 — 메일의 분할 보기와 같다. 상단 바의 찾기 칸은 **지금 보는 목록 안에서** 찾는다 + ("이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)"). +- 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수). +- 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기]. + 사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다." +- 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기]. +- 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소"). +- 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다. + 사내 상세 아래: "사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요." +- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다." · 고른 것이 없으면 "연락처를 고르세요". + +### 연락처 추가 · 고치기 (`ContactsEdit`) + +- 모달 620 — 필터 고치기 창과 같은 틀이고 나가기 확인도 같다("저장하지 않은 연락처가 있습니다" / [계속 고치기] [닫기]). +- 칸: 이름(100자) · 이메일(종류 회사 · 개인 · 기타 + 주소 + ✕, [이메일 더하기] 5개까지 — "받는 사람 칸에는 맨 위 주소가 먼저 나옵니다") · + 전화번호(휴대전화 · 회사 · 집 · 기타 + 번호 + ✕, 5개까지) · 회사 · 부서 · 직책(한 줄에 셋) · 메모(1,000자 — 셈 "38 / 1,000") · 그룹(체크 칸). + 맨 아래 "이름이나 이메일 가운데 하나는 있어야 합니다." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글). +- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만(첫 잘못된 칸에 초점, 알림 없음) — "이메일 형식이 올바르지 않습니다." 등. +- 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처)가 있으면 칸 아래 갈색 글 + "이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다. +- 지우기는 확인 없이: 창을 닫고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초). + +### 그룹 (`ContactsEdit`) + +- 새 그룹 · 이름 바꾸기: 모달 400, 칸 하나(50자) · [만들기] / [저장] [취소]. 같은 이름이면 칸 아래 "같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요."(`CONTACT_001` 사전 문구 — 알림 없이. 보드는 앞 문장만). 그룹은 100개까지. +- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기]. +- 연락처 ⋯(상세): 주소 복사 · 연락처 지우기. +- 구성원 넣기: 모달 620 — 「내 연락처 | 사내 직원」 전환(36 — `.segmented` 밀도 예외) + 찾기(초성), 체크 줄 48(이름 · 회사 · 주소). 이미 구성원인 연락처는 보이지 않는다. + "N명 고름" · [넣기] [취소]. 사내 직원을 고르면 내 주소록에도 이름 · 주소로 추가한다(같은 주소의 연락처가 이미 있으면 그 연락처를 넣는다). 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다." +- 빈 그룹: "‘…’ 그룹에 아직 아무도 없습니다" · "구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다." + [구성원 넣기]. +- 그룹에 넣기 · 빼기는 연락처 고치기 창의 「그룹」으로, 여러 사람을 한꺼번에는 [구성원 넣기]로. + +### 상태 (`ContactsEdit`) + +- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다." · [연락처 추가] · + "메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다." +- 사내 목록을 받지 못함: "사내 목록을 불러오지 못했습니다" · "잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다." · [다시 시도]. +- 한도: 연락처가 3,000개면 [연락처 추가]를 끄고(`aria-disabled`) 아래에 "연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요." +- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다 — 연락처 N명은 그대로 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초) + +### 메일과 함께 (`ContactsMail`) + +- **받는 사람 추천**: 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소(모두 합쳐 최대 8개). 주소록은 주소마다 한 줄 — 대표 주소는 "주소 · 회사", 그 밖의 주소는 "주소 · 개인"처럼 종류. + 사내는 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 · 공용 주소 모두다. 여러 묶음에 있는 주소는 앞 묶음에 한 번만. +- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "N명 · 고르면 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다." + 이메일이 없는 구성원 · 이미 넣은 주소는 빼고, 뺀 것이 있으면 "주소가 없는 1명은 뺐습니다"를 덧붙인다. +- **주소록에서 메일 쓰기**: 연락처 · 그룹 · 사내의 [메일 쓰기]와 주소 링크 → 새 쓰기 화면, 받는 사람을 채워서(그룹은 구성원 모두 — 받는 사람 칸). 쓰다 만 메일은 임시 저장 규칙대로 남는다. +- **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록). + 사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -587,3 +644,51 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다. 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 같은 확인 창이 "이 화면을 나가면 …" [나가기]로 바뀐다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로). - **부재중**: 메일함 패널 아래의 부재중 안내는 그대로 둔다(저장한 값으로 보인다 — 동작하지 않음은 설정 화면에서만 알린다). + +### 8단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다) + +- **받는 사람 추천**: 주소록 줄의 설명은 주소 종류로 정한다 — 회사 주소는 회사 이름(없으면 "회사"), 개인 주소는 "개인", 그 밖은 "기타". + 계약에 대표 표시가 없어 보드("대표 주소는 회사")와 두 경우에서 다르다 — 대표 주소가 개인이면 "· 개인", 대표가 아닌 회사 주소에는 회사 이름이 나온다. + 구성원이 0명인 그룹은 추천하지 않는다. 초성 · 조합 중 글자도 받아 둔 것으로 모자라면 서버에 묻는다(서버가 같은 맞추기를 쓴다 — 계약 2절). + 그룹을 고른 뒤 칸 아래 글(`role=status`)은 불러오는 중 "‘G’ 그룹의 구성원을 불러오는 중…" → + "‘G’ 그룹의 N명을 받는 사람에 넣었습니다." / "‘G’ 그룹에서 받는 사람에 더 넣을 주소가 없습니다." + 뺀 것이 있으면 "주소가 없는 N명 · 이미 넣은 N명은 뺐습니다." — 칸에 다시 치면 지운다. + 그룹을 이어 고르면 그룹마다 따로 받아 끝나는 대로 넣는다 — 칸 아래 글은 받는 중인 그룹이 있으면 마지막으로 고른 그룹의 "불러오는 중", 없으면 마지막에 끝난 그룹의 결과. +- **쓰기 화면을 받는 사람을 채워 열기**: 새 메일에만 쓰고(라우터 상태 `composeTo` — 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다), 잘못된 주소는 버린다. + 채운 받는 사람은 "바꾼 것"이 아니다 — 손대지 않으면 자동 저장 · 나가기 확인이 없다. 초점은 제목 칸으로. + 새 탭으로 연 쓰기 링크(가운데 클릭)는 빈 쓰기 화면이다 — 라우터 상태는 새 탭으로 가지 않는다(한계). +- **그룹**: [그룹에 메일 쓰기]는 받는 동안 바쁜 표시(`aria-busy`)이고 거듭 누르면 무시한다. 받는 사이 다른 목록 · 화면으로 갔거나 세션이 바뀌면 쓰기 화면으로 가지 않는다. + 주소가 있는 구성원이 없으면 오류 알림 "‘G’ 그룹에는 메일 주소가 있는 구성원이 없습니다." — 빈 그룹이면 목록 머리 단추와 그룹 ⋯ 의 같은 항목이 함께 꺼지고(`aria-disabled`) 까닭은 "아직 구성원이 없습니다.". + 그룹이 100개면 [새 그룹]을 끄고 "그룹은 100개까지 만들 수 있습니다.". [되돌리기]는 서버가 기억한 원본으로 다시 만든다(계약 3절 "되돌리기"). +- **구성원 넣기 창**: 서버가 이미 구성원인 사람을 빼고 준다(연락처 · 사내 둘 다, [더 보기]도 — `excludeGroupId`). 한 번에 100명까지 — 다 고르면 나머지 칸을 끄고 알린다(`role=status`). + 전환은 눌림 단추 둘(`role=group` + `aria-pressed` — 보드의 radiogroup 대신, 새 `.segmented--outline`). 목록은 높이 18rem 안에서 넘기고 [더 보기]가 있다. 아바타는 없다(보드대로). + 넣은 뒤 알림의 수는 서버가 넣었다고 한 수(`addedCount`)다 — 0명이면 "‘G’ 그룹에 새로 넣은 사람이 없습니다. 이미 구성원이거나 그룹에 넣을 수 없는 연락처입니다."(다른 프로그램이 uid 없이 만든 카드 — 계약 3절). +- **연락처 창(추가 · 고치기)**: + - [고치기]는 그 연락처를 새로 받아 연다(받는 동안 `aria-busy`, 거듭 누르면 무시, 받는 사이 다른 연락처를 골랐으면 열지 않는다 — 다른 탭 · 휴대폰에서 바꾼 것을 덮지 않게). + 찾을 수 없으면(`RES_001`) 알림 없이 목록에서 빼고 상세가 "찾을 수 없음" + [목록으로]를, 그 밖의 실패는 알림만 띄운다(창을 열지 않는다). + - 저장은 **바꾼 칸만** 보낸다 — 보낼 값으로 견주어 앞뒤 공백 · 빈 줄 · 제어 문자 · 그룹의 차례는 바꾼 것이 아니다. 바꾼 것이 없으면 보내지 않고 닫는다. 나가기 확인도 같은 기준이다. + - 검사도 바꾼 칸만 — 손대지 않은 남의 값(21번째 주소 · "내선"이 든 번호 · 긴 메모 · 100자 넘는 한 줄 칸)은 그대로 둔다. 이메일 · 전화번호는 고치면 그 칸 전부를 본다. + 보드에 없던 문구: "이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요." · "전화번호는 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요."([더하기] 아래 — 초점은 그 묶음의 첫 줄) · + "100자까지 쓸 수 있습니다."(그 칸 아래) · "같은 주소를 두 번 넣었습니다." · 전화번호 형식 · "메모는 1,000자까지 쓸 수 있습니다."(고쳤을 때만). + - 이메일은 서버보다 좁게 본다 — @ 앞은 dot-atom 64자, 도메인 칸마다 앞뒤 하이픈 없이 63자 · 끝 점 없음 · 마지막 칸은 글자 2개 이상이거나 `xn--`, 한글 도메인은 받는다, 254자(필터의 전달 주소와 같은 검사). + 이메일 칸은 `type="text"` + `inputmode="email"` — 브라우저가 한글 도메인을 punycode 로 바꾸지 않게(받는 사람 칸과 같다). + - 같은 주소 경고의 괄호는 그 연락처의 첫 그룹(없으면 회사)이다. 칸을 고치면 경고를 내리고, 채워 연 창(읽기 화면 · 사내 목록)은 열 때 바로 본다. 늦게 뜨는 안내는 늘 있는 `role=status` 영역 안에 뜬다. + - 필수 안내("이름이나 이메일 가운데 하나는…")는 빨간 글로 바뀌고 이름 칸에 초점을 둔다. 서버의 형식 거절(`VAL_001`)은 단추 줄 위에 사전 문구로. + 저장 중에 창을 닫았으면(또는 다시 열었으면) 창 안에 보일 거절도 알림으로 알린다(그룹 이름 창도 같다). +- **보이지 않는 제어 문자**는 거절하지 않고 고친다 — 한 줄 칸의 탭 · 제어 문자는 띄어쓰기로, 메모의 CR LF · CR 은 줄바꿈으로. 열 때 · 보낼 때 고치고, + 고친 것만으로는 바꾼 것이 아니다(표에서 붙여 넣은 탭 · 다른 프로그램의 CR LF 는 사용자에게 보이지 않아 고칠 수 없기 때문). +- **크기 · 제목**: 그룹 체크 줄 · [더 보기] · [새 그룹]은 44(보드 40 — 밀도 예외를 새로 만들지 않는다). 패널의 그룹 ⋯ 는 마우스를 올리거나 초점이 갈 때 보인다(메일함 행과 같다). + 제목 수준은 메일 화면과 같다 — 패널 묶음 이름은 글 + `aria-labelledby`, 목록 제목은 h1, 상세 이름은 h2. +- **알림 문구**(보드에 없던 것): "연락처를 되돌렸습니다." · "그룹을 되돌렸습니다." · "‘X’ 그룹을 만들었습니다." · "그룹 이름을 바꿨습니다." · + "주소를 복사했습니다." / "주소를 복사하지 못했습니다." · 구성원 없는 그룹을 지웠으면 "‘X’ 그룹을 지웠습니다.". 서버가 원본을 기억하지 못한 지우기(표 없음)는 같은 문구를 [되돌리기] 없이 띄운다. +- **지운 뒤**: 보던 연락처를 지우면 그 목록으로(고른 것 없이, 초점은 목록), 보던 그룹을 지우면 전체 연락처로. 이미 지워진 것(`RES_001`)은 지운 것으로 본다. 주소 복사는 대표 주소만(주소가 없으면 항목을 끄고 까닭). +- **목록 맞추기**: 만들기 · 고치기 · 되돌리기는 응답을 서버의 차례(frontend `compareContacts` — backend `contact-search.ts` 와 짝)로 받은 쪽 안의 자리에 넣는다(받은 쪽 끝보다 뒤면 넣지 않고 수만 고친다). + 응답으로 자리를 알 수 없을 때만 첫 쪽을 한 번 다시 받는다 — 찾은 목록에 새 연락처가 생겼을 때 · 구성원 넣기로 보는 그룹이 바뀌었을 때 · 사내 직원을 넣어 전체 목록에 새 연락처가 생겼을 때. +- **찾기**: 목록을 바꾸면 찾기를 푼다. 사내 목록은 칸 안내 · 빈 문구가 따로("사내 직원이 없습니다" · "공용 주소가 없습니다"). Enter 는 바로 찾고, Esc · ✕ 는 푼다. + 글자와 자모가 섞인 조합 중 글은 보내지 않는다(초성만이면 보낸다). 찾는 글은 254자까지(서버와 같다). +- **상세**: 연락처를 찾을 수 없으면(`RES_001`) [목록으로], 그 밖의 실패는 [다시 시도]. 목록을 받지 못해도 고른 연락처는 따로 받는다. 주소로 연 사내 항목은 그 항목 하나를 받는다. + [메일 쓰기]는 주소가 없으면 꺼지고(`aria-disabled`) 까닭 "메일 주소가 없는 연락처입니다.", 사내 [내 주소록에 추가]는 연락처가 3,000개면 꺼지고 3,000개 안내. +- **읽기 화면 ⋯**: 원문 아래에 구분선을 둔다(보드대로). 「주소록에서 보기」 아이콘은 책. 보낸 사람이 바뀌면(다른 메일) 다시 판단한다 — 254자 넘는 주소면 묻지 않고 "없음"으로. + 연락처 추가 창은 **메일 레이아웃에 둔다** — 메일을 바꾸거나 목록으로 돌아가도 쓴 그대로 남고, 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다. 닫으면 ⋯ 가 아직 있을 때만 초점을 돌려준다. 채우는 주소 종류는 회사. +- **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다). +- **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구. + 다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다). diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 635ddcc..4666f6e 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -26,9 +26,12 @@ "SettingsFilter.dc.html": { "x": 0, "y": 10680, "w": 1440, "h": 900, "title": "설정 · 필터", "expand": "fill", "is_interactive": true }, "MailFilterStates.dc.html": { "x": 1520, "y": 10680, "w": 1440, "h": 1420, "title": "메일 필터 상태 · 메일에서 바로 만들기" }, "MailFilterApply.dc.html": { "x": 0, "y": 12700, "w": 1440, "h": 900, "title": "설정 · 필터 · 이미 받은 메일에 적용", "expand": "fill", "is_interactive": true }, - "MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" } + "MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" }, + "Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true }, + "ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" }, + "ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -36,7 +39,8 @@ "phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 }, "phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, - "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 } + "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }, + "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 0fd1170..4f18f30 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -8,7 +8,7 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` -- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `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 // 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/` 패치를 받지 않는다). + 칸: `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`). 그 계정의 역할에는 로그인과 계정 목록 읽기 + (`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=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.