From 188b9e84f448c4a7c24c8242994ded8883108e47 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 00:54:53 +0900 Subject: [PATCH 01/11] =?UTF-8?q?test(backend):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=ED=99=94=20CPU=20=EB=8B=A8=EC=96=B8=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=85=EB=A0=A5=204MB=20=EB=A1=9C=20=EC=98=9B=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=99=80=20=ED=95=9C=EB=8F=84=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EB=84=93=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2MB 로 다시 재 보니 옛 경로(주소 없는 img 를 exclusiveFilter 로 지울 때마다 결과를 다시 자름)가 26~28초뿐이라 한도 15초와 2배도 차이 나지 않았다 — 빠른 PC 에서는 옛 경로로 돌아가도 통과할 수 있었다. 주석의 옛 기준(27만 개에 15초)은 벽시계로 잰 옛 값이었다. 입력을 4MB(66만 개)로 키우고 한도를 25초로 둔다 — 지금 경로는 부하 아래 2.0~2.4초(10~12배 여유), 옛 경로는 100~134초(Node 22 호스트) · 154초(Node 24 Linux 컨테이너)로 한도의 4배를 넘는다. Co-Authored-By: Claude Opus 5.5 --- .../src/modules/mail/html/sanitize-mail-html.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts index 87c4a57..6e9520b 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts @@ -250,17 +250,19 @@ describe('sanitizeMailHtml', () => { ); }); - it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 27만 개에 15초)', () => { - const count = 333_333; // 2MB + it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 67만 개에 100초)', () => { + // 정화에 닿는 길이 한도(html-parse-budget.ts 의 MAX_HTML_PARSE_LENGTH, 200만 자)보다 일부러 크게 — 예전 경로(이미지 수의 제곱)와의 차이를 벌린다 + const count = 666_666; // 4MB const cpuMs = startCpuTimer(); const result = sanitizeMailHtml('x'.repeat(count)); expect(result.html).toBe('x'.repeat(count)); - // CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초. 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다. + // CPU 시간 (test/cpu-time.ts) — 지금 2.0~2.4초(전체 테스트와 함께 돌 때 — 혼자면 1~1.7초). 예전 경로(결과를 이미지마다 다시 자름)는 + // 같은 크기에 100~134초(이 PC 의 Node 22)·154초(Node 24 Linux 컨테이너)다 (2026-10-29). 2MB 로 재던 때는 예전 경로가 26~28초뿐이라 한도와 2배도 차이 나지 않았다. // 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27). CPU 시간도 2026-10-28 CI 와 같은 Linux 컨테이너에서 - // 메모리가 모자라고 테스트가 함께 돌 때 5.75초까지 재져 한도 5초로 실패했다 — 그래서 평소의 10배 가까이, 예전 경로(20초+)보다는 아래로 둔다 - expect(cpuMs()).toBeLessThan(15_000); + // 메모리가 모자라고 테스트가 함께 돌 때 2MB 가 5.75초까지 재졌다 — 그래서 한도는 평소의 10배쯤, 예전 경로는 그 4배를 넘는다 + expect(cpuMs()).toBeLessThan(25_000); }); }); -- 2.52.0 From 5ddc849c11de3768504a936dcb540e6cb0e0cb87 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 08:03:42 +0900 Subject: [PATCH 02/11] =?UTF-8?q?docs:=207=EB=8B=A8=EA=B3=84(=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=20=EB=B0=9B=EC=9D=80=20=EB=A9=94=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=A0=81=EC=9A=A9)=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 엔진의 Sieve 는 받을 때만 돌고 엔진 검색은 낱말 단위라, 서버가 받은편지함을 읽어 Sieve 와 같은 규칙으로 판정한다. - 계약 2절 "이미 받은 메일에 적용"(POST /api/mail-filter-runs): 판정 규칙은 실엔진 배달로 맞췄다 — HTML 은 닫는 p · br 만 줄바꿈, style · script · head · template 은 글째 빼고, 속성이 붙은 태그는 엔진처럼 알아보지 않는다. 머리글은 접힌 줄을 띄어쓰기 하나로, 인코딩된 글은 홀로 선 것만 푼다 - 시작한 때 받은편지함에 있던 메일만 보고(위 경계), 마지막으로 남긴 메일이 빠지면 그 초부터 다시 읽는다. 본문 값은 한 통 1MiB 까지 — 바깥 메일 한 통으로 서버가 메모리를 크게 쓰지 않게 - 부재중 응답에 isActive(엔진에서 우리 규칙이 꺼졌는지), 엔진의 임시 업로드 한도, 바깥 주소 자동 전달은 허용하지 않는다는 정책(2026-10-29 결정) - 디자인 캔버스 확정본 MailFilterApply · MailFilterApplyStates 와 README "7단계"(구현하며 정한 보완 포함) Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/MailFilterApply.dc.html | 561 ++++++++++++++++++ .../webmail/MailFilterApplyStates.dc.html | 178 ++++++ docs/design/webmail/README.md | 57 +- docs/design/webmail/canvas.json | 9 +- docs/mail/api-contract.md | 102 +++- 5 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 docs/design/webmail/MailFilterApply.dc.html create mode 100644 docs/design/webmail/MailFilterApplyStates.dc.html diff --git a/docs/design/webmail/MailFilterApply.dc.html b/docs/design/webmail/MailFilterApply.dc.html new file mode 100644 index 0000000..4b34442 --- /dev/null +++ b/docs/design/webmail/MailFilterApply.dc.html @@ -0,0 +1,561 @@ + + + + +설정 · 필터 · 이미 받은 메일에 적용 + + + + + + + +
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+
+

필터

+

받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.

+
+ +
+ +
+
    + +
  1. + {{ r.no }} + + +
    + + +
    +
  2. +
    +
+

필터 {{ count }}개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.

+
+ +
+

위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.

+

이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.

+

스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.

+

전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.

+

부재중 자동 응답은 필터와 함께 동작합니다.

+
+ +
+
+
+
+ + +
+
+

{{ dialogTitle }}

+ +
+ + +
+ +
+ 어떤 메일에 + + +
    + +
  • + + + + +
  • +
    +
+
+ + 5개까지 · 글자는 대소문자를 가리지 않습니다 +
+
+ +

비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.

+
+
+ +
+ 할 일 +
+ + +
+

옮기면 이 아래 필터는 적용하지 않습니다.

+ + +
+ + +
+

{{ fwdHelp }}

+ +

할 일을 하나 이상 골라 주세요.

+
+
+ +
+ +

{{ applyHelp }}

+
+

스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.

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

저장하지 않은 필터가 있습니다

+

창을 닫으면 고친 내용이 사라집니다.

+
+ + +
+
+
+
+ + +
+ + {{ runText }} · {{ runCount }} +
+
+ + +
+ + + + + + + {{ toastText }} + + {{ toastLinkLabel }} + + + + +
+
+
+
+ + + diff --git a/docs/design/webmail/MailFilterApplyStates.dc.html b/docs/design/webmail/MailFilterApplyStates.dc.html new file mode 100644 index 0000000..8416d80 --- /dev/null +++ b/docs/design/webmail/MailFilterApplyStates.dc.html @@ -0,0 +1,178 @@ + + + + +이미 받은 메일에 필터 적용 · 상태 + + + + + + + +
+ +
+

이미 받은 메일에 필터 적용

+

7단계에서 새로 생기는 것입니다. 「설정 · 필터 · 이미 받은 메일에 적용」 보드에서 필터를 열고 「받은편지함에 이미 있는 메일에도 적용」을 골라 저장해 볼 수 있습니다(오른쪽 위 Tweaks 에서 결과를 바꿉니다). 이 보드는 그 다른 상태와 함께 고치는 두 가지 — 고치기 창의 나가기 확인, 부재중 화면의 안내 — 를 모았습니다.

+
+ +
+ +
+
+

고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」

+

할 일 아래 · 창을 열 때마다 꺼져 있다 · 저장할 때 한 번 쓰고 기억하지 않는다(필터의 설정이 아니다)

+
+ +
+

기본

+
+ +

저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.

+
+

스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.

+
+ +
+

고름 — 전달도 하는 필터

+
+ +

저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다. 전달은 새로 오는 메일에만 합니다.

+
+
+ +
+

고를 수 없음 — 전달만 하는 필터

+
+ +

전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.

+
+
+ +
+

고를 수 없음 — 다른 필터를 적용하는 중

+
+ +

다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.

+
+
+
+ +
+
+

저장한 뒤 — 진행 · 결과 알림

+

오른쪽 아래 · 끝날 때까지 남는다 · 그동안 다른 화면으로 가서 메일을 읽고 써도 된다(탭을 닫으면 멈춘다)

+
+
+ + ‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인 +
+
+ + ‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다. + 거래처 보기 +
+
+ + ‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다. +
+
+ + 받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다. +
+

숫자는 확인한 메일 / 받은편지함의 메일 수다. 받은편지함의 최근 메일부터 본다. 옮기는 필터의 결과에는 그 메일함으로 가는 단추를 둔다 — 읽음 · 중요만 하는 필터는 단추가 없다.

+
+ +
+ +
+ +
+
+

멈췄을 때

+

서버 · 연결 문제 — [다시 시도]는 남은 메일부터 이어서 본다

+
+
+ + ‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요. + +
+
+ + 필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요. + +
+
+ + 다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요. +
+

처음 요청부터 실패해도 필터는 저장되어 있다 — 새로 오는 메일에는 적용된다. 로그아웃 · 세션 만료 · 다른 계정 로그인이면 멈추고 알림을 닫는다.

+
+ +
+
+

저장하지 않고 닫을 때

+

고치기 창의 [취소] · Esc · 다른 화면으로 가기 — 창을 연 뒤 바꾼 것이 있을 때만 묻는다

+
+
+
+

저장하지 않은 필터가 있습니다

+

창을 닫으면 고친 내용이 사라집니다.

+
+ + +
+
+
+

처음 초점은 [계속 고치기] · Esc 도 같다(서명 · 부재중 설정과 같은 모양). 다른 화면으로 가려다 물으면 "이 화면을 나가면…" · [나가기]. 탭을 닫거나 새로 고치면 브라우저가 묻는다. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.

+
+ +
+
+

부재중 자동 응답 — 메일 서버에서 규칙이 꺼졌을 때

+

자동 응답을 켜 두었는데 메일 서버의 규칙이 이 화면 밖에서 바뀐 드문 경우 — 머리 아래 줄 (필터 화면의 줄과 같은 뜻)

+
+
+ + 켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다. +
+

자동 응답을 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다(한 규칙에 들어 있다). 지금까지는 이 화면이 이 상태를 몰라 "켜짐"으로만 보였다.

+
+ +
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 27c00f8..0028889 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -372,6 +372,43 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 읽기 화면 ⋯ 메뉴에 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터의 새 필터 창이 보낸 사람의 **주소**로 채워져 열린다. - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다(목록이 바로 바뀐다). 저장 "필터를 저장했습니다.", 지우기 "‘거래처 메일’ 필터를 지웠습니다. [되돌리기]". +## 7단계 — 이미 받은 메일에 필터 적용 (2026-10-29 확정 — `MailFilterApply` · `MailFilterApplyStates`) + +필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 6단계의 두 한계(고치기 창의 나가기 확인 없음 · 부재중 화면이 꺼진 규칙을 모름)도 함께 고친다. +규칙은 계약 2절 "이미 받은 메일에 적용". + +### 고치기 창 — 「받은편지함에 이미 있는 메일에도 적용」 + +- 할 일 아래, 6단계의 안내 띠 자리에 회색 상자 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5). 상자 아래 한 줄 "스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다." +- 창을 열 때마다 꺼져 있다 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다(읽기 화면에서 만드는 새 필터도 같다). 목록의 행에는 단추를 더하지 않는다 — 있는 필터는 창을 열어 고르고 저장한다. +- 설명은 "저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다." — 전달이 있는 필터면 " 전달은 새로 오는 메일에만 합니다."를 더한다. +- 고를 수 없는 두 경우(칸을 끄고 이름을 흐리게, 설명이 까닭): + 전달만 하는 필터 "전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다." · + 이 탭에서 다른 필터를 적용하는 중 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다." +- 목록 머리의 설명과 안내 상자의 "새로 오는 메일에만"을 바꾼다(`MailFilterApply` 보드의 글 — 이미 받은 메일 줄을 더하고, 전달 줄에 "새로 오는 메일만"). + +### 저장한 뒤 — 진행 · 결과 알림 + +- "필터를 저장했습니다." 대신 4단계의 진행 알림: "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(확인한 수 / 받은편지함 메일 수 — 최근 메일부터). + 끝나면 그 자리에서 "‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다." + 옮기는 필터면 [거래처 보기](그 메일함으로) — + 읽음 · 중요만 하는 필터는 단추 없음, 맞는 메일이 없으면 "받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다." +- 멈추면 "‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요." + [다시 시도](남은 메일부터 이어서). + 처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도]. 다른 창 · PC 에서 적용 중이면 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." +- 그동안 다른 화면으로 가서 메일을 읽고 써도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면 멈추고 알림을 닫는다. + +### 저장하지 않고 닫을 때 (6단계 한계를 고친다) + +- 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — 서명 · 부재중 설정의 나가기 확인과 같은 모양: + "저장하지 않은 필터가 있습니다" / "창을 닫으면 고친 내용이 사라집니다." [계속 고치기](채운 단추 · 처음 초점 · Esc) [닫기]. + 다른 화면으로 가려다 물으면 "이 화면을 나가면 고친 내용이 사라집니다." [나가기]. 탭 닫기 · 새로고침은 브라우저 확인창. +- 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다(바꾼 것은 창을 연 때와 견준다). + +### 부재중 자동 응답 화면 — 엔진에서 규칙이 꺼졌을 때 (6단계 한계를 고친다) + +- 자동 응답을 켜 두었는데(저장한 값) 엔진에서 우리 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(필터 화면의 줄과 같은 모양): + "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다." +- 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -531,4 +568,22 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지. 전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다. 저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`). -- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계). +- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계 — 7단계에서 고쳤다, 위 "7단계"). + +### 7단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다) + +- **「…에도 적용」 줄 높이**: 할 일 칸과 같은 44(보드는 40) — 고치기 창의 밀도 예외는 조건 칸 · 단추 40 뿐이라 새 예외를 만들지 않는다. +- **고를 수 없는 경우를 넓혔다**(보드는 전달만 · 적용 중 둘): 옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 하는 필터도 이미 받은 메일에 할 일이 없다(서버도 옮기지 않는다). + 까닭은 이 차례로 하나만 — 이 탭에서 적용 중 → 지워진 메일함 → 받은편지함으로 옮기기만 → 전달만. 할 일을 아직 하나도 고르지 않았으면 막지 않는다(저장할 때 칸 아래 "할 일을 하나 이상 골라 주세요."). + 고를 수 없게 되면 골라 둔 것을 쓰지 않고, 다시 고를 수 있게 되면 돌아온다. 같은 판정으로 저장한 뒤에도 시작하지 않는다 — 그때는 "필터를 저장했습니다." +- **꺼 둔 필터도 적용한다** — 사용자가 이번 저장에 고른 일이다. 적용 칸만 고르고 다른 칸을 고치지 않아도 바꾼 것으로 본다(나가기 확인 · [저장]). +- **전체 수**: 첫 응답 전에는 받은편지함의 메일 수(메일함을 아직 받지 않았으면 수를 뺀 문구), 그 뒤로는 "확인 + 남은 수"(최근 메일부터라 그사이 온 메일 · 옮겨진 메일로 조금 바뀔 수 있다). +- **실패 알림**: 요청은 인터셉터 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 사전 문구로 알린다(세션 만료 대화상자는 그대로) — 그래서 처음 요청부터 실패하면 + 사전 문구 알림과 진행 알림의 결과 "필터는 저장했지만 …"가 함께 보인다(4단계 오래 걸리는 처리와 같다). 다른 창 · PC 에서 적용 중(429)은 결과 문구 하나만. + 도중의 429 는 이어 할 자리가 있으니 [다시 시도]를 붙인다(처음 요청의 429 는 보드처럼 단추 없음). +- **이 탭에서 적용 중에 [다시 시도]**: 시작하지 않고 "다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 자리 · 같은 수의 [다시 시도]. +- **나아가지 못한 응답**(확인 0 인데 이어 할 자리가 있다)이 세 번 이어지면 멈춘다(`MAIL_FILTER_RUN_MAX_STALLS`) — 같은 요청을 끝없이 보내지 않게. +- **맞추기**: 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 사용량을 다시 받고, 보이는 목록이 받은편지함이나 옮긴 메일함이면 그 목록도 맞춘다(실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게). +- **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다. + 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 같은 확인 창이 "이 화면을 나가면 …" [나가기]로 바뀐다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로). +- **부재중**: 메일함 패널 아래의 부재중 안내는 그대로 둔다(저장한 값으로 보인다 — 동작하지 않음은 설정 화면에서만 알린다). diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 5273b6e..635ddcc 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -24,16 +24,19 @@ "SettingsNotify.dc.html": { "x": 0, "y": 8560, "w": 1440, "h": 900, "title": "설정 · 알림", "expand": "fill", "is_interactive": true }, "MailRealtime.dc.html": { "x": 1520, "y": 8560, "w": 1440, "h": 1520, "title": "실시간 새 메일 · 바탕화면 알림 상태" }, "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": "메일 필터 상태 · 메일에서 바로 만들기" } + "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": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" } }, - "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"], + "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"], "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 }, "phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 }, "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 } + "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 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index b0a1565..0fd1170 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -208,10 +208,11 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | -| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` | -| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` | +| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` | +| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` | | `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` | | `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` | +| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` | | `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` | @@ -408,6 +409,10 @@ interface RejectedRecipient { - 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `attachmentTotalMaxBytes` (`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 기본 25MB) — 넘으면 발송이 413 `FILE_002`. 클라이언트는 첨부를 추가할 때 이 값으로 **미리 막고** 안내한다 (다 쓰고 보낼 때 알게 하지 않는다). +- **엔진의 임시 업로드 한도**(7단계에 찾음): 계정마다 1시간 동안 올린 크기 합계 · 개수(Stalwart `uploadQuota` · `maxUploadCount` · `uploadTtl`). 엔진 기본값은 50MB · 1,000개 — + 넘으면 올린 것이 만료될 때까지(1시간) 그 직원의 첨부 올리기와 필터 · 부재중 저장(엔진에 규칙 글을 올린다) · 서명 그림이 든 발송이 429 `REQ_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 - 다운로드는 `` 이동으로 받는다 — 같은 출처라 쿠키가 함께 간다. 이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다 @@ -681,6 +686,10 @@ interface MailVacation { 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자) @@ -696,8 +705,9 @@ interface MailVacation { 서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다. 필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다. - 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve"). - - 우리 스크립트가 엔진에서 꺼져 있으면(필터의 `isActive: false`) 부재중도 동작하지 않는다. 이 API 의 `isEnabled` 는 저장한 값이라 그때도 `true` 로 온다 - (알려진 한계 — 드묾. 화면은 필터 화면의 줄로만 알린다). + - 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다. + 7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — + 아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다). - 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리"). - `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다). 둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다. @@ -832,7 +842,7 @@ interface MailFilterActions { 전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`). **스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다. 판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기). - 꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다. + 꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 저절로 적용하지 않는다 — 저장할 때 고르면 아래 "이미 받은 메일에 적용"(7단계). - **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인). - 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다. @@ -878,8 +888,86 @@ interface MailFilterActions { - 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다. 전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게. 규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내. -- 이 단계에서 하지 않는 것: 이미 받은 메일에 적용, **바깥 주소로 전달**(엔진에 SRS 가 없어 Gmail 등이 DMARC 로 거절할 수 있고, 회사 메일이 개인 메일로 빠지는 통로다 — - 필요해지면 정책과 함께), 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다). +- 이 단계에서 하지 않는 것: **바깥 주소로 전달** — 전달한 메일은 보낸 사람(From)이 원래의 바깥 도메인인데 우리 서버가 건넨다. 엔진은 봉투 보낸 사람을 + 필터 주인으로 바꿔 SPF 는 통과하지만(SRS 는 없다 — 거절되면 반송은 원래 보낸 사람이 아니라 필터 주인에게 온다), 받는 쪽의 DMARC 는 From 기준이라 + 원래 보낸 쪽의 DKIM 서명에 기댄다. 서명이 없거나 깨진 메일은 그 도메인이 엄격하면(p=reject) Gmail 등이 거절하거나 스팸으로 보낸다 — 전달 메일의 일반적인 한계라 + SRS 가 있어도 같다. 그리고 회사 메일이 개인 메일로 자동으로 빠지는 통로다 — **회사 정책으로 허용하지 않는다**(2026-10-29 결정 — 전달은 사내 주소로만). + 보통 발송(직원이 바깥 주소로 보내는 메일)과는 상관없다. + 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다). + +### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계 + +필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다. +엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다. + +```ts +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, 하나도 확인하지 못했으면 503 `MAIL_001`). 같은 직원의 적용은 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭 · PC). +- **판정은 서버가 한다** — 새로 오는 메일의 Sieve(위 "메일 필터"의 "만드는 Sieve")와 같은 규칙을 따라 한다. 엔진 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 — + 낱말이 통째로 같을 때만 맞고('회의'로 '회의를' · '아라'로 '김아라'를 못 찾는다) `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27, 2026-10-28 확인). + - 보낸 사람: From 머리글의 원래 글 — 접힌 줄(줄바꿈 + 공백 · 탭)은 띄어쓰기 하나로 펴고(접지 않은 탭 · 이어진 공백은 그대로), 인코딩된 글(RFC 2047 — 문자셋 그대로)은 + **홀로 선 것만** 풀고(앞이 처음 · 공백 · `"` · `,`, 뒤가 끝 · 공백 · `"` — `abc=?…?=` · `=?…?=` · `(=?…?=)` 는 그대로. 공백만 사이에 둔 인코딩된 글끼리는 붙인다), + 앞뒤 공백을 뺀 것 — 따옴표 · 꺾쇠까지 견준다(`"Kim, Ara" ` 에 `"Kim` 이 맞는다 — 엔진과 같다). 받는 사람: To · Cc 머리글 각각(하나라도 맞으면). 제목: 풀어 쓴 제목. + - 본문: 텍스트 · HTML 부분과 **텍스트 첨부**(text/* — 파일로 붙인 것도. 엔진이 그렇게 본다). 한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지 본다 + (글자 부분이 많아 넘으면 부분마다 같은 몫만큼 앞쪽만 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게). HTML 은 글자 부분만 — 엔진과 같게: + - 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그이고(`a < b` · `<광고>` 도), 따옴표 안의 `>` 에서도 끝난다. 닫히지 않은 `<` 는 끝까지 삼킨다. + 태그는 속성까지 **빈자리 없이** 뺀다(`회의록` → "회의록", `div` · `li` · `h1` · 표 칸 · 표 줄 · `hr` · 여는 `p` 사이도 이어 붙는다). 주석(``)은 안의 글까지 뺀다 + - `

`(닫는 것만) · `
`(`
` · `
` · `
` · 대문자도)은 줄바꿈이다 — 찾을 글(줄바꿈이 없다)은 이 자리를 건너 맞지 않는다(`

앞

뒤

` 에 "앞뒤" · "앞 뒤" 모두 안 맞음) + - `뒤')).toBe('a{}뒤'); + expect(htmlToText('제목')).toBe( + '제목', + ); + expect(htmlToText('')).toBe('글'); + }); + + it('뒤')).toBe('뒤'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('제목

본문

')).toBe( + '본문\n', + ); + expect(htmlToText('보임')).toBe('보임'); + // 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다 + expect(htmlToText('머리아직끝')).toBe('끝'); + }); + + it('글을 빼는 동안에도 태그는 본다 — 그 안의

·
도 줄바꿈이다', () => { + expect(htmlToText('앞뒤')).toBe('앞\n뒤'); + expect(htmlToText('앞뒤')).toBe('앞\n뒤'); + }); + + it('title · noscript · textarea · iframe · object · svg · xml 은 글이 남는다', () => { + for (const name of [ + 'title', + 'noscript', + 'textarea', + 'iframe', + 'object', + 'svg', + 'xml', + ]) { + expect(htmlToText(`<${name}>글`)).toBe('글'); + } + }); + + it('태그는 < 부터 다음 > 까지 — < 뒤가 무엇이든 태그이고, 따옴표는 보지 않는다', () => { + // `<광고>` 는 태그로 빠지고, 맨 앞이 된 공백은 넣지 않는다 + expect(htmlToText('

<광고> 안내

')).toBe('안내\n'); + expect(htmlToText('a>b사진')).toBe('b">사진'); + expect(htmlToText('
z')).toBe( + '링크 회의 안내 z', + ); + expect(htmlToText('<>글')).toBe('글'); + }); + + it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => { + // `< 1000원할인` 은 버려지고 `

` 가 태그가 된다 + expect(htmlToText('

가격 < 1000원할인

뒤')).toBe('가격\n뒤'); + expect(htmlToText('앞 < 뒤')).toBe('앞'); + expect(htmlToText('앞b')).toBe('a\nb'); + }); + + it(' 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 (DOCTYPE · mso 조건)는 보통 태그다', () => { + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('보임')).toBe('보임'); + expect(htmlToText('

글

')).toBe('글\n'); + expect(htmlToText('앞` 까지 주석이다(안의 글까지 뺀다). 그 밖의 ``(DOCTYPE · ``)는 보통 태그다 + * - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다. + * 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `
` · `` · `

` 는 알아본다 + * - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `

` · `

` 는 아무것도 아니다), + * `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다). + * `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `

` · `
` 도 줄바꿈) + * - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회의`) · div · li · 표 칸도 이어 붙는다 + * - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 안내` → "회의 안내") + * - 문자 참조는 `;` 로 끝나는 것만 푼다(` ` 는 그대로) — 이름(HTML 4 의 이름 · `'`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다: + * ` ` → 띄어쓰기 둘, ` ` → 줄바꿈, `회의 안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), ` ` → U+00A0. + * HTML5 에만 있는 이름(`.` · 대문자 `&` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계) + * - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다 + * + * 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · htmlparser2(sanitize-html)는 쓰지 않는다 — + * htmlparser2 는 중첩 깊이의 제곱으로 느려진다 (html-parse-budget.ts). 태그의 짝도 보지 않는다(깊이를 세지 않는다). + */ + +const LESS_THAN = 0x3c; +const GREATER_THAN = 0x3e; +const SLASH = 0x2f; +const AMPERSAND = 0x26; +const HASH = 0x23; +const SEMICOLON = 0x3b; + +/** 태그 · 참조가 만든 줄바꿈 — 공백 줄이기에 들어가지 않는다 */ +const LINE_BREAK = '\n'; + +/** 주석의 처음 · 끝 */ +const COMMENT_OPEN = ''; + +/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */ +const HIDING_TAGS = ['style', 'script', 'head', 'template'] as const; +type HidingTag = (typeof HIDING_TAGS)[number]; + +/** 알아보는 이름 가운데 가장 긴 것(template)의 길이 — 이보다 긴 태그 글은 이름을 견주지 않는다 */ +const LONGEST_KNOWN_TAG = 8; + +/** 문자 참조의 이름 · 숫자를 읽는 최대 길이 — 넘으면 참조가 아니라 글자로 본다 (`&` 마다 앞으로 조금만 본다 — 길이에 비례) */ +const MAX_REFERENCE_NAME_LENGTH = 32; +const MAX_REFERENCE_DIGITS = 8; + +/** 유니코드의 끝 · 대체 문자 */ +const MAX_CODE_POINT = 0x10ffff; +const REPLACEMENT_CHARACTER = '�'; + +/** U+00A0 ~ U+00FF 의 이름 — HTML 4 의 Latin-1 문자 참조, 차례대로 */ +const LATIN1_REFERENCE_NAMES = [ + 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr', + 'deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest', + 'Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml', + 'ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig', + 'agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml', + 'eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml', +] + .join(' ') + .split(' '); +const LATIN1_REFERENCE_START = 0xa0; + +/** 그리스 문자 — 대문자 U+0391 ~ U+03A9(U+03A2 는 없다), 소문자 U+03B1 ~ U+03C9 (HTML 4) */ +const GREEK_UPPER_NAMES = [ + 'Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho', + '- Sigma Tau Upsilon Phi Chi Psi Omega', +] + .join(' ') + .split(' '); +const GREEK_LOWER_NAMES = [ + 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho', + 'sigmaf sigma tau upsilon phi chi psi omega', +] + .join(' ') + .split(' '); +const GREEK_UPPER_START = 0x391; +const GREEK_LOWER_START = 0x3b1; + +/** 그 밖의 이름 — HTML 4 의 특수 · 기호 문자 참조와 `'` (HTML 5 · XML) */ +const OTHER_REFERENCES: readonly (readonly [string, number])[] = [ + ['quot', 0x22], + ['amp', 0x26], + ['apos', 0x27], + ['lt', 0x3c], + ['gt', 0x3e], + ['OElig', 0x152], + ['oelig', 0x153], + ['Scaron', 0x160], + ['scaron', 0x161], + ['Yuml', 0x178], + ['fnof', 0x192], + ['circ', 0x2c6], + ['tilde', 0x2dc], + ['thetasym', 0x3d1], + ['upsih', 0x3d2], + ['piv', 0x3d6], + ['ensp', 0x2002], + ['emsp', 0x2003], + ['thinsp', 0x2009], + ['zwnj', 0x200c], + ['zwj', 0x200d], + ['lrm', 0x200e], + ['rlm', 0x200f], + ['ndash', 0x2013], + ['mdash', 0x2014], + ['lsquo', 0x2018], + ['rsquo', 0x2019], + ['sbquo', 0x201a], + ['ldquo', 0x201c], + ['rdquo', 0x201d], + ['bdquo', 0x201e], + ['dagger', 0x2020], + ['Dagger', 0x2021], + ['bull', 0x2022], + ['hellip', 0x2026], + ['permil', 0x2030], + ['prime', 0x2032], + ['Prime', 0x2033], + ['lsaquo', 0x2039], + ['rsaquo', 0x203a], + ['oline', 0x203e], + ['frasl', 0x2044], + ['euro', 0x20ac], + ['image', 0x2111], + ['weierp', 0x2118], + ['real', 0x211c], + ['trade', 0x2122], + ['alefsym', 0x2135], + ['larr', 0x2190], + ['uarr', 0x2191], + ['rarr', 0x2192], + ['darr', 0x2193], + ['harr', 0x2194], + ['crarr', 0x21b5], + ['lArr', 0x21d0], + ['uArr', 0x21d1], + ['rArr', 0x21d2], + ['dArr', 0x21d3], + ['hArr', 0x21d4], + ['forall', 0x2200], + ['part', 0x2202], + ['exist', 0x2203], + ['empty', 0x2205], + ['nabla', 0x2207], + ['isin', 0x2208], + ['notin', 0x2209], + ['ni', 0x220b], + ['prod', 0x220f], + ['sum', 0x2211], + ['minus', 0x2212], + ['lowast', 0x2217], + ['radic', 0x221a], + ['prop', 0x221d], + ['infin', 0x221e], + ['ang', 0x2220], + ['and', 0x2227], + ['or', 0x2228], + ['cap', 0x2229], + ['cup', 0x222a], + ['int', 0x222b], + ['there4', 0x2234], + ['sim', 0x223c], + ['cong', 0x2245], + ['asymp', 0x2248], + ['ne', 0x2260], + ['equiv', 0x2261], + ['le', 0x2264], + ['ge', 0x2265], + ['sub', 0x2282], + ['sup', 0x2283], + ['nsub', 0x2284], + ['sube', 0x2286], + ['supe', 0x2287], + ['oplus', 0x2295], + ['otimes', 0x2297], + ['perp', 0x22a5], + ['sdot', 0x22c5], + ['lceil', 0x2308], + ['rceil', 0x2309], + ['lfloor', 0x230a], + ['rfloor', 0x230b], + // HTML 5 는 꺾쇠를 U+27E8 · U+27E9 로 바꿨다 (HTML 4 는 U+2329 · U+232A) + ['lang', 0x27e8], + ['rang', 0x27e9], + ['loz', 0x25ca], + ['spades', 0x2660], + ['clubs', 0x2663], + ['hearts', 0x2665], + ['diams', 0x2666], +]; + +/** 이름 → 글자 (대소문자를 가린다 — `Ä` 과 `ä` 은 다르다) */ +const NAMED_REFERENCES: ReadonlyMap = (() => { + const named = new Map(); + const addRun = (names: readonly string[], start: number) => + names.forEach((name, offset) => { + // '-' 는 빈 자리 (그 번호에는 이름이 없다) + if (name !== '-') named.set(name, String.fromCodePoint(start + offset)); + }); + addRun(LATIN1_REFERENCE_NAMES, LATIN1_REFERENCE_START); + addRun(GREEK_UPPER_NAMES, GREEK_UPPER_START); + addRun(GREEK_LOWER_NAMES, GREEK_LOWER_START); + for (const [name, codePoint] of OTHER_REFERENCES) { + named.set(name, String.fromCodePoint(codePoint)); + } + return named; +})(); + +/** 이름 있는 문자 참조의 수 — 테스트가 표를 빠뜨리지 않았는지 본다 (HTML 4 의 252개 + apos) */ +export const NAMED_REFERENCE_COUNT = NAMED_REFERENCES.size; + +/** + * 뽑은 글 — 글의 이어진 공백은 하나로 모았다가 다음 글자 앞에 띄어쓰기 하나로 넣고(맨 앞이면 넣지 않는다), + * 푼 참조 · 태그가 만든 줄바꿈은 줄이지 않고 그대로 넣는다 + */ +class TextSink { + private readonly pieces: string[] = []; + /** 글의 공백을 만났다 — 다음 글자 앞에 띄어쓰기 하나 */ + private pendingSpace = false; + + space(): void { + this.pendingSpace = true; + } + + /** 글자 · 푼 참조 — 모아 둔 공백을 먼저 넣는다 */ + text(value: string): void { + if (this.pendingSpace && this.pieces.length > 0) this.pieces.push(' '); + this.pendingSpace = false; + this.pieces.push(value); + } + + /** `
` · `

` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */ + lineBreak(): void { + this.pendingSpace = false; + this.pieces.push(LINE_BREAK); + } + + toString(): string { + return this.pieces.join(''); + } +} + +/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */ +class HidingTags { + private readonly open = new Set(); + + get active(): boolean { + return this.open.size > 0; + } + + toggle(tag: HidingTag, opening: boolean): void { + if (opening) this.open.add(tag); + else this.open.delete(tag); + } +} + +/** HTML 의 글자 부분 — 위 머리말의 규칙 */ +export function htmlToText(html: string): string { + const sink = new TextSink(); + const hiding = new HidingTags(); + let index = 0; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === LESS_THAN) { + index = readMarkup(html, index, sink, hiding); + continue; + } + if (isHtmlSpace(code)) { + if (!hiding.active) sink.space(); + index += 1; + continue; + } + if (code === AMPERSAND) { + const reference = readReference(html, index); + if (reference !== null) { + if (!hiding.active) sink.text(reference.value); + index = reference.end; + continue; + } + } + // 글자 — 다음 `<` · `&` · 공백 앞까지 한 번에 (풀지 못한 `&` 는 글자다) + const end = textRunEnd(html, index + 1); + if (!hiding.active) sink.text(html.slice(index, end)); + index = end; + } + return sink.toString(); +} + +/** 글자가 이어지는 끝 — `<` · `&` · 공백 앞 */ +function textRunEnd(html: string, from: number): number { + let index = from; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === LESS_THAN || code === AMPERSAND || isHtmlSpace(code)) break; + index += 1; + } + return index; +} + +/** + * `<` 에서 시작하는 표시를 읽고 그 다음 자리를 돌려준다 — 주석이면 `-->` 까지, 아니면 다음 `>` 까지의 태그. + * 태그 안에서 `<` 가 또 나오면 거기서 다시 시작한다(앞의 것은 버린다). 닫히지 않으면 html.length — 나머지를 모두 삼킨다 + */ +function readMarkup( + html: string, + open: number, + sink: TextSink, + hiding: HidingTags, +): number { + let start = open; + for (;;) { + if (html.startsWith(COMMENT_OPEN, start)) { + const close = html.indexOf(COMMENT_CLOSE, start + COMMENT_OPEN.length); + return close === -1 ? html.length : close + COMMENT_CLOSE.length; + } + let index = start + 1; + while (index < html.length) { + const code = html.charCodeAt(index); + if (code === GREATER_THAN || code === LESS_THAN) break; + index += 1; + } + if (index >= html.length) return html.length; + if (html.charCodeAt(index) === LESS_THAN) { + start = index; + continue; + } + applyTag(html, start + 1, index, sink, hiding); + return index + 1; + } +} + +/** + * 태그 글(`<` 와 `>` 사이)의 할 일 — 이름은 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 나머지 전부다(속성이 붙으면 알아보지 않는다). + * 닫는 태그는 첫 공백 아닌 글자가 `/` 인 것. br → 줄바꿈 · 닫는 p → 줄바꿈 · style · script · head · template → 안의 글을 빼기 시작 · 끝 + */ +function applyTag( + html: string, + from: number, + to: number, + sink: TextSink, + hiding: HidingTags, +): void { + let start = from; + while (start < to && isHtmlSpace(html.charCodeAt(start))) start += 1; + const closing = start < to && html.charCodeAt(start) === SLASH; + while (start < to && isNameEdge(html.charCodeAt(start))) start += 1; + let end = to; + while (end > start && isNameEdge(html.charCodeAt(end - 1))) end -= 1; + if (end - start > LONGEST_KNOWN_TAG) return; + + if (isTagName(html, start, end, 'br')) { + sink.lineBreak(); + return; + } + if (isTagName(html, start, end, 'p')) { + if (closing) sink.lineBreak(); + return; + } + const tag = HIDING_TAGS.find((name) => isTagName(html, start, end, name)); + if (tag) hiding.toggle(tag, !closing); +} + +/** 태그 이름 자리가 그 이름(소문자)과 같은가 — ASCII 대소문자만 무시한다 */ +function isTagName( + html: string, + start: number, + end: number, + name: string, +): boolean { + return end - start === name.length && matchesAsciiLower(html, start, name); +} + +/** 이름 앞뒤에서 빼는 글자 — 공백 · `/` */ +function isNameEdge(code: number): boolean { + return isHtmlSpace(code) || code === SLASH; +} + +/** html 의 그 자리부터가 소문자 낱말(영문)과 같은가 — ASCII 대소문자만 무시한다 */ +function matchesAsciiLower(html: string, at: number, word: string): boolean { + if (at + word.length > html.length) return false; + for (let offset = 0; offset < word.length; offset += 1) { + let code = html.charCodeAt(at + offset); + if (code >= 0x41 && code <= 0x5a) code += 0x20; + if (code !== word.charCodeAt(offset)) return false; + } + return true; +} + +/** `&` 자리에서 시작하는 문자 참조 — `;` 로 끝나는 것만, 풀 수 없으면 null (글자 그대로 둔다) */ +function readReference( + text: string, + at: number, +): { readonly value: string; readonly end: number } | null { + if (text.charCodeAt(at + 1) === HASH) return readNumericReference(text, at); + const nameStart = at + 1; + const limit = Math.min(text.length, nameStart + MAX_REFERENCE_NAME_LENGTH); + let index = nameStart; + while (index < limit && isAsciiAlphanumeric(text.charCodeAt(index))) { + index += 1; + } + if (index === nameStart || text.charCodeAt(index) !== SEMICOLON) return null; + const value = NAMED_REFERENCES.get(text.slice(nameStart, index)); + return value === undefined ? null : { value, end: index + 1 }; +} + +/** `{` · `😀` — 없는 글자(0 · 대리 · 유니코드 밖)는 대체 문자 (HTML 표준) */ +function readNumericReference( + text: string, + at: number, +): { readonly value: string; readonly end: number } | null { + const marker = text.charCodeAt(at + 2); + const hex = marker === 0x78 || marker === 0x58; + const digitsStart = hex ? at + 3 : at + 2; + const limit = Math.min(text.length, digitsStart + MAX_REFERENCE_DIGITS); + let index = digitsStart; + while ( + index < limit && + (hex + ? isHexDigit(text.charCodeAt(index)) + : isDecimalDigit(text.charCodeAt(index))) + ) { + index += 1; + } + if (index === digitsStart || text.charCodeAt(index) !== SEMICOLON) { + return null; + } + const codePoint = Number.parseInt( + text.slice(digitsStart, index), + hex ? 16 : 10, + ); + return { value: characterOf(codePoint), end: index + 1 }; +} + +function characterOf(codePoint: number): string { + if ( + codePoint === 0 || + codePoint > MAX_CODE_POINT || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + return REPLACEMENT_CHARACTER; + } + return String.fromCodePoint(codePoint); +} + +/** 글의 공백 — 띄어쓰기 · 탭 · LF · CR (엔진과 같다 — FF · U+00A0 · U+3000 은 아니다) */ +function isHtmlSpace(code: number): boolean { + return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isDecimalDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isHexDigit(code: number): boolean { + return ( + isDecimalDigit(code) || + (code >= 0x41 && code <= 0x46) || + (code >= 0x61 && code <= 0x66) + ); +} + +function isAsciiAlphanumeric(code: number): boolean { + return isAsciiLetter(code) || isDecimalDigit(code); +} diff --git a/backend/src/modules/mail/jmap/bulk-run.ts b/backend/src/modules/mail/jmap/bulk-run.ts new file mode 100644 index 0000000..6107668 --- /dev/null +++ b/backend/src/modules/mail/jmap/bulk-run.ts @@ -0,0 +1,99 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { JMAP_REQUEST_TIMEOUT_MS, type JmapCallOptions } from './jmap.client'; + +/** + * 오래 걸리는 처리 한 요청의 시간 · 진행 · 멈춘 차례 — 메일함 전체 읽음 · 비우기 · 지우기(jmap-mail.provider.ts, 계약 2절 "메일함 관리")와 + * 이미 받은 메일에 필터 적용(jmap-filter-run.ts, 계약 2절 "이미 받은 메일에 적용")이 같은 규칙을 쓴다. + * 이 모듈은 `modules/mail/jmap/` 밖으로 내보내지 않는다. + */ + +/** + * 한 요청의 시간 — 실제 상한이다 (BulkRun). 이 안에서 끝내고 남은 수(또는 이어 할 자리)를 돌려준다. + * 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다. + */ +export const BULK_REQUEST_BUDGET_MS = 25_000; +/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두어 번)가 끝까지 시간 안에 들게 */ +export const BULK_ROUND_RESERVE_MS = 5_000; + +/** + * 상태가 어긋나면(ifInState — stateMismatch) 다시 확인하는 횟수 — 조건부로 바꾸는 요청을 모두 합쳐. + * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓰고, + * 오래 걸리는 처리(BulkRun)는 아무것도 바뀌지 않은 차례가 이어서 이 횟수면 멈춘다. + * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 — + * 그래도 이 횟수 안에 맞지 않으면 지우지(옮기지) 않는다. + */ +export const CONDITIONAL_DESTROY_ATTEMPTS = 3; + +/** + * 오래 걸리는 처리의 한 요청 — 시간 · 진행 · 멈춘 차례. + * - 시간: 처음 엔진 호출(메일함 목록) **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다 + * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 — + * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게 + * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 + * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다 + * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 + * 처리한 것(processed)은 쓰는 쪽이 정한다 — 메일함 처리는 바꾼 메일 수, 필터 적용은 판정한 메일 수. + * 시계는 만들 때부터 간다 — 앞서 시작한 때(startedAt)를 주면 그때부터 센다(필터 적용은 서비스에 들어온 때 — 옮길 메일함 확인을 포함한다) + */ +export class BulkRun { + private readonly deadline: number; + private stalls = 0; + private lastStall: string | null = null; + /** 이번 요청에서 처리한 메일 수 */ + processed = 0; + + constructor(startedAt: number = Date.now()) { + this.deadline = startedAt + BULK_REQUEST_BUDGET_MS; + } + + /** 다음 차례를 시작해도 되는가 */ + canStartRound(): boolean { + return this.left() > BULK_ROUND_RESERVE_MS; + } + + /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */ + callOptions(): JmapCallOptions { + return { + timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())), + }; + } + + /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */ + progress(count: number): void { + if (count <= 0) return; + this.processed += count; + this.stalls = 0; + } + + /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */ + stalled(why: string): boolean { + this.stalls += 1; + this.lastStall = why; + return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS; + } + + /** + * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다. + * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로) + */ + cutOffAfterProgress(error: unknown): boolean { + return ( + this.processed > 0 && + this.left() <= 0 && + error instanceof BusinessException && + error.code === 'MAIL_001' + ); + } + + /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */ + assertProgress(operation: string): void { + if (this.processed > 0) return; + throw new BusinessException('MAIL_001', { + context: { reason: `${operation}-no-progress`, stalled: this.lastStall }, + }); + } + + private left(): number { + return this.deadline - Date.now(); + } +} diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts new file mode 100644 index 0000000..8fdaba7 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts @@ -0,0 +1,174 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; +import { + decodeFilterRunCursor, + encodeFilterRunCursor, + MAX_FILTER_RUN_SECONDS, + type FilterRunPosition, + type FilterRunRule, +} from './filter-run-cursor'; + +const rule: FilterRunRule = { + match: 'any', + conditions: [ + { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, +}; + +/** 2026-10-29T09:00:00Z (초) */ +const NINE = 1_793_264_400; + +function failureOf(run: () => unknown): Record | undefined { + try { + run(); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + return { code: error.code, reason: error.context?.reason }; + } + return undefined; +} + +const forged = (payload: unknown) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +/** 진짜 cursor 의 규칙 지문 — 손으로 만든 cursor 에 붙인다 */ +function hashOf(cursor: string): string { + return ( + JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + r: string; + } + ).r; +} + +describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 받은 메일에 적용")', () => { + it('만든 자리를 그대로 되돌린다 — 위 경계 · 남긴 마지막 메일 · 그 받은 시각 (남긴 메일이 아직 없으면 anchor 는 null, 받은 시각만 있을 수 있다)', () => { + const positions: FilterRunPosition[] = [ + { before: NINE + 1, anchor: 'seaaaaer', anchorTime: NINE - 60 }, + { before: NINE + 1, anchor: null, anchorTime: NINE - 60 }, + { before: NINE + 1, anchor: null, anchorTime: null }, + { before: MAX_FILTER_RUN_SECONDS, anchor: 'm1', anchorTime: 0 }, + ]; + for (const position of positions) { + expect( + decodeFilterRunCursor(encodeFilterRunCursor(position, rule), rule), + ).toEqual(position); + } + }); + + it('URL 에 그대로 넣을 수 있는 글자만 쓰고 짧다 (요청 검증의 1,024자 안 — 가장 긴 메일 id · 가장 큰 시각이어도)', () => { + const cursor = encodeFilterRunCursor( + { + before: MAX_FILTER_RUN_SECONDS, + anchor: 'a'.repeat(255), + anchorTime: MAX_FILTER_RUN_SECONDS - 1, + }, + rule, + ); + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); + expect(cursor.length).toBeLessThan(1_024); + }); + + it('다른 규칙의 cursor 는 VAL_001 — 묶는 방법 · 조건 · 할 일 어느 것이 달라도', () => { + const cursor = encodeFilterRunCursor( + { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }, + rule, + ); + const others: FilterRunRule[] = [ + { ...rule, match: 'all' }, + { + ...rule, + conditions: [{ ...rule.conditions[0], value: '@hanbit-partners' }], + }, + { + ...rule, + conditions: [{ ...rule.conditions[0], operator: 'notContains' }], + }, + { ...rule, actions: { ...rule.actions, moveToMailboxId: 'k' } }, + { ...rule, actions: { ...rule.actions, flag: true } }, + ]; + for (const other of others) { + expect(failureOf(() => decodeFilterRunCursor(cursor, other))).toEqual({ + code: 'VAL_001', + reason: 'invalid-filter-run-cursor:other-rule', + }); + } + }); + + it('규칙 객체의 키 순서 · 다른 속성(cursor 등)과는 상관없다 — 같은 규칙이면 같은 지문', () => { + const position = { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }; + const cursor = encodeFilterRunCursor(position, rule); + const reordered: MailFilterRunRequest = { + cursor: 'anything', + actions: { flag: false, markAsRead: true, moveToMailboxId: 'j' }, + conditions: [ + { + value: '@hanbit-partners.co.kr', + operator: 'contains', + field: 'from', + }, + ], + match: 'any', + }; + expect(decodeFilterRunCursor(cursor, reordered)).toEqual(position); + }); + + it('깨진 cursor 는 VAL_001 — base64url · JSON 이 아님 · 판 · 모양(시각의 형식 · 범위 · 차례, 메일 id 형식) · 너무 김', () => { + const r = hashOf( + encodeFilterRunCursor( + { before: NINE, anchor: 'm1', anchorTime: NINE - 1 }, + rule, + ), + ); + const good = { v: 2, b: NINE, a: 'm1', t: NINE - 1, r }; + const cases: [string, string][] = [ + ['%%%', 'not-json'], + [Buffer.from('not json').toString('base64url'), 'not-json'], + // 1판(자리 수로 이어 하던 것)은 받지 않는다 + [forged({ v: 1, a: 'm1', p: 1, r }), 'shape'], + [forged({ ...good, v: 3 }), 'shape'], + // 위 경계 — 없음 · 0 · 음수 · 소수 · 글 · 범위 밖 + [forged({ ...good, b: undefined }), 'shape'], + [forged({ ...good, b: 0, t: null, a: null }), 'shape'], + [forged({ ...good, b: -5 }), 'shape'], + [forged({ ...good, b: NINE + 0.5 }), 'shape'], + [forged({ ...good, b: String(NINE) }), 'shape'], + [forged({ ...good, b: MAX_FILTER_RUN_SECONDS + 1 }), 'shape'], + // 남긴 메일의 받은 시각 — 위 경계와 같거나 뒤 · 음수 · 소수 · 글 · 없음(anchor 가 있는데) + [forged({ ...good, t: NINE }), 'shape'], + [forged({ ...good, t: NINE + 10 }), 'shape'], + [forged({ ...good, t: -1 }), 'shape'], + [forged({ ...good, t: NINE - 0.5 }), 'shape'], + [forged({ ...good, t: String(NINE - 1) }), 'shape'], + [forged({ ...good, t: null }), 'shape'], + [forged({ ...good, t: undefined }), 'shape'], + // 메일 id 형식 + [forged({ ...good, a: 'm/1' }), 'shape'], + [forged({ ...good, a: '' }), 'shape'], + [forged({ ...good, a: 7 }), 'shape'], + [forged({ ...good, a: undefined }), 'shape'], + // 지문 없음 · 글이 아님 + [forged({ ...good, r: undefined }), 'shape'], + [forged({ ...good, r: 1 }), 'shape'], + [forged(null), 'shape'], + [forged([2, NINE]), 'shape'], + [forged('cursor'), 'shape'], + ['a'.repeat(1_025), 'too-long'], + ]; + for (const [cursor, reason] of cases) { + expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({ + code: 'VAL_001', + reason: `invalid-filter-run-cursor:${reason}`, + }); + } + // 손으로 만들어도 모양 · 지문이 맞으면 받는다 (자리는 엔진에 묻는 시작점일 뿐 — 위 경계 아래의 받은편지함만 읽는다) + expect(decodeFilterRunCursor(forged(good), rule)).toEqual({ + before: NINE, + anchor: 'm1', + anchorTime: NINE - 1, + }); + expect( + decodeFilterRunCursor(forged({ ...good, a: null, t: null }), rule), + ).toEqual({ before: NINE, anchor: null, anchorTime: null }); + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts new file mode 100644 index 0000000..057c3e1 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts @@ -0,0 +1,133 @@ +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + MAIL_ID_PATTERN, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, +} from '@/modules/mail/mail.constants'; +import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; + +/** + * 이미 받은 메일에 필터 적용의 이어 할 자리 (계약 2절 "이미 받은 메일에 적용"의 cursor — 클라이언트에게는 불투명 문자열, base64url JSON). + * 엔진의 조회 규칙(2026-10-29 확인)에 기대어 자리를 받은 시각으로 잡는다 — 받은 시각은 초 단위이고, `before` 는 미만(<)이며, + * 받은 시각이 같은 메일은 만든 차례로 나오고 표시를 바꾸거나 옮겼다 되돌려도 차례가 그대로다: + * - before(위 경계): 첫 요청이 본 받은편지함 맨 위 메일의 받은 시각 + 1초 — 모든 조회에 `before` 로 건다. 적용을 시작한 뒤 온 메일은 보지 않는다 + * (새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다) + * - anchor: 지금까지 확인하고 받은편지함에 **남긴**(옮기지 않은) 마지막 메일 — 다음 차례는 그 다음부터(`anchor` + 1) 읽는다 + * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다 + * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다 + * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다) + * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다 + */ +const CURSOR_VERSION = 2; + +/** 규칙 지문의 길이 */ +const RULE_HASH_LENGTH = 16; + +/** cursor 에 담는 시각의 끝 — JMAP UTCDate 의 끝(9999-12-31T23:59:59Z, 초) */ +export const MAX_FILTER_RUN_SECONDS = 253_402_300_799; + +/** 이어 할 자리 */ +export interface FilterRunPosition { + /** 위 경계(초) — 받은 시각이 이보다 앞선 메일만 본다 */ + readonly before: number; + /** 받은편지함에 남긴 마지막 메일 — 없으면 null */ + readonly anchor: string | null; + /** 그 메일(또는 빠진 그 메일)의 받은 시각(초) — 아직 없으면 null. anchor 가 있으면 늘 있다 */ + readonly anchorTime: number | null; +} + +/** 이 cursor 를 만든 규칙 — 묶는 방법 · 조건 · 할 일 */ +export type FilterRunRule = Pick< + MailFilterRunRequest, + 'match' | 'conditions' | 'actions' +>; + +interface EncodedFilterRunCursor { + v: number; + b: number; + a: string | null; + t: number | null; + r: string; +} + +/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */ +function ruleHash(rule: FilterRunRule): string { + const canonical = JSON.stringify([ + rule.match, + rule.conditions.map(({ field, operator, value }) => [ + field, + operator, + value, + ]), + [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag], + ]); + return createHash('sha256') + .update(canonical, 'utf8') + .digest('base64url') + .slice(0, RULE_HASH_LENGTH); +} + +function invalidCursor(reason: string): BusinessException { + return new BusinessException('VAL_001', { + context: { reason: `invalid-filter-run-cursor:${reason}` }, + }); +} + +/** cursor 에 담을 수 있는 시각(초)인가 */ +function isSeconds(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_FILTER_RUN_SECONDS + ); +} + +export function encodeFilterRunCursor( + position: FilterRunPosition, + rule: FilterRunRule, +): string { + const payload: EncodedFilterRunCursor = { + v: CURSOR_VERSION, + b: position.before, + a: position.anchor, + t: position.anchorTime, + r: ruleHash(rule), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */ +export function decodeFilterRunCursor( + cursor: string, + rule: FilterRunRule, +): FilterRunPosition { + if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { + throw invalidCursor('too-long'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw invalidCursor('not-json'); + } + + const { v, b, a, t, r } = (parsed ?? {}) as Partial; + if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { + throw invalidCursor('shape'); + } + if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { + throw invalidCursor('shape'); + } + // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 + if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { + throw invalidCursor('shape'); + } + if (typeof r !== 'string') throw invalidCursor('shape'); + + if (r !== ruleHash(rule)) throw invalidCursor('other-rule'); + + return { before: b, anchor: a, anchorTime: t }; +} diff --git a/backend/src/modules/mail/jmap/filter-run-texts.spec.ts b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts new file mode 100644 index 0000000..1b9bce0 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts @@ -0,0 +1,1412 @@ +import { judgeFilter } from '@/modules/mail/mail-filter-match'; +import type { MailFilterField } from '@/modules/mail/mail.types'; +import { + bodyTextsOf, + estimatedBodyBytes, + filterRunProperties, + headerTextsOf, + textPartsOf, +} from './filter-run-texts'; +import type { JmapEmail } from './jmap.types'; + +/** 엔진 메일 — 원래 머리글 속성(`header:From:all` 등)이 더 붙는다 */ +type DeliveredEmail = JmapEmail & Record; + +/** 실배달 한 통과 규칙 하나 — 엔진(Sieve)이 새로 받은 그 메일에 내린 판정 */ +interface DeliveredCase { + readonly key: string; + readonly note: string; + readonly field: MailFilterField; + readonly value: string; + /** 엔진의 판정 — 규칙의 옮기기가 일어났는가. null 은 판정 불가(엔진이 스팸으로 분류해 규칙을 돌리지 않았다) */ + readonly engine: boolean | null; + readonly email: DeliveredEmail; +} + +/** + * 2026-10-29 개발 엔진(Stalwart 0.16.27)에 실제로 배달한 시험 메일 20통 — 규칙(견줄 곳 · 찾을 글)마다 엔진이 Sieve 로 판정한 결과와, + * 같은 메일을 JMAP 으로 읽은 응답(원래 머리글 · 부분 · 본문 값). 원래 머리글은 엔진이 준 그대로다(앞의 공백 · encoded-word). + * 받는 쪽 주소는 시험 계정(arasystem.test), 보낸 쪽은 example.com. p7smvs8xqfv 는 시험마다 붙인 표지다 + */ +const DELIVERED: readonly DeliveredCase[] = [ + { + key: 'B1', + note: 'HTML 만 — 보이는 글', + field: 'body', + value: 'VISp7smvs8xqfv', + engine: true, + email: { + id: 'seaaaaer', + mailboxIds: { a: true }, + subject: 'B1 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

보이는 VISp7smvs8xqfv

' } }, + }, + }, + { + key: 'B2', + note: 'HTML 속성 값은 글이 아니다', + field: 'body', + value: 'ATTRp7smvs8xqfv', + engine: false, + email: { + id: 'siaaaaes', + mailboxIds: { a: true }, + subject: 'B2 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { + '0': { value: '

x

y' }, + }, + }, + }, + { + key: 'B3', + note: '

x

', + }, + }, + }, + }, + { + key: 'B4', + note: '문자 참조(&)를 푼다', + field: 'body', + value: 'A&Bp7smvs8xqfv', + engine: true, + email: { + id: 'sqaaaaeu', + mailboxIds: { a: true }, + subject: 'B4 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

A&Bp7smvs8xqfv

' } }, + }, + }, + { + key: 'B5', + note: '대안 본문 — HTML 쪽에만 있음', + field: 'body', + value: 'HALTp7smvs8xqfv', + engine: true, + email: { + id: 'suaaaaev', + mailboxIds: { a: true }, + subject: 'B5 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { + '1': { value: '평문만' }, + '2': { value: '

HALTp7smvs8xqfv

' }, + }, + }, + }, + { + key: 'B6', + note: '대안 본문 — 텍스트 쪽에만 있음', + field: 'body', + value: 'PALTp7smvs8xqfv', + engine: true, + email: { + id: 'syaaaaew', + mailboxIds: { a: true }, + subject: 'B6 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { + '1': { value: 'PALTp7smvs8xqfv' }, + '2': { value: '

서식만

' }, + }, + }, + }, + { + key: 'B7', + note: '텍스트 첨부(a.txt)도 본다', + field: 'body', + value: 'ATTACHp7smvs8xqfv', + engine: true, + email: { + id: 's2aaaaex', + mailboxIds: { a: true }, + subject: 'B7 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [{ partId: '2', type: 'text/plain', name: 'a.txt' }], + bodyValues: { + '1': { value: '본문' }, + '2': { value: 'ATTACHp7smvs8xqfv' }, + }, + }, + }, + { + key: 'B8', + note: '주석 안은 뺀다', + field: 'body', + value: 'CMTp7smvs8xqfv', + engine: false, + email: { + id: 'taaaaaey', + mailboxIds: { a: true }, + subject: 'B8 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

x

' } }, + }, + }, + { + key: 'B9', + note: '

x

" }, + }, + }, + }, + { + key: 'B10', + note: '인라인 텍스트 부분(disposition inline)', + field: 'body', + value: 'INLINEp7smvs8xqfv', + engine: true, + email: { + id: 'tiaaaae7', + mailboxIds: { a: true }, + subject: 'B10 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '2', type: 'text/plain' }, + ], + htmlBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '2', type: 'text/plain' }, + ], + attachments: [], + bodyValues: { + '1': { value: '본문' }, + '2': { value: 'INLINEp7smvs8xqfv' }, + }, + }, + }, + { + key: 'B11', + note: 'QP 로 보낸 텍스트 — 엔진이 풀어 준 값', + field: 'body', + value: 'ENCp7smvs8xqfv', + engine: true, + email: { + id: 'tmaaaae9', + mailboxIds: { a: true }, + subject: 'B11 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: '앞 ENCp7smvs8xqfv 뒤\n\n' } }, + }, + }, + { + key: 'B12', + note: 'EUC-KR 텍스트 — 엔진이 풀어 준 값', + field: 'body', + value: '한글표시 KRp7smvs8xqfv', + engine: true, + email: { + id: 'tqaaaae2', + mailboxIds: { a: true }, + subject: 'B12 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: '한글표시 KRp7smvs8xqfv' } }, + }, + }, + { + key: 'B13', + note: '  는 U+00A0 — 띄어쓰기로 견주면 맞지 않는다', + field: 'body', + value: '줄 간격p7smvs8xqfv', + engine: false, + email: { + id: 'drmaaaa2l', + mailboxIds: { a: true }, + subject: 'B13 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '

줄 간격p7smvs8xqfv

' } }, + }, + }, + { + key: 'B14', + note: '블록 사이에 띄어쓰기가 생기지 않는다', + field: 'body', + value: '앞 뒤p7smvs8xqfv', + engine: false, + email: { + id: 'xcqaaafyu', + mailboxIds: { a: true }, + subject: 'B14 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/html' }], + htmlBody: [{ partId: '0', type: 'text/html' }], + attachments: [], + bodyValues: { '0': { value: '
앞
뒤p7smvs8xqfv
' } }, + }, + }, + { + key: 'H1', + note: '인코딩된 표시 이름 — 엔진은 스팸으로 분류해 규칙을 돌리지 않았다(판정 불가)', + field: 'from', + value: '김아라 '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H2', + note: '따옴표 표시 이름 — 따옴표까지 견준다', + field: 'from', + value: '"Kim', + engine: true, + email: { + id: 'xcyaaafyw', + mailboxIds: { a: true }, + subject: 'H2 p7smvs8xqfv', + 'header:From:all': [' "Kim, Ara" '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H3', + note: '받는 사람 여럿 — 원래 글 그대로', + field: 'to', + value: 'a1@example.com, a2@', + engine: true, + email: { + id: 'xc2aaafyx', + mailboxIds: { a: true }, + subject: 'H3 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [ + ' a1@example.com, a2@example.com, ', + ], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H4', + note: 'UTF-8 로 인코딩된 제목 — 조사가 붙은 낱말', + field: 'subject', + value: '회의', + engine: true, + email: { + id: 'xdaaaafyy', + mailboxIds: { a: true }, + subject: 'H4 주간 회의를 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H5', + note: 'EUC-KR 로 인코딩된 제목', + field: 'subject', + value: '견적서', + engine: true, + email: { + id: 'xdeaaafyz', + mailboxIds: { a: true }, + subject: 'H5 견적서 요청 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, + { + key: 'H6', + note: '인코딩된 이름의 참조(Cc)', + field: 'to', + value: 'p7smvs8xqfvcc@', + engine: true, + email: { + id: 'xdiaaafy7', + mailboxIds: { a: true }, + subject: 'H6 p7smvs8xqfv', + 'header:From:all': [' Probe '], + 'header:To:all': [' '], + 'header:Cc:all': [ + ' =?UTF-8?B?7LC47KGw7J24?= ', + ], + textBody: [{ partId: '0', type: 'text/plain' }], + htmlBody: [{ partId: '0', type: 'text/plain' }], + attachments: [], + bodyValues: { '0': { value: 'x' } }, + }, + }, +]; + +const judge = ( + { field, value }: Pick, + email: JmapEmail, + operator: 'contains' | 'notContains' = 'contains', +) => + judgeFilter( + { match: 'all', conditions: [{ field, operator, value }] }, + { ...headerTextsOf(email), body: bodyTextsOf(email) }, + ); + +describe('이미 받은 메일의 견줄 글 — 엔진(Sieve)과 같은 판정 (계약 2절 "이미 받은 메일에 적용", 실배달 20통)', () => { + const judged = DELIVERED.filter((item) => item.engine !== null); + + it('판정할 수 있는 19통이 모두 있다 (본문 14 · 머리글 5)', () => { + expect(judged).toHaveLength(19); + expect(judged.filter((item) => item.field === 'body')).toHaveLength(14); + }); + + it.each(judged.map((item) => [item.key, item.note, item] as const))( + '%s %s', + (_key, _note, item) => { + expect(judge(item, item.email)).toBe(item.engine); + // 포함하지 않음은 같은 검사의 반대다 (`not header …` · `not body …`) + expect(judge(item, item.email, 'notContains')).toBe(!item.engine); + }, + ); + + it('H1 — 엔진은 스팸으로 분류해 규칙을 돌리지 않았다(판정 불가). 서버는 스팸 표시를 보지 않는다 — 인코딩된 표시 이름을 풀어 맞춘다', () => { + const spam = DELIVERED.find((item) => item.key === 'H1'); + expect(spam?.engine).toBeNull(); + if (!spam) return; + expect(judge(spam, spam.email)).toBe(true); + expect(headerTextsOf(spam.email).from).toEqual([ + '김아라 ', + ]); + }); +}); + +/** HTML 본문 사례 — 본문이 HTML 한 부분뿐인 메일, 규칙은 "본문 포함 " */ +interface HtmlCase { + readonly key: string; + readonly label: string; + readonly html: string; + readonly value: string; + /** 엔진의 판정 — 규칙의 옮기기가 일어났는가 */ + readonly engine: boolean; + /** 엔진과 다른 것을 알고 둔 사례 — 서버는 엔진의 반대로 판정한다 (계약 2절의 알려진 한계) */ + readonly limitation?: true; +} + +/** 머리글 사례 — 보낸 사람 · 받는 사람은 엔진이 주는 원래 머리글(raw), 제목은 엔진이 풀어 준 subject */ +interface HeaderCase { + readonly key: string; + readonly label: string; + readonly field: Exclude; + readonly value: string; + readonly engine: boolean; + readonly raw?: string; + readonly subject?: string; +} + +/** + * HTML 본문 — 2026-10-29 인증 발송(ara → seojun, 스팸 판정 없음) 75통에 엔진이 배달 때 내린 판정(규칙 "본문 포함 <찾을 글>"). + * 사례는 W(10) · X(15) · Y(23) · Z(27) 네 묶음 — 찾을 글은 끝에 키 + 표지(p7mark)가 붙는다. engine 이 기대값이다. + * limitation: 엔진과 다른 것을 알고 둔 사례(계약의 알려진 한계 — HTML5 에만 있는 이름 참조) + */ +const HTML_CASES: readonly HtmlCase[] = [ + { + key: 'W1', + label: '낱말 안 인라인 태그', + html: '

회의록W1p7mark

', + value: '회의록W1p7mark', + engine: true, + }, + { + key: 'W2', + label: '원문 줄바꿈', + html: '

프로젝트\r\n알파W2p7mark

', + value: '프로젝트 알파W2p7mark', + engine: true, + }, + { + key: 'W3', + label: '공백 셋', + html: '

둘 공백W3p7mark

', + value: '둘 공백W3p7mark', + engine: true, + }, + { + key: 'W4', + label: '블록 사이(붙여)', + html: '

앞

뒤W4p7mark

', + value: '앞뒤W4p7mark', + engine: false, + }, + { + key: 'W5', + label: '블록 사이(띄어)', + html: '

앞

뒤W5p7mark

', + value: '앞 뒤W5p7mark', + engine: false, + }, + { + key: 'W6', + label: 'br(붙여)', + html: '
앞
뒤W6p7mark
', + value: '앞뒤W6p7mark', + engine: false, + }, + { + key: 'W7', + label: '표 칸(붙여)', + html: '
앞뒤W7p7mark
', + value: '앞뒤W7p7mark', + engine: true, + }, + { + key: 'W8', + label: '숫자 참조', + html: '

한글W8p7mark

', + value: '한글W8p7mark', + engine: true, + }, + { + key: 'W9', + label: 'head 의 title', + html: '제목글W9p7mark

x

', + value: '제목글W9p7mark', + engine: false, + }, + { + key: 'W10', + label: '인라인 사이 띄어쓰기', + html: '회의 일정W10p7mark', + value: '회의 일정W10p7mark', + engine: true, + }, + { + key: 'X1', + label: '목록 항목 사이', + html: '
  • 앞
  • 뒤X1p7mark
', + value: '앞뒤X1p7mark', + engine: true, + }, + { + key: 'X2', + label: '제목 뒤', + html: '

앞

뒤X2p7mark', + value: '앞뒤X2p7mark', + engine: true, + }, + { + key: 'X3', + label: '표 줄 사이', + html: '
앞
뒤X3p7mark
', + value: '앞뒤X3p7mark', + engine: true, + }, + { + key: 'X4', + label: '인용 뒤', + html: '
앞
뒤X4p7mark', + value: '앞뒤X4p7mark', + engine: true, + }, + { + key: 'X5', + label: 'hr', + html: '앞
뒤X5p7mark', + value: '앞뒤X5p7mark', + engine: true, + }, + { + key: 'X6', + label: 'div 안 div', + html: '
앞
뒤X6p7mark
', + value: '앞뒤X6p7mark', + engine: true, + }, + { + key: 'X7', + label: '닫는 p 만', + html: '앞

뒤X7p7mark', + value: '앞뒤X7p7mark', + engine: false, + }, + { + key: 'X8', + label: '표 칸 사이 띄어', + html: '
앞 뒤X8p7mark
', + value: '앞 뒤X8p7mark', + engine: true, + }, + { + key: 'X9', + label: 'span 사이', + html: '앞뒤X9p7mark', + value: '앞뒤X9p7mark', + engine: true, + }, + { + key: 'X10', + label: 'br/ 닫힌 꼴', + html: '앞
뒤X10p7mark', + value: '앞뒤X10p7mark', + engine: false, + }, + { + key: 'X11', + label: '대문자 P', + html: '

앞

뒤X11p7mark

', + value: '앞뒤X11p7mark', + engine: false, + }, + { + key: 'X12', + label: 'head meta 뒤 body', + html: '본문X12p7mark', + value: '본문X12p7mark', + engine: true, + }, + { + key: 'X13', + label: 'a 링크 글', + html: '
', + value: '링크글X13p7mark', + engine: true, + }, + { + key: 'X14', + label: 'p 뒤 줄바꿈 하나', + html: '

앞

\r\n

뒤X14p7mark

', + value: '앞 뒤X14p7mark', + engine: false, + }, + { + key: 'X15', + label: 'head 밖 title 없음 · body 안 글', + html: '

앞

뒤X15p7mark', + value: '앞뒤X15p7mark', + engine: false, + }, + { + key: 'Y1', + label: '여는 p 만', + html: '앞

뒤Y1p7mark', + value: '앞뒤Y1p7mark', + engine: true, + }, + { + key: 'Y2', + label: '속성 있는 p', + html: '

앞

뒤Y2p7mark

', + value: '앞뒤Y2p7mark', + engine: false, + }, + { + key: 'Y3', + label: 'br 공백 슬래시', + html: '
앞
뒤Y3p7mark
', + value: '앞뒤Y3p7mark', + engine: false, + }, + { + key: 'Y4', + label: 'br 속성', + html: '
앞
뒤Y4p7mark
', + value: '앞뒤Y4p7mark', + engine: true, + }, + { + key: 'Y5', + label: 'style 속성', + html: '

x

', + value: '숨김Y5p7mark', + engine: true, + }, + { + key: 'Y6', + label: 'script 속성', + html: '

x

', + value: '숨김Y6p7mark', + engine: true, + }, + { + key: 'Y7', + label: 'title 속성', + html: '제목Y7p7mark

x

', + value: '제목Y7p7mark', + engine: true, + }, + { + key: 'Y8', + label: '글자 < 뒤 공백', + html: '

가격 < 1000원할인Y8p7mark

', + value: '가격 < 1000원할인Y8p7mark', + engine: false, + }, + { + key: 'Y9', + label: '< 뒤 한글', + html: '

<광고> 안내Y9p7mark

', + value: '<광고> 안내Y9p7mark', + engine: false, + }, + { + key: 'Y10', + label: '따옴표 안 > (뒤 글)', + html: '

a>b사진Y10p7mark

', + value: '사진Y10p7mark', + engine: true, + }, + { + key: 'Y11', + label: '따옴표 안 > (잘린 글)', + html: '

a>b사진Y11p7mark

', + value: 'b">사진Y11p7mark', + engine: true, + }, + { + key: 'Y12', + label: '닫히지 않은 따옴표', + html: '

z

', + value: '회의 안내Y12p7mark', + engine: true, + }, + { + key: 'Y13', + label: '참조 공백 둘(한 칸으로)', + html: '

회의 안내Y13p7mark

', + value: '회의 안내Y13p7mark', + engine: false, + }, + { + key: 'Y14', + label: '참조 공백 둘(두 칸)', + html: '

회의 안내Y14p7mark

', + value: '회의 안내Y14p7mark', + engine: true, + }, + { + key: 'Y15', + label: '참조 줄바꿈', + html: '

회의 안내Y15p7mark

', + value: '회의 안내Y15p7mark', + engine: false, + }, + { + key: 'Y16', + label: '; 없는   (풀림)', + html: '

회의 안내Y16p7mark

', + value: '회의 안내Y16p7mark', + engine: false, + }, + { + key: 'Y17', + label: '; 없는   (그대로)', + html: '

회의 안내Y17p7mark

', + value: '회의 안내Y17p7mark', + engine: true, + }, + { + key: 'Y18', + label: '& 대문자', + html: '

A&B안내Y18p7mark

', + value: 'A&B안내Y18p7mark', + engine: true, + limitation: true, + }, + { + key: 'Y19', + label: 'HTML5 이름 .', + html: '

점.안내Y19p7mark

', + value: '점.안내Y19p7mark', + engine: true, + limitation: true, + }, + { + key: 'Y20', + label: 'pre 는 끊는가', + html: '
앞
뒤Y20p7mark
', + value: '앞뒤Y20p7mark', + engine: true, + }, + { + key: 'Y21', + label: 'noscript 안 글', + html: '

x

', + value: '숨김Y21p7mark', + engine: true, + }, + { + key: 'Y22', + label: 'template 안 글', + html: '

x

', + value: '숨김Y22p7mark', + engine: false, + }, + { + key: 'Y23', + label: 'textarea 안 글', + html: '

x

', + value: '숨김Y23p7mark', + engine: true, + }, + { + key: 'Z1', + label: 'title 만(속성 없음)', + html: '제목Z1p7mark

x

', + value: '제목Z1p7mark', + engine: true, + }, + { + key: 'Z2', + label: 'head 안 글', + html: '머리Z2p7mark

x

', + value: '머리Z2p7mark', + engine: false, + }, + { + key: 'Z3', + label: 'head + title 속성', + html: '제목Z3p7mark

x

', + value: '제목Z3p7mark', + engine: false, + }, + { + key: 'Z4', + label: 'head 속성 + title', + html: '제목Z4p7mark

x

', + value: '제목Z4p7mark', + engine: true, + }, + { + key: 'Z5', + label: 'TEMPLATE 대문자', + html: '

x

', + value: '숨김Z5p7mark', + engine: false, + }, + { + key: 'Z6', + label: 'template 속성', + html: '

x

', + value: '숨김Z6p7mark', + engine: true, + }, + { + key: 'Z7', + label: 'iframe 안 글', + html: '

x

', + value: '숨김Z7p7mark', + engine: true, + }, + { + key: 'Z8', + label: 'object 안 글', + html: '숨김Z8p7mark

x

', + value: '숨김Z8p7mark', + engine: true, + }, + { + key: 'Z9', + label: 'svg 안 글', + html: '숨김Z9p7mark

x

', + value: '숨김Z9p7mark', + engine: true, + }, + { + key: 'Z10', + label: 'xml 안 글', + html: '숨김Z10p7mark

x

', + value: '숨김Z10p7mark', + engine: true, + }, + { + key: 'Z11', + label: 'o:p 닫기', + html: '앞뒤Z11p7mark', + value: '앞뒤Z11p7mark', + engine: true, + }, + { + key: 'Z12', + label: 'style 속성 없음', + html: '

x

', + value: '숨김Z12p7mark', + engine: false, + }, + { + key: 'Z13', + label: 'STYLE 대문자', + html: '

x

', + value: '숨김Z13p7mark', + engine: false, + }, + { + key: 'Z14', + label: ' 로 닫기', + html: '뒤Z14p7mark', + value: '뒤Z14p7mark', + engine: true, + }, + { + key: 'Z15', + label: 'BR 대문자', + html: '
앞
뒤Z15p7mark
', + value: '앞뒤Z15p7mark', + engine: false, + }, + { + key: 'Z16', + label: '
', + html: '
앞
뒤Z16p7mark
', + value: '앞뒤Z16p7mark', + engine: false, + }, + { + key: 'Z17', + label: '

공백', + html: '앞

뒤Z17p7mark', + value: '앞뒤Z17p7mark', + engine: false, + }, + { + key: 'Z18', + label: '

', + html: '앞

뒤Z18p7mark', + value: '앞뒤Z18p7mark', + engine: true, + }, + { + key: 'Z19', + label: 'mso 보이는 조건', + html: '보임Z19p7mark', + value: '보임Z19p7mark', + engine: true, + }, + { + key: 'Z20', + label: 'mso 주석 조건', + html: '', + value: '숨김Z20p7mark', + engine: false, + }, + { + key: 'Z21', + label: 'title 닫힌 뒤 글', + html: 't뒤Z21p7mark', + value: '뒤Z21p7mark', + engine: true, + }, + { + key: 'Z22', + label: 'head 닫힌 뒤 글', + html: '본문Z22p7mark', + value: '본문Z22p7mark', + engine: true, + }, + { + key: 'Z23', + label: 'style 안 >', + html: '뒤Z23p7mark', + value: '뒤Z23p7mark', + engine: true, + }, + { + key: 'Z24', + label: 'script 안

', + html: '앞뒤Z24p7mark', + value: '앞뒤Z24p7mark', + engine: false, + }, + { + key: 'Z25', + label: '공백 · 태그 · 공백', + html: '

회의 안내Z25p7mark

', + value: '회의 안내Z25p7mark', + engine: true, + }, + { + key: 'Z26', + label: '글 공백 + 참조 공백(두 칸)', + html: '

회의 안내Z26p7mark

', + value: '회의 안내Z26p7mark', + engine: true, + }, + { + key: 'Z27', + label: '글 공백 + 참조 공백(한 칸)', + html: '

회의 안내Z27p7mark

', + value: '회의 안내Z27p7mark', + engine: false, + }, +]; + +/** + * 머리글 — 같은 날 원래 글(raw MIME)로 보낸 19통(H · G)에 엔진이 내린 판정. raw 는 엔진이 JMAP 으로 주는 원래 글 그대로(앞에 공백 한 칸 · 접힌 줄). + * 제목(subject)은 엔진이 풀어 준 JMAP subject 를 쓴다 — 서버의 판정과 엔진의 판정이 같았다(H9 · G7) + */ +const HEADER_CASES: readonly HeaderCase[] = [ + { + key: 'H1', + label: '따옴표 안 인코딩 이름', + field: 'from', + value: '김아라', + engine: true, + raw: ' "=?UTF-8?B?6rmA7JWE6528?=" ', + }, + { + key: 'H2', + label: '따옴표 안 인코딩 이름(원래 글)', + field: 'from', + value: '=?UTF-8?B?', + engine: false, + raw: ' "=?UTF-8?B?6rmA7JWE6528?=" ', + }, + { + key: 'H3', + label: '탭으로 접힌 받는 사람', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n\t"다라" ', + }, + { + key: 'H4', + label: '받는 사람 이어진 공백', + field: 'to', + value: '"가나" <', + engine: false, + raw: ' "가나" ', + }, + { + key: 'H5', + label: '글자에 붙은 인코딩', + field: 'from', + value: 'abc김아라', + engine: false, + raw: ' abc=?UTF-8?B?6rmA7JWE6528?= ', + }, + { + key: 'H6', + label: '주석', + field: 'from', + value: '김아라 부장', + engine: true, + raw: ' ara.kim@arasystem.test (김아라 부장)', + }, + { + key: 'H7', + label: '주석 안 인코딩', + field: 'from', + value: '(부장)', + engine: false, + raw: ' ara.kim@arasystem.test (=?UTF-8?B?67aA7J6l?=)', + }, + { + key: 'H8', + label: '이어진 인코딩 두 낱말', + field: 'from', + value: '김아라', + engine: true, + raw: ' =?UTF-8?B?6rmA?=\r\n =?UTF-8?B?7JWE6528?= ', + }, + { + key: 'H9', + label: '제목의 이어진 인코딩', + field: 'subject', + value: '회의안내', + engine: true, + subject: 'H9 회의안내', + }, + { + key: 'G1', + label: '탭(접지 않음)', + field: 'to', + value: '"가나" <', + engine: false, + raw: ' "가나"\t', + }, + { + key: 'G2', + label: '접힌 줄 공백 넷', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n "다라" ', + }, + { + key: 'G3', + label: '접힌 줄 공백 하나', + field: 'to', + value: ', "다라"', + engine: true, + raw: ' "가나" ,\r\n "다라" ', + }, + { + key: 'G4', + label: '인코딩 뒤에 붙은 글', + field: 'from', + value: '김아라abc', + engine: false, + raw: ' =?UTF-8?B?6rmA7JWE6528?=abc ', + }, + { + key: 'G5', + label: '괄호 안 띄운 인코딩', + field: 'from', + value: '( 부장 )', + engine: true, + raw: ' ara.kim@arasystem.test ( =?UTF-8?B?67aA7J6l?= )', + }, + { + key: 'G6', + label: '쉼표에 붙은 인코딩', + field: 'to', + value: '다라', + engine: true, + raw: ' "가나" ,=?UTF-8?B?64uk6528?= ', + }, + { + key: 'G7', + label: '제목 글자에 붙은 인코딩', + field: 'subject', + value: 'abc회의', + engine: false, + subject: 'G7 abc=?UTF-8?B?7ZqM7J2Y?=', + }, + { + key: 'G8', + label: '따옴표 안 인코딩 + 글', + field: 'from', + value: '김아라 부장', + engine: true, + raw: ' "=?UTF-8?B?6rmA7JWE6528?= 부장" ', + }, + { + key: 'G9', + label: '꺾쇠에 붙은 인코딩', + field: 'from', + value: '김아라<', + engine: false, + raw: ' =?UTF-8?B?6rmA7JWE6528?=', + }, + { + key: 'G10', + label: '뒤에 쉼표가 붙은 인코딩', + field: 'to', + value: '다라,', + engine: false, + raw: ' , =?UTF-8?B?64uk6528?=,"가나" ', + }, +]; + +/** HTML 한 부분뿐인 메일 — 배달한 그대로 (textBody · htmlBody 가 같은 부분) */ +function htmlOnlyEmail(html: string): JmapEmail { + return { + id: 'm1', + textBody: [{ partId: '1', type: 'text/html' }], + htmlBody: [{ partId: '1', type: 'text/html' }], + attachments: [], + bodyValues: { '1': { value: html } }, + }; +} + +/** 머리글 사례의 메일 — 견주는 머리글 하나만 (받는 사람은 To) */ +function headerEmail({ field, raw, subject }: HeaderCase): DeliveredEmail { + return { + id: 'm1', + 'header:From:all': field === 'from' && raw !== undefined ? [raw] : [], + 'header:To:all': field === 'to' && raw !== undefined ? [raw] : [], + 'header:Cc:all': [], + subject: subject ?? null, + }; +} + +describe('엔진과 같은 판정 — HTML 본문 75통 (W · X · Y · Z, 2026-10-29 인증 발송)', () => { + const known = HTML_CASES.filter((item) => item.limitation !== true); + const limitations = HTML_CASES.filter((item) => item.limitation === true); + + it('네 묶음이 모두 있다 (W 10 · X 15 · Y 23 · Z 27) — 엔진과 다른 것은 HTML5 에만 있는 이름 참조 둘뿐', () => { + const count = (prefix: string) => + HTML_CASES.filter((item) => item.key.startsWith(prefix)).length; + expect(['W', 'X', 'Y', 'Z'].map(count)).toEqual([10, 15, 23, 27]); + expect(limitations.map((item) => item.key)).toEqual(['Y18', 'Y19']); + }); + + it.each(known.map((item) => [item.key, item.label, item] as const))( + '%s %s', + (_key, _label, item) => { + const email = htmlOnlyEmail(item.html); + expect(judge({ field: 'body', value: item.value }, email)).toBe( + item.engine, + ); + expect( + judge({ field: 'body', value: item.value }, email, 'notContains'), + ).toBe(!item.engine); + }, + ); + + it.each(limitations.map((item) => [item.key, item.label, item] as const))( + '%s %s — 알려진 한계: 엔진은 HTML5 의 이름도 풀지만 서버는 HTML 4 표 + ' 만 푼다', + (_key, _label, item) => { + expect( + judge({ field: 'body', value: item.value }, htmlOnlyEmail(item.html)), + ).toBe(!item.engine); + }, + ); +}); + +describe('엔진과 같은 판정 — 머리글 19통 (H · G, 원래 글로 보낸 것)', () => { + it.each(HEADER_CASES.map((item) => [item.key, item.label, item] as const))( + '%s %s', + (_key, _label, item) => { + const email = headerEmail(item); + expect(judge(item, email)).toBe(item.engine); + expect(judge(item, email, 'notContains')).toBe(!item.engine); + }, + ); +}); + +describe('headerTextsOf — 원래 머리글을 엔진이 보는 글로', () => { + const email = (extra: Record): DeliveredEmail => ({ + id: 'm1', + ...extra, + }); + + it('받는 사람은 To · Cc 머리글마다, 같은 이름의 머리글이 여럿이면 모두 — 제목은 엔진이 풀어 준 subject', () => { + expect( + headerTextsOf( + email({ + 'header:From:all': [' =?UTF-8?B?6rmA7JWE6528?= '], + 'header:To:all': [' a@example.com', ' b@example.com'], + 'header:Cc:all': [' =?UTF-8?B?7LC47KGw7J24?= '], + subject: '[견적] 10월', + }), + ), + ).toEqual({ + from: ['김아라 '], + to: ['a@example.com', 'b@example.com', '참조인 '], + subject: ['[견적] 10월'], + }); + }); + + it('머리글이 없으면(빈 배열 · null · 속성 없음) 빈 목록 — 포함하지 않음이 맞는다', () => { + const bare = headerTextsOf( + email({ 'header:From:all': [], 'header:To:all': null, subject: null }), + ); + expect(bare).toEqual({ from: [], to: [], subject: [] }); + expect( + judge( + { field: 'from', value: 'x' }, + email({ 'header:From:all': [] }), + 'notContains', + ), + ).toBe(true); + }); + + it('엔진이 속성 이름의 대소문자 · `:asRaw` 를 바꿔 주어도 찾는다 · 배열이 아니라 글 하나로 주어도 받는다', () => { + expect( + headerTextsOf( + email({ + 'header:from:asRaw:all': [' "Kim" '], + 'HEADER:TO:ALL': ' t@example.com', + }), + ), + ).toEqual({ + from: ['"Kim" '], + to: ['t@example.com'], + subject: [], + }); + }); +}); + +describe('bodyTextsOf · textPartsOf — 글자 부분(text/*)', () => { + const email: JmapEmail = { + id: 'm1', + textBody: [ + { partId: '1', type: 'text/plain' }, + { partId: '3', type: 'image/png' }, + ], + htmlBody: [{ partId: '2', type: 'TEXT/HTML' }], + attachments: [ + { partId: '1', type: 'text/plain' }, + { partId: '4', type: 'text/csv', size: 10 }, + { partId: '5', type: 'application/pdf' }, + { partId: '6', type: 'text/plain' }, + { type: 'text/plain' }, + ], + bodyValues: { + '1': { value: '본문' }, + '2': { value: '

서식 본문

' }, + '4': { value: 'a,b' }, + }, + }; + + it('textBody · htmlBody · attachments 차례로 같은 partId 는 한 번 — 그림 · PDF 는 빼고, HTML 은 글자만, 값이 없는 부분은 뺀다', () => { + expect(textPartsOf(email).map((part) => part.partId)).toEqual([ + '1', + '2', + '4', + '6', + ]); + expect(bodyTextsOf(email)).toEqual(['본문', '서식\u00a0본문\n', 'a,b']); + }); + + it('파일로 붙인 HTML(text/html 첨부)도 본문의 HTML 과 같은 규칙으로 글자만 본다', () => { + expect( + bodyTextsOf({ + id: 'm1', + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [{ partId: '2', type: 'Text/HTML', name: 'a.html' }], + bodyValues: { + '1': { value: '본문' }, + '2': { + value: + '

회의록

y', + }, + }, + }), + ).toEqual(['본문', '회의록\ny']); + }); + + it('본문 값의 크기 어림 — 글자 부분마다 크기의 두 배를 상한까지, 크기를 모르면 상한', () => { + expect( + estimatedBodyBytes( + { + id: 'm1', + textBody: [{ partId: '1', type: 'text/plain', size: 100 }], + htmlBody: [{ partId: '2', type: 'text/html', size: 900 }], + attachments: [ + { partId: '3', type: 'text/plain' }, + { partId: '4', type: 'image/png', size: 5_000 }, + ], + }, + 1_000, + ), + ).toBe(200 + 1_000 + 1_000); + }); +}); + +describe('filterRunProperties — 규칙이 견주는 것만 읽는다', () => { + it('늘 id · 받은편지함에 있는지 · 표시 · 받은 시각(이어 할 자리), 그리고 견줄 곳마다 원래 머리글(:all) · 제목 · 본문 부분', () => { + expect(filterRunProperties(new Set())).toEqual([ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + ]); + expect( + filterRunProperties(new Set(['body', 'subject', 'to', 'from'] as const)), + ).toEqual([ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + 'header:To:all', + 'header:Cc:all', + 'subject', + 'textBody', + 'htmlBody', + 'attachments', + ]); + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-texts.ts b/backend/src/modules/mail/jmap/filter-run-texts.ts new file mode 100644 index 0000000..acf2db8 --- /dev/null +++ b/backend/src/modules/mail/jmap/filter-run-texts.ts @@ -0,0 +1,143 @@ +import { htmlToText } from '@/modules/mail/html/html-text'; +import type { MailFilterTexts } from '@/modules/mail/mail-filter-match'; +import type { MailFilterField } from '@/modules/mail/mail.types'; +import { decodeHeaderText } from './mime-text'; +import type { JmapBodyPart, JmapEmail } from './jmap.types'; + +/** + * 이미 받은 메일에 필터를 적용할 때 엔진의 메일(JMAP Email)에서 견줄 글을 뽑는다 (순수 함수 — 판정은 mail-filter-match.ts). + * 엔진의 Sieve 가 새로 온 메일에서 보는 글과 같게 뽑는다 (sieve-script.ts 의 FIELD_TESTS — 2026-10-29 실배달 20통과 + * 인증 발송 94통(HTML 본문 75 · 머리글 19)으로 확인, filter-run-texts.spec.ts 의 표): + * - 보낸 사람 · 받는 사람: 원래 머리글(`header:From:all` · `header:To:all` · `header:Cc:all` — 같은 이름이 여럿이면 모두)을 + * 접힌 줄을 펴고 · 홀로 선 RFC 2047 낱말만 풀고 · 앞뒤 공백을 뺀 것 (mime-text.ts 의 decodeHeaderText). 엔진은 원래 글을 앞에 공백 한 칸 · + * 접힌 줄(CRLF + 공백) · encoded-word 그대로 준다. 이름에 `:asRaw` 를 붙여 물으면 엔진은 응답의 이름에서 그것을 뗀다 — 그래서 붙이지 않는다 + * (원래 글은 머리글 속성의 기본 모양이다 — RFC 8621 §4.1.2) + * - 제목: 엔진이 풀어 준 제목(`subject`) + * - 본문: textBody · htmlBody · attachments 의 text/* 부분(같은 partId 는 한 번) — 파일로 붙인 텍스트 첨부도 본다(엔진이 그렇게 본다). + * HTML(text/html — 본문이든 첨부든)은 엔진의 규칙대로 글자만 (html/html-text.ts). 값은 `fetchAllBodyValues` 로 받는다 (jmap-filter-run.ts) + */ + +/** 견줄 곳마다 원래 머리글 이름 — 받는 사람은 To · Cc 둘 다 (Sieve 의 `header :contains ["to", "cc"]`) */ +const FROM_HEADERS = ['From'] as const; +const RECIPIENT_HEADERS = ['To', 'Cc'] as const; + +/** 엔진에 묻는 원래 머리글 속성 — `:all` 은 같은 이름의 머리글을 모두 배열로 준다 (없으면 빈 배열) */ +function rawHeaderProperty(name: string): string { + return `header:${name}:all`; +} + +/** 본문 부분마다 읽는 속성 — 같은 부분 가리기 · 글자 부분인지 · 크기(나눠 읽을 어림) */ +export const FILTER_RUN_PART_PROPERTIES = ['partId', 'type', 'size']; + +/** 본문을 읽을 때의 속성 — 그사이 받은편지함에서 빠졌는지(mailboxIds) · 부분 · 값 */ +export const FILTER_RUN_BODY_PROPERTIES = [ + 'id', + 'mailboxIds', + 'textBody', + 'htmlBody', + 'attachments', + 'bodyValues', +]; + +/** + * 한 차례에 메일을 읽을 때의 속성 — 받은편지함에 아직 있는지 · 이미 읽음 · 중요인지(바꿀 것만 바꾼다) · 받은 시각(이어 할 자리) · 규칙이 견주는 머리글. + * 본문을 견주면 부분(값은 빼고)도 — 머리글로 판정이 나지 않는 메일만 값을 따로 읽는다 + */ +export function filterRunProperties( + fields: ReadonlySet, +): string[] { + return [ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + ...(fields.has('from') ? FROM_HEADERS.map(rawHeaderProperty) : []), + ...(fields.has('to') ? RECIPIENT_HEADERS.map(rawHeaderProperty) : []), + ...(fields.has('subject') ? ['subject'] : []), + ...(fields.has('body') ? ['textBody', 'htmlBody', 'attachments'] : []), + ]; +} + +/** + * 원래 머리글의 값들 — 물은 이름(`header:From:all`)으로 찾되 대소문자 · `:asRaw` 가 달라도 찾는다 + * (엔진이 응답의 속성 이름을 바꿔 주더라도 머리글이 없는 것처럼 보이지 않게 — 그러면 "포함하지 않음"이 모두 맞는다) + */ +function rawHeaderValues(email: JmapEmail, name: string): string[] { + const wanted = new Set([ + rawHeaderProperty(name).toLowerCase(), + `header:${name}:asRaw:all`.toLowerCase(), + ]); + for (const [key, value] of Object.entries(email)) { + if (!wanted.has(key.toLowerCase())) continue; + if (typeof value === 'string') return [value]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + } + return []; +} + +function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] { + return names.flatMap((name) => + rawHeaderValues(email, name).map(decodeHeaderText), + ); +} + +/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */ +export function headerTextsOf(email: JmapEmail): Omit { + const subject = email.subject; + return { + from: decodedHeaders(email, FROM_HEADERS), + to: decodedHeaders(email, RECIPIENT_HEADERS), + subject: typeof subject === 'string' ? [subject] : [], + }; +} + +function isTextPart(part: JmapBodyPart): boolean { + return (part.type ?? '').toLowerCase().startsWith('text/'); +} + +/** 글자 부분 — textBody · htmlBody · attachments 차례로, 같은 partId 는 한 번 (한 부분이 textBody 와 htmlBody 에 함께 오기도 한다) */ +export function textPartsOf(email: JmapEmail): JmapBodyPart[] { + const seen = new Set(); + const parts: JmapBodyPart[] = []; + for (const part of [ + ...(email.textBody ?? []), + ...(email.htmlBody ?? []), + ...(email.attachments ?? []), + ]) { + const { partId } = part; + if (!partId || seen.has(partId)) continue; + seen.add(partId); + if (isTextPart(part)) parts.push(part); + } + return parts; +} + +/** 본문의 견줄 글 — 글자 부분마다(HTML 은 글자만). 값을 받지 못한 부분은 뺀다 */ +export function bodyTextsOf(email: JmapEmail): string[] { + return textPartsOf(email).flatMap((part) => { + const value = email.bodyValues?.[part.partId ?? '']?.value; + if (typeof value !== 'string') return []; + return [ + (part.type ?? '').toLowerCase() === 'text/html' + ? htmlToText(value) + : value, + ]; + }); +} + +/** 본문 값을 받을 때 응답 크기의 어림(바이트) — 부분마다 상한까지, 문자셋을 UTF-8 로 바꾸며 늘 수 있어 두 배로 (크기를 모르면 상한) */ +export function estimatedBodyBytes( + email: JmapEmail, + maxPartBytes: number, +): number { + return textPartsOf(email).reduce( + (sum, part) => + sum + + (typeof part.size === 'number' && part.size >= 0 + ? Math.min(maxPartBytes, part.size * 2) + : maxPartBytes), + 0, + ); +} diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts new file mode 100644 index 0000000..8dfa1d3 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts @@ -0,0 +1,1490 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + Mailbox, + MailboxRole, + MailFilterCondition, + MailFilterRunActions, + MailFilterRunContext, + MailFilterRunRequest, +} from '@/modules/mail/mail.types'; +import { + decodeFilterRunCursor, + encodeFilterRunCursor, +} from './filter-run-cursor'; +import type { JmapClient } from './jmap.client'; +import { + FILTER_RUN_BODY_VALUE_BYTES, + FILTER_RUN_MESSAGE_BODY_BYTES, + JmapFilterRunner, + bodyValueQuota, +} from './jmap-filter-run'; +import type { JmapInvocation } from './jmap.types'; + +const ARA = 'ara.kim@arasystem.test'; +const INBOX = 'a'; +const TARGET = 'j'; +const TRASH = 't'; + +/** 2026-10-29T09:00:00Z (초) — 받은 시각의 기준 */ +const T0 = 1_793_264_400; + +/** 초 → 엔진이 주는 받은 시각 (UTCDate — 엔진은 초 단위로 둔다) */ +const utc = (seconds: number) => + new Date(seconds * 1_000).toISOString().replace('.000Z', 'Z'); + +const mailbox = ( + id: string, + role: MailboxRole | null, + totalCount = 0, +): Mailbox => ({ + id, + name: id, + role, + parentId: null, + sortOrder: 0, + totalCount, + unreadCount: 0, +}); + +/** 서비스가 읽어 넘긴 메일함 목록 — 받은편지함(a) · 내 메일함(j) · 휴지통(t) */ +const MAILBOXES: readonly Mailbox[] = [ + mailbox(INBOX, 'inbox', 3), + mailbox(TARGET, null), + mailbox(TRASH, 'trash'), +]; + +/** 서비스가 넘기는 것 — 메일함 목록과 들어온 때. 시계를 바꾸는 테스트는 바꾼 뒤에 부른다 */ +const context = ( + overrides: Partial = {}, +): MailFilterRunContext => ({ + mailboxes: MAILBOXES, + startedAt: Date.now(), + ...overrides, +}); + +const fromHanbit: MailFilterCondition = { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', +}; +const moveAndRead: MailFilterRunActions = { + moveToMailboxId: TARGET, + markAsRead: true, + flag: false, +}; +const request = ( + overrides: Partial = {}, +): MailFilterRunRequest => ({ + match: 'any', + conditions: [fromHanbit], + actions: moveAndRead, + cursor: null, + ...overrides, +}); + +async function failureOf( + promise: Promise, +): Promise | undefined> { + try { + await promise; + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + return { code: error.code, ...error.context }; + } + return undefined; +} + +describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용")', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다 + afterEach(() => jest.restoreAllMocks()); + + /** 엔진 요청을 준비한 응답으로 답하는 대역 — 요청의 모양을 본다 */ + describe('엔진에 묻는 모양', () => { + interface CallRecord { + readonly calls: JmapInvocation[]; + readonly options: unknown; + } + + /** JmapClient 대역 — call() 은 넘겨받은 build 로 호출 목록을 만들어 기록하고, 준비한 응답을 차례로 돌려준다 */ + function fakeRunner(responses: JmapInvocation[][]) { + const recorded: CallRecord[] = []; + const queue = [...responses]; + const client = { + call: jest.fn( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + recorded.push({ calls: build('d'), options }); + const next = queue.shift(); + if (!next) throw new Error('준비한 응답이 없습니다'); + return Promise.resolve(next); + }, + ), + }; + return { + client, + recorded, + runner: new JmapFilterRunner(client as unknown as JmapClient), + }; + } + + /** n 번째 엔진 호출이 끝난 뒤 시계를 넘긴다 — 한 요청의 시간(25초) 확인용 */ + function advanceClockAfterCall( + fake: ReturnType, + callNumber: number, + ms: number, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + fake.client.call.mockImplementation((...args) => { + const answer = call(...args); + if (fake.recorded.length === callNumber) now += ms; + return answer; + }); + } + + /** n 번째 엔진 호출이 시계를 ms 만큼 넘긴 뒤 error 로 끝난다 — 남은 시간 때문에 끊긴 호출 확인용 */ + function failCallWith( + fake: ReturnType, + callNumber: number, + ms: number, + error: Error, + ): void { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const call = fake.client.call.getMockImplementation(); + if (!call) throw new Error('call 구현이 없다'); + let count = 0; + fake.client.call.mockImplementation((...args) => { + count += 1; + if (count === callNumber) { + now += ms; + return Promise.reject(error); + } + return call(...args); + }); + } + + const argsOf = (record: CallRecord, index = 0) => record.calls[index][1]; + const methodsOf = (records: readonly CallRecord[]) => + records.flatMap((record) => record.calls.map(([method]) => method)); + + /** 받은편지함의 메일 한 통 — 보낸 사람(원래 머리글), 읽지 않음, 받은 시각 T0 */ + const mail = ( + id: string, + from = ' Probe ', + extra: Record = {}, + ) => ({ + id, + mailboxIds: { [INBOX]: true }, + keywords: {}, + receivedAt: utc(T0), + 'header:From:all': [from], + ...extra, + }); + const HANBIT = ' "한빛" '; + + /** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */ + const page = ( + ids: string[], + list: Record[], + { + state = 's1', + total = ids.length, + position = 0, + }: { state?: string; total?: number; position?: number } = {}, + ): JmapInvocation[] => [ + ['Email/query', { ids, position, total }, 'q'], + ['Email/get', { state, list, notFound: [] }, 'g'], + ]; + const updated = ( + ids: string[], + notUpdated: Record = {}, + ): JmapInvocation[] => [ + [ + 'Email/set', + { + updated: Object.fromEntries(ids.map((id) => [id, null])), + notUpdated, + }, + 'u', + ], + ]; + const mismatch: JmapInvocation[] = [ + ['error', { type: 'stateMismatch' }, 'u'], + ]; + + const ids = (prefix: string, from: number, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${from + index}`); + + it('받은편지함을 받은 시각 내림차순으로 읽어 판정하고, 맞은 메일만 한 번에 — 옮기기는 읽은 상태 그대로일 때만(ifInState). 메일함 목록은 서비스가 준 것을 쓴다', async () => { + const { runner, recorded } = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + mail('m1', HANBIT), + mail('m2'), + mail('m3', HANBIT, { keywords: { $seen: true } }), + ], + ), + updated(['m1', 'm3']), + ]); + + await expect(runner.run(ARA, request(), context())).resolves.toEqual({ + scanned: 3, + matched: 2, + remaining: 0, + cursor: null, + rounds: 1, + }); + + // 첫 요청의 첫 조회 — 위 경계를 아직 모른다 (그 차례의 맨 위 메일로 정한다) + expect(argsOf(recorded[0], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: 0, + limit: 500, + calculateTotal: true, + }); + // 견주는 머리글만 원래 글로 · 받은 시각(이어 할 자리) — 본문 조건이 없어 부분은 읽지 않는다 + expect(argsOf(recorded[0], 1)).toEqual({ + accountId: 'd', + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + ], + }); + // 이미 읽은 m3 에는 읽음을 다시 붙이지 않는다 — 옮기기만 + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { + 'keywords/$seen': true, + 'mailboxIds/a': null, + 'mailboxIds/j': true, + }, + m3: { 'mailboxIds/a': null, 'mailboxIds/j': true }, + }, + }); + // 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간 + expect(methodsOf(recorded)).toEqual([ + 'Email/query', + 'Email/get', + 'Email/set', + ]); + for (const record of recorded) { + for (const [, args] of record.calls) { + expect(args).not.toHaveProperty('destroy'); + } + expect(record.options).toEqual({ + timeoutMs: expect.any(Number) as number, + }); + } + }); + + it('맞는 메일이 없으면 Email/set 을 보내지 않는다 · 빈 받은편지함은 바로 끝 · 받은편지함이 없는 계정은 엔진에 묻지 않는다', async () => { + const none = fakeRunner([page(['m1', 'm2'], [mail('m1'), mail('m2')])]); + await expect(none.runner.run(ARA, request(), context())).resolves.toEqual( + { + scanned: 2, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }, + ); + expect(none.recorded).toHaveLength(1); + + const empty = fakeRunner([page([], [])]); + await expect( + empty.runner.run(ARA, request(), context()), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }); + + const noInbox = fakeRunner([]); + await expect( + noInbox.runner.run( + ARA, + request(), + context({ mailboxes: [mailbox(TARGET, null)] }), + ), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, + }); + expect(noInbox.recorded).toHaveLength(0); + }); + + it('옮길 메일함이 목록에 없거나(지워짐) 받은편지함이면 옮기지 않고 표시만 — 조건 없이(되돌릴 수 있는 표시), 이미 있는 표시는 다시 붙이지 않는다', async () => { + const flagged = { + moveToMailboxId: 'gonebox', + markAsRead: false, + flag: true, + }; + const gone = fakeRunner([ + page( + ['m1', 'm2'], + [mail('m1'), mail('m2', undefined, { keywords: { $flagged: true } })], + ), + updated(['m1']), + ]); + await expect( + gone.runner.run( + ARA, + request({ match: 'always', conditions: [], actions: flagged }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2, cursor: null }); + expect(argsOf(gone.recorded[1])).toEqual({ + accountId: 'd', + update: { m1: { 'keywords/$flagged': true } }, + }); + + // 받은편지함으로 옮기는 규칙 + 읽음 — 모두 이미 읽었으면 바꿀 것이 없다 (맞은 수는 센다) + const inbox = fakeRunner([ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT, { keywords: { $seen: true } }), + mail('m2', HANBIT, { keywords: { $seen: true } }), + ], + ), + ]); + await expect( + inbox.runner.run( + ARA, + request({ actions: { ...moveAndRead, moveToMailboxId: INBOX } }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2, cursor: null }); + expect(inbox.recorded).toHaveLength(1); + }); + + it('조회와 읽기 사이에 받은편지함에서 빠진 메일(다른 메일함으로 · 지워짐)은 판정 · 할 일 없이 건너뛰고 scanned 로 세지 않는다', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3', 'm4'], + [ + mail('m1', HANBIT), + mail('m2', HANBIT, { mailboxIds: { b: true } }), + mail('m4', undefined, { receivedAt: utc(T0 - 10) }), + ], + { total: 10 }, + ), + updated(['m1']), + ]); + // 첫 차례를 마친 뒤 21초가 지났다 — 다음 차례를 시작하지 않는다 + advanceClockAfterCall(fake, 2, 21_000); + + const result = await fake.runner.run(ARA, request(), context()); + expect(result).toMatchObject({ scanned: 2, matched: 1, remaining: 6 }); + expect(Object.keys(argsOf(fake.recorded[1]).update as object)).toEqual([ + 'm1', + ]); + // 남긴 마지막 메일은 m4 — 옮긴 m1 · 빠진 m2 · m3 은 자리가 되지 않는다 + expect(decodeFilterRunCursor(result.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm4', + anchorTime: T0 - 10, + }); + }); + + describe('이어 하기 (cursor)', () => { + it('시간을 다 쓰면 마친 차례까지 — cursor 는 위 경계(맨 위 메일의 받은 시각 + 1초) · 남긴 마지막 메일 · 그 받은 시각. 다음 요청은 위 경계 아래에서 그 메일 다음부터(anchor + 1)', async () => { + const first = ids('m', 0, 500); + const matchedIds = first.slice(0, 10); + const fake = fakeRunner([ + page( + first, + first.map((id, index) => + mail(id, matchedIds.includes(id) ? HANBIT : undefined, { + receivedAt: utc(T0 - index), + }), + ), + { total: 600 }, + ), + updated(matchedIds), + ]); + advanceClockAfterCall(fake, 2, 21_000); + + const stopped = await fake.runner.run(ARA, request(), context()); + expect(stopped).toMatchObject({ + scanned: 500, + matched: 10, + remaining: 100, + rounds: 1, + }); + expect(decodeFilterRunCursor(stopped.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm499', + anchorTime: T0 - 499, + }); + + jest.restoreAllMocks(); + jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + const rest = ids('m', 500, 100); + const next = fakeRunner([ + page( + rest, + rest.map((id, index) => + mail(id, undefined, { receivedAt: utc(T0 - 500 - index) }), + ), + { total: 590, position: 490 }, + ), + ]); + await expect( + next.runner.run(ARA, request({ cursor: stopped.cursor }), context()), + ).resolves.toEqual({ + scanned: 100, + matched: 0, + remaining: 0, + cursor: null, + rounds: 1, + }); + expect(argsOf(next.recorded[0], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + sort: [{ property: 'receivedAt', isAscending: false }], + anchor: 'm499', + anchorOffset: 1, + limit: 500, + calculateTotal: true, + }); + // anchor 는 cursor 에서 왔다 — 엔진이 요청을 거부하면 깨진 cursor 와 같게 VAL_001 + expect(next.recorded[0].options).toMatchObject({ + invalidRequestCode: 'VAL_001', + }); + }); + + it('같은 요청의 다음 차례도 첫 차례가 정한 위 경계를 건다', async () => { + const first = ids('m', 0, 500); + const rest = ids('m', 500, 200); + const fake = fakeRunner([ + page( + first, + first.map((id, index) => + mail(id, undefined, { receivedAt: utc(T0 - index) }), + ), + { total: 700 }, + ), + page( + rest, + rest.map((id) => + mail(id, undefined, { receivedAt: utc(T0 - 600) }), + ), + { total: 700, position: 500 }, + ), + ]); + await expect( + fake.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 700, cursor: null, rounds: 2 }); + expect(argsOf(fake.recorded[1], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm499', + anchorOffset: 1, + }); + }); + + it('남긴 마지막 메일이 그사이 받은편지함에서 빠졌으면(anchorNotFound) 그 받은 시각 + 1초 미만의 처음(자리 0)부터 다시 읽는다 — 그 초의 메일을 다시 판정해도 건너뛰지 않는다', async () => { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: 'm499', anchorTime: T0 - 499 }, + request(), + ); + const rest = ids('m', 500, 3); + const fake = fakeRunner([ + [ + ['error', { type: 'anchorNotFound' }, 'q'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + page( + rest, + rest.map((id) => + mail(id, undefined, { receivedAt: utc(T0 - 499) }), + ), + ), + ]); + + await expect( + fake.runner.run(ARA, request({ cursor }), context()), + ).resolves.toMatchObject({ scanned: 3, cursor: null }); + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm499', + }); + expect(argsOf(fake.recorded[1], 0)).toEqual({ + accountId: 'd', + filter: { inMailbox: INBOX, before: utc(T0 - 498) }, + sort: [{ property: 'receivedAt', isAscending: false }], + position: 0, + limit: 500, + calculateTotal: true, + }); + expect(fake.recorded[1].options).toEqual({ + timeoutMs: expect.any(Number) as number, + }); + }); + + it('남긴 메일이 아직 없는 cursor — 받은 시각이 남았으면 그 시각 + 1초 미만의, 없으면 위 경계 아래의 처음부터 읽는다 (앞서 본 메일은 모두 옮겨 나갔다)', async () => { + for (const [anchorTime, before] of [ + [T0 - 100, T0 - 99], + [null, T0 + 1], + ] as const) { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: null, anchorTime }, + request(), + ); + const fake = fakeRunner([page([], [])]); + await expect( + fake.runner.run(ARA, request({ cursor }), context()), + ).resolves.toMatchObject({ scanned: 0, cursor: null }); + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(before) }, + position: 0, + }); + expect(argsOf(fake.recorded[0], 0)).not.toHaveProperty('anchor'); + } + }); + + it('다른 규칙의 cursor · 깨진 cursor 는 엔진에 묻지 않고 VAL_001', async () => { + const cursor = encodeFilterRunCursor( + { before: T0 + 1, anchor: 'm1', anchorTime: T0 }, + request(), + ); + for (const bad of [ + request({ cursor, actions: { ...moveAndRead, flag: true } }), + request({ cursor: 'eyJ2IjoxfQ' }), + ]) { + const fake = fakeRunner([]); + expect( + await failureOf(fake.runner.run(ARA, bad, context())), + ).toMatchObject({ code: 'VAL_001' }); + expect(fake.recorded).toHaveLength(0); + } + }); + + it('엔진이 받은 시각을 주지 않으면 이어 할 자리를 정할 수 없다 — MAIL_001 (끝까지 했으면 cursor 가 필요 없어 그대로 답한다)', async () => { + const noTime = (id: string) => + mail(id, undefined, { receivedAt: null }); + const stopped = fakeRunner([ + page(['m1', 'm2'], [noTime('m1'), noTime('m2')], { total: 5 }), + ]); + advanceClockAfterCall(stopped, 1, 21_000); + expect( + await failureOf(stopped.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'received-at-missing' }); + + jest.restoreAllMocks(); + const whole = fakeRunner([ + page(['m1', 'm2'], [noTime('m1'), noTime('m2')]), + ]); + await expect( + whole.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 2, cursor: null }); + }); + }); + + describe('본문 조건', () => { + const quoteMail = ( + id: string, + from: string, + size = 40, + ): Record => + mail(id, from, { + textBody: [{ partId: '1', type: 'text/plain', size }], + htmlBody: [{ partId: '2', type: 'text/html', size }], + attachments: [], + }); + const bodies = (list: Record[]): JmapInvocation[] => [ + ['Email/get', { state: 's1', list }, 'b'], + ]; + const bodyOf = (id: string, html: string) => ({ + id, + mailboxIds: { [INBOX]: true }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '2', type: 'text/html' }], + attachments: [], + bodyValues: { '1': { value: '안녕하세요' }, '2': { value: html } }, + }); + const hasQuote: MailFilterCondition = { + field: 'body', + operator: 'contains', + value: '견적서', + }; + const textParts = (count: number, size: number) => + Array.from({ length: count }, (_, index) => ({ + partId: `${index + 1}`, + type: 'text/plain', + size, + })); + + it('한 부분은 256KiB · 한 통은 모두 합쳐 1MiB 까지 — 글자 부분이 많으면 부분마다 같은 몫(1MiB ÷ 부분 수)', () => { + expect(FILTER_RUN_BODY_VALUE_BYTES).toBe(262_144); + expect(FILTER_RUN_MESSAGE_BODY_BYTES).toBe(1_048_576); + expect( + [0, 1, 4, 5, 6, 300, 1_048_576, 2_000_000].map(bodyValueQuota), + ).toEqual([262_144, 262_144, 262_144, 209_715, 174_762, 3_495, 1, 1]); + }); + + it('한 차례 100통 · 부분을 함께 읽고, 머리글로 판정이 나지 않은 메일만 본문 값을 읽는다 (fetchAllBodyValues · 한 부분 256KiB)', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + quoteMail('m1', HANBIT), + quoteMail('m2', ' x@example.com'), + quoteMail('m3', HANBIT), + ], + ), + bodies([ + bodyOf('m1', '

10월 견적서를 보냅니다

'), + bodyOf('m3', '

회의 안내

'), + ]), + updated(['m1']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [fromHanbit, hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 3, matched: 1, cursor: null }); + + expect(argsOf(fake.recorded[0], 0)).toMatchObject({ limit: 100 }); + expect(argsOf(fake.recorded[0], 1)).toMatchObject({ + properties: [ + 'id', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'header:From:all', + 'textBody', + 'htmlBody', + 'attachments', + ], + bodyProperties: ['partId', 'type', 'size'], + }); + // m2 는 보낸 사람에서 이미 틀렸다 — 본문을 읽지 않는다 + expect(argsOf(fake.recorded[1])).toEqual({ + accountId: 'd', + ids: ['m1', 'm3'], + properties: [ + 'id', + 'mailboxIds', + 'textBody', + 'htmlBody', + 'attachments', + 'bodyValues', + ], + bodyProperties: ['partId', 'type'], + fetchAllBodyValues: true, + maxBodyValueBytes: FILTER_RUN_BODY_VALUE_BYTES, + }); + expect(Object.keys(argsOf(fake.recorded[2]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('하나라도(any) — 머리글이 맞은 메일은 본문을 읽지 않는다 · 본문을 읽는 사이 받은편지함에서 빠진 메일은 건너뛰고 세지 않는다', async () => { + const fake = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + quoteMail('m1', HANBIT), + quoteMail('m2', ' x@example.com'), + quoteMail('m3', ' y@example.com'), + ], + ), + bodies([ + bodyOf('m2', '

견적서 송부

'), + { ...bodyOf('m3', '

견적서

'), mailboxIds: { b: true } }, + ]), + updated(['m1', 'm2']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ conditions: [fromHanbit, hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 2 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ ids: ['m2', 'm3'] }); + expect(Object.keys(argsOf(fake.recorded[2]).update as object)).toEqual([ + 'm1', + 'm2', + ]); + }); + + it('글자 부분 300개짜리 한 통 — 부분마다 1MiB ÷ 300 = 3,495바이트로 읽어 판정하고, 몫이 다른 메일은 따로 읽는다', async () => { + const parts = textParts(300, 10_000); + const fake = fakeRunner([ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT, { + textBody: [], + htmlBody: [], + attachments: parts, + }), + quoteMail('m2', HANBIT), + ], + ), + bodies([ + { + id: 'm1', + mailboxIds: { [INBOX]: true }, + textBody: [], + htmlBody: [], + attachments: parts.map(({ partId, type }) => ({ partId, type })), + // 찾을 글은 마지막 부분에만 있다 — 300개 모두 견준다 + bodyValues: Object.fromEntries( + parts.map(({ partId }) => [ + partId, + { value: partId === '300' ? '10월 견적서' : '안내' }, + ]), + ), + }, + ]), + bodies([bodyOf('m2', '

회의 안내

')]), + updated(['m1']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 1 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ + ids: ['m1'], + fetchAllBodyValues: true, + maxBodyValueBytes: 3_495, + }); + expect(Math.floor(FILTER_RUN_MESSAGE_BODY_BYTES / 300)).toBe(3_495); + expect(argsOf(fake.recorded[2])).toMatchObject({ + ids: ['m2'], + maxBodyValueBytes: FILTER_RUN_BODY_VALUE_BYTES, + }); + expect(Object.keys(argsOf(fake.recorded[3]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('본문 값은 어림한 크기(부분마다 크기의 두 배를 몫까지)로 나눠 읽는다 — 한 번에 4MB 안쪽', async () => { + // 1MB 부분 6개 — 몫은 1MiB ÷ 6 = 174,762바이트, 한 통의 어림은 1,048,572바이트 → 한 번에 세 통 + const big = (id: string) => + mail(id, HANBIT, { + textBody: [], + htmlBody: [], + attachments: textParts(6, 1_000_000), + }); + const list = ['m1', 'm2', 'm3', 'm4', 'm5']; + const fake = fakeRunner([ + page(list, list.map(big)), + bodies(['m1', 'm2', 'm3'].map((id) => bodyOf(id, ''))), + bodies([bodyOf('m4', ''), bodyOf('m5', '

견적서

')]), + updated(['m5']), + ]); + + await expect( + fake.runner.run( + ARA, + request({ match: 'all', conditions: [hasQuote] }), + context(), + ), + ).resolves.toMatchObject({ scanned: 5, matched: 1 }); + expect(argsOf(fake.recorded[1])).toMatchObject({ + ids: ['m1', 'm2', 'm3'], + maxBodyValueBytes: 174_762, + }); + expect(argsOf(fake.recorded[2])).toMatchObject({ + ids: ['m4', 'm5'], + maxBodyValueBytes: 174_762, + }); + }); + }); + + describe('상태 · 거절 · 시간 (BulkRun — 메일함의 오래 걸리는 처리와 같은 규칙)', () => { + it('옮기는 사이 상태가 어긋나면 맞은 메일의 메일함을 다시 읽고, 아직 받은편지함에 있는 것만 그 상태로 다시 옮긴다', async () => { + const fake = fakeRunner([ + page(['m1', 'm2'], [mail('m1', HANBIT), mail('m2', HANBIT)]), + mismatch, + [ + [ + 'Email/get', + { + state: 's2', + list: [ + { id: 'm1', mailboxIds: { [INBOX]: true } }, + { id: 'm2', mailboxIds: { b: true } }, + ], + }, + 'g', + ], + ], + updated(['m1']), + ]); + + await expect( + fake.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 2, matched: 1, cursor: null }); + expect(argsOf(fake.recorded[2])).toEqual({ + accountId: 'd', + ids: ['m1', 'm2'], + properties: ['id', 'mailboxIds'], + }); + expect(argsOf(fake.recorded[3])).toMatchObject({ + ifInState: 's2', + update: { m1: expect.any(Object) as object }, + }); + expect(Object.keys(argsOf(fake.recorded[3]).update as object)).toEqual([ + 'm1', + ]); + }); + + it('이어서 세 번 어긋나면 멈춘다 — 확인한 것이 없으면 MAIL_001 (옮기지 않았다)', async () => { + const reread: JmapInvocation[] = [ + [ + 'Email/get', + { + state: 's2', + list: [{ id: 'm1', mailboxIds: { [INBOX]: true } }], + }, + 'g', + ], + ]; + const fake = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + mismatch, + reread, + mismatch, + reread, + mismatch, + ]); + + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ + code: 'MAIL_001', + reason: 'filter-run-no-progress', + stalled: 'state-mismatch', + }); + expect(fake.recorded).toHaveLength(6); + }); + + it('한 요청에서 판정한 것이 하나도 없으면(읽을 때마다 모두 그사이 빠짐) 진행 없음 — 세 차례 뒤 MAIL_001(503)', async () => { + const vanished = () => + page(['m1'], [mail('m1', HANBIT, { mailboxIds: { b: true } })], { + total: 5, + }); + const fake = fakeRunner([vanished(), vanished(), vanished()]); + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ + code: 'MAIL_001', + reason: 'filter-run-no-progress', + stalled: 'nothing-judged', + }); + expect(fake.recorded).toHaveLength(3); + expect(methodsOf(fake.recorded)).not.toContain('Email/set'); + }); + + it('상태를 모르면 조건 없이 옮기지 않는다 (MAIL_001)', async () => { + const fake = fakeRunner([ + [ + ['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'], + ['Email/get', { list: [mail('m1', HANBIT)] }, 'g'], + ], + ]); + expect( + await failureOf(fake.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'inbox-state-unknown' }); + }); + + it('엔진이 차례의 할 일을 모두 거절하면 — 확인한 것이 없으면 그 거절의 코드, 그사이 지워진 메일(notFound)은 거절로 보지 않는다', async () => { + const refused = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + updated([], { m1: { type: 'forbidden' } }), + ]); + expect( + await failureOf(refused.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'AUTH_003' }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('jmapType=forbidden'), + ); + + const vanished = fakeRunner([ + page(['m1'], [mail('m1', HANBIT)]), + updated([], { m1: { type: 'notFound' } }), + ]); + await expect( + vanished.runner.run(ARA, request(), context()), + ).resolves.toMatchObject({ scanned: 1, matched: 0, cursor: null }); + }); + + it('시간은 서비스에 들어온 때(context.startedAt — 옮길 메일함 확인 포함)부터 센다 — 21초 전에 들어왔으면 차례를 시작하지 않고 MAIL_001 (엔진에 묻지 않았다)', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + const fake = fakeRunner([]); + expect( + await failureOf( + fake.runner.run( + ARA, + request(), + context({ startedAt: 1_000_000 - 21_000 }), + ), + ), + ).toMatchObject({ code: 'MAIL_001', reason: 'filter-run-no-progress' }); + expect(fake.recorded).toHaveLength(0); + }); + + it('남은 시간 때문에 끊긴 엔진 호출 — 확인한 차례가 있으면 거기까지로 답하고(끊긴 차례는 다음 요청이 다시 한다), 없으면 MAIL_001 · 시간이 남았는데 끊긴 것은 엔진 장애 그대로', async () => { + const timeout = new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }); + const firstRound = (): JmapInvocation[][] => [ + page( + ['m1', 'm2'], + [ + mail('m1', HANBIT), + mail('m2', undefined, { receivedAt: utc(T0 - 1) }), + ], + { total: 5 }, + ), + updated(['m1']), + ]; + + const cut = fakeRunner(firstRound()); + failCallWith(cut, 3, 30_000, timeout); + const result = await cut.runner.run(ARA, request(), context()); + expect(result).toMatchObject({ scanned: 2, matched: 1, remaining: 3 }); + expect(decodeFilterRunCursor(result.cursor ?? '', request())).toEqual({ + before: T0 + 1, + anchor: 'm2', + anchorTime: T0 - 1, + }); + + jest.restoreAllMocks(); + const early = fakeRunner([]); + failCallWith(early, 1, 30_000, timeout); + expect( + await failureOf(early.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001' }); + + jest.restoreAllMocks(); + const slow = fakeRunner(firstRound()); + failCallWith(slow, 3, 0, timeout); + expect( + await failureOf(slow.runner.run(ARA, request(), context())), + ).toMatchObject({ code: 'MAIL_001', reason: 'timeout' }); + }); + }); + }); + + /** + * 상태가 있는 가짜 엔진으로 여러 요청을 끝까지 — 요청 사이 · 요청 도중에 받은편지함이 바뀌어도 맞는 메일을 남기지 않는지, 새 메일을 건드리지 않는지 본다. + * 가짜 엔진의 조회 규칙은 2026-10-29 개발 엔진에서 확인한 것이다(phase7 tie-order): 받은 시각 내림차순 · 같은 시각은 만든 차례 + * (표시를 바꾸거나 옮겼다 되돌려도 그대로) · `before` 는 미만 · 받은 시각은 초 · anchor 가 없으면 anchorNotFound · ifInState 가 다르면 stateMismatch + */ + describe('여러 요청에 걸쳐 (상태가 있는 가짜 엔진)', () => { + const HANBIT_FROM = '"한빛" '; + const OTHER_FROM = 'Probe '; + + /** 가짜 엔진의 메일 — 받은 시각은 초, doc 은 만든 차례 */ + interface SimEmail { + readonly id: string; + readonly doc: number; + readonly receivedAt: number; + readonly from: string; + readonly mailboxes: Set; + readonly keywords: Set; + } + + interface SimQuery { + readonly filter: { readonly inMailbox: string; readonly before?: string }; + readonly anchor?: string; + readonly anchorOffset?: number; + readonly position?: number; + readonly limit: number; + } + + class FakeEngine { + readonly emails = new Map(); + /** 받은 조회 — 위 경계가 걸렸는지 본다 */ + readonly queries: SimQuery[] = []; + /** 엔진 요청 하나를 처리하기 전에 — 요청 도중에 일어나는 일(새 메일 · 다른 탭의 옮기기) */ + readonly beforeCall: ((methods: readonly string[]) => void)[] = []; + private state = 1; + private nextDoc = 0; + + add(receivedAt: number, from: string): SimEmail { + const doc = this.nextDoc; + this.nextDoc += 1; + const email: SimEmail = { + id: `e${doc}`, + doc, + receivedAt, + from, + mailboxes: new Set([INBOX]), + keywords: new Set(), + }; + this.emails.set(email.id, email); + this.state += 1; + return email; + } + + /** 사용자가 옮긴다 (다른 탭 · 다른 메일 프로그램) */ + move(id: string, from: string, to: string): void { + const email = this.emails.get(id); + if (!email) return; + email.mailboxes.delete(from); + email.mailboxes.add(to); + this.state += 1; + } + + /** 그 메일함의 메일 — 받은 시각 내림차순, 같은 시각은 만든 차례 (before 는 미만) */ + sorted(mailboxId: string, before = Number.POSITIVE_INFINITY): SimEmail[] { + return [...this.emails.values()] + .filter( + (email) => + email.mailboxes.has(mailboxId) && email.receivedAt < before, + ) + .sort((x, y) => y.receivedAt - x.receivedAt || x.doc - y.doc); + } + + handle(calls: readonly JmapInvocation[]): JmapInvocation[] { + for (const hook of this.beforeCall) hook(calls.map(([name]) => name)); + const responses: JmapInvocation[] = []; + const queried = new Map(); + for (const [method, args, callId] of calls) { + if (method === 'Email/query') { + const query = args as unknown as SimQuery; + this.queries.push(query); + const before = + query.filter.before === undefined + ? Number.POSITIVE_INFINITY + : Date.parse(query.filter.before) / 1_000; + const list = this.sorted(query.filter.inMailbox, before); + let start = query.position ?? 0; + if (query.anchor !== undefined) { + const index = list.findIndex(({ id }) => id === query.anchor); + if (index === -1) { + responses.push(['error', { type: 'anchorNotFound' }, callId]); + continue; + } + start = Math.max(0, index + (query.anchorOffset ?? 0)); + } + const ids = list + .slice(start, start + query.limit) + .map(({ id }) => id); + queried.set(callId, ids); + responses.push([ + 'Email/query', + { ids, position: start, total: list.length }, + callId, + ]); + } else if (method === 'Email/get') { + const reference = args['#ids'] as { resultOf: string } | undefined; + const ids = reference + ? queried.get(reference.resultOf) + : (args.ids as string[]); + if (!ids) { + responses.push([ + 'error', + { type: 'invalidResultReference' }, + callId, + ]); + continue; + } + responses.push([ + 'Email/get', + { + state: `s${this.state}`, + list: ids.flatMap((id) => { + const email = this.emails.get(id); + return email ? [this.view(email)] : []; + }), + notFound: ids.filter((id) => !this.emails.has(id)), + }, + callId, + ]); + } else if (method === 'Email/set') { + expect(args).not.toHaveProperty('destroy'); + if ( + args.ifInState !== undefined && + args.ifInState !== `s${this.state}` + ) { + responses.push(['error', { type: 'stateMismatch' }, callId]); + continue; + } + responses.push([ + 'Email/set', + this.update( + args.update as Record>, + ), + callId, + ]); + } else { + throw new Error(`가짜 엔진이 모르는 메서드: ${method}`); + } + } + return responses; + } + + private view(email: SimEmail): Record { + return { + id: email.id, + mailboxIds: Object.fromEntries( + [...email.mailboxes].map((id) => [id, true]), + ), + keywords: Object.fromEntries( + [...email.keywords].map((keyword) => [keyword, true]), + ), + receivedAt: utc(email.receivedAt), + 'header:From:all': [` ${email.from}`], + }; + } + + private update( + patches: Record>, + ): Record { + const done: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries(patches)) { + const email = this.emails.get(id); + if (!email) { + notUpdated[id] = { type: 'notFound' }; + continue; + } + for (const [path, value] of Object.entries(patch)) { + const [kind, key] = path.split('/'); + const target = + kind === 'keywords' ? email.keywords : email.mailboxes; + if (value === true) target.add(key); + else target.delete(key); + } + done[id] = null; + } + if (Object.keys(done).length > 0) this.state += 1; + return { updated: done, notUpdated }; + } + } + + interface RunOptions { + /** 한 요청에 하는 차례 수 — 조회마다 시계를 그만큼 넘긴다 */ + readonly roundsPerRequest?: number; + /** 요청 사이에 — 사용자의 옮기기 · 새 메일 */ + readonly between?: (cursor: string) => void; + } + + /** cursor 가 null 이 될 때까지 같은 본문으로 다시 보낸다 (클라이언트처럼) — 요청마다 서비스처럼 메일함 목록 · 들어온 때를 넘긴다 */ + async function runAll( + engine: FakeEngine, + rule: MailFilterRunRequest, + { roundsPerRequest = 1, between }: RunOptions = {}, + ) { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const client = { + call: jest.fn( + (_user: string, build: (accountId: string) => JmapInvocation[]) => { + const calls = build('d'); + if (calls[0][0] === 'Email/query') { + now += Math.floor(21_000 / roundsPerRequest); + } + return Promise.resolve(engine.handle(calls)); + }, + ), + }; + const runner = new JmapFilterRunner(client as unknown as JmapClient); + let cursor: string | null = null; + let requests = 0; + let scanned = 0; + let matched = 0; + do { + if (cursor !== null) between?.(cursor); + const mailboxes = [ + mailbox(INBOX, 'inbox', engine.sorted(INBOX).length), + mailbox(TARGET, null), + mailbox(TRASH, 'trash'), + ]; + const result = await runner.run( + ARA, + { ...rule, cursor }, + { mailboxes, startedAt: now }, + ); + scanned += result.scanned; + matched += result.matched; + cursor = result.cursor; + requests += 1; + now += 100_000; + } while (cursor !== null && requests < 100); + return { requests, scanned, matched, done: cursor === null }; + } + + const moveRule = request(); + const flagRule = request({ + actions: { moveToMailboxId: null, markAsRead: false, flag: true }, + }); + + /** 받은편지함에 메일 count 통 — 7통에 한 통이 맞는 메일(한빛), 받은 시각은 timeOf(차례) */ + function fill( + engine: FakeEngine, + count: number, + timeOf: (index: number) => number = (index) => T0 + index, + ): void { + for (let index = 0; index < count; index += 1) { + engine.add(timeOf(index), index % 7 === 0 ? HANBIT_FROM : OTHER_FROM); + } + } + + /** 할 일을 하지 않은 채 받은편지함에 남은 맞는 메일 — 옮기기면 받은편지함에 남은 것, 표시만이면 중요 표시가 없는 것 */ + function missed( + engine: FakeEngine, + rule: MailFilterRunRequest, + among: ReadonlySet = new Set(engine.emails.keys()), + ): string[] { + return engine + .sorted(INBOX) + .filter( + (email) => + among.has(email.id) && + email.from === HANBIT_FROM && + (rule.actions.moveToMailboxId !== null || + !email.keywords.has('$flagged')), + ) + .map(({ id }) => id); + } + + it('옮기기 · 1,234통 · 요청마다 한 차례 — 맞는 메일만 모두 옮기고 모든 메일을 한 번씩 확인한다. 첫 조회 말고는 모두 위 경계를 건다', async () => { + const engine = new FakeEngine(); + fill(engine, 1_234); + + const result = await runAll(engine, moveRule); + expect(result).toMatchObject({ + done: true, + requests: 3, + scanned: 1_234, + matched: 177, + }); + expect(missed(engine, moveRule)).toEqual([]); + expect(engine.sorted(TARGET)).toHaveLength(177); + // 맞지 않은 메일은 그대로 + for (const email of engine.sorted(INBOX)) { + expect(email.from).toBe(OTHER_FROM); + expect(email.keywords.size).toBe(0); + } + expect(engine.queries[0].filter).toEqual({ inMailbox: INBOX }); + for (const query of engine.queries.slice(1)) { + expect(query.filter).toEqual({ + inMailbox: INBOX, + before: utc(T0 + 1_234), + }); + } + }); + + it('표시만 · 1,234통이 모두 같은 받은 시각 · 요청마다 두 차례 — 같은 초 안에서도 anchor 로 이어 읽어 한 번씩 확인한다', async () => { + const engine = new FakeEngine(); + fill(engine, 1_234, () => T0); + + const result = await runAll(engine, flagRule, { roundsPerRequest: 2 }); + expect(result).toMatchObject({ + done: true, + requests: 2, + scanned: 1_234, + matched: 177, + }); + expect(missed(engine, flagRule)).toEqual([]); + }); + + it.each([1, 5, 20, 100])( + '요청 사이에 사용자가 anchor 와 받은편지함 맨 위 %i통을 휴지통으로 옮겨도 맞는 메일이 남지 않는다 (옮기기 · 표시만)', + async (count) => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + fill(engine, 1_400); + let moved = false; + const result = await runAll(engine, rule, { + between: (cursor) => { + if (moved) return; + const { anchor } = decodeFilterRunCursor(cursor, rule); + if (anchor === null) return; + moved = true; + engine.move(anchor, INBOX, TRASH); + for (const email of engine.sorted(INBOX).slice(0, count)) { + engine.move(email.id, INBOX, TRASH); + } + }, + }); + expect(moved).toBe(true); + expect(result.done).toBe(true); + expect(missed(engine, rule)).toEqual([]); + } + }, + ); + + it('anchor 의 초에 같은 시각 메일이 여럿 — anchor 가 빠지면 그 초의 메일을 처음부터 다시 판정하고(그 뒤의 같은 초 메일을 건너뛰지 않는다), 다시 보는 것은 그 초뿐이다', async () => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + // 한 초에 37통 — 한 차례(500통)가 초의 가운데에서 끝난다 + fill(engine, 1_400, (index) => T0 + Math.floor(index / 37)); + /** 요청 사이에 빠진 anchor — 처음 한 번만 */ + const removed: { id: string; time: number | null }[] = []; + const result = await runAll(engine, rule, { + between: (cursor) => { + if (removed.length > 0) return; + const { anchor, anchorTime } = decodeFilterRunCursor(cursor, rule); + if (anchor === null) return; + removed.push({ id: anchor, time: anchorTime }); + engine.move(anchor, INBOX, TRASH); + }, + }); + + expect(result.done).toBe(true); + expect(missed(engine, rule)).toEqual([]); + // 빠진 anchor 의 초에는 그 뒤에도 같은 시각의 메일 · 맞는 메일이 있었다 + expect(removed).toHaveLength(1); + const [{ id: anchorId, time: anchorTime }] = removed; + expect(anchorTime).not.toBeNull(); + const sameSecond = [...engine.emails.values()].filter( + (email) => email.receivedAt === anchorTime, + ); + expect(sameSecond.length).toBe(37); + const anchorDoc = engine.emails.get(anchorId)?.doc ?? -1; + expect( + sameSecond.some( + (email) => email.doc > anchorDoc && email.from === HANBIT_FROM, + ), + ).toBe(true); + // 다시 판정하는 것은 그 초의 앞쪽(많아야 36통)뿐이다 + expect(result.scanned).toBeGreaterThanOrEqual(1_400); + expect(result.scanned).toBeLessThanOrEqual(1_400 + 36); + } + }); + + it('도중에 온 새 메일은 건드리지 않는다 — 요청 사이 · 요청 도중에 맞는 메일이 와도(새 메일은 Sieve 가 이미 판정했다), anchor 가 빠져 다시 읽어도', async () => { + for (const rule of [moveRule, flagRule]) { + const engine = new FakeEngine(); + fill(engine, 1_400); + const original = new Set(engine.emails.keys()); + let arrivals = 0; + const arrive = () => { + arrivals += 1; + engine.add(T0 + 10_000 + arrivals, HANBIT_FROM); + }; + // 요청 도중 — 첫 조회 뒤로는 조회마다 한 통 + let queries = 0; + engine.beforeCall.push((methods) => { + if (methods[0] !== 'Email/query') return; + queries += 1; + if (queries > 1) arrive(); + }); + let anchorRemoved = false; + const result = await runAll(engine, rule, { + between: (cursor) => { + // 요청 사이 — 세 통, 처음에는 anchor 도 빠진다(처음부터 다시 읽는다) + for (let index = 0; index < 3; index += 1) arrive(); + const { anchor } = decodeFilterRunCursor(cursor, rule); + if (!anchorRemoved && anchor !== null) { + anchorRemoved = true; + engine.move(anchor, INBOX, TRASH); + } + }, + }); + + expect(result.done).toBe(true); + expect(anchorRemoved).toBe(true); + expect(missed(engine, rule, original)).toEqual([]); + const fresh = [...engine.emails.values()].filter( + ({ id }) => !original.has(id), + ); + expect(fresh.length).toBeGreaterThan(5); + for (const email of fresh) { + expect([...email.mailboxes]).toEqual([INBOX]); + expect(email.keywords.size).toBe(0); + } + } + }); + + it('옮기기 직전에 사용자가 맞은 메일 하나를 휴지통으로 — 읽은 상태가 아니면 옮기지 않고(ifInState) 다시 읽어 남은 것만 옮긴다 (휴지통과 옮길 메일함에 함께 들지 않는다)', async () => { + const engine = new FakeEngine(); + fill(engine, 30); + let trashed: string | null = null; + engine.beforeCall.push((methods) => { + if (trashed !== null || methods[0] !== 'Email/set') return; + trashed = + engine.sorted(INBOX).find((email) => email.from === HANBIT_FROM) + ?.id ?? null; + if (trashed !== null) engine.move(trashed, INBOX, TRASH); + }); + + const result = await runAll(engine, moveRule); + expect(trashed).not.toBeNull(); + expect(result).toMatchObject({ done: true, scanned: 30, matched: 4 }); + expect( + [...engine.emails.values()].filter( + (email) => email.mailboxes.has(TRASH) && email.mailboxes.has(TARGET), + ), + ).toEqual([]); + expect(missed(engine, moveRule)).toEqual([]); + }); + + it('모두 맞는 규칙(always)으로 차례마다 모두 옮겨 남긴 메일이 없어도 이어 간다 — 위 경계 아래의 처음부터. 요청 사이에 온 새 메일은 그대로', async () => { + const engine = new FakeEngine(); + fill(engine, 1_200); + const always = request({ + match: 'always', + conditions: [], + actions: { moveToMailboxId: TARGET, markAsRead: false, flag: false }, + }); + + const result = await runAll(engine, always, { + between: () => { + for (let index = 0; index < 3; index += 1) { + engine.add(T0 + 20_000 + engine.emails.size, OTHER_FROM); + } + }, + }); + expect(result).toMatchObject({ + done: true, + requests: 3, + scanned: 1_200, + matched: 1_200, + }); + // 받은편지함에 남은 것은 도중에 온 새 메일 6통뿐 + const left = engine.sorted(INBOX); + expect(left).toHaveLength(6); + expect(left.every((email) => email.receivedAt >= T0 + 20_000)).toBe(true); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts new file mode 100644 index 0000000..f636005 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts @@ -0,0 +1,773 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + filterFields, + judgeFilter, + type MailFilterTexts, +} from '@/modules/mail/mail-filter-match'; +import type { + Mailbox, + MailFilterRunContext, + MailFilterRunOutcome, + MailFilterRunRequest, +} from '@/modules/mail/mail.types'; +import { BulkRun } from './bulk-run'; +import { + MAX_FILTER_RUN_SECONDS, + decodeFilterRunCursor, + encodeFilterRunCursor, +} from './filter-run-cursor'; +import { + FILTER_RUN_BODY_PROPERTIES, + FILTER_RUN_PART_PROPERTIES, + bodyTextsOf, + estimatedBodyBytes, + filterRunProperties, + headerTextsOf, + textPartsOf, +} from './filter-run-texts'; +import { JmapClient, type JmapCallOptions } from './jmap.client'; +import { jmapFailure } from './jmap.errors'; +import { toJmapUtcDate } from './jmap.mapper'; +import { + expectResult, + findResult, + firstRefusal, + isState, + warnRefusals, +} from './jmap-results'; +import type { + JmapEmail, + JmapEmailFilterCondition, + JmapGetResult, + JmapInvocation, + JmapQueryResult, + JmapSetResult, +} from './jmap.types'; + +/** + * 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지, + * 본문을 견주면 줄인다(본문 값을 읽는 시간 · 크기 — 차례가 남은 시간 안에 끝나게). + * 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 머리글 500통 읽기 0.17~0.19초 · 본문 2,000통 1초 안팎 · 500통 옮기기 0.35초 + */ +const HEADER_ROUND_SIZE = 500; +const BODY_ROUND_SIZE = 100; + +/** + * 본문을 읽는 상한 (UTF-8 바이트 — 계약의 "한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지") — 넘는 뒤쪽은 견주지 않는다. + * 글자 부분이 많아 한 통의 몫을 넘으면 부분마다 같은 몫(1MiB ÷ 부분 수)만큼 앞쪽만 읽는다 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게. + * 텍스트 · HTML 대안 본문이 함께 오는 메일은 텍스트 쪽이 작아 긴 메일의 뒷부분도 대개 이 안에 든다 + */ +export const FILTER_RUN_BODY_VALUE_BYTES = 262_144; +export const FILTER_RUN_MESSAGE_BODY_BYTES = 1_048_576; + +/** 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게) */ +const BODY_FETCH_BUDGET_BYTES = 4_000_000; + +/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */ +const MAX_IDS_PER_GET = 500; + +/** 받은 시각이 최근인 메일부터 — 같은 받은 시각의 메일은 엔진이 만든 차례로 늘 같게 준다 (2026-10-29 확인) */ +const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }]; + +/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 · 지난 차례의 마지막 메일(anchor)이 받은편지함에 없다 (RFC 8620 §5.3 · §5.5) */ +const STATE_MISMATCH = 'stateMismatch'; +const ANCHOR_NOT_FOUND = 'anchorNotFound'; +/** SetError — 그 메일이 없다 (그사이 지워짐) */ +const NOT_FOUND = 'notFound'; + +const MS_PER_SECOND = 1_000; + +/** + * 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지. + * 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다 + */ +export function bodyValueQuota(textParts: number): number { + if (textParts <= 0) return FILTER_RUN_BODY_VALUE_BYTES; + return Math.max( + 1, + Math.min( + FILTER_RUN_BODY_VALUE_BYTES, + Math.floor(FILTER_RUN_MESSAGE_BODY_BYTES / textParts), + ), + ); +} + +/** 이어 할 자리 (filter-run-cursor.ts 의 FilterRunPosition) — 위 경계는 첫 차례를 읽기 전에는 모른다(null) */ +interface RunState { + readonly before: number | null; + readonly anchor: string | null; + readonly anchorTime: number | null; +} + +/** 할 일 — 맞은 메일의 패치를 만든다 */ +interface RunPlan { + readonly inboxId: string; + /** 옮길 메일함 — 옮기지 않으면 null (규칙에 없음 · 그 메일함이 없음 · 받은편지함) */ + readonly moveTo: string | null; + readonly markAsRead: boolean; + readonly flag: boolean; +} + +/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */ +interface PageRead { + readonly properties: readonly string[]; + readonly needsBody: boolean; + readonly limit: number; +} + +/** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */ +type PageStart = { readonly before: number | null } & ( + | { readonly anchor: string } + | { readonly fromTop: true } +); + +/** 한 차례에 읽은 받은편지함의 메일 */ +interface RoundPage { + /** 첫 메일의 자리 (그 조회의 경계 안에서) */ + readonly start: number; + /** 받은 시각 내림차순 */ + readonly ids: readonly string[]; + /** 그 조회의 경계 안 받은편지함 메일 수 — 엔진이 세어 주지 않으면 어림 */ + readonly total: number; + readonly emails: ReadonlyMap; + /** 이 읽기의 Email 상태 — 옮길 때 ifInState 에 쓴다 (엔진이 알려 주지 않으면 null) */ + readonly state: string | null; + /** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */ + readonly from: RunState; +} + +/** 한 차례를 마친 결과 */ +interface RoundResult { + /** 판정한 메일 수 — 조회와 읽기 사이에 받은편지함에서 빠진 메일은 세지 않는다 */ + readonly judged: number; + /** 맞아서 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일 포함) */ + readonly matched: number; + /** 받은편지함에 남긴 마지막 메일과 그 받은 시각(초 — 엔진이 주지 않았으면 null) — 남긴 메일이 없으면 null */ + readonly lastStayed: { + readonly id: string; + readonly time: number | null; + } | null; +} + +/** 맞은 메일에 할 일을 한 결과 */ +interface Applied { + readonly updated: ReadonlySet; + /** 그사이 받은편지함에서 빠졌거나 없어진 메일 — 하지 않았다 */ + readonly gone: ReadonlySet; +} + +/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */ +interface BodyBatch { + readonly quota: number; + readonly ids: readonly string[]; +} + +/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */ +function runPlan( + request: MailFilterRunRequest, + inboxId: string, + mailboxes: readonly Mailbox[], +): RunPlan { + const target = request.actions.moveToMailboxId; + const moveTo = + target !== null && + target !== inboxId && + mailboxes.some((mailbox) => mailbox.id === target) + ? target + : null; + return { + inboxId, + moveTo, + markAsRead: request.actions.markAsRead, + flag: request.actions.flag, + }; +} + +/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */ +function emailPatch( + plan: RunPlan, + email: JmapEmail | undefined, +): Record | null { + const patch: Record = {}; + if (plan.markAsRead && email?.keywords?.$seen !== true) { + patch['keywords/$seen'] = true; + } + if (plan.flag && email?.keywords?.$flagged !== true) { + patch['keywords/$flagged'] = true; + } + if (plan.moveTo !== null) { + patch[`mailboxIds/${plan.inboxId}`] = null; + patch[`mailboxIds/${plan.moveTo}`] = true; + } + return Object.keys(patch).length > 0 ? patch : null; +} + +/** 받은 시각(초) — 엔진은 초 단위로 둔다(2026-10-29 확인). 읽을 수 없으면 null */ +function receivedSeconds(email: JmapEmail | undefined): number | null { + const time = email?.receivedAt ? Date.parse(email.receivedAt) : Number.NaN; + if (!Number.isFinite(time)) return null; + const seconds = Math.floor(time / MS_PER_SECOND); + return seconds >= 0 && seconds < MAX_FILTER_RUN_SECONDS ? seconds : null; +} + +/** 초 → `before` 조건의 UTCDate */ +function utcDate(seconds: number): string { + return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString()); +} + +/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */ +function newestSecond(page: RoundPage): number | null { + let newest: number | null = null; + for (const email of page.emails.values()) { + const seconds = receivedSeconds(email); + if (seconds !== null && (newest === null || seconds > newest)) { + newest = seconds; + } + } + return newest; +} + +/** + * 본문 값을 읽을 묶음 — 부분마다의 몫(bodyValueQuota)이 같은 메일끼리, 어림한 크기가 상한을 넘거나 엔진의 한 번 읽기 한도에 닿으면 나눈다. + * 한 통의 어림은 몫 × 부분 수 이하라(1MiB 안쪽) 한 묶음에 여러 통이 든다 + */ +function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] { + const groups = new Map(); + for (const email of emails) { + const quota = bodyValueQuota(textPartsOf(email).length); + const group = groups.get(quota); + if (group) group.push(email); + else groups.set(quota, [email]); + } + + const batches: BodyBatch[] = []; + for (const [quota, group] of groups) { + let ids: string[] = []; + let bytes = 0; + for (const email of group) { + const estimate = estimatedBodyBytes(email, quota); + if ( + ids.length > 0 && + (bytes + estimate > BODY_FETCH_BUDGET_BYTES || + ids.length >= MAX_IDS_PER_GET) + ) { + batches.push({ quota, ids }); + ids = []; + bytes = 0; + } + ids.push(email.id); + bytes += estimate; + } + if (ids.length > 0) batches.push({ quota, ids }); + } + return batches; +} + +/** + * 받은편지함에 이미 있는 메일에 필터 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계). + * 엔진의 Sieve 는 메일을 받을 때만 돌기 때문에, 받은편지함을 차례로 읽어 Sieve 와 같은 규칙으로 판정하고(mail-filter-match.ts) 표시 · 옮기기를 한다. + * 엔진의 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 — 낱말이 통째로 같을 때만 맞고 `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27). + * + * - 차례마다: `Email/query`(받은편지함 · 위 경계 미만 · 받은 시각 내림차순 · 지난 차례에 남긴 마지막 메일 다음부터) + `Email/get`(받은편지함에 아직 있는지 · + * 표시 · 받은 시각 · 견줄 머리글 · 본문 조건이면 부분) → 판정 → 머리글로 판정이 나지 않은 메일만 본문 값을 나눠 읽어 판정 → 맞은 메일에 `Email/set` 한 번 + * - 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다 (filter-run-cursor.ts) + * - 확인하는 사이 받은편지함에서 빠진 메일은 건너뛰고 세지 않는다. 옮기기는 읽은 상태 그대로일 때만(ifInState) — 그사이 다른 곳으로 옮긴 메일을 + * 옮길 메일함에 더 넣지 않게. 어긋나면 맞은 메일의 메일함을 다시 읽어 남은 것만 다시 한다. 표시만 할 때는 조건 없이(되돌릴 수 있는 표시) + * - 영구 삭제는 하지 않는다 — 이 파일에는 Email/set 의 destroy 가 없다(휴지통으로 옮기는 규칙도 옮기기다) + * - 한 요청의 시간 · 멈춤은 BulkRun(bulk-run.ts) — 메일함의 오래 걸리는 처리와 같다. 시간은 서비스에 들어온 때부터(context.startedAt), + * 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다 + * - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다) + * - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다 + */ +@Injectable() +export class JmapFilterRunner { + private readonly logger = new Logger(JmapFilterRunner.name); + + constructor(private readonly jmap: JmapClient) {} + + async run( + user: string, + request: MailFilterRunRequest, + context: MailFilterRunContext, + ): Promise { + // 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001 + let position: RunState = + request.cursor === null + ? { before: null, anchor: null, anchorTime: null } + : decodeFilterRunCursor(request.cursor, request); + + const run = new BulkRun(context.startedAt); + const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox'); + // 받은편지함이 없는 계정 — 적용할 메일이 없다 + if (!inbox) { + return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 }; + } + + const plan = runPlan(request, inbox.id, context.mailboxes); + const fields = filterFields(request); + const needsBody = fields.has('body'); + const read: PageRead = { + properties: filterRunProperties(fields), + needsBody, + limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE, + }; + + let scanned = 0; + let matched = 0; + let rounds = 0; + let remaining = inbox.totalCount; + let finished = false; + try { + while (run.canStartRound()) { + rounds += 1; + const page = await this.readPage(user, inbox.id, position, read, run); + position = page.from; + if (page.ids.length === 0) { + finished = true; + break; + } + // 첫 차례 — 맨 위 메일의 받은 시각 + 1초를 위 경계로 (그 뒤에 온 메일은 보지 않는다) + const newest = position.before === null ? newestSecond(page) : null; + if (newest !== null) position = { ...position, before: newest + 1 }; + + const round = await this.applyRound(user, request, plan, page, run); + // 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다) + if (round === null) break; + + const exhausted = page.start + page.ids.length >= page.total; + remaining = Math.max(0, page.total - (page.start + page.ids.length)); + if (round.lastStayed !== null) { + position = { + ...position, + anchor: round.lastStayed.id, + anchorTime: round.lastStayed.time, + }; + } + if (round.judged > 0) { + scanned += round.judged; + matched += round.matched; + run.progress(round.judged); + } + if (exhausted) { + finished = true; + break; + } + // 읽은 메일이 모두 그사이 빠졌다 — 다시 읽는다 (이어서 그러면 멈춘다) + if (round.judged === 0 && run.stalled('nothing-judged')) break; + } + } catch (error) { + if (!run.cutOffAfterProgress(error)) throw error; + } + if (!finished) run.assertProgress('filter-run'); + + return { + scanned, + matched, + remaining: finished ? 0 : remaining, + cursor: finished ? null : this.cursorOf(position, request), + rounds, + }; + } + + /** + * 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001 + * (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다) + */ + private cursorOf(position: RunState, request: MailFilterRunRequest): string { + if ( + position.before === null || + (position.anchor !== null && position.anchorTime === null) + ) { + throw new BusinessException('MAIL_001', { + context: { reason: 'received-at-missing' }, + }); + } + return encodeFilterRunCursor( + { + before: position.before, + anchor: position.anchor, + anchorTime: position.anchorTime, + }, + request, + ); + } + + /** + * 한 차례의 메일 — 지난 차례에 받은편지함에 남긴 마지막 메일(anchor) 다음부터 위 경계 미만에서. 그 메일이 그사이 빠졌으면(anchorNotFound) + * 그 받은 시각의 메일부터(`before: anchorTime + 1초` 의 처음) 다시 읽는다 — 그 초의 메일을 다시 판정해도 건너뛰지는 않는다. + * anchor 가 없으면(아직 남긴 메일이 없다 · 빠진 anchor 뒤에 남긴 메일이 없다) 그 경계의 처음부터 읽는다 — 앞서 본 메일은 모두 옮겨 나갔다. + * 받은 시각을 모르면(엔진이 주지 않았다) 위 경계의 처음부터 — 다시 판정하는 메일이 늘 뿐 건너뛰지는 않는다 + */ + private async readPage( + user: string, + inboxId: string, + from: RunState, + read: PageRead, + run: BulkRun, + ): Promise { + if (from.anchor !== null) { + const responses = await this.queryPage( + user, + inboxId, + { before: from.before, anchor: from.anchor }, + read, + run.callOptions(), + ); + const query = findResult(responses, 'q', 'Email/query'); + if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) { + return this.toPage(responses, from, read); + } + } + const restart: RunState = { ...from, anchor: null }; + const before = + restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before; + const responses = await this.queryPage( + user, + inboxId, + { before, fromTop: true }, + read, + run.callOptions(), + ); + return this.toPage(responses, restart, read); + } + + private toPage( + responses: JmapInvocation[], + from: RunState, + read: PageRead, + ): RoundPage { + const query = expectResult(responses, 'q', 'Email/query'); + const found = expectResult>( + responses, + 'g', + 'Email/get', + ); + const ids = query.ids ?? []; + const start = query.position ?? 0; + const known = typeof query.total === 'number' ? query.total : null; + return { + start, + ids, + total: + known !== null && known >= start + ids.length + ? known + : start + ids.length + (ids.length === read.limit ? 1 : 0), + emails: new Map((found.list ?? []).map((email) => [email.id, email])), + state: isState(found.state) ? found.state : null, + from, + }; + } + + /** + * 조회 + 읽기를 한 요청으로 — 같은 순간의 받은편지함이다. 위 경계는 `before`(미만)로 건다. + * anchor 는 cursor 에서 왔을 수 있다 — 엔진이 그 모양을 해석하지 못해 요청 전체를 거부하면 깨진 cursor 와 같게 VAL_001 + */ + private queryPage( + user: string, + inboxId: string, + start: PageStart, + read: PageRead, + options: JmapCallOptions, + ): Promise { + const filter: JmapEmailFilterCondition = + start.before === null + ? { inMailbox: inboxId } + : { inMailbox: inboxId, before: utcDate(start.before) }; + const byAnchor = 'anchor' in start; + return this.jmap.call( + user, + (accountId) => [ + [ + 'Email/query', + { + accountId, + filter, + sort: NEWEST_FIRST, + ...(byAnchor + ? { anchor: start.anchor, anchorOffset: 1 } + : { position: 0 }), + limit: read.limit, + calculateTotal: true, + }, + 'q', + ], + [ + 'Email/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: [...read.properties], + ...(read.needsBody + ? { bodyProperties: FILTER_RUN_PART_PROPERTIES } + : {}), + }, + 'g', + ], + ], + byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options, + ); + } + + /** + * 한 차례 — 판정하고 맞은 메일에 할 일을 한다. 멈춰야 하면(상태가 계속 어긋남 · 엔진이 모두 거절했는데 앞 차례의 진행이 있음) null + */ + private async applyRound( + user: string, + request: MailFilterRunRequest, + plan: RunPlan, + page: RoundPage, + run: BulkRun, + ): Promise { + /** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ + const verdicts = new Map(); + const undecided: JmapEmail[] = []; + const headerTexts = new Map>(); + for (const id of page.ids) { + const email = page.emails.get(id); + if (email?.mailboxIds?.[plan.inboxId] !== true) { + verdicts.set(id, 'gone'); + continue; + } + const texts = headerTextsOf(email); + const verdict = judgeFilter(request, { ...texts, body: null }); + if (verdict !== null) { + verdicts.set(id, verdict); + } else if (textPartsOf(email).length === 0) { + // 글자 부분이 없다 — 본문 값을 읽지 않고 빈 본문으로 판정한다 + verdicts.set(id, judgeFilter(request, { ...texts, body: [] }) === true); + } else { + undecided.push(email); + headerTexts.set(id, texts); + } + } + + // 머리글로 판정이 나지 않은 메일만 본문 값을 읽는다 — 한 통의 몫 · 어림한 크기로 나눠서 + for (const batch of bodyBatches(undecided)) { + const bodies = await this.readBodies( + user, + batch.ids, + batch.quota, + run.callOptions(), + ); + for (const id of batch.ids) { + const email = bodies.get(id); + const texts = headerTexts.get(id); + if (email?.mailboxIds?.[plan.inboxId] !== true || !texts) { + verdicts.set(id, 'gone'); + continue; + } + verdicts.set( + id, + judgeFilter(request, { ...texts, body: bodyTextsOf(email) }) === true, + ); + } + } + + const patches = new Map>(); + let unchanged = 0; + for (const id of page.ids) { + if (verdicts.get(id) !== true) continue; + const patch = emailPatch(plan, page.emails.get(id)); + if (patch === null) unchanged += 1; + else patches.set(id, patch); + } + + const applied: Applied | null = + patches.size === 0 + ? { updated: new Set(), gone: new Set() } + : await this.applyPatches(user, plan, page.state, patches, run); + if (applied === null) return null; + + let judged = 0; + let lastStayed: string | null = null; + for (const id of page.ids) { + const verdict = verdicts.get(id); + if (verdict === undefined || verdict === 'gone') continue; + judged += 1; + const moved = plan.moveTo !== null && applied.updated.has(id); + if (!moved && !applied.gone.has(id)) lastStayed = id; + } + return { + judged, + matched: unchanged + applied.updated.size, + lastStayed: + lastStayed === null + ? null + : { + id: lastStayed, + time: receivedSeconds(page.emails.get(lastStayed)), + }, + }; + } + + /** + * 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState): + * 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다. + * 표시만 할 때는 조건 없이 한다 (되돌릴 수 있는 표시 — 메일함 전체 읽음과 같다). + * 엔진이 모두 거절하면 — 앞 차례의 진행이 있으면 멈추고(null), 없으면 그 거절의 코드로 던진다 (클라이언트가 끝없이 다시 보내지 않게) + */ + private async applyPatches( + user: string, + plan: RunPlan, + state: string | null, + patches: ReadonlyMap>, + run: BulkRun, + ): Promise { + const gone = new Set(); + let pending = patches; + let ifInState = state; + for (;;) { + // 상태를 모르면 조건 없이 옮기지 않는다 + if (plan.moveTo !== null && ifInState === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'inbox-state-unknown' }, + }); + } + const set = await this.updateEmails( + user, + pending, + plan.moveTo === null ? null : ifInState, + run.callOptions(), + ); + if (set !== null) { + const updated = new Set( + Object.keys(set.updated ?? {}).filter((id) => pending.has(id)), + ); + for (const [id, error] of Object.entries(set.notUpdated ?? {})) { + if (error?.type === NOT_FOUND) gone.add(id); + } + warnRefusals( + this.logger, + user, + set.notUpdated, + '이미 받은 메일에 필터를 적용하며 바꾸지', + ); + const refusal = firstRefusal(set.notUpdated); + if (updated.size === 0 && refusal) { + if (run.processed > 0) return null; + throw jmapFailure(refusal, { method: 'Email/set' }); + } + return { updated, gone }; + } + + if (run.stalled('state-mismatch')) return null; + const fresh = await this.readInboxMembership( + user, + plan.inboxId, + [...pending.keys()], + run.callOptions(), + ); + ifInState = fresh.state; + const still = new Map>(); + for (const [id, patch] of pending) { + if (fresh.inInbox.has(id)) still.set(id, patch); + else gone.add(id); + } + pending = still; + if (pending.size === 0) return { updated: new Set(), gone }; + } + } + + /** + * Email/set 의 고치기만 — 지우기(destroy)는 보내지 않는다. ifInState 가 있으면 어긋났을 때(stateMismatch) null. + * 그 밖의 메서드 오류는 그 코드로 던진다 + */ + private async updateEmails( + user: string, + patches: ReadonlyMap>, + ifInState: string | null, + options: JmapCallOptions, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/set', + { + accountId, + ...(ifInState === null ? {} : { ifInState }), + update: Object.fromEntries(patches), + }, + 'u', + ], + ], + options, + ); + const result = findResult(responses, 'u', 'Email/set'); + if (!result.ok) { + if (ifInState !== null && result.error.type === STATE_MISMATCH) { + return null; + } + throw jmapFailure(result.error, { method: 'Email/set' }); + } + return result.args; + } + + /** 맞은 메일이 아직 받은편지함에 있는지와 그때의 상태 — 상태가 어긋난 뒤 다시 할 때 */ + private async readInboxMembership( + user: string, + inboxId: string, + ids: readonly string[], + options: JmapCallOptions, + ): Promise<{ readonly state: string | null; readonly inInbox: Set }> { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { accountId, ids: [...ids], properties: ['id', 'mailboxIds'] }, + 'g', + ], + ], + options, + ); + const found = expectResult>( + responses, + 'g', + 'Email/get', + ); + return { + state: isState(found.state) ? found.state : null, + inInbox: new Set( + (found.list ?? []) + .filter((email) => email.mailboxIds?.[inboxId] === true) + .map((email) => email.id), + ), + }; + } + + /** 본문 값 — 글자 부분(text/*) 모두, 부분마다 그 몫까지 (fetchAllBodyValues — 텍스트 첨부도 온다) */ + private async readBodies( + user: string, + ids: readonly string[], + quota: number, + options: JmapCallOptions, + ): Promise> { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { + accountId, + ids: [...ids], + properties: FILTER_RUN_BODY_PROPERTIES, + bodyProperties: ['partId', 'type'], + fetchAllBodyValues: true, + maxBodyValueBytes: quota, + }, + 'b', + ], + ], + options, + ); + const { list = [] } = expectResult>( + responses, + 'b', + 'Email/get', + ); + return new Map(list.map((email) => [email.id, email])); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts index 661f035..0573765 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts @@ -317,6 +317,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 toDate: null, subject: '4단계 부재중', textBody: '휴가입니다.', + isActive: true, }); expect(engine.recorded[0]).toEqual({ calls: [ @@ -336,11 +337,12 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 expect(engine.client.downloadBytes).not.toHaveBeenCalled(); }); - it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용', async () => { + it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용 · 동작함(막힌 것이 없다)', async () => { const engine = new FakeSieveEngine(); - await expect(engine.store.getVacation(ARA)).resolves.toEqual( - NEVER_CONFIGURED_VACATION, - ); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...NEVER_CONFIGURED_VACATION, + isActive: true, + }); }); it('VacationResponse 의 null 제목 · 내용은 빈 문자열로 이어받는다 — 처음 저장하면 그 값이 우리 스크립트에 들어간다', async () => { @@ -362,7 +364,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 textBody: '', }; - await expect(engine.store.getVacation(ARA)).resolves.toEqual(imported); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...imported, + isActive: true, + }); await engine.store.setFilters(ARA, { rules: [rule('r1')], version: filterRulesVersion([]), @@ -380,7 +385,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion(rules), isActive: true, }); - await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...vacationOn, + isActive: true, + }); expect(engine.client.downloadBytes).toHaveBeenCalledWith( ARA, ours.blobId, @@ -399,7 +407,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion(rules), isActive: false, }); - await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn); + // 부재중 응답도 같은 isActive 를 알린다 — 켜 둔 값(isEnabled)은 저장한 그대로다 (7단계) + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...vacationOn, + isActive: false, + }); }); it('원본이 깨졌으면 규칙 없음 · 부재중 꺼짐 · isActive false — 켜져 있어도, 글이 너무 커도', async () => { @@ -421,9 +433,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion([]), isActive: false, }); - await expect(engine.store.getVacation(ARA)).resolves.toEqual( - NEVER_CONFIGURED_VACATION, - ); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...NEVER_CONFIGURED_VACATION, + isActive: false, + }); } }); @@ -456,9 +469,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion([]), isActive: false, }); - await expect(engine.store.getVacation(ARA)).resolves.toEqual( - NEVER_CONFIGURED_VACATION, - ); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...NEVER_CONFIGURED_VACATION, + isActive: false, + }); }); it('필터 저장 · 부재중 저장 모두 쓰지 않는다 — 503 MAIL_001(filter-source-newer), 글은 그대로 남는다', async () => { @@ -932,13 +946,29 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION }); const before = await engine.store.getFilters(ARA); - await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual( - vacationOn, - ); + await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({ + ...vacationOn, + isActive: true, + }); expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn }); await expect(engine.store.getFilters(ARA)).resolves.toEqual(before); }); + it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => { + const engine = new FakeSieveEngine(); + engine.addOurs({ rules: [rule('r1')], vacation: vacationOn }); + engine.addScript('vacation', 'require ["vacation"];\r\n', true); + await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({ + isActive: false, + }); + + await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({ + ...vacationOn, + isActive: true, + }); + expect(engine.ours()?.isActive).toBe(true); + }); + it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => { const engine = new FakeSieveEngine(); engine.addScript('vacation', 'require ["vacation"];\r\n', true); diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts index e17c151..22a8391 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts @@ -5,6 +5,7 @@ import type { MailFilterSet, MailFilterUpdate, MailVacation, + MailVacationState, } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; @@ -135,6 +136,11 @@ function toFilterSet(view: ScriptView): MailFilterSet { }; } +/** 부재중 자동 응답의 응답 — 저장한 값 + 필터와 같은 isActive (계약 2절 "부재중 자동 응답"의 MailVacationState) */ +function toVacationState(view: ScriptView): MailVacationState { + return { ...view.source.vacation, isActive: view.isActive }; +} + /** * 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리). * Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면 @@ -183,21 +189,24 @@ export class JmapFilterScriptStore { return toFilterSet(saved); } - /** 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태 */ - async getVacation(user: string): Promise { - return (await this.snapshot(user)).source.vacation; + /** + * 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태. + * 동작하는지(isActive)는 필터의 것과 같은 값이다 — 같은 스크립트 하나를 읽어 안다 (7단계) + */ + async getVacation(user: string): Promise { + return toVacationState(await this.snapshot(user)); } - /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */ + /** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */ async setVacation( user: string, vacation: MailVacation, - ): Promise { + ): Promise { const saved = await this.write(user, (current) => ({ rules: current.rules, vacation, })); - return saved.source.vacation; + return toVacationState(saved); } /** diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 9d523ba..0fa6fa9 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -25,6 +25,7 @@ import { } from './jmap.client'; import type { JmapClient, JmapSession } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch'; import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; @@ -176,6 +177,7 @@ function fakeClient( cache, overrides.settings ?? settings, new JmapFilterScriptStore(client as unknown as JmapClient), + new JmapFilterRunner(client as unknown as JmapClient), ), }; } @@ -5569,7 +5571,11 @@ describe('JmapMailProvider', () => { it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => { const { provider, recorded } = fakeClient([noScript]); - await expect(provider.getVacation(ARA)).resolves.toEqual(vacation); + // 저장한 적이 없으면 막힌 것이 없다 — isActive: true (7단계) + await expect(provider.getVacation(ARA)).resolves.toEqual({ + ...vacation, + isActive: true, + }); expect(recorded[0].calls.map(([method]) => method)).toEqual([ 'SieveScript/get', 'VacationResponse/get', @@ -5601,7 +5607,10 @@ describe('JmapMailProvider', () => { }); const off = { ...vacation, isEnabled: false }; - await expect(provider.setVacation(ARA, off)).resolves.toEqual(off); + await expect(provider.setVacation(ARA, off)).resolves.toEqual({ + ...off, + isActive: true, + }); expect((client.upload.mock.calls[0] as unknown[])[2]).toBe( 'application/sieve', ); diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 31ad39c..bec65c3 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -23,12 +23,16 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailFilterRunContext, + MailFilterRunOutcome, + MailFilterRunRequest, MailFilterSet, MailFilterUpdate, MailLiveState, MailState, MailUsage, MailVacation, + MailVacationState, MailWatch, MailWatchListener, MessageDeletionResult, @@ -69,6 +73,7 @@ import { type DeliveryResult, } from './delivery-status'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { JmapMailWatch, @@ -79,15 +84,17 @@ import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push'; import { expectResult, findResult, + firstRefusal, firstSetError, isState, + warnRefusals, } from './jmap-results'; import { describeFailure, isMissingAttachmentBlob, jmapFailure, - mapJmapErrorType, } from './jmap.errors'; +import { BulkRun, CONDITIONAL_DESTROY_ATTEMPTS } from './bulk-run'; import { BODY_PART_PROPERTIES, DETAIL_PROPERTIES, @@ -172,14 +179,6 @@ const DRAFT_DETAIL_PROPERTIES = [ const SENT_ADDRESS_PROPERTIES = ['to', 'cc', 'bcc', 'receivedAt']; const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt']; -/** - * 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐. - * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다. - * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 — - * 그래도 이 횟수 안에 맞지 않으면 지우지 않는다. - */ -const CONDITIONAL_DESTROY_ATTEMPTS = 3; - /** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */ const STATE_MISMATCH = 'stateMismatch'; @@ -198,13 +197,9 @@ const PARENT_ALLOWED_ROLES: ReadonlySet = new Set(['inbox', 'archive']); const EMPTYABLE_ROLES: ReadonlySet = new Set(['trash', 'junk']); /** - * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기) 한 요청의 시간 (계약 2절 "메일함 관리") — 실제 상한이다 (BulkRun). - * 이 안에서 끝내고 남은 수(remaining)를 돌려준다. 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다. + * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기 — 계약 2절 "메일함 관리")가 한 차례에 다루는 메일 수 — + * 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500). 한 요청의 시간 · 멈춘 차례는 BulkRun (bulk-run.ts) */ -const BULK_REQUEST_BUDGET_MS = 25_000; -/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두 번)가 끝까지 시간 안에 들게 */ -const BULK_ROUND_RESERVE_MS = 5_000; -/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */ const BULK_BATCH_SIZE = 500; /** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */ @@ -455,74 +450,6 @@ function mailboxSetFailure( return jmapFailure(error, { method }); } -/** - * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기)의 한 요청 — 시간 · 진행 · 멈춘 차례 (계약 2절 "메일함 관리"). - * - 시간: 메일함 목록을 읽기 **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다 - * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 — - * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게 - * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 - * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다 - * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 - */ -class BulkRun { - private readonly deadline = Date.now() + BULK_REQUEST_BUDGET_MS; - private stalls = 0; - private lastStall: string | null = null; - /** 이번 요청에서 처리한 메일 수 */ - processed = 0; - - /** 다음 차례를 시작해도 되는가 */ - canStartRound(): boolean { - return this.left() > BULK_ROUND_RESERVE_MS; - } - - /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */ - callOptions(): JmapCallOptions { - return { - timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())), - }; - } - - /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */ - progress(count: number): void { - if (count <= 0) return; - this.processed += count; - this.stalls = 0; - } - - /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */ - stalled(why: string): boolean { - this.stalls += 1; - this.lastStall = why; - return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS; - } - - /** - * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다. - * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로) - */ - cutOffAfterProgress(error: unknown): boolean { - return ( - this.processed > 0 && - this.left() <= 0 && - error instanceof BusinessException && - error.code === 'MAIL_001' - ); - } - - /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */ - assertProgress(operation: string): void { - if (this.processed > 0) return; - throw new BusinessException('MAIL_001', { - context: { reason: `${operation}-no-progress`, stalled: this.lastStall }, - }); - } - - private left(): number { - return this.deadline - Date.now(); - } -} - /** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */ function queryTotal(result: JmapQueryResult, received: number): number { if (typeof result.total === 'number' && result.total >= received) { @@ -531,15 +458,6 @@ function queryTotal(result: JmapQueryResult, received: number): number { return received === BULK_BATCH_SIZE ? received + 1 : received; } -/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */ -function firstRefusal( - ...errors: (Record | undefined)[] -): JmapError | undefined { - return errors - .flatMap((record) => Object.values(record ?? {})) - .find((error) => error?.type !== NOT_FOUND); -} - /** * 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명). * 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다. @@ -727,6 +645,8 @@ export class JmapMailProvider implements MailProvider { @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, /** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */ private readonly filterScripts: JmapFilterScriptStore, + /** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */ + private readonly filterRuns: JmapFilterRunner, ) {} verifyCredentials(email: string, password: string): Promise { @@ -1166,12 +1086,15 @@ export class JmapMailProvider implements MailProvider { * 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다 * (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts) */ - getVacation(user: string): Promise { + getVacation(user: string): Promise { return this.filterScripts.getVacation(user); } /** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */ - setVacation(user: string, vacation: MailVacation): Promise { + setVacation( + user: string, + vacation: MailVacation, + ): Promise { return this.filterScripts.setVacation(user, vacation); } @@ -1188,6 +1111,15 @@ export class JmapMailProvider implements MailProvider { return this.filterScripts.setFilters(user, update); } + /** 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 차례 · 판정 · 이어 할 자리는 JmapFilterRunner (계약 2절 "이미 받은 메일에 적용") */ + runMailFilter( + user: string, + request: MailFilterRunRequest, + context: MailFilterRunContext, + ): Promise { + return this.filterRuns.run(user, request, context); + } + async getUsage(user: string): Promise { const responses = await this.jmap.call( user, @@ -2896,27 +2828,13 @@ export class JmapMailProvider implements MailProvider { ); } - /** - * 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다). - * 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다. - * @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지' - */ + /** 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 (jmap-results.ts 의 warnRefusals) */ private warnRefusals( user: string, errors: Record | undefined, doing: string, ): void { - const counts = new Map(); - for (const error of Object.values(errors ?? {})) { - const type = error?.type ?? 'unknown'; - if (type === NOT_FOUND) continue; - counts.set(type, (counts.get(type) ?? 0) + 1); - } - for (const [type, count] of counts) { - this.logger.warn( - `[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`, - ); - } + warnRefusals(this.logger, user, errors, doing); } /** diff --git a/backend/src/modules/mail/jmap/jmap-results.ts b/backend/src/modules/mail/jmap/jmap-results.ts index 72ea2f0..b9eb042 100644 --- a/backend/src/modules/mail/jmap/jmap-results.ts +++ b/backend/src/modules/mail/jmap/jmap-results.ts @@ -1,12 +1,19 @@ +import type { Logger } from '@nestjs/common'; + import { BusinessException } from '@/common/errors/business.exception'; -import { jmapFailure } from './jmap.errors'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { jmapFailure, mapJmapErrorType } from './jmap.errors'; import type { JmapError, JmapInvocation } from './jmap.types'; /** - * JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts)와 엔진 규칙 저장(jmap-filter-script.ts)이 같이 쓴다. + * JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts) · 엔진 규칙 저장(jmap-filter-script.ts) · 필터 적용(jmap-filter-run.ts)이 같이 쓴다. * 이 모듈의 타입 · 함수는 `modules/mail/jmap/` 밖으로 내보내지 않는다. */ +/** SetError — 그 대상이 없다 (그사이 지워짐) */ +const NOT_FOUND = 'notFound'; + export type MethodResult = | { ok: true; args: Record } | { ok: false; error: JmapError }; @@ -49,3 +56,36 @@ export function firstSetError( export function isState(value: unknown): value is string { return typeof value === 'string' && value !== ''; } + +/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */ +export function firstRefusal( + ...errors: (Record | undefined)[] +): JmapError | undefined { + return errors + .flatMap((record) => Object.values(record ?? {})) + .find((error) => error?.type !== NOT_FOUND); +} + +/** + * 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다). + * 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다. + * @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지' + */ +export function warnRefusals( + logger: Logger, + user: string, + errors: Record | undefined, + doing: string, +): void { + const counts = new Map(); + for (const error of Object.values(errors ?? {})) { + const type = error?.type ?? 'unknown'; + if (type === NOT_FOUND) continue; + counts.set(type, (counts.get(type) ?? 0) + 1); + } + for (const [type, count] of counts) { + logger.warn( + `[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`, + ); + } +} diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index a114860..0768ed0 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -189,6 +189,8 @@ export interface JmapEmailFilterCondition { /** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */ inMailboxOtherThan?: string[]; notKeyword?: string; + /** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */ + before?: string; /** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */ text?: string; } diff --git a/backend/src/modules/mail/jmap/mime-text.spec.ts b/backend/src/modules/mail/jmap/mime-text.spec.ts index 61c0ddb..a56054d 100644 --- a/backend/src/modules/mail/jmap/mime-text.spec.ts +++ b/backend/src/modules/mail/jmap/mime-text.spec.ts @@ -1,5 +1,7 @@ +import { startCpuTimer } from '@/../test/cpu-time'; import { base64TextBody, + decodeHeaderText, encodeHeaderWords, plainTextMimeEntity, } from './mime-text'; @@ -94,3 +96,205 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => { ); }); }); + +/** + * 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정). + * 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값) + */ +describe('decodeHeaderText — 원래 머리글을 푼다 (RFC 5322 접힌 줄 · RFC 2047)', () => { + it('앞뒤 공백을 빼고 따옴표 · 꺾쇠 · 주소는 그대로 둔다', () => { + expect(decodeHeaderText(' "Kim, Ara" ')).toBe( + '"Kim, Ara" ', + ); + expect(decodeHeaderText(' ')).toBe( + '', + ); + }); + + it('UTF-8 B 로 인코딩된 표시 이름 · 제목을 푼다 (실배달 메일의 From · Cc · Subject)', () => { + expect( + decodeHeaderText(' =?UTF-8?B?6rmA7JWE6528?= '), + ).toBe('김아라 '); + expect( + decodeHeaderText(' =?UTF-8?B?7LC47KGw7J24?= '), + ).toBe('참조인 '); + expect( + decodeHeaderText( + ' =?UTF-8?B?SDQg7KO86rCEIO2ajOydmOulvCBwN3NtdnM4eHFmdg==?=', + ), + ).toBe('H4 주간 회의를 p7smvs8xqfv'); + }); + + it('EUC-KR 은 그 문자셋으로 푼다 — ks_c_5601-1987 · cp949 이름도 같다 (실배달 메일의 EUC-KR 제목)', () => { + const encoded = 'SDUgsN/A+7ytIL/kw7sgcDdzbXZzOHhxZnY='; + for (const charset of ['euc-kr', 'EUC-KR', 'ks_c_5601-1987', 'cp949']) { + expect(decodeHeaderText(` =?${charset}?B?${encoded}?=`)).toBe( + 'H5 견적서 요청 p7smvs8xqfv', + ); + } + }); + + it('Q 인코딩 — _ 는 공백, =XX 는 그 바이트 (대소문자 무시)', () => { + expect(decodeHeaderText('=?utf-8?q?Kim_Ara=2C_=ED=95=9C?=')).toBe( + 'Kim Ara, 한', + ); + expect(decodeHeaderText('=?ISO-8859-1?Q?J=F6rg?= ')).toBe( + 'Jörg ', + ); + }); + + it('접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다 — 접지 않은 탭 · 이어진 공백은 그대로 (엔진 판정 H3 · H4 · G1 ~ G3)', () => { + expect( + decodeHeaderText( + ' a1@example.com,\r\n a2@example.com,\r\n\t', + ), + ).toBe('a1@example.com, a2@example.com, '); + expect( + decodeHeaderText( + ' "가나" ,\r\n "다라" ', + ), + ).toBe('"가나" , "다라" '); + expect(decodeHeaderText(' a,\n \t b')).toBe('a, b'); + expect(decodeHeaderText(' "가나"\t')).toBe( + '"가나"\t', + ); + expect(decodeHeaderText(' "가나" ')).toBe( + '"가나" ', + ); + }); + + it('인코딩된 글은 홀로 선 것만 푼다 — 앞이 처음 · 공백 · " · , 이고 뒤가 끝 · 공백 · " 일 때, 앞뒤가 다르다 (엔진 판정 H1 · H5 · H7 · G4 ~ G6 · G8 ~ G10)', () => { + const name = '=?UTF-8?B?6rmA7JWE6528?='; // 김아라 + const title = '=?UTF-8?B?67aA7J6l?='; // 부장 + expect(decodeHeaderText(` "${name}" `)).toBe( + '"김아라" ', + ); + expect(decodeHeaderText(` "${name} 부장" `)).toBe( + '"김아라 부장" ', + ); + expect(decodeHeaderText(` ,${name} `)).toBe( + ',김아라 ', + ); + expect(decodeHeaderText(` a@example.com ( ${title} )`)).toBe( + 'a@example.com ( 부장 )', + ); + // 글자 · ( 뒤, 글자 · < · , 앞이면 풀지 않는다 — 앞의 , 는 풀고(위) 뒤의 , 는 풀지 않는다(G10) + for (const raw of [ + `abc${name} `, + `${name}abc `, + `a@example.com (${title})`, + `${name}`, + `, ${name},"가나" `, + ]) { + expect(decodeHeaderText(` ${raw}`)).toBe(raw); + } + }); + + it('공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(RFC 2047 §6.2) — 글이 사이에 있으면 공백도 남긴다 (엔진 판정 H8 · H9)', () => { + expect(decodeHeaderText('=?UTF-8?B?7ZWc?= \r\n =?UTF-8?B?6riA?=')).toBe( + '한글', + ); + expect( + decodeHeaderText( + ' =?UTF-8?B?6rmA?=\r\n =?UTF-8?B?7JWE6528?= ', + ), + ).toBe('김아라 '); + expect(decodeHeaderText('=?UTF-8?B?7ZWc?= 그리고 =?UTF-8?B?6riA?=')).toBe( + '한 그리고 글', + ); + // 사이 없이 붙은 둘은 홀로 서지 않았다 — 풀지 않는다 + expect(decodeHeaderText('=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=')).toBe( + '=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=', + ); + // 풀지 않은 낱말이 사이에 있으면 이어 붙이지 않는다 (그 앞뒤 공백도 그대로) + expect( + decodeHeaderText('=?UTF-8?B?7ZWc?= a=?UTF-8?B?6riA?= =?UTF-8?B?6riA?='), + ).toBe('한 a=?UTF-8?B?6riA?= 글'); + }); + + it('한 글자를 두 encoded-word 에 나눈 메일도 읽는다 — 같은 문자셋이 붙어 있으면 바이트를 이어 푼다', () => { + // '한'(ED 95 9C) 을 ED · 95 9C 로 나눴다 + expect(decodeHeaderText('=?UTF-8?B?7Q==?= =?UTF-8?B?lZw=?=')).toBe('한'); + // 문자셋이 다르면 따로 푼다 + expect(decodeHeaderText('=?UTF-8?B?7ZWc?= =?euc-kr?B?sN8=?=')).toBe('한견'); + }); + + it('RFC 2231 의 언어 표시(문자셋*언어)는 떼고 푼다', () => { + expect(decodeHeaderText('=?UTF-8*ko?B?7ZWc6riA?=')).toBe('한글'); + }); + + it('모르는 문자셋 · 모양이 틀린 encoded-word 는 풀지 않고 그대로 둔다', () => { + expect(decodeHeaderText('=?x-unknown?B?7ZWc?= ')).toBe( + '=?x-unknown?B?7ZWc?= ', + ); + expect(decodeHeaderText('=?UTF-8?X?abc?= =?UTF-8?B?7ZWc')).toBe( + '=?UTF-8?X?abc?= =?UTF-8?B?7ZWc', + ); + // 모르는 문자셋 뒤의 낱말과는 공백을 버리지 않는다 + expect(decodeHeaderText('=?x-unknown?B?eA==?= =?UTF-8?B?7ZWc?=')).toBe( + '=?x-unknown?B?eA==?= 한', + ); + }); + + it('처음 보는 문자셋 이름은 한 머리글에서 16개까지만 찾아본다 — 그 뒤의 처음 보는 이름은 모르는 문자셋, 이미 아는 이름은 그대로 푼다', () => { + // 모르는 이름을 찾아보는 일은 한 번에 10µs 남짓(TextDecoder 가 던진다) — 이름이 모두 다른 낱말을 잔뜩 넣은 머리글이 서버를 붙잡지 않게 + const unknown = (prefix: string, count: number) => + Array.from( + { length: count }, + (_, index) => `=?${prefix}${index}?B?eA==?=`, + ).join(' '); + // 16개를 다 쓴 뒤의 windows-1250(이 테스트에서 처음 쓰는 이름)은 찾아보지 않는다 + expect( + decodeHeaderText(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`), + ).toBe(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`); + // 15개 뒤라면 찾아보고 푼다 — 그 뒤로는 기억해 두어 찾아보지 않고 푼다 + expect( + decodeHeaderText(`${unknown('budget-b', 15)} =?windows-1250?B?eA==?=`), + ).toBe(`${unknown('budget-b', 15)} x`); + expect( + decodeHeaderText(`${unknown('budget-c', 16)} =?windows-1250?B?eA==?=`), + ).toBe(`${unknown('budget-c', 16)} x`); + // 이미 아는(앞에서 푼) UTF-8 도 그대로 + expect(decodeHeaderText('=?UTF-8?B?7ZWc?=')).toBe('한'); + expect( + decodeHeaderText(`${unknown('budget-d', 20)} =?UTF-8?B?7ZWc?=`), + ).toBe(`${unknown('budget-d', 20)} 한`); + }); + + it('찾은 문자셋만 기억한다 — 모르는 이름은 다음 머리글에서 다시 찾아보고(그 머리글의 16개 안에 센다), 기억 칸을 차지하지 않는다', () => { + const unknown = (prefix: string, count: number) => + Array.from( + { length: count }, + (_, index) => `=?${prefix}${index}?B?eA==?=`, + ).join(' '); + // 모르는 이름을 기억했다면 다음 머리글에서는 찾아보지 않아 16개가 남는다 — 기억하지 않으므로 다시 16개를 쓴다 + decodeHeaderText(unknown('forget', 16)); + expect( + decodeHeaderText(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`), + ).toBe(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`); + // 모르는 이름 128개를 찾아본 뒤에도 처음 보는 아는 이름은 기억한다 — 16개를 쓴 머리글에서도 찾아보지 않고 푼다 + for (let round = 0; round < 8; round += 1) { + decodeHeaderText(unknown(`fill-${round}-`, 16)); + } + expect(decodeHeaderText('=?iso-8859-2?B?eA==?=')).toBe('x'); + expect( + decodeHeaderText(`${unknown('fill-x', 16)} =?iso-8859-2?B?eA==?=`), + ).toBe(`${unknown('fill-x', 16)} x`); + }); + + it('입력 길이에 비례한다 (CPU 시간) — 닫히지 않은 encoded-word · =? 만 · 모르는 문자셋 · 이름이 모두 다른 문자셋이 이어진 1MB', () => { + // 한도는 넉넉히 — 혼자 돌면 다섯 개 합계 CPU 0.25초 안쪽이다(무거운 테스트 파일 다음이면 GC 가 끼어든다 — html-text.spec.ts). + // 막으려는 것은 역추적하는 정규식 · 낱말마다 문자셋을 찾다 던지는 경로(10µs 남짓 × 낱말 수)다 + const cpuMs = startCpuTimer(); + decodeHeaderText(`=?UTF-8?B?${'A'.repeat(1_000_000)}`); + decodeHeaderText('=?'.repeat(500_000)); + decodeHeaderText('=?a?B?b?= '.repeat(100_000)); + decodeHeaderText( + Array.from({ length: 70_000 }, (_, index) => `=?x${index}?B?eA==?=`).join( + ' ', + ), + ); + decodeHeaderText('=?UTF-8?B?7ZWc?= '.repeat(60_000)); + expect(cpuMs()).toBeLessThan(10_000); + }); +}); diff --git a/backend/src/modules/mail/jmap/mime-text.ts b/backend/src/modules/mail/jmap/mime-text.ts index 488e543..0a1b164 100644 --- a/backend/src/modules/mail/jmap/mime-text.ts +++ b/backend/src/modules/mail/jmap/mime-text.ts @@ -1,7 +1,9 @@ /** - * 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때의 글 모양 (순수 함수) — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문. - * 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다 - * (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인). + * 메일 머리글 · 본문의 글 모양 (순수 함수). + * - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문. + * 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다 + * (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인). + * - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts) */ const CRLF = '\r\n'; @@ -81,3 +83,178 @@ export function plainTextMimeEntity(text: string): string { '', ].join(CRLF); } + +/** + * encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다 + * (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다 + */ +const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g; +/** + * 접힌 줄 — 줄바꿈과 그 뒤의 공백 · 탭 전부를 띄어쓰기 하나로 바꾼다. 엔진(Sieve 의 `header` 검사)이 그렇게 본다(2026-10-29 확인 — 탭으로 접은 줄 · + * 공백 넷으로 접은 줄 모두 띄어쓰기 하나). 접지 않은 탭 · 이어진 공백은 그대로 둔다(엔진도 그대로 견준다) + */ +const FOLDED_LINE_BREAK = /\r?\n[ \t]+/g; +/** encoded-word 사이에 공백만 있다 — 그 공백은 버린다 (RFC 2047 §6.2) */ +const LINEAR_SPACE_ONLY = /^[ \t]*$/; +/** Q 인코딩의 `=XX` */ +const Q_ESCAPE = /^[0-9A-Fa-f]{2}$/; +const Q_UNDERSCORE = 0x5f; +const Q_SPACE = 0x20; +const Q_ESCAPE_MARK = 0x3d; +const ASCII_LIMIT = 0x80; + +/** + * 웹 표준(WHATWG Encoding)의 이름표에 없는 한국어 문자셋 이름 — TextDecoder 가 모르면 이것으로 다시 찾는다. + * (ks_c_5601-1987 · windows-949 · euc-kr 은 이름표에 있다 — 모두 euc-kr(UHC 포함)로 읽는다) + */ +const CHARSET_ALIASES: ReadonlyMap = new Map([ + ['cp949', 'euc-kr'], + ['ms949', 'euc-kr'], + ['uhc', 'euc-kr'], + ['x-windows-949', 'euc-kr'], +]); + +/** + * 문자셋 이름 → 디코더 — 메일에 쓰이는 문자셋은 몇 되지 않아 이만큼만 기억한다(넘으면 더 기억하지 않는다). + * 찾은 것만 기억한다 — 모르는 이름(null)까지 기억하면 바깥에서 온 메일의 아무 이름이나 자리를 차지해 진짜 문자셋을 밀어낸다. + * 디코더는 스트림 없이(decode 한 번에) 쓰므로 함께 써도 된다 + */ +const DECODER_CACHE_LIMIT = 64; +const decoderCache = new Map(); +/** + * 한 머리글에서 기억에 없는 문자셋 이름을 찾아보는 횟수 — 넘으면 모르는 문자셋으로 본다. + * 모르는 이름을 찾아보는 일(TextDecoder 가 던진다)은 한 번에 10µs 남짓이라, 이름이 다른 encoded-word 를 잔뜩 넣은 머리글이 + * 서버를 붙잡지 않게 (한 머리글에 문자셋 이름은 많아야 두셋이다). 한 머리글 안에서는 찾아본 이름을 다시 찾지 않는다(모르는 이름 포함) + */ +const MAX_NEW_CHARSETS_PER_HEADER = 16; + +/** + * 인코딩된 글이 홀로 섰는지 보는 앞 글자 · 뒤 글자 — 처음 · 끝(NaN)도 경계다. 앞뒤가 다르다(엔진 판정 G6 · G10, 2026-10-29): + * 앞은 공백 · 탭 · CR · LF · `"` · `,`, 뒤는 공백 · 탭 · CR · LF · `"` — 뒤에 `,` 가 붙은 인코딩된 글은 엔진이 풀지 않는다 + */ +const WORD_START_BOUNDARY_CODES: ReadonlySet = new Set([ + 0x20, 0x09, 0x0d, 0x0a, 0x22, 0x2c, +]); +const WORD_END_BOUNDARY_CODES: ReadonlySet = new Set([ + 0x20, 0x09, 0x0d, 0x0a, 0x22, +]); + +/** 이름표(WHATWG) · 한국어 이름으로 디코더를 만든다 — 모르면 null */ +function createDecoder(charset: string): TextDecoder | null { + for (const name of [charset, CHARSET_ALIASES.get(charset)]) { + if (name === undefined) continue; + try { + return new TextDecoder(name); + } catch { + // 이름표에 없는 이름 — 다음 이름으로 + } + } + return null; +} + +/** + * 그 문자셋의 디코더 — 모르는 문자셋이면 null (그 낱말은 풀지 않고 그대로 둔다). + * looked 는 이 머리글에서 찾아본 이름(모르는 이름은 null) — MAX_NEW_CHARSETS_PER_HEADER 개까지 찾아본다 + */ +function decoderFor( + label: string, + looked: Map, +): TextDecoder | null { + const charset = label.split('*')[0].trim().toLowerCase(); + const cached = decoderCache.get(charset) ?? looked.get(charset); + if (cached !== undefined) return cached; + if (looked.size >= MAX_NEW_CHARSETS_PER_HEADER) return null; + const decoder = createDecoder(charset); + looked.set(charset, decoder); + if (decoder !== null && decoderCache.size < DECODER_CACHE_LIMIT) { + decoderCache.set(charset, decoder); + } + return decoder; +} + +/** 그 글자가 인코딩된 글의 경계인가 — 처음 · 끝은 NaN (경계다) */ +function isWordBoundary( + code: number, + boundaries: ReadonlySet, +): boolean { + return Number.isNaN(code) || boundaries.has(code); +} + +/** encoded-word 의 글 칸 → 바이트 (B: base64 · Q: `_` 는 공백, `=XX` 는 그 바이트) */ +function encodedWordBytes(encoding: string, text: string): Buffer { + if (encoding === 'B' || encoding === 'b') return Buffer.from(text, 'base64'); + const bytes: number[] = []; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + const escape = text.slice(index + 1, index + 3); + if (code === Q_UNDERSCORE) { + bytes.push(Q_SPACE); + } else if (code === Q_ESCAPE_MARK && Q_ESCAPE.test(escape)) { + bytes.push(Number.parseInt(escape, 16)); + index += 2; + } else if (code < ASCII_LIMIT) { + bytes.push(code); + } else { + // Q 글 칸에 올 수 없는 글자 — 그 글자의 UTF-8 바이트로 (깨진 메일도 읽을 만큼은 읽는다) + bytes.push(...Buffer.from(text[index], 'utf8')); + } + } + return Buffer.from(bytes); +} + +/** 공백만 두고 이어진 같은 문자셋의 encoded-word 들 — 바이트를 이어 붙여 한 번에 푼다 (한 글자를 두 낱말에 나눈 메일도 읽게) */ +interface EncodedRun { + readonly decoder: TextDecoder; + readonly bytes: Buffer[]; +} + +/** + * 원래 머리글(엔진이 주는 그대로 — 앞의 공백 · 접힌 줄 · encoded-word)을 엔진의 Sieve(`header :contains`)가 견주는 글로 푼다 — + * 2026-10-29 인증 발송 19통으로 엔진과 같은 판정을 확인한 규칙 (filter-run-texts.spec.ts 의 H · G 표): + * 1. 접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다. 접지 않은 탭 · 이어진 공백은 그대로 둔다 + * 2. 인코딩된 글은 **홀로 선 것만** 그 문자셋으로 푼다 — 앞이 처음 · 공백 · 탭 · CR · LF · `"` · `,` 이고 뒤가 끝 · 공백 · 탭 · CR · LF · `"` 일 때 + * (앞뒤가 다르다 — `,=?…?= ` 는 풀고 `=?…?=,"가나"` 는 풀지 않는다). + * 글자 · `(` 뒤(`abc=?…?=` · `(=?…?=)`), 글자 · `<` · `,` 앞(`=?…?=abc` · `=?…?=` · `=?…?=,`)이면 풀지 않고 그대로 둔다. + * 공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(같은 문자셋이면 바이트를 이어 푼다) + * 3. 앞뒤 공백을 뺀다. 따옴표 · 꺾쇠 · 주소는 그대로 둔다 (`"Kim, Ara" ` — 계약 2절 "이미 받은 메일에 적용"의 판정) + * 모르는 문자셋의 낱말 · 모양이 틀린 낱말도 풀지 않고 그대로 둔다 + */ +export function decodeHeaderText(raw: string): string { + const unfolded = raw.replace(FOLDED_LINE_BREAK, ' '); + const looked = new Map(); + let decoded = ''; + let copied = 0; + let run: EncodedRun | null = null; + for (const match of unfolded.matchAll(ENCODED_WORD)) { + const [word, charset, encoding, text] = match; + const between = unfolded.slice(copied, match.index); + copied = match.index + word.length; + const standalone = + isWordBoundary( + unfolded.charCodeAt(match.index - 1), + WORD_START_BOUNDARY_CODES, + ) && + isWordBoundary( + unfolded.charCodeAt(match.index + word.length), + WORD_END_BOUNDARY_CODES, + ); + const decoder = standalone ? decoderFor(charset, looked) : null; + if (decoder === null) { + if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes)); + run = null; + decoded += between + word; + continue; + } + const bytes = encodedWordBytes(encoding, text); + const adjacent = run !== null && LINEAR_SPACE_ONLY.test(between); + if (run !== null && adjacent && run.decoder.encoding === decoder.encoding) { + run.bytes.push(bytes); + continue; + } + if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes)); + if (!adjacent) decoded += between; + run = { decoder, bytes: [bytes] }; + } + if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes)); + return (decoded + unfolded.slice(copied)).trim(); +} diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts index 684909b..890c9b9 100644 --- a/backend/src/modules/mail/jmap/sieve-script.ts +++ b/backend/src/modules/mail/jmap/sieve-script.ts @@ -32,6 +32,9 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text'; * - 대소문자는 ASCII 만 무시한다 (엔진 기본 비교) * - 글은 모두 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다. `${` 는 받지 않는다(요청 검증) — 엔진이 늘 변수로 펼친다 * - 줄바꿈은 CRLF. 규칙 글이 없으면(켠 규칙도 부재중도 없음) 머리 주석만 남는다 + * + * 이미 받은 메일에 적용할 때(7단계)는 서버가 같은 판정을 한다 — **FIELD_TESTS 는 mail-filter-match.ts 의 판정과 짝이다** + * (견줄 곳 · 대소문자 · 포함하지 않음의 뜻 · 묶는 방법). 한쪽을 바꾸면 다른 쪽(과 견줄 글 뽑기 filter-run-texts.ts)도 맞춘다 */ /** @@ -69,7 +72,10 @@ const INDENT = ' '; const DESCRIPTION_LINE = '# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.'; -/** 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인) */ +/** + * 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인). + * 이미 받은 메일의 판정(mail-filter-match.ts · filter-run-texts.ts)과 짝이다 — 여기를 바꾸면 그쪽도 맞춘다 + */ const FIELD_TESTS: Record string> = { from: (value) => `header :contains "from" ${value}`, to: (value) => `header :contains ["to", "cc"] ${value}`, diff --git a/backend/src/modules/mail/mail-filter-match.spec.ts b/backend/src/modules/mail/mail-filter-match.spec.ts new file mode 100644 index 0000000..53c50e0 --- /dev/null +++ b/backend/src/modules/mail/mail-filter-match.spec.ts @@ -0,0 +1,149 @@ +import { + asciiLowerCase, + filterFields, + judgeFilter, + type MailFilterTexts, +} from './mail-filter-match'; +import type { + MailFilterCondition, + MailFilterCriteria, + MailFilterField, +} from './mail.types'; + +/** 견줄 글 — 주지 않은 곳은 빈 목록, 본문은 읽은 것으로(빈 목록) */ +const texts = (overrides: Partial = {}): MailFilterTexts => ({ + from: [], + to: [], + subject: [], + body: [], + ...overrides, +}); + +const has = (field: MailFilterField, value: string): MailFilterCondition => ({ + field, + operator: 'contains', + value, +}); +const lacks = (field: MailFilterField, value: string): MailFilterCondition => ({ + field, + operator: 'notContains', + value, +}); + +const all = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({ + match: 'all', + conditions, +}); +const any = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({ + match: 'any', + conditions, +}); + +describe('judgeFilter — 엔진의 Sieve 와 같은 판정 (계약 2절 "이미 받은 메일에 적용")', () => { + const mail = texts({ + from: ['"Kim, Ara" '], + to: ['seojun.park@arasystem.test', '참조인 '], + subject: ['[견적] 10월 견적서 송부'], + body: ['안녕하세요.\n견적서를 보냅니다.', '첨부 메모'], + }); + + it('모든 메일(always)은 조건 없이 맞는다 — 견줄 글이 비어 있어도, 본문을 읽지 않았어도', () => { + const always: MailFilterCriteria = { match: 'always', conditions: [] }; + expect(judgeFilter(always, texts())).toBe(true); + expect(judgeFilter(always, texts({ body: null }))).toBe(true); + }); + + it('포함 — 머리글 · 본문 부분 중 하나라도 그 글을 담으면 맞는다 (따옴표 · 꺾쇠까지 원래 글로 견준다)', () => { + expect(judgeFilter(all(has('from', '"Kim')), mail)).toBe(true); + expect(judgeFilter(all(has('from', '@hanbit-partners')), mail)).toBe(true); + expect(judgeFilter(all(has('to', 'doyun.lee@')), mail)).toBe(true); + expect(judgeFilter(all(has('to', '참조인 { + expect(judgeFilter(all(has('from', 'HANBIT-Partners')), mail)).toBe(true); + const unicode = texts({ subject: ['Ärger σΣ abc'] }); + expect(judgeFilter(all(has('subject', 'ärger')), unicode)).toBe(false); + expect(judgeFilter(all(has('subject', 'ÄRGER')), unicode)).toBe(true); + expect(judgeFilter(all(has('subject', 'σσ')), unicode)).toBe(false); + expect(judgeFilter(all(has('subject', 'ABC')), unicode)).toBe(false); + expect(asciiLowerCase('ABC 가나 Ä Σ Z')).toBe('abc 가나 Ä Σ Z'); + }); + + it('포함하지 않음 — 그 글을 담은 머리글 · 부분이 하나도 없으면 맞는다. 머리글 · 본문이 아예 없어도 맞는다 (`not header …`)', () => { + expect(judgeFilter(all(lacks('from', '광고')), mail)).toBe(true); + expect(judgeFilter(all(lacks('to', 'doyun')), mail)).toBe(false); + const bare = texts(); + for (const field of ['from', 'to', 'subject', 'body'] as const) { + expect(judgeFilter(all(lacks(field, 'x')), bare)).toBe(true); + expect(judgeFilter(all(has(field, 'x')), bare)).toBe(false); + } + }); + + it('모두(all) 는 조건이 모두 맞아야, 하나라도(any) 는 하나만 맞으면 된다', () => { + expect( + judgeFilter(all(has('from', 'hanbit'), has('subject', '견적')), mail), + ).toBe(true); + expect( + judgeFilter(all(has('from', 'hanbit'), has('subject', '회의')), mail), + ).toBe(false); + expect( + judgeFilter(any(has('from', '광고'), has('subject', '견적')), mail), + ).toBe(true); + expect( + judgeFilter(any(has('from', '광고'), has('subject', '회의')), mail), + ).toBe(false); + }); + + describe('본문을 아직 읽지 않았을 때 (texts.body 가 null)', () => { + const headersOnly = { ...mail, body: null }; + + it('본문 조건으로 판정이 갈리면 null — 어댑터가 본문을 읽어 다시 판정한다', () => { + expect(judgeFilter(all(has('body', '견적서')), headersOnly)).toBeNull(); + expect( + judgeFilter( + all(has('from', 'hanbit'), has('body', '견적서')), + headersOnly, + ), + ).toBeNull(); + expect( + judgeFilter( + any(has('from', '광고'), lacks('body', '견적서')), + headersOnly, + ), + ).toBeNull(); + }); + + it('머리글로 판정이 나면 본문 없이 끝낸다 — 모두는 하나가 틀리면 false, 하나라도는 하나가 맞으면 true', () => { + expect( + judgeFilter( + all(has('body', '견적서'), has('from', '광고')), + headersOnly, + ), + ).toBe(false); + expect( + judgeFilter( + any(has('body', '견적서'), has('from', 'hanbit')), + headersOnly, + ), + ).toBe(true); + }); + }); + + it('filterFields — 규칙이 견주는 곳만 (모든 메일이면 없음)', () => { + expect([ + ...filterFields( + all(has('from', 'a'), lacks('body', 'b'), has('from', 'c')), + ), + ]).toEqual(['from', 'body']); + expect(filterFields({ match: 'always', conditions: [] }).size).toBe(0); + }); +}); diff --git a/backend/src/modules/mail/mail-filter-match.ts b/backend/src/modules/mail/mail-filter-match.ts new file mode 100644 index 0000000..a77708e --- /dev/null +++ b/backend/src/modules/mail/mail-filter-match.ts @@ -0,0 +1,90 @@ +import type { + MailFilterCondition, + MailFilterCriteria, + MailFilterField, +} from './mail.types'; + +/** + * 메일 필터의 판정 — 메일 한 통의 견줄 글이 규칙의 조건에 맞는가 (계약 2절 "이미 받은 메일에 적용"의 판정 — 7단계). 메일 엔진과 무관한 순수 함수. + * + * 새로 오는 메일은 엔진이 Sieve 로 판정한다 — **jmap/sieve-script.ts 의 FIELD_TESTS 와 짝이다** (한쪽을 바꾸면 다른 쪽도 맞춘다). + * 이미 받은 메일도 같은 판정이 되게 Sieve 의 규칙을 그대로 옮겼다 (2026-10-29 실배달 20통으로 엔진과 같은 판정을 확인 — jmap/filter-run-texts.spec.ts): + * - from → `header :contains "from"`: From 머리글마다 — 접힌 줄을 펴고 RFC 2047 을 풀고 앞뒤 공백을 뺀 원래 글(이름 · 따옴표 · 꺾쇠 · 주소) + * - to → `header :contains ["to", "cc"]`: To · Cc 머리글마다 — 하나라도 맞으면 맞다 + * - subject → `header :contains "subject"`: 풀어 쓴 제목 + * - body → `body :text :contains`: 글자 부분(text/* — 텍스트 첨부 포함)마다. HTML 은 글자만 (html/html-text.ts) + * - 대소문자는 ASCII 만 무시한다 — 엔진 기본 비교(i;ascii-casemap). 한글 · 전각 글자는 그대로 견준다 + * - 포함하지 않음은 `not` — 그 머리글 · 본문이 하나도 없으면 참이다 + * - 모두(all) = allof · 하나라도(any) = anyof · 모든 메일(always) = true + * 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다(계약). 견줄 글을 엔진 메일에서 뽑는 일은 어댑터가 한다 + * (jmap/filter-run-texts.ts) + */ + +/** 메일 한 통의 견줄 글 — 견줄 곳마다 글 목록 (머리글이 여럿이면 여럿) */ +export interface MailFilterTexts { + readonly from: readonly string[]; + readonly to: readonly string[]; + /** 제목 — 없으면 빈 배열 */ + readonly subject: readonly string[]; + /** 본문의 글자 부분마다 — null 이면 아직 읽지 않았다(본문 조건의 판정을 미룬다 — judgeFilter 가 null) */ + readonly body: readonly string[] | null; +} + +/** ASCII 대문자만 소문자로 — 엔진의 i;ascii-casemap 과 같다 (한글 · 그리스 문자 · 전각 영문은 그대로) */ +const ASCII_UPPERCASE = /[A-Z]+/g; + +export function asciiLowerCase(text: string): string { + return text.replace(ASCII_UPPERCASE, (run) => run.toLowerCase()); +} + +/** 규칙이 견주는 곳 — 어댑터가 엔진에서 읽을 것을 고른다 (모든 메일이면 없음) */ +export function filterFields( + criteria: MailFilterCriteria, +): ReadonlySet { + return new Set(criteria.conditions.map((condition) => condition.field)); +} + +/** + * 규칙에 맞는가 — 맞으면 true, 아니면 false. 본문을 아직 읽지 않았고(texts.body 가 null) 본문 조건으로 판정이 갈리면 null + * (모두 — 머리글 조건이 하나라도 틀리면 false · 하나라도 — 머리글 조건이 하나라도 맞으면 true 로 본문을 읽지 않고 끝낸다) + */ +export function judgeFilter( + criteria: MailFilterCriteria, + texts: MailFilterTexts, +): boolean | null { + if (criteria.match === 'always') return true; + const lowered = new Map(); + const loweredOf = (field: MailFilterField): readonly string[] | null => { + const known = lowered.get(field); + if (known) return known; + const values = texts[field]; + if (values === null) return null; + const result = values.map(asciiLowerCase); + lowered.set(field, result); + return result; + }; + + let undecided = false; + for (const condition of criteria.conditions) { + const verdict = conditionVerdict(condition, loweredOf(condition.field)); + if (verdict === null) { + undecided = true; + continue; + } + if (criteria.match === 'all' && !verdict) return false; + if (criteria.match === 'any' && verdict) return true; + } + if (undecided) return null; + return criteria.match === 'all'; +} + +/** 조건 하나 — 견줄 글(소문자로 바꾼 것)이 없으면(아직 읽지 않음) null */ +function conditionVerdict( + condition: MailFilterCondition, + values: readonly string[] | null, +): boolean | null { + if (values === null) return null; + const needle = asciiLowerCase(condition.value); + const found = values.some((value) => value.includes(needle)); + return condition.operator === 'contains' ? found : !found; +} diff --git a/backend/src/modules/mail/mail-filter.controller.ts b/backend/src/modules/mail/mail-filter.controller.ts index 6f72c21..10693be 100644 --- a/backend/src/modules/mail/mail-filter.controller.ts +++ b/backend/src/modules/mail/mail-filter.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Put, + UseGuards, +} from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -12,6 +21,11 @@ import { import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { MailFilterSetDto, MailFilterUpdateDto } from './dto/mail-filter.dto'; import { + MailFilterRunDto, + MailFilterRunRequestDto, +} from './dto/mail-filter-run.dto'; +import { + MAIL_FILTER_RUNS_ROUTE, MAIL_FILTERS_ROUTE, MAX_MAIL_FILTER_CONDITIONS, MAX_MAIL_FILTER_NAME_LENGTH, @@ -24,7 +38,8 @@ import { MailService } from './mail.service'; const APPLY_NOTE = '새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). ' + '메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다. 전달은 한 통에 한 곳(엔진 한도 — 두 번째는 엔진이 버린다)이고 사본을 남긴다. ' + - '스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙 · 이미 받은 메일에는 적용하지 않는다. ' + + '스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙에는 적용하지 않는다. ' + + '이미 받은 메일에는 저절로 적용하지 않는다 — 저장한 뒤 POST /api/mail-filter-runs 로 받은편지함에 적용한다. ' + '필터와 부재중 자동 응답은 메일 엔진의 규칙 하나에 함께 저장된다.'; // 메일 필터 (계약 2절 "메일 필터", 6단계) @@ -86,3 +101,50 @@ export class MailFilterController { return this.mail.updateMailFilters(user, dto); } } + +// 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용", 7단계) — 받은편지함만, 요청의 규칙 하나로 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(MAIL_FILTER_RUNS_ROUTE) +export class MailFilterRunController { + constructor(private readonly mail: MailService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '이미 받은 메일에 필터 적용 (받은편지함)', + description: + '받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(방금 저장한 그 규칙을 보낸다). ' + + '받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로 읽음 · 중요 표시를 붙이고 옮긴다(맞은 메일을 차례마다 엔진 요청 한 번으로). ' + + '확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다. 옮길 메일함이 받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다. ' + + '전달은 하지 않는다 · 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다) · 스팸 판정 표시는 보지 않는다. ' + + `조건은 필터 규칙과 같은 검사다(all · any 는 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 는 없음 · 찾을 글은 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자 · 제어 문자 · "\${" 없음). ` + + '한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' + + '같은 직원의 적용은 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001.', + }) + @ApiStandardResponse(MailFilterRunDto, { + description: + '이번 요청에서 확인한 수 · 적용한 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)', + }) + @ApiErrorResponse( + 'VAL_001', + `형식 · 한도(조건 ${MAX_MAIL_FILTER_CONDITIONS}개 · 찾을 글 ${MAX_MAIL_FILTER_VALUE_LENGTH}자) · 묶는 방법과 조건 수가 맞지 않음 · 할 일 없음 · 제어 문자 · "\${" · ` + + '보낸편지함 · 임시보관함으로 옮김 · 깨진 cursor · 다른 규칙의 cursor · 모르는 필드(actions 의 forwardTo 포함) — 적용하지 않았다', + ) + @ApiErrorResponse( + 'REQ_001', + '같은 직원의 적용이 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 적용했을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다(다시 해도 같다)', + ) + run( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailFilterRunRequestDto, + ) { + return this.mail.runMailFilter(user, dto); + } +} diff --git a/backend/src/modules/mail/mail-filters.spec.ts b/backend/src/modules/mail/mail-filters.spec.ts index a65a1ea..1604c00 100644 --- a/backend/src/modules/mail/mail-filters.spec.ts +++ b/backend/src/modules/mail/mail-filters.spec.ts @@ -4,6 +4,7 @@ import { } from './mail.constants'; import { findFilterRulesProblem, + findFilterRunProblem, hasFilterAction, hasSieveVariable, } from './mail-filters'; @@ -183,3 +184,60 @@ describe('hasSieveVariable · hasFilterAction', () => { ).toBe(true); }); }); + +describe('findFilterRunProblem — 이미 받은 메일에 적용 (계약 2절 "이미 받은 메일에 적용", 7단계)', () => { + const run = { + match: 'any' as const, + conditions: [ + { + field: 'subject' as const, + operator: 'contains' as const, + value: '견적', + }, + ], + actions: { moveToMailboxId: 'j', markAsRead: false, flag: false }, + }; + + it('규칙 저장과 같은 규칙 — 맞으면 null, 모든 메일은 조건 없이', () => { + expect(findFilterRunProblem(run)).toBeNull(); + expect( + findFilterRunProblem({ ...run, match: 'always', conditions: [] }), + ).toBeNull(); + }); + + it('묶는 방법과 조건 수가 맞지 않음 · 조건 6개 · 찾을 글 · 할 일 없음(전달이 없어 옮기기 · 읽음 · 중요 가운데 하나) · 옮길 메일함 id', () => { + expect(findFilterRunProblem({ ...run, match: 'always' })).toBe( + 'conditions-for-match', + ); + expect(findFilterRunProblem({ ...run, conditions: [] })).toBe( + 'conditions-for-match', + ); + expect( + findFilterRunProblem({ + ...run, + conditions: Array.from( + { length: MAX_MAIL_FILTER_CONDITIONS + 1 }, + () => run.conditions[0], + ), + }), + ).toBe('too-many-conditions'); + expect( + findFilterRunProblem({ + ...run, + conditions: [{ ...run.conditions[0], value: '광고${x}' }], + }), + ).toBe('condition-value-variable'); + expect( + findFilterRunProblem({ + ...run, + actions: { moveToMailboxId: null, markAsRead: false, flag: false }, + }), + ).toBe('no-action'); + expect( + findFilterRunProblem({ + ...run, + actions: { ...run.actions, moveToMailboxId: '받은 편지함' }, + }), + ).toBe('move-mailbox-id'); + }); +}); diff --git a/backend/src/modules/mail/mail-filters.ts b/backend/src/modules/mail/mail-filters.ts index df7c146..c8946c7 100644 --- a/backend/src/modules/mail/mail-filters.ts +++ b/backend/src/modules/mail/mail-filters.ts @@ -12,16 +12,20 @@ import { import type { MailFilterActions, MailFilterCondition, + MailFilterCriteria, MailFilterField, MailFilterMatch, MailFilterOperator, MailFilterRule, + MailFilterRunActions, + MailFilterRunRequest, } from './mail.types'; /** * 메일 필터 규칙 (계약 2절 "메일 필터"의 타입) — 메일 엔진과 무관한 순수 규칙. * 요청 검증(dto/mail-filter.dto.ts)이 칸마다 형식을 보고, 서비스가 칸 사이의 규칙을 이것으로 한 번 더 본다. * 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 규칙으로 본다 — 어긋나면 깨진 원본이다. + * 이미 받은 메일에 적용하는 요청(dto/mail-filter-run.dto.ts — 7단계)도 조건 · 할 일을 같은 규칙으로 본다 (findFilterRunProblem). */ /** 견줄 곳 · 견주는 방법 · 묶는 방법 — 요청 검증과 원본 읽기가 같이 쓴다 */ @@ -58,13 +62,15 @@ export function hasSieveVariable(value: string): boolean { return value.includes(SIEVE_VARIABLE_MARKER); } -/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달) */ -export function hasFilterAction(actions: MailFilterActions): boolean { +/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달 — 이미 받은 메일에 적용할 때는 전달이 없다) */ +export function hasFilterAction( + actions: MailFilterRunActions & Partial>, +): boolean { return ( actions.moveToMailboxId !== null || actions.markAsRead || actions.flag || - actions.forwardTo !== null + (actions.forwardTo ?? null) !== null ); } @@ -79,10 +85,16 @@ function conditionProblem(condition: MailFilterCondition): string | null { return null; } -/** 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다) */ -function actionsProblem(actions: MailFilterActions): string | null { +/** + * 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다). + * 이미 받은 메일에 적용하는 요청에는 전달이 없다 + */ +function actionsProblem( + actions: MailFilterRunActions & Partial>, +): string | null { if (!hasFilterAction(actions)) return 'no-action'; - const { moveToMailboxId, forwardTo } = actions; + const { moveToMailboxId } = actions; + const forwardTo = actions.forwardTo ?? null; if (moveToMailboxId !== null && !MAIL_ID_PATTERN.test(moveToMailboxId)) { return 'move-mailbox-id'; } @@ -99,6 +111,20 @@ function actionsProblem(actions: MailFilterActions): string | null { return null; } +/** 묶는 방법에 맞는 조건 수(모든 메일은 0개, 나머지는 1~5개)와 조건마다의 찾을 글 — 어긋난 첫 까닭, 맞으면 null */ +function criteriaProblem(criteria: MailFilterCriteria): string | null { + const count = criteria.conditions.length; + if (criteria.match === 'always' ? count !== 0 : count < 1) { + return 'conditions-for-match'; + } + if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions'; + for (const condition of criteria.conditions) { + const problem = conditionProblem(condition); + if (problem) return problem; + } + return null; +} + /** 규칙 하나 — id · 이름 · 묶는 방법에 맞는 조건 수 · 조건 · 할 일 */ function ruleProblem(rule: MailFilterRule): string | null { if (!MAIL_FILTER_RULE_ID_PATTERN.test(rule.id)) return 'rule-id'; @@ -108,16 +134,18 @@ function ruleProblem(rule: MailFilterRule): string | null { ) { return 'rule-name'; } - const count = rule.conditions.length; - if (rule.match === 'always' ? count !== 0 : count < 1) { - return 'conditions-for-match'; - } - if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions'; - for (const condition of rule.conditions) { - const problem = conditionProblem(condition); - if (problem) return problem; - } - return actionsProblem(rule.actions); + return criteriaProblem(rule) ?? actionsProblem(rule.actions); +} + +/** + * 이미 받은 메일에 적용하는 요청이 계약에 맞는지 (계약 2절 "이미 받은 메일에 적용") — 어긋난 첫 까닭, 맞으면 null. + * 규칙 저장과 같은 규칙이다 — 묶는 방법에 맞는 조건 수 · 찾을 글 · 할 일 하나 이상(전달은 없다) · 옮길 메일함 id 형식. + * 옮길 메일함의 역할(보낸편지함 · 임시보관함이 아님)은 서비스가 메일함 목록으로 본다 + */ +export function findFilterRunProblem( + request: Pick, +): string | null { + return criteriaProblem(request) ?? actionsProblem(request.actions); } /** diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 3dc228c..eaf0a65 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -111,6 +111,18 @@ export const MAIL_FILTER_RULE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** 필터 지문(version) — 서버가 만든 값(base64url)을 그대로 돌려받는다 */ export const MAIL_FILTER_VERSION_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +/** + * 이미 받은 메일에 필터 적용 라우트 경로 (`/api/mail-filter-runs`) — 계약 2절 "이미 받은 메일에 적용" (7단계) + */ +export const MAIL_FILTER_RUNS_ROUTE = 'mail-filter-runs'; + +/** + * 이미 받은 메일에 필터 적용의 이어 할 자리(cursor) — 서버가 만든 값(base64url)을 그대로 돌려받는다. + * 서버가 만드는 값은 500자 안쪽이다(메일 id 255자까지 + 자리 + 규칙 지문 — jmap/filter-run-cursor.ts) — 넉넉히 1,024자까지 받는다 + */ +export const MAX_MAIL_FILTER_RUN_CURSOR_LENGTH = 1_024; +export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/; + /** * 메일 엔진(Stalwart)이 규칙 글 안에서 변수로 펼치는 표시 — require 와 상관없이 늘 펼친다(2026-10-28 확인). * 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞으므로 찾을 글 · 전달 주소에 쓰지 않는다 (계약 2절 "만드는 Sieve") diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index bcd31ac..e4f83d7 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -11,11 +11,15 @@ import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.mod import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; import { JmapClient } from './jmap/jmap.client'; +import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; import { MailAttachmentController } from './mail-attachment.controller'; import { MailEventsController } from './mail-events.controller'; -import { MailFilterController } from './mail-filter.controller'; +import { + MailFilterController, + MailFilterRunController, +} from './mail-filter.controller'; import { MailEventsService } from './mail-events.service'; import { MailUploadTempCleanup, @@ -111,6 +115,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailEventsController, MailVacationController, MailFilterController, + MailFilterRunController, MessageController, MessageDeletionController, MailAttachmentController, @@ -126,6 +131,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { JmapClient, // 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다 JmapFilterScriptStore, + // 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다 + JmapFilterRunner, { provide: MAIL_PROVIDER, useClass: JmapMailProvider }, MailService, // 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown) diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index d16614d..c0b35a7 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -9,11 +9,15 @@ import type { MailboxBulkResult, MailboxCreate, MailboxDeletion, + MailFilterRunContext, + MailFilterRunOutcome, + MailFilterRunRequest, MailFilterSet, MailFilterUpdate, MailState, MailUsage, MailVacation, + MailVacationState, MailWatch, MailWatchListener, MessageDeletionResult, @@ -106,15 +110,15 @@ export interface MailProvider { /** * 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용. - * 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리) + * 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리) — 그 규칙이 엔진에서 동작하는지(isActive)를 함께 (7단계) */ - getVacation(user: string): Promise; + getVacation(user: string): Promise; /** - * 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. + * 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. 저장하면 다시 동작한다(isActive). * 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013 */ - setVacation(user: string, vacation: MailVacation): Promise; + setVacation(user: string, vacation: MailVacation): Promise; /** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */ getMailFilters(user: string): Promise; @@ -130,6 +134,20 @@ export interface MailProvider { update: MailFilterUpdate, ): Promise; + /** + * 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 저장해 둔 필터가 아니라 요청의 규칙을 쓴다. + * 받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로(mail-filter-match.ts) 읽음 · 중요 표시를 붙이고 옮긴다. + * 한 요청의 시간 안에서 차례로 하고 이어 할 자리(cursor — 끝이면 null)를 돌려준다 — 메일함의 오래 걸리는 처리와 같은 상한 · 같은 규칙 + * (진행한 것이 없이 멈추면 MAIL_001). 옮길 메일함이 받은편지함이거나 없으면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다. + * cursor 가 깨졌거나 다른 규칙의 것이면 엔진에 묻기 전에 VAL_001. 규칙의 형식 · 내용 · 옮길 메일함의 역할은 서비스가 검사한 값이다. + * 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾고, 한 요청의 시간은 context.startedAt 부터 센다 + */ + runMailFilter( + user: string, + request: MailFilterRunRequest, + context: MailFilterRunContext, + ): Promise; + /** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */ getUsage(user: string): Promise; diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 461e37d..50c52dc 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -6,6 +6,7 @@ import { BusinessException } from '@/common/errors/business.exception'; import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants'; import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; +import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto'; import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget'; import { @@ -76,6 +77,7 @@ function fakeProvider(): ProviderMocks { setVacation: mockOf<'setVacation'>(), getMailFilters: mockOf<'getMailFilters'>(), setMailFilters: mockOf<'setMailFilters'>(), + runMailFilter: mockOf<'runMailFilter'>(), openMessageSource: mockOf<'openMessageSource'>(), getUsage: mockOf<'getUsage'>(), getAttachmentsTotalLimit: @@ -648,12 +650,13 @@ describe('MailService', () => { it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => { provider.setVacation.mockImplementation((_user, value) => - Promise.resolve(value), + Promise.resolve({ ...value, isActive: true }), ); - await expect(service.updateVacation(user, vacation)).resolves.toEqual( - vacation, - ); + await expect(service.updateVacation(user, vacation)).resolves.toEqual({ + ...vacation, + isActive: true, + }); expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation); const [line] = logSpy.mock.calls[0] as [string]; expect(line).toContain('켬'); @@ -682,10 +685,13 @@ describe('MailService', () => { subject: '', textBody: '', }; - provider.setVacation.mockResolvedValue(neverConfigured); + provider.setVacation.mockResolvedValue({ + ...neverConfigured, + isActive: true, + }); await expect( service.updateVacation(user, neverConfigured), - ).resolves.toEqual(neverConfigured); + ).resolves.toEqual({ ...neverConfigured, isActive: true }); }); it('켤 때 앞뒤 공백을 빼고 빈 제목 · 내용도 VAL_001 — 끈 채로는 받고, 저장은 쓴 그대로(공백을 지우지 않는다)', async () => { @@ -702,7 +708,7 @@ describe('MailService', () => { expect(provider.setVacation).not.toHaveBeenCalled(); provider.setVacation.mockImplementation((_user, value) => - Promise.resolve(value), + Promise.resolve({ ...value, isActive: true }), ); await service.updateVacation(user, { ...vacation, @@ -758,7 +764,7 @@ describe('MailService', () => { }); provider.setVacation.mockImplementation((_user, value) => - Promise.resolve(value), + Promise.resolve({ ...value, isActive: true }), ); // 한도까지는 켜도 받는다 const full = { @@ -766,7 +772,10 @@ describe('MailService', () => { subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH), textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH), }; - await expect(service.updateVacation(user, full)).resolves.toEqual(full); + await expect(service.updateVacation(user, full)).resolves.toEqual({ + ...full, + isActive: true, + }); // 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다 const off = { ...vacation, @@ -774,7 +783,10 @@ describe('MailService', () => { subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1), textBody: 'x'.repeat(1_000), }; - await expect(service.updateVacation(user, off)).resolves.toEqual(off); + await expect(service.updateVacation(user, off)).resolves.toEqual({ + ...off, + isActive: true, + }); expect(provider.setVacation).toHaveBeenCalledTimes(2); }); @@ -791,7 +803,7 @@ describe('MailService', () => { expect(provider.setVacation).not.toHaveBeenCalled(); provider.setVacation.mockImplementation((_user, value) => - Promise.resolve(value), + Promise.resolve({ ...value, isActive: true }), ); await service.updateVacation(user, { ...vacation, @@ -816,7 +828,7 @@ describe('MailService', () => { expect(provider.setVacation).not.toHaveBeenCalled(); provider.setVacation.mockImplementation((_user, value) => - Promise.resolve(value), + Promise.resolve({ ...value, isActive: true }), ); await service.updateVacation(user, { ...vacation, toDate: null }); await service.updateVacation(user, { ...vacation, fromDate: null }); @@ -1133,6 +1145,257 @@ describe('MailService', () => { }); }); + describe('이미 받은 메일에 필터 적용 (7단계)', () => { + const body = (overrides: object = {}) => + ({ + match: 'any', + conditions: [ + { + field: 'from', + operator: 'contains', + value: '비밀 거래처', + }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, + cursor: null, + ...overrides, + }) as unknown as MailFilterRunRequestDto; + const outcome = { + scanned: 500, + matched: 37, + remaining: 120, + cursor: 'next-cursor', + rounds: 2, + }; + const mailbox = (id: string, role: Mailbox['role']): Mailbox => ({ + id, + name: id, + role, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }); + + /** 손으로 끝내는 적용 — 끝낼 때까지 진행 중이다 */ + function pending(): { + promise: Promise; + finish: (value: T) => void; + fail: (error: Error) => void; + } { + let finish!: (value: T) => void; + let fail!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + return { promise, finish, fail }; + } + + const mailboxes = [ + mailbox('a', 'inbox'), + mailbox('e', 'sent'), + mailbox('d', 'drafts'), + mailbox('j', null), + ]; + + beforeEach(() => { + provider.listMailboxes.mockResolvedValue(mailboxes); + provider.runMailFilter.mockResolvedValue(outcome); + }); + + it('검사를 지나면 본문의 규칙(묶는 방법 · 조건 · 할 일 · cursor)과 한 번 읽은 메일함 목록을 넘기고 결과를 돌려준다 — 차례 수는 로그에만', async () => { + await expect(service.runMailFilter(user, body())).resolves.toEqual({ + scanned: 500, + matched: 37, + remaining: 120, + cursor: 'next-cursor', + }); + expect(provider.listMailboxes).toHaveBeenCalledTimes(1); + expect(provider.runMailFilter).toHaveBeenCalledWith( + user.email, + { + match: 'any', + conditions: [ + { field: 'from', operator: 'contains', value: '비밀 거래처' }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, + cursor: null, + }, + { mailboxes, startedAt: expect.any(Number) as number }, + ); + // 로그에는 수 · 차례 · 걸린 시간만 — 규칙의 글 · 주소 · cursor 는 남기지 않는다 + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('확인 500통 · 적용 37통 · 남음 120통'); + expect(line).toContain('차례 2번'); + expect(line).not.toContain('비밀 거래처'); + expect(line).not.toContain('next-cursor'); + expect(line).not.toContain(user.email); + }); + + it.each<[string, object, string]>([ + [ + '모든 메일인데 조건이 있음', + { match: 'always' }, + 'conditions-for-match', + ], + ['하나라도인데 조건이 없음', { conditions: [] }, 'conditions-for-match'], + [ + '할 일이 하나도 없음', + { + actions: { moveToMailboxId: null, markAsRead: false, flag: false }, + }, + 'no-action', + ], + ])( + '칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — %s', + async (_label, overrides, problem) => { + let failure: unknown; + try { + await service.runMailFilter(user, body(overrides)); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(BusinessException); + expect((failure as BusinessException).context).toEqual({ + reason: 'invalid-filter-run', + problem, + }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.runMailFilter).not.toHaveBeenCalled(); + }, + ); + + it('보낸편지함 · 임시보관함으로는 옮기지 않는다 — VAL_001 (규칙 저장과 같은 판정). 메일함 목록은 늘 한 번 읽어 넘긴다(어댑터가 받은편지함을 찾는다)', async () => { + for (const moveToMailboxId of ['e', 'd']) { + expect( + await failureOf(() => + service.runMailFilter( + user, + body({ + actions: { moveToMailboxId, markAsRead: false, flag: false }, + }), + ), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'filter-move-target-not-allowed', + }); + } + expect(provider.runMailFilter).not.toHaveBeenCalled(); + + provider.listMailboxes.mockClear(); + await service.runMailFilter( + user, + body({ + actions: { moveToMailboxId: null, markAsRead: true, flag: true }, + }), + ); + expect(provider.listMailboxes).toHaveBeenCalledTimes(1); + // 없는 메일함은 받는다 — 어댑터가 옮기지 않고 표시만 한다 + await service.runMailFilter( + user, + body({ + actions: { moveToMailboxId: 'gone', markAsRead: true, flag: false }, + }), + ); + expect(provider.runMailFilter).toHaveBeenCalledTimes(2); + }); + + it('한 요청의 시간은 서비스에 들어온 때부터 — 메일함 목록을 읽는 시간도 그 안이다 (어댑터는 들어온 때를 받는다)', async () => { + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + provider.listMailboxes.mockImplementationOnce(() => { + now += 21_000; + return Promise.resolve(mailboxes); + }); + + await service.runMailFilter(user, body()); + expect(provider.runMailFilter).toHaveBeenCalledWith( + user.email, + expect.any(Object), + { mailboxes, startedAt: 1_000_000 }, + ); + }); + + it('같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001(대소문자가 다른 주소도 같은 직원), 다른 직원은 동시에 된다, 끝나면(실패여도) 다시 받는다', async () => { + const first = pending(); + provider.runMailFilter.mockReturnValueOnce(first.promise); + + const running = service.runMailFilter(user, body()); + // 메일함 확인(엔진)을 지나 어댑터에 들어갈 때까지 기다린다 + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.runMailFilter).toHaveBeenCalledTimes(1); + provider.listMailboxes.mockClear(); + + for (const sameUser of [ + user, + { ...user, email: 'ARA.KIM@arasystem.test' }, + ]) { + expect( + await failureOf(() => service.runMailFilter(sameUser, body())), + ).toEqual({ code: 'REQ_001', reason: 'filter-run-in-progress' }); + } + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.runMailFilter).toHaveBeenCalledTimes(1); + + // 다른 직원 · 같은 직원의 메일함 처리(비우기 등)는 따로 센다 + await expect( + service.runMailFilter( + { email: 'doyun.lee@arasystem.test', name: '이도윤' }, + body(), + ), + ).resolves.toMatchObject({ scanned: 500 }); + provider.emptyMailbox.mockResolvedValueOnce({ + processed: 0, + remaining: 0, + }); + await expect(service.emptyMailbox(user, 'b')).resolves.toEqual({ + processed: 0, + remaining: 0, + }); + + first.fail(new BusinessException('MAIL_001')); + expect(await failureOf(() => running)).toMatchObject({ + code: 'MAIL_001', + }); + await expect(service.runMailFilter(user, body())).resolves.toMatchObject({ + scanned: 500, + }); + }); + + it('어댑터의 실패는 그대로 — 깨진 cursor VAL_001 · 진행 없음 MAIL_001 (적용 로그 없음)', async () => { + for (const code of ['VAL_001', 'MAIL_001'] as const) { + provider.runMailFilter.mockRejectedValueOnce( + new BusinessException(code), + ); + expect( + await codeOf(() => + service.runMailFilter(user, body({ cursor: 'eyJ2IjoxfQ' })), + ), + ).toBe(code); + } + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => { + it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => { + const state = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '휴가입니다.', + isActive: false, + }; + provider.getVacation.mockResolvedValue(state); + + await expect(service.getVacation(user)).resolves.toBe(state); + expect(provider.getVacation).toHaveBeenCalledWith(user.email); + }); + }); + describe('메일 상세', () => { it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => { provider.getMessage.mockResolvedValue(source()); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index e85aaf8..ee75cb0 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -24,6 +24,7 @@ import type { UpdateMessagesDto, } from './dto/bulk-messages.dto'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; +import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { MailVacationDto } from './dto/mail-vacation.dto'; import type { CreateMailboxDto, @@ -62,7 +63,7 @@ import { MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_TEXT_LENGTH, } from './mail.constants'; -import { findFilterRulesProblem } from './mail-filters'; +import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; import type { @@ -77,11 +78,15 @@ import type { MailboxBulkResult, MailboxDeletion, MailboxRole, + MailFilterActions, MailFilterRule, + MailFilterRun, + MailFilterRunRequest, MailFilterSet, MailQuota, MailState, MailVacation, + MailVacationState, MessageAttachment, MessageDeletionResult, MessageDetail, @@ -217,6 +222,27 @@ const FILTER_MOVE_FORBIDDEN_ROLES: ReadonlySet = new Set([ 'drafts', ]); +/** + * 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다 + * (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 새 메일은 받은편지함에 들어오고, 이미 받은 메일은 옮기지 않는다). + * 규칙 저장(규칙마다의 할 일 — index 는 몇 번째 규칙)과 이미 받은 메일에 적용(할 일 하나)이 같이 쓴다 + */ +function assertMoveTargetsAllowed( + mailboxes: readonly Mailbox[], + actions: readonly Pick[], +): void { + const roles = new Map(mailboxes.map((mailbox) => [mailbox.id, mailbox.role])); + for (const [index, { moveToMailboxId }] of actions.entries()) { + if (moveToMailboxId === null) continue; + const role = roles.get(moveToMailboxId); + if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) { + throw new BusinessException('VAL_001', { + context: { reason: 'filter-move-target-not-allowed', role, index }, + }); + } + } +} + /** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */ function signatureImageCid(imageId: string, sender: string): string { const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase(); @@ -245,6 +271,13 @@ export class MailService { */ private readonly mailboxOperations = new Set(); + /** + * 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). + * 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. + */ + private readonly filterRuns = new Set(); + constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, @@ -374,8 +407,8 @@ export class MailService { return result; } - /** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */ - getVacation(user: AuthenticatedUser): Promise { + /** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진에서 우리 규칙이 동작하는지(isActive)를 함께 (7단계) */ + getVacation(user: AuthenticatedUser): Promise { return this.provider.getVacation(user.email); } @@ -392,7 +425,7 @@ export class MailService { async updateVacation( user: AuthenticatedUser, dto: MailVacationDto, - ): Promise { + ): Promise { const vacation: MailVacation = { isEnabled: dto.isEnabled, fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate), @@ -468,7 +501,10 @@ export class MailService { }); } this.assertFilterForwardTargets(user, rules); - await this.assertFilterMoveTargets(user, rules); + await this.assertFilterMoveTargets( + user, + rules.map((rule) => rule.actions), + ); const saved = await this.provider.setMailFilters(user.email, { rules, @@ -481,6 +517,59 @@ export class MailService { return saved; } + /** + * 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 받은편지함의 메일을 한 요청의 시간 안에서 차례로 판정하고 + * 표시 · 옮기기를 한 뒤 이어 할 자리(cursor)를 돌려준다. 저장해 둔 필터가 아니라 본문의 규칙을 쓴다. + * - 칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — 규칙 저장과 같다(묶는 방법 ↔ 조건 수 · 할 일 하나 이상 — mail-filters.ts) + * - 같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001 + * - 메일함 목록은 한 번만 읽는다 — 옮길 메일함이 보낸편지함 · 임시보관함이면 VAL_001 (규칙 저장과 같은 판정 — assertMoveTargetsAllowed), + * 없는 메일함은 받는다(옮기지 않는다). 어댑터는 그 목록에서 받은편지함 · 옮길 메일함을 찾는다 + * - 한 요청의 시간(25초)은 여기 들어온 때부터 센다 — 메일함 목록을 읽는 것도 그 안이다 + * 로그에는 확인 · 적용 · 남은 수와 차례 수 · 걸린 시간만 남긴다 — 규칙의 글(찾을 글)은 남기지 않는다 (필터 저장과 같다) + */ + async runMailFilter( + user: AuthenticatedUser, + dto: MailFilterRunRequestDto, + ): Promise { + const startedAt = Date.now(); + const request: MailFilterRunRequest = { + match: dto.match, + conditions: dto.conditions, + actions: { + moveToMailboxId: dto.actions.moveToMailboxId, + markAsRead: dto.actions.markAsRead, + flag: dto.actions.flag, + }, + cursor: dto.cursor, + }; + const problem = findFilterRunProblem(request); + if (problem) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid-filter-run', problem }, + }); + } + + return this.exclusive( + this.filterRuns, + user.email.toLowerCase(), + 'filter-run-in-progress', + user, + async () => { + const mailboxes = await this.provider.listMailboxes(user.email); + assertMoveTargetsAllowed(mailboxes, [request.actions]); + const { rounds, ...result } = await this.provider.runMailFilter( + user.email, + request, + { mailboxes, startedAt }, + ); + this.logger.log( + `${traceTag()} 이미 받은 메일에 필터를 적용했습니다: user=${maskEmail(user.email)}, 확인 ${result.scanned}통 · 적용 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`, + ); + return result; + }, + ); + } + /** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */ async openMessageSource( user: AuthenticatedUser, @@ -739,59 +828,57 @@ export class MailService { } /** - * 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다 - * (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 그때는 받은편지함에 들어온다). 옮기는 규칙이 없으면 엔진에 묻지 않는다 + * 필터 규칙이 옮길 메일함 — 판정은 assertMoveTargetsAllowed. 옮기는 규칙이 없으면 엔진에 묻지 않는다(메일함 목록을 읽지 않는다) */ private async assertFilterMoveTargets( user: AuthenticatedUser, - rules: readonly MailFilterRule[], + actions: readonly Pick[], ): Promise { - const targets = rules.flatMap((rule, index) => - rule.actions.moveToMailboxId === null - ? [] - : [{ index, mailboxId: rule.actions.moveToMailboxId }], - ); - if (targets.length === 0) return; - - const roles = new Map( - (await this.provider.listMailboxes(user.email)).map((mailbox) => [ - mailbox.id, - mailbox.role, - ]), - ); - for (const { index, mailboxId } of targets) { - const role = roles.get(mailboxId); - if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) { - throw new BusinessException('VAL_001', { - context: { reason: 'filter-move-target-not-allowed', role, index }, - }); - } + if (actions.every(({ moveToMailboxId }) => moveToMailboxId === null)) { + return; } + assertMoveTargetsAllowed( + await this.provider.listMailboxes(user.email), + actions, + ); } - /** - * 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 - * (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다 - */ - private async runExclusive( + /** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */ + private runExclusive( user: AuthenticatedUser, mailboxId: string, operation: () => Promise, ): Promise { - const key = `${user.email.toLowerCase()}\n${mailboxId}`; - if (this.mailboxOperations.has(key)) { + return this.exclusive( + this.mailboxOperations, + `${user.email.toLowerCase()}\n${mailboxId}`, + 'mailbox-operation-in-progress', + user, + operation, + ); + } + + /** + * 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양). + * 끝나면 성공 · 실패와 상관없이 푼다 + */ + private async exclusive( + running: Set, + key: string, + reason: string, + user: AuthenticatedUser, + operation: () => Promise, + ): Promise { + if (running.has(key)) { throw new BusinessException('REQ_001', { - context: { - reason: 'mailbox-operation-in-progress', - user: maskEmail(user.email), - }, + context: { reason, user: maskEmail(user.email) }, }); } - this.mailboxOperations.add(key); + running.add(key); try { return await operation(); } finally { - this.mailboxOperations.delete(key); + running.delete(key); } } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 62b4ac5..8cc8caa 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -198,6 +198,15 @@ export interface MailVacation { readonly textBody: string; } +/** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */ +export interface MailVacationState extends MailVacation { + /** + * false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. + * 메일 필터의 isActive 와 같은 값이다 (한 번도 저장하지 않은 계정은 true) + */ + readonly isActive: boolean; +} + /** 필터 조건을 견줄 곳 — 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */ export type MailFilterField = 'from' | 'to' | 'subject' | 'body'; @@ -253,6 +262,48 @@ export interface MailFilterUpdate { readonly version: string; } +/** 규칙의 조건 부분 — 묶는 방법과 조건 (규칙 저장 · 이미 받은 메일에 적용 · 판정이 같이 쓴다) */ +export type MailFilterCriteria = Pick; + +/** 이미 받은 메일에 할 일 (계약 2절 "이미 받은 메일에 적용") — 규칙의 할 일에서 전달을 뺀 것 (이미 받은 메일은 전달하지 않는다) */ +export type MailFilterRunActions = Omit; + +/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용하는 요청 (POST /api/mail-filter-runs — 7단계) — 서비스가 검사한 값이다 */ +export interface MailFilterRunRequest extends MailFilterCriteria { + /** 하나 이상 — 옮길 메일함이 받은편지함이거나 없으면 옮기지 않는다(표시만 한다) */ + readonly actions: MailFilterRunActions; + /** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 규칙의 것이면 VAL_001) */ + readonly cursor: string | null; +} + +/** 이미 받은 메일에 적용한 결과 (계약 2절의 MailFilterRun) — cursor 가 null 이 될 때까지 클라이언트가 같은 본문으로 다시 보낸다 */ +export interface MailFilterRun { + /** 이번 요청에서 확인한 받은편지함 메일 수 */ + readonly scanned: number; + /** 그중 조건에 맞아 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일도 센다) */ + readonly matched: number; + /** 아직 확인하지 않은 메일 수 — 어림 (그사이 들어오고 나간 메일이 있다) */ + readonly remaining: number; + /** null = 끝 */ + readonly cursor: string | null; +} + +/** + * 이미 받은 메일에 적용할 때 서비스가 어댑터에 넘기는 것 — 이 요청에서 읽은 메일함 목록(받은편지함 · 옮길 메일함을 여기서 찾는다 — 다시 읽지 않는다)과 + * 요청이 서비스에 들어온 때. 한 요청의 시간(25초)은 그때부터 센다 — 옮길 메일함 확인도 그 안이다 (계약 2절 "이미 받은 메일에 적용") + */ +export interface MailFilterRunContext { + readonly mailboxes: readonly Mailbox[]; + /** Date.now() 의 값 (ms) */ + readonly startedAt: number; +} + +/** 어댑터가 돌려주는 적용 결과 — 응답(MailFilterRun)에 로그만 쓰는 값을 더했다 (서비스가 떼고 답한다) */ +export interface MailFilterRunOutcome extends MailFilterRun { + /** 이번 요청에서 엔진에 물은 차례 수 */ + readonly rounds: number; +} + /** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */ export interface MessageSource { readonly stream: Readable; diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 8b9da32..0121a54 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -30,7 +30,7 @@ import { MailQuotaDto, MailStateDto, } from './dto/mail-response.dto'; -import { MailVacationDto } from './dto/mail-vacation.dto'; +import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto'; import { CreateMailboxDto, MailboxMessagesPatchDto, @@ -202,9 +202,12 @@ export class MailVacationController { @ApiOperation({ summary: '부재중 자동 응답', description: - '한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" } 다.', + '한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' + + 'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).', + }) + @ApiStandardResponse(MailVacationStateDto, { + description: '지금 설정 · 동작하는지', }) - @ApiStandardResponse(MailVacationDto, { description: '지금 설정' }) get(@CurrentUser() user: AuthenticatedUser) { return this.mail.getVacation(user); } @@ -218,12 +221,15 @@ export class MailVacationController { `꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` + '시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' + '제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' + - '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다.', + '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' + + '저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).', + }) + @ApiStandardResponse(MailVacationStateDto, { + description: '저장한 설정 · 동작하는지', }) - @ApiStandardResponse(MailVacationDto, { description: '저장한 설정' }) @ApiErrorResponse( 'VAL_001', - `VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드 ` + + `VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` + '/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다', ) @ApiErrorResponse( diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 020b7b5..bd16bf1 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -38,9 +38,12 @@ import type { MailboxCreate, MailboxDeletion, MailFilterRule, + MailFilterRunOutcome, + MailFilterRunRequest, MailFilterSet, MailFilterUpdate, MailVacation, + MailVacationState, MailWatch, MailWatchListener, MessageDeletionResult, @@ -189,6 +192,9 @@ const E2E_MAILBOXES: Mailbox[] = ( unreadCount: 0, })); +/** 대역 메일 엔진이 "다른 규칙의 cursor"라고 답하는 cursor (400 VAL_001 경로 — 실제 해석은 jmap/filter-run-cursor.ts) */ +const OTHER_RULE_CURSOR = 'otherrulecursor'; + /** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */ const fakeFilterVersion = (rules: readonly MailFilterRule[]) => createHash('sha256').update(JSON.stringify(rules)).digest('base64url'); @@ -369,6 +375,11 @@ describe('응답 계약 (e2e)', () => { { rules: readonly MailFilterRule[]; vacation: MailVacation } >(); const filterSaves: { user: string; update: MailFilterUpdate }[] = []; + /** 7단계 — 엔진에서 우리 규칙이 꺼진 직원(부재중 · 필터가 동작하지 않음 — 저장하면 다시 켜진다) · 받은 "이미 받은 메일에 적용" */ + const inactiveScripts = new Set(); + const filterRuns: { user: string; request: MailFilterRunRequest }[] = []; + /** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */ + let slowFilterRunGate: Promise | null = null; const singleUpdates: { user: string; id: string; @@ -669,21 +680,23 @@ describe('응답 계약 (e2e)', () => { if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; return { processed: 2, remaining: 0 }; }, - getVacation: (user: string): Promise => - Promise.resolve( - mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION, - ), + getVacation: (user: string): Promise => + Promise.resolve({ + ...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION), + isActive: !inactiveScripts.has(user), + }), // 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts) setVacation: ( user: string, vacation: MailVacation, - ): Promise => { + ): Promise => { vacationSaves.push({ user, vacation }); mailScripts.set(user, { rules: mailScripts.get(user)?.rules ?? [], vacation, }); - return Promise.resolve(vacation); + inactiveScripts.delete(user); + return Promise.resolve({ ...vacation, isActive: true }); }, listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 @@ -735,6 +748,34 @@ describe('응답 계약 (e2e)', () => { isActive: true, }); }, + // 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다 + runMailFilter: async ( + user: string, + request: MailFilterRunRequest, + ): Promise => { + filterRuns.push({ user, request }); + if (request.cursor === OTHER_RULE_CURSOR) { + throw new BusinessException('VAL_001', { + context: { reason: 'e2e-filter-run-other-rule' }, + }); + } + if (slowFilterRunGate) await slowFilterRunGate; + return request.cursor === null + ? { + scanned: 500, + matched: 3, + remaining: 120, + cursor: 'e2enextcursor', + rounds: 1, + } + : { + scanned: 120, + matched: 1, + remaining: 0, + cursor: null, + rounds: 1, + }; + }, openMessageSource: ( _user: string, id: string, @@ -1242,6 +1283,7 @@ describe('응답 계약 (e2e)', () => { ['put', '/api/mail-vacation'], ['get', '/api/mail-filters'], ['put', '/api/mail-filters'], + ['post', '/api/mail-filter-runs'], ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], @@ -2581,30 +2623,41 @@ describe('응답 계약 (e2e)', () => { .set('Cookie', cookieOf(ME)) .send(payload); - it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => { + it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — isActive 를 뺀 그대로 다시 저장해도 200, isActive 를 넣으면 400', async () => { const res = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); expect(body(res)).toEqual({ success: true, - data: NEVER_CONFIGURED_VACATION, + data: { ...NEVER_CONFIGURED_VACATION, isActive: true }, }); - const restored = await put(dataOf(res)).expect(200); - expect(dataOf(restored)).toEqual(NEVER_CONFIGURED_VACATION); + const { isActive, ...stored } = dataOf(res); + expect(isActive).toBe(true); + const restored = await put(stored).expect(200); + expect(dataOf(restored)).toEqual({ + ...NEVER_CONFIGURED_VACATION, + isActive: true, + }); + + // 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState + const before = vacationSaves.length; + const rejected = await put(dataOf(res)).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + expect(vacationSaves).toHaveLength(before); }); it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => { const res = await put(vacation).expect(200); - expect(dataOf(res)).toEqual(vacation); + expect(dataOf(res)).toEqual({ ...vacation, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation }); const read = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual(vacation); + expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); }); it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { @@ -2660,7 +2713,7 @@ describe('응답 계약 (e2e)', () => { const res = await put(full).expect(200); - expect(dataOf(res)).toEqual(full); + expect(dataOf(res)).toEqual({ ...full, isActive: true }); }); it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => { @@ -2671,7 +2724,7 @@ describe('응답 계약 (e2e)', () => { }; const res = await put(longOff).expect(200); - expect(dataOf(res)).toEqual(longOff); + expect(dataOf(res)).toEqual({ ...longOff, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff }); const before = vacationSaves.length; @@ -2706,7 +2759,7 @@ describe('응답 계약 (e2e)', () => { fromDate: '2026-11-02T15:00:00.000Z', toDate: '2026-11-07T15:00:00.000Z', }; - expect(dataOf(res)).toEqual(saved); + expect(dataOf(res)).toEqual({ ...saved, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved }); }); }); @@ -3222,7 +3275,7 @@ describe('응답 계약 (e2e)', () => { .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual(vacation); + expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); await request(server) .put('/api/mail-vacation') @@ -3236,6 +3289,183 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('7단계 — 이미 받은 메일에 필터 적용 · 부재중의 isActive', () => { + describe('이미 받은 메일에 적용 (POST /api/mail-filter-runs)', () => { + const ME = 'filter.run@e2e.arasystem.test'; + const run = { + match: 'any', + conditions: [ + { + field: 'from', + operator: 'contains', + value: '@hanbit-partners.co.kr', + }, + ], + actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, + cursor: null, + }; + const post = (payload: unknown, email = ME) => + request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookieOf(email)) + .send(payload as object); + + it('200(201 이 아니다) — 확인 · 적용 · 남은 수와 cursor, 차례 수는 응답에 없다 · 받은 cursor 로 다시 보내면 이어 한다 (찾을 글은 앞뒤 공백을 지워 넘긴다)', async () => { + const first = await post({ + ...run, + conditions: [ + { ...run.conditions[0], value: ' @hanbit-partners.co.kr ' }, + ], + }).expect(200); + expect(body(first)).toEqual({ + success: true, + data: { + scanned: 500, + matched: 3, + remaining: 120, + cursor: 'e2enextcursor', + }, + }); + expect(filterRuns.at(-1)).toEqual({ user: ME, request: run }); + + const next = await post({ ...run, cursor: 'e2enextcursor' }).expect( + 200, + ); + expect(dataOf(next)).toEqual({ + scanned: 120, + matched: 1, + remaining: 0, + cursor: null, + }); + expect(filterRuns.at(-1)?.request.cursor).toBe('e2enextcursor'); + }); + + it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다 (전달 · 보낸편지함 · 임시보관함 · 할 일 없음 · cursor 빠짐 · 모르는 필드)', async () => { + const before = filterRuns.length; + const withActions = (overrides: object) => ({ + ...run, + actions: { ...run.actions, ...overrides }, + }); + const withoutCursor = Object.fromEntries( + Object.entries(run).filter(([key]) => key !== 'cursor'), + ); + const payloads = [ + withActions({ forwardTo: 'doyun.lee@e2e.arasystem.test' }), + withActions({ moveToMailboxId: 'e' }), + withActions({ moveToMailboxId: 'd' }), + withActions({ moveToMailboxId: null, markAsRead: false }), + { ...run, match: 'always' }, + { ...run, conditions: [] }, + { + ...run, + conditions: [{ ...run.conditions[0], value: '광고${x}' }], + }, + withoutCursor, + { ...run, cursor: 'not base64url!' }, + { ...run, cursor: 'a'.repeat(1_025) }, + { ...run, version: 'v' }, + { ...run, id: 'rule-1' }, + ]; + const answers: string[] = []; + for (const payload of payloads) { + const res = await post(payload); + answers.push(`${res.status} ${body(res).error?.code ?? ''}`); + } + expect(answers).toEqual(payloads.map(() => '400 VAL_001')); + expect(filterRuns).toHaveLength(before); + }); + + it('다른 규칙의 cursor 는 어댑터가 400 VAL_001 로 막는다 · 없는 메일함(지워짐)으로 옮기는 규칙은 받는다', async () => { + const res = await post({ ...run, cursor: OTHER_RULE_CURSOR }).expect( + 400, + ); + expect(body(res).error?.code).toBe('VAL_001'); + + await post({ + ...run, + actions: { ...run.actions, moveToMailboxId: 'deletedbox' }, + }).expect(200); + }); + + it( + '같은 직원의 적용이 진행 중이면 429 REQ_001 — 엔진에 묻지 않는다. 다른 직원은 된다, 끝나면 다시 된다', + async () => { + const gate = createGate(); + slowFilterRunGate = gate.opened; + try { + const before = filterRuns.length; + const slow = post(run).then((res) => res); + await waitUntil(() => filterRuns.length > before, GATE_DEADLINE_MS); + expect(filterRuns).toHaveLength(before + 1); + + const busy = await post(run).expect(429); + expect(body(busy).error?.code).toBe('REQ_001'); + expect(filterRuns).toHaveLength(before + 1); + + gate.open(); + expect((await slow).status).toBe(200); + } finally { + gate.open(); + slowFilterRunGate = null; + } + await post(run).expect(200); + await post(run, 'filter.run.other@e2e.arasystem.test').expect(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + + it('본문의 모양 한도(배열 하나 1,000)를 넘으면 DTO 검사 전에 400 VAL_001 — 로그인 전이어도 401 이 아니다', async () => { + const huge = JSON.stringify({ + ...run, + conditions: Array.from({ length: 80_000 }, () => ({})), + }); + for (const cookie of [cookieOf(ME), '']) { + const res = await request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookie) + .set('Content-Type', 'application/json') + .send(huge); + expect(res.status).toBe(400); + expect(body(res).error?.code).toBe('VAL_001'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + } + }); + }); + + describe('부재중 자동 응답의 isActive (GET · PUT /api/mail-vacation)', () => { + const ME = 'vacation.inactive@e2e.arasystem.test'; + + it('엔진에서 우리 규칙이 꺼져 있으면 isActive: false — 켜 둔 값은 그대로, 저장하면 다시 켜진다', async () => { + const vacation = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(vacation) + .expect(200); + inactiveScripts.add(ME); + + const read = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual({ ...vacation, isActive: false }); + + const saved = await request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(vacation) + .expect(200); + expect(dataOf(saved)).toEqual({ ...vacation, isActive: true }); + }); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). -- 2.52.0 From aef67aa310d8e3bc8df0f4aaa20a2ffd2f879f11 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 08:38:01 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(frontend):=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EA=B8=B0=20=EC=B0=BD=20=E2=80=94=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=20=EB=B0=9B=EC=9D=80=20=EB=A9=94=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9=20=C2=B7=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=ED=99=95=EC=9D=B8,=20=EB=B6=80=EC=9E=AC=EC=A4=91?= =?UTF-8?q?=20=EB=8F=99=EC=9E=91=20=EC=95=88=20=ED=95=A8=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지 보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서) - 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다. 실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다 - 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은 ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다 - 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄 - 진행 알림의 결과에도 단추를 붙인다(useToast) Co-Authored-By: Claude Opus 5.5 --- .../feedback/LeaveConfirmDialog.vue | 3 +- .../components/feedback/ModalDialog.spec.ts | 53 ++ .../src/components/feedback/ModalDialog.vue | 27 +- .../src/components/mail/MailboxPanel.spec.ts | 5 +- .../settings/MailFilterApplyField.vue | 132 +++++ .../components/settings/MailFilterDialog.vue | 102 +++- .../components/settings/MailFilterNotes.vue | 14 +- .../components/settings/MailFilterNotice.vue | 17 +- .../src/composables/useDialogLeaveConfirm.ts | 78 +++ frontend/src/composables/useMail.spec.ts | 37 +- frontend/src/composables/useMail.ts | 50 +- .../src/composables/useMailFilterActions.ts | 18 +- .../src/composables/useMailFilterDialog.ts | 8 +- .../src/composables/useMailFilterRun.spec.ts | 403 ++++++++++++++ frontend/src/composables/useMailFilterRun.ts | 135 +++++ frontend/src/composables/useToast.ts | 22 +- .../src/pages/settings/MailFilterPage.spec.ts | 513 +++++++++++++++++- .../src/pages/settings/MailFilterPage.vue | 7 +- .../pages/settings/MailVacationPage.spec.ts | 54 +- .../src/pages/settings/MailVacationPage.vue | 17 +- frontend/src/shared/constants/mail-filter.ts | 89 ++- .../src/shared/constants/mail-vacation.ts | 5 + frontend/src/shared/constants/mail.ts | 5 + frontend/src/shared/types/mail.ts | 47 +- .../src/shared/utils/mail-filter-run.spec.ts | 143 +++++ frontend/src/shared/utils/mail-filter-run.ts | 121 +++++ frontend/src/shared/utils/mail-filter.spec.ts | 29 + frontend/src/shared/utils/mail-filter.ts | 34 +- .../src/stores/mail-filter-run.store.spec.ts | 211 +++++++ frontend/src/stores/mail-filter-run.store.ts | 104 ++++ .../src/stores/mail-vacation.store.spec.ts | 37 +- frontend/src/stores/mail-vacation.store.ts | 14 +- frontend/src/stores/toast.store.spec.ts | 29 + 33 files changed, 2479 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/settings/MailFilterApplyField.vue create mode 100644 frontend/src/composables/useDialogLeaveConfirm.ts create mode 100644 frontend/src/composables/useMailFilterRun.spec.ts create mode 100644 frontend/src/composables/useMailFilterRun.ts create mode 100644 frontend/src/shared/utils/mail-filter-run.spec.ts create mode 100644 frontend/src/shared/utils/mail-filter-run.ts create mode 100644 frontend/src/stores/mail-filter-run.store.spec.ts create mode 100644 frontend/src/stores/mail-filter-run.store.ts diff --git a/frontend/src/components/feedback/LeaveConfirmDialog.vue b/frontend/src/components/feedback/LeaveConfirmDialog.vue index f0d5e99..b69db0e 100644 --- a/frontend/src/components/feedback/LeaveConfirmDialog.vue +++ b/frontend/src/components/feedback/LeaveConfirmDialog.vue @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/src/components/settings/MailFilterDialog.vue b/frontend/src/components/settings/MailFilterDialog.vue index cccc4f1..9c1255c 100644 --- a/frontend/src/components/settings/MailFilterDialog.vue +++ b/frontend/src/components/settings/MailFilterDialog.vue @@ -1,7 +1,8 @@ + + + + + + +
+ + + +
+ +
+

주소록

+
+ + +
+
+ +
+ 김아라 + 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=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다. -- 2.52.0 From 59420df1b98d1d5b0265c4bc0d75a308e5b97457 Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 14:06:27 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(docker):=20mail=20setup=20=E2=80=94?= =?UTF-8?q?=20=EC=82=AC=EB=82=B4=20=EB=AA=A9=EB=A1=9D=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=C2=B7=20=EB=A9=94=EC=9D=BC=EC=9D=84=20=EB=B0=9B=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20RCPT=20=EA=B1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다. 그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 — 백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다. 관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter — 위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고, setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다. 일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/env-structure.md | 5 +- .claude/skills/docker/SKILL.md | 6 +- .env.development.example | 5 + .env.production.example | 6 + docker-compose.yml | 3 + mail/README.md | 97 +++++- mail/scripts/setup.mjs | 588 ++++++++++++++++++++++++++++++++- 7 files changed, 693 insertions(+), 17 deletions(-) diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 6b48fd7..b4fe872 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -24,9 +24,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ ├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨 ├─▶ uploads : 운영은 named volume _uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트 ├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*· - │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS) + │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART) │ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포") │ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다 + │ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery) └─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md) MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다 ``` @@ -71,7 +72,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ > 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, > `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`), > 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)· -> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`· +> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)· > `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)· > `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다) > (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조. diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 0b3bbdc..8a4b88c 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -122,6 +122,8 @@ sudo docker compose --env-file .env.production logs -f backend **다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`). 홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 `X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등). + - **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다. + "직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다. - **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고 사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다). - **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"): @@ -294,7 +296,8 @@ node --env-file=.env.development mail/scripts/setup.mjs - 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)· `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다. - 데이터는 named volume `_mail-config`·`_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다). -- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회. +- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회, + 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일(→ RCPT 에서 550 — 받아 놓고 위조된 보낸 사람에게 반송하지 않게, mail/README.md "보안"). - setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도"). 신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다. @@ -307,6 +310,7 @@ node --env-file=.env.development mail/scripts/setup.mjs (파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포"). - 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다). **bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다. +- 사내 목록 계정 `@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정"). - ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도 대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안". diff --git a/.env.development.example b/.env.development.example index 4425fe9..fcfca61 100644 --- a/.env.development.example +++ b/.env.development.example @@ -140,6 +140,11 @@ MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env # ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 MAIL_GATEWAY_USER=mail-gateway@arasystem.test MAIL_GATEWAY_PASSWORD=change-me-in-local-env +# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때 +# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없고, setup 스크립트가 복구 관리자(위 MAIL_RECOVERY_ADMIN)로 만든다. +# 관리자·게이트웨이 아이디와 달라야 한다. 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "사내 목록 계정"). +# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙) +MAIL_DIRECTORY_LOCAL_PART=mail-directory # 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다 MAIL_JMAP_URL=http://mail:8080 # 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다 diff --git a/.env.production.example b/.env.production.example index d0470ea..58f8d9d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -149,6 +149,12 @@ MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY # ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY +# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때 +# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없다. 관리자·게이트웨이 아이디와 달라야 하고, 옮겨 올 메일함과 겹치지 않게 정한다. +# ⚠️ 엔진 관리자는 이 계정의 권한을 내줄 수 없어 setup 의 --recovery 실행(위 복구 관리자 절차)에서만 만들어진다 — 첫 배포 때 만들어지고, +# 이미 운영 중인 서버라면 --recovery 를 한 번 더 한다. 없는 동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 (mail/README.md "사내 목록 계정"). +# 바꾸면 --recovery 로 setup 을 다시 실행하고 backend 를 다시 띄운다. 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙) +MAIL_DIRECTORY_LOCAL_PART=mail-directory # 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망) MAIL_JMAP_URL=http://mail:8080 # 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다. diff --git a/docker-compose.yml b/docker-compose.yml index 7439cf2..a3f86b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,9 @@ services: - MAIL_ADMIN_LOCAL_PART=${MAIL_ADMIN_LOCAL_PART:-admin} # 관리자 외에 웹메일 로그인·대리 접근을 막을 계정 (주소 전체, 콤마). 비어 있어도 위 관리자는 항상 막는다 - MAIL_BLOCKED_ACCOUNTS=${MAIL_BLOCKED_ACCOUNTS:-} + # 사내 목록 계정 아이디 — mail setup 이 만든 비밀번호 없는 계정(<이 값>@MAIL_DOMAIN). 백엔드가 게이트웨이 대리 접근으로 + # 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는다 (docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md) + - MAIL_DIRECTORY_LOCAL_PART=${MAIL_DIRECTORY_LOCAL_PART:-mail-directory} # 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다 # (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md) - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} diff --git a/mail/README.md b/mail/README.md index d20c5f6..375c127 100644 --- a/mail/README.md +++ b/mail/README.md @@ -46,6 +46,7 @@ | `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` | | 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` | | `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` | +| `@` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 | | `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` | --- @@ -103,16 +104,23 @@ node --env-file=.env.development mail/scripts/setup.mjs 5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만 6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`). 공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안) -7. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — + 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 + (`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정") +7. **사내 목록 계정** — 계정 목록 읽기 전용 역할(`authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery`) + 비밀번호 없는 계정 `@도메인`. + 엔진 관리자는 이 권한을 내줄 수 없어 **복구 관리자가 있을 때만**(개발 · 운영 `--recovery`) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고 + 역할·계정이 없거나 다르면 `[주의]` — 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로 `Principal/query`·`get` 을 해 본다 ([사내 목록 계정](#사내-목록-계정)) +8. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 — 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)). 계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — - 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 8 의 리로드로 반영된다 -8. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. + 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 9 의 리로드로 반영된다 +9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다. - 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)) -9. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 8 의 리로드로 반영된 것을 확인한 뒤에만 10 -10. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인 + 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)). + 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다) +10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 + (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다) +11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인 그 밖의 안전장치: @@ -139,16 +147,62 @@ node --env-file=.env.development mail/scripts/setup.mjs | `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 | | `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" | | `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) | +| `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) | - 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401 - 메일함 접근: Basic `<직원 주소>%` : `MAIL_GATEWAY_PASSWORD` (게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403) +- 사내 목록: Basic `@%` : `MAIL_GATEWAY_PASSWORD` → 세션의 `urn:ietf:params:jmap:principals` 계정으로 + `Principal/query`·`Principal/get` ([사내 목록 계정](#사내-목록-계정) — 계정이 없으면 401, 역할에 `authenticate` 가 없으면 403) - ⚠️ 세션 응답의 `apiUrl`·`downloadUrl`·`uploadUrl`·`eventSourceUrl` 은 **`MAIL_PUBLIC_URL` 기준 절대 주소**다 (개발 `http://localhost:8180/jmap/`). 백엔드는 이 주소를 따르지 말고 `MAIL_JMAP_URL` 에 경로만 붙여 쓴다. - `Email/query` 의 `subject` 필터는 단어 단위 검색이고 제목 앞의 `[태그]` 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다. - 대리 접근은 메일 엔진 로그 `auth.success` 에 `accountName`(대상 직원)·`details`(게이트웨이)·`remoteIp` 로 남는다. - 신뢰 대역은 자동 차단에서 빠지므로 **로그인 무차별 대입 방어는 백엔드가 맡아야 한다** (로그인 엔드포인트 엄격 한도 — [security](../.claude/rules/security.md)). +### 사내 목록 계정 + +백엔드가 사내 목록(계약 [3절 "사내 목록"](../docs/mail/api-contract.md) — `GET /api/directory-entries` 와 받는 사람 추천의 "사내")을 채우려고 +엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는 전용 계정이다. 직원 역할에서는 이 권한을 뺐으므로([보안](#보안)) 계정 목록을 읽는 길은 이 계정 하나뿐이다. + +| 항목 | 값 | +| --- | --- | +| 주소 | `@` (기본 `mail-directory@`) — 관리자·게이트웨이 아이디와 같으면 setup 이 아무것도 바꾸기 전에 멈춘다 | +| 역할 | 설명 `사내 목록 — 계정 목록 읽기 전용 [setup.mjs]` · 권한 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐 (빼는 권한·물려받는 역할 없음). `authenticate` 가 없으면 대리 접근이 403 이다 | +| 계정 | 표시 이름 `사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]` · **비밀번호 없음** · 저장 한도 `quotas.maxDiskQuota` 1,048,576바이트 | +| 쓰는 법 | 백엔드만 — 게이트웨이 대리 접근 `<사내 목록 주소>%` 로 세션의 `urn:ietf:params:jmap:principals` 계정에 `Principal/query`·`Principal/get` | +| 엔진이 주는 칸 | `id`·`type`(`individual` 개인 · `group` 공용 주소)·`name`(로그인 이름)·`description`(표시 이름)·`email` 뿐 — 비밀번호 같은 칸은 없다. 관리자·게이트웨이·이 계정도 목록에 나오므로 빼는 것은 백엔드 몫이다 (계약 3절 "빼는 계정") | +| 메일 | 받지 않는다 — 이 주소(+태그 꼴 포함)로 오는 메일은 RCPT 에서 없는 주소처럼 550 이다([보안](#보안) "메일을 받지 않는 시스템 계정"). 그 뒤의 막으로 역할에 `emailReceive` 가 없고(배달하지 않는다) 저장 한도도 작다 | + +- **왜 복구 관리자인가** — 엔진 관리자 역할(System Administrator)에는 `jmapPrincipal*` 이 없고, 엔진은 역할·계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를 바꾸는 쪽이 가졌는지 본다. + 그래서 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 (`You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet`). + 관리자로 되는 것은 계정의 표시 이름·저장 한도 고치기, 비밀번호 빼기, 계정 지우기다 (2026-10-29, 0.16.27 에서 확인). +- **언제 만들어지나** — 개발은 setup 을 돌릴 때마다(env 파일에 복구 관리자가 있다). 운영은 [운영 배포](#운영-배포)의 `--recovery` 실행(첫 배포)에서 만들어진다. + **이미 운영 중인 서버**는 같은 절차(1 복구 관리자 주기 → `up -d mail` → 3 `--recovery` → 4·5 끄기)를 한 번 더 한다 — 이미 맞는 나머지는 `[유지]` 다. +- **복구 관리자 없이 돌리면**(운영의 보통 실행) — 읽어서 확인하고, 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기·옛 계정 지우기)만 맞춘다. + 역할·계정이 없거나 역할이 다르면 `[주의]` 만 내고 **실패로 끝내지 않는다** — 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록(최근 90일)을 쓴다 (계약 3절). + `--recovery` 로 돌렸는데 복구 관리자로 로그인하지 못해 맞추지 못했으면 실패로 끝난다(mail 컨테이너를 그 값으로 다시 만들었는지 본다). +- **비밀번호가 없다** — 이 계정으로는 직접 로그인할 수 없고, 게이트웨이 비밀번호를 가진 백엔드만 대리 접근으로 쓴다. + 누가 비밀번호를 넣으면 그것으로 계정 목록(관리자·게이트웨이 주소 포함)을 읽을 수 있으므로 setup 이 다음 실행에서 뺀다(관리자로도 된다). +- 이 역할은 기본 직원 역할(`User`)과 따로다 — 직원 역할에서 `jmapPrincipal*` 을 빼는 단계([setup 스크립트가 하는 일](#setup-스크립트가-하는-일) 6)는 이 역할을 건드리지 않는다. + +지우거나 바꾸면: + +| 한 일 | 생기는 일 | 되돌리기 | +| --- | --- | --- | +| 계정을 지움 | 대리 접근이 401 → 사내 목록 `503 MAIL_001`, 받는 사람 추천의 "사내"는 로그인한 직원 기록으로 | 복구 관리자로 setup (운영 `--recovery`) | +| 역할을 지움 | 계정이 쓰는 동안은 엔진이 거절한다(`objectIsLinked`) — 계정을 먼저 지워야 지워진다 | 위와 같다 | +| 역할에 권한을 더함 · 물려받는 역할을 붙임 | 그 권한이 이 계정(= 게이트웨이를 가진 백엔드)에 열린다 — setup 이 `[변경]`(복구 관리자) 또는 `[주의]` | 복구 관리자로 setup — 관리자로는 뺄 수 없다 | +| 역할에서 `authenticate`·`jmapPrincipal*` 을 뺌 (빼는 권한에 넣음 포함) | 계정 목록을 읽지 못한다 → 사내 목록 `503 MAIL_001` | 복구 관리자로 setup | +| 계정의 역할을 바꿈 | 위의 둘 중 하나 | 복구 관리자로 setup | +| 계정에 비밀번호를 넣음 | 그 비밀번호로 계정 목록을 읽을 수 있다 | setup (관리자로도 뺀다) | +| 다른 계정에 이 역할을 줌 | 그 계정이 계정 목록 전체를 읽는다 — setup 이 `[주의]` | 관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) | +| `MAIL_DIRECTORY_LOCAL_PART` 를 바꿈 | 복구 관리자로 돌린 setup 이 새 계정을 만들고, 그 뒤에 옛 계정(setup 표시 + 이 역할 하나)을 지운다 — backend 도 다시 띄운다(`up -d backend`) | 값을 되돌리고 같은 절차 | + +- 확인 (2026-10-29, 개발 엔진 0.16.27): 처음 실행은 `[생성]` 둘(역할·계정)과 `[확인] 사내 목록 — 대리 접근 … (Principal/query · get)`, 다시 돌리면 `[유지]`. + 역할에 권한·빼는 권한·물려받는 역할을 붙이고 계정에 비밀번호를 넣은 뒤 돌리면 복구 관리자로 모두 `[변경]`, 복구 관리자 없이 돌리면 계정만 `[변경]`(관리자로)이고 역할은 `[주의]` · 종료 코드 0. + 계정을 지우고 복구 관리자 없이 돌리면 `[주의] 사내 목록 계정이 없습니다 (계정 없음)` · 종료 코드 0. 아이디를 바꿨다 되돌리면 `[생성]` 새 계정 · `[삭제]` 옛 계정. + --- ## 운영 배포 @@ -170,6 +224,7 @@ node --env-file=.env.development mail/scripts/setup.mjs - **setup 을 돌릴 사용자** — docker 를 쓸 수 있어야 한다(docker 그룹, 또는 매번 `sudo -E`). 저장소는 그 사용자로 클론한다(`sudo git clone` 금지) — `data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다). - **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다). +- **`MAIL_DIRECTORY_LOCAL_PART`**(사내 목록 계정, 기본 `mail-directory`)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 ([사내 목록 계정](#사내-목록-계정)). 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다. @@ -206,6 +261,8 @@ export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings node --env-file=.env.production mail/scripts/setup.mjs ``` +- 3)의 `--recovery` 실행은 **사내 목록 계정**도 만든다 — 엔진 관리자는 그 권한을 내줄 수 없어 복구 관리자로만 된다 ([사내 목록 계정](#사내-목록-계정)). + 이 기능(8단계) 전부터 운영 중인 서버는 1)~5) 를 한 번 더 한다. 5)의 보통 실행은 그 계정이 있고 맞는지 읽어서 확인하고, 없으면 `[주의]` 만 낸다. - 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다. - 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면 경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https:///jmap/session` → `403`. @@ -240,6 +297,9 @@ sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | ``` - 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180::8080 <메일 호스트>` → . setup 은 운영에서 직원 계정을 만들지 않는다. + ⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어 + `You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). + 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). - `MAIL_HOSTNAME` 은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다. - 관리자 비밀번호를 바꾸려면: env 의 `MAIL_ADMIN_PASSWORD` 변경 → 위 1)·`up -d mail` → `--recovery` 실행 → 4)·5). - 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은 `sudo docker compose --env-file .env.production down` 후 `up -d` 로 네트워크와 @@ -502,6 +562,21 @@ sudo docker compose --env-file .env.production start mail - ⚠️ 되돌리기는 **일반 관리자로는 안 된다** — 관리자 역할(System Administrator)에 이 권한이 없어 "You are not authorized to grant permissions" 로 거부된다. 복구 관리자(일회성)로 `x:Role/set` `update {"": {"enabledPermissions/jmapPrincipalGet": true, …}}` → `InvalidateCaches` 를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면 `ensureUserRoleWithoutPrincipals` 단계를 먼저 고친다) + - 백엔드가 사내 목록을 읽는 것은 따로 만든 사내 목록 계정(권한 셋, 비밀번호 없음)으로만이다 — 직원 역할에 되돌려 주지 않는다 ([사내 목록 계정](#사내-목록-계정)). + - 직원이 공용 주소(Group 계정)의 구성원이 되어도 Group 역할의 Principal 권한을 물려받지 않는다 (2026-10-29 확인 — 구성원이 된 뒤에도 `Principal/query` → forbidden). +- **메일을 받지 않는 시스템 계정은 RCPT 에서 거절한다 (backscatter 막기).** 관리자 · 게이트웨이 · 사내 목록 계정의 역할에는 `emailReceive` 가 없다(관리자의 System Administrator 역할도). + 그대로 두면 엔진은 그 주소로 온 메일을 RCPT 에서 250 으로 받아 놓고, 배달할 때 거절해(`security.unauthorized … details = "emailReceive"`) **보낸 사람에게 반송(DSN)한다** — + 보낸 사람을 위조한 스팸이면 모르는 사람에게 반송이 나가 서버 평판이 떨어진다. 없는 주소는 처음부터 RCPT 에서 `550 5.1.2` 다. + - setup 이 `x:MtaStageRcpt` 의 `script` 식을 "받는 주소가 그 계정이거나 그 +태그 꼴일 때만" 시스템 Sieve 스크립트 `arasystem-system-recipients`(`reject "550 5.1.2 Mailbox does not exist."`)를 + 고르게 맞춘다(ReloadSettings 로 반영). 없는 주소와 같은 응답이라 바깥에서 이 계정들이 있는지도 알 수 없다. 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다. + - 대상: 게이트웨이 · 사내 목록 계정과, 관리자 역할에 `emailReceive` 가 없을 때의 **관리자 역할 계정 모두**(나중에 더 만든 관리자도 — setup 을 다시 돌리면 식에 더해진다). + 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 그 주소는 넣지 않는다(실제 메일함일 수 있다). + - 엔진은 식의 `rcpt` 를 소문자로 주지만 +태그(부주소)는 떼지 않는다 — 그래서 `<아이디>+…@<도메인>` 꼴을 따로 본다(구분자는 엔진 기본 `+` 로 가정한다). + - 웹메일에서 이 주소로 보내면(엔진 안의 인증된 세션) 같은 RCPT 에서 끝난다 — 제출의 받는 사람별 결과가 `delivered: no` · `550 5.1.2` 로 바로 오고(계약 2절 "받는 사람별 결과") 반송은 없다. + - RCPT 단계 스크립트(`x:MtaStageRcpt` 의 `script`)는 하나뿐이다. 기본값(없음)이거나 setup 이 써 둔 식일 때만 고치고, 다른 설정이 쓰고 있으면 건드리지 않고 `[주의]` 를 낸다 — + 그때는 그 식에 이 계정들일 때 `arasystem-system-recipients` 를 고르는 갈래를 합친다. + - 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550, + 직원 · 직원 +태그 · 이름이 `admin` 으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면 `[유지]`, 관리자를 더 만들거나 지우면 `[변경]`. - **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고 일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5). - **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는 @@ -574,5 +649,15 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) | 웹메일 발송의 받는 사람이 `452 4.4.5 Rate limit exceeded` 로 거절된다 | setup 출력에 `받는 쪽 한도 … 인증 없는 세션에만` 이 있는지 본다. 받는 쪽 한도를 새로 만들었다면 그 `match` 도 — 엔진 로그 `smtp.rate-limit-exceeded` 의 `id` 가 어느 한도인지 알려 준다 ([엔진 한도](#엔진-한도)) | | 한 직원의 첨부 올리기 · 필터 · 부재중 저장이 429 `REQ_001` 로 막힌다 (백엔드 로그 `"reason":"mail-engine-rate-limit","call":"upload"`) | 엔진의 임시 업로드 한도(계정마다 1시간 — 크기 합계 · 개수)를 넘었다. 올린 것이 만료되면(최대 1시간) 풀린다. setup 출력에 `임시 업로드 한도 — 계정마다 크기 합계 …` 가 있는지 보고, 개수를 넘는 직원이 잦으면 `maxUploadCount` 를 올린다 ([엔진 한도](#엔진-한도) "임시 업로드 한도") | | `임시 업로드 한도(x:Jmap 의 uploadQuota = …)를 읽지 못해 바꾸지 않았습니다` · `[주의] 임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다` | 엔진이 숫자가 아닌 값(판을 올려 속성이 바뀌었는지 본다)이나 0 을 돌려줬다 — 관리 API `x:Jmap/get` 으로 `uploadQuota` 를 보고 첨부 합계 한도 × 20 이상으로 둔 뒤 `ReloadSettings` (0 이 제한 없음인지는 확인하지 않았다) | +| `[주의] 사내 목록 계정이 없습니다 (…)` | 복구 관리자 없이 돌렸다 — 실패가 아니다. 운영: "운영 배포" 1)·`up -d mail` → 3) `--recovery` → 4)·5) 를 한 번. 개발: `MAIL_RECOVERY_ADMIN` 을 채우고 `up -d mail` 후 setup. 그동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 ([사내 목록 계정](#사내-목록-계정)) | +| `[주의] 사내 목록 계정이 setup 기준과 다릅니다 (역할의 권한이 다름 … · 계정의 역할이 다름)` | 누가 역할·계정을 고쳤다. 관리자로는 되돌릴 수 없다(엔진이 거절) — 위와 같이 복구 관리자로 setup. 이어서 나오는 `[확인]`·`[주의]` 줄이 지금 백엔드가 읽을 수 있는지 알려 준다 | +| `…(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 …` | 그 주소를 실제 계정이 쓴다(그 계정은 건드리지 않았다). `MAIL_DIRECTORY_LOCAL_PART` 를 아무도 쓰지 않는 아이디로 바꾸고 `up -d backend` → setup 재실행 | +| `사내 목록 계정으로 계정 목록을 읽지 못했습니다 — …: 세션 HTTP 403` · `Principal/… → forbidden` | 역할에 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 가 없거나 빼는 권한에 들었다 — 복구 관리자로 setup. `세션 HTTP 401` 이면 계정이 없거나 게이트웨이 비밀번호·허용 IP 문제다 | +| `[주의] 사내 목록 역할을 다른 계정도 가지고 있습니다: …` | 관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) | +| `[유지] 옛 사내 목록 계정 … — 새 계정 … 가 준비된 뒤에 지운다` | `MAIL_DIRECTORY_LOCAL_PART` 를 바꿨는데 새 계정을 아직 만들지 못했다(복구 관리자 없음) — 복구 관리자로 setup 을 돌리면 새 계정을 만들고 옛 계정을 지운다 | +| 사내 목록(`GET /api/directory-entries`)이 `503 MAIL_001` | setup 출력의 요약 `사내 목록` 줄을 본다 — 준비되지 않았으면 위 줄들. 준비됐는데도 그러면 backend 의 `MAIL_DIRECTORY_LOCAL_PART` 가 setup 과 같은지 보고 `up -d backend` | +| `[주의] RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다` | 메일을 받지 않는 시스템 계정은 지금처럼 받아 놓고 반송한다. 그 식에 시스템 계정일 때 `arasystem-system-recipients` 를 고르는 갈래를 합치거나, 그 설정이 필요 없으면 기본값(`{"match":{},"else":"false"}`)으로 되돌리고 setup 재실행 ([보안](#보안)) | +| `시스템 계정의 RCPT 거절 확인 실패 — …` | setup 이 `ReloadSettings` 까지 했는지, `x:MtaStageRcpt` 의 `script` 식과 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive: true`)를 관리 API 로 본다. 확인은 메일 컨테이너 안의 `curl` 로 RCPT 까지만 보낸다 | +| `[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 …` | 관리자 역할의 이름을 바꿨거나 `emailReceive` 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다 | | `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) | | 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` | diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index 2755084..73a5bd1 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -6,7 +6,7 @@ * node --env-file=.env.development mail/scripts/setup.mjs * node --env-file=.env.development mail/scripts/setup.mjs --print-dns # 권장 DNS 레코드도 출력 * MAIL_RECOVERY_ADMIN=… node --env-file=.env.production mail/scripts/setup.mjs --recovery - * # 운영의 최초 bootstrap·관리자 복구 — 복구 관리자는 일회성으로만 준다 + * # 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다 * * 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md). * 비밀번호는 어떤 경우에도 출력하지 않는다. @@ -32,15 +32,22 @@ * 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능) * 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게 * 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다) + * 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — + * 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다) * 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다 * (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안") - * 7. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영) + * 7. 사내 목록 계정 목록(Principal) 읽기 전용 역할 + 비밀번호 없는 계정(@도메인) — 백엔드가 게이트웨이 + * 대리 접근으로 직원 이름 · 주소만 읽는다 (계약 docs/mail/api-contract.md 3절 "사내 목록"). + * 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때(개발 · 운영 --recovery)만 만들고 맞춘다. + * 없으면 관리자로 되는 것(설명 · 저장 한도 · 비밀번호 빼기)만 맞추고 [주의] — 실패로 끝내지 않는다 + * 8. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영) * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 - * 8. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인 - * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 9 보다 먼저 한다) - * 9. [개발] 테스트 직원 계정 + * 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인 + * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다. + * 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다) + * 10. [개발] 테스트 직원 계정 * * 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행) * — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기 @@ -99,6 +106,8 @@ const HTTP_ENDPOINT_POLICY = { }; /** 기본 직원 역할 — 계정의 roles {"@type":"User"} 가 가리키는 기본 제공 역할 */ const USER_ROLE_DESCRIPTION = 'User'; +/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */ +const ADMIN_ROLE_DESCRIPTION = 'System Administrator'; /** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */ const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/; /** @@ -173,6 +182,60 @@ const GATEWAY_PERMISSIONS = { authenticate: true, impersonate: true }; const GATEWAY_DESCRIPTION = `메일 게이트웨이 (백엔드 대리 접근 전용) ${MANAGED_MARK}`; const ALLOWED_IP_REASON = `${MANAGED_MARK} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`; +/** + * 사내 목록 계정 아이디 기본값 — 루트 env MAIL_DIRECTORY_LOCAL_PART. 백엔드도 이 값 하나를 읽으므로 기본값이 같아야 한다 + * (형식은 위 LOCAL_PART_PATTERN — MAIL_ADMIN_LOCAL_PART 와 같은 규칙). + */ +const DEFAULT_DIRECTORY_LOCAL_PART = 'mail-directory'; +/** + * 사내 목록 계정 — 백엔드가 엔진의 계정 목록(JMAP Principal)에서 직원 이름 · 주소만 읽는 전용 계정 + * (계약 docs/mail/api-contract.md 3절 "사내 목록"). 역할은 로그인 + 계정 목록 읽기뿐이고 비밀번호가 없다 — + * 백엔드가 게이트웨이 대리 접근(<사내 목록 주소>%<게이트웨이 주소>)으로만 쓴다. authenticate 가 없으면 대리 접근이 403 이다. + * ⚠️ 엔진 관리자 역할(System Administrator)에는 jmapPrincipal* 이 없고, 엔진은 역할 · 계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를 + * 바꾸는 쪽이 가졌는지 본다 — 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 + * ("You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet"). 그래서 복구 관리자로만 만들고 맞춘다. + * 관리자로 되는 것: 계정의 설명 · 저장 한도 고치기, 비밀번호 빼기, 계정 지우기 (2026-10-29 Stalwart 0.16.27 에서 확인 — ensureDirectoryAccount). + */ +const DIRECTORY_ROLE_DESCRIPTION = `사내 목록 — 계정 목록 읽기 전용 ${MANAGED_MARK}`; +const DIRECTORY_PERMISSIONS = { authenticate: true, jmapPrincipalGet: true, jmapPrincipalQuery: true }; +const DIRECTORY_DESCRIPTION = `사내 목록 (백엔드 계정 목록 읽기 전용) ${MANAGED_MARK}`; +/** + * 사내 목록 계정의 저장 한도(x:Account 의 quotas — maxDiskQuota, 바이트). 주소가 있으니 메일이 쌓이지 않게 작게 둔다. + * 역할에 emailReceive 가 없어 엔진은 이 주소로 온 메일을 배달하지 않고 보낸 사람에게 반송한다(security.unauthorized "emailReceive", + * 2026-10-29 확인) — 한도는 그 위의 두 번째 막이다. 0 은 받기는 하지만 뜻(제한 없음인지)을 확인하지 않아 쓰지 않는다. + */ +const DIRECTORY_QUOTAS = { maxDiskQuota: 1024 * 1024 }; +/** 계정의 권한 설정 기본값 — 역할에서만 물려받는다 (계정에 직접 권한을 더하지 않았다) */ +const INHERIT_PERMISSIONS = { '@type': 'Inherit' }; +/** JMAP 계정 목록(Principal) — 메일 쪽 세션의 이 능력 계정으로 Principal/query · get 을 부른다 */ +const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals'; + +/** + * 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 받는 쪽 RCPT 단계에서 없는 주소처럼 거절한다. + * 세 계정의 역할에는 emailReceive 가 없어(관리자의 System Administrator 역할도) 엔진은 RCPT 를 250 으로 받아 놓고, 배달할 때 + * security.unauthorized "emailReceive" 로 거절해 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 메일이면 모르는 사람에게 반송이 나가 + * 서버 평판이 떨어진다(backscatter). 없는 주소는 처음부터 RCPT 에서 550 이다 (2026-10-29 Stalwart 0.16.27 에서 확인). + * 방법: x:MtaStageRcpt 의 script 식이 받는 주소가 이 계정들일 때만 아래 시스템 Sieve 스크립트를 고르고, 스크립트는 reject 한다 + * (reject 의 글이 그대로 SMTP 응답이 된다). 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다. + * 엔진은 식의 rcpt 를 소문자로 넘기지만 +태그(부주소)는 떼지 않는다 — 그래서 '<아이디>+…@<도메인>' 꼴도 함께 본다. + * 반영은 ReloadSettings 뒤다 (main 의 reloadSettings — 그 전에는 옛 규칙으로 받는다). + */ +/** 엔진이 없는 주소에 주는 응답과 같은 글 — 바깥에서 이 계정들이 있는지 알아볼 수 없게 */ +const SYSTEM_RCPT_REPLY = '550 5.1.2 Mailbox does not exist.'; +const SYSTEM_RCPT_SCRIPT = { + name: 'arasystem-system-recipients', + description: `메일을 받지 않는 시스템 계정 — RCPT 에서 거절 ${MANAGED_MARK}`, + contents: ['require ["reject"];', `reject "${SYSTEM_RCPT_REPLY}";`].join('\r\n'), +}; +/** x:MtaStageRcpt 의 script 기본값 — RCPT 단계 스크립트 없음 */ +const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' }; +/** 식에 그대로 넣는 주소의 꼴 — 따옴표 · 공백 · 식 문법이 들어갈 수 없게 (아이디는 loadConfig 가 이미 LOCAL_PART_PATTERN 으로 봤다) */ +const PLAIN_ADDRESS_PATTERN = /^[a-z0-9._-]+@[a-z0-9.-]+$/; +/** RCPT 거절 확인에 쓰는 EHLO 이름 — 예약 도메인 (엔진은 FQDN 이 아닌 EHLO 를 거절한다) */ +const RCPT_CHECK_EHLO = 'setup-check.example.net'; +/** RCPT 거절 확인에서 한 세션에 보내는 RCPT 수 상한 — 엔진은 한 세션의 RCPT 실패를 5번까지만 받는다 (x:MtaStageRcpt 의 maxFailures) */ +const RCPT_CHECK_MAX_PROBES = 4; + /** 개발 전용 — 사내 도메인 밖 메일을 받는 Mailpit (docker-compose.dev.yml 의 mailpit 서비스) */ const DEV_RELAY = { name: 'mailpit', @@ -350,6 +413,21 @@ function loadConfig(argv) { errors.push(`MAIL_ADMIN_LOCAL_PART(${adminLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`); } + // 사내 목록 계정 아이디 — 백엔드가 같은 값으로 그 계정을 대리 접근해 계정 목록을 읽는다 (ensureDirectoryAccount) + const directoryLocalRaw = readEnv('MAIL_DIRECTORY_LOCAL_PART') ?? DEFAULT_DIRECTORY_LOCAL_PART; + const directoryLocalPart = directoryLocalRaw.toLowerCase(); + if (!LOCAL_PART_PATTERN.test(directoryLocalPart)) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${directoryLocalRaw}").`, + ); + } else if (directoryLocalPart === adminLocalPart) { + errors.push('MAIL_DIRECTORY_LOCAL_PART 가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.'); + } else if (directoryLocalPart === gatewayLocal) { + errors.push('MAIL_DIRECTORY_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.'); + } else if (!isProduction && DEV_USERS.some((user) => user.name === directoryLocalPart)) { + errors.push(`MAIL_DIRECTORY_LOCAL_PART(${directoryLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`); + } + // 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다 const blockedAccounts = (readEnv('MAIL_BLOCKED_ACCOUNTS') ?? '') .split(',') @@ -477,6 +555,7 @@ function loadConfig(argv) { gatewayAddress, gatewayLocal, gatewayPassword, + directoryLocalPart, appNetworkSubnet, networkName: `${projectName}_app-network`, trustedNetworks, @@ -1357,7 +1436,8 @@ function preflight(cfg) { /** * 직원(User) 역할에서 Principal 조회 권한을 뺀다. * 기본값으로는 직원 계정이 JMAP Principal/get 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다. - * 웹메일은 백엔드를 거치고 백엔드는 Principal 메서드를 쓰지 않는다. 대리 접근 세션도 직원 역할의 권한을 쓴다. + * 웹메일은 백엔드를 거치고, 대리 접근 세션도 직원 역할의 권한을 쓴다. 백엔드가 계정 목록을 읽는 것은 사내 목록 계정 + * (따로 만든 역할 — ensureDirectoryAccount)으로만이고, 이 함수는 기본 직원 역할(설명 "User")만 건드린다. * 역할 권한은 캐시되므로 ReloadSettings 가 아니라 InvalidateCaches 로 반영한다 (Stalwart 0.16 에서 확인). */ async function ensureUserRoleWithoutPrincipals(client) { @@ -1380,6 +1460,308 @@ async function ensureUserRoleWithoutPrincipals(client) { log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`); } +// ============================================================ +// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만) +// ============================================================ + +/** 복구 관리자의 관리 API 클라이언트 — 값이 없거나, 로그인되지 않거나, 관리 권한이 없으면 null */ +async function openRecoveryClient(cfg) { + if (!cfg.recovery) return null; + const client = await openClient(cfg, cfg.recovery.user, cfg.recovery.password); + if (!client?.accountId) return null; + return (await hasManagementAccess(client)) ? client : null; +} + +/** 값이 true 인 키 (정렬) */ +function trueKeys(value) { + return Object.entries(value ?? {}) + .filter(([, flag]) => flag === true) + .map(([key]) => key) + .sort(); +} + +/** 사내 목록 역할이 setup 기준(DIRECTORY_PERMISSIONS 만 · 빼는 권한 없음 · 물려받는 역할 없음)과 다른 점 — 같으면 빈 배열 */ +function directoryRoleDrift(role) { + const expected = Object.keys(DIRECTORY_PERMISSIONS); + const enabled = trueKeys(role.enabledPermissions); + const drift = []; + const missing = expected.filter((key) => !enabled.includes(key)); + const extra = enabled.filter((key) => !expected.includes(key)); + const disabled = trueKeys(role.disabledPermissions); + const inherited = trueKeys(role.roleIds); + if (missing.length > 0) drift.push(`모자란 권한 ${missing.join(', ')}`); + if (extra.length > 0) drift.push(`더 붙은 권한 ${extra.join(', ')}`); + if (disabled.length > 0) drift.push(`빼는 권한 ${disabled.join(', ')}`); + if (inherited.length > 0) drift.push(`물려받는 역할 ${inherited.join(', ')}`); + return drift; +} + +/** 계정이 그 역할 하나만 가졌는가 (roleId 가 없으면 false) */ +function holdsOnlyRole(account, roleId) { + return Boolean(roleId) && account.roles?.['@type'] === 'Custom' && sameTrueSet(account.roles.roleIds, [roleId]); +} + +/** 계정 하나를 출력할 이름 — 주소, 없으면 아이디 */ +function accountLabel(account) { + return account.emailAddress ?? account.name ?? account.id; +} + +/** + * 사내 목록 계정을 맞춘다 — @<도메인>, 역할 DIRECTORY_PERMISSIONS 하나 · 비밀번호 없음 · 저장 한도 DIRECTORY_QUOTAS + * (계약 docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md "사내 목록 계정"). + * - 복구 관리자를 쓸 수 있으면(개발 · 운영 --recovery) 역할 · 계정을 만들거나 맞춘다. + * - 없으면(운영의 일반 실행) 관리자로 되는 것(계정의 설명 · 저장 한도 · 비밀번호 빼기 · 옛 계정 지우기)만 맞추고, 역할 · 계정이 없거나 + * 권한이 다르면 [주의] 만 낸다 — 실패로 끝내지 않는다 (그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다 — 계약 3절). + * 관리자로는 역할의 권한을 고칠 수 없다(빼기도 — DIRECTORY_ROLE_DESCRIPTION 머리말). + * - 그 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 건드리지 않고 실패로 보고한다 (백엔드는 그 주소를 사내 목록 · 웹메일 로그인에서 뺀다). + * - MAIL_DIRECTORY_LOCAL_PART 를 바꿨으면 옛 계정(setup 표시 + 이 역할 하나)은 새 계정이 준비된 뒤에 지운다. + * 이 계정은 메일을 보내지도 받지도 못한다(역할에 emailSend · emailReceive 가 없다) — 그래서 개발의 Mailpit 경로 리로드 전에 만들어도 된다. + * 준비되면 백엔드가 쓰는 방식 그대로 읽어 본다 (checkDirectoryAccess). + * 돌려주는 값: { address, state } — state 는 ready(준비됨) · unreadable(맞췄지만 읽지 못함) · pending(복구 관리자가 있어야 맞춘다) · conflict(다른 계정의 주소) + */ +async function ensureDirectoryAccount(admin, cfg, domain) { + const address = `${cfg.directoryLocalPart}@${domain.name}`; + const label = `사내 목록 계정 ${address}`; + const roleLabel = `역할 — 사내 목록 계정 목록 읽기 (${Object.keys(DIRECTORY_PERMISSIONS).join(', ')})`; + const shape = `비밀번호 없음 · 저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트 · 역할은 위 하나`; + const recovery = await openRecoveryClient(cfg); + const via = recovery ? ' (복구 관리자 사용)' : ''; + // 계정의 설명 · 저장 한도 · 비밀번호 빼기 · 지우기는 관리자로도 된다 + const writer = recovery ?? admin; + /** 복구 관리자가 있어야 맞출 수 있는 것 */ + const pending = []; + let changed = false; + + // 1) 역할 — 설명으로 찾는다 + const roles = await listAll(admin, 'Role', ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds']); + const role = roles.find((item) => item.description === DIRECTORY_ROLE_DESCRIPTION); + let roleId = role?.id ?? null; + if (!role) { + if (recovery) { + const result = await setObjects(recovery, 'Role', { + create: { role: { description: DIRECTORY_ROLE_DESCRIPTION, enabledPermissions: DIRECTORY_PERMISSIONS } }, + }); + roleId = result.created.role.id; + changed = true; + log(`[생성] ${roleLabel}${via}`); + } else { + pending.push('역할 없음'); + } + } else { + const drift = directoryRoleDrift(role); + if (drift.length === 0) { + log(`[유지] ${roleLabel}`); + } else if (recovery) { + await setObjects(recovery, 'Role', { + update: { [role.id]: { enabledPermissions: DIRECTORY_PERMISSIONS, disabledPermissions: {}, roleIds: {} } }, + }); + changed = true; + log(`[변경] ${roleLabel} 로 되돌림 — 전: ${drift.join(' · ')}${via}`); + } else { + pending.push(`역할의 권한이 다름(${drift.join(' · ')})`); + } + } + + // 2) 계정 — 아이디 + 도메인으로 찾는다 (비밀번호 값은 읽지도 출력하지도 않는다 — 있는지만 본다) + const accounts = await listAll(admin, 'Account', [ + 'name', + 'domainId', + 'emailAddress', + 'description', + 'roles', + 'credentials', + 'quotas', + 'permissions', + 'memberGroupIds', + ]); + const existing = accounts.find((account) => account.name === cfg.directoryLocalPart && account.domainId === domain.id); + if (existing && !existing.description?.includes(MANAGED_MARK) && !holdsOnlyRole(existing, roleId)) { + problems.push( + `${address}(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 사내 목록 계정으로 쓰지 않았습니다 — 그 계정은 건드리지 않았습니다. ` + + 'MAIL_DIRECTORY_LOCAL_PART 를 아무도 쓰지 않는 아이디로 바꾸고 backend 를 다시 띄운 뒤 이 스크립트를 다시 실행하세요 ' + + '(백엔드는 이 주소를 사내 목록 · 웹메일 로그인에서 뺀다 — mail/README.md "사내 목록 계정").', + ); + return { address, state: 'conflict' }; + } + const directoryRoles = roleId ? { '@type': 'Custom', roleIds: { [roleId]: true } } : null; + if (!existing) { + if (recovery && roleId) { + await setObjects(recovery, 'Account', { + create: { + account: { + '@type': 'User', + name: cfg.directoryLocalPart, + domainId: domain.id, + description: DIRECTORY_DESCRIPTION, + roles: directoryRoles, + quotas: DIRECTORY_QUOTAS, + }, + }, + }); + changed = true; + log(`[생성] ${label} — ${shape}${via}`); + } else { + pending.push('계정 없음'); + } + } else { + const patch = {}; + const changes = []; + if (existing.description !== DIRECTORY_DESCRIPTION) { + patch.description = DIRECTORY_DESCRIPTION; + changes.push('표시 이름'); + } + if (stableJson(existing.quotas ?? {}) !== stableJson(DIRECTORY_QUOTAS)) { + patch.quotas = DIRECTORY_QUOTAS; + changes.push(`저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트`); + } + if (Object.keys(existing.credentials ?? {}).length > 0) { + // 비밀번호가 있으면 그것으로 직접 로그인해 계정 목록(관리자 · 게이트웨이 주소 포함)을 읽을 수 있다 + patch.credentials = {}; + changes.push('비밀번호 뺌'); + } + let rolePending = false; + if (roleId && !holdsOnlyRole(existing, roleId)) { + if (recovery) { + patch.roles = directoryRoles; + changes.push('역할'); + } else { + pending.push('계정의 역할이 다름'); + rolePending = true; + } + } + if (changes.length > 0) { + await setObjects(writer, 'Account', { update: { [existing.id]: patch } }); + changed = true; + log(`[변경] ${label} — ${changes.join(', ')}${via}`); + } else if (roleId && !rolePending) { + log(`[유지] ${label} — ${shape}`); + } + if ( + stableJson(existing.permissions ?? INHERIT_PERMISSIONS) !== stableJson(INHERIT_PERMISSIONS) || + Object.keys(existing.memberGroupIds ?? {}).length > 0 + ) { + warn( + `${label} 에 역할 밖의 권한 설정(permissions)이나 그룹(memberGroupIds)이 있습니다 — setup 은 이것을 맞추지 않는다. ` + + '관리 화면 › 계정에서 지우세요 (mail/README.md "사내 목록 계정").', + ); + } + } + const ready = Boolean(roleId) && pending.length === 0; + + // 3) 이 역할을 가진 다른 계정 — setup 이 만든 옛 계정(아이디를 바꿨다)은 새 계정이 준비된 뒤 지우고, 그 밖은 알린다 + if (roleId) { + const others = accounts.filter( + (account) => account !== existing && account.roles?.['@type'] === 'Custom' && account.roles.roleIds?.[roleId] === true, + ); + const stale = others.filter((account) => account.description?.includes(MANAGED_MARK) && holdsOnlyRole(account, roleId)); + const foreign = others.filter((account) => !stale.includes(account)); + if (stale.length > 0 && ready) { + await setObjects(writer, 'Account', { destroy: stale.map((account) => account.id) }); + changed = true; + for (const account of stale) log(`[삭제] 옛 사내 목록 계정 ${accountLabel(account)} — MAIL_DIRECTORY_LOCAL_PART 에서 빠짐${via}`); + } else if (stale.length > 0) { + log(`[유지] 옛 사내 목록 계정 ${stale.map(accountLabel).join(', ')} — 새 계정 ${address} 가 준비된 뒤에 지운다`); + } + if (foreign.length > 0) { + warn( + `사내 목록 역할을 다른 계정도 가지고 있습니다: ${foreign.map(accountLabel).join(', ')} — 그 계정은 계정 목록 전체(관리자 · 게이트웨이 주소 포함)를 읽을 수 있다. ` + + '의도한 것이 아니면 관리 화면 › 계정에서 그 계정의 역할을 바꾸세요 (mail/README.md "사내 목록 계정").', + ); + } + } + + // 역할 권한 · 계정은 캐시된다 — 바로 반영되게 비운다 + if (changed) await setObjects(writer, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } }); + if (ready) { + const readable = await checkDirectoryAccess(cfg, address, { strict: Boolean(recovery) }); + return { address, state: readable === false ? 'unreadable' : 'ready' }; + } + + // 복구 관리자가 있어야 맞출 수 있는 것이 남았다 + const absent = pending.includes('역할 없음') || pending.includes('계정 없음'); + const how = cfg.isProduction + ? '--recovery 로 한 번 실행하세요' + : `${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) 다시 실행하세요`; + const message = absent + ? `사내 목록 계정이 없습니다 (${pending.join(' · ')}) — ${how} (mail/README.md "사내 목록 계정"). 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다.` + : `사내 목록 계정이 setup 기준과 다릅니다 (${pending.join(' · ')}) — 관리자로는 맞출 수 없다(엔진이 거절한다). ${how} (mail/README.md "사내 목록 계정").`; + if (cfg.recovery && !recovery) { + // 복구 관리자를 줬는데 쓰지 못했다 — 운영은 --recovery 실행이므로 실패로 본다 + const reason = + `복구 관리자(${RECOVERY_ENV_KEY})로 관리 API 에 로그인하지 못해 사내 목록 계정을 맞추지 못했습니다 — ` + + `mail 컨테이너가 지금 값으로 만들어졌는지(up -d mail) 확인하세요. `; + if (cfg.isProduction) problems.push(reason + message); + else warn(reason + message); + } else { + warn(message); + } + // 계정은 있는데 기준과 다르면 — 지금 백엔드가 읽을 수 있는지만 알려 준다 (더 붙은 권한만 있으면 읽힌다) + if (!absent && existing) await checkDirectoryAccess(cfg, address, { strict: false }); + return { address, state: 'pending' }; +} + +/** + * 사내 목록 계정으로 계정 목록을 읽을 수 있는지 — 백엔드가 쓰는 방식 그대로 + * (게이트웨이 대리 접근 <사내 목록 주소>%<게이트웨이 주소> → 세션의 principals 계정 → Principal/query · get). + * 역할을 바꾼 직후에는 캐시가 퍼질 때까지 몇 번 다시 본다. 이 스크립트의 접속 주소가 게이트웨이 허용 IP 밖이면 + * (MAIL_TRUSTED_NETWORKS 를 좁힌 운영) 게이트웨이 자신도 로그인되지 않으므로 건너뛴다. + * 읽지 못하면 strict(복구 관리자로 맞춘 실행)는 실패로, 아니면 [주의] 로 남긴다. 돌려주는 값: true · false · null(건너뜀) + */ +async function checkDirectoryAccess(cfg, address, { strict }) { + const login = `${address}%${cfg.gatewayAddress}`; + const authorization = basicAuth(login, cfg.gatewayPassword); + let outcome = ''; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + const sessionRes = await request(cfg, '/jmap/session', { headers: { authorization } }); + if (!sessionRes.ok) { + await sessionRes.body?.cancel(); + if (sessionRes.status === 401 && (await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) === 401) { + log(`[건너뜀] 이 스크립트의 접속 주소가 게이트웨이 허용 IP(신뢰 대역) 밖이라 사내 목록 계정의 대리 접근(${login})을 확인하지 못했습니다.`); + return null; + } + outcome = `세션 HTTP ${sessionRes.status}${sessionRes.status === 403 ? ' — 역할에 authenticate 가 없거나 빼는 권한에 들어 있다' : ''}`; + } else { + const session = await sessionRes.json(); + const accountId = session.primaryAccounts?.[PRINCIPALS_CAPABILITY]; + if (!accountId) { + outcome = `세션에 ${PRINCIPALS_CAPABILITY} 계정이 없음`; + } else { + // 백엔드처럼 찾고(query) 읽는다(get) — 둘 중 하나만 막혀도 사내 목록을 채우지 못한다 + const res = await request(cfg, '/jmap/', { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ + using: ['urn:ietf:params:jmap:core', PRINCIPALS_CAPABILITY], + methodCalls: [ + ['Principal/query', { accountId, limit: 1 }, 'q'], + ['Principal/get', { accountId, '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, properties: ['email'] }, 'g'], + ], + }), + }); + if (res.ok) { + const responses = (await res.json()).methodResponses; + const failed = responses.find(([name]) => name === 'error'); + if (!failed) { + log(`[확인] 사내 목록 — 대리 접근 ${login} 로 계정 목록을 읽을 수 있습니다 (Principal/query · get).`); + return true; + } + outcome = `Principal/${failed[2] === 'q' ? 'query' : 'get'} → ${failed[1].type}`; + } else { + await res.body?.cancel(); + outcome = `JMAP 요청 HTTP ${res.status}`; + } + } + } + await sleep(POLL_INTERVAL_MS / 2); + } + const message = + `사내 목록 계정으로 계정 목록을 읽지 못했습니다 — ${login}: ${outcome}. ` + + '역할의 권한(authenticate · jmapPrincipalGet · jmapPrincipalQuery)과 게이트웨이 허용 IP 를 확인하세요 (mail/README.md "사내 목록 계정").'; + if (strict) problems.push(message); + else warn(message); + return false; +} + /** 공개 443(https 리스너)의 HTTP 경로 정책 — 바꿨으면 true (ReloadSettings 뒤에 반영된다) */ async function ensureHttpEndpointPolicy(client) { const current = (await call(client, 'x:Http/get', { ids: ['singleton'], properties: ['allowedEndpoints'] })).list[0]; @@ -1392,6 +1774,97 @@ async function ensureHttpEndpointPolicy(client) { return true; } +/** + * 메일을 받지 않는 시스템 계정의 주소 — 게이트웨이 · 사내 목록(역할을 setup 이 정한다 — emailReceive 없음)과, + * 관리자 역할(ADMIN_ROLE_DESCRIPTION)에 emailReceive 가 없을 때의 관리자 역할 계정 모두(MAIL_ADMIN_LOCAL_PART 관리자 + 더 만든 관리자). + * 관리자 역할을 찾지 못하거나 그 역할이 메일을 받게 바뀌었으면 관리자 주소는 넣지 않는다 — 받는 계정을 막지 않게. + * 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면(conflict — 실제 메일함일 수 있다) 그 주소도 넣지 않는다. + */ +async function nonReceivingSystemAddresses(client, cfg, directory) { + const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']); + const adminRole = roles.find((role) => role.description === ADMIN_ROLE_DESCRIPTION); + const adminsReceive = !adminRole || adminRole.enabledPermissions?.emailReceive === true; + const admins = adminsReceive + ? [] + : (await listAll(client, 'Account', ['emailAddress', 'roles'])) + .filter(isAdminAccount) + .map((account) => account.emailAddress?.toLowerCase()) + .filter(Boolean) + .sort((a, b) => (a === cfg.adminAddress ? -1 : b === cfg.adminAddress ? 1 : a.localeCompare(b))); + if (adminsReceive) { + warn( + `관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 관리자 주소는 RCPT 거절에 넣지 않았습니다 — ` + + '관리자 계정이 메일을 받지 못한다면 그 주소로 온 메일은 받아 놓고 반송한다 (mail/README.md "보안").', + ); + } + return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])]; +} + +/** 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다 */ +function systemRcptScriptExpression(addresses) { + const condition = addresses + .map((address) => { + const at = address.lastIndexOf('@'); + return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`; + }) + .join(' || '); + return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' }; +} + +/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */ +function isOwnRcptScriptExpression(value) { + const branches = Object.values(value?.match ?? {}); + return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false'; +} + +/** + * 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true. + * 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고, + * 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다. + */ +async function ensureSystemRecipientsRejected(client, addresses) { + const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address)); + if (unsafe.length > 0) { + problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`); + return false; + } + const scriptLabel = `시스템 Sieve 스크립트 ${SYSTEM_RCPT_SCRIPT.name} — reject "${SYSTEM_RCPT_REPLY}"`; + const scripts = await listAll(client, 'SieveSystemScript', ['name', 'description', 'isActive', 'contents']); + const script = scripts.find((item) => item.name === SYSTEM_RCPT_SCRIPT.name); + const desired = { description: SYSTEM_RCPT_SCRIPT.description, isActive: true, contents: SYSTEM_RCPT_SCRIPT.contents }; + if (!script) { + await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } }); + log(`[생성] ${scriptLabel}`); + } else { + const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value)); + if (Object.keys(patch).length > 0) { + await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } }); + log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`); + } else { + log(`[유지] ${scriptLabel}`); + } + } + + const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`; + const expression = systemRcptScriptExpression(addresses); + const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script; + if (stableJson(current) === stableJson(expression)) { + log(`[유지] ${label}`); + return true; + } + if (stableJson(current) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(current)) { + warn( + `RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 (${JSON.stringify(current)}) — ` + + `메일을 받지 않는 시스템 계정(${addresses.join(', ')})은 지금처럼 받아 놓고 보낸 사람에게 반송한다. ` + + '그 스크립트를 고르는 식에 이 계정들일 때 거절하는 갈래를 합치세요 (mail/README.md "보안").', + ); + return false; + } + await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } }); + log(`[변경] ${label}`); + return true; +} + /** * 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다. * 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다). @@ -1506,6 +1979,85 @@ async function checkHttpEndpointPolicy(cfg) { ); } +/** + * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null. + * 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다. + */ +function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) { + const result = spawnSync( + 'docker', + [ + 'exec', + '-i', + cfg.container, + 'curl', + '-sS', + '-v', + '--max-time', + '20', + '--url', + `smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`, + '--mail-from', + sender, + '--mail-rcpt-allowfails', + ...recipients.flatMap((recipient) => ['--mail-rcpt', recipient]), + '-T', + '-', + ], + { input: '', encoding: 'utf8' }, + ); + if (result.error) return null; + const replies = new Map(); + let asked = null; + for (const line of (result.stderr ?? '').split(/\r?\n/)) { + const sent = /^> RCPT TO:<([^>]*)>/.exec(line); + if (sent) { + asked = sent[1].toLowerCase(); + continue; + } + const reply = /^< (\d{3}[ -].*)$/.exec(line); + if (reply && asked) { + replies.set(asked, reply[1]); + asked = null; + } + } + return replies; +} + +/** + * 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로 + * 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라, + * 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다. + * 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다). + */ +async function checkSystemRecipientsRejected(cfg, addresses) { + const networks = cfg.docker ? containerNetworks(cfg.container) : null; + const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null; + if (!mailIp) { + const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.'; + if (cfg.isProduction) problems.push(message); + else log(`[건너뜀] ${message}`); + return; + } + const last = addresses[addresses.length - 1]; + const probes = [...addresses, last.replace('@', '+setup-check@')].slice(0, RCPT_CHECK_MAX_PROBES); + let replies = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + replies = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes); + if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break; + await sleep(POLL_INTERVAL_MS / 2); + } + const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550')); + if (notRejected.length === 0) { + log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`); + return; + } + problems.push( + `시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` + + 'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").', + ); +} + /** * 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서). * 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면 @@ -1692,7 +2244,7 @@ function printHelp() { log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]'); log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md'); log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).'); - log(` --recovery 운영의 최초 bootstrap·관리자 복구 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`); + log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`); } /** 후속 조치가 필요하다 (종료 코드 3) — 안내를 출력하고 끝낸다 */ @@ -1744,6 +2296,11 @@ async function main() { // 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API await ensureUserRoleWithoutPrincipals(admin); await ensureHttpEndpointPolicy(admin); + // 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의]) + const directory = await ensureDirectoryAccount(admin, cfg, domain); + // 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영) + const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory); + const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients); // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다), // 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영) await ensureRecipientThrottle(admin); @@ -1752,6 +2309,7 @@ async function main() { if (!cfg.isProduction) await ensureDevRelay(admin); await reloadSettings(admin); await checkHttpEndpointPolicy(cfg); + if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients); const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg); const devUsers = []; @@ -1788,7 +2346,21 @@ async function main() { log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`); log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`); log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`); - log(' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가'); + log( + ' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가' + + (systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''), + ); + const directoryFallback = '읽지 못하는 동안 백엔드는 로그인한 직원 기록으로 대신한다'; + if (directory.state === 'ready') { + log(` 사내 목록 ${directory.address} — 계정 목록(Principal) 읽기 전용 · 비밀번호 없음 · 대리 접근: ${directory.address}%${cfg.gatewayAddress}`); + } else if (directory.state === 'unreadable') { + log(` 사내 목록 ${directory.address} — 맞췄지만 계정 목록을 읽지 못함(위 [실패] · [주의]) — ${directoryFallback}`); + } else if (directory.state === 'conflict') { + log(` 사내 목록 ${directory.address} — 다른 계정의 주소라 쓰지 않음(위 [실패]) · MAIL_DIRECTORY_LOCAL_PART 를 바꾼다 — ${directoryFallback}`); + } else { + const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`; + log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`); + } if (gatewayReachesAdmin) { log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")'); } -- 2.52.0 From 08cf31fbe6efba52b68ac0ae19fa294c2ed94c4d Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 14:09:59 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(backend):=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EB=A1=9D=20API=20=E2=80=94=20=EC=97=B0=EB=9D=BD=EC=B2=98=20?= =?UTF-8?q?=C2=B7=20=EA=B7=B8=EB=A3=B9=20=C2=B7=20=EC=82=AC=EB=82=B4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0,=20=EB=B0=9B=EB=8A=94=20=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EC=97=90=20=EC=A3=BC=EC=86=8C=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 3절. 내 주소록은 엔진의 JMAP Contacts(직원마다 기본 주소록)에, 사내 목록은 엔진 계정 목록을 사내 목록 계정으로만 읽는다. 엔진 검색은 낱말 단위이고 이름 정렬을 못 해서, 직원마다 주소록을 엔진 state 로 확인하는 캐시에 두고 서버가 찾고(초성 · 전화 숫자) 정렬한다. 쓰기는 직원마다 차례로 · ifInState 로 하고, 어긋나거나 엔진이 잠깐 거절하면 다시 읽어 세 번까지. 리뷰로 고친 것: - 전화번호 정규식이 입력 길이의 제곱이었다(1MB 본문 하나로 약 24분 CPU) — 정규식을 나누고, DTO 검사를 applyDecorators 로 묶어 형식 → 길이 → 모양 차례로 돌린다. - 고치기는 PATCH(보낸 칸만 검사) — 칸 안의 남의 속성(label · contexts · fax · pref · role · 둘째 조직)은 지금 카드와 합쳐 지킨다(CardDAV 와 함께 쓰려고). - [되돌리기]는 지우기 직전에 읽은 카드 전체를 5분 기억했다가 같은 uid 로 다시 만든다(되돌리기 표 — 우리 규칙 밖의 값 · 다른 프로그램의 칸도 그대로). - 큰 주소록이 캐시에 들지 못해 요청마다 엔진을 통째로 다시 읽었다 — 크기 한도 800만을 쓰기에 걸고 캐시의 한 사람 상한과 맞췄다. - 받는 사람 추천의 q 를 화면과 같은 맞추기(초성 · 조합 중 글자)로 — 주소록이 크면 미리 받은 100줄이 주소록으로 차서 사내 · 최근 주소의 초성 찾기가 사라졌다. - excludeGroupId · 사내 항목 하나 · q 254자, 판은 읽기 전에 물은 것으로, 한글 이름 조각은 붙여 잇는다. 에러 코드 CONTACT_001(같은 이름 그룹) · CONTACT_002(주소록 한도)를 더하고 클라이언트 사전을 생성했다. Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 3 + .../decorators/trim-string.decorator.spec.ts | 24 +- .../decorators/trim-string.decorator.ts | 18 +- backend/src/common/errors/error-codes.spec.ts | 5 + backend/src/common/errors/error-codes.ts | 13 + backend/src/modules/auth/auth.service.spec.ts | 6 +- backend/src/modules/auth/employee-address.ts | 4 +- .../src/modules/contacts/address-book.spec.ts | 498 ++++++ backend/src/modules/contacts/address-book.ts | 362 +++++ .../contacts/contact-book-cache.spec.ts | 123 ++ .../modules/contacts/contact-book-cache.ts | 82 + .../contacts/contact-groups.controller.ts | 164 ++ .../src/modules/contacts/contact-id.pipe.ts | 20 + .../src/modules/contacts/contact-paging.ts | 55 + .../contact-restorations.controller.ts | 63 + .../contacts/contact-restore-store.spec.ts | 114 ++ .../modules/contacts/contact-restore-store.ts | 143 ++ .../modules/contacts/contact-search.spec.ts | 223 +++ .../src/modules/contacts/contact-search.ts | 204 +++ .../contacts/contacts.constants.spec.ts | 85 ++ .../modules/contacts/contacts.constants.ts | 106 ++ .../modules/contacts/contacts.controller.ts | 172 +++ .../src/modules/contacts/contacts.module.ts | 37 + .../src/modules/contacts/contacts.provider.ts | 173 +++ .../modules/contacts/contacts.service.spec.ts | 1349 +++++++++++++++++ .../src/modules/contacts/contacts.service.ts | 1219 +++++++++++++++ .../src/modules/contacts/contacts.types.ts | 131 ++ .../contacts/directory-entries.controller.ts | 87 ++ .../modules/contacts/directory-policy.spec.ts | 123 ++ .../src/modules/contacts/directory-policy.ts | 66 + .../contacts/directory.service.spec.ts | 257 ++++ .../src/modules/contacts/directory.service.ts | 149 ++ .../contacts/dto/contact-group.dto.spec.ts | 174 +++ .../modules/contacts/dto/contact-group.dto.ts | 178 +++ .../contacts/dto/contact-restoration.dto.ts | 56 + .../modules/contacts/dto/contact.dto.spec.ts | 462 ++++++ .../src/modules/contacts/dto/contact.dto.ts | 525 +++++++ .../contacts/dto/directory-entry.dto.ts | 84 + .../mail-directory/mail-directory.entity.ts | 7 +- .../mail-directory/mail-directory.module.ts | 2 +- .../mail-directory/mail-directory.service.ts | 3 +- .../src/modules/mail/dto/mail-response.dto.ts | 27 +- .../dto/recipient-suggestion-query.dto.ts | 2 +- .../modules/mail/jmap/contact-card.spec.ts | 861 +++++++++++ backend/src/modules/mail/jmap/contact-card.ts | 777 ++++++++++ .../mail/jmap/jmap-contacts.provider.spec.ts | 826 ++++++++++ .../mail/jmap/jmap-contacts.provider.ts | 449 ++++++ .../mail/jmap/jmap-directory.provider.spec.ts | 201 +++ .../mail/jmap/jmap-directory.provider.ts | 140 ++ .../mail/jmap/jmap-mail.provider.spec.ts | 6 +- .../src/modules/mail/jmap/jmap.client.spec.ts | 169 ++- backend/src/modules/mail/jmap/jmap.client.ts | 148 +- backend/src/modules/mail/jmap/jmap.types.ts | 36 + .../src/modules/mail/mail-engine.module.ts | 41 + backend/src/modules/mail/mail.module.ts | 45 +- backend/src/modules/mail/mail.service.spec.ts | 6 +- .../src/modules/mail/mail.settings.spec.ts | 48 +- backend/src/modules/mail/mail.settings.ts | 25 +- backend/src/modules/mail/mail.types.ts | 13 +- .../src/modules/mail/recipient-match.spec.ts | 156 ++ backend/src/modules/mail/recipient-match.ts | 262 ++++ .../modules/mail/recipient-ranking.spec.ts | 165 +- backend/src/modules/mail/recipient-ranking.ts | 90 +- .../mail/recipient-suggestion-cursor.ts | 62 +- .../mail/recipient-suggestion.controller.ts | 13 +- .../recipient-suggestions.service.spec.ts | 224 ++- .../mail/recipient-suggestions.service.ts | 80 +- .../src/shared/config/env.validation.spec.ts | 74 + backend/src/shared/config/env.validation.ts | 53 +- .../src/shared/utils/offset-cursor.spec.ts | 52 + backend/src/shared/utils/offset-cursor.ts | 72 + backend/test/app.e2e-spec.ts | 918 ++++++++++- flutter/lib/core/api/error_lexicon.g.dart | 2 + .../shared/constants/error-codes.generated.ts | 2 + 74 files changed, 13427 insertions(+), 187 deletions(-) create mode 100644 backend/src/modules/contacts/address-book.spec.ts create mode 100644 backend/src/modules/contacts/address-book.ts create mode 100644 backend/src/modules/contacts/contact-book-cache.spec.ts create mode 100644 backend/src/modules/contacts/contact-book-cache.ts create mode 100644 backend/src/modules/contacts/contact-groups.controller.ts create mode 100644 backend/src/modules/contacts/contact-id.pipe.ts create mode 100644 backend/src/modules/contacts/contact-paging.ts create mode 100644 backend/src/modules/contacts/contact-restorations.controller.ts create mode 100644 backend/src/modules/contacts/contact-restore-store.spec.ts create mode 100644 backend/src/modules/contacts/contact-restore-store.ts create mode 100644 backend/src/modules/contacts/contact-search.spec.ts create mode 100644 backend/src/modules/contacts/contact-search.ts create mode 100644 backend/src/modules/contacts/contacts.constants.spec.ts create mode 100644 backend/src/modules/contacts/contacts.constants.ts create mode 100644 backend/src/modules/contacts/contacts.controller.ts create mode 100644 backend/src/modules/contacts/contacts.module.ts create mode 100644 backend/src/modules/contacts/contacts.provider.ts create mode 100644 backend/src/modules/contacts/contacts.service.spec.ts create mode 100644 backend/src/modules/contacts/contacts.service.ts create mode 100644 backend/src/modules/contacts/contacts.types.ts create mode 100644 backend/src/modules/contacts/directory-entries.controller.ts create mode 100644 backend/src/modules/contacts/directory-policy.spec.ts create mode 100644 backend/src/modules/contacts/directory-policy.ts create mode 100644 backend/src/modules/contacts/directory.service.spec.ts create mode 100644 backend/src/modules/contacts/directory.service.ts create mode 100644 backend/src/modules/contacts/dto/contact-group.dto.spec.ts create mode 100644 backend/src/modules/contacts/dto/contact-group.dto.ts create mode 100644 backend/src/modules/contacts/dto/contact-restoration.dto.ts create mode 100644 backend/src/modules/contacts/dto/contact.dto.spec.ts create mode 100644 backend/src/modules/contacts/dto/contact.dto.ts create mode 100644 backend/src/modules/contacts/dto/directory-entry.dto.ts create mode 100644 backend/src/modules/mail/jmap/contact-card.spec.ts create mode 100644 backend/src/modules/mail/jmap/contact-card.ts create mode 100644 backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-contacts.provider.ts create mode 100644 backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-directory.provider.ts create mode 100644 backend/src/modules/mail/mail-engine.module.ts create mode 100644 backend/src/modules/mail/recipient-match.spec.ts create mode 100644 backend/src/modules/mail/recipient-match.ts create mode 100644 backend/src/shared/utils/offset-cursor.spec.ts create mode 100644 backend/src/shared/utils/offset-cursor.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d751f9a..5b63f49 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard'; import { OriginGuard } from '@/common/guards/origin.guard'; import { AuthModule } from '@/modules/auth/auth.module'; +import { ContactsModule } from '@/modules/contacts/contacts.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; import { UploadModule } from '@/modules/upload/upload.module'; @@ -65,6 +66,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 AuthModule, // 웹메일 API (Stalwart JMAP 어댑터) MailModule, + // 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다) + ContactsModule, UploadModule, UserModule, ], diff --git a/backend/src/common/decorators/trim-string.decorator.spec.ts b/backend/src/common/decorators/trim-string.decorator.spec.ts index e6147e4..2262093 100644 --- a/backend/src/common/decorators/trim-string.decorator.spec.ts +++ b/backend/src/common/decorators/trim-string.decorator.spec.ts @@ -1,13 +1,19 @@ import { plainToInstance } from 'class-transformer'; -import { TrimString } from './trim-string.decorator'; +import { TrimString, TrimStringOrNull } from './trim-string.decorator'; class Named { @TrimString() name!: unknown; } +class Optional { + @TrimStringOrNull() + name!: unknown; +} + const nameOf = (name: unknown) => plainToInstance(Named, { name }).name; +const optionalOf = (name: unknown) => plainToInstance(Optional, { name }).name; describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => { it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => { @@ -22,3 +28,19 @@ describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => { expect(nameOf([' a '])).toEqual([' a ']); }); }); + +describe('TrimStringOrNull — 앞뒤 공백을 지우고 빈 글은 null', () => { + it('지운 뒤 남은 글이 없으면 null, 있으면 지운 글', () => { + expect(optionalOf(' 한빛상사 ')).toBe('한빛상사'); + expect(optionalOf('\n첫 줄\n둘째 줄\n')).toBe('첫 줄\n둘째 줄'); + for (const blank of ['', ' ', '\t\n\u00a0\u3000']) { + expect(optionalOf(blank)).toBeNull(); + } + }); + + it('문자열이 아니면 그대로 둔다 — null 은 null, 키가 없으면 undefined(형식 검사가 막는다)', () => { + expect(optionalOf(null)).toBeNull(); + expect(optionalOf(7)).toBe(7); + expect(plainToInstance(Optional, {}).name).toBeUndefined(); + }); +}); diff --git a/backend/src/common/decorators/trim-string.decorator.ts b/backend/src/common/decorators/trim-string.decorator.ts index 62f0057..60ee40f 100644 --- a/backend/src/common/decorators/trim-string.decorator.ts +++ b/backend/src/common/decorators/trim-string.decorator.ts @@ -3,7 +3,7 @@ import { Transform, type TransformFnParams } from 'class-transformer'; /** * 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다. * 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다). - * 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다 + * 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts) · 주소록의 주소 · 번호 · 그룹 이름이 쓴다 */ export function TrimString(): PropertyDecorator { return Transform(trimStringValue); @@ -14,3 +14,19 @@ export function trimStringValue({ value }: TransformFnParams): unknown { const raw: unknown = value; return typeof raw === 'string' ? raw.trim() : raw; } + +/** + * 앞뒤 공백을 지우고, 남은 글이 없으면 null 로 바꾼다 — "값 없음"을 null 로 받는 칸(연락처의 이름 · 회사 · 부서 · 직책 · 메모 — + * 계약 3절 "빈 글은 null")이 쓴다. 검사는 바꾼 값으로 한다 (null 이면 ValidateIf 로 건너뛰게 둔다). 문자열이 아니면 그대로 둔다 + */ +export function TrimStringOrNull(): PropertyDecorator { + return Transform(trimStringOrNullValue); +} + +/** TrimStringOrNull 의 변환 */ +export function trimStringOrNullValue({ value }: TransformFnParams): unknown { + const raw: unknown = value; + if (typeof raw !== 'string') return raw; + const trimmed = raw.trim(); + return trimmed === '' ? null : trimmed; +} diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index 6d90089..e8d95b3 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -64,6 +64,11 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST); }); + it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => { + expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT); + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 62df90a..265b83a 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -161,6 +161,19 @@ const DOMAIN_ERROR_CODES = { message: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', }, + // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). + // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) + CONTACT_001: { + status: HttpStatus.CONFLICT, + message: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + }, + // 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명)를 넘는다 — 아무것도 바꾸지 않았다 (계약 3절). + // 문구의 숫자는 modules/contacts/contacts.constants.ts 의 한도와 같아야 한다 (contacts.constants.spec.ts 가 확인한다) + CONTACT_002: { + status: HttpStatus.CONFLICT, + message: + '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 4022f55..cf01df6 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -33,7 +33,11 @@ const mailSettings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const settings: AuthSettings = { diff --git a/backend/src/modules/auth/employee-address.ts b/backend/src/modules/auth/employee-address.ts index 9d06b4f..34c0fe9 100644 --- a/backend/src/modules/auth/employee-address.ts +++ b/backend/src/modules/auth/employee-address.ts @@ -29,7 +29,9 @@ export function normalizeEmail(email: string): string { * 로그인할 수 있는 직원 주소인지 확인한다. * - 사내 도메인(MAIL_DOMAIN) 주소만 * - 게이트웨이 서비스 계정은 직접 로그인할 수 없다 - * - 막는 계정(메일 관리자 `@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts) + * - 막는 계정(메일 관리자 `@<도메인>`·사내 목록 계정 `@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 + * 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts) + * 사내 목록(계약 3절)에서 빼는 계정도 이 함수로 고른다 (contacts/directory-policy.ts) — 로그인을 막는 목록과 같게 */ export function isEmployeeAddress( email: string, diff --git a/backend/src/modules/contacts/address-book.spec.ts b/backend/src/modules/contacts/address-book.spec.ts new file mode 100644 index 0000000..383ee8a --- /dev/null +++ b/backend/src/modules/contacts/address-book.spec.ts @@ -0,0 +1,498 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + applyAddressBookWrite, + buildBookView, + cardWeight, + emailKey, + groupWeight, + newCardUid, + weightAfterChanges, +} from './address-book'; +import { matchesContact, parseContactSearch } from './contact-search'; +import type { + AddressBookCard, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, +} from './contacts.provider'; + +const card = ( + id: string, + overrides: Partial = {}, +): AddressBookContactCard => ({ + id, + uid: `urn:uuid:${id}`, + name: id, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + ...overrides, +}); + +const group = ( + id: string, + name: string, + memberUids: string[], + overrides: Partial = {}, +): AddressBookGroupCard => ({ + id, + uid: `urn:uuid:${id}`, + name, + memberUids, + ...overrides, +}); + +const snapshot = ( + contacts: AddressBookContactCard[], + groups: AddressBookGroupCard[] = [], +): AddressBookSnapshot => ({ version: 'v1', contacts, groups }); + +describe('buildBookView (주소록 한 사람분)', () => { + it('연락처 · 그룹을 이름 차례로 — 연락처의 그룹 · 그룹의 구성원 수는 uid 로 잇는다', () => { + const view = buildBookView( + snapshot( + [ + card('c-ha', { name: '하늘' }), + card('c-ga', { name: '가나다' }), + card('c-na', { name: '나비' }), + ], + [ + group('g-z', '협력사', ['urn:uuid:c-ha']), + group('g-a', '거래처', ['urn:uuid:c-ha', 'urn:uuid:c-ga']), + ], + ), + ); + + expect(view.contacts.map((entry) => entry.contact.id)).toEqual([ + 'c-ga', + 'c-na', + 'c-ha', + ]); + expect(view.groups.map((entry) => entry.group)).toEqual([ + { id: 'g-a', name: '거래처', memberCount: 2 }, + { id: 'g-z', name: '협력사', memberCount: 1 }, + ]); + // 그룹 차례 — 거래처 → 협력사 + expect(view.contactsById.get('c-ha')?.contact.groupIds).toEqual([ + 'g-a', + 'g-z', + ]); + expect(view.contactsById.get('c-na')?.contact.groupIds).toEqual([]); + // 구성원은 연락처의 이름 차례 + expect(view.groupsById.get('g-a')?.memberIds).toEqual(['c-ga', 'c-ha']); + expect(view.version).toBe('v1'); + }); + + it('구성원 수는 이 주소록의 연락처만 센다 — 지워진 카드 · 다른 주소록 · 그룹을 가리키는 uid 는 세지 않는다', () => { + const view = buildBookView( + snapshot( + [card('c1')], + [ + group('g-inner', '안쪽', []), + group('g', '거래처', [ + 'urn:uuid:c1', + 'urn:uuid:gone', + 'urn:uuid:g-inner', + ]), + ], + ), + ); + + expect(view.groupsById.get('g')?.group.memberCount).toBe(1); + expect(view.groupsById.get('g')?.memberIds).toEqual(['c1']); + }); + + it('uid 가 없는 카드는 어느 그룹에도 들지 않는다 · 같은 uid 의 카드 둘은 둘 다 구성원이다', () => { + const view = buildBookView( + snapshot( + [ + card('no-uid', { uid: null }), + card('twin-a', { uid: 'urn:uuid:twin' }), + card('twin-b', { uid: 'urn:uuid:twin' }), + ], + [group('g', '거래처', ['urn:uuid:twin'])], + ), + ); + + expect(view.contactsById.get('no-uid')?.contact.groupIds).toEqual([]); + expect(view.groupsById.get('g')?.group.memberCount).toBe(2); + }); + + it('주소로 연락처를 찾는 표(대소문자 무시) · 받는 사람 추천 줄 — 연락처의 이름 차례, 대표 주소가 먼저, 같은 주소는 한 번', () => { + const view = buildBookView( + snapshot([ + card('c-b', { + name: '박지훈', + company: '한빛상사', + emails: [ + { address: 'Jihoon@Hanbit.example', label: 'work' }, + { address: 'jihoon@home.example', label: 'personal' }, + { address: 'JIHOON@hanbit.example', label: 'other' }, + ], + }), + card('c-a', { + name: '가나다', + emails: [{ address: 'jihoon@hanbit.example', label: 'other' }], + }), + ]), + ); + + expect( + view.contactsByEmail + .get('jihoon@hanbit.example') + ?.map((entry) => entry.contact.id), + ).toEqual(['c-a', 'c-b']); + expect(view.suggestionLines).toEqual([ + { + name: '가나다', + email: 'jihoon@hanbit.example', + company: null, + emailLabel: 'other', + }, + { + name: '박지훈', + email: 'Jihoon@Hanbit.example', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon@home.example', + company: '한빛상사', + emailLabel: 'personal', + }, + ]); + }); + + it('읽는 상한(5,000장) · 그룹 100개 × 구성원 500명도 만들고 찾는 데 계산량이 몇 배로 늘지 않는다 (CPU 시간 — 지금 250ms 남짓)', () => { + const contacts = Array.from({ length: 5_000 }, (_, index) => + card(`c${index}`, { + name: `연락처 ${(index * 7_919) % 5_000} 박지훈`, + emails: [0, 1, 2].map((n) => ({ + address: `p${index}.${n}@x.example`, + label: 'work' as const, + })), + phones: [{ number: `010-${index}-5540`, label: 'mobile' as const }], + company: '한빛상사', + note: 'x'.repeat(200), + }), + ); + const groups = Array.from({ length: 100 }, (_, g) => + group( + `g${g}`, + `그룹 ${g}`, + Array.from( + { length: 500 }, + (_, m) => `urn:uuid:c${(g * 37 + m) % 5_000}`, + ), + ), + ); + const query = parseContactSearch('ㅂㅈㅎ'); + if (query === null) throw new Error('찾는 글을 해석하지 못했다'); + + const cpuMs = startCpuTimer(); + const view = buildBookView(snapshot(contacts, groups)); + for (let round = 0; round < 10; round += 1) { + expect( + view.contacts.filter((entry) => matchesContact(entry.search, query)), + ).toHaveLength(5_000); + } + expect(cpuMs()).toBeLessThan(2_500); + expect(view.groupsById.get('g99')?.group.memberCount).toBe(500); + }); + + it('uid 로 찾는 표 — 연락처(같은 uid 둘이면 둘 다) · 이 주소록이 쓰는 uid(그룹 포함, uid 없는 카드 제외)', () => { + const view = buildBookView( + snapshot( + [ + card('a', { uid: 'urn:uuid:twin' }), + card('b', { uid: 'urn:uuid:twin' }), + card('c', { uid: null }), + ], + [group('g', '거래처', [])], + ), + ); + expect( + view.contactsByUid.get('urn:uuid:twin')?.map((entry) => entry.card.id), + ).toEqual(['a', 'b']); + expect([...view.uidsInUse].sort()).toEqual(['urn:uuid:g', 'urn:uuid:twin']); + }); + + it('크기는 글자 수로 어림한다 — 메모가 길면 크다', () => { + const small = buildBookView(snapshot([card('c1')])); + const large = buildBookView( + snapshot([card('c1', { note: 'x'.repeat(1_000) })]), + ); + expect(large.weight - small.weight).toBe(1_000); + }); +}); + +describe('applyAddressBookWrite (쓴 바꾸기를 기억한 주소록에)', () => { + const base = snapshot( + [ + card('c1', { name: '하나', company: '옛 회사', department: '옛 부서' }), + card('c2'), + ], + [group('g1', '거래처', ['urn:uuid:c1'])], + ); + const contactCard = ( + id: string, + overrides: Partial = {}, + ): AddressBookCard => ({ kind: 'contact', card: card(id, overrides) }); + + it('지우기 · 그룹 고치기(보낸 구성원)를 옮기고, 만들거나 고친 연락처는 어댑터가 준 모양으로 — 새 판을 단다', () => { + const next = applyAddressBookWrite( + base, + { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + name: '새 사람', + emails: [{ address: 'new@x.example', label: 'work' }], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }, + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g-new', + name: '새 그룹', + memberUids: ['urn:uuid:new'], + }, + ], + update: [ + { + kind: 'contact', + id: 'c1', + fields: { company: '새 회사' }, + }, + { + kind: 'group', + id: 'g1', + memberUids: ['urn:uuid:c1', 'urn:uuid:new'], + }, + ], + destroy: ['c2'], + }, + { + createdIds: new Map([ + ['contact', 'c-new'], + ['group', 'g-new'], + ]), + cards: new Map([ + // 어댑터가 칸 안의 남의 정보와 합친 결과 — 부서는 그대로, 직책은 다른 프로그램의 둘째 직책이 보인다 + [ + 'c1', + contactCard('c1', { + name: '하나', + company: '새 회사', + department: '옛 부서', + title: '둘째 직책', + }), + ], + [ + 'c-new', + contactCard('c-new', { uid: 'urn:uuid:new', name: '새 사람' }), + ], + [ + 'g-new', + { + kind: 'group', + card: group('g-new', '새 그룹', ['urn:uuid:new'], { + uid: 'urn:uuid:g-new', + }), + }, + ], + ]), + }, + 'v2', + ); + + expect(next?.version).toBe('v2'); + expect(next?.contacts.map((entry) => entry.id)).toEqual(['c1', 'c-new']); + expect(next?.contacts[0]).toMatchObject({ + company: '새 회사', + department: '옛 부서', + title: '둘째 직책', + }); + expect(next?.groups).toEqual([ + group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:new']), + group('g-new', '새 그룹', ['urn:uuid:new'], { uid: 'urn:uuid:g-new' }), + ]); + // 받은 주소록은 그대로 둔다 + expect(base.contacts).toHaveLength(2); + expect(base.groups[0]?.memberUids).toEqual(['urn:uuid:c1']); + }); + + it('고친 연락처의 모양이 없으면 보낸 칸을 옮긴다 · 그룹 이름만 바꾸면 구성원은 그대로', () => { + const next = applyAddressBookWrite( + base, + { + create: [], + update: [ + { kind: 'contact', id: 'c1', fields: { department: null } }, + { kind: 'group', id: 'g1', name: '협력사' }, + ], + destroy: [], + }, + { createdIds: new Map(), cards: new Map() }, + 'v2', + ); + expect(next?.contacts[0]).toMatchObject({ + company: '옛 회사', + department: null, + }); + expect(next?.groups).toEqual([group('g1', '협력사', ['urn:uuid:c1'])]); + }); + + it('만든 카드의 id 나 모양이 없으면 null — 다시 읽어야 한다', () => { + const created = { + create: [ + { + kind: 'group' as const, + key: 'group', + uid: newCardUid(), + name: '새 그룹', + memberUids: [], + }, + ], + update: [], + destroy: [], + }; + expect( + applyAddressBookWrite( + base, + created, + { createdIds: new Map(), cards: new Map() }, + 'v2', + ), + ).toBeNull(); + expect( + applyAddressBookWrite( + base, + created, + { createdIds: new Map([['group', 'g-new']]), cards: new Map() }, + 'v2', + ), + ).toBeNull(); + }); +}); + +describe('weightAfterChanges (쓰기 전에 재는 크기 — 계약 3절 "크기")', () => { + const base = snapshot( + [card('c1', { note: 'x'.repeat(100) }), card('c2')], + [group('g1', '거래처', ['urn:uuid:c1'])], + ); + const book = buildBookView(base); + const fields = { + name: '새 사람', + emails: [{ address: 'new@x.example', label: 'work' as const }], + phones: [], + company: null, + department: null, + title: null, + note: 'y'.repeat(500), + }; + + it('만들기는 더하고 · 지우기는 빼고 · 고치기는 바뀐 만큼', () => { + expect( + weightAfterChanges(book, { create: [], update: [], destroy: [] }), + ).toBe(book.weight); + expect( + weightAfterChanges(book, { + create: [{ kind: 'contact', key: 'k', uid: 'urn:uuid:n', fields }], + update: [], + destroy: [], + }), + ).toBe(book.weight + cardWeight({ id: '', uid: 'urn:uuid:n', ...fields })); + expect( + weightAfterChanges(book, { create: [], update: [], destroy: ['c1'] }), + ).toBe(book.weight - cardWeight(base.contacts[0])); + // 메모 100자 → 10자 + expect( + weightAfterChanges(book, { + create: [], + update: [ + { kind: 'contact', id: 'c1', fields: { note: 'z'.repeat(10) } }, + ], + destroy: [], + }), + ).toBe(book.weight - 90); + // 그룹에 구성원 하나 더 + expect( + weightAfterChanges(book, { + create: [], + update: [ + { + kind: 'group', + id: 'g1', + memberUids: ['urn:uuid:c1', 'urn:uuid:c2'], + }, + ], + destroy: [], + }), + ).toBe( + book.weight - + groupWeight(base.groups[0]) + + groupWeight(group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c2'])), + ); + }); + + it('되돌리기는 지울 때의 모양으로 잰다 — 그룹은 다시 넣을 구성원으로', () => { + const restoredGroup = group('old', '옛 그룹', [ + 'urn:uuid:c1', + 'urn:uuid:gone', + ]); + expect( + weightAfterChanges(book, { + create: [ + { + kind: 'restored', + key: 'r', + uid: 'urn:uuid:old', + original: { source: '{}', bytes: 2 }, + memberUids: ['urn:uuid:c1'], + card: { kind: 'group', card: restoredGroup }, + }, + ], + update: [], + destroy: [], + }), + ).toBe( + book.weight + + groupWeight({ + ...restoredGroup, + id: '', + uid: 'urn:uuid:old', + memberUids: ['urn:uuid:c1'], + }), + ); + }); +}); + +describe('도움 함수', () => { + it('새 카드의 uid 는 urn:uuid 형식이고 매번 다르다', () => { + const a = newCardUid(); + const b = newCardUid(); + expect(a).toMatch( + /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(a).not.toBe(b); + }); + + it('주소 비교는 앞뒤 공백을 빼고 소문자로', () => { + expect(emailKey(' Ara.Kim@AraSystem.Test ')).toBe( + 'ara.kim@arasystem.test', + ); + }); +}); diff --git a/backend/src/modules/contacts/address-book.ts b/backend/src/modules/contacts/address-book.ts new file mode 100644 index 0000000..7c747a8 --- /dev/null +++ b/backend/src/modules/contacts/address-book.ts @@ -0,0 +1,362 @@ +import { randomUUID } from 'crypto'; + +import { + compareContacts, + compareGroups, + contactSearchIndex, + type ContactSearchIndex, +} from './contact-search'; +import type { + AddressBookCard, + AddressBookCardUpdate, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + NewAddressBookCard, +} from './contacts.provider'; +import type { + Contact, + ContactFields, + ContactGroup, + ContactSuggestionLine, +} from './contacts.types'; + +/** + * 주소록 한 사람분을 화면 모양으로 — 연락처(속한 그룹) · 그룹(구성원 수) · 찾기 자료 · 받는 사람 추천 줄 (순수 함수). + * 엔진에서 읽은 것(AddressBookSnapshot)으로 한 번 만들어 판(version)과 함께 기억한다 (contacts.service.ts). + * + * - 그룹 구성원은 카드의 uid 로 잇는다 — 이 주소록에 있는 연락처만 센다(지워진 카드를 가리키는 uid 는 세지 않는다) + * - 연락처 · 그룹은 이름 차례(contact-search.ts) + */ + +/** 연락처 한 장 — 카드(uid)와 화면 모양 · 찾기 자료 */ +export interface BookContact { + readonly card: AddressBookContactCard; + readonly contact: Contact; + readonly search: ContactSearchIndex; +} + +/** 그룹 하나 — 카드와 화면 모양 · 구성원(이 주소록의 연락처 id, 이름 차례) */ +export interface BookGroup { + readonly card: AddressBookGroupCard; + readonly group: ContactGroup; + readonly memberIds: readonly string[]; +} + +export interface BookView { + readonly version: string; + readonly snapshot: AddressBookSnapshot; + /** 이름 차례 */ + readonly contacts: readonly BookContact[]; + readonly groups: readonly BookGroup[]; + readonly contactsById: ReadonlyMap; + readonly groupsById: ReadonlyMap; + /** 주소(소문자) → 그 주소가 있는 연락처 (이름 차례) — 사내 목록에서 넣을 때 같은 주소의 연락처를 찾는다 */ + readonly contactsByEmail: ReadonlyMap; + /** uid → 그 uid 의 연락처 (같은 uid 의 카드가 둘 이상일 수 있다 — 다른 프로그램이 만든 것) */ + readonly contactsByUid: ReadonlyMap; + /** 이 주소록의 카드(연락처 · 그룹)가 쓰는 uid — 엔진은 한 주소록 안의 같은 uid 를 받지 않는다(2026-10-29 확인) */ + readonly uidsInUse: ReadonlySet; + /** 받는 사람 추천의 주소록 묶음 — 연락처의 이름 차례, 연락처 안에서는 대표 주소가 먼저 */ + readonly suggestionLines: readonly ContactSuggestionLine[]; + /** 대략의 크기(글자 수) — 쓰기의 크기 한도(MAX_ADDRESS_BOOK_WEIGHT)와 기억하는 양의 상한에 쓴다 (contact-book-cache.ts) */ + readonly weight: number; +} + +/** 카드 하나에 더하는 대략의 덩치 — 객체 · 배열 자리 */ +const CARD_OVERHEAD = 64; +const ITEM_OVERHEAD = 16; + +/** 새 카드의 uid — 다른 프로그램(CardDAV)과 같은 형식. 엔진은 만든 뒤 uid 를 바꾸지 못하게 한다 */ +export function newCardUid(): string { + return `urn:uuid:${randomUUID()}`; +} + +/** 비교 · 중복 확인용 주소 — 앞뒤 공백을 빼고 소문자로 */ +export function emailKey(address: string): string { + return address.trim().toLowerCase(); +} + +function textWeight(...texts: (string | null)[]): number { + return texts.reduce((sum, text) => sum + (text?.length ?? 0), 0); +} + +/** 연락처 카드 하나의 크기 — 우리가 다루는 칸의 글자 수 + 카드 · 항목마다 조금 (계약 3절 "크기") */ +export function cardWeight(card: AddressBookContactCard): number { + return ( + CARD_OVERHEAD + + textWeight( + card.id, + card.uid, + card.name, + card.company, + card.department, + card.title, + card.note, + ) + + card.emails.reduce( + (sum, email) => sum + ITEM_OVERHEAD + email.address.length, + 0, + ) + + card.phones.reduce( + (sum, phone) => sum + ITEM_OVERHEAD + phone.number.length, + 0, + ) + ); +} + +/** 그룹 카드 하나의 크기 */ +export function groupWeight(card: AddressBookGroupCard): number { + return ( + CARD_OVERHEAD + + textWeight(card.id, card.uid, card.name) + + card.memberUids.reduce((sum, uid) => sum + ITEM_OVERHEAD + uid.length, 0) + ); +} + +function fieldsOf(card: AddressBookContactCard): ContactFields { + return { + name: card.name, + emails: card.emails, + phones: card.phones, + company: card.company, + department: card.department, + title: card.title, + note: card.note, + }; +} + +/** 주소록 한 사람분을 만든다 */ +export function buildBookView(snapshot: AddressBookSnapshot): BookView { + // uid → 그 uid 의 연락처 id (같은 uid 의 카드가 둘 이상일 수 있다 — 엔진이 막지 않는다) + const idsByUid = new Map(); + for (const card of snapshot.contacts) { + if (card.uid === null) continue; + const ids = idsByUid.get(card.uid) ?? []; + ids.push(card.id); + idsByUid.set(card.uid, ids); + } + + const contactOrder = [...snapshot.contacts].sort(compareContacts); + const rank = new Map(contactOrder.map((card, index) => [card.id, index])); + const rankOf = (id: string) => rank.get(id) ?? 0; + + const groupCards = [...snapshot.groups].sort(compareGroups); + const groupIdsByContact = new Map(); + const groups: BookGroup[] = groupCards.map((card) => { + const members = new Set(); + for (const uid of card.memberUids) { + for (const id of idsByUid.get(uid) ?? []) members.add(id); + } + const memberIds = [...members].sort((a, b) => rankOf(a) - rankOf(b)); + for (const id of memberIds) { + const groupIds = groupIdsByContact.get(id) ?? []; + groupIds.push(card.id); + groupIdsByContact.set(id, groupIds); + } + return { + card, + group: { id: card.id, name: card.name, memberCount: memberIds.length }, + memberIds, + }; + }); + + const contacts: BookContact[] = contactOrder.map((card) => { + const contact: Contact = { + id: card.id, + ...fieldsOf(card), + groupIds: groupIdsByContact.get(card.id) ?? [], + }; + // 찾기 자료는 처음 찾을 때 만든다 — 바꿀 때마다 주소록을 다시 만드는데, 그때마다 모든 연락처의 글을 맞추지 않게 + // (5,000장에 CPU 90ms 남짓 — 2026-10-29 잼) + let search: ContactSearchIndex | null = null; + return { + card, + contact, + get search() { + search ??= contactSearchIndex(contact); + return search; + }, + }; + }); + + const contactsByUid = new Map(); + for (const entry of contacts) { + if (entry.card.uid === null) continue; + const owners = contactsByUid.get(entry.card.uid) ?? []; + owners.push(entry); + contactsByUid.set(entry.card.uid, owners); + } + const uidsInUse = new Set(contactsByUid.keys()); + for (const card of snapshot.groups) { + if (card.uid !== null) uidsInUse.add(card.uid); + } + + const contactsByEmail = new Map(); + const suggestionLines: ContactSuggestionLine[] = []; + for (const entry of contacts) { + const seen = new Set(); + for (const email of entry.contact.emails) { + const key = emailKey(email.address); + if (seen.has(key)) continue; + seen.add(key); + const owners = contactsByEmail.get(key) ?? []; + owners.push(entry); + contactsByEmail.set(key, owners); + suggestionLines.push({ + name: entry.contact.name, + email: email.address, + company: entry.contact.company, + emailLabel: email.label, + }); + } + } + + return { + version: snapshot.version, + snapshot, + contacts, + groups, + contactsById: new Map(contacts.map((entry) => [entry.card.id, entry])), + groupsById: new Map(groups.map((entry) => [entry.card.id, entry])), + contactsByEmail, + contactsByUid, + uidsInUse, + suggestionLines, + weight: + snapshot.contacts.reduce((sum, card) => sum + cardWeight(card), 0) + + snapshot.groups.reduce((sum, card) => sum + groupWeight(card), 0), + }; +} + +/** 고치기의 바꾼 칸만 (undefined 는 바꾸지 않음) */ +function definedFields(fields: Partial): Partial { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); +} + +function applyContactUpdate( + card: AddressBookContactCard, + update: AddressBookCardUpdate | undefined, +): AddressBookContactCard { + if (update?.kind !== 'contact') return card; + return { ...card, ...definedFields(update.fields) }; +} + +function applyGroupUpdate( + card: AddressBookGroupCard, + update: AddressBookCardUpdate | undefined, +): AddressBookGroupCard { + if (update?.kind !== 'group') return card; + return { + ...card, + ...(update.name !== undefined ? { name: update.name } : {}), + ...(update.memberUids !== undefined + ? { memberUids: [...update.memberUids] } + : {}), + }; +} + +/** 만들 카드의 크기 (id 는 엔진이 정하므로 빼고 잰다) */ +function newCardWeight(card: NewAddressBookCard): number { + if (card.kind === 'contact') { + return cardWeight({ id: '', uid: card.uid, ...card.fields }); + } + if (card.kind === 'group') { + return groupWeight({ + id: '', + uid: card.uid, + name: card.name, + memberUids: card.memberUids, + }); + } + const original = card.card; + return original.kind === 'contact' + ? cardWeight({ ...original.card, id: '', uid: card.uid }) + : groupWeight({ + ...original.card, + id: '', + uid: card.uid, + memberUids: card.memberUids ?? original.card.memberUids, + }); +} + +/** 바꾸기를 한 뒤의 주소록 크기 (어림 — 고친 칸은 보낸 값으로 잰다) — 크기 한도(계약 3절 "크기")를 엔진에 쓰기 전에 본다 */ +export function weightAfterChanges( + book: BookView, + changes: AddressBookChanges, +): number { + const destroyed = new Set(changes.destroy); + let weight = book.weight; + for (const id of destroyed) { + const contact = book.contactsById.get(id)?.card; + if (contact) weight -= cardWeight(contact); + const group = book.groupsById.get(id)?.card; + if (group) weight -= groupWeight(group); + } + for (const update of changes.update) { + if (destroyed.has(update.id)) continue; + if (update.kind === 'contact') { + const current = book.contactsById.get(update.id)?.card; + if (current) { + weight += + cardWeight(applyContactUpdate(current, update)) - cardWeight(current); + } + } else { + const current = book.groupsById.get(update.id)?.card; + if (current) { + weight += + groupWeight(applyGroupUpdate(current, update)) - groupWeight(current); + } + } + } + for (const card of changes.create) weight += newCardWeight(card); + return weight; +} + +/** 엔진에 쓴 바꾸기 — 만든 카드의 id · 만들거나 고친 카드의 지금 모양 (어댑터가 엔진에 보낸 카드를 읽은 것) */ +export interface WrittenChanges { + readonly createdIds: ReadonlyMap; + readonly cards: ReadonlyMap; +} + +/** + * 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다. + * 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다. + * 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다) + */ +export function applyAddressBookWrite( + snapshot: AddressBookSnapshot, + changes: AddressBookChanges, + written: WrittenChanges, + version: string, +): AddressBookSnapshot | null { + const destroyed = new Set(changes.destroy); + const updates = new Map(changes.update.map((update) => [update.id, update])); + const fresh = (id: string, kind: AddressBookCard['kind']) => { + const card = written.cards.get(id); + return card?.kind === kind ? card : undefined; + }; + + const contacts = snapshot.contacts + .filter((card) => !destroyed.has(card.id)) + .map((card) => { + const read = fresh(card.id, 'contact'); + if (read?.kind === 'contact') return read.card; + return applyContactUpdate(card, updates.get(card.id)); + }); + const groups = snapshot.groups + .filter((card) => !destroyed.has(card.id)) + .map((card) => applyGroupUpdate(card, updates.get(card.id))); + + for (const card of changes.create) { + const id = written.createdIds.get(card.key); + const read = id === undefined ? undefined : written.cards.get(id); + if (read === undefined) return null; + if (read.kind === 'contact') contacts.push(read.card); + else groups.push(read.card); + } + return { version, contacts, groups }; +} diff --git a/backend/src/modules/contacts/contact-book-cache.spec.ts b/backend/src/modules/contacts/contact-book-cache.spec.ts new file mode 100644 index 0000000..14b692c --- /dev/null +++ b/backend/src/modules/contacts/contact-book-cache.spec.ts @@ -0,0 +1,123 @@ +import type { BookView } from './address-book'; +import { ContactBookCache } from './contact-book-cache'; + +/** 판과 크기만 쓰는 가짜 주소록 */ +const view = (version: string, weight = 10): BookView => + ({ version, weight }) as unknown as BookView; + +describe('ContactBookCache (직원마다 기억하는 주소록)', () => { + let now: number; + const clock = () => now; + + beforeEach(() => { + now = 1_000_000; + }); + + it('판이 같을 때만 꺼내 준다 — 다르면 null (다시 읽는다)', () => { + const cache = new ContactBookCache( + { + maxUsers: 10, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + const book = view('v1'); + cache.set('ara', book); + + expect(cache.get('ara', 'v1')).toBe(book); + expect(cache.get('ara', 'v2')).toBeNull(); + expect(cache.get('doyun', 'v1')).toBeNull(); + }); + + it('사람 수 상한 — 가장 오래 쓰지 않은 사람부터 잊는다', () => { + const cache = new ContactBookCache( + { + maxUsers: 2, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + cache.set('a', view('va')); + cache.set('b', view('vb')); + // a 를 쓴다 — 이제 가장 오래 쓰지 않은 사람은 b + expect(cache.get('a', 'va')).not.toBeNull(); + cache.set('c', view('vc')); + + expect(cache.get('a', 'va')).not.toBeNull(); + expect(cache.get('b', 'vb')).toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + expect(cache.size).toBe(2); + }); + + it('크기 상한 — 합이 넘으면 오래된 사람부터 잊고, 한 사람 상한을 넘는 주소록은 기억하지 않는다', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 60, idleMs: 60_000 }, + clock, + ); + cache.set('a', view('va', 60)); + cache.set('b', view('vb', 30)); + cache.set('c', view('vc', 30)); + + expect(cache.get('a', 'va')).toBeNull(); + expect(cache.get('b', 'vb')).not.toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + + cache.set('huge', view('vh', 61)); + expect(cache.get('huge', 'vh')).toBeNull(); + // 큰 주소록 때문에 다른 사람을 밀어내지 않는다 + expect(cache.size).toBe(2); + }); + + it('한 사람 상한 안의 주소록은 늘 기억한다 — 다른 사람을 밀어내고라도 (계약 3절 "크기" 안의 주소록은 늘 기억할 수 있다)', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 80, idleMs: 60_000 }, + clock, + ); + cache.set('a', view('va', 50)); + cache.set('b', view('vb', 40)); + cache.set('big', view('vbig', 80)); + + expect(cache.get('big', 'vbig')).not.toBeNull(); + expect(cache.get('a', 'va')).toBeNull(); + expect(cache.get('b', 'vb')).toBeNull(); + }); + + it('같은 사람을 다시 기억하면 크기를 한 번만 센다 · 잊으면 크기도 빠진다', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 100, idleMs: 60_000 }, + clock, + ); + for (let index = 0; index < 5; index += 1) { + cache.set('a', view(`v${index}`, 60)); + } + cache.set('b', view('vb', 40)); + expect(cache.size).toBe(2); + + cache.delete('a'); + cache.set('c', view('vc', 60)); + expect(cache.get('b', 'vb')).not.toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + }); + + it('오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다)', () => { + const cache = new ContactBookCache( + { + maxUsers: 10, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + cache.set('old', view('vo')); + now += 60_000; + cache.set('new', view('vn')); + + expect(cache.size).toBe(1); + expect(cache.get('old', 'vo')).toBeNull(); + }); +}); diff --git a/backend/src/modules/contacts/contact-book-cache.ts b/backend/src/modules/contacts/contact-book-cache.ts new file mode 100644 index 0000000..9f46bd9 --- /dev/null +++ b/backend/src/modules/contacts/contact-book-cache.ts @@ -0,0 +1,82 @@ +import type { BookView } from './address-book'; + +export interface ContactBookCacheLimits { + /** 기억하는 사람 수 */ + readonly maxUsers: number; + /** 모두 합친 대략의 크기(글자 수) — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 */ + readonly maxTotalWeight: number; + /** 한 사람의 크기 상한 — 이보다 큰 주소록은 기억하지 않는다(요청마다 다시 읽는다). 쓰기의 크기 한도와 같은 잣대 */ + readonly maxUserWeight: number; + /** 이만큼 쓰지 않으면 잊는다 (ms) */ + readonly idleMs: number; +} + +interface Entry { + readonly view: BookView; + lastUsedAt: number; +} + +/** + * 직원마다 기억하는 주소록 (계약 3절 "캐시") — 판(version)이 같을 때만 꺼내 준다. 쓰는 쪽은 요청마다 엔진에 판을 물어 견준다. + * 가장 오래 쓰지 않은 사람부터 잊는다(사람 수 · 모두 합친 크기 상한), 오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다). + * 한 사람의 상한(maxUserWeight)은 모두 합친 상한보다 작게 둔다 — 크기 한도 안의 주소록은 늘 기억할 수 있다(다른 사람을 밀어낼 뿐). + * + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 — 틀리지는 않는다(쓸 때마다 판을 엔진에 묻는다), 처음 읽기가 인스턴스마다 생길 뿐이다. + */ +export class ContactBookCache { + /** 넣은(쓴) 차례 — 맨 앞이 가장 오래 쓰지 않은 사람 */ + private readonly entries = new Map(); + private totalWeight = 0; + + constructor( + private readonly limits: ContactBookCacheLimits, + private readonly now: () => number = () => Date.now(), + ) {} + + /** 판이 같으면 기억한 주소록 (쓴 사람을 맨 뒤로), 아니면 null */ + get(user: string, version: string): BookView | null { + const entry = this.entries.get(user); + if (!entry || entry.view.version !== version) return null; + entry.lastUsedAt = this.now(); + this.entries.delete(user); + this.entries.set(user, entry); + return entry.view; + } + + set(user: string, view: BookView): void { + this.delete(user); + this.dropIdle(); + // 한 사람의 상한을 넘는 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않는다 — 요청마다 다시 읽는다 + if (view.weight > this.limits.maxUserWeight) return; + + this.entries.set(user, { view, lastUsedAt: this.now() }); + this.totalWeight += view.weight; + while ( + this.entries.size > this.limits.maxUsers || + this.totalWeight > this.limits.maxTotalWeight + ) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.delete(oldest); + } + } + + delete(user: string): void { + const entry = this.entries.get(user); + if (!entry) return; + this.entries.delete(user); + this.totalWeight -= entry.view.weight; + } + + /** 기억한 사람 수 (점검용) */ + get size(): number { + return this.entries.size; + } + + private dropIdle(): void { + const cutoff = this.now() - this.limits.idleMs; + for (const [user, entry] of this.entries) { + if (entry.lastUsedAt <= cutoff) this.delete(user); + } + } +} diff --git a/backend/src/modules/contacts/contact-groups.controller.ts b/backend/src/modules/contacts/contact-groups.controller.ts new file mode 100644 index 0000000..d22239a --- /dev/null +++ b/backend/src/modules/contacts/contact-groups.controller.ts @@ -0,0 +1,164 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + CONTACT_GROUPS_ROUTE, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactGroupCreateDto, + ContactGroupDeletedDto, + ContactGroupDto, + ContactGroupMembersAddedDto, + ContactGroupMembersDto, + ContactGroupPageDto, + ContactGroupRenameDto, +} from './dto/contact-group.dto'; + +const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자 · 제어 문자 없음, 이 주소록 안에서 겹치지 않는다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).`; + +// 내 주소록 — 그룹 (계약 3절 "주소록", 8단계). 엔진 카드는 JSContact kind group — 구성원은 연락처 카드의 uid 로 잇는다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_GROUPS_ROUTE) +export class ContactGroupsController { + constructor(private readonly contacts: ContactsService) {} + + @Get() + @Header('Cache-Control', 'private, no-store') + @ApiOperation({ + summary: '그룹 목록 (커서 페이지네이션 · total 포함)', + description: + '이름 차례(한국어 사전 차례 → id). memberCount 는 이 주소록에 있는 연락처 가운데 구성원 수. ' + + '그룹은 받는 사람 추천에 넣지 않는다 — 쓰기 화면이 이 목록을 받아 그룹 이름으로 찾는다. Cache-Control: private, no-store.', + }) + @ApiStandardResponse(ContactGroupPageDto, { description: '그룹 목록' }) + @ApiErrorResponse('VAL_001', '잘못된 커서 · limit 범위 밖') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + ) { + return this.contacts.listGroups(user, query); + } + + @Post() + @ApiOperation({ + summary: '그룹 만들기', + description: + `${NAME_RULES} 그룹은 ${MAX_CONTACT_GROUPS}개까지. 본문은 { name } 만 — 구성원은 POST /api/contact-groups/{id}/members 로 넣고, ` + + '지운 그룹은 POST /api/contact-restorations 로 되돌린다.', + }) + @ApiStandardResponse(ContactGroupDto, { + status: 201, + description: '만든 그룹', + }) + @ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드') + @ApiErrorResponse( + 'CONTACT_001', + `CONTACT_001 같은 이름의 그룹이 있다 / CONTACT_002 그룹 ${MAX_CONTACT_GROUPS}개 한도 — 둘 다 409, 아무것도 바꾸지 않았다`, + ) + create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactGroupCreateDto, + ) { + return this.contacts.createGroup(user, dto); + } + + @Patch(':id') + @ApiOperation({ + summary: '그룹 이름 바꾸기', + description: `${NAME_RULES} 같은 이름이면 바꾸지 않고 그대로 돌려준다.`, + }) + @ApiStandardResponse(ContactGroupDto, { description: '바꾼 그룹' }) + @ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드') + @ApiErrorResponse('RES_001', '없는 그룹 (형식이 틀린 id 포함)') + @ApiErrorResponse('CONTACT_001', '같은 이름의 다른 그룹이 있다') + rename( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactGroupRenameDto, + ) { + return this.contacts.renameGroup(user, id, dto.name); + } + + @Delete(':id') + @ApiOperation({ + summary: '그룹 지우기', + description: + '그룹 카드만 지운다 — 연락처는 그대로. 응답은 이름 · 구성원과 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, ' + + '그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 서버가 기억하지 못했으면 null).', + }) + @ApiStandardResponse(ContactGroupDeletedDto, { + description: '지운 그룹 — 이름 · 구성원 · 되돌리기 표', + }) + @ApiErrorResponse('RES_001', '없는 그룹') + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.removeGroup(user, id); + } + + @Post(':id/members') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '그룹에 구성원 넣기', + description: + `contactIds(내 연락처)와 directoryIds(사내 목록)를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. ` + + '사내 목록에서 고른 사람은 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를, 없으면 이름 · 주소로 새로 만들어 넣는다. ' + + `이미 구성원인 연락처 · uid 가 없는 카드는 세지 않는다. 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 ${MAX_CONTACT_GROUP_MEMBERS}명 · ` + + '주소록 크기 800만까지 — 넘으면 아무것도 하지 않는다.', + }) + @ApiStandardResponse(ContactGroupMembersAddedDto, { + description: '넣은 뒤의 그룹 · 이번에 넣은 수 · 새로 만든 연락처 수', + }) + @ApiErrorResponse( + 'VAL_001', + `합쳐 0개 · ${MAX_GROUP_MEMBERS_PER_REQUEST}개 초과 · 형식 · 겹침 · 모르는 필드`, + ) + @ApiErrorResponse('RES_001', '없는 그룹 · 연락처 · 사내 목록 id') + @ApiErrorResponse( + 'CONTACT_002', + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애 · 사내 목록을 읽지 못함') + addMembers( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactGroupMembersDto, + ) { + return this.contacts.addGroupMembers(user, id, dto); + } +} diff --git a/backend/src/modules/contacts/contact-id.pipe.ts b/backend/src/modules/contacts/contact-id.pipe.ts new file mode 100644 index 0000000..17fcd73 --- /dev/null +++ b/backend/src/modules/contacts/contact-id.pipe.ts @@ -0,0 +1,20 @@ +import { Injectable, PipeTransform } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { CONTACT_ID_PATTERN } from './contacts.constants'; + +/** + * 경로의 연락처 · 그룹 id 형식 확인. + * 형식이 틀린 id 는 "그런 대상이 없다"와 같으므로 RES_001 로 응답한다 (엔진까지 보내지 않는다 — 메일의 MailIdPipe 와 같은 규칙) + */ +@Injectable() +export class ContactIdPipe implements PipeTransform { + transform(value: unknown): string { + if (typeof value === 'string' && CONTACT_ID_PATTERN.test(value)) { + return value; + } + throw new BusinessException('RES_001', { + context: { reason: 'malformed-contact-id' }, + }); + } +} diff --git a/backend/src/modules/contacts/contact-paging.ts b/backend/src/modules/contacts/contact-paging.ts new file mode 100644 index 0000000..038bd52 --- /dev/null +++ b/backend/src/modules/contacts/contact-paging.ts @@ -0,0 +1,55 @@ +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; + +/** + * 주소록 · 그룹 · 사내 목록의 커서 페이지 (계약 3절) — 서버가 그 자리에서 거르고 줄 세운 목록의 위치를 커서로 준다. + * 커서는 받은 그 범위(목록 · q · groupId · kind)로만 쓴다 — 다르면 엔진에 묻기 전에 400 VAL_001. + * 차례 사이에 목록이 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. + */ + +/** 목록 범위 — 목록 이름과 거르는 값(없으면 null)을 이은 글 */ +export function listScope( + list: string, + ...filters: (string | null | undefined)[] +): string { + return JSON.stringify([list, ...filters.map((value) => value ?? null)]); +} + +/** 커서의 위치 — 없으면 0 */ +export function cursorOffset( + cursor: string | undefined, + scope: string, +): number { + if (cursor === undefined) return 0; + const reading = decodeOffsetCursor(cursor, scope); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-contact-cursor:${reading.problem}` }, + }); + } + return reading.offset; +} + +/** 거른 목록의 한 쪽 — total 은 거른 개수 */ +export function pageOf( + items: readonly T[], + start: number, + limit: number, + scope: string, +): PaginatedResult { + const page = items.slice(start, start + limit); + const next = start + page.length; + const hasNext = next < items.length; + return toPaginatedResult([...page], { + hasNext, + nextCursor: hasNext ? encodeOffsetCursor(next, scope) : null, + total: items.length, + }); +} diff --git a/backend/src/modules/contacts/contact-restorations.controller.ts b/backend/src/modules/contacts/contact-restorations.controller.ts new file mode 100644 index 0000000..f056512 --- /dev/null +++ b/backend/src/modules/contacts/contact-restorations.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + CONTACT_RESTORATIONS_ROUTE, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactRestorationDto, + ContactRestoredDto, +} from './dto/contact-restoration.dto'; + +// 내 주소록 — 지운 연락처 · 그룹 되돌리기 (계약 3절 "되돌리기", 8단계). 지우기 응답의 restoreToken 으로 원본 그대로 다시 만든다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(CONTACT_RESTORATIONS_ROUTE) +export class ContactRestorationsController { + constructor(private readonly contacts: ContactsService) {} + + @Post() + @ApiOperation({ + summary: '지운 연락처 · 그룹 되돌리기', + description: + '서버가 지우기 직전에 읽은 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 그대로 다시 만든다 — 같은 uid(그사이 같은 uid 의 카드가 생겼으면 새 uid), ' + + `id 는 새로 받는다. 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 건너뛴다), ` + + '그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 표는 5분 · 한 번만(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). ' + + '서버를 다시 띄우면 표는 사라진다.', + }) + @ApiStandardResponse(ContactRestoredDto, { + status: 201, + description: '다시 만든 연락처 · 그룹', + }) + @ApiErrorResponse('VAL_001', '표 형식 · 모르는 필드') + @ApiErrorResponse( + 'RES_001', + '표가 없음 · 지남 · 이미 씀 · 다른 직원의 표 (모두 같게 답한다)', + ) + @ApiErrorResponse( + 'CONTACT_001', + `CONTACT_001 같은 이름의 그룹이 그사이 생겼다 / CONTACT_002 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 ${MAX_CONTACT_GROUPS}개 · 크기 한도 — 둘 다 409, 아무것도 바꾸지 않았다`, + ) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애 (엔진이 원본을 받지 않음 포함)') + restore( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactRestorationDto, + ) { + return this.contacts.restore(user, dto.token); + } +} diff --git a/backend/src/modules/contacts/contact-restore-store.spec.ts b/backend/src/modules/contacts/contact-restore-store.spec.ts new file mode 100644 index 0000000..4f250cd --- /dev/null +++ b/backend/src/modules/contacts/contact-restore-store.spec.ts @@ -0,0 +1,114 @@ +import { + ContactRestoreStore, + type RestorableCard, +} from './contact-restore-store'; +import { + CONTACT_RESTORE_TOKEN_BYTES, + CONTACT_RESTORE_TOKEN_PATTERN, +} from './contacts.constants'; + +const LIMITS = { + ttlMs: 5 * 60_000, + maxPerUser: 3, + maxCardBytes: 100, + maxTotalBytes: 250, + tokenBytes: CONTACT_RESTORE_TOKEN_BYTES, +}; + +const contact = (bytes = 10, name = '박지훈'): RestorableCard => ({ + kind: 'contact', + original: { source: '{}', bytes }, + card: { + id: 'c1', + uid: 'urn:uuid:c1', + name, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + groups: [], +}); + +describe('ContactRestoreStore (되돌리기 표)', () => { + let now: number; + let store: ContactRestoreStore; + + beforeEach(() => { + now = 1_000_000; + store = new ContactRestoreStore(LIMITS, () => now); + }); + + it('표는 무작위 128비트 base64url(22자) — 그 직원만 찾는다', () => { + const card = contact(); + const token = store.remember('ara', card); + expect(token).toMatch(CONTACT_RESTORE_TOKEN_PATTERN); + expect(store.find('ara', token ?? '')).toBe(card); + expect(store.find('doyun', token ?? '')).toBeNull(); + expect(store.find('ara', 'AAAAAAAAAAAAAAAAAAAAAA')).toBeNull(); + // 다른 직원이 찾아도 표는 그대로 남는다 + expect(store.find('ara', token ?? '')).toBe(card); + expect(store.remember('ara', contact())).not.toBe(token); + }); + + it('한 번만 — 쓰는 쪽이 forget 하면 다시 찾지 못한다', () => { + const token = store.remember('ara', contact()) ?? ''; + store.forget(token); + expect(store.find('ara', token)).toBeNull(); + expect(store.size).toBe(0); + // 없는 표를 잊어도 괜찮다 + expect(() => store.forget(token)).not.toThrow(); + }); + + it('5분이 지나면 찾지 못한다 (그때 잊는다)', () => { + const token = store.remember('ara', contact()) ?? ''; + now += LIMITS.ttlMs - 1; + expect(store.find('ara', token)).not.toBeNull(); + now += 1; + expect(store.find('ara', token)).toBeNull(); + expect(store.size).toBe(0); + }); + + it('새로 기억할 때 지난 표를 함께 잊는다 (줄지 않는 표를 두지 않는다)', () => { + store.remember('ara', contact()); + store.remember('doyun', contact()); + now += LIMITS.ttlMs; + store.remember('seojun', contact()); + expect(store.size).toBe(1); + }); + + it('직원마다 최근 maxPerUser 개 — 넘으면 그 직원의 가장 오래된 표부터 (다른 직원 것은 그대로)', () => { + const tokens = Array.from( + { length: LIMITS.maxPerUser + 1 }, + (_, index) => store.remember('ara', contact(10, `n${index}`)) ?? '', + ); + const other = store.remember('doyun', contact()) ?? ''; + expect(store.find('ara', tokens[0] ?? '')).toBeNull(); + for (const token of tokens.slice(1)) { + expect(store.find('ara', token)).not.toBeNull(); + } + expect(store.find('doyun', other)).not.toBeNull(); + }); + + it('원본이 maxCardBytes 를 넘으면 기억하지 않는다 — 표 null', () => { + expect(store.remember('ara', contact(LIMITS.maxCardBytes))).not.toBeNull(); + expect(store.remember('ara', contact(LIMITS.maxCardBytes + 1))).toBeNull(); + expect(store.size).toBe(1); + }); + + it('모두 합친 크기가 넘으면 가장 오래된 표부터 잊는다 — 직원이 달라도', () => { + const first = store.remember('ara', contact(100)) ?? ''; + const second = store.remember('doyun', contact(100)) ?? ''; + const third = store.remember('seojun', contact(100)) ?? ''; + expect(store.find('ara', first)).toBeNull(); + expect(store.find('doyun', second)).not.toBeNull(); + expect(store.find('seojun', third)).not.toBeNull(); + // 쓴 표를 잊으면 크기도 빠진다 — 다음 표가 앞 표를 밀어내지 않는다 + store.forget(second); + const fourth = store.remember('ara', contact(100)) ?? ''; + expect(store.find('seojun', third)).not.toBeNull(); + expect(store.find('ara', fourth)).not.toBeNull(); + }); +}); diff --git a/backend/src/modules/contacts/contact-restore-store.ts b/backend/src/modules/contacts/contact-restore-store.ts new file mode 100644 index 0000000..a35ea20 --- /dev/null +++ b/backend/src/modules/contacts/contact-restore-store.ts @@ -0,0 +1,143 @@ +import { randomBytes } from 'crypto'; + +import type { + AddressBookContactCard, + AddressBookGroupCard, + RemovedCardOriginal, +} from './contacts.provider'; + +/** 지운 연락처가 그때 속했던 그룹 — 되돌릴 때 uid 로 찾는다(그사이 되돌린 그룹은 id 가 바뀌어도 uid 는 같다), uid 가 없으면 id 로 */ +export interface RestorableGroupRef { + readonly id: string; + readonly uid: string | null; +} + +/** 되돌릴 수 있는 것 — 지운 카드의 원본(엔진 카드 전체)과 지울 때의 우리 칸 */ +export type RestorableCard = + | { + readonly kind: 'contact'; + readonly original: RemovedCardOriginal; + readonly card: AddressBookContactCard; + /** 그때 속했던 그룹 */ + readonly groups: readonly RestorableGroupRef[]; + } + | { + readonly kind: 'group'; + readonly original: RemovedCardOriginal; + readonly card: AddressBookGroupCard; + }; + +export interface ContactRestoreLimits { + /** 표를 기억하는 시간 (ms) */ + readonly ttlMs: number; + /** 직원마다 기억하는 표 수 — 넘으면 그 직원의 가장 오래된 표부터 잊는다 */ + readonly maxPerUser: number; + /** 원본 하나의 상한(UTF-8 바이트) — 넘으면 기억하지 않는다(표 null) */ + readonly maxCardBytes: number; + /** 모두 합친 원본의 상한(바이트) — 넘으면 가장 오래된 표부터 잊는다 */ + readonly maxTotalBytes: number; + /** 표의 무작위 바이트 수 */ + readonly tokenBytes: number; +} + +interface Entry { + readonly user: string; + readonly card: RestorableCard; + readonly expiresAt: number; +} + +/** + * 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 원본을 직원마다 잠깐 기억한다(서버 메모리). + * - 표는 무작위 128비트(base64url) — 지운 직원의 것만 쓸 수 있다(다른 직원 · 지난 표 · 없는 표는 find 가 null) + * - 한 번만 쓴다 — 되돌리기가 성공하면 쓰는 쪽이 forget 한다(실패하면 남겨 두어 다시 쓸 수 있다) + * - 5분 · 직원마다 최근 20개 · 원본 하나 256KB · 모두 합쳐 32MB (contacts.constants.ts) — 넣은 차례가 곧 끝나는 차례라 앞에서부터 잊는다 + * - 표 · 원본은 로그에 남기지 않는다 + * + * ⚠️ 프로세스 메모리다. 서버를 여러 대로 늘리면 공유 저장소로 옮겨야 한다 — 다른 인스턴스로 간 되돌리기는 404 RES_001 이 된다. + */ +export class ContactRestoreStore { + /** 표 → 기억한 것 (넣은 차례 — 맨 앞이 가장 먼저 끝난다) */ + private readonly entries = new Map(); + /** 직원 → 그 직원의 표 (넣은 차례) */ + private readonly byUser = new Map(); + private totalBytes = 0; + + constructor( + private readonly limits: ContactRestoreLimits, + private readonly now: () => number = () => Date.now(), + ) {} + + /** 기억하고 표를 준다 — 원본이 상한을 넘으면 기억하지 않고 null */ + remember(user: string, card: RestorableCard): string | null { + this.dropExpired(); + if (card.original.bytes > this.limits.maxCardBytes) return null; + + const tokens = this.byUser.get(user) ?? []; + while (tokens.length >= this.limits.maxPerUser) { + const oldest = tokens[0]; + if (oldest === undefined) break; + this.forget(oldest); + } + + let token = this.newToken(); + while (this.entries.has(token)) token = this.newToken(); + this.entries.set(token, { + user, + card, + expiresAt: this.now() + this.limits.ttlMs, + }); + const owned = this.byUser.get(user) ?? []; + owned.push(token); + this.byUser.set(user, owned); + this.totalBytes += card.original.bytes; + + while (this.totalBytes > this.limits.maxTotalBytes) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.forget(oldest); + } + return this.entries.has(token) ? token : null; + } + + /** 그 직원의 표 — 없거나 · 지났거나 · 다른 직원의 것이면 null (지난 표는 이때 잊는다) */ + find(user: string, token: string): RestorableCard | null { + const entry = this.entries.get(token); + if (!entry) return null; + if (entry.expiresAt <= this.now()) { + this.forget(token); + return null; + } + return entry.user === user ? entry.card : null; + } + + /** 표를 잊는다 (쓴 표 · 밀려난 표) */ + forget(token: string): void { + const entry = this.entries.get(token); + if (!entry) return; + this.entries.delete(token); + this.totalBytes -= entry.card.original.bytes; + const owned = this.byUser.get(entry.user); + if (!owned) return; + const index = owned.indexOf(token); + if (index >= 0) owned.splice(index, 1); + if (owned.length === 0) this.byUser.delete(entry.user); + } + + /** 기억한 표 수 (점검용) */ + get size(): number { + return this.entries.size; + } + + private newToken(): string { + return randomBytes(this.limits.tokenBytes).toString('base64url'); + } + + /** 지난 표를 잊는다 — 넣은 차례가 곧 끝나는 차례다 */ + private dropExpired(): void { + const now = this.now(); + for (const [token, entry] of this.entries) { + if (entry.expiresAt > now) break; + this.forget(token); + } + } +} diff --git a/backend/src/modules/contacts/contact-search.spec.ts b/backend/src/modules/contacts/contact-search.spec.ts new file mode 100644 index 0000000..d85644c --- /dev/null +++ b/backend/src/modules/contacts/contact-search.spec.ts @@ -0,0 +1,223 @@ +import { + compareContacts, + compareDirectoryEntries, + compareGroups, + contactSearchIndex, + foldText, + matchesContact, + matchesDirectoryEntry, + nameInitials, + parseContactSearch, + type ContactSearchQuery, +} from './contact-search'; +import type { Contact, DirectoryEntry } from './contacts.types'; + +const contact = (overrides: Partial = {}): Contact => ({ + id: 'c1', + name: '박지훈', + emails: [{ address: 'Jihoon.Park@Hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: 'Manager', + note: '메모에는 찾는 글이 있어도 맞지 않는다', + groupIds: [], + ...overrides, +}); + +const search = (q: string): ContactSearchQuery => { + const parsed = parseContactSearch(q); + if (parsed === null) throw new Error(`해석하지 못한 찾는 글: ${q}`); + return parsed; +}; + +const matches = (q: string, target: Contact = contact()) => + matchesContact(contactSearchIndex(target), search(q)); + +describe('찾는 글 해석 (parseContactSearch)', () => { + it('앞뒤 공백을 빼고 비면 null — 거르지 않는다', () => { + for (const q of [undefined, null, '', ' ', '\u3000']) { + expect(parseContactSearch(q)).toBeNull(); + } + }); + + it('초성만 친 글 — 띄어쓰기는 빼고, 겹자음은 두 초성으로 나눈다 (입력기가 ㄱ 다음 ㅅ 을 ㄳ 으로 합친다)', () => { + expect(parseContactSearch('ㅂㅈㅎ')).toEqual({ + kind: 'initials', + initials: 'ㅂㅈㅎ', + }); + expect(parseContactSearch(' ㅂ ㅈ ㅎ ')).toEqual({ + kind: 'initials', + initials: 'ㅂㅈㅎ', + }); + expect(parseContactSearch('ㄳㅇ')).toEqual({ + kind: 'initials', + initials: 'ㄱㅅㅇ', + }); + // 쌍자음은 그대로 (초성이다) + expect(parseContactSearch('ㄲㅊ')).toEqual({ + kind: 'initials', + initials: 'ㄲㅊ', + }); + }); + + it('초성과 다른 글자가 섞이면 글로 찾는다 — 번호처럼 생긴 글이면 숫자도', () => { + expect(parseContactSearch('박ㅈ')).toEqual({ + kind: 'text', + text: '박ㅈ', + digits: null, + }); + expect(parseContactSearch(' 010-3192 ')).toEqual({ + kind: 'text', + text: '010-3192', + digits: '0103192', + }); + expect(parseContactSearch('Hanbit')).toEqual({ + kind: 'text', + text: 'hanbit', + digits: null, + }); + // 숫자가 없는 기호뿐인 글은 번호로 보지 않는다 + expect(parseContactSearch('+-')).toMatchObject({ digits: null }); + // 숫자와 글자가 섞이면 번호가 아니다 + expect(parseContactSearch('a1')).toMatchObject({ digits: null }); + }); +}); + +describe('연락처 찾기 (matchesContact)', () => { + it('초성 — 이름의 초성과 견준다 (띄어쓰기는 빼고, 가운데부터여도)', () => { + expect(matches('ㅂㅈㅎ')).toBe(true); + expect(matches('ㅈㅎ')).toBe(true); + expect(matches('ㅂㅎ')).toBe(false); + expect(matches('ㅂㅈㅎ', contact({ name: '박 지훈' }))).toBe(true); + // 이름이 없으면 초성으로는 찾지 못한다 + expect(matches('ㅎ', contact({ name: null }))).toBe(false); + // 초성은 이름만 본다 — 회사(한빛상사)의 초성은 보지 않는다 + expect(matches('ㅎㅂ')).toBe(false); + }); + + it('겹자음으로 합쳐진 초성도 찾는다 — 김선우를 ㄱ ㅅ ㅇ 으로 치면 ㄳㅇ', () => { + expect(matches('ㄳㅇ', contact({ name: '김선우' }))).toBe(true); + }); + + it('이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 글 — ASCII 대소문자 무시, 메모는 보지 않는다', () => { + for (const q of [ + '지훈', + 'jihoon.park', + 'HANBIT', + '한빛', + '구매', + 'manager', + ]) { + expect(matches(q)).toBe(true); + } + expect(matches('찾는 글')).toBe(false); + expect(matches('없는 사람')).toBe(false); + }); + + it('전화번호는 숫자만 견준다 — 01031925540 · 3192 · 010 3192 가 010-3192-5540 에 맞는다', () => { + for (const q of ['01031925540', '3192', '010 3192', '(010)3192', '5540']) { + expect(matches(q)).toBe(true); + } + expect(matches('3193')).toBe(false); + // 번호 칸의 글 그대로도 맞는다 + expect(matches('010-3192')).toBe(true); + }); + + it('조합형(NFD)으로 친 한글도 찾는다', () => { + expect(matches('지훈'.normalize('NFD'))).toBe(true); + }); + + it('ASCII 가 아닌 대소문자는 가리지 않고 그대로 견준다 (계약 — ASCII 만 무시)', () => { + expect(foldText('ÄBC')).toBe('Äbc'); + }); +}); + +describe('사내 목록 찾기 (matchesDirectoryEntry)', () => { + const entry: DirectoryEntry = { + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }; + + it('이름 · 주소로 같은 규칙 — 초성 · 글', () => { + expect(matchesDirectoryEntry(entry, search('ㄷㅇ'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('도윤'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('DOYUN'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('ㅅㅈ'))).toBe(false); + expect(matchesDirectoryEntry(entry, search('서준'))).toBe(false); + }); +}); + +describe('이름의 초성 (nameInitials)', () => { + it('한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로', () => { + expect(nameInitials('박지훈')).toBe('ㅂㅈㅎ'); + expect(nameInitials('꽃 님')).toBe('ㄲㄴ'); + expect(nameInitials('Kim 아라')).toBe('kimㅇㄹ'); + }); +}); + +describe('차례', () => { + it('연락처 — 이름(한국어 사전 차례), 이름이 없으면 대표 주소, 같으면 대표 주소 → id, 이름도 주소도 없으면 맨 뒤', () => { + const cards = [ + contact({ id: 'z', name: null, emails: [] }), + contact({ id: 'c', name: '하늘', emails: [] }), + contact({ + id: 'b', + name: null, + emails: [{ address: 'apple@x.example', label: 'other' }], + }), + contact({ + id: 'a2', + name: '가나다', + emails: [{ address: 'b@x.example', label: 'work' }], + }), + contact({ + id: 'a1', + name: '가나다', + emails: [{ address: 'b@x.example', label: 'work' }], + }), + contact({ + id: 'a0', + name: '가나다', + emails: [{ address: 'a@x.example', label: 'work' }], + }), + contact({ id: 'd', name: '나비', emails: [] }), + ]; + + expect([...cards].sort(compareContacts).map((card) => card.id)).toEqual([ + 'a0', + 'a1', + 'a2', + 'd', + 'c', + // 한국어 사전 차례는 한글이 영문(apple — 이름이 없어 대표 주소로 견준다)보다 앞이다 + 'b', + 'z', + ]); + }); + + it('그룹 — 이름 → id · 사내 목록 — 이름 → 주소 → id', () => { + expect( + [ + { id: '2', name: '하나' }, + { id: '1', name: '거래처' }, + { id: '0', name: '거래처' }, + ] + .sort(compareGroups) + .map((group) => group.id), + ).toEqual(['0', '1', '2']); + + const people: DirectoryEntry[] = [ + { id: '3', name: '이도윤', email: 'b@x.test', kind: 'person' }, + { id: '2', name: '이도윤', email: 'a@x.test', kind: 'person' }, + { id: '1', name: '김아라', email: 'z@x.test', kind: 'person' }, + ]; + expect(people.sort(compareDirectoryEntries).map((p) => p.id)).toEqual([ + '1', + '2', + '3', + ]); + }); +}); diff --git a/backend/src/modules/contacts/contact-search.ts b/backend/src/modules/contacts/contact-search.ts new file mode 100644 index 0000000..943083d --- /dev/null +++ b/backend/src/modules/contacts/contact-search.ts @@ -0,0 +1,204 @@ +import type { Contact, DirectoryEntry } from './contacts.types'; + +/** + * 주소록 · 사내 목록의 찾기(q)와 차례 (순수 함수) — 계약 3절 "찾기" · "차례". + * + * - 초성만 친 글(ㄱ~ㅎ, 쌍자음 · 겹자음 포함)이면 이름의 초성과 견준다 — ㅂㅈㅎ → 박지훈. 겹자음은 나눈다(ㄱ 다음 ㅅ 을 치면 입력기가 ㄳ 으로 합친다) + * - 그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 그 글이 들어 있으면 맞다 (ASCII 대소문자 무시, 유니코드 NFC 로 맞춰 견준다) + * - 전화번호는 숫자만 견준다 — 번호처럼 생긴 글(숫자 · 띄어쓰기 · + - ( ) . # *)이면 그 숫자가 번호의 숫자 안에 있으면 맞다 + * (`01031925540` · `3192` 가 `010-3192-5540` 에 맞는다) + * - 사내 목록은 이름 · 주소로 같은 규칙 + * + * 엔진의 주소록 검색은 낱말 단위이고 이름 정렬을 하지 못해서(2026-10-29 확인) 서버가 주소록 전체를 두고 찾고 줄 세운다. + */ + +const SYLLABLE_START = 0xac00; +const SYLLABLE_END = 0xd7a3; +/** 한 초성에 딸린 글자 수 — 중성 21 × 종성 28 */ +const SYLLABLES_PER_INITIAL = 21 * 28; +/** 초성 — 호환 자모 */ +const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'); +/** 호환 자모의 자음(ㄱ U+3131 ~ ㅎ U+314E) — 쌍자음 · 겹자음 포함 */ +const CONSONANT_JAMO = /^[ㄱ-ㅎ]$/u; +/** 겹자음 → 두 초성 */ +const SPLIT_CONSONANTS: Readonly> = { + ㄳ: 'ㄱㅅ', + ㄵ: 'ㄴㅈ', + ㄶ: 'ㄴㅎ', + ㄺ: 'ㄹㄱ', + ㄻ: 'ㄹㅁ', + ㄼ: 'ㄹㅂ', + ㄽ: 'ㄹㅅ', + ㄾ: 'ㄹㅌ', + ㄿ: 'ㄹㅍ', + ㅀ: 'ㄹㅎ', + ㅄ: 'ㅂㅅ', +}; +/** 번호처럼 생긴 글 — 숫자 · 띄어쓰기 · + - ( ) . # * 만 (숫자가 하나 이상인지는 따로 본다) */ +const PHONE_LIKE = /^[0-9\s+\-().#*]+$/u; +const WHITESPACE = /\s/u; +const NON_DIGITS = /\D+/g; + +/** 견주기용 글 — NFC 로 맞추고 ASCII 대문자만 소문자로 */ +export function foldText(text: string): string { + return text + .normalize('NFC') + .replace(/[A-Z]+/g, (letters) => letters.toLowerCase()); +} + +/** 이름의 초성 — 한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로(견주기용으로 맞춰) 둔다 */ +export function nameInitials(name: string): string { + let initials = ''; + for (const char of foldText(name)) { + if (WHITESPACE.test(char)) continue; + const code = char.codePointAt(0) ?? 0; + initials += + code >= SYLLABLE_START && code <= SYLLABLE_END + ? (INITIALS[ + Math.floor((code - SYLLABLE_START) / SYLLABLES_PER_INITIAL) + ] ?? char) + : char; + } + return initials; +} + +/** 해석한 찾는 글 — 초성으로 찾기 · 글로 찾기(번호처럼 생겼으면 그 숫자도) */ +export type ContactSearchQuery = + | { readonly kind: 'initials'; readonly initials: string } + | { + readonly kind: 'text'; + readonly text: string; + /** 번호처럼 생긴 글이면 그 숫자 — 아니면 null */ + readonly digits: string | null; + }; + +/** 찾는 글을 해석한다 — 앞뒤 공백을 빼고 비면 null(거르지 않는다) */ +export function parseContactSearch( + query: string | null | undefined, +): ContactSearchQuery | null { + const trimmed = query?.trim() ?? ''; + if (trimmed === '') return null; + + const letters = Array.from(trimmed).filter((char) => !WHITESPACE.test(char)); + if (letters.every((char) => CONSONANT_JAMO.test(char))) { + return { + kind: 'initials', + initials: letters.map((char) => SPLIT_CONSONANTS[char] ?? char).join(''), + }; + } + + const digits = trimmed.replace(NON_DIGITS, ''); + return { + kind: 'text', + text: foldText(trimmed), + digits: PHONE_LIKE.test(trimmed) && digits !== '' ? digits : null, + }; +} + +/** 연락처 하나의 찾기 자료 — 주소록을 읽을 때 한 번 만들어 둔다 (address-book.ts) */ +export interface ContactSearchIndex { + /** 이름의 초성 — 이름이 없으면 빈 글 */ + readonly initials: string; + /** 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호 (견주기용) */ + readonly texts: readonly string[]; + /** 전화번호의 숫자 */ + readonly phoneDigits: readonly string[]; +} + +export function contactSearchIndex(contact: Contact): ContactSearchIndex { + const texts = [ + contact.name, + ...contact.emails.map((email) => email.address), + contact.company, + contact.department, + contact.title, + ...contact.phones.map((phone) => phone.number), + ] + .filter((text): text is string => text !== null && text !== '') + .map(foldText); + return { + initials: contact.name === null ? '' : nameInitials(contact.name), + texts, + phoneDigits: contact.phones + .map((phone) => phone.number.replace(NON_DIGITS, '')) + .filter((digits) => digits !== ''), + }; +} + +export function matchesContact( + index: ContactSearchIndex, + query: ContactSearchQuery, +): boolean { + if (query.kind === 'initials') return index.initials.includes(query.initials); + if (index.texts.some((text) => text.includes(query.text))) return true; + const { digits } = query; + return ( + digits !== null && index.phoneDigits.some((phone) => phone.includes(digits)) + ); +} + +/** 사내 목록 한 줄이 맞는가 — 이름 · 주소로 같은 규칙 */ +export function matchesDirectoryEntry( + entry: DirectoryEntry, + query: ContactSearchQuery, +): boolean { + if (query.kind === 'initials') { + return nameInitials(entry.name).includes(query.initials); + } + return ( + foldText(entry.name).includes(query.text) || + foldText(entry.email).includes(query.text) + ); +} + +/** 한국어 사전 차례 (계약 3절 "차례") */ +const collator = new Intl.Collator('ko'); + +/** 둘 다 있으면 사전 차례, 하나만 없으면 없는 쪽이 뒤 */ +function compareOptional(a: string | null, b: string | null): number { + if (a === null || b === null) { + return a === b ? 0 : a === null ? 1 : -1; + } + return collator.compare(a, b); +} + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * 연락처의 차례 — 이름(없으면 대표 주소)으로 견주고, 같으면 대표 주소 → id. + * 이름도 주소도 없는 연락처(다른 프로그램이 전화번호만 넣은 카드 등)는 맨 뒤 + */ +export function compareContacts( + a: Pick, + b: Pick, +): number { + const primaryA = a.emails[0]?.address ?? null; + const primaryB = b.emails[0]?.address ?? null; + return ( + compareOptional(a.name ?? primaryA, b.name ?? primaryB) || + compareOptional(primaryA, primaryB) || + compareIds(a.id, b.id) + ); +} + +/** 그룹의 차례 — 이름 → id */ +export function compareGroups( + a: { readonly id: string; readonly name: string }, + b: { readonly id: string; readonly name: string }, +): number { + return collator.compare(a.name, b.name) || compareIds(a.id, b.id); +} + +/** 사내 목록의 차례 — 이름 → 주소 → id */ +export function compareDirectoryEntries( + a: DirectoryEntry, + b: DirectoryEntry, +): number { + return ( + collator.compare(a.name, b.name) || + collator.compare(a.email, b.email) || + compareIds(a.id, b.id) + ); +} diff --git a/backend/src/modules/contacts/contacts.constants.spec.ts b/backend/src/modules/contacts/contacts.constants.spec.ts new file mode 100644 index 0000000..c247ee2 --- /dev/null +++ b/backend/src/modules/contacts/contacts.constants.spec.ts @@ -0,0 +1,85 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { ERROR_CODES } from '@/common/errors/error-codes'; +import { + CONTACT_NOTE_PATTERN, + CONTACT_PHONE_DIGIT, + CONTACT_PHONE_PATTERN, + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_MAX_PER_USER, + CONTACT_RESTORE_MAX_TOTAL_BYTES, + MAX_ADDRESS_BOOK_WEIGHT, + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; + +const isPhone = (number: string) => + CONTACT_PHONE_PATTERN.test(number) && CONTACT_PHONE_DIGIT.test(number); + +describe('주소록 상수', () => { + it('CONTACT_002 문구의 숫자가 한도와 같다 — 연락처 · 그룹 · 그룹 하나의 구성원', () => { + const { message } = ERROR_CODES.CONTACT_002; + expect(message).toContain( + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개`, + ); + expect(message).toContain(`그룹 ${MAX_CONTACT_GROUPS}개`); + expect(message).toContain(`${MAX_CONTACT_GROUP_MEMBERS}명`); + }); + + it('전화번호 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만', () => { + for (const number of [ + '010-3192-5540', + '+82 10 3192 5540', + '(02) 333.4444', + '1588-0000#2', + '*77', + '7', + ]) { + expect(isPhone(number)).toBe(true); + } + for (const number of [ + '', + ' ', + '+-()', + '#*', + 'tel:010-1234-5678', + '010-1234-5678 내선', + '010\t1234', + '010', + ]) { + expect(isPhone(number)).toBe(false); + } + }); + + it('전화번호 정규식은 길이에 비례한다 — 숫자 100만 개 뒤에 틀린 글자 하나도 바로 (CPU 시간)', () => { + // 한 정규식(`^[…]*[0-9][…]*$`)이던 때는 두 * 가 숫자를 나눠 가져 길이의 제곱 — 2만 자에 0.5초, 100만 자면 수십 분 (2026-10-29 리뷰 H1) + const digits = '1'.repeat(1_000_000); + const cpuMs = startCpuTimer(); + expect(isPhone(`${digits}x`)).toBe(false); + expect(isPhone(`x${digits}`)).toBe(false); + expect(isPhone(` ${'-'.repeat(1_000_000)}`)).toBe(false); + expect(isPhone(digits)).toBe(true); + // 지금 수 ms — 한도 1초는 부하를 견디고 옛 정규식(2만 자 0.5초 · 100만 자 수십 분)은 확실히 넘는다 + expect(cpuMs()).toBeLessThan(1_000); + }); + + it('메모 — 줄바꿈(\\n)만 되고 탭 · \\r · 그 밖의 제어 문자는 안 된다', () => { + expect(CONTACT_NOTE_PATTERN.test('첫 줄\n둘째 줄')).toBe(true); + for (const note of ['a\tb', 'a\r\nb', 'a\u0000', 'a\u007f', 'a\u0085']) { + expect(CONTACT_NOTE_PATTERN.test(note)).toBe(false); + } + }); + + it('크기 — 한 사람의 쓰기 한도(800만)는 캐시 전체(2,400만)보다 작다 (한도 안의 주소록은 늘 기억할 수 있다)', () => { + expect(MAX_ADDRESS_BOOK_WEIGHT).toBeLessThan( + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + ); + }); + + it('되돌리기 — 한 직원이 표를 다 채워도(20개 × 256KB) 전체 상한 안이다', () => { + expect( + CONTACT_RESTORE_MAX_PER_USER * CONTACT_RESTORE_MAX_CARD_BYTES, + ).toBeLessThanOrEqual(CONTACT_RESTORE_MAX_TOTAL_BYTES); + }); +}); diff --git a/backend/src/modules/contacts/contacts.constants.ts b/backend/src/modules/contacts/contacts.constants.ts new file mode 100644 index 0000000..5407182 --- /dev/null +++ b/backend/src/modules/contacts/contacts.constants.ts @@ -0,0 +1,106 @@ +import { + MAIL_ID_PATTERN, + MAX_EMAIL_LENGTH, +} from '@/modules/mail/mail.constants'; + +/** + * 주소록 API 한도 · 형식 상수 — 계약(docs/mail/api-contract.md 3절 "주소록")의 숫자를 한 곳에 모은다. + * 글자 수는 앞뒤 공백을 뺀 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다. + */ + +/** 라우트 경로 (`/api/contacts` · `/api/contact-groups` · `/api/contact-restorations` · `/api/directory-entries`) */ +export const CONTACTS_ROUTE = 'contacts'; +export const CONTACT_GROUPS_ROUTE = 'contact-groups'; +export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations'; +export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries'; + +/** + * 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다). + * ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다 + */ +export const MAX_CONTACTS = 3_000; +export const MAX_CONTACT_GROUPS = 100; +export const MAX_CONTACT_GROUP_MEMBERS = 500; + +/** + * 주소록 크기 한도 — 서버가 재는 크기(address-book.ts 의 weight — 우리가 다루는 칸의 글자 수 + 항목마다 조금)를 이보다 크게 만드는 쓰기 + * (만들기 · 고치기 · 구성원 넣기 · 되돌리기)는 409 CONTACT_002. 줄이는 쓰기와 지우기는 늘 된다 (계약 3절 "크기"). + * 한 사람분 캐시의 상한과 같은 잣대다 — 이 안의 주소록은 늘 통째로 기억할 수 있다. + * 연락처 3,000개에 그룹 100개를 구성원 500명씩 채워도 연락처마다 1,000자 넘게 남는다 (2026-10-29 리뷰: 칸마다 최대인 연락처 3,000장은 2,400만이었다) + */ +export const MAX_ADDRESS_BOOK_WEIGHT = 8_000_000; + +/** 연락처 칸의 길이 — 이름 · 회사 · 부서 · 직책 100자, 메모 1,000자, 주소 254자, 번호 40자 */ +export const MAX_CONTACT_TEXT_LENGTH = 100; +export const MAX_CONTACT_NOTE_LENGTH = 1_000; +export const MAX_CONTACT_EMAIL_LENGTH = MAX_EMAIL_LENGTH; +export const MAX_CONTACT_PHONE_LENGTH = 40; + +/** 한 연락처의 이메일 · 전화번호 수 — 화면은 5개까지 더한다(다른 프로그램이 넣은 것은 그대로 보이고 저장된다) */ +export const MAX_CONTACT_EMAILS = 20; +export const MAX_CONTACT_PHONES = 20; + +/** 한 연락처가 속하는 그룹 수 (본문의 groupIds) */ +export const MAX_CONTACT_GROUP_IDS = 100; + +/** 그룹 이름 1~50자 */ +export const MAX_CONTACT_GROUP_NAME_LENGTH = 50; + +/** 구성원 넣기 한 번에 contactIds + directoryIds 합쳐 1~100개 */ +export const MAX_GROUP_MEMBERS_PER_REQUEST = 100; + +/** 찾는 글(q) 최대 길이 — 연락처 · 사내 목록. 메일 주소 하나(254자)를 통째로 찾을 수 있게 (읽기 화면 ⋯ 의 "주소록에 있는가") */ +export const MAX_CONTACT_QUERY_LENGTH = MAX_EMAIL_LENGTH; + +/** 연락처 · 그룹 · 사내 목록 id — 엔진의 객체 id(JMAP Id: URL-safe base64 문자 1~255자)와 같은 형식이다 */ +export const CONTACT_ID_PATTERN = MAIL_ID_PATTERN; + +/** + * 전화번호 — 숫자 · 띄어쓰기 · + - ( ) . # * 만(CONTACT_PHONE_PATTERN)이고 숫자가 하나 이상(CONTACT_PHONE_DIGIT). + * 두 정규식으로 나눠 둔다 — 한 정규식(`^[…]*[0-9][…]*$`)으로 쓰면 두 `*` 가 숫자를 나눠 가져, 맞지 않는 긴 글에서 길이의 제곱으로 느려진다 + * (2026-10-29 리뷰 — 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분). 둘 다 길이에 비례한다. 웹(frontend shared/utils/contacts.ts 의 + * isValidContactPhone)도 같은 두 검사다 + */ +export const CONTACT_PHONE_PATTERN = /^[0-9 +\-().#*]+$/; +export const CONTACT_PHONE_DIGIT = /[0-9]/; + +/** 메모 — 줄바꿈(\n)은 되고 그 밖의 제어 문자(탭 · \r 포함 C0 · DEL · C1)는 안 된다 */ +export const CONTACT_NOTE_PATTERN = + // eslint-disable-next-line no-control-regex + /^[^\u0000-\u0009\u000b-\u001f\u007f-\u009f]*$/; + +/** + * 직원마다 기억하는 주소록 (계약 3절 "캐시") — 요청마다 엔진에 판(상태)만 묻고, 그대로면 다시 읽지 않는다. + * - 사람 수 200명 · 모두 합쳐 크기 2,400만(대략 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다) + * - 한 사람은 크기 한도(MAX_ADDRESS_BOOK_WEIGHT — 800만)까지 — 그보다 큰 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않고 요청마다 다시 읽는다 + * - 30분 동안 쓰지 않으면 잊는다 (기억할 때 그때그때 지운다) + */ +export const CONTACT_BOOK_CACHE_MAX_USERS = 200; +export const CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT = 24_000_000; +export const CONTACT_BOOK_CACHE_IDLE_MS = 30 * 60_000; + +/** + * 판이 그대로일 때만 쓰는 횟수 — 그사이 다른 곳(다른 탭 · 메일 프로그램)에서 바뀌거나(stateMismatch) 엔진이 같은 카드의 동시 고치기를 + * 거절하면(serverUnavailable) 다시 읽고 다시 한다. 넘으면 MAIL_001 + */ +export const CONTACT_WRITE_ATTEMPTS = 3; + +/** + * 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 엔진 카드 전체를 직원마다 기억한다(서버 메모리). + * - 5분 · 직원마다 최근 20개 · 카드 하나가 256KB(JSON, UTF-8)를 넘으면 기억하지 않는다(표 null) + * - 모두 합쳐 32MB 를 넘으면 가장 오래된 표부터 잊는다 — 사진이 든 큰 카드를 여러 직원이 한꺼번에 지워도 메모리가 끝없이 늘지 않게 + * ⚠️ 서버를 여러 대로 늘리면 표도 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404 RES_001) + */ +export const CONTACT_RESTORE_TTL_MS = 5 * 60_000; +export const CONTACT_RESTORE_MAX_PER_USER = 20; +export const CONTACT_RESTORE_MAX_CARD_BYTES = 256 * 1024; +export const CONTACT_RESTORE_MAX_TOTAL_BYTES = 32 * 1024 * 1024; +/** 표 — 무작위 128비트를 base64url(채움 없이)로 쓴 22자 */ +export const CONTACT_RESTORE_TOKEN_BYTES = 16; +export const CONTACT_RESTORE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/; + +/** 사내 목록을 기억하는 시간 — 모든 직원이 함께 쓴다 (계약 3절 "사내 목록") */ +export const DIRECTORY_CACHE_TTL_MS = 10 * 60_000; + +/** 사내 목록을 읽지 못한 일을 error 로그로 다시 남기는 간격 — 계정이 없는 동안 요청마다 남기지 않게 */ +export const DIRECTORY_FAILURE_LOG_INTERVAL_MS = 10 * 60_000; diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts new file mode 100644 index 0000000..b4a5d6a --- /dev/null +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -0,0 +1,172 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + CONTACTS_ROUTE, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACTS, + MAX_CONTACT_TEXT_LENGTH, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactDeletedDto, + ContactDto, + ContactInputDto, + ContactListQueryDto, + ContactPageDto, + ContactPatchDto, +} from './dto/contact.dto'; + +/** 개인 정보라 공유 캐시 · 브라우저 캐시 어디에도 남기지 않는다 (받는 사람 추천과 같다) */ +const NO_STORE = 'private, no-store'; + +const INPUT_RULES = + `이름이나 이메일 가운데 하나는 있어야 한다. 이름 · 회사 · 부서 · 직책 ${MAX_CONTACT_TEXT_LENGTH}자 · 메모 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자(줄바꿈만 되는 제어 문자) · ` + + `이메일 ${MAX_CONTACT_EMAILS}개(254자 · 메일 주소 형식 · 같은 연락처 안에서 겹치지 않게 — 대소문자 무시) · 전화번호 ${MAX_CONTACT_PHONES}개(40자 · 숫자와 띄어쓰기 · + - ( ) . # *). ` + + '글은 앞뒤 공백을 지워 저장한다(빈 글은 null).'; + +const SIZE_RULE = + '주소록 크기(우리가 다루는 칸의 글자 수 + 항목마다 조금)를 800만 넘게 하는 쓰기는 409 CONTACT_002 — 줄이는 쓰기 · 지우기는 늘 된다.'; + +// 내 주소록 — 연락처 (계약 3절 "주소록", 8단계). 엔진의 기본 주소록 하나를 그 직원 본인으로 읽고 쓴다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACTS_ROUTE) +export class ContactsController { + constructor(private readonly contacts: ContactsService) {} + + @Get() + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '연락처 목록 (커서 페이지네이션 · total 포함)', + description: + '이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소, 같으면 대표 주소 → id). q 는 초성만 친 글이면 이름의 초성과(ㅂㅈㅎ → 박지훈), ' + + '그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다: 3192 가 010-3192-5540 에 맞는다). ' + + 'groupId 면 그 그룹의 구성원만, excludeGroupId 면 그 그룹의 구성원을 빼고(둘을 함께 쓰면 400 VAL_001). ' + + '커서는 받은 그 q · groupId · excludeGroupId 로만 쓴다. 차례 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. ' + + '서버는 주소록을 엔진의 상태와 함께 기억하고 요청마다 상태만 확인한다. Cache-Control: private, no-store.', + }) + @ApiStandardResponse(ContactPageDto, { description: '연락처 목록' }) + @ApiErrorResponse( + 'VAL_001', + `q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · groupId 와 excludeGroupId 를 함께 · 잘못된 커서 · 다른 q · 그룹의 커서 · limit 범위 밖 · 모르는 쿼리`, + ) + @ApiErrorResponse('RES_001', '없는 groupId · excludeGroupId') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: ContactListQueryDto, + ) { + return this.contacts.list(user, query); + } + + @Get(':id') + @Header('Cache-Control', NO_STORE) + @ApiOperation({ summary: '연락처 하나' }) + @ApiStandardResponse(ContactDto, { description: '연락처' }) + @ApiErrorResponse('RES_001', '없는 연락처 (형식이 틀린 id 포함)') + get( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.get(user, id); + } + + @Post() + @ApiOperation({ + summary: '연락처 추가', + description: + `${INPUT_RULES} groupIds 의 그룹에도 함께 넣는다. 연락처는 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명까지. ` + + `${SIZE_RULE} 엔진 카드는 JSContact(kind individual) — 서버가 uid(urn:uuid)를 넣는다.`, + }) + @ApiStandardResponse(ContactDto, { + status: 201, + description: '만든 연락처', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이 · 개수 · 이름도 이메일도 없음 · 같은 주소가 두 번 · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 groupId') + @ApiErrorResponse( + 'CONTACT_002', + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + create(@CurrentUser() user: AuthenticatedUser, @Body() dto: ContactInputDto) { + return this.contacts.create(user, dto); + } + + @Patch(':id') + @ApiOperation({ + summary: '연락처 고치기 (바꾼 칸만)', + description: + '보낸 칸만 바꾼다(하나 이상 — 비면 400 VAL_001). 보낸 칸만 검사하고 "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. ' + + 'emails · phones · groupIds 는 보내면 통째로 바뀐다. 엔진 카드의 다른 칸(주소 · 생일 · 사진 · 다른 프로그램의 칸)과 칸 안의 남의 정보' + + '(같은 주소 · 번호 항목의 label · 다른 contexts · fax 같은 features, role 직책, 다른 조직 · 부서)는 그대로 둔다 — 종류를 바꾼 것만 우리 값으로. ' + + '이름을 고치면 이름 조각(components)은 지운다. groupIds 로 그룹에 넣고 뺀다 — 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다(400 VAL_001). ' + + `같은 칸을 양쪽에서 바꿨으면 나중에 저장한 쪽이 이긴다(판을 묻지 않는다). ${INPUT_RULES} ${SIZE_RULE}`, + }) + @ApiStandardResponse(ContactDto, { description: '고친 연락처' }) + @ApiErrorResponse( + 'VAL_001', + '빈 본문 · 형식 · 길이 · 개수 · 합친 결과에 이름도 이메일도 없음 · 같은 주소가 두 번 · uid 없는 카드를 그룹에 넣음 · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 연락처 · 없는 groupId') + @ApiErrorResponse( + 'CONTACT_002', + `그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + patch( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactPatchDto, + ) { + return this.contacts.patch(user, id, dto); + } + + @Delete(':id') + @ApiOperation({ + summary: '연락처 지우기 (영구 삭제)', + description: + '엔진 주소록에는 휴지통이 없다. 그 연락처를 가리키는 그룹 구성원도 함께 뺀다. ' + + '응답은 지운 연락처와 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, 원본 그대로 다시 만든다. ' + + '서버가 기억하지 못했으면 null).', + }) + @ApiStandardResponse(ContactDeletedDto, { + description: '지운 연락처와 되돌리기 표', + }) + @ApiErrorResponse('RES_001', '없는 연락처') + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.remove(user, id); + } +} diff --git a/backend/src/modules/contacts/contacts.module.ts b/backend/src/modules/contacts/contacts.module.ts new file mode 100644 index 0000000..f6c3ff8 --- /dev/null +++ b/backend/src/modules/contacts/contacts.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; + +import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider'; +import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { ContactGroupsController } from './contact-groups.controller'; +import { ContactRestorationsController } from './contact-restorations.controller'; +import { ContactsController } from './contacts.controller'; +import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider'; +import { ContactsService } from './contacts.service'; +import { DirectoryEntriesController } from './directory-entries.controller'; +import { DirectoryService } from './directory.service'; + +/** + * 주소록 모듈 (계약 3절 "주소록" — 8단계) — 내 주소록(연락처 · 그룹)과 사내 목록. + * 엔진을 바꿀 때는 `CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER` 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다. + * 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다. + * + * 메일 모듈이 받는 사람 추천에 쓰도록 서비스를 내보낸다 — 이 모듈은 메일 모듈을 가져오지 않는다 (순환 없음) + */ +@Module({ + imports: [MailEngineModule], + controllers: [ + ContactsController, + ContactGroupsController, + ContactRestorationsController, + DirectoryEntriesController, + ], + providers: [ + { provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider }, + { provide: DIRECTORY_PROVIDER, useClass: JmapDirectoryProvider }, + ContactsService, + DirectoryService, + ], + exports: [ContactsService, DirectoryService], +}) +export class ContactsModule {} diff --git a/backend/src/modules/contacts/contacts.provider.ts b/backend/src/modules/contacts/contacts.provider.ts new file mode 100644 index 0000000..295a5c2 --- /dev/null +++ b/backend/src/modules/contacts/contacts.provider.ts @@ -0,0 +1,173 @@ +import type { ContactFields, DirectoryEntryKind } from './contacts.types'; + +/** + * 주소록 · 사내 목록 엔진 포트 — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙). + * + * 지금 구현은 Stalwart(JMAP) — 주소록은 `modules/mail/jmap/jmap-contacts.provider.ts`, 사내 목록은 `jmap-directory.provider.ts`. + * 엔진을 바꿀 때는 이 두 인터페이스의 새 구현을 contacts.module.ts 에 연결하면 된다. + * + * 규칙: + * - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · 카드 형식을 밖으로 흘리지 않는다) + * - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 등 + * - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 주소록에만 접근한다 + * - 주소록은 엔진의 **기본 주소록** 하나만 쓴다 (계약 3절) + */ +export const CONTACTS_PROVIDER = Symbol('CONTACTS_PROVIDER'); +export const DIRECTORY_PROVIDER = Symbol('DIRECTORY_PROVIDER'); + +/** 주소록의 연락처 카드 한 장 (그룹 아님) — 엔진 카드에서 우리가 다루는 칸만 */ +export interface AddressBookContactCard extends ContactFields { + readonly id: string; + /** + * 카드의 uid — 그룹 구성원이 이 값으로 잇는다. 다른 프로그램이 uid 없이 만든 카드에는 없다(null) — + * 엔진이 만든 뒤에는 uid 를 바꾸지 못하게 해서(2026-10-29 확인) 그런 카드는 그룹에 넣을 수 없다 + */ + readonly uid: string | null; +} + +/** 주소록의 그룹 카드 */ +export interface AddressBookGroupCard { + readonly id: string; + readonly uid: string | null; + /** 그룹 이름 — 없으면 빈 글 */ + readonly name: string; + /** 구성원 카드의 uid — 이 주소록에 없는 카드(지워짐 · 다른 주소록)를 가리키는 것도 그대로 둔다 */ + readonly memberUids: readonly string[]; +} + +/** 주소록의 카드 하나 — 연락처 또는 그룹 */ +export type AddressBookCard = + | { readonly kind: 'contact'; readonly card: AddressBookContactCard } + | { readonly kind: 'group'; readonly card: AddressBookGroupCard }; + +/** 주소록 전체 — 그 내용의 판(version)과 함께 */ +export interface AddressBookSnapshot { + /** 판 — 불투명 문자열. 엔진에서 주소록이 무엇이든 바뀌면 달라진다 (writeAddressBook 에 그대로 돌려준다) */ + readonly version: string; + readonly contacts: readonly AddressBookContactCard[]; + readonly groups: readonly AddressBookGroupCard[]; +} + +/** + * 지운 카드의 원본 — 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 어댑터가 글로 쓴 것. + * 서비스는 들여다보지 않고 기억했다가 되돌리기(NewAddressBookCard 의 restored)에 그대로 돌려준다 (계약 3절 "되돌리기") + */ +export interface RemovedCardOriginal { + /** 엔진 카드 전체 — 어댑터만 읽는다 */ + readonly source: string; + /** source 의 크기(UTF-8 바이트) — 되돌리기 표에 기억할지 정한다(256KB) */ + readonly bytes: number; +} + +/** 새로 만들 카드 — key 는 이 바꾸기 안의 이름(결과의 createdIds 의 키), uid 는 서비스가 정한다 (그룹이 같은 바꾸기 안에서 가리킨다) */ +export type NewAddressBookCard = + | { + readonly kind: 'contact'; + readonly key: string; + readonly uid: string; + readonly fields: ContactFields; + } + | { + readonly kind: 'group'; + readonly key: string; + readonly uid: string; + readonly name: string; + readonly memberUids: readonly string[]; + } + | { + /** 지운 카드를 원본 그대로 다시 만든다 — uid · 주소록만 바꾸고(그룹은 구성원도) 나머지 칸은 원본 그대로 */ + readonly kind: 'restored'; + readonly key: string; + readonly uid: string; + readonly original: RemovedCardOriginal; + /** 그룹이면 다시 넣을 구성원 uid(원본의 구성원 대신), 연락처면 null */ + readonly memberUids: readonly string[] | null; + /** 원본의 우리 칸 (지울 때 읽은 모양) — 서비스가 크기를 미리 잴 때 쓴다 */ + readonly card: AddressBookCard; + }; + +/** + * 고칠 카드 — 바꾸는 칸만(undefined 는 그대로). 연락처는 칸 안의 남의 정보를 지킨다 — 어댑터가 지금 카드와 합친다(계약 3절 "고치기"). + * 그룹의 구성원은 통째로 바꾼다 — 엔진은 구성원이 없는 그룹에 한 사람만 더하는 패치를 받지 않는다(2026-10-29 확인) + */ +export type AddressBookCardUpdate = + | { + readonly kind: 'contact'; + readonly id: string; + readonly fields: Partial; + } + | { + readonly kind: 'group'; + readonly id: string; + readonly name?: string; + readonly memberUids?: readonly string[]; + }; + +/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */ +export interface AddressBookChanges { + readonly create: readonly NewAddressBookCard[]; + readonly update: readonly AddressBookCardUpdate[]; + readonly destroy: readonly string[]; +} + +/** + * 바꾸기의 결과 + * - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) · + * 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본) + * - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다) + * - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다 + * (서비스의 계획은 앞 시도가 들어갔는지 보고 이어 한다 — 지운 카드의 원본은 함께 준다) + */ +export type AddressBookWriteOutcome = + | { + readonly kind: 'written'; + readonly version: string | null; + readonly createdIds: ReadonlyMap; + readonly cards: ReadonlyMap; + readonly removed: ReadonlyMap; + } + | { readonly kind: 'conflict' } + | { + readonly kind: 'busy'; + readonly removed: ReadonlyMap; + }; + +export interface ContactsProvider { + /** 지금 주소록의 판 — 카드를 읽지 않는 가벼운 조회. readAddressBook 의 version 과 견준다 */ + getAddressBookVersion(user: string): Promise; + + /** 주소록 전체 (기본 주소록의 연락처 · 그룹) — 엔진에 주소록이 없으면 빈 주소록 */ + readAddressBook(user: string): Promise; + + /** + * 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음). + * 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict). + * 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 — + * 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001 + */ + writeAddressBook( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise; +} + +/** 엔진의 계정 하나 — 사내 목록이 될 수 있는 것(개인 · 그룹 계정)만 */ +export interface DirectoryAccount { + /** 엔진의 계정 id */ + readonly id: string; + /** 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared) */ + readonly kind: DirectoryEntryKind; + /** 엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름) — 없으면 null */ + readonly displayName: string | null; + /** 계정의 주소 — 없으면 null */ + readonly email: string | null; +} + +export interface DirectoryProvider { + /** + * 엔진의 계정 목록 — 서버만 쓰는 사내 목록 계정으로 읽는다 (계약 3절 "사내 목록"). + * 그 계정이 없거나 읽을 권한이 없으면 · 엔진 장애면 던진다 (MAIL_001 등). 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다 + */ + listAccounts(): Promise; +} diff --git a/backend/src/modules/contacts/contacts.service.spec.ts b/backend/src/modules/contacts/contacts.service.spec.ts new file mode 100644 index 0000000..3c65f50 --- /dev/null +++ b/backend/src/modules/contacts/contacts.service.spec.ts @@ -0,0 +1,1349 @@ +import { Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import { cardWeight } from './address-book'; +import { + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_TTL_MS, + MAX_ADDRESS_BOOK_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; +import type { + AddressBookCard, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + AddressBookWriteOutcome, + ContactsProvider, + RemovedCardOriginal, +} from './contacts.provider'; +import { ContactsService } from './contacts.service'; +import type { + ContactFields, + ContactInput, + DirectoryEntry, +} from './contacts.types'; +import type { DirectoryService } from './directory.service'; +import type { ContactListQueryDto } from './dto/contact.dto'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +const ARA: AuthenticatedUser = { + email: 'Ara.Kim@arasystem.test', + name: '김아라', +}; +const DOYUN: AuthenticatedUser = { + email: 'doyun.lee@arasystem.test', + name: '이도윤', +}; + +/** + * 엔진 주소록 대역 — 판(state)이 같을 때만 쓰고(아니면 conflict), 쓸 때마다 판이 오른다. + * 서비스가 기억한 주소록에 옮긴 결과와 견주려고 바꾸기를 따로(서비스의 address-book.ts 를 쓰지 않고) 옮긴다. + * 직원마다 따로 두지 않는다 — 한 직원의 주소록만 흉내 낸다 + */ +class FakeAddressBook implements ContactsProvider { + state = 1; + readonly contacts = new Map(); + readonly groups = new Map(); + /** 우리가 다루지 않는 칸(다른 프로그램의 칸) — 지울 때 원본에 실린다 */ + readonly extras = new Map>(); + /** 되돌리기로 만든 카드가 받은 원본 */ + readonly restoredFrom = new Map(); + readonly writes: AddressBookChanges[] = []; + reads = 0; + /** 다음 쓰기들 앞에서 다른 곳이 먼저 바꾼다 (conflict) */ + outsideChanges = 0; + /** 다음 쓰기가 실패한다 (엔진이 일부를 받지 않음 등) */ + failure: BusinessException | null = null; + /** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */ + readonly busy: ('lost' | 'applied')[] = []; + private nextId = 1; + + readonly getAddressBookVersion = jest.fn(() => + Promise.resolve(`v${this.state}`), + ); + + readonly readAddressBook = jest.fn((): Promise => { + this.reads += 1; + return Promise.resolve(this.snapshot()); + }); + + readonly writeAddressBook = jest.fn( + ( + _user: string, + version: string, + changes: AddressBookChanges, + ): Promise => { + if (this.outsideChanges > 0) { + this.outsideChanges -= 1; + this.state += 1; + } + if (version !== `v${this.state}`) { + return Promise.resolve({ kind: 'conflict' }); + } + if (this.failure) { + const failure = this.failure; + this.failure = null; + this.state += 1; + return Promise.reject(failure); + } + const removed = new Map(); + for (const id of changes.destroy) { + const card = this.contacts.get(id) ?? this.groups.get(id); + if (card) removed.set(id, this.original(card)); + } + const mode = this.busy.shift(); + if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed }); + const { createdIds, cards } = this.apply(changes); + if (mode === 'applied') { + return Promise.resolve({ kind: 'busy', removed }); + } + return Promise.resolve({ + kind: 'written', + version: `v${this.state}`, + createdIds, + cards, + removed, + }); + }, + ); + + snapshot(): AddressBookSnapshot { + return { + version: `v${this.state}`, + contacts: [...this.contacts.values()], + groups: [...this.groups.values()], + }; + } + + /** 다른 프로그램(CardDAV)이 바꾼다 */ + touch(): void { + this.state += 1; + } + + seedContact( + id: string, + overrides: Partial = {}, + ): AddressBookContactCard { + const card: AddressBookContactCard = { + id, + uid: `urn:uuid:${id}`, + name: id, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + ...overrides, + }; + this.contacts.set(id, card); + return card; + } + + seedGroup( + id: string, + name: string, + memberUids: string[] = [], + ): AddressBookGroupCard { + const card: AddressBookGroupCard = { + id, + uid: `urn:uuid:${id}`, + name, + memberUids, + }; + this.groups.set(id, card); + return card; + } + + private original(card: { id: string }): RemovedCardOriginal { + const source = JSON.stringify({ ...card, ...this.extras.get(card.id) }); + return { source, bytes: Buffer.byteLength(source, 'utf8') }; + } + + private apply(changes: AddressBookChanges): { + createdIds: Map; + cards: Map; + } { + this.writes.push(changes); + const createdIds = new Map(); + const cards = new Map(); + for (const card of changes.create) { + const id = `n${this.nextId++}`; + createdIds.set(card.key, id); + if (card.kind === 'contact') { + const stored = { id, uid: card.uid, ...card.fields }; + this.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else if (card.kind === 'group') { + const stored = { + id, + uid: card.uid, + name: card.name, + memberUids: [...card.memberUids], + }; + this.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } else { + this.restoredFrom.set(id, card.original.source); + if (card.card.kind === 'contact') { + const stored = { ...card.card.card, id, uid: card.uid }; + this.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else { + const stored = { + ...card.card.card, + id, + uid: card.uid, + memberUids: [...(card.memberUids ?? card.card.card.memberUids)], + }; + this.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } + } + } + for (const update of changes.update) { + if (update.kind === 'contact') { + const current = this.contacts.get(update.id); + if (current) { + const fields = Object.fromEntries( + Object.entries(update.fields).filter(([, v]) => v !== undefined), + ); + const stored = { ...current, ...fields }; + this.contacts.set(update.id, stored); + cards.set(update.id, { kind: 'contact', card: stored }); + } + } else { + const current = this.groups.get(update.id); + if (current) { + this.groups.set(update.id, { + ...current, + name: update.name ?? current.name, + memberUids: update.memberUids + ? [...update.memberUids] + : current.memberUids, + }); + } + } + } + for (const id of changes.destroy) { + this.contacts.delete(id); + this.groups.delete(id); + } + this.state += 1; + return { createdIds, cards }; + } +} + +const emptyFields: ContactFields = { + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, +}; + +const input = (overrides: Partial = {}): ContactInput => ({ + ...emptyFields, + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + groupIds: [], + ...overrides, +}); + +const listQuery = ( + overrides: Partial = {}, +): ContactListQueryDto => ({ limit: 20, ...overrides }); + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error(`BusinessException 이 아니다: ${String(error)}`); + } + return error; +} + +describe('ContactsService (주소록)', () => { + let book: FakeAddressBook; + let findByIds: jest.Mock, [readonly string[]]>; + let directoryList: jest.Mock; + let now: number; + let service: ContactsService; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + const newService = () => + new ContactsService( + book, + { findByIds, list: directoryList } as unknown as DirectoryService, + () => now, + ); + + beforeEach(() => { + book = new FakeAddressBook(); + now = 1_800_000_000_000; + findByIds = jest + .fn, [readonly string[]]>() + .mockResolvedValue([]); + directoryList = jest.fn(); + service = newService(); + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('목록 · 하나 · 기억', () => { + beforeEach(() => { + book.seedContact('c-ha', { + name: '하늘', + phones: [{ number: '010-3192-5540', label: 'mobile' }], + }); + book.seedContact('c-pa', { + name: '박지훈', + company: '한빛상사', + emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }], + }); + book.seedContact('c-ga', { name: '가나다' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c-pa', 'urn:uuid:gone']); + }); + + it('이름 차례 · total 포함 — 연락처의 그룹은 이 주소록의 그룹만', async () => { + const page = await service.list(ARA, listQuery()); + + expect(page.items.map((contact) => contact.id)).toEqual([ + 'c-ga', + 'c-pa', + 'c-ha', + ]); + expect(page.items[1]?.groupIds).toEqual(['g1']); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 3, + }); + }); + + it('q — 초성 · 회사 · 전화번호 숫자 / groupId — 그 그룹의 구성원만 / excludeGroupId — 그 그룹의 구성원을 빼고', async () => { + const ids = async (overrides: Partial) => + (await service.list(ARA, listQuery(overrides))).items.map((c) => c.id); + + expect(await ids({ q: 'ㅂㅈㅎ' })).toEqual(['c-pa']); + expect(await ids({ q: ' 한빛 ' })).toEqual(['c-pa']); + expect(await ids({ q: '31925540' })).toEqual(['c-ha']); + expect(await ids({ groupId: 'g1' })).toEqual(['c-pa']); + expect(await ids({ groupId: 'g1', q: '하늘' })).toEqual([]); + expect(await ids({ excludeGroupId: 'g1' })).toEqual(['c-ga', 'c-ha']); + expect(await ids({ excludeGroupId: 'g1', q: '하늘' })).toEqual(['c-ha']); + }); + + it('없는 그룹(groupId · excludeGroupId)은 RES_001 · 둘을 함께 쓰거나 다른 q · 그룹의 커서는 엔진에 묻기 전에 VAL_001', async () => { + for (const query of [{ groupId: 'nope' }, { excludeGroupId: 'nope' }]) { + expect( + (await failureOf(service.list(ARA, listQuery(query)))).code, + ).toBe('RES_001'); + } + + const first = await service.list(ARA, listQuery({ limit: 1 })); + const cursor = first.pageInfo.nextCursor ?? ''; + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + expect( + (await service.list(ARA, listQuery({ limit: 1, cursor }))).items.map( + (c) => c.id, + ), + ).toEqual(['c-pa']); + + book.getAddressBookVersion.mockClear(); + for (const other of [ + listQuery({ cursor, q: '박' }), + listQuery({ cursor, groupId: 'g1' }), + listQuery({ cursor, excludeGroupId: 'g1' }), + listQuery({ groupId: 'g1', excludeGroupId: 'g1' }), + ]) { + expect((await failureOf(service.list(ARA, other))).code).toBe( + 'VAL_001', + ); + } + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + }); + + it('판이 그대로면 다시 읽지 않고, 다른 곳(메일 프로그램)에서 바뀌면 다시 읽는다 — 주소는 대소문자 없이 한 사람', async () => { + await service.list(ARA, listQuery()); + await service.get({ ...ARA, email: 'ara.kim@ARASYSTEM.TEST' }, 'c-ga'); + expect(book.reads).toBe(1); + expect(book.getAddressBookVersion).toHaveBeenCalledTimes(2); + expect(book.getAddressBookVersion).toHaveBeenLastCalledWith( + 'ara.kim@arasystem.test', + ); + + book.touch(); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('같은 때의 여러 요청은 한 번만 읽는다', async () => { + await Promise.all([ + service.list(ARA, listQuery()), + service.get(ARA, 'c-ga'), + service.suggestionLines(ARA.email), + ]); + expect(book.reads).toBe(1); + }); + + it('하나 — 없는 연락처 · 그룹 id 는 RES_001', async () => { + expect((await service.get(ARA, 'c-pa')).company).toBe('한빛상사'); + for (const id of ['nope', 'g1']) { + expect((await failureOf(service.get(ARA, id))).code).toBe('RES_001'); + } + }); + + it('받는 사람 추천 줄 — 연락처의 이름 차례, 주소가 있는 연락처만', async () => { + expect(await service.suggestionLines(ARA.email)).toEqual([ + { + name: '박지훈', + email: 'Jihoon@Hanbit.example', + company: '한빛상사', + emailLabel: 'work', + }, + ]); + }); + + it('사내 목록 — excludeGroupId 면 그 그룹 구성원이 가진 모든 주소(소문자)를 사내 목록 서비스에 넘긴다, 없는 그룹은 RES_001', async () => { + book.seedContact('c-two', { + emails: [ + { address: 'One@X.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + }); + book.seedGroup('g2', '둘', ['urn:uuid:c-two', 'urn:uuid:c-pa']); + directoryList.mockImplementation( + async ( + _query: DirectoryEntryQueryDto, + excluded?: () => Promise>, + ) => [...((await excluded?.()) ?? [])].sort(), + ); + + await expect( + service.listDirectory(ARA, { limit: 20, excludeGroupId: 'g2' }), + ).resolves.toEqual([ + 'jihoon@hanbit.example', + 'one@x.example', + 'two@x.example', + ]); + await expect(service.listDirectory(ARA, { limit: 20 })).resolves.toEqual( + [], + ); + expect( + ( + await failureOf( + service.listDirectory(ARA, { limit: 20, excludeGroupId: 'nope' }), + ) + ).code, + ).toBe('RES_001'); + }); + }); + + describe('연락처 추가 (create)', () => { + it('uid 를 넣어 만들고 고른 그룹에 넣는다 — 응답은 그룹까지, 다시 읽지 않고 바로 목록에 보인다', async () => { + book.seedGroup('g1', '거래처', ['urn:uuid:other']); + await service.list(ARA, listQuery()); + + const created = await service.create(ARA, input({ groupIds: ['g1'] })); + + expect(created).toMatchObject({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + groupIds: ['g1'], + }); + const stored = book.contacts.get(created.id); + expect(stored?.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/); + expect(book.groups.get('g1')?.memberUids).toEqual([ + 'urn:uuid:other', + stored?.uid, + ]); + // 한 번의 엔진 요청에 만들기 + 그룹 고치기 + expect(book.writes).toHaveLength(1); + expect(book.writes[0]?.create).toHaveLength(1); + expect(book.writes[0]?.update).toHaveLength(1); + + const page = await service.list(ARA, listQuery()); + expect(page.items.map((contact) => contact.id)).toEqual([created.id]); + expect(book.reads).toBe(1); + // 기억한 주소록이 엔진의 새 내용과 같다 + expect(page.items).toEqual( + (await newService().list(ARA, listQuery())).items, + ); + }); + + it('이름이나 이메일 가운데 하나 — 둘 다 없거나 같은 주소(대소문자 무시)가 두 번이면 엔진에 묻기 전에 VAL_001', async () => { + expect( + ( + await failureOf( + service.create(ARA, input({ name: null, emails: [] })), + ) + ).context?.reason, + ).toBe('contact-name-or-email-required'); + expect( + ( + await failureOf( + service.create( + ARA, + input({ + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'A@X.example', label: 'personal' }, + ], + }), + ), + ) + ).context?.reason, + ).toBe('contact-duplicate-email'); + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + + // 이름만 · 이메일만은 된다 + await service.create(ARA, input({ emails: [] })); + await service.create(ARA, input({ name: null })); + expect(book.contacts.size).toBe(2); + }); + + it(`연락처 ${MAX_CONTACTS}개 · 그룹 하나 ${MAX_CONTACT_GROUP_MEMBERS}명이면 CONTACT_002 — 아무것도 쓰지 않는다 · 없는 그룹은 RES_001`, async () => { + for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS; index += 1) { + book.seedContact(`m${index}`); + } + book.seedGroup( + 'full', + '가득', + Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS }, + (_, index) => `urn:uuid:m${index}`, + ), + ); + expect( + (await failureOf(service.create(ARA, input({ groupIds: ['full'] })))) + .code, + ).toBe('CONTACT_002'); + expect( + (await failureOf(service.create(ARA, input({ groupIds: ['nope'] })))) + .code, + ).toBe('RES_001'); + + for ( + let index = MAX_CONTACT_GROUP_MEMBERS; + index < MAX_CONTACTS; + index += 1 + ) { + book.seedContact(`m${index}`); + } + book.touch(); + expect((await failureOf(service.create(ARA, input()))).code).toBe( + 'CONTACT_002', + ); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('로그에는 개수 · id 만 — 이름 · 주소는 남기지 않는다', async () => { + await service.create(ARA, input({ note: '비밀 메모' })); + + const logged = logSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('연락처를 만들었습니다'); + expect(logged).not.toMatch(/박지훈|jihoon|hanbit|비밀 메모|ara\.kim@/); + }); + }); + + describe('크기 한도 (계약 3절 "크기")', () => { + /** 주소록을 한도 가까이 채운다 — 메모가 긴 연락처 몇 장 */ + const fillTo = (target: number) => { + const note = 'x'.repeat(1_000_000); + let weight = 0; + let index = 0; + while (weight + 1_100_000 < target) { + weight += cardWeight(book.seedContact(`big${index}`, { note })); + index += 1; + } + return weight; + }; + + it(`크기를 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 넘게 하는 쓰기(만들기 · 고치기)는 CONTACT_002 — 아무것도 쓰지 않는다`, async () => { + fillTo(MAX_ADDRESS_BOOK_WEIGHT); + const room = MAX_ADDRESS_BOOK_WEIGHT - bookWeight(); + const longText = 'y'.repeat(Math.min(room + 1, 200_000)); + + const failure = await failureOf( + service.create(ARA, input({ note: longText.repeat(6) })), + ); + expect(failure.code).toBe('CONTACT_002'); + expect(failure.context?.reason).toBe('address-book-too-large'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + // 한도 안이면 된다 + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + }); + + it('이미 넘은 주소록(다른 프로그램이 크게 만든 것)도 줄이는 쓰기 · 지우기는 된다 — 키우는 쓰기만 막는다', async () => { + const note = 'x'.repeat(1_000_000); + // 1,100만 — 아래에서 두 장을 줄여도 한도를 넘는다 + for (let index = 0; index < 11; index += 1) { + book.seedContact(`big${index}`, { note }); + } + expect(bookWeight()).toBeGreaterThan(MAX_ADDRESS_BOOK_WEIGHT); + + await expect( + service.patch(ARA, 'big0', { note: '짧게' }), + ).resolves.toMatchObject({ note: '짧게' }); + await expect(service.remove(ARA, 'big1')).resolves.toMatchObject({ + contact: { id: 'big1' }, + }); + expect( + (await failureOf(service.create(ARA, input({ name: '하나 더' })))).code, + ).toBe('CONTACT_002'); + }); + + function bookWeight(): number { + // 서비스가 재는 크기와 같은 잣대 — 연락처마다 cardWeight + return [...book.contacts.values()].reduce( + (sum, card) => sum + cardWeight(card), + 0, + ); + } + }); + + describe('연락처 고치기 (patch — 바꾼 칸만)', () => { + beforeEach(() => { + book.seedContact('c1', { + name: '박지훈', + emails: [{ address: 'jihoon@hanbit.example', label: 'work' }], + company: '한빛상사', + department: '구매팀', + }); + book.seedGroup('g-old', '옛 그룹', ['urn:uuid:c1', 'urn:uuid:other']); + book.seedGroup('g-new', '새 그룹'); + }); + + it('보낸 칸 가운데 바뀐 것만 엔진에 — 회사 · 부서도 따로(칸 안의 남의 정보는 어댑터가 지킨다), 그룹은 넣고 빼기를 한 번에', async () => { + const updated = await service.patch(ARA, 'c1', { + company: '한빛상사', + department: '영업팀', + title: '과장', + groupIds: ['g-new'], + }); + + expect(updated).toMatchObject({ + id: 'c1', + company: '한빛상사', + department: '영업팀', + title: '과장', + groupIds: ['g-new'], + }); + const [write] = book.writes; + expect(write?.update).toEqual( + expect.arrayContaining([ + { + kind: 'contact', + id: 'c1', + fields: { department: '영업팀', title: '과장' }, + }, + { kind: 'group', id: 'g-new', memberUids: ['urn:uuid:c1'] }, + { kind: 'group', id: 'g-old', memberUids: ['urn:uuid:other'] }, + ]), + ); + expect(write?.update).toHaveLength(3); + }); + + it('보내지 않은 칸은 그대로 — groupIds 를 보내지 않으면 그룹도 그대로', async () => { + const updated = await service.patch(ARA, 'c1', { note: '메모' }); + expect(updated).toMatchObject({ + name: '박지훈', + company: '한빛상사', + note: '메모', + groupIds: ['g-old'], + }); + expect(book.writes[0]?.update).toEqual([ + { kind: 'contact', id: 'c1', fields: { note: '메모' } }, + ]); + }); + + it('빈 본문은 VAL_001 (엔진에 묻기 전에) · 바뀐 것이 없으면 쓰지 않고 지금 연락처를 준다', async () => { + expect( + (await failureOf(service.patch(ARA, 'c1', {}))).context?.reason, + ).toBe('contact-patch-empty'); + expect( + (await failureOf(service.patch(ARA, 'c1', { name: undefined }))).context + ?.reason, + ).toBe('contact-patch-empty'); + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + + const same = await service.patch(ARA, 'c1', { + company: '한빛상사', + groupIds: ['g-old'], + }); + expect(same.groupIds).toEqual(['g-old']); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('"이름이나 이메일"은 합친 결과로 — 이름만 지우면 되고, 이름 · 이메일을 모두 지우면 VAL_001 · 같은 주소 두 번도 VAL_001', async () => { + await expect( + service.patch(ARA, 'c1', { name: null }), + ).resolves.toMatchObject({ name: null }); + expect( + (await failureOf(service.patch(ARA, 'c1', { emails: [] }))).context + ?.reason, + ).toBe('contact-name-or-email-required'); + expect( + ( + await failureOf( + service.patch(ARA, 'c1', { + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'A@x.example', label: 'other' }, + ], + }), + ) + ).context?.reason, + ).toBe('contact-duplicate-email'); + }); + + it('없는 연락처 · 그룹은 RES_001 · 가득 찬 그룹에 넣으면 CONTACT_002', async () => { + expect( + (await failureOf(service.patch(ARA, 'nope', { note: 'x' }))).code, + ).toBe('RES_001'); + expect( + (await failureOf(service.patch(ARA, 'c1', { groupIds: ['nope'] }))) + .code, + ).toBe('RES_001'); + + book.seedGroup( + 'full', + '가득', + Array.from({ length: MAX_CONTACT_GROUP_MEMBERS }, (_, index) => { + book.seedContact(`m${index}`); + return `urn:uuid:m${index}`; + }), + ); + book.touch(); + expect( + ( + await failureOf( + service.patch(ARA, 'c1', { groupIds: ['g-old', 'full'] }), + ) + ).code, + ).toBe('CONTACT_002'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('uid 가 없는 카드(다른 프로그램이 만든 것)는 그룹에 넣을 수 없다 — VAL_001, 칸만 고치는 것은 된다', async () => { + book.seedContact('no-uid', { uid: null, name: '옛 카드' }); + book.touch(); + + expect( + (await failureOf(service.patch(ARA, 'no-uid', { groupIds: ['g-new'] }))) + .context?.reason, + ).toBe('contact-without-uid'); + await expect( + service.patch(ARA, 'no-uid', { name: '새 이름' }), + ).resolves.toMatchObject({ name: '새 이름', groupIds: [] }); + }); + }); + + describe('지우기 · 되돌리기 (remove · removeGroup · restore)', () => { + it('연락처 — 그룹 구성원에서도 빼고 지운다. 응답은 지우기 전의 연락처(그룹 포함)와 되돌리기 표', async () => { + book.seedContact('c1', { name: '하나' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c2']); + + const removed = await service.remove(ARA, 'c1'); + + expect(removed.contact).toMatchObject({ + id: 'c1', + name: '하나', + groupIds: ['g1'], + }); + expect(removed.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(book.contacts.has('c1')).toBe(false); + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:c2']); + expect((await failureOf(service.get(ARA, 'c1'))).code).toBe('RES_001'); + // 표는 로그에 남기지 않는다 + const logged = [ + ...(logSpy.mock.calls as unknown[][]), + ...(warnSpy.mock.calls as unknown[][]), + ] + .map(([message]) => String(message)) + .join('\n'); + expect(logged).not.toContain(removed.restoreToken); + }); + + it('연락처 되돌리기 — 원본 그대로(같은 uid) 다시 만들고 그때 속했던 그룹에 다시 넣는다 · 표는 한 번만', async () => { + book.seedContact('c1', { + name: '하나', + emails: [{ address: 'one@x.example', label: 'work' }], + }); + book.extras.set('c1', { nicknames: { k: { name: '별명' } } }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + book.seedGroup('g2', '협력사', ['urn:uuid:c1']); + const { restoreToken } = await service.remove(ARA, 'c1'); + + const restored = await service.restore(ARA, restoreToken ?? ''); + + expect(restored).toMatchObject({ + kind: 'contact', + contact: { name: '하나', groupIds: ['g1', 'g2'] }, + }); + const id = restored.kind === 'contact' ? restored.contact.id : ''; + expect(id).not.toBe('c1'); + expect(book.contacts.get(id)?.uid).toBe('urn:uuid:c1'); + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:c1']); + // 다른 프로그램의 칸도 그대로 넘긴다 + expect(JSON.parse(book.restoredFrom.get(id) ?? '{}')).toMatchObject({ + id: 'c1', + nicknames: { k: { name: '별명' } }, + }); + // 한 번만 + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it('연락처 되돌리기 — 그사이 그룹을 지웠다 되돌렸으면(새 id · 같은 uid) 그 그룹에, 지운 그룹 · 가득 찬 그룹은 건너뛴다', async () => { + book.seedContact('c1'); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + book.seedGroup('g2', '없어질 그룹', ['urn:uuid:c1']); + const fullMembers = Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS - 1 }, + (_, index) => { + book.seedContact(`m${index}`); + return `urn:uuid:m${index}`; + }, + ); + book.seedGroup('g3', '거의 가득', [...fullMembers, 'urn:uuid:c1']); + const contact = await service.remove(ARA, 'c1'); + // 그룹을 지우고 되돌린다 — 새 id, 같은 uid + const group = await service.removeGroup(ARA, 'g1'); + const regrouped = await service.restore(ARA, group.restoreToken ?? ''); + const newGroupId = regrouped.kind === 'group' ? regrouped.group.id : ''; + expect(newGroupId).not.toBe('g1'); + await service.removeGroup(ARA, 'g2'); + // 가득 찬 그룹을 채운다 + book.seedContact('filler'); + book.groups.set('g3', { + ...book.groups.get('g3')!, + memberUids: [...fullMembers, 'urn:uuid:filler'], + }); + book.touch(); + + const restored = await service.restore(ARA, contact.restoreToken ?? ''); + + expect(restored.kind === 'contact' && restored.contact.groupIds).toEqual([ + newGroupId, + ]); + }); + + it('연락처 되돌리기 — 그사이 같은 uid 의 카드가 생겼으면 새 uid (엔진은 한 주소록 안의 같은 uid 를 받지 않는다)', async () => { + book.seedContact('c1', { uid: 'urn:uuid:same' }); + const { restoreToken } = await service.remove(ARA, 'c1'); + book.seedContact('other', { uid: 'urn:uuid:same' }); + book.touch(); + + const restored = await service.restore(ARA, restoreToken ?? ''); + const id = restored.kind === 'contact' ? restored.contact.id : ''; + expect(book.contacts.get(id)?.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/); + }); + + it(`연락처 되돌리기 — 연락처 ${MAX_CONTACTS}개면 CONTACT_002 이고 표는 남는다(자리가 나면 다시 쓸 수 있다)`, async () => { + book.seedContact('c1'); + const { restoreToken } = await service.remove(ARA, 'c1'); + for (let index = 0; index < MAX_CONTACTS; index += 1) { + book.seedContact(`m${index}`); + } + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_002'); + + book.contacts.delete('m0'); + book.touch(); + await expect( + service.restore(ARA, restoreToken ?? ''), + ).resolves.toMatchObject({ kind: 'contact' }); + }); + + it('그룹 — 그룹만 지우고 이름 · 구성원 · 표를 준다, 되돌리면 그때의 구성원 가운데 지금도 있는 연락처로', async () => { + book.seedContact('c-b', { name: '나비' }); + book.seedContact('c-a', { name: '가나다' }); + book.seedGroup('g1', '거래처', [ + 'urn:uuid:c-b', + 'urn:uuid:c-a', + 'urn:uuid:gone', + ]); + + const removed = await service.removeGroup(ARA, 'g1'); + expect(removed).toMatchObject({ + id: 'g1', + name: '거래처', + memberIds: ['c-a', 'c-b'], + }); + expect(removed.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(book.groups.size).toBe(0); + expect(book.contacts.size).toBe(2); + + book.contacts.delete('c-b'); + book.touch(); + const restored = await service.restore(ARA, removed.restoreToken ?? ''); + expect(restored).toMatchObject({ + kind: 'group', + group: { name: '거래처', memberCount: 1 }, + }); + const id = restored.kind === 'group' ? restored.group.id : ''; + expect(book.groups.get(id)).toMatchObject({ + uid: 'urn:uuid:g1', + memberUids: ['urn:uuid:c-a'], + }); + }); + + it(`그룹 되돌리기 — 같은 이름이 그사이 생겼으면 CONTACT_001 · 그룹 ${MAX_CONTACT_GROUPS}개면 CONTACT_002 (표는 남는다)`, async () => { + book.seedGroup('g1', '거래처'); + const { restoreToken } = await service.removeGroup(ARA, 'g1'); + book.seedGroup('g-same', ' 거래처 '); + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_001'); + + book.groups.delete('g-same'); + for (let index = 0; index < MAX_CONTACT_GROUPS; index += 1) { + book.seedGroup(`x${index}`, `그룹 ${index}`); + } + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_002'); + }); + + it('표 — 다른 직원의 것 · 5분 지난 것 · 없는 것은 RES_001 (모두 같게)', async () => { + book.seedContact('c1'); + book.seedContact('c2'); + const first = await service.remove(ARA, 'c1'); + const second = await service.remove(ARA, 'c2'); + + expect( + (await failureOf(service.restore(DOYUN, first.restoreToken ?? ''))) + .code, + ).toBe('RES_001'); + expect( + (await failureOf(service.restore(ARA, 'AAAAAAAAAAAAAAAAAAAAAA'))).code, + ).toBe('RES_001'); + + now += CONTACT_RESTORE_TTL_MS; + expect( + (await failureOf(service.restore(ARA, second.restoreToken ?? ''))).code, + ).toBe('RES_001'); + // 다른 직원이 물었던 표도 5분 안이었다면 쓸 수 있었다 — 지금은 지났다 + expect( + (await failureOf(service.restore(ARA, first.restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it(`원본이 ${CONTACT_RESTORE_MAX_CARD_BYTES / 1024}KB 를 넘으면 표는 null (지우기는 된다 · warn 로그는 크기만)`, async () => { + book.seedContact('photo', { name: '사진 있는 사람' }); + book.extras.set('photo', { + media: { m: { uri: `data:image/jpeg;base64,${'A'.repeat(300_000)}` } }, + }); + + const removed = await service.remove(ARA, 'photo'); + + expect(removed.restoreToken).toBeNull(); + expect(book.contacts.has('photo')).toBe(false); + const warned = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(warned).toContain('bytes='); + expect(warned).not.toContain('사진 있는 사람'); + }); + + it('같은 uid 의 다른 카드가 남아 있으면 그 카드의 구성원 자격은 그대로 둔다', async () => { + book.seedContact('twin-a', { uid: 'urn:uuid:twin' }); + book.seedContact('twin-b', { uid: 'urn:uuid:twin' }); + book.seedGroup('g1', '거래처', ['urn:uuid:twin']); + + await service.remove(ARA, 'twin-a'); + + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:twin']); + expect(book.writes[0]?.update).toEqual([]); + }); + + it('없는 연락처 · 그룹은 RES_001', async () => { + expect((await failureOf(service.remove(ARA, 'nope'))).code).toBe( + 'RES_001', + ); + expect((await failureOf(service.removeGroup(ARA, 'nope'))).code).toBe( + 'RES_001', + ); + }); + }); + + describe('그룹', () => { + it('목록은 이름 차례 · 구성원 수는 이 주소록의 연락처만', async () => { + book.seedContact('c1'); + book.seedGroup('g2', '협력사'); + book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:gone']); + + const page = await service.listGroups(ARA, { limit: 20 }); + + expect(page.items).toEqual([ + { id: 'g1', name: '거래처', memberCount: 1 }, + { id: 'g2', name: '협력사', memberCount: 0 }, + ]); + expect(page.pageInfo.total).toBe(2); + }); + + it('만들기 — 구성원 없이, 같은 이름(앞뒤 공백 · ASCII 대소문자 무시)은 CONTACT_001, 100개면 CONTACT_002', async () => { + book.seedGroup('g1', 'Partners'); + + expect( + (await failureOf(service.createGroup(ARA, { name: 'partners' }))).code, + ).toBe('CONTACT_001'); + const created = await service.createGroup(ARA, { name: '거래처' }); + expect(created).toMatchObject({ name: '거래처', memberCount: 0 }); + expect(book.writes[0]?.create).toMatchObject([ + { kind: 'group', name: '거래처', memberUids: [] }, + ]); + + for ( + let index = book.groups.size; + index < MAX_CONTACT_GROUPS; + index += 1 + ) { + book.seedGroup(`x${index}`, `그룹 ${index}`); + } + book.touch(); + expect( + (await failureOf(service.createGroup(ARA, { name: '하나 더' }))).code, + ).toBe('CONTACT_002'); + }); + + it('이름 바꾸기 — 같은 이름이면 쓰지 않고, 다른 그룹과 같으면 CONTACT_001, 대소문자만 바꾸기는 된다', async () => { + book.seedGroup('g1', 'partners'); + book.seedGroup('g2', '거래처'); + + await service.renameGroup(ARA, 'g1', 'partners'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + + expect( + (await failureOf(service.renameGroup(ARA, 'g1', '거래처'))).code, + ).toBe('CONTACT_001'); + await expect( + service.renameGroup(ARA, 'g1', 'Partners'), + ).resolves.toMatchObject({ id: 'g1', name: 'Partners' }); + expect( + (await failureOf(service.renameGroup(ARA, 'nope', 'x'))).code, + ).toBe('RES_001'); + }); + }); + + describe('구성원 넣기 (addGroupMembers)', () => { + beforeEach(() => { + book.seedContact('c1', { name: '하나' }); + book.seedContact('c2', { name: '둘' }); + book.seedContact('dup', { + name: '이도윤(거래처)', + emails: [{ address: 'Doyun.Lee@arasystem.test', label: 'other' }], + }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + }); + + it('내 연락처 · 사내 목록에서 — 같은 주소의 연락처는 그 연락처를, 없으면 이름 · 주소(회사)로 새로 만든다. 이미 구성원은 세지 않는다', async () => { + findByIds.mockResolvedValue([ + { + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }, + { + id: 's', + name: '영업팀', + email: 'sales@arasystem.test', + kind: 'shared', + }, + ]); + + const result = await service.addGroupMembers(ARA, 'g1', { + contactIds: ['c1', 'c2'], + directoryIds: ['d', 's'], + }); + + expect(result).toEqual({ + group: { id: 'g1', name: '거래처', memberCount: 4 }, + addedCount: 3, + createdCount: 1, + }); + expect(findByIds).toHaveBeenCalledWith(['d', 's']); + const created = [...book.contacts.values()].find( + (card) => card.emails[0]?.address === 'sales@arasystem.test', + ); + expect(created).toMatchObject({ + name: '영업팀', + emails: [{ address: 'sales@arasystem.test', label: 'work' }], + }); + expect(book.writes).toHaveLength(1); + }); + + it('모두 이미 구성원이면 쓰지 않는다', async () => { + await expect( + service.addGroupMembers(ARA, 'g1', { contactIds: ['c1'] }), + ).resolves.toEqual({ + group: { id: 'g1', name: '거래처', memberCount: 1 }, + addedCount: 0, + createdCount: 0, + }); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('합쳐 0개 · 100개 넘음은 VAL_001 — 없는 그룹 · 연락처 · 사내 목록 id 는 RES_001', async () => { + for (const body of [ + {}, + { contactIds: [] }, + { + contactIds: Array.from({ length: 60 }, (_, i) => `c${i}`), + directoryIds: Array.from({ length: 41 }, (_, i) => `d${i}`), + }, + ]) { + expect( + (await failureOf(service.addGroupMembers(ARA, 'g1', body))).code, + ).toBe('VAL_001'); + } + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'nope', { contactIds: ['c1'] }), + ) + ).code, + ).toBe('RES_001'); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { contactIds: ['nope'] }), + ) + ).code, + ).toBe('RES_001'); + + findByIds.mockRejectedValue(new BusinessException('RES_001')); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { directoryIds: ['x'] }), + ) + ).code, + ).toBe('RES_001'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 연락처 ${MAX_CONTACTS}개를 넘으면 CONTACT_002 — 아무것도 쓰지 않는다`, async () => { + for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) { + book.seedContact(`m${index}`); + } + book.seedGroup( + 'g1', + '거래처', + Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS - 1 }, + (_, index) => `urn:uuid:m${index}`, + ), + ); + book.touch(); + // 한 명은 들어간다 · 두 명은 넘는다 + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { contactIds: ['c1', 'c2'] }), + ) + ).code, + ).toBe('CONTACT_002'); + + for (let index = book.contacts.size; index < MAX_CONTACTS; index += 1) { + book.seedContact(`z${index}`); + } + book.touch(); + findByIds.mockResolvedValue([ + { + id: 'n', + name: '새 직원', + email: 'new@arasystem.test', + kind: 'person', + }, + ]); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { directoryIds: ['n'] }), + ) + ).code, + ).toBe('CONTACT_002'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + }); + + describe('판이 어긋날 때 · 엔진이 바쁠 때 · 실패 · 차례', () => { + it('그사이 다른 곳에서 바뀌면 다시 읽고 다시 정한다 — 세 번 어긋나면 MAIL_001', async () => { + book.outsideChanges = 1; + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + expect(book.writeAddressBook).toHaveBeenCalledTimes(2); + expect(book.reads).toBe(2); + + book.outsideChanges = 3; + const error = await failureOf(service.create(ARA, input({ name: '또' }))); + expect(error.code).toBe('MAIL_001'); + expect(error.context?.reason).toBe('address-book-keeps-changing'); + }); + + it('어긋난 뒤 다시 정할 때 지금 주소록으로 본다 — 그사이 같은 이름의 그룹이 생겼으면 CONTACT_001', async () => { + book.writeAddressBook.mockImplementationOnce(() => { + book.seedGroup('g-other', '거래처'); + book.touch(); + return Promise.resolve({ kind: 'conflict' }); + }); + + expect( + (await failureOf(service.createGroup(ARA, { name: '거래처' }))).code, + ).toBe('CONTACT_001'); + }); + + it('엔진이 바쁘면(busy — 아무것도 들어가지 않음) 다시 읽고 한 번 더 한다 · 세 번 모두 바쁘면 MAIL_001(address-book-busy)', async () => { + book.busy.push('lost'); + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + expect(book.writeAddressBook).toHaveBeenCalledTimes(2); + expect(book.contacts.size).toBe(1); + + book.busy.push('lost', 'lost', 'lost'); + const error = await failureOf(service.create(ARA, input({ name: '또' }))); + expect(error.code).toBe('MAIL_001'); + expect(error.context?.reason).toBe('address-book-busy'); + }); + + it('바쁘다고 했지만 들어갔으면(applied) 다음 시도가 이어 한다 — 만든 연락처를 같은 uid 로 찾아 두 장 만들지 않는다', async () => { + book.seedGroup('g1', '거래처'); + book.busy.push('applied'); + + const created = await service.create(ARA, input({ groupIds: ['g1'] })); + + expect(book.contacts.size).toBe(1); + expect(created).toMatchObject({ name: '박지훈', groupIds: ['g1'] }); + expect(book.writes).toHaveLength(1); + }); + + it('바쁘다고 했지만 지우기가 들어갔으면 — 다음 시도는 그때 읽은 연락처와 되돌리기 표로 답한다(RES_001 이 아니다)', async () => { + book.seedContact('c1', { name: '하나' }); + book.busy.push('applied'); + + const removed = await service.remove(ARA, 'c1'); + + expect(removed.contact).toMatchObject({ id: 'c1', name: '하나' }); + expect(removed.restoreToken).not.toBeNull(); + await expect( + service.restore(ARA, removed.restoreToken ?? ''), + ).resolves.toMatchObject({ kind: 'contact', contact: { name: '하나' } }); + }); + + it('바쁘다고 했지만 되돌리기가 들어갔으면 — 다음 시도는 그 카드를 찾아 이어 한다(두 번 만들지 않는다)', async () => { + book.seedContact('c1', { name: '하나' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + const { restoreToken } = await service.remove(ARA, 'c1'); + book.busy.push('applied'); + + const restored = await service.restore(ARA, restoreToken ?? ''); + + expect(book.contacts.size).toBe(1); + expect(restored).toMatchObject({ + kind: 'contact', + contact: { name: '하나', groupIds: ['g1'] }, + }); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it('엔진이 바꾸기를 받지 않으면 그 코드로 던지고 기억한 주소록을 버린다 — 다음 요청은 다시 읽는다', async () => { + await service.list(ARA, listQuery()); + book.failure = new BusinessException('VAL_001', { + context: { reason: 'address-book-change-refused' }, + }); + + expect((await failureOf(service.create(ARA, input()))).code).toBe( + 'VAL_001', + ); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('엔진이 새 판을 알려 주지 않으면 응답은 주고 기억하지 않는다', async () => { + book.writeAddressBook.mockImplementationOnce( + (_user, _version, changes) => { + book.state += 1; + const card = changes.create[0]; + return Promise.resolve({ + kind: 'written', + version: null, + createdIds: new Map(changes.create.map((c) => [c.key, 'n9'])), + cards: new Map( + card?.kind === 'contact' + ? [ + [ + 'n9', + { + kind: 'contact', + card: { id: 'n9', uid: card.uid, ...card.fields }, + }, + ], + ] + : [], + ), + removed: new Map(), + }); + }, + ); + + await expect(service.create(ARA, input())).resolves.toMatchObject({ + id: 'n9', + name: '박지훈', + }); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('같은 직원의 바꾸기는 차례로 한다 — 동시에 보내도 어긋나지 않는다', async () => { + const results = await Promise.all([ + service.create(ARA, input({ name: '하나' })), + service.create(ARA, input({ name: '둘', emails: [] })), + service.createGroup(ARA, { name: '거래처' }), + ]); + + expect(results.map((result) => result.name)).toEqual([ + '하나', + '둘', + '거래처', + ]); + // 어긋남 없이 세 번만 썼다 + expect(book.writeAddressBook).toHaveBeenCalledTimes(3); + expect(book.contacts.size).toBe(2); + }); + }); +}); diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts new file mode 100644 index 0000000..b27638b --- /dev/null +++ b/backend/src/modules/contacts/contacts.service.ts @@ -0,0 +1,1219 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import type { + CursorPaginationQueryDto, + PaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + applyAddressBookWrite, + buildBookView, + emailKey, + newCardUid, + weightAfterChanges, + type BookContact, + type BookGroup, + type BookView, + type WrittenChanges, +} from './address-book'; +import { ContactBookCache } from './contact-book-cache'; +import { cursorOffset, listScope, pageOf } from './contact-paging'; +import { + ContactRestoreStore, + type RestorableCard, + type RestorableGroupRef, +} from './contact-restore-store'; +import { matchesContact, parseContactSearch } from './contact-search'; +import { + CONTACT_BOOK_CACHE_IDLE_MS, + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + CONTACT_BOOK_CACHE_MAX_USERS, + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_MAX_PER_USER, + CONTACT_RESTORE_MAX_TOTAL_BYTES, + CONTACT_RESTORE_TOKEN_BYTES, + CONTACT_RESTORE_TTL_MS, + CONTACT_WRITE_ATTEMPTS, + MAX_ADDRESS_BOOK_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from './contacts.constants'; +import { + CONTACTS_PROVIDER, + type AddressBookCardUpdate, + type AddressBookChanges, + type AddressBookContactCard, + type ContactsProvider, + type NewAddressBookCard, + type RemovedCardOriginal, +} from './contacts.provider'; +import type { + Contact, + ContactDeleted, + ContactEmail, + ContactFields, + ContactGroup, + ContactGroupDeleted, + ContactGroupMembersAdded, + ContactInput, + ContactPatch, + ContactPhone, + ContactRestored, + ContactSuggestionLine, + DirectoryEntry, +} from './contacts.types'; +import { DirectoryService } from './directory.service'; +import type { ContactListQueryDto } from './dto/contact.dto'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +/** 되돌리기 표의 시계 — 테스트가 바꿔 끼운다 (없으면 Date.now) */ +export const CONTACTS_CLOCK = Symbol('CONTACTS_CLOCK'); + +/** 이 바꾸기 안에서 새 카드를 가리키는 이름 */ +const NEW_CONTACT_KEY = 'contact'; +const NEW_GROUP_KEY = 'group'; +const NEW_MEMBER_KEY_PREFIX = 'member-'; +const RESTORED_KEY = 'restored'; + +const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] }; + +/** + * 한 번의 바꾸기 시도 — 계획이 앞 시도를 알아본다. + * 엔진이 serverUnavailable 로 거절하면(busy) 일부가 들어갔을 수 있다 — 다음 시도의 계획은 앞 시도에서 만든 카드(같은 uid)를 찾아 이어 한다 + */ +interface WriteAttempt { + /** 이 바꾸기에서 새 카드에 쓸 uid — 다시 할 때도 같은 값 (앞 시도가 들어갔는지 알아보는 데 쓴다) */ + uid(key: string): string; + /** 앞 시도의 결과를 모른다 (엔진이 busy) */ + readonly uncertain: boolean; +} + +/** 쓴 결과 — 만든 카드의 id · 지운 카드의 원본(되돌리기 표 — 앞 시도에서 읽은 것도) */ +interface WriteResult { + readonly createdIds: ReadonlyMap; + readonly removed: ReadonlyMap; +} + +/** 한 번의 바꾸기 계획 — 지금 주소록으로 바꿀 것을 정하고, 바꾼 뒤의 주소록으로 응답을 만든다 */ +interface ChangePlan { + readonly changes: AddressBookChanges; + readonly result: (after: BookView, written: WriteResult) => T; + /** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */ + readonly log?: string; +} + +/** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */ +interface Loading { + readonly token: object; + readonly promise: Promise; +} + +/** 지운 연락처 — 응답 · 되돌리기 표에 쓴다 */ +interface RemovedContact { + readonly contact: Contact; + readonly card: AddressBookContactCard; + readonly groups: readonly RestorableGroupRef[]; +} + +/** 지운 그룹 */ +interface RemovedGroup { + readonly group: BookGroup; +} + +type Mutable = { -readonly [K in keyof T]?: T[K] }; + +function normalizeUser(email: string): string { + return email.trim().toLowerCase(); +} + +function isEmpty(changes: AddressBookChanges): boolean { + return ( + changes.create.length === 0 && + changes.update.length === 0 && + changes.destroy.length === 0 + ); +} + +function notFound(reason: string): BusinessException { + return new BusinessException('RES_001', { context: { reason } }); +} + +function overLimit( + reason: string, + context: Record = {}, +): BusinessException { + return new BusinessException('CONTACT_002', { + context: { reason, ...context }, + }); +} + +/** 연락처 칸만 꺼낸다 (groupIds 는 그룹 카드의 일) */ +function fieldsOf(input: ContactFields): ContactFields { + return { + name: input.name, + emails: input.emails.map(({ address, label }) => ({ address, label })), + phones: input.phones.map(({ number, label }) => ({ number, label })), + company: input.company, + department: input.department, + title: input.title, + note: input.note, + }; +} + +/** 고치기 본문을 지금 연락처에 합친 칸 — 보내지 않은 칸(undefined)은 지금 값 */ +function mergedFields( + current: ContactFields, + patch: ContactPatch, +): ContactFields { + // null 은 "지운다" — 보내지 않은 칸(undefined)만 지금 값을 쓴다 + const pick = (key: K): ContactFields[K] => { + const sent = patch[key]; + return sent !== undefined ? sent : current[key]; + }; + return fieldsOf({ + name: pick('name'), + emails: pick('emails'), + phones: pick('phones'), + company: pick('company'), + department: pick('department'), + title: pick('title'), + note: pick('note'), + }); +} + +function sameEmails(a: readonly ContactEmail[], b: readonly ContactEmail[]) { + return ( + a.length === b.length && + a.every( + (email, index) => + email.address === b[index]?.address && email.label === b[index]?.label, + ) + ); +} + +function samePhones(a: readonly ContactPhone[], b: readonly ContactPhone[]) { + return ( + a.length === b.length && + a.every( + (phone, index) => + phone.number === b[index]?.number && phone.label === b[index]?.label, + ) + ); +} + +/** + * 바뀐 칸만 — 바꾸지 않은 칸은 엔진에 보내지 않는다(다른 프로그램이 그 칸에 둔 것 · 우리가 읽으며 맞춘 모양을 그대로 둔다). + * 칸 안의 남의 정보는 어댑터가 지킨다(계약 3절 "고치기" — 회사만 바꾸면 부서 · 다른 조직은 그대로) + */ +function changedFields( + current: ContactFields, + next: ContactFields, +): Partial { + const changed: Mutable = {}; + if (current.name !== next.name) changed.name = next.name; + if (!sameEmails(current.emails, next.emails)) changed.emails = next.emails; + if (!samePhones(current.phones, next.phones)) changed.phones = next.phones; + if (current.company !== next.company) changed.company = next.company; + if (current.department !== next.department) { + changed.department = next.department; + } + if (current.title !== next.title) changed.title = next.title; + if (current.note !== next.note) changed.note = next.note; + return changed; +} + +/** 그룹 이름을 견주는 모양 — 앞뒤 공백을 빼고 ASCII 대소문자만 무시한다 (계약 3절 ContactGroup) */ +function groupNameKey(name: string): string { + return name.trim().replace(/[A-Z]+/g, (letters) => letters.toLowerCase()); +} + +/** 그룹의 구성원 uid 를 고친 것 — 더할 uid 는 끝에(이미 있으면 그대로), 뺄 uid 는 모두 */ +function withMembers( + group: BookGroup, + add: readonly string[], + remove: ReadonlySet = new Set(), +): string[] { + const uids = group.card.memberUids.filter((uid) => !remove.has(uid)); + const present = new Set(uids); + for (const uid of add) { + if (!present.has(uid)) { + present.add(uid); + uids.push(uid); + } + } + return uids; +} + +function joinGroups(groups: readonly BookGroup[], uid: string) { + return groups.map( + (group): AddressBookCardUpdate => ({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [uid]), + }), + ); +} + +/** + * 주소록 (계약 3절 "주소록") — 직원마다 엔진의 기본 주소록 하나(연락처 · 그룹). + * + * - 읽기: 요청마다 엔진에 판(상태)만 묻고(가볍다), 기억한 판과 같으면 다시 읽지 않는다 (contact-book-cache.ts — 200명 · 모두 2,400만 · + * 한 사람 800만). 다른 프로그램(CardDAV)에서 바꾼 것도 판이 바뀌어 곧 보인다 + * - 찾기 · 차례 · 커서는 서버가 한다 (contact-search.ts · contact-paging.ts — 엔진의 검색은 낱말 단위 · 이름 정렬 없음) + * - 바꾸기: 같은 직원의 바꾸기는 차례로 하고, 읽은 판 그대로일 때만 엔진에 쓴다 — 그사이 다른 곳에서 바뀌거나(conflict) 엔진이 동시 고치기를 + * 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 — + * CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts) + * - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts) + * - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다 + * + * ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다. + * 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404). + */ +@Injectable() +export class ContactsService { + private readonly logger = new Logger(ContactsService.name); + private readonly books = new ContactBookCache({ + maxUsers: CONTACT_BOOK_CACHE_MAX_USERS, + maxTotalWeight: CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + maxUserWeight: MAX_ADDRESS_BOOK_WEIGHT, + idleMs: CONTACT_BOOK_CACHE_IDLE_MS, + }); + private readonly restorations: ContactRestoreStore; + /** 읽는 중인 주소록 — 같은 직원의 동시 요청은 한 번만 읽는다. 끝나면 빠진다 */ + private readonly loading = new Map(); + /** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */ + private readonly writeQueues = new Map>(); + + constructor( + @Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider, + private readonly directory: DirectoryService, + @Optional() @Inject(CONTACTS_CLOCK) clock?: () => number, + ) { + this.restorations = new ContactRestoreStore( + { + ttlMs: CONTACT_RESTORE_TTL_MS, + maxPerUser: CONTACT_RESTORE_MAX_PER_USER, + maxCardBytes: CONTACT_RESTORE_MAX_CARD_BYTES, + maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES, + tokenBytes: CONTACT_RESTORE_TOKEN_BYTES, + }, + clock ?? (() => Date.now()), + ); + } + + /** + * GET /api/contacts — 이름 차례, 찾는 글 · 그룹(그 구성원만 · 그 구성원을 빼고)으로 거른 한 쪽 (total 포함). + * 커서는 엔진에 묻기 전에 확인한다 + */ + async list( + user: AuthenticatedUser, + query: ContactListQueryDto, + ): Promise> { + const { groupId, excludeGroupId } = query; + if (groupId !== undefined && excludeGroupId !== undefined) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-group-filters-together' }, + }); + } + const q = query.q?.trim() ? query.q.trim() : undefined; + const scope = listScope('contacts', q, groupId, excludeGroupId); + const start = cursorOffset(query.cursor, scope); + + const book = await this.freshBook(user.email); + const only = + groupId === undefined + ? null + : new Set(this.requireGroup(book, groupId).memberIds); + const except = + excludeGroupId === undefined + ? null + : new Set(this.requireGroup(book, excludeGroupId).memberIds); + const search = parseContactSearch(q); + const matched = book.contacts + .filter( + (entry) => + (only === null || only.has(entry.card.id)) && + (except === null || !except.has(entry.card.id)) && + (search === null || matchesContact(entry.search, search)), + ) + .map((entry) => entry.contact); + return pageOf(matched, start, query.limit, scope); + } + + /** GET /api/contacts/{id} */ + async get(user: AuthenticatedUser, id: string): Promise { + const book = await this.freshBook(user.email); + return this.requireContact(book, id).contact; + } + + /** POST /api/contacts — 새 연락처(uid 를 서버가 넣는다) + 고른 그룹에 넣기를 한 번에 */ + async create(user: AuthenticatedUser, input: ContactInput): Promise { + this.assertNameOrEmail(input); + this.assertDistinctEmails(input.emails); + return this.change(user, (book, attempt) => { + const groups = input.groupIds.map((id) => this.requireGroup(book, id)); + const uid = attempt.uid(NEW_CONTACT_KEY); + const landed = attempt.uncertain + ? book.contactsByUid.get(uid)?.[0] + : undefined; + if (landed) { + // 앞 시도가 들어갔다 — 빠진 그룹만 마저 넣는다 + const missing = groups.filter( + (group) => !group.card.memberUids.includes(uid), + ); + for (const group of missing) this.assertRoomFor(group, 1); + return { + changes: { + create: [], + update: joinGroups(missing, uid), + destroy: [], + }, + result: (after) => this.requireContact(after, landed.card.id).contact, + log: `연락처를 만들었습니다(앞 시도를 이어서): 그룹 ${missing.length}개`, + }; + } + + if (book.contacts.length >= MAX_CONTACTS) { + throw overLimit('contacts-limit', { contacts: book.contacts.length }); + } + for (const group of groups) this.assertRoomFor(group, 1); + return { + changes: { + create: [ + { + kind: 'contact', + key: NEW_CONTACT_KEY, + uid, + fields: fieldsOf(input), + }, + ], + update: joinGroups(groups, uid), + destroy: [], + }, + result: (after, written) => + this.requireContact( + after, + written.createdIds.get(NEW_CONTACT_KEY) ?? '', + ).contact, + log: `연락처를 만들었습니다: 그룹 ${groups.length}개`, + }; + }); + } + + /** + * PATCH /api/contacts/{id} — 보낸 칸만 바꾼다(하나 이상 — 비면 400 VAL_001). "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. + * 바뀐 칸만 엔진에 보내고, 칸 안의 남의 정보는 어댑터가 지킨다(계약 3절 "고치기"). groupIds 를 보내면 그룹에 넣고 뺀다 — + * uid 가 없는 카드(다른 프로그램이 만든 것)는 그룹에 넣을 수 없다(400 VAL_001) + */ + async patch( + user: AuthenticatedUser, + id: string, + patch: ContactPatch, + ): Promise { + const sent = Object.values(patch).filter((value) => value !== undefined); + if (sent.length === 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-patch-empty' }, + }); + } + if (patch.emails !== undefined) this.assertDistinctEmails(patch.emails); + + return this.change(user, (book) => { + const entry = this.requireContact(book, id); + const next = mergedFields(entry.contact, patch); + this.assertNameOrEmail(next); + + const current = new Set(entry.contact.groupIds); + const wantedIds = patch.groupIds ?? entry.contact.groupIds; + const wanted = wantedIds.map((groupId) => + this.requireGroup(book, groupId), + ); + const wantedSet = new Set(wantedIds); + const joining = wanted.filter((group) => !current.has(group.card.id)); + const leaving = entry.contact.groupIds + .filter((groupId) => !wantedSet.has(groupId)) + .map((groupId) => this.requireGroup(book, groupId)); + + const { uid } = entry.card; + if (joining.length > 0 && uid === null) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-without-uid', id }, + }); + } + for (const group of joining) this.assertRoomFor(group, 1); + + const fields = changedFields(entry.contact, next); + const update: AddressBookCardUpdate[] = []; + if (Object.keys(fields).length > 0) { + update.push({ kind: 'contact', id, fields }); + } + if (uid !== null) { + update.push(...joinGroups(joining, uid)); + for (const group of leaving) { + update.push({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [], new Set([uid])), + }); + } + } + return { + changes: { create: [], update, destroy: [] }, + result: (after) => this.requireContact(after, id).contact, + log: `연락처를 고쳤습니다: id=${id}, 칸 ${Object.keys(fields).length}개, 그룹 +${joining.length} -${leaving.length}`, + }; + }); + } + + /** + * DELETE /api/contacts/{id} — 영구 삭제(엔진 주소록에는 휴지통이 없다). 이 연락처를 가리키는 그룹 구성원도 함께 뺀다 — + * 같은 uid 의 다른 카드가 남아 있으면 그 카드의 구성원 자격은 그대로 둔다. 응답은 지운 연락처와 되돌리기 표 + */ + async remove(actor: AuthenticatedUser, id: string): Promise { + const owner = normalizeUser(actor.email); + let first: RemovedContact | null = null; + return this.change(actor, (book, attempt) => { + const entry = book.contactsById.get(id); + if (!entry) { + // 앞 시도가 들어갔다 — 그때 읽은 것으로 답한다 + if (attempt.uncertain && first) { + const removed = first; + return { + changes: NO_CHANGES, + result: (_after, written) => + this.contactDeleted(owner, removed, written.removed.get(id)), + }; + } + throw notFound('contact-not-found'); + } + const { uid } = entry.card; + const sharedUid = + uid !== null && + book.contacts.some( + (other) => other.card.id !== id && other.card.uid === uid, + ); + const leaving = + uid === null || sharedUid + ? [] + : book.groups.filter((group) => group.card.memberUids.includes(uid)); + const removed: RemovedContact = { + contact: entry.contact, + card: entry.card, + groups: entry.contact.groupIds.flatMap((groupId) => { + const group = book.groupsById.get(groupId); + return group ? [{ id: group.card.id, uid: group.card.uid }] : []; + }), + }; + first = removed; + return { + changes: { + create: [], + update: leaving.map((group) => ({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [], new Set([uid ?? ''])), + })), + destroy: [id], + }, + result: (_after, written) => + this.contactDeleted(owner, removed, written.removed.get(id)), + log: `연락처를 지웠습니다: id=${id}, 그룹에서 뺌 ${leaving.length}개`, + }; + }); + } + + /** GET /api/contact-groups — 이름 차례 (total 포함) */ + async listGroups( + user: AuthenticatedUser, + query: CursorPaginationQueryDto, + ): Promise> { + const scope = listScope('contact-groups'); + const start = cursorOffset(query.cursor, scope); + const book = await this.freshBook(user.email); + return pageOf( + book.groups.map((entry) => entry.group), + start, + query.limit, + scope, + ); + } + + /** POST /api/contact-groups — 그룹 100개 · 같은 이름을 본다 (지운 그룹은 restore 로 되돌린다) */ + async createGroup( + user: AuthenticatedUser, + input: { readonly name: string }, + ): Promise { + return this.change(user, (book, attempt) => { + const uid = attempt.uid(NEW_GROUP_KEY); + const landed = attempt.uncertain + ? book.groups.find((group) => group.card.uid === uid) + : undefined; + if (landed) { + return { changes: NO_CHANGES, result: () => landed.group }; + } + if (book.groups.length >= MAX_CONTACT_GROUPS) { + throw overLimit('contact-groups-limit', { groups: book.groups.length }); + } + this.assertGroupNameFree(book, input.name); + return { + changes: { + create: [ + { + kind: 'group', + key: NEW_GROUP_KEY, + uid, + name: input.name, + memberUids: [], + }, + ], + update: [], + destroy: [], + }, + result: (after, written) => + this.requireGroup(after, written.createdIds.get(NEW_GROUP_KEY) ?? '') + .group, + log: '연락처 그룹을 만들었습니다', + }; + }); + } + + /** PATCH /api/contact-groups/{id} — 이름만 바꾼다 (같은 이름은 그대로 두고 돌려준다) */ + async renameGroup( + user: AuthenticatedUser, + id: string, + name: string, + ): Promise { + return this.change(user, (book) => { + const group = this.requireGroup(book, id); + if (group.card.name === name) { + return { changes: NO_CHANGES, result: () => group.group }; + } + this.assertGroupNameFree(book, name, id); + return { + changes: { + create: [], + update: [{ kind: 'group', id, name }], + destroy: [], + }, + result: (after) => this.requireGroup(after, id).group, + log: `연락처 그룹 이름을 바꿨습니다: id=${id}`, + }; + }); + } + + /** DELETE /api/contact-groups/{id} — 그룹 카드만 지운다(연락처는 그대로). 응답은 이름 · 구성원과 되돌리기 표 */ + async removeGroup( + actor: AuthenticatedUser, + id: string, + ): Promise { + const owner = normalizeUser(actor.email); + let first: RemovedGroup | null = null; + return this.change(actor, (book, attempt) => { + const group = book.groupsById.get(id); + if (!group) { + if (attempt.uncertain && first) { + const removed = first; + return { + changes: NO_CHANGES, + result: (_after, written) => + this.groupDeleted(owner, removed, written.removed.get(id)), + }; + } + throw notFound('contact-group-not-found'); + } + const removed: RemovedGroup = { group }; + first = removed; + return { + changes: { create: [], update: [], destroy: [id] }, + result: (_after, written) => + this.groupDeleted(owner, removed, written.removed.get(id)), + log: `연락처 그룹을 지웠습니다: id=${id}, 구성원 ${group.memberIds.length}명`, + }; + }); + } + + /** + * POST /api/contact-restorations — 지운 연락처 · 그룹을 원본 그대로 다시 만든다 (계약 3절 "되돌리기"). + * 같은 uid(그사이 이 주소록에 같은 uid 의 카드가 생겼으면 새 uid). 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(500명이 찬 그룹은 건너뛴다), + * 그룹은 그때의 구성원 가운데 지금도 있는 연락처로 만든다. 표는 성공하면 지운다 — 없거나 · 지났거나 · 다른 직원의 것이면 404 RES_001 + */ + async restore( + actor: AuthenticatedUser, + token: string, + ): Promise { + const owner = normalizeUser(actor.email); + let plannedUid: string | null = null; + return this.change(actor, (book, attempt) => { + const entry = this.restorations.find(owner, token); + if (!entry) throw notFound('contact-restore-token-not-found'); + + if (attempt.uncertain && plannedUid !== null) { + const landed = this.landedRestore(book, entry, plannedUid, token); + if (landed) return landed; + } + const uid = + [entry.card.uid, attempt.uid(RESTORED_KEY)].find( + (candidate): candidate is string => + candidate !== null && !book.uidsInUse.has(candidate), + ) ?? newCardUid(); + plannedUid = uid; + + if (entry.kind === 'contact') { + if (book.contacts.length >= MAX_CONTACTS) { + throw overLimit('contacts-limit', { contacts: book.contacts.length }); + } + const groups = this.restorableGroups(book, entry.groups, uid); + return { + changes: { + create: [ + { + kind: 'restored', + key: RESTORED_KEY, + uid, + original: entry.original, + memberUids: null, + card: { kind: 'contact', card: entry.card }, + }, + ], + update: joinGroups(groups, uid), + destroy: [], + }, + result: (after, written) => { + this.restorations.forget(token); + return { + kind: 'contact', + contact: this.requireContact( + after, + written.createdIds.get(RESTORED_KEY) ?? '', + ).contact, + }; + }, + log: `지운 연락처를 되돌렸습니다: 그룹 ${groups.length}개`, + }; + } + + if (book.groups.length >= MAX_CONTACT_GROUPS) { + throw overLimit('contact-groups-limit', { groups: book.groups.length }); + } + this.assertGroupNameFree(book, entry.card.name); + const memberUids = [ + ...new Set( + entry.card.memberUids.filter((member) => + book.contactsByUid.has(member), + ), + ), + ]; + return { + changes: { + create: [ + { + kind: 'restored', + key: RESTORED_KEY, + uid, + original: entry.original, + memberUids, + card: { kind: 'group', card: entry.card }, + }, + ], + update: [], + destroy: [], + }, + result: (after, written) => { + this.restorations.forget(token); + return { + kind: 'group', + group: this.requireGroup( + after, + written.createdIds.get(RESTORED_KEY) ?? '', + ).group, + }; + }, + log: `지운 연락처 그룹을 되돌렸습니다: 구성원 ${memberUids.length}명`, + }; + }); + } + + /** + * POST /api/contact-groups/{id}/members — 내 연락처(contactIds)와 사내 목록(directoryIds)에서 넣는다 (합쳐 1~100개). + * 사내 목록에서 고른 사람은 같은 주소(대소문자 무시)의 연락처가 있으면 그 연락처를, 없으면 이름 · 주소로 새로 만들어 넣는다. + * 이미 구성원인 연락처 · uid 가 없는 카드(그룹에 넣을 수 없다)는 세지 않는다. 한도(연락처 3,000 · 구성원 500 · 크기)를 넘으면 아무것도 하지 않고 CONTACT_002 + */ + async addGroupMembers( + user: AuthenticatedUser, + id: string, + input: { + readonly contactIds?: readonly string[]; + readonly directoryIds?: readonly string[]; + }, + ): Promise { + const contactIds = input.contactIds ?? []; + const directoryIds = input.directoryIds ?? []; + const requested = contactIds.length + directoryIds.length; + if (requested < 1 || requested > MAX_GROUP_MEMBERS_PER_REQUEST) { + throw new BusinessException('VAL_001', { + context: { reason: 'group-members-count', requested }, + }); + } + // 사내 목록은 바꾸기 차례 밖에서 — 모든 직원이 함께 기억하는 목록이다 (읽지 못하면 MAIL_001, 없는 id 는 RES_001) + const people = await this.directory.findByIds(directoryIds); + + return this.change(user, (book, attempt) => { + const group = this.requireGroup(book, id); + const cards = contactIds.map( + (contactId) => this.requireContact(book, contactId).card, + ); + const create: NewAddressBookCard[] = []; + const planned = new Set(); + for (const person of people) { + const key = emailKey(person.email); + // 같은 주소의 연락처가 여럿이면 그룹에 넣을 수 있는(uid 가 있는) 것부터 — 앞 시도가 만든 연락처도 이렇게 찾는다 + const owners = book.contactsByEmail.get(key) ?? []; + const existing = + owners.find((owner) => owner.card.uid !== null) ?? owners[0]; + if (existing) { + cards.push(existing.card); + } else if (!planned.has(key)) { + planned.add(key); + const memberKey = `${NEW_MEMBER_KEY_PREFIX}${create.length}`; + create.push({ + kind: 'contact', + key: memberKey, + uid: attempt.uid(`${NEW_MEMBER_KEY_PREFIX}${key}`), + fields: { + name: person.name, + emails: [{ address: person.email, label: 'work' }], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }); + } + } + + const already = new Set(group.card.memberUids); + const adding = [ + ...new Set([ + ...cards + .map((card) => card.uid) + .filter((uid): uid is string => uid !== null && !already.has(uid)), + ...create.map((card) => card.uid), + ]), + ]; + if (book.contacts.length + create.length > MAX_CONTACTS) { + throw overLimit('contacts-limit', { + contacts: book.contacts.length, + creating: create.length, + }); + } + this.assertRoomFor(group, adding.length); + + const changes: AddressBookChanges = + adding.length === 0 + ? NO_CHANGES + : { + create, + update: [ + { + kind: 'group', + id, + memberUids: withMembers(group, adding), + }, + ], + destroy: [], + }; + return { + changes, + result: (after) => ({ + group: this.requireGroup(after, id).group, + addedCount: adding.length, + createdCount: create.length, + }), + log: `연락처 그룹에 넣었습니다: id=${id}, 넣음 ${adding.length}명, 새 연락처 ${create.length}명`, + }; + }); + } + + /** + * GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다. + * 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001 + */ + listDirectory( + user: AuthenticatedUser, + query: DirectoryEntryQueryDto, + ): Promise> { + const { excludeGroupId } = query; + return this.directory.list( + query, + excludeGroupId === undefined + ? undefined + : () => this.groupMemberEmails(user.email, excludeGroupId), + ); + } + + /** + * 받는 사람 추천의 주소록 묶음 (계약 3절 "메일과 함께") — 연락처의 이름 차례, 주소마다 한 줄(대표 주소가 먼저). + * 요청마다 판을 확인한 주소록에서 만든다 — 연락처를 바꾸면 다음 추천에 바로 보인다 + */ + async suggestionLines( + userEmail: string, + ): Promise { + return (await this.freshBook(userEmail)).suggestionLines; + } + + /** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */ + private async groupMemberEmails( + userEmail: string, + groupId: string, + ): Promise> { + const book = await this.freshBook(userEmail); + const group = this.requireGroup(book, groupId); + return new Set( + group.memberIds.flatMap( + (memberId) => + book.contactsById + .get(memberId) + ?.contact.emails.map((email) => emailKey(email.address)) ?? [], + ), + ); + } + + /** 지운 연락처의 응답 — 원본을 읽었으면 되돌리기 표를 준다(너무 크면 null) */ + private contactDeleted( + owner: string, + removed: RemovedContact, + original: RemovedCardOriginal | undefined, + ): ContactDeleted { + return { + contact: removed.contact, + restoreToken: this.rememberRemoved(owner, original, (source) => ({ + kind: 'contact', + original: source, + card: removed.card, + groups: removed.groups, + })), + }; + } + + private groupDeleted( + owner: string, + removed: RemovedGroup, + original: RemovedCardOriginal | undefined, + ): ContactGroupDeleted { + const { group } = removed; + return { + id: group.card.id, + name: group.card.name, + memberIds: group.memberIds, + restoreToken: this.rememberRemoved(owner, original, (source) => ({ + kind: 'group', + original: source, + card: group.card, + })), + }; + } + + private rememberRemoved( + owner: string, + original: RemovedCardOriginal | undefined, + build: (original: RemovedCardOriginal) => RestorableCard, + ): string | null { + if (!original) return null; + const token = this.restorations.remember(owner, build(original)); + if (token === null) { + this.logger.warn( + `${traceTag()} 지운 카드가 커서 되돌리기 표를 주지 않았습니다: user=${maskEmail(owner)}, bytes=${original.bytes}`, + ); + } + return token; + } + + /** 앞 시도의 되돌리기가 들어갔다 — 연락처는 빠진 그룹만 마저 넣는다 */ + private landedRestore( + book: BookView, + entry: RestorableCard, + uid: string, + token: string, + ): ChangePlan | null { + if (entry.kind === 'contact') { + const landed = book.contactsByUid.get(uid)?.[0]; + if (!landed) return null; + const groups = this.restorableGroups(book, entry.groups, uid); + return { + changes: { create: [], update: joinGroups(groups, uid), destroy: [] }, + result: (after) => { + this.restorations.forget(token); + return { + kind: 'contact', + contact: this.requireContact(after, landed.card.id).contact, + }; + }, + log: `지운 연락처를 되돌렸습니다(앞 시도를 이어서): 그룹 ${groups.length}개`, + }; + } + const landed = book.groups.find((group) => group.card.uid === uid); + if (!landed) return null; + return { + changes: NO_CHANGES, + result: () => { + this.restorations.forget(token); + return { kind: 'group', group: landed.group }; + }, + }; + } + + /** 지운 연락처가 다시 들어갈 그룹 — 그때 속했고 지금도 있는 그룹(uid 로, 없으면 id 로), 이미 든 그룹 · 500명이 찬 그룹은 건너뛴다 */ + private restorableGroups( + book: BookView, + refs: readonly RestorableGroupRef[], + uid: string, + ): BookGroup[] { + const found = new Map(); + for (const ref of refs) { + const group = + ref.uid !== null + ? book.groups.find((candidate) => candidate.card.uid === ref.uid) + : book.groupsById.get(ref.id); + if ( + group && + !found.has(group.card.id) && + !group.card.memberUids.includes(uid) && + group.group.memberCount < MAX_CONTACT_GROUP_MEMBERS + ) { + found.set(group.card.id, group); + } + } + return [...found.values()]; + } + + /** 판을 엔진에 물어 기억한 주소록과 견준다 — 같으면 그대로, 다르면 다시 읽는다 */ + private async freshBook(userEmail: string): Promise { + const user = normalizeUser(userEmail); + const version = await this.provider.getAddressBookVersion(user); + return this.books.get(user, version) ?? this.load(user); + } + + private load(user: string): Promise { + const pending = this.loading.get(user); + if (pending) return pending.promise; + + const token = {}; + const promise = this.provider + .readAddressBook(user) + .then((snapshot) => { + const view = buildBookView(snapshot); + // 읽는 사이 바꾸기가 끝났으면(그 결과를 이미 기억했다) 옛 결과는 기억하지 않는다 + if (this.loading.get(user)?.token === token) this.books.set(user, view); + return view; + }) + .finally(() => { + if (this.loading.get(user)?.token === token) this.loading.delete(user); + }); + this.loading.set(user, { token, promise }); + return promise; + } + + /** + * 바꾸기 — 같은 직원의 바꾸기는 차례로, 읽은 판 그대로일 때만 쓴다. 그사이 바뀌거나(conflict) 엔진이 동시 고치기를 거절하면(busy) + * 다시 읽고 다시 정한다 — busy 뒤의 계획은 앞 시도가 들어갔는지(같은 uid 의 카드 · 지운 카드) 보고 이어 한다. + * plan 이 던지면(RES_001 · CONTACT_001 · CONTACT_002 · VAL_001) 아무것도 쓰지 않는다. 크기 한도도 쓰기 전에 본다 + */ + private change( + actor: AuthenticatedUser, + plan: (book: BookView, attempt: WriteAttempt) => ChangePlan, + ): Promise { + const user = normalizeUser(actor.email); + return this.inWriteQueue(user, async () => { + const uids = new Map(); + const removed = new Map(); + let uncertain = false; + let busy = false; + const attempt: WriteAttempt = { + uid: (key) => { + const known = uids.get(key); + if (known !== undefined) return known; + const uid = newCardUid(); + uids.set(key, uid); + return uid; + }, + get uncertain() { + return uncertain; + }, + }; + + for (let tries = 1; tries <= CONTACT_WRITE_ATTEMPTS; tries += 1) { + const book = await this.freshBook(user); + const planned = plan(book, attempt); + if (isEmpty(planned.changes)) { + return planned.result(book, { createdIds: new Map(), removed }); + } + this.assertWeight(book, planned.changes); + + let outcome; + try { + outcome = await this.provider.writeAddressBook( + user, + book.version, + planned.changes, + ); + } catch (error) { + // 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다 + this.forget(user); + throw error; + } + if (outcome.kind === 'conflict') { + busy = false; + continue; + } + for (const [id, original] of outcome.removed) removed.set(id, original); + if (outcome.kind === 'busy') { + uncertain = true; + busy = true; + continue; + } + + const after = this.afterWrite(user, book, planned.changes, outcome); + if (planned.log) { + this.logger.log( + `${traceTag()} ${planned.log} (user=${maskEmail(user)})`, + ); + } + return planned.result(after, { + createdIds: outcome.createdIds, + removed, + }); + } + throw new BusinessException('MAIL_001', { + context: { + reason: busy ? 'address-book-busy' : 'address-book-keeps-changing', + attempts: CONTACT_WRITE_ATTEMPTS, + user: maskEmail(user), + }, + }); + }); + } + + /** 쓴 바꾸기를 기억한 주소록에 옮긴다 — 새 판을 모르면 응답에만 쓰고 기억하지 않는다(다음 요청이 다시 읽는다) */ + private afterWrite( + user: string, + book: BookView, + changes: AddressBookChanges, + outcome: WrittenChanges & { readonly version: string | null }, + ): BookView { + const next = applyAddressBookWrite( + book.snapshot, + changes, + outcome, + outcome.version ?? '', + ); + if (next === null) { + this.forget(user); + throw new BusinessException('MAIL_001', { + context: { reason: 'created-card-id-missing' }, + }); + } + const view = buildBookView(next); + // 읽는 중이던 옛 주소록이 이 결과를 덮지 않게 읽기를 놓는다 + this.loading.delete(user); + if (outcome.version === null) this.books.delete(user); + else this.books.set(user, view); + return view; + } + + private forget(user: string): void { + this.loading.delete(user); + this.books.delete(user); + } + + /** 같은 직원의 바꾸기를 차례로 돌린다 (앞 바꾸기가 실패해도 다음 바꾸기는 돈다) */ + private inWriteQueue(user: string, task: () => Promise): Promise { + const previous = this.writeQueues.get(user) ?? Promise.resolve(); + const run = previous.then(task, task); + const tail = run.then( + () => undefined, + () => undefined, + ); + this.writeQueues.set(user, tail); + void tail.then(() => { + if (this.writeQueues.get(user) === tail) this.writeQueues.delete(user); + }); + return run; + } + + /** 이름이나 이메일 가운데 하나 — 엔진에 묻기 전에 본다 */ + private assertNameOrEmail(fields: Pick) { + if (fields.name === null && fields.emails.length === 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-name-or-email-required' }, + }); + } + } + + /** 같은 연락처 안의 같은 주소(대소문자 무시) */ + private assertDistinctEmails(emails: readonly ContactEmail[]): void { + const addresses = new Set(emails.map((email) => emailKey(email.address))); + if (addresses.size !== emails.length) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-duplicate-email' }, + }); + } + } + + /** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */ + private assertWeight(book: BookView, changes: AddressBookChanges): void { + const after = weightAfterChanges(book, changes); + if (after > MAX_ADDRESS_BOOK_WEIGHT && after > book.weight) { + throw overLimit('address-book-too-large', { + weight: book.weight, + after, + }); + } + } + + /** 이 그룹에 count 명을 더 넣을 수 있는가 — 넘으면 CONTACT_002 */ + private assertRoomFor(group: BookGroup, count: number): void { + if (group.group.memberCount + count > MAX_CONTACT_GROUP_MEMBERS) { + throw overLimit('contact-group-members-limit', { + groupId: group.card.id, + members: group.group.memberCount, + adding: count, + }); + } + } + + /** 같은 이름의 다른 그룹이 있으면 CONTACT_001 */ + private assertGroupNameFree( + book: BookView, + name: string, + exceptId?: string, + ): void { + const key = groupNameKey(name); + const taken = book.groups.some( + (group) => + group.card.id !== exceptId && groupNameKey(group.card.name) === key, + ); + if (taken) { + throw new BusinessException('CONTACT_001', { + context: { reason: 'contact-group-name-taken' }, + }); + } + } + + private requireContact(book: BookView, id: string): BookContact { + const entry = book.contactsById.get(id); + if (!entry) throw notFound('contact-not-found'); + return entry; + } + + private requireGroup(book: BookView, id: string): BookGroup { + const entry = book.groupsById.get(id); + if (!entry) throw notFound('contact-group-not-found'); + return entry; + } +} diff --git a/backend/src/modules/contacts/contacts.types.ts b/backend/src/modules/contacts/contacts.types.ts new file mode 100644 index 0000000..043708f --- /dev/null +++ b/backend/src/modules/contacts/contacts.types.ts @@ -0,0 +1,131 @@ +/** + * 주소록 도메인 타입 — API 계약(docs/mail/api-contract.md 3절 "주소록")과 같은 모양이다. + * + * 메일 엔진(JMAP Contacts · Principal)의 타입은 어댑터(modules/mail/jmap/) 밖으로 나오지 않는다. 엔진을 바꿔도 이 타입은 그대로다. + */ + +/** 이메일의 종류 — 회사 · 개인 · 기타 */ +export type ContactEmailLabel = 'work' | 'personal' | 'other'; + +/** 전화번호의 종류 — 휴대전화 · 회사 · 집 · 기타 */ +export type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other'; + +export const CONTACT_EMAIL_LABELS: readonly ContactEmailLabel[] = [ + 'work', + 'personal', + 'other', +]; + +export const CONTACT_PHONE_LABELS: readonly ContactPhoneLabel[] = [ + 'mobile', + 'work', + 'home', + 'other', +]; + +export interface ContactEmail { + readonly address: string; + readonly label: ContactEmailLabel; +} + +export interface ContactPhone { + readonly number: string; + readonly label: ContactPhoneLabel; +} + +/** 우리가 다루는 연락처 칸 — 그룹은 따로 (그룹 카드의 구성원으로 잇는다) */ +export interface ContactFields { + /** 표시 이름 — 없으면 null */ + readonly name: string | null; + /** 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다 */ + readonly emails: readonly ContactEmail[]; + readonly phones: readonly ContactPhone[]; + readonly company: string | null; + readonly department: string | null; + /** 직책 */ + readonly title: string | null; + /** 메모 (줄바꿈 있음) */ + readonly note: string | null; +} + +/** 연락처 (계약 3절 Contact) */ +export interface Contact extends ContactFields { + readonly id: string; + /** 속한 그룹 — 이 주소록의 그룹만 */ + readonly groupIds: readonly string[]; +} + +/** POST 본문 (계약 3절 ContactInput) — 요청 검증이 앞뒤 공백을 지운 값(빈 글은 null) */ +export interface ContactInput extends ContactFields { + readonly groupIds: readonly string[]; +} + +/** + * PATCH 본문 (계약 3절 ContactPatch) — 바꾼 칸만(하나 이상). 보내지 않은 칸(undefined)은 검사하지도 바꾸지도 않는다. + * emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다 + */ +export type ContactPatch = Partial; + +/** 연락처 지우기의 응답 (계약 3절 ContactDeleted) */ +export interface ContactDeleted { + /** 지운 연락처 */ + readonly contact: Contact; + /** [되돌리기] 표 — 서버가 기억하지 못했으면 null (카드가 256KB 를 넘음 등) */ + readonly restoreToken: string | null; +} + +/** 그룹 (계약 3절 ContactGroup) */ +export interface ContactGroup { + readonly id: string; + readonly name: string; + /** 이 주소록에 있는 연락처 가운데 구성원 수 */ + readonly memberCount: number; +} + +/** 그룹 지우기의 응답 (계약 3절 ContactGroupDeleted) */ +export interface ContactGroupDeleted { + readonly id: string; + readonly name: string; + readonly memberIds: readonly string[]; + /** [되돌리기] 표 — 서버가 기억하지 못했으면 null */ + readonly restoreToken: string | null; +} + +/** 되돌리기의 응답 (계약 3절 ContactRestored) — 다시 만든 연락처 · 그룹 (id 는 새로 받는다) */ +export type ContactRestored = + | { readonly kind: 'contact'; readonly contact: Contact } + | { readonly kind: 'group'; readonly group: ContactGroup }; + +export interface ContactGroupMembersAdded { + readonly group: ContactGroup; + /** 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다) */ + readonly addedCount: number; + /** 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수 */ + readonly createdCount: number; +} + +/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */ +export type DirectoryEntryKind = 'person' | 'shared'; + +export const DIRECTORY_ENTRY_KINDS: readonly DirectoryEntryKind[] = [ + 'person', + 'shared', +]; + +/** 사내 목록 한 줄 (계약 3절 DirectoryEntry) */ +export interface DirectoryEntry { + /** 엔진의 계정 id */ + readonly id: string; + /** 엔진의 표시 이름 — 없으면 주소의 @ 앞 */ + readonly name: string; + readonly email: string; + readonly kind: DirectoryEntryKind; +} + +/** 받는 사람 추천에 넣는 주소록의 한 줄 — 연락처의 주소 하나 (계약 3절 "메일과 함께") */ +export interface ContactSuggestionLine { + readonly name: string | null; + readonly email: string; + readonly company: string | null; + readonly emailLabel: ContactEmailLabel; +} diff --git a/backend/src/modules/contacts/directory-entries.controller.ts b/backend/src/modules/contacts/directory-entries.controller.ts new file mode 100644 index 0000000..d419d67 --- /dev/null +++ b/backend/src/modules/contacts/directory-entries.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Header, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + DIRECTORY_ENTRIES_ROUTE, + MAX_CONTACT_QUERY_LENGTH, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { DirectoryService } from './directory.service'; +import { + DirectoryEntryDto, + DirectoryEntryPageDto, + DirectoryEntryQueryDto, +} from './dto/directory-entry.dto'; + +const NO_STORE = 'private, no-store'; +const UNREADABLE = + '사내 목록을 읽지 못함 — 사내 목록 계정이 없음(메일 엔진 설정 스크립트를 복구 관리자로 실행해야 만든다) · 권한 · 엔진 장애'; + +// 사내 목록 (계약 3절 "사내 목록", 8단계) — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만. 모든 직원이 같은 목록을 본다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(DIRECTORY_ENTRIES_ROUTE) +export class DirectoryEntriesController { + constructor( + private readonly directory: DirectoryService, + private readonly contacts: ContactsService, + ) {} + + @Get() + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '사내 목록 (커서 페이지네이션 · total 포함)', + description: + '엔진의 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared). 이름은 엔진의 표시 이름(없으면 주소의 @ 앞). ' + + '엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정(MAIL_BLOCKED_ACCOUNTS) · 메일 엔진 설정 스크립트가 만든 계정과 사내 도메인이 아닌 · 주소가 없는 계정은 뺀다. ' + + '이름 차례(한국어 사전 차례 → 주소 → id). q 는 초성만 친 글이면 이름의 초성과, 그 밖에는 이름 · 주소에 든 것(ASCII 대소문자 무시). ' + + 'excludeGroupId 면 내 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다(구성원 넣기 창). ' + + '커서는 받은 그 q · kind · excludeGroupId 로만 쓴다. 서버는 목록을 10분 기억한다(모든 직원이 함께). Cache-Control: private, no-store.', + }) + @ApiStandardResponse(DirectoryEntryPageDto, { description: '사내 목록' }) + @ApiErrorResponse( + 'VAL_001', + `q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · kind 가 person · shared 가 아님 · 잘못된 커서 · 다른 q · kind · excludeGroupId 의 커서 · limit 범위 밖`, + ) + @ApiErrorResponse('RES_001', '없는 excludeGroupId (내 주소록의 그룹)') + @ApiErrorResponse('MAIL_001', UNREADABLE) + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: DirectoryEntryQueryDto, + ) { + return this.contacts.listDirectory(user, query); + } + + @Get(':id') + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '사내 목록 한 항목', + description: + '기억한 사내 목록(10분)에서 찾는다 — 화면이 주소로 연 사내 항목. 빼는 계정(관리자 · 게이트웨이 · 막은 계정 등)은 없는 것과 같다.', + }) + @ApiStandardResponse(DirectoryEntryDto, { description: '사내 목록 한 항목' }) + @ApiErrorResponse('RES_001', '없거나 빼는 계정 (형식이 틀린 id 포함)') + @ApiErrorResponse('MAIL_001', UNREADABLE) + get(@Param('id', ContactIdPipe) id: string) { + return this.directory.findById(id); + } +} diff --git a/backend/src/modules/contacts/directory-policy.spec.ts b/backend/src/modules/contacts/directory-policy.spec.ts new file mode 100644 index 0000000..5704fd9 --- /dev/null +++ b/backend/src/modules/contacts/directory-policy.spec.ts @@ -0,0 +1,123 @@ +import type { DirectoryAccount } from './contacts.provider'; +import { + SETUP_MANAGED_MARK, + toDirectoryEntries, + type DirectoryPolicy, +} from './directory-policy'; + +const policy: DirectoryPolicy = { + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + 'ops@arasystem.test', + ]), +}; + +const account = ( + id: string, + email: string | null, + displayName: string | null = null, + kind: DirectoryAccount['kind'] = 'person', +): DirectoryAccount => ({ id, kind, displayName, email }); + +describe('사내 목록에 넣는 계정 (toDirectoryEntries)', () => { + it('엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정은 뺀다 — 웹메일 로그인을 막는 것과 같은 목록', () => { + const entries = toDirectoryEntries( + [ + account('a', 'admin@arasystem.test', 'System administrator'), + account( + 'g', + 'mail-gateway@arasystem.test', + `메일 게이트웨이 (백엔드 대리 접근 전용) ${SETUP_MANAGED_MARK}`, + ), + account( + 'm', + 'mail-directory@arasystem.test', + `사내 목록 (백엔드 계정 목록 읽기 전용) ${SETUP_MANAGED_MARK}`, + ), + account('o', 'Ops@AraSystem.test', '운영'), + account('k', 'ara.kim@arasystem.test', '김아라'), + ], + policy, + ); + + expect(entries).toEqual([ + { + id: 'k', + name: '김아라', + email: 'ara.kim@arasystem.test', + kind: 'person', + }, + ]); + }); + + it('메일 엔진 설정 스크립트가 만든 계정(설명에 [setup.mjs])도 뺀다 — 이름이 정적 목록과 달라도', () => { + expect( + toDirectoryEntries( + [ + account( + 'x', + 'renamed-service@arasystem.test', + `무엇 ${SETUP_MANAGED_MARK}`, + ), + ], + policy, + ), + ).toEqual([]); + }); + + it('사내 도메인이 아닌 주소 · 주소 없음 · 직원 주소 형식이 아닌 주소는 뺀다', () => { + expect( + toDirectoryEntries( + [ + account('1', 'partner@vendor.example', '바깥'), + account('2', 'ara.kim@mail.arasystem.test', '하위 도메인'), + account('3', null, '주소 없음'), + account('4', ' ', '빈 주소'), + account('5', 'ara%gw@arasystem.test', '대리 접근 구분자'), + ], + policy, + ), + ).toEqual([]); + }); + + it('주소는 소문자로, 같은 주소는 처음 것 하나만 — 이름은 표시 이름(제어 문자는 띄어쓰기), 없으면 주소의 @ 앞', () => { + const entries = toDirectoryEntries( + [ + account('d1', 'Doyun.Lee@AraSystem.Test', ' 이도윤\t(영업) '), + account('d2', 'doyun.lee@arasystem.test', '중복'), + account('s', 'sales@arasystem.test', null, 'shared'), + account('e', 'seojun.park@arasystem.test', ' '), + ], + policy, + ); + + expect(entries).toEqual([ + // 이름 차례 — 한글이 영문보다 앞이다 + { + id: 'd1', + name: '이도윤 (영업)', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }, + { id: 's', name: 'sales', email: 'sales@arasystem.test', kind: 'shared' }, + { + id: 'e', + name: 'seojun.park', + email: 'seojun.park@arasystem.test', + kind: 'person', + }, + ]); + }); + + it('메일 엔진 설정이 없으면(도메인 없음) 아무도 넣지 않는다', () => { + expect( + toDirectoryEntries([account('k', 'ara.kim@arasystem.test', '김아라')], { + ...policy, + domain: null, + }), + ).toEqual([]); + }); +}); diff --git a/backend/src/modules/contacts/directory-policy.ts b/backend/src/modules/contacts/directory-policy.ts new file mode 100644 index 0000000..4f979fa --- /dev/null +++ b/backend/src/modules/contacts/directory-policy.ts @@ -0,0 +1,66 @@ +import { isEmployeeAddress } from '@/modules/auth/employee-address'; +import { compareDirectoryEntries } from './contact-search'; +import type { DirectoryAccount } from './contacts.provider'; +import type { DirectoryEntry } from './contacts.types'; + +/** + * 사내 목록에 넣는 계정 (순수 함수) — 계약 3절 "사내 목록". + * + * 빼는 계정은 웹메일 로그인에서 막는 것과 **같은 함수**(auth/employee-address.ts 의 isEmployeeAddress)로 고른다: + * 사내 도메인(MAIL_DOMAIN)이 아닌 주소 · 주소 없음 · 게이트웨이 · 막은 계정(엔진 관리자 · 사내 목록 계정 · MAIL_BLOCKED_ACCOUNTS — + * mail.settings.ts 의 blockedAccounts). 거기에 더해 setup 이 만든 계정(설명에 SETUP_MANAGED_MARK)도 뺀다. + */ + +/** 메일 엔진 설정 스크립트가 자기가 만든 객체의 설명에 붙이는 표시 — mail/scripts/setup.mjs 의 MANAGED_MARK 와 같다 */ +export const SETUP_MANAGED_MARK = '[setup.mjs]'; + +export interface DirectoryPolicy { + /** 사내 메일 도메인 (소문자) — 없으면(메일 엔진 설정 없음) 아무도 넣지 않는다 */ + readonly domain: string | null; + readonly gatewayUser: string | null; + readonly blockedAccounts: ReadonlySet; +} + +/** 표시 이름 — 제어 문자는 띄어쓰기로, 앞뒤 공백은 뺀다. 없으면 주소의 @ 앞 */ +function displayNameOf(account: DirectoryAccount, email: string): string { + const name = account.displayName + // eslint-disable-next-line no-control-regex + ?.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ') + .trim(); + return name ? name : email.slice(0, email.lastIndexOf('@')); +} + +/** 엔진의 계정 목록 → 사내 목록 (이름 차례) — 같은 주소는 처음 것 하나만 */ +export function toDirectoryEntries( + accounts: readonly DirectoryAccount[], + policy: DirectoryPolicy, +): DirectoryEntry[] { + const { domain } = policy; + if (domain === null) return []; + + const seen = new Set(); + const entries: DirectoryEntry[] = []; + for (const account of accounts) { + const email = account.email?.trim().toLowerCase() ?? ''; + if ( + !isEmployeeAddress( + email, + domain, + policy.gatewayUser, + policy.blockedAccounts, + ) || + (account.displayName ?? '').includes(SETUP_MANAGED_MARK) || + seen.has(email) + ) { + continue; + } + seen.add(email); + entries.push({ + id: account.id, + name: displayNameOf(account, email), + email, + kind: account.kind, + }); + } + return entries.sort(compareDirectoryEntries); +} diff --git a/backend/src/modules/contacts/directory.service.spec.ts b/backend/src/modules/contacts/directory.service.spec.ts new file mode 100644 index 0000000..e5abaaf --- /dev/null +++ b/backend/src/modules/contacts/directory.service.spec.ts @@ -0,0 +1,257 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants'; +import type { DirectoryAccount } from './contacts.provider'; +import { DirectoryService } from './directory.service'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +const settings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + gatewayPassword: 'secret', + attachmentMaxBytes: 20 * 1024 * 1024, + attachmentTotalMaxBytes: 25 * 1024 * 1024, + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', +}; + +const ACCOUNTS: DirectoryAccount[] = [ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 'm', + kind: 'person', + displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]', + email: 'mail-directory@arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@arasystem.test', + }, + { + id: 'p', + kind: 'person', + displayName: '박서준', + email: 'seojun.park@arasystem.test', + }, + { + id: 'd', + kind: 'person', + displayName: '이도윤', + email: 'doyun.lee@arasystem.test', + }, + { + id: 'k', + kind: 'person', + displayName: '김아라', + email: 'ara.kim@arasystem.test', + }, +]; + +const query = ( + overrides: Partial = {}, +): DirectoryEntryQueryDto => ({ limit: 20, ...overrides }); + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error('BusinessException 이 아니다'); + } + return error; +} + +describe('DirectoryService (사내 목록)', () => { + const NOW = 1_800_000_000_000; + let listAccounts: jest.Mock, []>; + let service: DirectoryService; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + listAccounts = jest + .fn, []>() + .mockResolvedValue(ACCOUNTS); + service = new DirectoryService({ listAccounts }, settings); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => { + const page = await service.list(query()); + + expect(page.items.map((entry) => entry.name)).toEqual([ + '김아라', + '박서준', + '영업팀', + '이도윤', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 4, + }); + }); + + it('q 는 초성 · 이름 · 주소 — kind 는 직원 · 공용 주소만', async () => { + expect( + (await service.list(query({ q: 'ㄷㅇ' }))).items.map((e) => e.id), + ).toEqual(['d']); + expect( + (await service.list(query({ q: ' SEOJUN ' }))).items.map((e) => e.id), + ).toEqual(['p']); + expect( + (await service.list(query({ kind: 'shared' }))).items.map((e) => e.id), + ).toEqual(['s']); + expect( + (await service.list(query({ kind: 'person', q: '영업' }))).items, + ).toEqual([]); + }); + + it('커서 — 다음 쪽으로 이어지고, 받은 그 q · kind 로만 쓴다 (다르면 엔진에 묻기 전에 VAL_001)', async () => { + const first = await service.list(query({ limit: 3 })); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 }); + const cursor = first.pageInfo.nextCursor ?? ''; + + const second = await service.list(query({ limit: 3, cursor })); + expect(second.items.map((entry) => entry.id)).toEqual(['d']); + expect(second.pageInfo.hasNext).toBe(false); + + listAccounts.mockClear(); + for (const other of [ + query({ cursor, kind: 'person' }), + query({ cursor, q: '김' }), + query({ cursor: 'not-a-cursor' }), + ]) { + const error = await failureOf(service.list(other)); + expect(error.code).toBe('VAL_001'); + expect(error.context?.reason).toMatch(/^invalid-contact-cursor:/); + } + expect(listAccounts).not.toHaveBeenCalled(); + }); + + it('모든 직원이 함께 10분 기억한다 — 같은 때의 여러 요청은 한 번만 읽는다', async () => { + await Promise.all([ + service.entries(), + service.entries(), + service.list(query()), + ]); + expect(listAccounts).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS - 1); + await service.entries(); + expect(listAccounts).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS + 1); + await service.entries(); + expect(listAccounts).toHaveBeenCalledTimes(2); + }); + + it('읽지 못하면 MAIL_001 이고 기억하지 않는다 — 엔진 한도(REQ_001) 등도 MAIL_001 로 (사유는 로그용으로 남긴다)', async () => { + listAccounts.mockRejectedValueOnce( + new BusinessException('MAIL_001', { + context: { reason: 'directory-account-missing' }, + }), + ); + expect((await failureOf(service.entries())).context?.reason).toBe( + 'directory-account-missing', + ); + + listAccounts.mockRejectedValueOnce( + new BusinessException('REQ_001', { + context: { reason: 'mail-engine-rate-limit' }, + }), + ); + const limited = await failureOf(service.entries()); + expect(limited.code).toBe('MAIL_001'); + expect(limited.context).toMatchObject({ + reason: 'mail-engine-rate-limit', + code: 'REQ_001', + }); + + // 다음 요청은 다시 읽는다 + await expect(service.entries()).resolves.toHaveLength(4); + expect(listAccounts).toHaveBeenCalledTimes(3); + }); + + it('id 로 찾는다 — 넘긴 차례대로, 사내 목록에 없는 id(빠진 관리자 포함)는 RES_001', async () => { + expect( + (await service.findByIds(['k', 's'])).map((entry) => entry.email), + ).toEqual(['ara.kim@arasystem.test', 'sales@arasystem.test']); + expect((await failureOf(service.findByIds(['k', 'a']))).code).toBe( + 'RES_001', + ); + // 찾을 것이 없으면 엔진에 묻지 않는다 + listAccounts.mockClear(); + await expect(service.findByIds([])).resolves.toEqual([]); + expect(listAccounts).not.toHaveBeenCalled(); + }); + + it('한 항목(GET /api/directory-entries/{id}) — 기억한 목록에서, 없거나 빼는 계정이면 RES_001', async () => { + await expect(service.findById('d')).resolves.toEqual({ + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }); + for (const id of ['a', 'm', 'nope']) { + expect((await failureOf(service.findById(id))).code).toBe('RES_001'); + } + expect(listAccounts).toHaveBeenCalledTimes(1); + }); + + it('excludeGroupId — 받은 주소(소문자)를 뺀다 · 커서는 그 값으로만 쓰고, 커서를 먼저 확인한다(빼기 목록을 묻기 전에)', async () => { + const excluded = jest + .fn>, []>() + .mockResolvedValue( + new Set(['doyun.lee@arasystem.test', 'other@x.example']), + ); + const page = await service.list( + query({ excludeGroupId: 'g', limit: 2 }), + excluded, + ); + expect(page.items.map((entry) => entry.id)).toEqual(['k', 'p']); + expect(page.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + + const cursor = page.pageInfo.nextCursor ?? ''; + const next = await service.list( + query({ excludeGroupId: 'g', limit: 2, cursor }), + excluded, + ); + expect(next.items.map((entry) => entry.id)).toEqual(['s']); + + excluded.mockClear(); + for (const other of [ + query({ cursor }), + query({ cursor, excludeGroupId: 'h' }), + ]) { + expect((await failureOf(service.list(other, excluded))).code).toBe( + 'VAL_001', + ); + } + expect(excluded).not.toHaveBeenCalled(); + + // 그룹이 없으면(주소록 서비스가 RES_001) 그대로 + excluded.mockRejectedValueOnce(new BusinessException('RES_001')); + expect( + ( + await failureOf( + service.list(query({ excludeGroupId: 'gone' }), excluded), + ) + ).code, + ).toBe('RES_001'); + }); +}); diff --git a/backend/src/modules/contacts/directory.service.ts b/backend/src/modules/contacts/directory.service.ts new file mode 100644 index 0000000..ddba1f5 --- /dev/null +++ b/backend/src/modules/contacts/directory.service.ts @@ -0,0 +1,149 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { PaginatedResult } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { cursorOffset, listScope, pageOf } from './contact-paging'; +import { matchesDirectoryEntry, parseContactSearch } from './contact-search'; +import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants'; +import { + DIRECTORY_PROVIDER, + type DirectoryProvider, +} from './contacts.provider'; +import type { DirectoryEntry } from './contacts.types'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; +import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy'; + +/** 기억한 사내 목록 */ +interface DirectoryListing { + readonly entries: readonly DirectoryEntry[]; + readonly byId: ReadonlyMap; + readonly expiresAt: number; +} + +function directoryEntryNotFound(): BusinessException { + return new BusinessException('RES_001', { + context: { reason: 'directory-entry-not-found' }, + }); +} + +/** 사내 목록을 읽지 못했다 — 받는 사람 추천은 이것을 보고 로그인 기록으로 대신한다. 503 MAIL_001 로 답한다 */ +function directoryFailure(error: unknown): unknown { + if (!(error instanceof BusinessException) || error.code === 'MAIL_001') { + return error; + } + // 엔진 한도(REQ_001) 등 — 직원의 요청 때문이 아니라 서버가 쓰는 계정의 일이라 엔진 장애로 답한다 + return new BusinessException('MAIL_001', { + context: { + reason: 'directory-unavailable', + code: error.code, + ...(error.context ?? {}), + }, + }); +} + +/** + * 사내 목록 (계약 3절 "사내 목록") — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만. + * - 모든 직원이 함께 쓰는 목록을 10분 기억한다(서버 메모리). 같은 때의 여러 요청은 한 번만 읽는다 + * - 읽지 못하면(사내 목록 계정이 없음 · 권한 · 엔진 장애) 기억하지 않고 MAIL_001 — 다음 요청이 다시 읽는다 + * - 빼는 계정은 웹메일 로그인에서 막는 것과 같다 (directory-policy.ts) + * + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 10분씩 따로 기억한다 (틀리지는 않고, 계정을 바꾼 뒤 보이는 때가 인스턴스마다 다를 뿐이다). + */ +@Injectable() +export class DirectoryService { + private cached: DirectoryListing | null = null; + private loading: Promise | null = null; + private readonly policy: DirectoryPolicy; + + constructor( + @Inject(DIRECTORY_PROVIDER) private readonly provider: DirectoryProvider, + @Inject(MAIL_SETTINGS) settings: MailSettings, + ) { + this.policy = { + domain: settings.domain, + gatewayUser: settings.gatewayUser, + blockedAccounts: settings.blockedAccounts, + }; + } + + /** 사내 목록 전체 (이름 차례) — 읽지 못하면 MAIL_001 */ + async entries(): Promise { + return (await this.listing()).entries; + } + + /** + * GET /api/directory-entries — 찾는 글 · 종류로 거른 한 쪽. 커서는 엔진에 묻기 전에 확인한다. + * excluded 가 있으면(excludeGroupId — 주소록 서비스가 그 그룹 구성원의 주소를 준다) 그 주소(소문자)를 뺀다 + */ + async list( + query: DirectoryEntryQueryDto, + excluded?: () => Promise>, + ): Promise> { + const q = query.q?.trim() ? query.q.trim() : undefined; + const scope = listScope( + 'directory-entries', + q, + query.kind, + query.excludeGroupId, + ); + const start = cursorOffset(query.cursor, scope); + + const except = excluded ? await excluded() : null; + const { entries } = await this.listing(); + const search = parseContactSearch(q); + const matched = entries.filter( + (entry) => + (query.kind === undefined || entry.kind === query.kind) && + (except === null || !except.has(entry.email.toLowerCase())) && + (search === null || matchesDirectoryEntry(entry, search)), + ); + return pageOf(matched, start, query.limit, scope); + } + + /** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */ + async findById(id: string): Promise { + const [entry] = await this.findByIds([id]); + if (!entry) throw directoryEntryNotFound(); + return entry; + } + + /** id 로 찾는다 (넘긴 차례대로) — 사내 목록에 없는 id 가 하나라도 있으면 RES_001 */ + async findByIds(ids: readonly string[]): Promise { + if (ids.length === 0) return []; + const { byId } = await this.listing(); + return ids.map((id) => { + const entry = byId.get(id); + if (!entry) throw directoryEntryNotFound(); + return entry; + }); + } + + private listing(): Promise { + if (this.cached && this.cached.expiresAt > Date.now()) { + return Promise.resolve(this.cached); + } + if (this.loading) return this.loading; + + const loading = this.provider + .listAccounts() + .then((accounts) => { + const entries = toDirectoryEntries(accounts, this.policy); + const listing: DirectoryListing = { + entries, + byId: new Map(entries.map((entry) => [entry.id, entry])), + expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS, + }; + this.cached = listing; + return listing; + }) + .catch((error: unknown) => { + throw directoryFailure(error); + }) + .finally(() => { + if (this.loading === loading) this.loading = null; + }); + this.loading = loading; + return loading; + } +} diff --git a/backend/src/modules/contacts/dto/contact-group.dto.spec.ts b/backend/src/modules/contacts/dto/contact-group.dto.spec.ts new file mode 100644 index 0000000..745b72b --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-group.dto.spec.ts @@ -0,0 +1,174 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_CONTACT_QUERY_LENGTH, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from '@/modules/contacts/contacts.constants'; +import { DirectoryEntryQueryDto } from './directory-entry.dto'; +import { + ContactGroupCreateDto, + ContactGroupMembersDto, + ContactGroupRenameDto, +} from './contact-group.dto'; +import { ContactRestorationDto } from './contact-restoration.dto'; + +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션 (stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */ +async function check( + type: new () => T, + body: object, +): Promise<{ names: string[]; dto: T }> { + const dto = plainToInstance(type, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + }); + return { names: constraintNames(errors), dto }; +} + +const ids = (count: number, prefix = 'c') => + Array.from({ length: count }, (_, index) => `${prefix}${index}`); + +describe('그룹 본문 (POST · PATCH /api/contact-groups)', () => { + it(`이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자, 제어 문자 없음 — 지운 값으로 저장한다`, async () => { + const { names, dto } = await check(ContactGroupRenameDto, { + name: ` ${'가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH)} `, + }); + expect(names).toEqual([]); + expect(dto.name).toBe('가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH)); + + for (const name of ['', ' ']) { + expect((await check(ContactGroupRenameDto, { name })).names).toEqual([ + 'minCodeUnits', + ]); + } + expect( + ( + await check(ContactGroupRenameDto, { + name: 'x'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH + 1), + }) + ).names, + ).toEqual(['maxCodeUnits']); + expect( + (await check(ContactGroupRenameDto, { name: '거래\n처' })).names, + ).toEqual(['matches']); + expect((await check(ContactGroupRenameDto, {})).names).toContain( + 'isString', + ); + expect( + (await check(ContactGroupRenameDto, { name: '거래처', memberIds: [] })) + .names, + ).toEqual(['whitelistValidation']); + }); + + it('만들기는 { name } 만 — memberIds 는 받지 않는다(지운 그룹은 POST /api/contact-restorations 로 되돌린다)', async () => { + expect( + (await check(ContactGroupCreateDto, { name: '거래처' })).names, + ).toEqual([]); + expect( + (await check(ContactGroupCreateDto, { name: '거래처', memberIds: ['a'] })) + .names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('구성원 넣기 본문 (POST /api/contact-groups/{id}/members)', () => { + it(`contactIds · directoryIds — 둘 다 없어도 형식은 맞다(합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개는 서비스가 본다), 각각 ${MAX_GROUP_MEMBERS_PER_REQUEST}개까지`, async () => { + expect((await check(ContactGroupMembersDto, {})).names).toEqual([]); + expect( + ( + await check(ContactGroupMembersDto, { + contactIds: ids(MAX_GROUP_MEMBERS_PER_REQUEST), + directoryIds: ids(1, 'd'), + }) + ).names, + ).toEqual([]); + for (const key of ['contactIds', 'directoryIds']) { + expect( + ( + await check(ContactGroupMembersDto, { + [key]: ids(MAX_GROUP_MEMBERS_PER_REQUEST + 1), + }) + ).names, + ).toEqual(['arrayMaxSize']); + expect( + (await check(ContactGroupMembersDto, { [key]: ['x', 'x'] })).names, + ).toEqual(['arrayDistinct']); + } + expect( + (await check(ContactGroupMembersDto, { members: ['x'] })).names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('되돌리기 본문 (POST /api/contact-restorations)', () => { + it('token — 지우기 응답의 restoreToken 모양(base64url 22자)만, 모르는 칸은 받지 않는다', async () => { + expect( + (await check(ContactRestorationDto, { token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g' })) + .names, + ).toEqual([]); + for (const token of [ + '', + 'short', + 'q3Jd0Vb1Lw9xA2mYk7Pz4g=', + 'q3Jd0Vb1Lw9xA2mYk7Pz4/', + 'x'.repeat(100_000), + ]) { + expect((await check(ContactRestorationDto, { token })).names).toEqual([ + 'matches', + ]); + } + expect((await check(ContactRestorationDto, { token: 7 })).names).toContain( + 'isString', + ); + expect( + ( + await check(ContactRestorationDto, { + token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + kind: 'contact', + }) + ).names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('사내 목록 쿼리 (GET /api/directory-entries)', () => { + it(`kind 는 person · shared 만 · q 는 ${MAX_CONTACT_QUERY_LENGTH}자까지 · excludeGroupId 는 id 형식`, async () => { + expect( + (await check(DirectoryEntryQueryDto, { kind: 'person', q: '도윤' })) + .names, + ).toEqual([]); + expect( + (await check(DirectoryEntryQueryDto, { kind: 'group' })).names, + ).toEqual(['isIn']); + expect( + ( + await check(DirectoryEntryQueryDto, { + q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH), + excludeGroupId: 'jy', + }) + ).names, + ).toEqual([]); + expect( + ( + await check(DirectoryEntryQueryDto, { + q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1), + }) + ).names, + ).toEqual(['maxCodeUnits']); + expect( + (await check(DirectoryEntryQueryDto, { excludeGroupId: 'a b' })).names, + ).toEqual(['matches']); + expect( + (await check(DirectoryEntryQueryDto, { groupId: 'g' })).names, + ).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/contacts/dto/contact-group.dto.ts b/backend/src/modules/contacts/dto/contact-group.dto.ts new file mode 100644 index 0000000..212f950 --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-group.dto.ts @@ -0,0 +1,178 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + Matches, +} from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + CONTACT_ID_PATTERN, + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from '@/modules/contacts/contacts.constants'; +import type { + ContactGroup, + ContactGroupDeleted, + ContactGroupMembersAdded, +} from '@/modules/contacts/contacts.types'; +import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants'; + +/** + * 그룹 — 만들기 · 이름 바꾸기 · 구성원 넣기 본문과 응답 (계약 3절 "주소록"). + * 이름은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다. 같은 이름(앞뒤 공백 · ASCII 대소문자 무시)은 서비스가 본다(409 CONTACT_001). + * 칸의 검사는 applyDecorators 로 묶어 적은 차례대로 돈다 — 형식 → 길이 · 개수 → 모양 (contact.dto.ts 머리말 — stopAtFirstError 라 길이에서 걸리면 정규식을 돌리지 않는다) + */ + +const NAME_DESCRIPTION = `그룹 이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자(JS 문자열 길이) · 제어 문자 없음. 이 주소록 안에서 겹치지 않는다(ASCII 대소문자 무시 — 아니면 409 CONTACT_001)`; + +/** 그룹 이름 — 앞뒤 공백을 뺀 1~50자 · 제어 문자 없음 */ +function GroupName(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '그룹 이름 형식이 올바르지 않습니다.' }), + MinCodeUnits(1, { message: '그룹 이름을 입력해 주세요.' }), + MaxCodeUnits(MAX_CONTACT_GROUP_NAME_LENGTH, { + message: `그룹 이름은 ${MAX_CONTACT_GROUP_NAME_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '그룹 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** 구성원 넣기의 id 목록 — 없어도 되고, 100개까지 · id 형식 · 겹치지 않게 */ +function MemberIdList(duplicateMessage: string): PropertyDecorator { + return applyDecorators( + IsOptional(), + IsArray({ message: '구성원 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_GROUP_MEMBERS_PER_REQUEST, { + message: `한 번에 ${MAX_GROUP_MEMBERS_PER_REQUEST}명까지 넣을 수 있습니다.`, + }), + Matches(CONTACT_ID_PATTERN, { + each: true, + message: '구성원을 다시 골라 주세요.', + }), + ArrayDistinct({ message: duplicateMessage }), + ); +} + +/** 이름 칸 — 만들기 · 이름 바꾸기가 같은 규칙 */ +export class ContactGroupNameDto { + @ApiProperty({ + description: NAME_DESCRIPTION, + example: '거래처', + minLength: 1, + maxLength: MAX_CONTACT_GROUP_NAME_LENGTH, + }) + @GroupName() + name!: string; +} + +/** POST /api/contact-groups — `{ name }` 만 (지운 그룹은 POST /api/contact-restorations 로 되돌린다) */ +export class ContactGroupCreateDto extends ContactGroupNameDto {} + +/** PATCH /api/contact-groups/{id} */ +export class ContactGroupRenameDto extends ContactGroupNameDto {} + +/** POST /api/contact-groups/{id}/members — 합쳐 1~100개 (서비스가 본다 — 아니면 400 VAL_001) */ +export class ContactGroupMembersDto { + @ApiPropertyOptional({ + description: `넣을 내 연락처 id — 겹치지 않게. contactIds 와 directoryIds 를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. 이 주소록에 없는 연락처면 404 RES_001`, + type: [String], + maxItems: MAX_GROUP_MEMBERS_PER_REQUEST, + example: ['jw'], + }) + @MemberIdList('같은 연락처를 두 번 넣을 수 없습니다.') + contactIds?: string[]; + + @ApiPropertyOptional({ + description: + '넣을 사내 목록 id(엔진의 계정 id) — 겹치지 않게. 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를 넣고, ' + + '없으면 이름 · 주소(종류는 회사)로 연락처를 새로 만들어 넣는다. 사내 목록에 없는 id 면 404 RES_001, 사내 목록을 읽지 못하면 503 MAIL_001', + type: [String], + maxItems: MAX_GROUP_MEMBERS_PER_REQUEST, + example: ['b'], + }) + @MemberIdList('같은 사람을 두 번 넣을 수 없습니다.') + directoryIds?: string[]; +} + +/** 응답 — 계약 3절 ContactGroup */ +export class ContactGroupDto implements ContactGroup { + @ApiProperty({ description: '그룹 id (엔진의 카드 id)', example: 'jy' }) + id!: string; + + @ApiProperty({ description: '그룹 이름', example: '거래처' }) + name!: string; + + @ApiProperty({ + description: + '이 주소록에 있는 연락처 가운데 구성원 수 (지워진 카드를 가리키는 구성원은 세지 않는다)', + example: 4, + }) + memberCount!: number; +} + +export class ContactGroupPageDto { + @ApiProperty({ + type: [ContactGroupDto], + description: '이름 차례(한국어 사전 차례 → id)', + }) + items!: ContactGroupDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함' }) + pageInfo!: PageInfoDto; +} + +/** 그룹 지우기의 응답 — 계약 3절 ContactGroupDeleted */ +export class ContactGroupDeletedDto implements ContactGroupDeleted { + @ApiProperty({ description: '지운 그룹 id', example: 'jy' }) + id!: string; + + @ApiProperty({ description: '지운 그룹 이름', example: '거래처' }) + name!: string; + + @ApiProperty({ + description: '지우기 전 구성원(이 주소록의 연락처 id, 이름 차례)', + type: [String], + example: ['jw', 'jx'], + }) + memberIds!: string[]; + + @ApiProperty({ + description: + '[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null', + nullable: true, + type: String, + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + restoreToken!: string | null; +} + +export class ContactGroupMembersAddedDto implements ContactGroupMembersAdded { + @ApiProperty({ type: ContactGroupDto, description: '넣은 뒤의 그룹' }) + group!: ContactGroupDto; + + @ApiProperty({ + description: + '이번에 그룹에 들어간 수 (이미 구성원이던 것 · 그룹에 넣을 수 없는 카드는 세지 않는다)', + example: 2, + }) + addedCount!: number; + + @ApiProperty({ + description: '사내 목록에서 골라 내 주소록에 새로 만든 연락처 수', + example: 1, + }) + createdCount!: number; +} diff --git a/backend/src/modules/contacts/dto/contact-restoration.dto.ts b/backend/src/modules/contacts/dto/contact-restoration.dto.ts new file mode 100644 index 0000000..dfc236e --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-restoration.dto.ts @@ -0,0 +1,56 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; + +import { CONTACT_RESTORE_TOKEN_PATTERN } from '@/modules/contacts/contacts.constants'; +import { ContactGroupDto } from './contact-group.dto'; +import { ContactDto } from './contact.dto'; + +/** + * 되돌리기 — POST /api/contact-restorations 본문과 응답 (계약 3절 "되돌리기"). + * 표는 지우기 응답의 restoreToken 그대로다(무작위 128비트 — base64url 22자). 모양이 틀리면 400 VAL_001, 모양은 맞는데 없는 표 · 지난 표 · + * 이미 쓴 표 · 다른 직원의 표는 404 RES_001 (서비스가 본다). 표는 로그에 남기지 않는다 + */ + +function RestoreToken(): PropertyDecorator { + return applyDecorators( + IsString({ message: '되돌리기 표 형식이 올바르지 않습니다.' }), + Matches(CONTACT_RESTORE_TOKEN_PATTERN, { + message: '되돌리기 표 형식이 올바르지 않습니다.', + }), + ); +} + +export class ContactRestorationDto { + @ApiProperty({ + description: + '지우기 응답(DELETE /api/contacts/{id} · /api/contact-groups/{id})의 restoreToken — 5분 · 한 번만 (성공하면 지운다. 실패하면 5분 안에 다시 쓸 수 있다)', + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + @RestoreToken() + token!: string; +} + +/** 응답 — 계약 3절 ContactRestored (kind 에 따라 contact 나 group 하나만 있다) */ +export class ContactRestoredDto { + @ApiProperty({ + description: '다시 만든 것 — contact 연락처 · group 그룹', + enum: ['contact', 'group'], + example: 'contact', + }) + kind!: 'contact' | 'group'; + + @ApiPropertyOptional({ + type: ContactDto, + description: + 'kind 가 contact 일 때 — 다시 만든 연락처(새 id). 그때 속했고 지금도 있는 그룹에 다시 들어 있다(구성원 500명이 찬 그룹은 건너뛴다)', + }) + contact?: ContactDto; + + @ApiPropertyOptional({ + type: ContactGroupDto, + description: + 'kind 가 group 일 때 — 다시 만든 그룹(새 id). 구성원은 그때의 구성원 가운데 지금도 있는 연락처', + }) + group?: ContactGroupDto; +} diff --git a/backend/src/modules/contacts/dto/contact.dto.spec.ts b/backend/src/modules/contacts/dto/contact.dto.spec.ts new file mode 100644 index 0000000..3b00b27 --- /dev/null +++ b/backend/src/modules/contacts/dto/contact.dto.spec.ts @@ -0,0 +1,462 @@ +import { BadRequestException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { startCpuTimer } from '@/../test/cpu-time'; +import { createValidationPipe } from '@/app.setup'; +import { + MAX_CONTACT_EMAIL_LENGTH, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_IDS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACT_TEXT_LENGTH, +} from '@/modules/contacts/contacts.constants'; +import { ContactGroupNameDto } from './contact-group.dto'; +import { + ContactInputDto, + ContactListQueryDto, + ContactPatchDto, +} from './contact.dto'; +import { DirectoryEntryQueryDto } from './directory-entry.dto'; + +/** 걸린 규칙 이름 — 중첩된 칸(emails.0.address 등)까지 */ +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */ +async function check( + type: new () => T, + body: object, + stopAtFirstError = false, +): Promise<{ names: string[]; dto: T }> { + const dto = plainToInstance(type, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + stopAtFirstError, + }); + return { names: constraintNames(errors), dto }; +} + +const body = (overrides: object = {}) => ({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', + groupIds: ['jy'], + ...overrides, +}); + +const namesOf = async (overrides: object) => + (await check(ContactInputDto, body(overrides))).names; + +describe('연락처 본문 (POST /api/contacts)', () => { + it('맞는 본문 — 이메일 · 전화 · 그룹이 없어도 형식은 맞다 (이름이나 이메일 가운데 하나는 서비스가 본다)', async () => { + expect(await namesOf({})).toEqual([]); + expect( + await namesOf({ + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + groupIds: [], + }), + ).toEqual([]); + }); + + it('글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null', async () => { + const { names, dto } = await check( + ContactInputDto, + body({ + name: ` ${'가'.repeat(MAX_CONTACT_TEXT_LENGTH)} `, + company: ' ', + department: '\u3000구매팀\u3000', + title: '', + note: '\n 메모 \n', + emails: [{ address: ' a@x.example ', label: 'other' }], + phones: [{ number: ' 02-333-4444 ', label: 'work' }], + }), + ); + + expect(names).toEqual([]); + expect(dto.name).toBe('가'.repeat(MAX_CONTACT_TEXT_LENGTH)); + expect(dto.company).toBeNull(); + expect(dto.department).toBe('구매팀'); + expect(dto.title).toBeNull(); + expect(dto.note).toBe('메모'); + expect(dto.emails[0]?.address).toBe('a@x.example'); + expect(dto.phones[0]?.number).toBe('02-333-4444'); + }); + + it('모든 칸을 보낸다 — 키가 빠지면 형식 오류 · 모르는 칸은 받지 않는다', async () => { + for (const key of [ + 'name', + 'emails', + 'phones', + 'company', + 'department', + 'title', + 'note', + 'groupIds', + ]) { + const partial: Record = body(); + delete partial[key]; + expect( + (await check(ContactInputDto, partial)).names.length, + ).toBeGreaterThan(0); + } + expect(await namesOf({ birthday: '1990-05-01' })).toEqual([ + 'whitelistValidation', + ]); + expect( + await namesOf({ + emails: [{ address: 'a@x.example', label: 'work', pref: 1 }], + }), + ).toEqual(['whitelistValidation']); + }); + + it(`한 줄 칸(이름 · 회사 · 부서 · 직책) — ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이), 제어 문자 없음`, async () => { + for (const key of ['name', 'company', 'department', 'title']) { + expect( + await namesOf({ [key]: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + // 이모지는 두 자로 센다 (웹의 .length 와 같다) + expect( + await namesOf({ + [key]: `${'😀'.repeat(MAX_CONTACT_TEXT_LENGTH / 2)}x`, + }), + ).toEqual(['maxCodeUnits']); + expect(await namesOf({ [key]: '가\t나' })).toEqual(['matches']); + expect(await namesOf({ [key]: 7 })).toContain('isString'); + } + }); + + it(`메모 — ${MAX_CONTACT_NOTE_LENGTH}자까지, 줄바꿈(\\n)만 되고 탭 · \\r 등은 안 된다`, async () => { + expect( + await namesOf({ note: 'x'.repeat(MAX_CONTACT_NOTE_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect( + await namesOf({ note: `줄\n${'x'.repeat(MAX_CONTACT_NOTE_LENGTH - 2)}` }), + ).toEqual([]); + for (const note of ['a\tb', 'a\r\nb', 'a\u0000b']) { + expect(await namesOf({ note })).toEqual(['matches']); + } + }); + + it(`이메일 — ${MAX_CONTACT_EMAILS}개까지 · 메일 주소 형식 · 254자 · 종류는 work · personal · other (같은 주소 두 번은 서비스가 본다)`, async () => { + const email = { address: 'a@x.example', label: 'work' }; + expect( + await namesOf({ + emails: Array.from({ length: MAX_CONTACT_EMAILS + 1 }, (_, i) => ({ + ...email, + address: `a${i}@x.example`, + })), + }), + ).toEqual(['arrayMaxSize']); + expect( + await namesOf({ + emails: Array.from({ length: MAX_CONTACT_EMAILS }, (_, i) => ({ + ...email, + address: `a${i}@x.example`, + })), + }), + ).toEqual([]); + expect(await namesOf({ emails: [{ ...email, address: 'a@' }] })).toContain( + 'isEmail', + ); + expect( + await namesOf({ + emails: [ + { ...email, address: `${'a'.repeat(64)}@${'b'.repeat(190)}.example` }, + ], + }), + ).toContain('maxCodeUnits'); + expect(await namesOf({ emails: [{ ...email, label: 'home' }] })).toEqual([ + 'isIn', + ]); + expect(await namesOf({ emails: email })).toContain('isArray'); + expect(await namesOf({ emails: [null] })).not.toEqual([]); + expect(await namesOf({ emails: [email, email] })).toEqual([]); + }); + + it(`전화번호 — ${MAX_CONTACT_PHONES}개까지 · ${MAX_CONTACT_PHONE_LENGTH}자 · 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 종류는 mobile · work · home · other`, async () => { + const phone = { number: '010-3192-5540', label: 'mobile' }; + expect( + await namesOf({ + phones: Array.from({ length: MAX_CONTACT_PHONES + 1 }, () => phone), + }), + ).toEqual(['arrayMaxSize']); + expect( + await namesOf({ + phones: [ + { ...phone, number: '1'.repeat(MAX_CONTACT_PHONE_LENGTH + 1) }, + ], + }), + ).toEqual(['maxCodeUnits']); + // 글자가 틀림(내선 · tel:) · 숫자가 없음(+-) · 빈 글 + for (const number of ['내선 12', 'tel:010', '+-', '(#*)', '', ' ']) { + expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([ + 'matches', + ]); + } + for (const number of ['7', '+82 10-3192-5540', '(02) 333.4444 #2']) { + expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([]); + } + expect(await namesOf({ phones: [{ ...phone, label: 'fax' }] })).toEqual([ + 'isIn', + ]); + }); + + it(`그룹 — ${MAX_CONTACT_GROUP_IDS}개까지 · id 형식 · 겹치지 않게(ArrayDistinct)`, async () => { + expect( + await namesOf({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + (_, i) => `g${i}`, + ), + }), + ).toEqual(['arrayMaxSize']); + expect(await namesOf({ groupIds: ['a/b'] })).toEqual(['matches']); + expect(await namesOf({ groupIds: ['g1', 'g1'] })).toEqual([ + 'arrayDistinct', + ]); + expect(await namesOf({ groupIds: 'g1' })).toContain('isArray'); + }); +}); + +describe('연락처 목록 쿼리 (GET /api/contacts)', () => { + const queryNames = async (query: object) => + (await check(ContactListQueryDto, query)).names; + + it(`q 는 앞뒤 공백을 지운 ${MAX_CONTACT_QUERY_LENGTH}자까지 · groupId · excludeGroupId 는 id 형식 · limit 1~100 · 모르는 쿼리는 받지 않는다`, async () => { + expect(await queryNames({})).toEqual([]); + expect( + await queryNames({ q: 'ㅂㅈㅎ', groupId: 'jy', limit: '100' }), + ).toEqual([]); + expect( + ( + await check(ContactListQueryDto, { + q: ` ${'x'.repeat(MAX_CONTACT_QUERY_LENGTH)} `, + }) + ).names, + ).toEqual([]); + expect( + await queryNames({ q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect(await queryNames({ groupId: 'a b' })).toEqual(['matches']); + // excludeGroupId 도 id 형식 (groupId 와 함께 쓰는 것은 서비스가 400) + expect(await queryNames({ excludeGroupId: 'jy' })).toEqual([]); + expect(await queryNames({ excludeGroupId: 'a/b' })).toEqual(['matches']); + expect(await queryNames({ limit: '101' })).toEqual(['max']); + expect(await queryNames({ sort: 'name' })).toEqual(['whitelistValidation']); + }); +}); + +describe('연락처 고치기 본문 (PATCH /api/contacts/{id} — 바꾼 칸만)', () => { + const patchNames = async (body: object) => + (await check(ContactPatchDto, body)).names; + + it('보낸 칸만 검사한다 — 빈 본문 · 칸 하나도 형식은 맞다(빈 본문은 서비스가 400), 보내지 않은 칸은 undefined', async () => { + expect(await patchNames({})).toEqual([]); + const { names, dto } = await check(ContactPatchDto, { + department: ' 영업팀 ', + }); + expect(names).toEqual([]); + expect(dto.department).toBe('영업팀'); + expect(dto.name).toBeUndefined(); + expect(dto.emails).toBeUndefined(); + expect(dto.groupIds).toBeUndefined(); + }); + + it('글 칸의 null · 빈 글은 "지운다"(null) — 배열 칸의 null 은 형식 오류', async () => { + const { names, dto } = await check(ContactPatchDto, { + name: null, + company: ' ', + note: '', + }); + expect(names).toEqual([]); + expect(dto.name).toBeNull(); + expect(dto.company).toBeNull(); + expect(dto.note).toBeNull(); + for (const key of ['emails', 'phones', 'groupIds']) { + expect(await patchNames({ [key]: null })).toContain('isArray'); + } + }); + + it('보낸 칸은 POST 와 같은 규칙 — 길이 · 모양 · 개수 · 모르는 칸', async () => { + expect( + await patchNames({ title: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect(await patchNames({ name: '가\t나' })).toEqual(['matches']); + expect(await patchNames({ note: 'a\tb' })).toEqual(['matches']); + expect( + await patchNames({ phones: [{ number: '내선', label: 'work' }] }), + ).toEqual(['matches']); + expect( + await patchNames({ emails: [{ address: 'a@', label: 'work' }] }), + ).toContain('isEmail'); + expect( + await patchNames({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + (_, i) => `g${i}`, + ), + }), + ).toEqual(['arrayMaxSize']); + expect(await patchNames({ groupIds: ['g1', 'g1'] })).toEqual([ + 'arrayDistinct', + ]); + expect(await patchNames({ birthday: '1990-05-01' })).toEqual([ + 'whitelistValidation', + ]); + }); +}); + +describe('검사 차례 — 형식 → 길이 · 개수 → 모양 (stopAtFirstError 로 길이에서 멈춘다, 2026-10-29 리뷰 H1)', () => { + /** 전역 파이프처럼 첫 실패에서 멈춘다 — 길이와 모양이 함께 틀린 값은 길이만 걸려야 한다(정규식 · 주소 검사를 돌리지 않는다) */ + const firstNames = async (type: new () => object, body: object) => + (await check(type, body, true)).names; + /** 모양이 틀린 글자를 가운데에 둔다 — 앞뒤에 두면 앞뒤 공백 지우기가 먼저 지운다 */ + const tooLong = (length: number, bad: string) => + `x${bad}${'x'.repeat(length)}`; + + it.each([ + ['name', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['company', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['department', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['title', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['note', MAX_CONTACT_NOTE_LENGTH, '\t'], + ])('%s — 길고 제어 문자도 든 글은 maxCodeUnits 만', async (key, max, bad) => { + for (const type of [ContactInputDto, ContactPatchDto]) { + expect( + await firstNames(type, body({ [key]: tooLong(max, bad) })), + ).toEqual(['maxCodeUnits']); + } + }); + + it('전화번호 · 주소 — 길고 모양도 틀린 값은 maxCodeUnits 만, 문자열이 아니면 isString 만', async () => { + expect( + await firstNames( + ContactInputDto, + body({ + phones: [ + { + number: tooLong(MAX_CONTACT_PHONE_LENGTH, '내선'), + label: 'work', + }, + ], + }), + ), + ).toEqual(['maxCodeUnits']); + expect( + await firstNames( + ContactInputDto, + body({ + emails: [ + { + address: tooLong(MAX_CONTACT_EMAIL_LENGTH, '@@ '), + label: 'work', + }, + ], + }), + ), + ).toEqual(['maxCodeUnits']); + expect( + await firstNames( + ContactInputDto, + body({ phones: [{ number: 7, label: 'work' }] }), + ), + ).toEqual(['isString']); + }); + + it('그룹 이름 · 찾는 글(q) — 길이가 먼저', async () => { + expect( + await firstNames(ContactGroupNameDto, { name: tooLong(50, '\n') }), + ).toEqual(['maxCodeUnits']); + for (const type of [ContactListQueryDto, DirectoryEntryQueryDto]) { + expect( + await firstNames(type, { q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + } + }); + + it('그룹 id 목록 — 배열이 아니면 isArray 만 · 개수가 넘으면 arrayMaxSize 만(항목 모양 · 겹침은 보지 않는다)', async () => { + expect(await firstNames(ContactInputDto, body({ groupIds: 'g1' }))).toEqual( + ['isArray'], + ); + expect( + await firstNames( + ContactInputDto, + body({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + () => 'a b', + ), + }), + ), + ).toEqual(['arrayMaxSize']); + }); +}); + +describe('전역 DTO 검증의 비용 (createValidationPipe — 한 칸에 100만 자, CPU 시간)', () => { + /** + * 본문 1MB 안에 한 칸을 100만 자로 채워도 길이에서 싸게 400 이다 (2026-10-29 리뷰 H1 — 전에는 전화번호 정규식이 길이 검사보다 먼저 돌았고 + * 그 정규식이 길이의 제곱이라 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분이었다). + * 지금 한 칸에 수 ms(Windows 틱 15.6ms 아래) — 한도 1초는 부하(CI 의 4~7배)를 견디고, 옛 경로(4만 자에 1.7초)는 확실히 넘는다 + */ + const BUDGET_MS = 1_000; + const MILLION = 1_000_000; + + it.each<[string, new () => object, object]>([ + [ + '전화번호 (숫자 뒤에 틀린 글자 하나 — 옛 정규식이 가장 느렸던 모양)', + ContactInputDto, + body({ phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] }), + ], + [ + '전화번호 (고치기)', + ContactPatchDto, + { phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] }, + ], + [ + '주소', + ContactInputDto, + body({ + emails: [ + { address: `${'a'.repeat(MILLION)}@x.example`, label: 'work' }, + ], + }), + ], + [ + '메모 (끝에 탭)', + ContactInputDto, + body({ note: `${'가'.repeat(MILLION)}\t` }), + ], + ['이름 (고치기)', ContactPatchDto, { name: `${'가'.repeat(MILLION)}\t` }], + [ + '찾는 글', + ContactListQueryDto, + { q: `${'ㄱ'.repeat(MILLION)}`, limit: '20' }, + ], + ])('%s — 1초 안에 400', async (_label, metatype, value) => { + const pipe = createValidationPipe(); + const cpuMs = startCpuTimer(); + await expect( + pipe.transform(value, { type: 'body', metatype }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(cpuMs()).toBeLessThan(BUDGET_MS); + }); +}); diff --git a/backend/src/modules/contacts/dto/contact.dto.ts b/backend/src/modules/contacts/dto/contact.dto.ts new file mode 100644 index 0000000..e380bee --- /dev/null +++ b/backend/src/modules/contacts/dto/contact.dto.ts @@ -0,0 +1,525 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEmail, + IsIn, + IsOptional, + IsString, + Matches, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + TrimString, + TrimStringOrNull, +} from '@/common/decorators/trim-string.decorator'; +import { + CursorPaginationQueryDto, + PageInfoDto, +} from '@/common/dto/pagination.dto'; +import { + CONTACT_ID_PATTERN, + CONTACT_NOTE_PATTERN, + CONTACT_PHONE_DIGIT, + CONTACT_PHONE_PATTERN, + MAX_CONTACT_EMAIL_LENGTH, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_IDS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACT_TEXT_LENGTH, +} from '@/modules/contacts/contacts.constants'; +import { + CONTACT_EMAIL_LABELS, + CONTACT_PHONE_LABELS, + type Contact, + type ContactDeleted, + type ContactEmail, + type ContactEmailLabel, + type ContactInput, + type ContactPatch, + type ContactPhone, + type ContactPhoneLabel, +} from '@/modules/contacts/contacts.types'; +import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants'; + +/** + * 연락처 — POST 본문(ContactInput) · PATCH 본문(ContactPatch)과 응답(Contact · ContactDeleted) (계약 3절 "주소록"). + * 여기서는 칸마다 형식을 본다. 이름이나 이메일 가운데 하나는 있어야 함 · 같은 연락처 안의 같은 주소(대소문자 무시) · 빈 PATCH 는 서비스가 본다 (모두 400 VAL_001). + * 글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null. + * - POST 는 모든 칸을 보낸다(값 없음은 null · 빈 배열, 키가 빠지면 형식 오류) + * - PATCH 는 바꾼 칸만 보낸다 — 보내지 않은 칸은 검사하지 않는다(다른 프로그램이 우리 규칙 밖의 값을 넣었어도 다른 칸은 고칠 수 있다) + * + * **검사 차례**: 칸의 검사는 applyDecorators 로 묶어 **적은 차례대로** 돈다 — 형식(IsString · IsArray) → 길이 · 개수 → 모양(정규식 · 메일 주소). + * 전역 파이프는 stopAtFirstError 라(app.setup.ts) 길이에서 걸리면 그 뒤의 정규식 · 주소 검사를 하지 않는다 — 1MB 짜리 한 칸이 와도 싸게 400 이다 + * (2026-10-29 리뷰 H1 — 데코레이터를 그냥 쌓으면 아래 것부터 돌아, 정규식이 길이 검사보다 먼저 돌았다) + */ + +/** POST — null 은 "없음"으로 받는다. 키가 없으면(undefined) 형식 오류다 (모든 칸을 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; +/** PATCH — 보내지 않은 칸(undefined)과 "없음"(null)은 검사하지 않는다 */ +const whenSentText = (_object: object, value: unknown) => + value !== undefined && value !== null; +/** PATCH — 보내지 않은 배열 칸은 검사하지 않는다 (null 은 형식 오류) */ +const whenSent = (_object: object, value: unknown) => value !== undefined; + +type Sent = 'always' | 'patch'; + +/** 한 줄 글 칸(이름 · 회사 · 부서 · 직책) — 앞뒤 공백을 빼고 100자, 제어 문자 없음, 빈 글은 null */ +function SingleLineText( + label: string, + topic: string, + sent: Sent, +): PropertyDecorator { + return applyDecorators( + TrimStringOrNull(), + ValidateIf(sent === 'always' ? unlessNull : whenSentText), + IsString({ message: `${label} 형식이 올바르지 않습니다.` }), + MaxCodeUnits(MAX_CONTACT_TEXT_LENGTH, { + message: `${topic} ${MAX_CONTACT_TEXT_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: `${label}에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.`, + }), + ); +} + +function NoteText(sent: Sent): PropertyDecorator { + return applyDecorators( + TrimStringOrNull(), + ValidateIf(sent === 'always' ? unlessNull : whenSentText), + IsString({ message: '메모 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_NOTE_LENGTH, { + message: `메모는 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자 이하로 입력해 주세요.`, + }), + Matches(CONTACT_NOTE_PATTERN, { + message: '메모에는 줄바꿈 말고 다른 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** 메일 주소 — 254자 · 메일 주소 형식 (앞뒤 공백을 지운 값) */ +function EmailAddress(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '이메일 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_EMAIL_LENGTH, { + message: `이메일 주소는 ${MAX_CONTACT_EMAIL_LENGTH}자 이하로 입력해 주세요.`, + }), + IsEmail({}, { message: '이메일 형식이 올바르지 않습니다.' }), + ); +} + +/** 전화번호 — 40자 · 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 숫자가 하나 이상 (앞뒤 공백을 지운 값) */ +function PhoneNumber(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '전화번호 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_PHONE_LENGTH, { + message: `전화번호는 ${MAX_CONTACT_PHONE_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(CONTACT_PHONE_PATTERN, { + message: + '전화번호에는 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.', + }), + Matches(CONTACT_PHONE_DIGIT, { + message: '전화번호에는 숫자가 하나 이상 있어야 합니다.', + }), + ); +} + +export class ContactEmailDto implements ContactEmail { + @ApiProperty({ + description: `메일 주소 — ${MAX_CONTACT_EMAIL_LENGTH}자까지 · 메일 주소 형식 (앞뒤 공백은 지워 저장한다)`, + example: 'jihoon.park@hanbit.co.kr', + maxLength: MAX_CONTACT_EMAIL_LENGTH, + }) + @EmailAddress() + address!: string; + + @ApiProperty({ + description: '종류 — work 회사 · personal 개인 · other 기타', + enum: [...CONTACT_EMAIL_LABELS], + example: 'work', + }) + @IsIn(CONTACT_EMAIL_LABELS, { message: '이메일 종류를 다시 골라 주세요.' }) + label!: ContactEmailLabel; +} + +export class ContactPhoneDto implements ContactPhone { + @ApiProperty({ + description: `전화번호 — ${MAX_CONTACT_PHONE_LENGTH}자까지, 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 (앞뒤 공백은 지워 저장한다)`, + example: '010-3192-5540', + maxLength: MAX_CONTACT_PHONE_LENGTH, + }) + @PhoneNumber() + number!: string; + + @ApiProperty({ + description: '종류 — mobile 휴대전화 · work 회사 · home 집 · other 기타', + enum: [...CONTACT_PHONE_LABELS], + example: 'mobile', + }) + @IsIn(CONTACT_PHONE_LABELS, { message: '전화번호 종류를 다시 골라 주세요.' }) + label!: ContactPhoneLabel; +} + +function EmailList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '이메일 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_EMAILS, { + message: `이메일은 ${MAX_CONTACT_EMAILS}개까지 넣을 수 있습니다.`, + }), + ValidateNested({ each: true }), + Type(() => ContactEmailDto), + ); +} + +function PhoneList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '전화번호 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_PHONES, { + message: `전화번호는 ${MAX_CONTACT_PHONES}개까지 넣을 수 있습니다.`, + }), + ValidateNested({ each: true }), + Type(() => ContactPhoneDto), + ); +} + +function GroupIdList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '그룹 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_GROUP_IDS, { + message: `그룹은 ${MAX_CONTACT_GROUP_IDS}개까지 고를 수 있습니다.`, + }), + Matches(CONTACT_ID_PATTERN, { + each: true, + message: '그룹을 다시 골라 주세요.', + }), + ArrayDistinct({ message: '같은 그룹을 두 번 고를 수 없습니다.' }), + ); +} + +/** 한 줄 글 칸의 설명 */ +function singleLineDescription(what: string): string { + return `${what} — 앞뒤 공백을 뺀 ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이) · 제어 문자 없음. 없으면 null (빈 글은 null 로 저장한다)`; +} + +const NOTE_DESCRIPTION = `메모 — 앞뒤 공백을 뺀 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자까지. 줄바꿈(\\n)은 되고 그 밖의 제어 문자(탭 · \\r 포함)는 안 된다. 없으면 null`; +const EMAILS_DESCRIPTION = `이메일 — ${MAX_CONTACT_EMAILS}개까지(화면은 5개까지 더한다), 앞의 것이 대표 주소. 같은 연락처 안에서 겹치지 않게(대소문자 무시 — 아니면 400 VAL_001)`; +const PHONES_DESCRIPTION = `전화번호 — ${MAX_CONTACT_PHONES}개까지`; +const GROUP_IDS_DESCRIPTION = `속할 그룹 id — ${MAX_CONTACT_GROUP_IDS}개까지, 겹치지 않게. 이 주소록에 없는 그룹이면 404 RES_001`; + +export class ContactInputDto implements ContactInput { + @ApiProperty({ + description: singleLineDescription('표시 이름'), + nullable: true, + type: String, + example: '박지훈', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('이름', '이름은', 'always') + name!: string | null; + + @ApiProperty({ + description: EMAILS_DESCRIPTION, + type: [ContactEmailDto], + maxItems: MAX_CONTACT_EMAILS, + }) + @EmailList('always') + emails!: ContactEmailDto[]; + + @ApiProperty({ + description: PHONES_DESCRIPTION, + type: [ContactPhoneDto], + maxItems: MAX_CONTACT_PHONES, + }) + @PhoneList('always') + phones!: ContactPhoneDto[]; + + @ApiProperty({ + description: singleLineDescription('회사'), + nullable: true, + type: String, + example: '한빛상사', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('회사', '회사는', 'always') + company!: string | null; + + @ApiProperty({ + description: singleLineDescription('부서'), + nullable: true, + type: String, + example: '구매팀', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('부서', '부서는', 'always') + department!: string | null; + + @ApiProperty({ + description: singleLineDescription('직책'), + nullable: true, + type: String, + example: '과장', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('직책', '직책은', 'always') + title!: string | null; + + @ApiProperty({ + description: NOTE_DESCRIPTION, + nullable: true, + type: String, + example: '견적 담당\n월요일 오전에 연락', + maxLength: MAX_CONTACT_NOTE_LENGTH, + }) + @NoteText('always') + note!: string | null; + + @ApiProperty({ + description: GROUP_IDS_DESCRIPTION, + type: [String], + maxItems: MAX_CONTACT_GROUP_IDS, + example: ['jy'], + }) + @GroupIdList('always') + groupIds!: string[]; +} + +const PATCH_NOTE = + ' — 보낼 때만 검사하고 바꾼다(보내지 않으면 그대로, 다른 프로그램이 넣은 규칙 밖의 값도 그대로)'; + +/** + * PATCH 본문 — 바꾼 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다). 보낸 칸만 위 규칙으로 보고, "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. + * emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다. 텍스트 칸의 null 은 "지운다" + */ +export class ContactPatchDto implements ContactPatch { + @ApiPropertyOptional({ + description: `${singleLineDescription('표시 이름')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '박지훈', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('이름', '이름은', 'patch') + name?: string | null; + + @ApiPropertyOptional({ + description: `${EMAILS_DESCRIPTION}${PATCH_NOTE}`, + type: [ContactEmailDto], + maxItems: MAX_CONTACT_EMAILS, + }) + @EmailList('patch') + emails?: ContactEmailDto[]; + + @ApiPropertyOptional({ + description: `${PHONES_DESCRIPTION}${PATCH_NOTE}`, + type: [ContactPhoneDto], + maxItems: MAX_CONTACT_PHONES, + }) + @PhoneList('patch') + phones?: ContactPhoneDto[]; + + @ApiPropertyOptional({ + description: `${singleLineDescription('회사')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '한빛상사', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('회사', '회사는', 'patch') + company?: string | null; + + @ApiPropertyOptional({ + description: `${singleLineDescription('부서')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '구매팀', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('부서', '부서는', 'patch') + department?: string | null; + + @ApiPropertyOptional({ + description: `${singleLineDescription('직책')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '과장', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('직책', '직책은', 'patch') + title?: string | null; + + @ApiPropertyOptional({ + description: `${NOTE_DESCRIPTION}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '견적 담당', + maxLength: MAX_CONTACT_NOTE_LENGTH, + }) + @NoteText('patch') + note?: string | null; + + @ApiPropertyOptional({ + description: `${GROUP_IDS_DESCRIPTION}${PATCH_NOTE}`, + type: [String], + maxItems: MAX_CONTACT_GROUP_IDS, + example: ['jy'], + }) + @GroupIdList('patch') + groupIds?: string[]; +} + +/** 응답 — 계약 3절 Contact */ +export class ContactDto implements Contact { + @ApiProperty({ description: '연락처 id (엔진의 카드 id)', example: 'jw' }) + id!: string; + + @ApiProperty({ + description: + '표시 이름 (엔진 카드의 name.full — 없으면 이름 조각을 이어 붙인 것(한글 조각끼리는 붙여 쓴다), 그것도 없으면 null)', + nullable: true, + type: String, + example: '박지훈', + }) + name!: string | null; + + @ApiProperty({ + description: + '이메일 — 앞의 것이 대표 주소(받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다). 다른 프로그램이 넣은 것은 개수 제한 없이 그대로 보인다', + type: [ContactEmailDto], + }) + emails!: ContactEmailDto[]; + + @ApiProperty({ description: '전화번호', type: [ContactPhoneDto] }) + phones!: ContactPhoneDto[]; + + @ApiProperty({ + description: '회사 (없으면 null)', + nullable: true, + type: String, + example: '한빛상사', + }) + company!: string | null; + + @ApiProperty({ + description: '부서 (없으면 null)', + nullable: true, + type: String, + example: '구매팀', + }) + department!: string | null; + + @ApiProperty({ + description: '직책 (없으면 null)', + nullable: true, + type: String, + example: '과장', + }) + title!: string | null; + + @ApiProperty({ + description: '메모 (줄바꿈 있음, 없으면 null)', + nullable: true, + type: String, + example: '견적 담당\n월요일 오전에 연락', + }) + note!: string | null; + + @ApiProperty({ + description: '속한 그룹 id — 이 주소록의 그룹만 (그룹 이름 차례)', + type: [String], + example: ['jy'], + }) + groupIds!: string[]; +} + +/** 연락처 지우기의 응답 — 계약 3절 ContactDeleted */ +export class ContactDeletedDto implements ContactDeleted { + @ApiProperty({ + type: ContactDto, + description: '지운 연락처 (그룹에서도 뺐다)', + }) + contact!: ContactDto; + + @ApiProperty({ + description: + '[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null(카드가 256KB 를 넘음 등 — 화면은 [되돌리기] 없이 알린다)', + nullable: true, + type: String, + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + restoreToken!: string | null; +} + +export class ContactPageDto { + @ApiProperty({ + type: [ContactDto], + description: + '이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소로 견준다. 같으면 대표 주소 → id). 이름도 주소도 없는 연락처는 맨 뒤', + }) + items!: ContactDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' }) + pageInfo!: PageInfoDto; +} + +/** 찾는 글(q) — 연락처 · 사내 목록이 같은 규칙 */ +export function ContactQueryText(): PropertyDecorator { + return applyDecorators( + IsOptional(), + TrimString(), + IsString({ message: '찾는 글 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_QUERY_LENGTH, { + message: `찾는 글은 ${MAX_CONTACT_QUERY_LENGTH}자 이하로 입력해 주세요.`, + }), + ); +} + +/** 그룹 id 쿼리(groupId · excludeGroupId) */ +export function ContactGroupIdQuery(): PropertyDecorator { + return applyDecorators( + IsOptional(), + IsString({ message: '그룹을 다시 골라 주세요.' }), + Matches(CONTACT_ID_PATTERN, { message: '그룹을 다시 골라 주세요.' }), + ); +} + +/** GET /api/contacts — 커서 페이지네이션 + 찾는 글 · 그룹 */ +export class ContactListQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: + `찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고(ㅂㅈㅎ → 박지훈), ` + + '그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다)', + example: 'ㅂㅈㅎ', + }) + @ContactQueryText() + q?: string; + + @ApiPropertyOptional({ + description: '이 그룹의 구성원만 — 없는 그룹이면 404 RES_001', + example: 'jy', + }) + @ContactGroupIdQuery() + groupId?: string; + + @ApiPropertyOptional({ + description: + '이 그룹의 구성원을 빼고(구성원 넣기 창) — 없는 그룹이면 404 RES_001. groupId 와 함께 쓰지 않는다(400 VAL_001)', + example: 'jy', + }) + @ContactGroupIdQuery() + excludeGroupId?: string; +} diff --git a/backend/src/modules/contacts/dto/directory-entry.dto.ts b/backend/src/modules/contacts/dto/directory-entry.dto.ts new file mode 100644 index 0000000..ccec2f2 --- /dev/null +++ b/backend/src/modules/contacts/dto/directory-entry.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { + CursorPaginationQueryDto, + PageInfoDto, +} from '@/common/dto/pagination.dto'; +import { MAX_CONTACT_QUERY_LENGTH } from '@/modules/contacts/contacts.constants'; +import { + DIRECTORY_ENTRY_KINDS, + type DirectoryEntry, + type DirectoryEntryKind, +} from '@/modules/contacts/contacts.types'; +import { ContactGroupIdQuery, ContactQueryText } from './contact.dto'; + +/** GET /api/directory-entries — 커서 페이지네이션 + 찾는 글 · 종류 (계약 3절 "사내 목록") */ +export class DirectoryEntryQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: + `찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고, ` + + '그 밖에는 이름 · 주소에 든 것 (ASCII 대소문자 무시)', + example: '도윤', + }) + @ContactQueryText() + q?: string; + + @ApiPropertyOptional({ + description: 'person 직원(개인 계정)만 · shared 공용 주소(그룹 계정)만', + enum: [...DIRECTORY_ENTRY_KINDS], + example: 'person', + }) + @IsOptional() + @IsIn(DIRECTORY_ENTRY_KINDS, { message: '종류를 다시 골라 주세요.' }) + kind?: DirectoryEntryKind; + + @ApiPropertyOptional({ + description: + '이 그룹(내 주소록) 구성원이 가진 주소(대소문자 무시)를 뺀다 — 구성원 넣기 창. 없는 그룹이면 404 RES_001', + example: 'jy', + }) + @ContactGroupIdQuery() + excludeGroupId?: string; +} + +/** 응답 — 계약 3절 DirectoryEntry */ +export class DirectoryEntryDto implements DirectoryEntry { + @ApiProperty({ + description: + '엔진의 계정 id — 그룹 구성원 넣기(POST /api/contact-groups/{id}/members)의 directoryIds 에 쓴다', + example: 'b', + }) + id!: string; + + @ApiProperty({ + description: + '엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름), 없으면 주소의 @ 앞', + example: '이도윤', + }) + name!: string; + + @ApiProperty({ + description: '사내 주소 (소문자)', + example: 'doyun.lee@arasystem.it.kr', + }) + email!: string; + + @ApiProperty({ + description: 'person 직원(개인 계정) · shared 공용 주소(그룹 계정)', + enum: [...DIRECTORY_ENTRY_KINDS], + example: 'person', + }) + kind!: DirectoryEntryKind; +} + +export class DirectoryEntryPageDto { + @ApiProperty({ + type: [DirectoryEntryDto], + description: '이름 차례(한국어 사전 차례 → 주소 → id)', + }) + items!: DirectoryEntryDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' }) + pageInfo!: PageInfoDto; +} diff --git a/backend/src/modules/mail-directory/mail-directory.entity.ts b/backend/src/modules/mail-directory/mail-directory.entity.ts index 70ac8fa..942f26c 100644 --- a/backend/src/modules/mail-directory/mail-directory.entity.ts +++ b/backend/src/modules/mail-directory/mail-directory.entity.ts @@ -6,9 +6,10 @@ const EMAIL_COLUMN_LENGTH = 254; export const DIRECTORY_NAME_LENGTH = 200; /** - * 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름. - * 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs). - * 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다). + * 로그인 기록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름. + * 8단계부터 받는 사람 추천의 "사내"는 엔진의 사내 목록(contacts/directory.service.ts — 사내 목록 계정으로 계정 목록을 읽는다)이고, + * 이 표는 그것을 읽지 못할 때 대신 쓴다. 직원 역할의 계정 목록(Principal) 권한은 그대로 막아 두었다 (mail/scripts/setup.mjs). + * 메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 넣지 않는다 (로그인할 수도 없다). */ @Entity({ name: 'mail_directory' }) export class MailDirectoryEntryEntity { diff --git a/backend/src/modules/mail-directory/mail-directory.module.ts b/backend/src/modules/mail-directory/mail-directory.module.ts index 742d523..197900b 100644 --- a/backend/src/modules/mail-directory/mail-directory.module.ts +++ b/backend/src/modules/mail-directory/mail-directory.module.ts @@ -5,7 +5,7 @@ import { MailDirectoryEntryEntity } from './mail-directory.entity'; import { MailDirectoryService } from './mail-directory.service'; /** - * 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다. + * 로그인 기록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천 — 엔진의 사내 목록을 읽지 못할 때 대신)이 함께 쓴다. * 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다). */ @Module({ diff --git a/backend/src/modules/mail-directory/mail-directory.service.ts b/backend/src/modules/mail-directory/mail-directory.service.ts index d57a4c3..de9af1e 100644 --- a/backend/src/modules/mail-directory/mail-directory.service.ts +++ b/backend/src/modules/mail-directory/mail-directory.service.ts @@ -20,7 +20,8 @@ export const MAIL_DIRECTORY_ACTIVE_DAYS = 90; const DAY_MS = 24 * 60 * 60 * 1000; /** - * 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService). + * 로그인 기록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 엔진의 사내 목록을 읽지 못할 때 대신 읽는다 + * (RecipientSuggestionService — 8단계부터 "사내"는 contacts/directory.service.ts). * 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다. */ @Injectable() diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index d5b4fef..4cb8cc9 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -1,6 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + CONTACT_EMAIL_LABELS, + type ContactEmailLabel, +} from '@/modules/contacts/contacts.types'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; import type { Draft, @@ -526,6 +530,7 @@ export class DraftSavedDto implements SavedDraft { } const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [ + 'contact', 'directory', 'recent', ]; @@ -548,18 +553,36 @@ export class RecipientSuggestionDto implements RecipientSuggestion { @ApiProperty({ description: - 'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory', + 'contact = 내 주소록(연락처의 주소마다 한 줄), directory = 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원), ' + + 'recent = 최근 주고받은 주소. 여러 묶음에 있으면 앞 묶음(contact → directory → recent)', enum: SUGGESTION_SOURCES, example: 'directory', }) source!: RecipientSuggestion['source']; + + @ApiProperty({ + description: 'contact 만 — 그 연락처의 회사 (그 밖에는 null)', + nullable: true, + type: String, + example: null, + }) + company!: string | null; + + @ApiProperty({ + description: + 'contact 만 — 그 주소의 종류 work 회사 · personal 개인 · other 기타 (그 밖에는 null)', + nullable: true, + enum: [...CONTACT_EMAIL_LABELS], + example: null, + }) + emailLabel!: ContactEmailLabel | null; } export class RecipientSuggestionPageDto { @ApiProperty({ type: [RecipientSuggestionDto], description: - '사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순', + '주소록(연락처의 이름 차례 — 대표 주소가 먼저) → 사내 → 최근 주고받은 주소, 사내 · 최근 안에서는 주고받은 횟수와 최근성 순', }) items!: RecipientSuggestionDto[]; diff --git a/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts index 683ad26..93e3e4d 100644 --- a/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts +++ b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts @@ -7,7 +7,7 @@ import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants'; /** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */ export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ - description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`, + description: `찾는 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하) — 쓰기 화면과 같은 맞추기: 이름은 초성 · 조합 중 글자도, 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. 없거나 공백뿐이면 거르지 않는다`, example: '도윤', }) @IsOptional() diff --git a/backend/src/modules/mail/jmap/contact-card.spec.ts b/backend/src/modules/mail/jmap/contact-card.spec.ts new file mode 100644 index 0000000..674102a --- /dev/null +++ b/backend/src/modules/mail/jmap/contact-card.spec.ts @@ -0,0 +1,861 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { ContactFields } from '@/modules/contacts/contacts.types'; +import { + cardOriginal, + contactPatch, + groupPatch, + newCardJson, + patchedCard, + readContactCard, +} from './contact-card'; +import type { JmapContactCard } from './jmap.types'; + +/** 2026-10-29 개발 엔진(Stalwart 0.16.27)이 돌려준 카드 모양 — p8-contacts-state.log */ +const ENGINE_CARD: JmapContactCard = { + id: 'ju', + uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + kind: 'individual', + name: { full: '시험' }, + emails: { + e1: { address: 't@example.com', contexts: { work: true }, pref: 1 }, + }, + phones: { p1: { number: '010-1234-5678', features: { mobile: true } } }, + organizations: { o1: { name: '시험회사', units: [{ name: '시험부서' }] } }, + titles: { t1: { name: '과장', kind: 'title' } }, + notes: { n1: { note: '메모\n둘째 줄' } }, +}; + +const fields: ContactFields = { + name: '박지훈', + emails: [ + { address: 'jihoon@hanbit.example', label: 'work' }, + { address: 'jihoon@home.example', label: 'personal' }, + { address: 'jihoon@old.example', label: 'other' }, + ], + phones: [ + { number: '010-3192-5540', label: 'mobile' }, + { number: '02-333-4444', label: 'work' }, + { number: '031-555-6666', label: 'home' }, + { number: '070-1111-2222', label: 'other' }, + ], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', +}; + +describe('JSContact 카드 읽기 (readContactCard)', () => { + it('엔진이 돌려준 카드 — 우리 칸으로 그대로 (메모의 줄바꿈 포함)', () => { + expect(readContactCard(ENGINE_CARD)).toEqual({ + kind: 'contact', + card: { + id: 'ju', + uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + name: '시험', + emails: [{ address: 't@example.com', label: 'work' }], + phones: [{ number: '010-1234-5678', label: 'mobile' }], + company: '시험회사', + department: '시험부서', + title: '과장', + note: '메모\n둘째 줄', + }, + }); + }); + + it('uid 없는 카드(다른 프로그램이 uid 없이 만든 것) — uid 는 null', () => { + const read = readContactCard({ id: 'a', name: { full: '옛 카드' } }); + expect(read?.card.uid).toBeNull(); + }); + + it('칸이 없는 카드 — 엔진은 이름 없는 카드에 빈 full 을 준다 → 이름 null, 나머지는 빈 값', () => { + expect(readContactCard({ id: 'j0', name: { full: '' } })).toEqual({ + kind: 'contact', + card: { + id: 'j0', + uid: null, + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }); + // kind 가 없거나 group 이 아니면 연락처다 + expect(readContactCard({ id: 'x', kind: 'org' })?.kind).toBe('contact'); + // id 가 없으면 읽지 않는다 + expect(readContactCard({ id: '' })).toBeNull(); + }); + + it('이메일 6개 이상(화면은 5개까지 더한다) — 모두 읽고, pref(작을수록 먼저) → 엔진이 준 차례', () => { + const emails: Record> = Object.fromEntries( + Array.from({ length: 7 }, (_, index) => [ + `e${index + 1}`, + { address: `p${index + 1}@x.example` }, + ]), + ); + emails.e6 = { + address: 'p6@x.example', + pref: 1, + contexts: { private: true }, + }; + emails.e3 = { address: 'p3@x.example', pref: 50 }; + + const read = readContactCard({ id: 'many', emails }); + expect( + read?.kind === 'contact' && + read.card.emails.map((email) => email.address), + ).toEqual([ + 'p6@x.example', + 'p3@x.example', + 'p1@x.example', + 'p2@x.example', + 'p4@x.example', + 'p5@x.example', + 'p7@x.example', + ]); + expect(read?.kind === 'contact' && read.card.emails[0]?.label).toBe( + 'personal', + ); + }); + + it('종류 — 이메일은 contexts(work 회사 · private 개인 · 그 밖 기타), 전화는 features.mobile → contexts work · private → 기타', () => { + const read = readContactCard({ + id: 'k', + emails: { + a: { address: 'a@x.example', contexts: { work: true, private: true } }, + b: { address: 'b@x.example', contexts: { private: true } }, + c: { address: 'c@x.example', contexts: { billing: true } }, + }, + phones: { + a: { + number: '1', + features: { mobile: true }, + contexts: { work: true }, + }, + b: { number: '2', contexts: { work: true } }, + c: { number: '3', contexts: { private: true } }, + d: { number: '4', features: { fax: true } }, + }, + }); + + expect(read?.kind === 'contact' && read.card.emails).toEqual([ + { address: 'a@x.example', label: 'work' }, + { address: 'b@x.example', label: 'personal' }, + { address: 'c@x.example', label: 'other' }, + ]); + expect(read?.kind === 'contact' && read.card.phones).toEqual([ + { number: '1', label: 'mobile' }, + { number: '2', label: 'work' }, + { number: '3', label: 'home' }, + { number: '4', label: 'other' }, + ]); + }); + + it('다른 프로그램의 모양 — 이름 조각 · tel: 번호 · 문자열 부서 · role 직책 · 여러 메모(\\r\\n · 탭) · 모양이 틀린 값은 건너뛴다', () => { + const read = readContactCard({ + id: 'v', + name: { + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: '아라' }, + ], + }, + emails: { + a: { address: 42 }, + b: null, + c: { address: ' ara@x.example ' }, + }, + phones: { a: { number: 'tel:+82-10-1234-5678' }, b: { number: '' } }, + organizations: { o: { name: '한빛\t상사', units: ['영업팀'] } }, + titles: { + r: { name: '총무', kind: 'role' }, + t: { name: '대리' }, + }, + notes: { a: { note: '첫 줄\r\n둘째\t줄' }, b: { note: '다른 메모' } }, + }); + + expect(read).toEqual({ + kind: 'contact', + card: { + id: 'v', + uid: null, + name: '김아라', + emails: [{ address: 'ara@x.example', label: 'other' }], + phones: [{ number: '+82-10-1234-5678', label: 'other' }], + company: '한빛 상사', + department: '영업팀', + title: '대리', + note: '첫 줄\n둘째 줄\n다른 메모', + }, + }); + }); + + it.each<[string, Record, string]>([ + [ + '한글 조각끼리는 붙여 쓴다', + { + components: [ + { kind: 'surname', value: '남궁' }, + { kind: 'given', value: '민수' }, + ], + }, + '남궁민수', + ], + [ + '로마자 조각은 띄어 쓴다', + { + components: [ + { kind: 'given', value: 'Ara' }, + { kind: 'surname', value: 'Kim' }, + ], + }, + 'Ara Kim', + ], + [ + '한글과 로마자 사이는 띄어 쓴다', + { + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: 'Ara' }, + ], + }, + '김 Ara', + ], + [ + 'separator 조각은 그 글 그대로 (기본 구분보다 먼저)', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'surname', value: '김' }, + { kind: 'separator', value: ', ' }, + { kind: 'given', value: '아라' }, + ], + }, + '김, 아라', + ], + [ + 'defaultSeparator 가 있으면 한글끼리도 그것으로', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: '아라' }, + ], + }, + '김 아라', + ], + [ + '빈 separator 는 붙여 쓰기 · 앞뒤의 separator 와 빈 조각은 버린다', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'separator', value: '-' }, + { kind: 'given', value: 'Jean' }, + { kind: 'separator', value: '' }, + { kind: 'given2', value: 'Luc' }, + { kind: 'title', value: ' ' }, + { kind: 'separator', value: '!' }, + ], + }, + 'JeanLuc', + ], + [ + 'full 이 있으면 조각은 보지 않는다', + { full: '김아라 과장', components: [{ kind: 'given', value: '아라' }] }, + '김아라 과장', + ], + ])('이름 조각만 있는 이름 — %s', (_label, name, expected) => { + expect(readContactCard({ id: 'n', name })?.card.name).toBe(expected); + }); + + it('그룹 — 이름과 구성원(true 인 uid 만). 구성원을 모두 빼면 엔진은 빈 members 를 준다', () => { + expect( + readContactCard({ + id: 'g', + uid: 'urn:uuid:g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:a': true, 'urn:uuid:b': false, ' ': true }, + }), + ).toEqual({ + kind: 'group', + card: { + id: 'g', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:a'], + }, + }); + expect( + readContactCard({ id: 'e', kind: 'group', members: {} }), + ).toMatchObject({ card: { name: '', memberUids: [] } }); + }); +}); + +describe('JSContact 카드 쓰기 (newCardJson · cardPatch)', () => { + it('새 연락처 — @type · version · uid · kind · 기본 주소록, 대표 주소만 pref 1, 종류는 contexts · features', () => { + expect( + newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields, + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:new', + addressBookIds: { book: true }, + kind: 'individual', + name: { full: '박지훈' }, + emails: { + e1: { + address: 'jihoon@hanbit.example', + contexts: { work: true }, + pref: 1, + }, + e2: { address: 'jihoon@home.example', contexts: { private: true } }, + e3: { address: 'jihoon@old.example' }, + }, + phones: { + p1: { number: '010-3192-5540', features: { mobile: true } }, + p2: { number: '02-333-4444', contexts: { work: true } }, + p3: { number: '031-555-6666', contexts: { private: true } }, + p4: { number: '070-1111-2222' }, + }, + organizations: { + o1: { name: '한빛상사', units: [{ name: '구매팀' }] }, + }, + titles: { t1: { name: '과장', kind: 'title' } }, + notes: { n1: { note: '견적 담당\n월요일 오전' } }, + }); + }); + + it('새 연락처의 빈 칸은 보내지 않는다 — 부서만 있으면 소속은 부서만', () => { + expect( + newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + name: null, + emails: [{ address: 'a@x.example', label: 'other' }], + phones: [], + company: null, + department: '총무팀', + title: null, + note: null, + }, + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:new', + addressBookIds: { book: true }, + kind: 'individual', + emails: { e1: { address: 'a@x.example', pref: 1 } }, + organizations: { o1: { units: [{ name: '총무팀' }] } }, + }); + }); + + it('새 그룹 — kind group · 이름 · 구성원(uid 맵), 구성원이 없으면 members 를 보내지 않는다', () => { + expect( + newCardJson('book', { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:a', 'urn:uuid:b'], + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:g', + addressBookIds: { book: true }, + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:a': true, 'urn:uuid:b': true }, + }); + expect( + newCardJson('book', { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '빈 그룹', + memberUids: [], + }), + ).not.toHaveProperty('members'); + }); + + it('쓰고 다시 읽으면 같다 (왕복)', () => { + const json = newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields, + }); + expect(readContactCard({ ...json, id: 'n1' })).toEqual({ + kind: 'contact', + card: { id: 'n1', uid: 'urn:uuid:new', ...fields }, + }); + }); +}); + +describe('되돌리기 카드 (newCardJson 의 restored · cardOriginal)', () => { + /** 2026-10-29 개발 엔진이 properties: null 로 돌려준 카드 모양 (be-probe-r2.log — 다른 프로그램의 칸이 많은 카드) */ + const ORIGINAL = { + id: 'lm', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:orig', + kind: 'individual', + addressBookIds: { old: true }, + name: { full: '시험 원본', components: [{ kind: 'given', value: '원본' }] }, + nicknames: { k1: { name: '별명' } }, + emails: { x1: { address: 'one@x.example', label: '본사', pref: 1 } }, + anniversaries: { + b1: { + date: { '@type': 'PartialDate', month: 4, day: 26 }, + kind: 'birth', + }, + }, + keywords: { vip: true }, + }; + + it('원본에서 id 만 빼고 uid · 주소록을 바꾼다 — 우리가 다루지 않는 칸(별명 · 생일 · 키워드)도 그대로', () => { + const original = cardOriginal(ORIGINAL); + const { id: _id, ...rest } = ORIGINAL; + void _id; + expect( + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:new', + original, + memberUids: null, + card: { + kind: 'contact', + card: { + id: 'lm', + uid: 'urn:uuid:orig', + name: '시험 원본', + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }, + }), + ).toEqual({ ...rest, uid: 'urn:uuid:new', addressBookIds: { book: true } }); + }); + + it('그룹은 구성원을 넘긴 것으로 바꾼다 — 비면 members 를 뺀다', () => { + const original = cardOriginal({ + id: 'g', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:g', + kind: 'group', + addressBookIds: { book: true }, + name: { full: '거래처' }, + members: { 'urn:uuid:gone': true, 'urn:uuid:a': true }, + }); + const restored = (memberUids: string[]) => + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:g', + original, + memberUids, + card: { + kind: 'group', + card: { id: 'g', uid: 'urn:uuid:g', name: '거래처', memberUids: [] }, + }, + }); + expect(restored(['urn:uuid:a'])).toMatchObject({ + kind: 'group', + members: { 'urn:uuid:a': true }, + }); + expect(restored(['urn:uuid:a']).members).toEqual({ 'urn:uuid:a': true }); + expect(restored([])).not.toHaveProperty('members'); + }); + + it('원본의 크기는 UTF-8 바이트 — 한글은 세 바이트', () => { + expect(cardOriginal({ id: 'a', name: { full: '가' } }).bytes).toBe( + Buffer.byteLength('{"id":"a","name":{"full":"가"}}', 'utf8'), + ); + }); + + it('원본이 우리가 쓴 글이 아니면 MAIL_001 (서버 안의 잘못)', () => { + expect(() => + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:x', + original: { source: '[1,2]', bytes: 5 }, + memberUids: null, + card: { + kind: 'group', + card: { id: 'g', uid: null, name: '', memberUids: [] }, + }, + }), + ).toThrow(BusinessException); + }); +}); + +describe('연락처 고치기 (contactPatch — 칸 안의 남의 정보를 지킨다, 계약 3절 "고치기")', () => { + /** 다른 프로그램이 만든 카드 — 2026-10-29 개발 엔진에 넣고 다시 읽은 모양(be-probe-r2.log) */ + const RICH: Record = { + id: 'lm', + uid: 'urn:uuid:rich', + kind: 'individual', + name: { + full: '시험 원본', + components: [ + { kind: 'surname', value: '시험' }, + { kind: 'given', value: '원본' }, + ], + defaultSeparator: ' ', + isOrdered: true, + }, + emails: { + x1: { + address: 'one@x.example', + contexts: { work: true, billing: true }, + pref: 1, + label: '본사', + }, + x2: { address: 'two@x.example', contexts: { private: true }, pref: 3 }, + }, + phones: { + y1: { + number: 'tel:+82-2-333-4444', + contexts: { work: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + y2: { number: '010-1111-2222', features: { mobile: true, text: true } }, + }, + organizations: { + o1: { + name: '한빛상사', + units: [{ name: '구매팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + titles: { + t2: { name: '과장', kind: 'title', organizationId: 'o1' }, + t1: { name: '팀장', kind: 'role', organizationId: 'o1' }, + }, + notes: { + n1: { note: '첫 메모', author: { name: '누군가' } }, + n2: { note: '둘째 메모' }, + }, + }; + const contactOf = (card: Record) => { + const read = readContactCard(card); + if (read?.kind !== 'contact') throw new Error('연락처가 아니다'); + return read.card; + }; + + it('읽은 모양 — 대표 주소 · 팩스는 기타 · role 은 직책이 아니다 · 첫 조직 · 메모는 이어서', () => { + expect(contactOf(RICH)).toEqual({ + id: 'lm', + uid: 'urn:uuid:rich', + name: '시험 원본', + emails: [ + { address: 'one@x.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + phones: [ + { number: '+82-2-333-4444', label: 'work' }, + { number: '010-1111-2222', label: 'mobile' }, + ], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '첫 메모\n둘째 메모', + }); + }); + + it('이메일 — 같은 주소(대소문자 무시) 항목의 남의 속성을 남기고, 종류를 바꾼 것만 우리 값(다른 contexts 는 둔다) · 새 주소는 새 키 · 빠진 주소는 뺀다', () => { + const patch = contactPatch(RICH, { + emails: [ + { address: 'TWO@x.example', label: 'work' }, + { address: 'one@x.example', label: 'other' }, + { address: 'new@x.example', label: 'personal' }, + ], + }); + expect(patch).toEqual({ + emails: { + // 대표 주소가 된 둘째 — pref 1, 대소문자를 바꾼 주소는 보낸 대로, 종류만 work 로 + x2: { address: 'TWO@x.example', contexts: { work: true }, pref: 1 }, + // 첫째 — 종류를 other 로: work · private 만 빼고 billing · label 은 둔다. pref 1 은 첫 주소만이라 뺀다 + x1: { + address: 'one@x.example', + contexts: { billing: true }, + label: '본사', + }, + e1: { address: 'new@x.example', contexts: { private: true } }, + }, + }); + }); + + it('이메일을 그대로 다시 보내면 항목이 그대로다 (주소 · 종류 · 남의 속성 · pref)', () => { + expect( + contactPatch(RICH, { + emails: [ + { address: 'one@x.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + }), + ).toEqual({ emails: RICH.emails }); + }); + + it('pref — 첫 주소는 1, 나머지는 차례를 바꾸지 않는 것만 남긴다 (pref 없는 항목 뒤의 pref 도 뺀다)', () => { + const card = { + id: 'p', + emails: { + a: { address: 'a@x.example', pref: 2 }, + b: { address: 'b@x.example', pref: 5 }, + c: { address: 'c@x.example' }, + d: { address: 'd@x.example', pref: 9 }, + e: { address: 'e@x.example', pref: 7 }, + }, + }; + const patch = contactPatch(card, { + emails: ['b', 'a', 'e', 'c', 'd'].map((key) => ({ + address: `${key}@x.example`, + label: 'other' as const, + })), + }); + expect(patch.emails).toEqual({ + b: { address: 'b@x.example', pref: 1 }, + a: { address: 'a@x.example', pref: 2 }, + e: { address: 'e@x.example', pref: 7 }, + c: { address: 'c@x.example' }, + d: { address: 'd@x.example' }, + }); + // 다시 읽으면 보낸 차례 그대로 + expect( + contactOf(patchedCard(card, patch)).emails.map((email) => email.address), + ).toEqual(['b', 'a', 'e', 'c', 'd'].map((key) => `${key}@x.example`)); + }); + + it('전화 — 같은 번호(보인 글 → 같은 숫자) 항목의 fax · text · label 을 남기고 종류만 우리 값, 보인 번호를 그대로 받으면 tel: 글을 둔다', () => { + const patch = contactPatch(RICH, { + phones: [ + // 팩스 번호 — 종류를 집으로: contexts.work → private, features.fax · voice · label 은 둔다 + { number: '+82-2-333-4444', label: 'home' }, + // 휴대전화 번호를 띄어 써 보냄 — 같은 숫자라 짝, 종류를 회사로: features.mobile 만 빼고 text 는 둔다 + { number: '010 1111 2222', label: 'work' }, + { number: '031-555-6666', label: 'mobile' }, + ], + }); + expect(patch).toEqual({ + phones: { + y1: { + number: 'tel:+82-2-333-4444', + contexts: { private: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + y2: { + number: '010 1111 2222', + features: { text: true }, + contexts: { work: true }, + }, + p1: { number: '031-555-6666', features: { mobile: true } }, + }, + }); + }); + + it('직책 — role 이 아닌 첫 직책만 고치고(다른 속성은 둔다) role 은 그대로, 지우면 그 항목만 뺀다', () => { + expect(contactPatch(RICH, { title: '차장' })).toEqual({ + titles: { + t2: { name: '차장', kind: 'title', organizationId: 'o1' }, + t1: { name: '팀장', kind: 'role', organizationId: 'o1' }, + }, + }); + expect(contactPatch(RICH, { title: null })).toEqual({ + titles: { t1: { name: '팀장', kind: 'role', organizationId: 'o1' } }, + }); + // 직책이 없던 카드 — 새 항목 (role 은 그대로) + expect( + contactPatch( + { id: 'r', titles: { t1: { name: '팀장', kind: 'role' } } }, + { title: '대리' }, + ), + ).toEqual({ + titles: { + t1: { name: '팀장', kind: 'role' }, + t2: { name: '대리', kind: 'title' }, + }, + }); + expect(contactPatch({ id: 'e' }, { title: null })).toEqual({ + titles: null, + }); + }); + + it('회사 · 부서 — 첫 조직의 이름 · 첫 units 만(sortAs · 둘째 부서 · 둘째 조직은 둔다)', () => { + expect(contactPatch(RICH, { company: '한빛상사2' })).toEqual({ + organizations: { + o1: { + name: '한빛상사2', + units: [{ name: '구매팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + }); + expect(contactPatch(RICH, { department: '영업팀' })).toEqual({ + organizations: { + o1: { + name: '한빛상사', + units: [{ name: '영업팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + }); + // 부서를 지우면 첫 units 만 빠진다 + expect(contactPatch(RICH, { department: null })).toMatchObject({ + organizations: { o1: { units: [{ name: '1파트' }] } }, + }); + }); + + it('회사 · 부서를 둘 다 비우면 — 남은 것이 없는 조직은 빼고(둘째 조직은 그대로), 남은 칸이 있으면 둔다', () => { + const plain = { + id: 'o', + organizations: { + a: { '@type': 'Organization', name: '회사', units: [{ name: '부서' }] }, + b: { name: '다른 회사' }, + }, + }; + expect(contactPatch(plain, { company: null, department: null })).toEqual({ + organizations: { b: { name: '다른 회사' } }, + }); + expect( + contactPatch( + { id: 'o', organizations: { a: { name: '회사' } } }, + { company: null }, + ), + ).toEqual({ organizations: null }); + expect( + contactPatch(RICH, { company: null, department: null }), + ).toMatchObject({ + organizations: { + o1: { units: [{ name: '1파트' }], sortAs: 'HANBIT' }, + o2: { name: '두번째 회사' }, + }, + }); + // 조직이 없던 카드 — 새 조직 + expect( + contactPatch({ id: 'n' }, { company: '새 회사', department: '새 부서' }), + ).toEqual({ + organizations: { o1: { name: '새 회사', units: [{ name: '새 부서' }] } }, + }); + }); + + it('메모 — 여러 개였으면 첫 항목의 다른 속성(author)을 남기고 하나로, null 이면 칸을 뺀다', () => { + expect(contactPatch(RICH, { note: '하나로 쓴 메모' })).toEqual({ + notes: { n1: { note: '하나로 쓴 메모', author: { name: '누군가' } } }, + }); + expect(contactPatch(RICH, { note: null })).toEqual({ notes: null }); + expect(contactPatch({ id: 'n' }, { note: '새 메모' })).toEqual({ + notes: { n1: { note: '새 메모' } }, + }); + }); + + it('이름 — { full } 로 (이름 조각과 그에 딸린 칸은 지운다), null 이면 칸을 뺀다', () => { + expect(contactPatch(RICH, { name: '시험 고침' })).toEqual({ + name: { full: '시험 고침' }, + }); + expect(contactPatch(RICH, { name: null })).toEqual({ name: null }); + }); + + it('보내지 않은 칸은 패치에 없다 · 빈 목록은 칸을 뺀다', () => { + expect(contactPatch(RICH, {})).toEqual({}); + expect(contactPatch(RICH, { emails: [], phones: [] })).toEqual({ + emails: null, + phones: null, + }); + }); + + it('왕복 — 고친 카드를 다시 읽으면 보낸 칸 그대로이고, 다른 칸 · 칸 안의 남의 정보(fax · role · 둘째 조직 · label)는 남는다', () => { + const fields: Partial = { + name: '시험 고침', + emails: [ + { address: 'two@x.example', label: 'work' }, + { address: 'one@x.example', label: 'work' }, + ], + phones: [{ number: '+82-2-333-4444', label: 'home' }], + company: '새 회사', + title: '차장', + note: '고친 메모', + }; + const next = patchedCard( + { ...RICH, addresses: { a1: { countryCode: 'KR' } } }, + contactPatch(RICH, fields), + ); + expect(contactOf(next)).toEqual({ + id: 'lm', + uid: 'urn:uuid:rich', + name: '시험 고침', + emails: fields.emails, + phones: fields.phones, + company: '새 회사', + department: '구매팀', + title: '차장', + note: '고친 메모', + }); + expect(next.addresses).toEqual({ a1: { countryCode: 'KR' } }); + expect(next.phones).toEqual({ + y1: { + number: 'tel:+82-2-333-4444', + contexts: { private: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + }); + expect(next.titles).toMatchObject({ t1: { kind: 'role', name: '팀장' } }); + expect(next.organizations).toMatchObject({ o2: { name: '두번째 회사' } }); + expect(next.emails).toMatchObject({ + x1: { label: '본사', contexts: { work: true, billing: true } }, + }); + }); +}); + +describe('그룹 고치기 (groupPatch)', () => { + it('이름은 name 통째로, 구성원은 members 통째로(엔진은 구성원 없는 그룹에 members/ 패치를 받지 않는다), 비면 null', () => { + expect(groupPatch({ kind: 'group', id: 'g', name: '협력사' })).toEqual({ + name: { full: '협력사' }, + }); + expect( + groupPatch({ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }), + ).toEqual({ members: { 'urn:uuid:a': true } }); + expect(groupPatch({ kind: 'group', id: 'g', memberUids: [] })).toEqual({ + members: null, + }); + }); + + it('patchedCard — null 인 칸은 뺀다', () => { + expect( + patchedCard( + { id: 'g', name: { full: 'a' }, members: { x: true } }, + { + members: null, + name: { full: 'b' }, + }, + ), + ).toEqual({ id: 'g', name: { full: 'b' } }); + }); +}); diff --git a/backend/src/modules/mail/jmap/contact-card.ts b/backend/src/modules/mail/jmap/contact-card.ts new file mode 100644 index 0000000..ffb6a5f --- /dev/null +++ b/backend/src/modules/mail/jmap/contact-card.ts @@ -0,0 +1,777 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { + AddressBookCard, + AddressBookCardUpdate, + NewAddressBookCard, + RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; +import type { + ContactEmail, + ContactEmailLabel, + ContactFields, + ContactPhone, + ContactPhoneLabel, +} from '@/modules/contacts/contacts.types'; +import type { JmapContactCard } from './jmap.types'; + +/** + * JSContact 카드(RFC 9553 — 엔진의 ContactCard) ↔ 주소록 카드 (순수 함수) — 계약 3절 "엔진에 두는 모양" · "고치기" · "되돌리기". + * 이 파일의 함수 · 엔진 모양은 `modules/mail/jmap/` 밖으로 내보내지 않는다 (jmap-contacts.provider.ts 가 쓴다). + * + * 읽기 (다른 프로그램 · CardDAV 에서 온 카드도 — 모양이 맞지 않는 값은 건너뛴다): + * - kind 가 group 이면 그룹(구성원은 `members` 의 uid), 그 밖은 연락처 + * - 이름: name.full — 없으면 이름 조각(components)을 이어 붙인 것(조각 사이는 separator 조각 → defaultSeparator → 한글끼리는 붙이고 그 밖은 + * 띄어쓰기 — 김 + 아라 → 김아라). 엔진은 이름 없는 카드에 빈 full 을 준다 → null + * - 이메일 · 전화: pref(작을수록 먼저) → 엔진이 준 차례. 이메일 종류는 contexts(work 회사 · private 개인 · 그 밖 기타), + * 전화는 features.mobile 휴대전화 → contexts work 회사 · private 집 → 기타. 번호의 `tel:` 은 뗀다 + * - 소속: organizations 의 첫째 — name 이 회사, units 첫째가 부서. 직책: titles 에서 kind 가 role 이 아닌 첫째. 메모: notes 를 줄바꿈으로 잇는다 + * - 한 줄 칸은 제어 문자를 띄어쓰기로 바꾸고 앞뒤 공백을 뺀다. 메모는 줄바꿈을 \n 으로 맞추고 그 밖의 제어 문자는 띄어쓰기로 — 고치기 화면이 + * 받은 그대로 다시 보내도 요청 검증(contacts/dto/contact.dto.ts)을 통과하게 + * + * 만들기: `@type: Card` · `version: 1.0` · `uid` · `kind` · `addressBookIds`(기본 주소록) + 우리 칸. 대표 주소는 `pref: 1`, 키는 e1 · p1 … + * (엔진이 넣은 차례를 지킨다 — 2026-10-29 확인). 되돌리기는 지운 카드의 원본에서 id 만 빼고 uid · 주소록(그룹은 구성원도)만 바꿔 그대로 만든다 — + * 엔진은 읽은 카드 전체를 그대로 다시 받는다(id 말고 빼야 할 칸 없음 · 같은 주소록 안의 같은 uid 는 invalidProperties uid — 2026-10-29 확인) + * + * 고치기(계약 3절 "고치기"): 바뀐 칸만, 지금 카드와 합쳐 그 칸을 통째로 쓴다(맨 위 칸 — 엔진은 남의 속성을 실은 칸을 그대로 받는다, 2026-10-29 확인). + * - 이메일 · 전화: 보낸 주소(대소문자 무시) · 번호(같은 글 → 같은 숫자)를 지금 항목과 짝지어 그 항목의 다른 속성(label · 다른 contexts · fax 같은 features · + * pref)을 남기고, 종류를 바꾼 것만 우리 값(contexts work · private, features.mobile)으로 고친다. 대표 주소 `pref: 1` 은 첫 주소에만 — + * 다른 pref 는 그대로 두되 1 이거나 차례를 바꾸게 되면 뺀다(읽는 차례가 보낸 차례와 같게) + * - 직책: `kind: title`(role 이 아닌) 첫 항목 하나만. 회사 · 부서: 첫 조직의 이름과 첫 units 만 (둘 다 비고 남은 것이 없으면 그 조직을 뺀다). + * 메모: 여러 개였으면 하나로. 이름: name 을 `{ full }` 로 — 이름 조각(components)과 그에 딸린 칸은 지운다 + */ + +const JSCONTACT_TYPE = 'Card'; +const JSCONTACT_VERSION = '1.0'; +const GROUP_KIND = 'group'; +const INDIVIDUAL_KIND = 'individual'; +/** 직책 칸의 종류 — role(역할)은 직책이 아니다 */ +const ROLE_TITLE_KIND = 'role'; +const TITLE_KIND = 'title'; +/** 이름 조각의 종류 — 조각 사이에 넣는 글 */ +const SEPARATOR_KIND = 'separator'; +/** 객체의 종류 표시 — 이것만 남은 조직은 빈 조직이다 */ +const TYPE_PROPERTY = '@type'; + +/** 종류를 나타내는 contexts · features 의 키 — 종류를 바꿀 때 이것만 우리 값으로 바꾸고 다른 키는 둔다 */ +const WORK_CONTEXT = 'work'; +const PRIVATE_CONTEXT = 'private'; +const MOBILE_FEATURE = 'mobile'; + +/** 새 항목의 키 앞머리 — e1 · p1 · o1 · t1 · n1 */ +const EMAIL_KEY_PREFIX = 'e'; +const PHONE_KEY_PREFIX = 'p'; +const ORGANIZATION_KEY_PREFIX = 'o'; +const TITLE_KEY_PREFIX = 't'; +const NOTE_KEY_PREFIX = 'n'; + +/** 대표 주소의 pref */ +const PRIMARY_PREF = 1; + +/** 제어 문자 (C0 · DEL · C1) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g; +/** 메모에서 줄바꿈 말고 다른 제어 문자 */ +// eslint-disable-next-line no-control-regex +const NOTE_CONTROL_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g; +const TEL_SCHEME = /^tel:/i; +const NON_DIGITS = /\D+/g; +/** 한글 음절 — 이름 조각을 붙여 쓸지 정한다 */ +const HANGUL_SYLLABLE_START = 0xac00; +const HANGUL_SYLLABLE_END = 0xd7a3; + +type JsonObject = Record; + +function isRecord(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 한 줄 글 — 문자열이 아니거나 비면 null */ +function singleLine(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.replace(CONTROL_CHARACTERS, ' ').trim(); + return text === '' ? null : text; +} + +/** 여러 줄 글 (메모) — 줄바꿈은 \n 으로 맞추고 앞뒤 공백을 뺀다 */ +function multiLine(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value + .replace(/\r\n?/g, '\n') + .replace(NOTE_CONTROL_CHARACTERS, ' ') + .trim(); + return text === '' ? null : text; +} + +function prefOf(entry: JsonObject): number { + return typeof entry.pref === 'number' && Number.isFinite(entry.pref) + ? entry.pref + : Number.POSITIVE_INFINITY; +} + +/** 맵 칸(emails 등)의 항목 — 객체인 것만, pref(작을수록 먼저) → 엔진이 준 차례 */ +function preferredEntries(map: unknown): [string, JsonObject][] { + if (!isRecord(map)) return []; + return Object.entries(map) + .filter((entry): entry is [string, JsonObject] => isRecord(entry[1])) + .map((entry, index) => ({ entry, index })) + .sort( + (a, b) => prefOf(a.entry[1]) - prefOf(b.entry[1]) || a.index - b.index, + ) + .map(({ entry }) => entry); +} + +function flag(map: unknown, key: string): boolean { + return isRecord(map) && map[key] === true; +} + +function isHangulSyllable(char: string | undefined): boolean { + const code = char?.charCodeAt(0) ?? 0; + return code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END; +} + +/** 조각 사이의 기본 글 — 한글 음절끼리는 붙여 쓴다(김 + 아라 → 김아라), 그 밖은 띄어쓰기 */ +function joinerBetween(previous: string, next: string): string { + return isHangulSyllable(previous.at(-1)) && isHangulSyllable(next[0]) + ? '' + : ' '; +} + +/** 이름 조각을 이어 붙인다 — separator 조각이 있으면 그 글, 없으면 defaultSeparator, 그것도 없으면 joinerBetween */ +function joinNameComponents(name: JsonObject): string | null { + if (!Array.isArray(name.components)) return null; + const defaultSeparator = + typeof name.defaultSeparator === 'string' ? name.defaultSeparator : null; + let text = ''; + let previous: string | null = null; + let separator: string | null = null; + for (const component of name.components) { + if (!isRecord(component)) continue; + if (component.kind === SEPARATOR_KIND) { + if (typeof component.value === 'string') { + separator = (separator ?? '') + component.value; + } + continue; + } + const part = singleLine(component.value); + if (part === null) continue; + if (previous !== null) { + text += separator ?? defaultSeparator ?? joinerBetween(previous, part); + } + text += part; + previous = part; + separator = null; + } + return singleLine(text); +} + +function nameOf(name: unknown): string | null { + if (!isRecord(name)) return null; + return singleLine(name.full) ?? joinNameComponents(name); +} + +function emailLabelOf(contexts: unknown): ContactEmailLabel { + if (flag(contexts, WORK_CONTEXT)) return 'work'; + if (flag(contexts, PRIVATE_CONTEXT)) return 'personal'; + return 'other'; +} + +function phoneLabelOf(entry: JsonObject): ContactPhoneLabel { + if (flag(entry.features, MOBILE_FEATURE)) return 'mobile'; + if (flag(entry.contexts, WORK_CONTEXT)) return 'work'; + if (flag(entry.contexts, PRIVATE_CONTEXT)) return 'home'; + return 'other'; +} + +/** 읽은 주소 — 한 줄 글 */ +function emailAddressOf(entry: JsonObject): string | null { + return singleLine(entry.address); +} + +/** 읽은 번호 — 한 줄 글에서 `tel:` 을 뗀 것 */ +function phoneNumberOf(entry: JsonObject): string | null { + const raw = singleLine(entry.number); + return raw === null ? null : singleLine(raw.replace(TEL_SCHEME, '')); +} + +function emailsOf(map: unknown): ContactEmail[] { + return preferredEntries(map).flatMap(([, entry]) => { + const address = emailAddressOf(entry); + return address === null + ? [] + : [{ address, label: emailLabelOf(entry.contexts) }]; + }); +} + +function phonesOf(map: unknown): ContactPhone[] { + return preferredEntries(map).flatMap(([, entry]) => { + const number = phoneNumberOf(entry); + return number === null ? [] : [{ number, label: phoneLabelOf(entry) }]; + }); +} + +/** 첫째 조직 — 객체인 첫 값 (키와 함께) */ +function firstOrganization(map: unknown): [string, JsonObject] | undefined { + if (!isRecord(map)) return undefined; + return Object.entries(map).find((entry): entry is [string, JsonObject] => + isRecord(entry[1]), + ); +} + +function unitName(unit: unknown): string | null { + return singleLine(isRecord(unit) ? unit.name : unit); +} + +/** 소속 — 첫째 조직의 이름(회사)과 첫째 부서 */ +function organizationOf(map: unknown): { + company: string | null; + department: string | null; +} { + const first = firstOrganization(map)?.[1]; + if (!first) return { company: null, department: null }; + const unit: unknown = Array.isArray(first.units) ? first.units[0] : undefined; + return { company: singleLine(first.name), department: unitName(unit) }; +} + +/** 직책으로 읽는 항목 — role 이 아니고 이름이 있는 첫째 (키와 함께) */ +function titleEntry(map: unknown): [string, JsonObject] | undefined { + if (!isRecord(map)) return undefined; + return Object.entries(map).find( + (entry): entry is [string, JsonObject] => + isRecord(entry[1]) && + entry[1].kind !== ROLE_TITLE_KIND && + singleLine(entry[1].name) !== null, + ); +} + +function titleOf(map: unknown): string | null { + return singleLine(titleEntry(map)?.[1].name); +} + +function noteOf(map: unknown): string | null { + if (!isRecord(map)) return null; + const notes = Object.values(map) + .filter(isRecord) + .map((entry) => multiLine(entry.note)) + .filter((note): note is string => note !== null); + return notes.length > 0 ? notes.join('\n') : null; +} + +function membersOf(map: unknown): string[] { + if (!isRecord(map)) return []; + return Object.entries(map) + .filter(([uid, member]) => member === true && uid.trim() !== '') + .map(([uid]) => uid); +} + +function uidOf(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +/** 엔진의 카드 하나를 읽는다 — 카드가 아니거나 id 가 없으면 null */ +export function readContactCard( + card: JmapContactCard | JsonObject | null | undefined, +): AddressBookCard | null { + if ( + typeof card !== 'object' || + card === null || + typeof card.id !== 'string' || + card.id === '' + ) { + return null; + } + const uid = uidOf(card.uid); + + if (card.kind === GROUP_KIND) { + return { + kind: 'group', + card: { + id: card.id, + uid, + name: nameOf(card.name) ?? '', + memberUids: membersOf(card.members), + }, + }; + } + return { + kind: 'contact', + card: { + id: card.id, + uid, + name: nameOf(card.name), + emails: emailsOf(card.emails), + phones: phonesOf(card.phones), + ...organizationOf(card.organizations), + title: titleOf(card.titles), + note: noteOf(card.notes), + }, + }; +} + +// ── 만들기 ─────────────────────────────────────────────── + +function emailContexts(label: ContactEmailLabel): JsonObject | undefined { + if (label === 'work') return { [WORK_CONTEXT]: true }; + if (label === 'personal') return { [PRIVATE_CONTEXT]: true }; + return undefined; +} + +function newEmailEntry(email: ContactEmail): JsonObject { + const contexts = emailContexts(email.label); + return { address: email.address, ...(contexts ? { contexts } : {}) }; +} + +function newPhoneEntry(phone: ContactPhone): JsonObject { + return { number: phone.number, ...phoneTypeKeys(phone.label) }; +} + +/** 전화 종류의 우리 값 — 휴대전화는 features.mobile, 회사 · 집은 contexts work · private */ +function phoneTypeKeys(label: ContactPhoneLabel): { + features?: JsonObject; + contexts?: JsonObject; +} { + if (label === 'mobile') return { features: { [MOBILE_FEATURE]: true } }; + if (label === 'work') return { contexts: { [WORK_CONTEXT]: true } }; + if (label === 'home') return { contexts: { [PRIVATE_CONTEXT]: true } }; + return {}; +} + +function newOrganization( + company: string | null, + department: string | null, +): JsonObject { + return { + ...(company !== null ? { name: company } : {}), + ...(department !== null ? { units: [{ name: department }] } : {}), + }; +} + +/** 새 연락처의 우리 칸 (빈 칸은 빼고) */ +function newContactProperties(fields: ContactFields): JsonObject { + const properties: JsonObject = {}; + if (fields.name !== null) properties.name = { full: fields.name }; + if (fields.emails.length > 0) { + properties.emails = Object.fromEntries( + fields.emails.map((email, index) => [ + `${EMAIL_KEY_PREFIX}${index + 1}`, + { + ...newEmailEntry(email), + // 대표 주소 — 다른 프로그램도 이것을 먼저 보인다 + ...(index === 0 ? { pref: PRIMARY_PREF } : {}), + }, + ]), + ); + } + if (fields.phones.length > 0) { + properties.phones = Object.fromEntries( + fields.phones.map((phone, index) => [ + `${PHONE_KEY_PREFIX}${index + 1}`, + newPhoneEntry(phone), + ]), + ); + } + if (fields.company !== null || fields.department !== null) { + properties.organizations = { + [`${ORGANIZATION_KEY_PREFIX}1`]: newOrganization( + fields.company, + fields.department, + ), + }; + } + if (fields.title !== null) { + properties.titles = { + [`${TITLE_KEY_PREFIX}1`]: { name: fields.title, kind: TITLE_KIND }, + }; + } + if (fields.note !== null) { + properties.notes = { [`${NOTE_KEY_PREFIX}1`]: { note: fields.note } }; + } + return properties; +} + +function membersJson(uids: readonly string[]): Record | null { + return uids.length === 0 + ? null + : Object.fromEntries(uids.map((uid) => [uid, true as const])); +} + +/** 지운 카드의 원본을 읽는다 — 우리가 쓴 글만 받는다(아니면 서버 안의 잘못) */ +function parseOriginal(original: RemovedCardOriginal): JsonObject { + let parsed: unknown; + try { + parsed = JSON.parse(original.source); + } catch { + parsed = null; + } + if (!isRecord(parsed)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'restore-original-malformed' }, + }); + } + return parsed; +} + +/** 만들 카드 — 기본 주소록에 (ContactCard/set 의 create) */ +export function newCardJson( + addressBookId: string, + card: NewAddressBookCard, +): JsonObject { + const addressBookIds = { [addressBookId]: true }; + if (card.kind === 'restored') { + // 원본 그대로 — 엔진이 주는 id 만 빼고 uid · 주소록(그룹은 구성원도)을 바꾼다 + const restored: JsonObject = { ...parseOriginal(card.original) }; + delete restored.id; + restored.uid = card.uid; + restored.addressBookIds = addressBookIds; + if (card.memberUids !== null) { + const members = membersJson(card.memberUids); + if (members === null) delete restored.members; + else restored.members = members; + } + return restored; + } + const base = { + '@type': JSCONTACT_TYPE, + version: JSCONTACT_VERSION, + uid: card.uid, + addressBookIds, + }; + if (card.kind === 'group') { + const members = membersJson(card.memberUids); + return { + ...base, + kind: GROUP_KIND, + name: { full: card.name }, + ...(members !== null ? { members } : {}), + }; + } + return { + ...base, + kind: INDIVIDUAL_KIND, + ...newContactProperties(card.fields), + }; +} + +/** 지운 카드의 원본 — 되돌리기가 그대로 다시 만든다 (엔진 카드 전체) */ +export function cardOriginal(card: JsonObject): RemovedCardOriginal { + const source = JSON.stringify(card); + return { source, bytes: Buffer.byteLength(source, 'utf8') }; +} + +// ── 고치기 ─────────────────────────────────────────────── + +/** 이미 쓴 키와 겹치지 않는 새 키 — 앞머리 + 1, 2, … */ +function freshKeys(prefix: string, used: Set): () => string { + let next = 1; + return () => { + while (used.has(`${prefix}${next}`)) next += 1; + const key = `${prefix}${next}`; + used.add(key); + return key; + }; +} + +/** 키를 뺀 객체 — 비면 undefined */ +function without( + map: unknown, + keys: readonly string[], +): JsonObject | undefined { + if (!isRecord(map)) return undefined; + const rest = Object.fromEntries( + Object.entries(map).filter(([key]) => !keys.includes(key)), + ); + return Object.keys(rest).length > 0 ? rest : undefined; +} + +/** 객체에 값을 넣거나(값이 있으면) 뺀다(undefined) */ +function assign(target: JsonObject, key: string, value: unknown): void { + if (value === undefined) delete target[key]; + else target[key] = value; +} + +/** 다른 contexts 는 두고 종류(work · private)만 우리 값으로 */ +function relabeledEmail(entry: JsonObject, label: ContactEmailLabel): void { + const ours = emailContexts(label); + const rest = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]); + const contexts = rest || ours ? { ...rest, ...ours } : undefined; + assign(entry, 'contexts', contexts); +} + +/** 다른 features · contexts 는 두고 종류(features.mobile · contexts work · private)만 우리 값으로 */ +function relabeledPhone(entry: JsonObject, label: ContactPhoneLabel): void { + const ours = phoneTypeKeys(label); + const features = without(entry.features, [MOBILE_FEATURE]); + const contexts = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]); + assign( + entry, + 'features', + features || ours.features ? { ...features, ...ours.features } : undefined, + ); + assign( + entry, + 'contexts', + contexts || ours.contexts ? { ...contexts, ...ours.contexts } : undefined, + ); +} + +/** + * pref 를 보낸 차례에 맞춘다 — first 가 있으면 첫 항목은 그 값(대표 주소 1)이고 다른 항목의 그 값은 뺀다. + * 나머지 pref 는 앞 항목보다 작지 않을 때만 남긴다(pref 가 없는 항목 뒤의 pref 도 뺀다) — 읽는 차례(pref → 맵 차례)가 보낸 차례와 같게 + */ +function orderPrefs( + entries: readonly JsonObject[], + first: number | null, +): void { + /** 앞 항목의 pref — 아직 없으면 null, 앞에 pref 없는 항목이 있으면 +∞ */ + let last: number | null = null; + entries.forEach((entry, index) => { + if (index === 0 && first !== null) { + entry.pref = first; + last = first; + return; + } + const pref = prefOf(entry); + const keep = + Number.isFinite(pref) && + pref !== first && + (last === null || pref >= last); + if (keep) { + last = pref; + } else { + delete entry.pref; + last = Number.POSITIVE_INFINITY; + } + }); +} + +/** 보낸 것마다 짝이 되는 지금 항목을 찾는다 — 한 항목은 한 번만. matchers 차례로(앞의 것이 더 엄격하다) */ +function pairEntries( + sent: readonly T[], + current: [string, JsonObject][], + matchers: readonly ((item: T, entry: JsonObject) => boolean)[], +): ([string, JsonObject] | undefined)[] { + const taken = new Set(); + const pairs: ([string, JsonObject] | undefined)[] = sent.map(() => undefined); + for (const matches of matchers) { + sent.forEach((item, index) => { + if (pairs[index]) return; + const found = current.find( + ([key, entry]) => !taken.has(key) && matches(item, entry), + ); + if (found) { + taken.add(found[0]); + pairs[index] = found; + } + }); + } + return pairs; +} + +function emailKey(address: string): string { + return address.trim().toLowerCase(); +} + +function mergedEmails( + current: unknown, + emails: readonly ContactEmail[], +): JsonObject | null { + if (emails.length === 0) return null; + const entries = preferredEntries(current); + const pairs = pairEntries(emails, entries, [ + (email, entry) => emailAddressOf(entry) === email.address, + (email, entry) => { + const address = emailAddressOf(entry); + return address !== null && emailKey(address) === emailKey(email.address); + }, + ]); + const keyOf = freshKeys( + EMAIL_KEY_PREFIX, + new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))), + ); + const merged = emails.map((email, index): [string, JsonObject] => { + const pair = pairs[index]; + if (!pair) return [keyOf(), newEmailEntry(email)]; + const entry = { ...pair[1] }; + if (emailAddressOf(entry) !== email.address) entry.address = email.address; + if (emailLabelOf(entry.contexts) !== email.label) { + relabeledEmail(entry, email.label); + } + return [pair[0], entry]; + }); + orderPrefs( + merged.map(([, entry]) => entry), + PRIMARY_PREF, + ); + return Object.fromEntries(merged); +} + +function digitsOf(text: string): string { + return text.replace(NON_DIGITS, ''); +} + +function mergedPhones( + current: unknown, + phones: readonly ContactPhone[], +): JsonObject | null { + if (phones.length === 0) return null; + const entries = preferredEntries(current); + const pairs = pairEntries(phones, entries, [ + (phone, entry) => phoneNumberOf(entry) === phone.number, + (phone, entry) => { + const number = phoneNumberOf(entry); + const digits = digitsOf(phone.number); + return number !== null && digits !== '' && digitsOf(number) === digits; + }, + ]); + const keyOf = freshKeys( + PHONE_KEY_PREFIX, + new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))), + ); + const merged = phones.map((phone, index): [string, JsonObject] => { + const pair = pairs[index]; + if (!pair) return [keyOf(), newPhoneEntry(phone)]; + const entry = { ...pair[1] }; + // 보인 번호를 그대로 돌려받았으면 엔진의 글(tel: 등)을 그대로 둔다 + if (phoneNumberOf(entry) !== phone.number) entry.number = phone.number; + if (phoneLabelOf(entry) !== phone.label) relabeledPhone(entry, phone.label); + return [pair[0], entry]; + }); + orderPrefs( + merged.map(([, entry]) => entry), + null, + ); + return Object.fromEntries(merged); +} + +/** 회사 · 부서 — 첫 조직의 이름과 첫 units 만. 둘 다 비고 남은 것이 없으면 그 조직을 뺀다 */ +function mergedOrganizations( + current: unknown, + change: { + readonly company?: string | null; + readonly department?: string | null; + }, +): JsonObject | null { + const map: JsonObject = isRecord(current) ? { ...current } : {}; + const first = firstOrganization(map); + if (first) { + const [key, organization] = first; + const next = { ...organization }; + if (change.company !== undefined) + assign(next, 'name', change.company ?? undefined); + if (change.department !== undefined) { + const units: unknown[] = Array.isArray(next.units) + ? [...(next.units as unknown[])] + : []; + if (change.department === null) { + units.shift(); + } else { + const unit = units[0]; + units[0] = isRecord(unit) + ? { ...unit, name: change.department } + : { name: change.department }; + } + assign(next, 'units', units.length > 0 ? units : undefined); + } + const remains = Object.keys(next).some((name) => name !== TYPE_PROPERTY); + if (remains) map[key] = next; + else delete map[key]; + } else { + const company = change.company ?? null; + const department = change.department ?? null; + if (company !== null || department !== null) { + const keyOf = freshKeys( + ORGANIZATION_KEY_PREFIX, + new Set(Object.keys(map)), + ); + map[keyOf()] = newOrganization(company, department); + } + } + return Object.keys(map).length > 0 ? map : null; +} + +/** 직책 — 직책으로 읽는 항목 하나만 (role 등 다른 항목은 그대로) */ +function mergedTitles( + current: unknown, + title: string | null, +): JsonObject | null { + const map: JsonObject = isRecord(current) ? { ...current } : {}; + const entry = titleEntry(map); + if (entry) { + const [key, value] = entry; + if (title === null) delete map[key]; + else map[key] = { ...value, name: title }; + } else if (title !== null) { + const keyOf = freshKeys(TITLE_KEY_PREFIX, new Set(Object.keys(map))); + map[keyOf()] = { name: title, kind: TITLE_KIND }; + } + return Object.keys(map).length > 0 ? map : null; +} + +/** 메모 — 하나로 (여러 개였으면 첫 항목의 다른 속성을 남기고 글만 바꾼다) */ +function mergedNotes(current: unknown, note: string | null): JsonObject | null { + if (note === null) return null; + const first = isRecord(current) + ? Object.entries(current).find((entry): entry is [string, JsonObject] => + isRecord(entry[1]), + ) + : undefined; + return first + ? { [first[0]]: { ...first[1], note } } + : { [`${NOTE_KEY_PREFIX}1`]: { note } }; +} + +/** + * 연락처 고치기 — 바뀐 칸(fields 에 있는 것)만, 지금 카드(current — 그 칸들을 읽은 것)와 합쳐 그 칸을 통째로 쓰는 패치. + * 값이 null 인 칸은 카드에서 뺀다 + */ +export function contactPatch( + current: JsonObject, + fields: Partial, +): JsonObject { + const patch: JsonObject = {}; + if (fields.name !== undefined) { + patch.name = fields.name === null ? null : { full: fields.name }; + } + if (fields.emails !== undefined) { + patch.emails = mergedEmails(current.emails, fields.emails); + } + if (fields.phones !== undefined) { + patch.phones = mergedPhones(current.phones, fields.phones); + } + if (fields.company !== undefined || fields.department !== undefined) { + patch.organizations = mergedOrganizations(current.organizations, { + ...(fields.company !== undefined ? { company: fields.company } : {}), + ...(fields.department !== undefined + ? { department: fields.department } + : {}), + }); + } + if (fields.title !== undefined) { + patch.titles = mergedTitles(current.titles, fields.title); + } + if (fields.note !== undefined) { + patch.notes = mergedNotes(current.notes, fields.note); + } + return patch; +} + +/** 그룹 고치기 — 이름 · 구성원을 통째로 */ +export function groupPatch( + update: Extract, +): JsonObject { + return { + ...(update.name !== undefined ? { name: { full: update.name } } : {}), + ...(update.memberUids !== undefined + ? { members: membersJson(update.memberUids) } + : {}), + }; +} + +/** 맨 위 칸 패치를 카드에 옮긴 모양 (null 은 칸을 뺀다) — 고친 뒤의 카드를 읽는 데 쓴다 */ +export function patchedCard(card: JsonObject, patch: JsonObject): JsonObject { + const next = { ...card }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts new file mode 100644 index 0000000..bc8dd9f --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts @@ -0,0 +1,826 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { AddressBookChanges } from '@/modules/contacts/contacts.provider'; +import type { JmapClient } from './jmap.client'; +import { + ADDRESS_BOOK_READ_MAX_CARDS, + JmapContactsProvider, +} from './jmap-contacts.provider'; +import type { JmapContactCard, JmapInvocation } from './jmap.types'; + +const ARA = 'ara.kim@arasystem.test'; +const CONTACTS = 'urn:ietf:params:jmap:contacts'; + +interface FakeBook { + id: string; + isDefault?: boolean; +} + +/** + * 대역 엔진 — 주소록(AddressBook) · 카드(ContactCard) · 상태(ifInState)만 흉내 낸다 + * (JMAP for Contacts — Stalwart 0.16.27 에서 확인한 모양: 지난 ifInState 는 메서드 오류 stateMismatch) + */ +class FakeContactsEngine { + state = 10; + books: FakeBook[] = [{ id: 'book', isDefault: true }]; + /** 카드 id → 담긴 주소록 · 카드 칸 */ + readonly cards = new Map< + string, + { book: string; card: Record } + >(); + readonly requests: { calls: JmapInvocation[]; options: unknown }[] = []; + /** 다음 ContactCard/set 의 응답을 대신한다 */ + setReply: ((args: Record) => JmapInvocation) | null = null; + private nextId = 1; + + readonly client = { + call: jest.fn( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + const calls = build('acc'); + this.requests.push({ calls, options }); + const responses: JmapInvocation[] = []; + for (const call of calls) responses.push(this.handle(call, responses)); + return Promise.resolve(responses); + }, + ), + }; + + add(card: JmapContactCard, book = 'book'): void { + this.cards.set(card.id, { book, card: { ...card } }); + } + + cardOf(id: string): Record | undefined { + return this.cards.get(id)?.card; + } + + private handle( + [method, args, callId]: JmapInvocation, + previous: JmapInvocation[], + ): JmapInvocation { + switch (method) { + case 'AddressBook/get': + return [ + method, + { state: 'books', list: this.books.map((book) => ({ ...book })) }, + callId, + ]; + case 'ContactCard/query': { + const filter = args.filter as { inAddressBook?: string }; + const ids = [...this.cards.entries()] + .filter(([, stored]) => stored.book === filter.inAddressBook) + .map(([id]) => id); + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? ids.length); + return [ + method, + { + ids: ids.slice(position, position + limit), + position, + ...(args.calculateTotal === true ? { total: ids.length } : {}), + }, + callId, + ]; + } + case 'ContactCard/get': { + const reference = args['#ids'] as { resultOf: string } | undefined; + const ids = reference + ? ((previous.find(([, , id]) => id === reference.resultOf)?.[1] + .ids as string[]) ?? []) + : (args.ids as string[]); + return [ + method, + { + state: `s${this.state}`, + list: ids.flatMap((id) => { + const card = this.cardOf(id); + return card ? [{ ...card }] : []; + }), + notFound: ids.filter((id) => !this.cardOf(id)), + }, + callId, + ]; + } + case 'ContactCard/set': + return this.set(args, callId); + default: + return ['error', { type: 'unknownMethod' }, callId]; + } + } + + private set(args: Record, callId: string): JmapInvocation { + if (this.setReply) { + const reply = this.setReply(args); + this.setReply = null; + return reply; + } + if (args.ifInState !== `s${this.state}`) { + return ['error', { type: 'stateMismatch' }, callId]; + } + const oldState = `s${this.state}`; + const created: Record = {}; + for (const [key, card] of Object.entries( + (args.create as Record>) ?? {}, + )) { + const id = `n${this.nextId++}`; + const books = Object.keys(card.addressBookIds as Record); + this.add({ ...card, id }, books[0]); + created[key] = { id }; + } + const updated: Record = {}; + for (const [id, patch] of Object.entries( + (args.update as Record>) ?? {}, + )) { + const card = this.cardOf(id); + if (card) { + Object.assign(card, patch); + updated[id] = null; + } + } + for (const id of (args.destroy as string[]) ?? []) this.cards.delete(id); + this.state += 1; + return [ + 'ContactCard/set', + { oldState, newState: `s${this.state}`, created, updated }, + callId, + ]; + } +} + +const version = (book: string | null, state: string) => + JSON.stringify([book, state]); + +const noChanges: AddressBookChanges = { create: [], update: [], destroy: [] }; + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error(`BusinessException 이 아니다: ${String(error)}`); + } + return error; +} + +describe('JmapContactsProvider (엔진 주소록)', () => { + let engine: FakeContactsEngine; + let provider: JmapContactsProvider; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + engine = new FakeContactsEngine(); + provider = new JmapContactsProvider(engine.client as unknown as JmapClient); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('판 (getAddressBookVersion)', () => { + it('기본 주소록 id 와 카드 상태 — 한 요청, 카드는 읽지 않는다(ids: []), contacts 기능을 선언한다', async () => { + engine.books = [{ id: 'shared' }, { id: 'mine', isDefault: true }]; + + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version('mine', 's10'), + ); + const [request] = engine.requests; + expect(request?.calls.map(([method]) => method)).toEqual([ + 'AddressBook/get', + 'ContactCard/get', + ]); + expect(request?.calls[1]?.[1]).toMatchObject({ ids: [] }); + expect(request?.options).toEqual({ capabilities: [CONTACTS] }); + }); + + it('기본 주소록이 없으면 첫 주소록, 주소록이 없으면 null — 카드가 바뀌면 판이 달라진다', async () => { + engine.books = [{ id: 'first' }, { id: 'second' }]; + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version('first', 's10'), + ); + engine.books = []; + engine.state = 11; + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version(null, 's11'), + ); + }); + + it('엔진이 상태를 알려 주지 않으면 MAIL_001', async () => { + engine.client.call.mockResolvedValueOnce([ + ['AddressBook/get', { list: [] }, 'b'], + ['ContactCard/get', { list: [] }, 's'], + ]); + expect((await failureOf(provider.getAddressBookVersion(ARA))).code).toBe( + 'MAIL_001', + ); + }); + }); + + describe('읽기 (readAddressBook)', () => { + it('기본 주소록의 카드만 500장씩 — 연락처와 그룹으로 나누고, 판은 읽기 전에 물은 것이다', async () => { + for (let index = 0; index < 1_200; index += 1) { + engine.add({ + id: `c${index}`, + uid: `urn:uuid:c${index}`, + kind: 'individual', + name: { full: `연락처 ${index}` }, + }); + } + engine.add({ + id: 'g', + uid: 'urn:uuid:g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:c1': true }, + }); + engine.add({ id: 'elsewhere', name: { full: '다른 주소록' } }, 'other'); + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.version).toBe(version('book', 's10')); + expect(snapshot.contacts).toHaveLength(1_200); + expect(snapshot.groups).toEqual([ + { + id: 'g', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:c1'], + }, + ]); + const queries = engine.requests + .flatMap((request) => request.calls) + .filter(([method]) => method === 'ContactCard/query'); + expect(queries.map(([, args]) => [args.position, args.limit])).toEqual([ + [0, 500], + [500, 500], + [1_000, 500], + ]); + expect(queries[0]?.[1]).toMatchObject({ + filter: { inAddressBook: 'book' }, + calculateTotal: true, + }); + // 사진 등 큰 칸은 읽지 않는다 + const get = engine.requests[1]?.calls[1]?.[1]; + expect(get?.properties).not.toContain('media'); + }); + + it('읽는 사이 차례가 밀려 같은 카드가 두 번 와도 한 번만 넣는다', async () => { + for (let index = 0; index < 600; index += 1) { + engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } }); + } + // 첫 차례(판을 읽은 다음 요청)를 읽은 뒤 맨 앞에 카드가 하나 생긴다 — 둘째 차례의 첫 카드는 이미 읽은 것이다 + const original = engine.client.call.getMockImplementation(); + let requests = 0; + engine.client.call.mockImplementation((user, build, options) => { + const result = original?.(user, build, options); + requests += 1; + if (requests === 2) { + const rest = [...engine.cards.entries()]; + engine.cards.clear(); + engine.add({ id: 'new', name: { full: '새 카드' } }); + for (const [id, stored] of rest) engine.cards.set(id, stored); + } + return result ?? Promise.resolve([]); + }); + + const snapshot = await provider.readAddressBook(ARA); + + const ids = snapshot.contacts.map((card) => card.id); + expect(ids).toHaveLength(600); + expect(new Set(ids).size).toBe(600); + expect(ids).not.toContain('new'); + }); + + it('판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 카드가 생겨 상태가 올라도 옛 판으로 둔다(다음 요청이 판이 달라 다시 읽는다)', async () => { + for (let index = 0; index < 600; index += 1) { + engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } }); + } + const original = engine.client.call.getMockImplementation(); + let requests = 0; + engine.client.call.mockImplementation((user, build, options) => { + requests += 1; + // 판을 물은 뒤 · 첫 차례를 읽기 전에 다른 곳에서 카드를 만든다 + if (requests === 2) { + engine.add({ id: 'late', name: { full: '늦게 온 카드' } }); + engine.state += 1; + } + return original?.(user, build, options) ?? Promise.resolve([]); + }); + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.version).toBe(version('book', 's10')); + await expect(provider.getAddressBookVersion(ARA)).resolves.not.toBe( + snapshot.version, + ); + }); + + it('주소록이 없으면 빈 주소록 (카드를 묻지 않는다)', async () => { + engine.books = []; + await expect(provider.readAddressBook(ARA)).resolves.toEqual({ + version: version(null, 's10'), + contacts: [], + groups: [], + }); + expect(engine.requests).toHaveLength(1); + }); + + it(`${ADDRESS_BOOK_READ_MAX_CARDS}장을 넘으면 앞의 그만큼만 읽고 warn 로그(개수만)`, async () => { + for (let index = 0; index < ADDRESS_BOOK_READ_MAX_CARDS + 3; index += 1) { + engine.add({ id: `c${index}`, name: { full: `비밀 이름 ${index}` } }); + } + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.contacts).toHaveLength(ADDRESS_BOOK_READ_MAX_CARDS); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain(`카드 ${ADDRESS_BOOK_READ_MAX_CARDS + 3}장`); + expect(logged).not.toContain('비밀 이름'); + expect(logged).not.toContain(ARA); + }); + }); + + describe('쓰기 (writeAddressBook)', () => { + const newPerson = { + name: '새 사람', + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }; + + it('고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(한 요청) ContactCard/set 하나에 만들기 · 고치기 · 지우기 — 새 판 · 만든 id · 카드 모양 · 지운 원본을 준다', async () => { + engine.add({ + id: 'c1', + uid: 'urn:uuid:c1', + kind: 'individual', + name: { full: '하나' }, + anniversaries: { a: { kind: 'birth' } }, + } as JmapContactCard); + engine.add({ + id: 'c2', + uid: 'urn:uuid:c2', + name: { full: '둘' }, + photos: { p: { href: 'https://x.example/p.jpg' } }, + } as JmapContactCard); + engine.add({ + id: 'g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:c1': true }, + }); + + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: newPerson, + }, + ], + update: [ + { kind: 'contact', id: 'c1', fields: { name: '고친 이름' } }, + { + kind: 'group', + id: 'g', + memberUids: ['urn:uuid:c1', 'urn:uuid:new'], + }, + ], + destroy: ['c2'], + }, + ); + + // 먼저 읽기 — 고칠 연락처는 우리 칸만, 지울 카드는 전체(properties: null) + const [read, write] = engine.requests; + expect(read?.calls.map(([method, args]) => [method, args.ids])).toEqual([ + ['ContactCard/get', ['c1']], + ['ContactCard/get', ['c2']], + ]); + expect(read?.calls[0]?.[1].properties).toEqual( + expect.arrayContaining(['name', 'emails', 'organizations']), + ); + expect(read?.calls[1]?.[1].properties).toBeNull(); + + const [call] = write?.calls ?? []; + expect(call?.[0]).toBe('ContactCard/set'); + expect(call?.[1]).toMatchObject({ + ifInState: 's10', + create: { + contact: { + '@type': 'Card', + uid: 'urn:uuid:new', + kind: 'individual', + addressBookIds: { book: true }, + name: { full: '새 사람' }, + }, + }, + update: { + c1: { name: { full: '고친 이름' } }, + g: { members: { 'urn:uuid:c1': true, 'urn:uuid:new': true } }, + }, + destroy: ['c2'], + }); + // 고친 칸만 보낸다 — 다른 칸(생일)은 엔진에 그대로 + expect(engine.cardOf('c1')).toMatchObject({ + name: { full: '고친 이름' }, + anniversaries: { a: { kind: 'birth' } }, + }); + expect(write?.options).toEqual({ capabilities: [CONTACTS] }); + + if (outcome.kind !== 'written') throw new Error('written 이 아니다'); + expect(outcome.version).toBe(version('book', 's11')); + expect(outcome.createdIds).toEqual(new Map([['contact', 'n1']])); + // 만들거나 고친 연락처의 모양 (그룹 고치기는 넣지 않는다) + expect([...outcome.cards.keys()].sort()).toEqual(['c1', 'n1']); + expect(outcome.cards.get('c1')).toMatchObject({ + kind: 'contact', + card: { id: 'c1', name: '고친 이름', uid: 'urn:uuid:c1' }, + }); + expect(outcome.cards.get('n1')).toMatchObject({ + kind: 'contact', + card: { id: 'n1', uid: 'urn:uuid:new', name: '새 사람' }, + }); + // 지운 카드의 원본 — 카드 전체(사진 칸 포함) + const original = outcome.removed.get('c2'); + expect(JSON.parse(original?.source ?? 'null')).toEqual({ + id: 'c2', + uid: 'urn:uuid:c2', + name: { full: '둘' }, + photos: { p: { href: 'https://x.example/p.jpg' } }, + }); + expect(original?.bytes).toBe( + Buffer.byteLength(original?.source ?? '', 'utf8'), + ); + }); + + it('연락처 고치기는 지금 카드와 합친다 — 같은 주소의 label · 다른 contexts 를 남긴다 (contact-card.ts)', async () => { + engine.add({ + id: 'c1', + uid: 'urn:uuid:c1', + emails: { + k: { + address: 'a@x.example', + contexts: { work: true, billing: true }, + label: '본사', + pref: 1, + }, + }, + }); + + await provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + update: [ + { + kind: 'contact', + id: 'c1', + fields: { + emails: [ + { address: 'a@x.example', label: 'personal' }, + { address: 'b@x.example', label: 'work' }, + ], + }, + }, + ], + }); + + expect(engine.cardOf('c1')?.emails).toEqual({ + k: { + address: 'a@x.example', + contexts: { billing: true, private: true }, + label: '본사', + pref: 1, + }, + e1: { address: 'b@x.example', contexts: { work: true } }, + }); + }); + + it('먼저 읽은 상태가 판과 다르거나 카드가 없으면 conflict — set 을 보내지 않는다', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + await expect( + provider.writeAddressBook(ARA, version('book', 's9'), { + ...noChanges, + update: [{ kind: 'contact', id: 'c1', fields: { name: '둘' } }], + }), + ).resolves.toEqual({ kind: 'conflict' }); + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['gone'], + }), + ).resolves.toEqual({ kind: 'conflict' }); + expect( + engine.requests + .flatMap((request) => request.calls) + .filter(([method]) => method === 'ContactCard/set'), + ).toHaveLength(0); + expect(engine.cardOf('c1')).toMatchObject({ name: { full: '하나' } }); + }); + + it('만들기 · 그룹 고치기만이면 먼저 읽지 않는다 · 빈 칸(만들기 · 고치기 · 지우기)은 보내지 않는다', async () => { + engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } }); + await provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + update: [{ kind: 'group', id: 'g', name: '협력사' }], + }); + expect(engine.requests).toHaveLength(1); + expect(Object.keys(engine.requests[0]?.calls[0]?.[1] ?? {})).toEqual([ + 'accountId', + 'ifInState', + 'update', + ]); + }); + + it('그사이 바뀌었으면(stateMismatch) conflict — 아무것도 하지 않았다', async () => { + engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } }); + await expect( + provider.writeAddressBook(ARA, version('book', 's9'), { + ...noChanges, + update: [{ kind: 'group', id: 'g', name: '협력사' }], + }), + ).resolves.toEqual({ kind: 'conflict' }); + expect(engine.cardOf('g')).toMatchObject({ name: { full: '거래처' } }); + }); + + it('엔진이 동시 고치기를 거절하면(serverUnavailable — 메서드 오류 · 카드마다) busy — 지운 카드의 원본을 함께, warn 로그에 엔진 설명은 없다', async () => { + engine.add({ id: 'c1', uid: 'urn:uuid:c1', name: { full: '하나' } }); + engine.setReply = () => [ + 'error', + { + type: 'serverUnavailable', + description: 'store.assert-value-failed 하나', + }, + 'w', + ]; + const busy = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...noChanges, destroy: ['c1'] }, + ); + expect(busy.kind).toBe('busy'); + expect(busy.kind === 'busy' && [...busy.removed.keys()]).toEqual(['c1']); + + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's11', + created: { contact: { id: 'n9' } }, + notUpdated: { g: { type: 'serverUnavailable' } }, + }, + 'w', + ]; + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: newPerson, + }, + ], + update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:new'] }], + destroy: [], + }), + ).resolves.toMatchObject({ kind: 'busy' }); + + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('serverUnavailable'); + expect(logged).not.toMatch(/assert-value|하나|ara\.kim@/); + }); + + it('엔진이 새 상태를 알려 주지 않으면 version 은 null', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + engine.setReply = (args) => [ + 'ContactCard/set', + { created: {}, destroyed: args.destroy }, + 'w', + ]; + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ).resolves.toMatchObject({ + kind: 'written', + version: null, + createdIds: new Map(), + }); + }); + + it('엔진이 일부를 받지 않으면 그 종류의 코드 — warn 로그는 개수 · 종류만(카드 내용 · 엔진 설명 없음)', async () => { + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's11', + notCreated: { + contact: { + type: 'invalidProperties', + description: 'Invalid email 박지훈 ', + properties: ['emails/e1/address'], + }, + }, + }, + 'w', + ]; + + const error = await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + ...newPerson, + name: '박지훈', + emails: [{ address: 'jihoon@x', label: 'work' }], + }, + }, + ], + }), + ); + + expect(error.code).toBe('VAL_001'); + expect(error.context).toMatchObject({ + reason: 'address-book-change-refused', + jmapType: 'invalidProperties', + }); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('만들기 1/1'); + expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/); + }); + + it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => { + const original = { + source: JSON.stringify({ + id: 'old', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:old', + kind: 'individual', + addressBookIds: { gone: true }, + name: { full: '돌아온 사람' }, + nicknames: { k: { name: '별명' } }, + }), + bytes: 1, + }; + const restored = { + kind: 'restored' as const, + key: 'restored', + uid: 'urn:uuid:old', + original, + memberUids: null, + card: { + kind: 'contact' as const, + card: { + id: 'old', + uid: 'urn:uuid:old', + ...newPerson, + name: '돌아온 사람', + }, + }, + }; + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...noChanges, create: [restored] }, + ); + expect(engine.cardOf('n1')).toEqual({ + id: 'n1', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:old', + kind: 'individual', + addressBookIds: { book: true }, + name: { full: '돌아온 사람' }, + nicknames: { k: { name: '별명' } }, + }); + expect(outcome).toMatchObject({ kind: 'written' }); + expect( + outcome.kind === 'written' && outcome.cards.get('n1')?.card.name, + ).toBe('돌아온 사람'); + + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's12', + notCreated: { + restored: { type: 'invalidProperties', properties: ['media'] }, + }, + }, + 'w', + ]; + const error = await failureOf( + provider.writeAddressBook(ARA, version('book', 's11'), { + ...noChanges, + create: [restored], + }), + ); + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ + reason: 'restore-refused', + jmapType: 'invalidProperties', + properties: ['media'], + }); + }); + + it('메서드 오류 · 만든 id 가 없는 응답은 코드로 — serverFail 은 MAIL_001, 권한 없음은 AUTH_003', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + engine.setReply = () => ['error', { type: 'serverFail' }, 'w']; + expect( + ( + await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ) + ).code, + ).toBe('MAIL_001'); + + engine.setReply = () => ['error', { type: 'forbidden' }, 'w']; + expect( + ( + await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ) + ).code, + ).toBe('AUTH_003'); + + engine.setReply = () => [ + 'ContactCard/set', + { newState: 's11', created: {} }, + 'w', + ]; + const missing = await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + create: [ + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: [], + }, + ], + }), + ); + expect(missing.context?.reason).toBe('created-card-id-missing'); + }); + + it('주소록이 없는데 만들 카드가 있으면 MAIL_001 (엔진에 묻지 않고 error 로그) · 우리가 만들지 않은 판은 MAIL_001', async () => { + const error = await failureOf( + provider.writeAddressBook(ARA, version(null, 's10'), { + ...noChanges, + create: [ + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: [], + }, + ], + }), + ); + expect(error.context?.reason).toBe('address-book-missing'); + expect(errorSpy).toHaveBeenCalledTimes(1); + + for (const bad of ['not-json', '["book"]', '[1, "s"]', '["book", ""]']) { + expect( + (await failureOf(provider.writeAddressBook(ARA, bad, noChanges))) + .context?.reason, + ).toBe('address-book-version-malformed'); + } + expect(engine.requests).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts new file mode 100644 index 0000000..782d507 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts @@ -0,0 +1,449 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + AddressBookCard, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + AddressBookWriteOutcome, + ContactsProvider, + RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + cardOriginal, + contactPatch, + groupPatch, + newCardJson, + patchedCard, + readContactCard, +} from './contact-card'; +import { JmapClient } from './jmap.client'; +import { jmapFailure, mapJmapErrorType } from './jmap.errors'; +import { + expectResult, + findResult, + firstSetError, + isState, +} from './jmap-results'; +import type { + JmapAddressBook, + JmapContactCard, + JmapGetResult, + JmapInvocation, + JmapQueryResult, + JmapSetResult, +} from './jmap.types'; + +/** JMAP for Contacts — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-29 확인) */ +const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts'; + +/** 한 번에 읽는 카드 수 — 엔진의 maxObjectsInGet(500) 이하 */ +const READ_PAGE_SIZE = 500; +/** + * 주소록을 읽는 상한 — 우리 한도(연락처 3,000 · 그룹 100)보다 넉넉히. 다른 프로그램이 더 넣었으면 앞의 이만큼만 읽는다(warn 로그). + * 한 사람의 주소록이 서버 메모리 · 엔진 호출을 끝없이 쓰지 않게 + */ +export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000; + +/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */ +const CARD_PROPERTIES = [ + 'uid', + 'kind', + 'name', + 'emails', + 'phones', + 'organizations', + 'titles', + 'notes', + 'members', +]; + +/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3 — ContactCard/set 도 같다, 2026-10-29 확인) */ +const STATE_MISMATCH = 'stateMismatch'; +/** + * 엔진이 같은 카드를 동시에 고치는 요청을 거절한 것(store.assert-value-failed — 2026-10-29 확인). 메서드 오류로도 · 카드마다의 SetError 로도 + * 받는다고 보고, 어느 쪽이든 일부가 들어갔을 수 있다 — busy 로 알려 서비스가 다시 읽고 이어 하게 한다 + */ +const SERVER_UNAVAILABLE = 'serverUnavailable'; + +/** 판 — 기본 주소록 id 와 카드 상태 (서비스에는 불투명 문자열) */ +interface BookVersion { + readonly addressBookId: string | null; + readonly state: string; +} + +function encodeVersion(version: BookVersion): string { + return JSON.stringify([version.addressBookId, version.state]); +} + +/** 우리가 만든 판만 받는다 — 아니면 서버 안의 잘못이다 */ +function decodeVersion(text: string): BookVersion { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + if ( + Array.isArray(parsed) && + parsed.length === 2 && + (parsed[0] === null || typeof parsed[0] === 'string') && + isState(parsed[1]) + ) { + return { addressBookId: parsed[0] as string | null, state: parsed[1] }; + } + throw new BusinessException('MAIL_001', { + context: { reason: 'address-book-version-malformed' }, + }); +} + +/** 기본 주소록 — isDefault, 없으면 엔진이 준 첫 주소록 (계약 3절 "엔진에 주소록이 여럿이면 기본 주소록만") */ +function pickAddressBook(books: readonly JmapAddressBook[]): string | null { + const preferred = books.find((book) => book.isDefault === true) ?? books[0]; + return typeof preferred?.id === 'string' && preferred.id !== '' + ? preferred.id + : null; +} + +/** + * 메일 엔진(Stalwart)의 주소록 — JMAP for Contacts (계약 3절 "주소록"). 직원마다 기본 주소록 하나를 게이트웨이 대리 접근으로 읽고 쓴다. + * + * - 판: AddressBook/get(기본 주소록 id) + ContactCard/get(ids: [] — 상태만, 가볍다). 주소록 · 카드 무엇이든 바뀌면 판이 달라진다 + * - 읽기: 판을 먼저 묻고 ContactCard/query(그 주소록) + get 을 500장씩 — 판은 읽기 전에 물은 것이다(읽는 사이 바뀌면 다음 요청이 판이 달라 다시 읽는다) + * - 쓰기: 고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(칸 안의 남의 정보 · 되돌리기 원본 — contact-card.ts), ContactCard/set 하나에 + * 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy. + * 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/` 패치를 받지 않는다 — 그래서 uid 는 만들 때 + * 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인) + * - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만) + */ +@Injectable() +export class JmapContactsProvider implements ContactsProvider { + private readonly logger = new Logger(JmapContactsProvider.name); + + constructor(private readonly jmap: JmapClient) {} + + async getAddressBookVersion(user: string): Promise { + return encodeVersion(await this.readVersion(user)); + } + + async readAddressBook(user: string): Promise { + const head = await this.readVersion(user); + const { addressBookId } = head; + if (addressBookId === null) { + return { version: encodeVersion(head), contacts: [], groups: [] }; + } + + const contacts: AddressBookContactCard[] = []; + const groups: AddressBookGroupCard[] = []; + // 읽는 사이 카드가 생기거나 지워지면 차례가 밀려 같은 카드가 두 번 올 수 있다 — 한 번만 (판이 달라져 다음 요청이 다시 읽는다) + const seen = new Set(); + let position = 0; + let total: number | null = null; + while (position < ADDRESS_BOOK_READ_MAX_CARDS) { + const limit = Math.min( + READ_PAGE_SIZE, + ADDRESS_BOOK_READ_MAX_CARDS - position, + ); + const responses = await this.call(user, (accountId) => [ + [ + 'ContactCard/query', + { + accountId, + filter: { inAddressBook: addressBookId }, + position, + limit, + calculateTotal: true, + }, + 'q', + ], + [ + 'ContactCard/get', + { + accountId, + '#ids': { + resultOf: 'q', + name: 'ContactCard/query', + path: '/ids', + }, + properties: CARD_PROPERTIES, + }, + 'g', + ], + ]); + const query = expectResult( + responses, + 'q', + 'ContactCard/query', + ); + const page = expectResult>( + responses, + 'g', + 'ContactCard/get', + ); + for (const card of page.list ?? []) { + const read = readContactCard(card); + if (read === null || seen.has(read.card.id)) continue; + seen.add(read.card.id); + if (read.kind === 'contact') contacts.push(read.card); + else groups.push(read.card); + } + + const ids = query.ids ?? []; + position += ids.length; + total = typeof query.total === 'number' ? query.total : null; + if (ids.length === 0 || (total !== null && position >= total)) break; + } + + if (total !== null && total > position) { + this.logger.warn( + `[MAIL_001]${traceTag()} 주소록이 커서 앞의 ${position}장만 읽었습니다: user=${maskEmail(user)}, 카드 ${total}장`, + ); + } + // 판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 만든 카드를 빠뜨린 채 "최신"으로 기억하지 않게 (그 카드가 판을 바꿨으니 다음 요청이 다시 읽는다) + return { version: encodeVersion(head), contacts, groups }; + } + + async writeAddressBook( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise { + const { addressBookId, state } = decodeVersion(version); + if (changes.create.length > 0 && addressBookId === null) { + this.logger.error( + `[MAIL_001]${traceTag()} 메일 엔진에 이 직원의 주소록이 없어 연락처를 만들 수 없습니다 — 엔진의 주소록 설정을 확인하세요: user=${maskEmail(user)}`, + ); + throw new BusinessException('MAIL_001', { + context: { reason: 'address-book-missing', user: maskEmail(user) }, + }); + } + + // 고칠 연락처 · 지울 카드를 그 판 그대로 먼저 읽는다 — 칸 안의 남의 정보를 지키고(고치기) 되돌리기 원본을 남긴다(지우기) + const current = await this.readCurrentCards( + user, + state, + changes.update.flatMap((change) => + change.kind === 'contact' ? [change.id] : [], + ), + changes.destroy, + ); + if (current === null) return { kind: 'conflict' }; + const removed = current.originals; + + const create = new Map( + changes.create.map((card) => [ + card.key, + newCardJson(addressBookId ?? '', card), + ]), + ); + const update: Record> = {}; + const cards = new Map(); + for (const change of changes.update) { + if (change.kind === 'group') { + update[change.id] = groupPatch(change); + continue; + } + const card = current.cards.get(change.id) ?? { id: change.id }; + const patch = contactPatch(card, change.fields); + update[change.id] = patch; + const view = readContactCard(patchedCard(card, patch)); + if (view) cards.set(change.id, view); + } + + const responses = await this.call(user, (accountId) => [ + [ + 'ContactCard/set', + { + accountId, + ifInState: state, + ...(create.size > 0 ? { create: Object.fromEntries(create) } : {}), + ...(changes.update.length > 0 ? { update } : {}), + ...(changes.destroy.length > 0 ? { destroy: changes.destroy } : {}), + }, + 'w', + ], + ]); + + const result = findResult(responses, 'w', 'ContactCard/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) return { kind: 'conflict' }; + if (result.error.type === SERVER_UNAVAILABLE) { + this.warnBusy(user, 'method'); + return { kind: 'busy', removed }; + } + throw jmapFailure(result.error, { method: 'ContactCard/set' }); + } + const set = result.args as JmapSetResult; + const refusals = [ + ...Object.values(set.notCreated ?? {}), + ...Object.values(set.notUpdated ?? {}), + ...Object.values(set.notDestroyed ?? {}), + ]; + if (refusals.some((refusal) => refusal?.type === SERVER_UNAVAILABLE)) { + this.warnBusy(user, 'set-error'); + return { kind: 'busy', removed }; + } + const refusal = + firstSetError(set.notCreated) ?? + firstSetError(set.notUpdated) ?? + firstSetError(set.notDestroyed); + if (refusal) { + // 받은 것은 이미 바뀌었다 — 서비스가 기억한 주소록을 버리고 다시 읽는다 + this.logger.warn( + `[${mapJmapErrorType(refusal.type)}]${traceTag()} 엔진이 주소록 바꾸기의 일부를 받지 않았습니다: user=${maskEmail(user)}, jmapType=${refusal.type ?? 'unknown'}, ` + + `거절 — 만들기 ${Object.keys(set.notCreated ?? {}).length}/${changes.create.length} · 고치기 ${Object.keys(set.notUpdated ?? {}).length}/${changes.update.length} · 지우기 ${Object.keys(set.notDestroyed ?? {}).length}/${changes.destroy.length}`, + ); + // 되돌리기를 엔진이 받지 않은 것은 사용자가 고칠 수 있는 입력 오류가 아니다 — 엔진 장애로 답한다 + const restoring = changes.create.some((card) => card.kind === 'restored'); + if (restoring && firstSetError(set.notCreated)) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'restore-refused', + jmapType: refusal.type ?? 'unknown', + ...(refusal.properties?.length + ? { properties: refusal.properties } + : {}), + }, + }); + } + throw jmapFailure(refusal, { + method: 'ContactCard/set', + reason: 'address-book-change-refused', + }); + } + + const createdIds = new Map(); + for (const card of changes.create) { + const id = set.created?.[card.key]?.id; + if (typeof id !== 'string' || id === '') { + throw new BusinessException('MAIL_001', { + context: { reason: 'created-card-id-missing' }, + }); + } + createdIds.set(card.key, id); + // 만든 카드 — 엔진에 보낸 카드를 읽은 것 (엔진은 보낸 칸을 그대로 둔다 — 2026-10-29 확인) + const view = readContactCard({ ...create.get(card.key), id }); + if (view) cards.set(id, view); + } + return { + kind: 'written', + version: isState(set.newState) + ? encodeVersion({ addressBookId, state: set.newState }) + : null, + createdIds, + cards, + removed, + }; + } + + /** + * 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나. + * 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다) + */ + private async readCurrentCards( + user: string, + state: string, + updateIds: readonly string[], + destroyIds: readonly string[], + ): Promise<{ + cards: ReadonlyMap>; + originals: ReadonlyMap; + } | null> { + if (updateIds.length === 0 && destroyIds.length === 0) { + return { cards: new Map(), originals: new Map() }; + } + const calls = (accountId: string): JmapInvocation[] => [ + ...(updateIds.length > 0 + ? [ + [ + 'ContactCard/get', + { accountId, ids: [...updateIds], properties: CARD_PROPERTIES }, + 'u', + ] as JmapInvocation, + ] + : []), + ...(destroyIds.length > 0 + ? [ + [ + 'ContactCard/get', + // 되돌리기 원본 — 다른 프로그램의 칸까지 모두 + { accountId, ids: [...destroyIds], properties: null }, + 'd', + ] as JmapInvocation, + ] + : []), + ]; + const responses = await this.call(user, calls); + const read = (callId: string, ids: readonly string[]) => { + if (ids.length === 0) return new Map>(); + const got = expectResult>( + responses, + callId, + 'ContactCard/get', + ); + if (got.state !== state) return null; + const byId = new Map>( + (got.list ?? []).map((card) => [card.id, { ...card }]), + ); + return ids.every((id) => byId.has(id)) ? byId : null; + }; + const updating = read('u', updateIds); + const destroying = read('d', destroyIds); + if (updating === null || destroying === null) return null; + return { + cards: updating, + originals: new Map( + [...destroying].map(([id, card]) => [id, cardOriginal(card)]), + ), + }; + } + + /** 엔진이 동시 고치기를 거절했다 — 서비스가 다시 읽고 다시 한다 (엔진 오류의 설명 문장은 남기지 않는다) */ + private warnBusy(user: string, where: 'method' | 'set-error'): void { + this.logger.warn( + `[MAIL_001]${traceTag()} 엔진이 주소록 바꾸기를 잠시 받지 않았습니다(serverUnavailable — 같은 카드를 동시에 고침) — 다시 읽고 다시 합니다: user=${maskEmail(user)}, where=${where}`, + ); + } + + /** 기본 주소록 id 와 카드 상태 — 한 요청 */ + private async readVersion(user: string): Promise { + const responses = await this.call(user, (accountId) => [ + [ + 'AddressBook/get', + { accountId, ids: null, properties: ['isDefault'] }, + 'b', + ], + ['ContactCard/get', { accountId, ids: [] }, 's'], + ]); + const books = expectResult>( + responses, + 'b', + 'AddressBook/get', + ); + const cards = expectResult>( + responses, + 's', + 'ContactCard/get', + ); + if (!isState(cards.state)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'contact-state-missing' }, + }); + } + return { + addressBookId: pickAddressBook(books.list ?? []), + state: cards.state, + }; + } + + private call( + user: string, + build: (accountId: string) => JmapInvocation[], + ): Promise { + return this.jmap.call(user, build, { capabilities: [CONTACTS_CAPABILITY] }); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts new file mode 100644 index 0000000..fce999d --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts @@ -0,0 +1,201 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { JmapClient } from './jmap.client'; +import { + DIRECTORY_READ_MAX_ACCOUNTS, + JmapDirectoryProvider, + toDirectoryAccount, +} from './jmap-directory.provider'; +import type { JmapInvocation, JmapPrincipal } from './jmap.types'; + +/** 대역 엔진 — 계정 목록(Principal/query · get)만 흉내 낸다 (2026-10-29 개발 엔진이 준 칸: id · type · description · email) */ +class FakePrincipalEngine { + principals: JmapPrincipal[] = []; + readonly requests: JmapInvocation[][] = []; + /** 이 메서드는 오류로 답한다 */ + refuse: { method: string; type: string } | null = null; + + readonly client = { + callDirectory: jest.fn((build: (accountId: string) => JmapInvocation[]) => { + const calls = build('dir'); + this.requests.push(calls); + const responses: JmapInvocation[] = []; + for (const [method, args, callId] of calls) { + if (this.refuse?.method === method) { + responses.push(['error', { type: this.refuse.type }, callId]); + continue; + } + if (method === 'Principal/query') { + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? this.principals.length); + responses.push([ + method, + { + ids: this.principals + .slice(position, position + limit) + .map((principal) => principal.id), + position, + ...(args.calculateTotal === true + ? { total: this.principals.length } + : {}), + }, + callId, + ]); + } else if (method === 'Principal/get') { + const query = responses.find(([name]) => name === 'Principal/query'); + const ids = (query?.[1].ids as string[] | undefined) ?? []; + responses.push([ + method, + { + list: ids.map((id) => + this.principals.find((principal) => principal.id === id), + ), + }, + callId, + ]); + } + } + return Promise.resolve(responses); + }), + }; +} + +describe('JmapDirectoryProvider (엔진의 계정 목록)', () => { + let engine: FakePrincipalEngine; + let provider: JmapDirectoryProvider; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + engine = new FakePrincipalEngine(); + provider = new JmapDirectoryProvider( + engine.client as unknown as JmapClient, + ); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('사내 목록 계정으로 Principal/query(500개씩 · total) + get 을 한 요청에 — 개인은 person, 그룹은 shared', async () => { + engine.principals = [ + { + id: 'a', + type: 'individual', + name: 'admin@arasystem.test', + description: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 's', + type: 'group', + description: '영업팀', + email: 'sales@arasystem.test', + }, + { + id: 'r', + type: 'resource', + description: '회의실', + email: 'room@arasystem.test', + }, + ]; + + await expect(provider.listAccounts()).resolves.toEqual([ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@arasystem.test', + }, + ]); + const [calls] = engine.requests; + expect(calls?.map(([method]) => method)).toEqual([ + 'Principal/query', + 'Principal/get', + ]); + expect(calls?.[0]?.[1]).toEqual({ + accountId: 'dir', + position: 0, + limit: 500, + calculateTotal: true, + }); + expect(calls?.[1]?.[1]).toMatchObject({ + '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, + properties: ['type', 'description', 'email'], + }); + }); + + it('500개가 넘으면 이어 읽는다', async () => { + engine.principals = Array.from({ length: 1_100 }, (_, index) => ({ + id: `p${index}`, + type: 'individual', + description: `직원 ${index}`, + email: `p${index}@arasystem.test`, + })); + + await expect(provider.listAccounts()).resolves.toHaveLength(1_100); + expect(engine.requests.map((calls) => calls[0]?.[1].position)).toEqual([ + 0, 500, 1_000, + ]); + }); + + it(`${DIRECTORY_READ_MAX_ACCOUNTS}개를 넘으면 앞의 그만큼만 — warn 로그(개수만)`, async () => { + engine.principals = Array.from( + { length: DIRECTORY_READ_MAX_ACCOUNTS + 1 }, + (_, index) => ({ + id: `p${index}`, + type: 'individual', + description: `비밀 이름 ${index}`, + email: `p${index}@arasystem.test`, + }), + ); + + await expect(provider.listAccounts()).resolves.toHaveLength( + DIRECTORY_READ_MAX_ACCOUNTS, + ); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain(`계정 ${DIRECTORY_READ_MAX_ACCOUNTS + 1}개`); + expect(logged).not.toContain('비밀 이름'); + }); + + it('읽기 권한이 없으면(forbidden) 사내 목록을 읽지 못한 것 — MAIL_001 (AUTH_003 이 아니다)', async () => { + engine.refuse = { method: 'Principal/query', type: 'forbidden' }; + + const error = await provider + .listAccounts() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(BusinessException); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { + reason: 'directory-read-refused', + method: 'Principal/query', + jmapType: 'forbidden', + }, + }); + }); + + it('계정 하나 — 모양이 틀린 값은 null 로, 사내 목록 종류가 아니면 넣지 않는다', () => { + expect( + toDirectoryAccount({ + id: 'x', + type: 'individual', + description: 7, + email: null, + }), + ).toEqual({ id: 'x', kind: 'person', displayName: null, email: null }); + expect(toDirectoryAccount({ id: 'x', type: 'location' })).toBeNull(); + expect(toDirectoryAccount({ id: '', type: 'individual' })).toBeNull(); + expect(toDirectoryAccount({ id: 'x' })).toBeNull(); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-directory.provider.ts b/backend/src/modules/mail/jmap/jmap-directory.provider.ts new file mode 100644 index 0000000..90de784 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-directory.provider.ts @@ -0,0 +1,140 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + DirectoryAccount, + DirectoryProvider, +} from '@/modules/contacts/contacts.provider'; +import type { DirectoryEntryKind } from '@/modules/contacts/contacts.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { JmapClient } from './jmap.client'; +import { findResult } from './jmap-results'; +import type { + JmapGetResult, + JmapInvocation, + JmapPrincipal, + JmapQueryResult, +} from './jmap.types'; + +/** 한 번에 읽는 계정 수 — 엔진의 maxObjectsInGet(500) 이하 */ +const READ_PAGE_SIZE = 500; +/** 계정 목록을 읽는 상한 — 회사 하나의 계정 수로 넉넉하다. 넘으면 앞의 이만큼만 (warn 로그) */ +export const DIRECTORY_READ_MAX_ACCOUNTS = 10_000; + +/** 읽는 칸 — 엔진이 주는 칸은 id · type · name(로그인 이름) · description(표시 이름) · email 뿐이다 (2026-10-29 확인) */ +const PRINCIPAL_PROPERTIES = ['type', 'description', 'email']; + +/** 엔진의 계정 종류 → 사내 목록의 종류 — 그 밖(resource · location 등)은 넣지 않는다 */ +const KINDS: ReadonlyMap = new Map([ + ['individual', 'person'], + ['group', 'shared'], +]); + +/** 엔진의 계정 하나 — 모양이 맞지 않거나 사내 목록 종류가 아니면 null */ +export function toDirectoryAccount( + principal: JmapPrincipal | null | undefined, +): DirectoryAccount | null { + if ( + typeof principal !== 'object' || + principal === null || + typeof principal.id !== 'string' || + principal.id === '' + ) { + return null; + } + const kind = + typeof principal.type === 'string' ? KINDS.get(principal.type) : undefined; + if (kind === undefined) return null; + return { + id: principal.id, + kind, + displayName: + typeof principal.description === 'string' ? principal.description : null, + email: typeof principal.email === 'string' ? principal.email : null, + }; +} + +/** 메서드 응답을 꺼낸다 — 메서드 오류(읽기 권한 없음 forbidden 등)는 사내 목록을 읽지 못한 것(MAIL_001)이다 */ +function expectDirectoryResult( + responses: JmapInvocation[], + callId: string, + method: string, +): T { + const result = findResult(responses, callId, method); + if (!result.ok) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'directory-read-refused', + method, + jmapType: result.error.type ?? 'unknown', + }, + }); + } + return result.args as T; +} + +/** + * 메일 엔진(Stalwart)의 계정 목록 — 사내 목록 (계약 3절 "사내 목록"). setup 이 만든 사내 목록 계정으로만 읽는다(JmapClient.callDirectory). + * Principal/query(500개씩 · total) + Principal/get 을 한 요청에 — 읽기만 한다. 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다. + * 계정의 이름 · 주소는 로그에 남기지 않는다 (개수 · 엔진 오류 종류만) + */ +@Injectable() +export class JmapDirectoryProvider implements DirectoryProvider { + private readonly logger = new Logger(JmapDirectoryProvider.name); + + constructor(private readonly jmap: JmapClient) {} + + async listAccounts(): Promise { + const accounts: DirectoryAccount[] = []; + let position = 0; + let total: number | null = null; + while (position < DIRECTORY_READ_MAX_ACCOUNTS) { + const limit = Math.min( + READ_PAGE_SIZE, + DIRECTORY_READ_MAX_ACCOUNTS - position, + ); + const responses = await this.jmap.callDirectory((accountId) => [ + [ + 'Principal/query', + { accountId, position, limit, calculateTotal: true }, + 'q', + ], + [ + 'Principal/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, + properties: PRINCIPAL_PROPERTIES, + }, + 'g', + ], + ]); + const query = expectDirectoryResult( + responses, + 'q', + 'Principal/query', + ); + const page = expectDirectoryResult>( + responses, + 'g', + 'Principal/get', + ); + for (const principal of page.list ?? []) { + const account = toDirectoryAccount(principal); + if (account) accounts.push(account); + } + + const ids = query.ids ?? []; + position += ids.length; + total = typeof query.total === 'number' ? query.total : null; + if (ids.length === 0 || (total !== null && position >= total)) break; + } + + if (total !== null && total > position) { + this.logger.warn( + `[MAIL_001]${traceTag()} 엔진의 계정이 많아 사내 목록에 앞의 ${position}개만 읽었습니다: 계정 ${total}개`, + ); + } + return accounts; + } +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 0fa6fa9..ea9c184 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -54,7 +54,11 @@ const settings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * MB, attachmentTotalMaxBytes: 25 * MB, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; /** 검증된 Stalwart 기본 메일함 (영어 이름 · sortOrder 0) */ diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index a0037fb..2c79db4 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -12,6 +12,7 @@ import { expandTemplate, toRelativeReference, } from './jmap.client'; +import type { JmapInvocation } from './jmap.types'; const settings: MailSettings = { jmapBaseUrl: 'http://mail:8080', @@ -20,7 +21,11 @@ const settings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const ARA = 'ara.kim@arasystem.test'; @@ -879,6 +884,168 @@ describe('JmapClient', () => { }); }); + describe('사내 목록 계정 호출 (callDirectory)', () => { + const DIRECTORY = 'mail-directory@arasystem.test'; + /** 사내 목록 계정의 세션 — 메일 계정이 없고 계정 목록(principals) 계정만 있다 (2026-10-29 개발 엔진) */ + const directorySession = { + username: DIRECTORY, + apiUrl: 'http://localhost:8180/jmap/', + primaryAccounts: { + 'urn:ietf:params:jmap:principals': 'x', + 'urn:stalwart:jmap': 'x', + }, + accounts: { x: {} }, + }; + const principalCalls = (accountId: string): JmapInvocation[] => [ + ['Principal/query', { accountId }, 'q'], + ]; + + it('<사내 목록 계정>%<게이트웨이> 로 세션을 열고, 계정 목록 계정 id 로 core · principals 만 선언해 부른다 — 막는 계정이어도 이 호출만은 된다', async () => { + const responses = [['Principal/query', { ids: ['a'] }, 'q']]; + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({ methodResponses: responses })); + + await expect(client.callDirectory(principalCalls)).resolves.toEqual( + responses, + ); + + const session = requestAt(0); + expect(session.url).toBe('http://mail:8080/jmap/session'); + expect(decodeBasic(session.authorization)).toBe( + `${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`, + ); + const call = requestAt(1); + expect(call.url).toBe('http://mail:8080/jmap/'); + expect(decodeBasic(call.authorization)).toBe( + `${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(JSON.parse(call.init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:principals'], + methodCalls: [['Principal/query', { accountId: 'x' }, 'q']], + }); + + // 직원 메일함으로는 대리 접근하지 않는다 (막는 계정) + expect(await codeOf(client.getSession(DIRECTORY))).toBe('AUTH_001'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('세션은 직원 세션과 따로 캐시한다 — 두 번째 호출은 세션을 다시 묻지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockImplementation(() => + Promise.resolve(json({ methodResponses: [] })), + ); + + await client.callDirectory(principalCalls); + await client.callDirectory(principalCalls); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(cache.set).toHaveBeenCalledWith( + 'mail:jmap-directory-session', + expect.objectContaining({ account: DIRECTORY, accountId: 'x' }), + expect.any(Number), + ); + }); + + it('계정 목록 계정이 세션에 없으면 첫 계정, 그것도 없으면 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce( + json({ + ...directorySession, + primaryAccounts: {}, + accounts: { y: {} }, + }), + ) + .mockResolvedValueOnce(json({ methodResponses: [] })); + await client.callDirectory(principalCalls); + expect( + ( + JSON.parse(requestAt(1).init.body as string) as { + methodCalls: JmapInvocation[]; + } + ).methodCalls[0]?.[1], + ).toEqual({ accountId: 'y' }); + + await cache.del('mail:jmap-directory-session'); + fetchMock.mockResolvedValueOnce( + json({ ...directorySession, primaryAccounts: {}, accounts: {} }), + ); + const error = await client + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'directory-session-incomplete' }, + }); + }); + + it('계정이 없음(401) · 로그인 권한 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — AUTH_001 이 아니다(직원 세션과 상관없다), 캐시한 세션을 지운다', async () => { + const reasonOf = async () => { + const error = await client + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(BusinessException); + expect((error as BusinessException).code).toBe('MAIL_001'); + return (error as BusinessException).context?.reason; + }; + + // 계정 없음 — 게이트웨이로 직접 들어가 보면 된다 + fetchMock + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({}, 200)); + expect(await reasonOf()).toBe('directory-account-missing'); + + // 게이트웨이도 거부된다 — 메일 설정 전체의 문제 + fetchMock + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({}, 401)); + expect(await reasonOf()).toBe('gateway-rejected'); + + fetchMock.mockResolvedValueOnce(json({}, 403)); + expect(await reasonOf()).toBe('directory-account-forbidden'); + + // 세션을 연 뒤 호출에서 거부돼도 같다 + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({}, 403)); + expect(await reasonOf()).toBe('directory-account-forbidden'); + expect(cache.del).toHaveBeenCalledWith('mail:jmap-directory-session'); + }); + + it('엔진 5xx 는 MAIL_001, 429 는 REQ_001, 형식이 깨진 응답은 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({}, 502)); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'MAIL_001', + ); + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'REQ_001', + ); + fetchMock.mockResolvedValueOnce(json({ unexpected: true })); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'MAIL_001', + ); + }); + + it('사내 목록 계정을 모르면(도메인 없음) 엔진에 가지 않고 MAIL_001', async () => { + const unconfigured = new JmapClient( + { ...settings, directoryAccount: null }, + cache, + ); + const error = await unconfigured + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'directory-not-configured' }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + describe('변화 소식 스트림 (openEventSource)', () => { const OPTIONS = { types: ['Mailbox', 'Email'], pingSeconds: 30 } as const; diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index a87965a..39424e9 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -163,6 +163,11 @@ const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i; /** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */ const SESSION_CACHE_TTL_MS = 5 * 60_000; +/** JMAP Principals — 사내 목록 계정으로 엔진의 계정 목록을 읽는다 (계약 3절 "사내 목록") */ +const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals'; +/** 사내 목록 계정의 세션 캐시 키 — 직원 세션(sessionCacheKey)과 따로 둔다 */ +const DIRECTORY_SESSION_CACHE_KEY = 'mail:jmap-directory-session'; + const HTTP_UNAUTHORIZED = 401; const HTTP_BAD_REQUEST = 400; const HTTP_FORBIDDEN = 403; @@ -189,6 +194,17 @@ export interface JmapSession { readonly eventSourcePath?: string | null; } +/** + * 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다 + * (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다 + */ +interface JmapDirectorySession { + /** 이 세션을 연 사내 목록 계정 주소 — 설정이 바뀌면 다시 연다 */ + readonly account: string; + readonly accountId: string; + readonly apiPath: string; +} + /** 엔진의 변화 소식 스트림(JMAP EventSource)을 열 때 정하는 것 */ export interface JmapEventSourceOptions { /** 받을 종류 — Mailbox · Email 등 (RFC 8620 7.3 의 types) */ @@ -319,6 +335,7 @@ function parseContentLength(value: string | null): number | null { * - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401) * - 본인 비밀번호 바꾸기: 같은 본인 자격으로 `x:AccountPassword/set` (게이트웨이 대리 접근으로는 바꾸지 않는다) * - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>` + * - 사내 목록: 같은 대리 접근으로 사내 목록 계정 하나만 — 계정 목록(Principals) 읽기에만 쓴다 (callDirectory) * - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md) * * 인증 헤더·비밀번호는 어떤 경우에도 로그에 남기지 않는다. @@ -704,6 +721,50 @@ export class JmapClient { ); } + /** + * 사내 목록 계정으로 계정 목록(JMAP Principals)을 부른다 (계약 3절 "사내 목록") — `using` 은 core · principals 뿐이다. + * 그 계정(`MAIL_DIRECTORY_LOCAL_PART@MAIL_DOMAIN`)은 비밀번호가 없고 로그인 · 계정 목록 읽기 권한만 있다 — 게이트웨이 대리 접근으로만 쓴다. + * 로그인 · 직원 메일함 대리 접근을 막는 목록(blockedAccounts)에 있으므로, 이 호출만 그 주소 하나를 대리 접근한다(직원 메일함 호출에는 쓰지 않는다). + * - 계정이 없음(세션 401) · 로그인 권한(authenticate)이 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — 직원 세션과 상관없으므로 AUTH_001 로 답하지 않는다 + * - 메서드 오류(읽기 권한이 없으면 forbidden)는 그대로 돌려준다 — 해석은 부른 쪽(jmap-directory.provider.ts)이 한다 + */ + async callDirectory( + build: (accountId: string) => JmapInvocation[], + ): Promise { + const account = this.requireDirectoryAccount(); + const session = await this.getDirectorySession(account); + const body = JSON.stringify({ + using: [CORE_CAPABILITY, PRINCIPALS_CAPABILITY], + methodCalls: build(session.accountId), + }); + + return this.exchange( + session.apiPath, + { + method: 'POST', + headers: { + authorization: this.impersonation(account), + 'content-type': 'application/json', + }, + body, + }, + REQUEST_TIMEOUT_MS, + 'directory', + async (response) => { + await this.assertDirectoryAccepted(response); + await this.assertOk(response, 'directory'); + const parsed = await readJson(response, 'directory'); + if (!Array.isArray(parsed.methodResponses)) { + throw engineFailure({ + reason: 'malformed-response', + call: 'directory', + }); + } + return parsed.methodResponses; + }, + ); + } + /** * 첨부를 업로드한다 → blob id. * 본문은 Blob 으로 받는다 — 임시 파일을 연 Blob(fs.openAsBlob)을 넘기면 파일을 조금씩 읽어 흘려보내므로 @@ -959,7 +1020,92 @@ export class JmapClient { return this.settings; } - /** 막는 계정(관리자·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */ + /** 사내 목록 계정 주소 — 메일 엔진 설정 · 도메인이 없으면 MAIL_001 */ + private requireDirectoryAccount(): string { + const { directoryAccount } = this.requireConfigured(); + if (!directoryAccount) { + throw engineFailure({ reason: 'directory-not-configured' }); + } + return directoryAccount; + } + + /** + * 사내 목록 계정의 세션 — 5분 캐시한다(직원 세션과 같은 시간). 계정 id 는 세션의 `primaryAccounts['urn:ietf:params:jmap:principals']` + * (없으면 세션의 첫 계정) + */ + private async getDirectorySession( + account: string, + ): Promise { + const cached = await this.cache.get( + DIRECTORY_SESSION_CACHE_KEY, + ); + if (cached && cached.account === account) return cached; + + const session = await this.exchange( + SESSION_PATH, + { headers: { authorization: this.impersonation(account) } }, + REQUEST_TIMEOUT_MS, + 'directory-session', + async (response): Promise => { + await this.assertDirectoryAccepted(response); + await this.assertOk(response, 'directory-session'); + const resource = await readJson( + response, + 'directory-session', + ); + const accountId = + resource.primaryAccounts?.[PRINCIPALS_CAPABILITY] ?? + Object.keys(resource.accounts ?? {})[0]; + if (!accountId || !resource.apiUrl) { + throw engineFailure({ + reason: 'directory-session-incomplete', + call: 'directory-session', + }); + } + return { + account, + accountId, + apiPath: toRelativeReference(resource.apiUrl), + }; + }, + ); + await this.cache.set( + DIRECTORY_SESSION_CACHE_KEY, + session, + SESSION_CACHE_TTL_MS, + ); + return session; + } + + /** + * 사내 목록 계정의 대리 접근 거부 — 모두 MAIL_001 (사내 목록을 읽지 못함, 계약 3절): + * 403 은 그 계정의 역할에 로그인(authenticate)이 없거나 꺼짐, 401 은 계정이 없음 — 게이트웨이로 직접 들어가 보아 + * 게이트웨이도 거부되면 gateway-rejected(전체 메일 설정 문제)로 남긴다 + */ + private async assertDirectoryAccepted(response: Response): Promise { + if ( + response.status !== HTTP_UNAUTHORIZED && + response.status !== HTTP_FORBIDDEN + ) { + return; + } + await discard(response); + await this.cache.del(DIRECTORY_SESSION_CACHE_KEY); + if (response.status === HTTP_FORBIDDEN) { + throw engineFailure({ + reason: 'directory-account-forbidden', + status: response.status, + }); + } + throw engineFailure({ + reason: (await this.gatewayAccepted()) + ? 'directory-account-missing' + : 'gateway-rejected', + status: response.status, + }); + } + + /** 막는 계정(관리자·사내 목록 계정·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */ private assertNotBlocked(address: string): void { if (this.settings.blockedAccounts.has(address.trim().toLowerCase())) { throw new BusinessException('AUTH_001', { diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 0768ed0..ff573c8 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -232,3 +232,39 @@ export interface JmapUploadResult { type?: string; size?: number; } + +/** JMAP for Contacts — AddressBook (계정마다 기본 주소록 하나 — isDefault, 2026-10-29 확인) */ +export interface JmapAddressBook { + id: string; + name?: string; + isDefault?: boolean; +} + +/** + * JMAP for Contacts — ContactCard (JSContact 카드, RFC 9553). 칸마다 모양이 여러 가지일 수 있어(다른 프로그램 · CardDAV 에서 온 카드) + * 모두 unknown 으로 받고 읽을 때 모양을 확인한다 (contact-card.ts) + */ +export interface JmapContactCard { + id: string; + uid?: unknown; + kind?: unknown; + name?: unknown; + emails?: unknown; + phones?: unknown; + organizations?: unknown; + titles?: unknown; + notes?: unknown; + members?: unknown; +} + +/** + * JMAP Principals — 엔진의 계정 하나 (사내 목록 계정으로 읽는다 — 2026-10-29 확인: 주는 칸은 id · type · name(로그인 이름) · + * description(표시 이름) · email 뿐이다) + */ +export interface JmapPrincipal { + id: string; + type?: unknown; + name?: unknown; + description?: unknown; + email?: unknown; +} diff --git a/backend/src/modules/mail/mail-engine.module.ts b/backend/src/modules/mail/mail-engine.module.ts new file mode 100644 index 0000000..dcd3705 --- /dev/null +++ b/backend/src/modules/mail/mail-engine.module.ts @@ -0,0 +1,41 @@ +import { Logger, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { JmapClient } from './jmap/jmap.client'; +import { + MAIL_SETTINGS, + type MailSettings, + buildMailSettings, + isMailEngineConfigured, +} from './mail.settings'; + +function createMailSettings(config: ConfigService): MailSettings { + const settings = buildMailSettings((key) => config.get(key)); + if (!isMailEngineConfigured(settings)) { + // 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다 + new Logger('MailEngineModule').warn( + '메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일·주소록 API 가 MAIL_001 을 돌려줍니다.', + ); + } + return settings; +} + +/** + * 메일 엔진 연결 — 설정(MAIL_SETTINGS)과 JMAP 클라이언트(JmapClient) 하나를 메일 모듈과 주소록 모듈이 함께 쓴다. + * 두 모듈이 서로를 가져오지 않게(메일 → 주소록 방향만) 여기로 뺐다 (8단계). + * + * ⚠️ JmapClient 는 엔진 어댑터(`modules/mail/jmap/` 의 구현)만 쓴다 — 서비스 · 컨트롤러는 포트(MAIL_PROVIDER · CONTACTS_PROVIDER · + * DIRECTORY_PROVIDER)만 안다 (JMAP 타입 · 호출은 jmap/ 밖으로 내보내지 않는다) + */ +@Module({ + providers: [ + { + provide: MAIL_SETTINGS, + inject: [ConfigService], + useFactory: createMailSettings, + }, + JmapClient, + ], + exports: [MAIL_SETTINGS, JmapClient], +}) +export class MailEngineModule {} diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e4f83d7..43ec754 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -1,4 +1,4 @@ -import { Logger, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MulterModule, @@ -6,11 +6,11 @@ import { } from '@nestjs/platform-express'; import { BusinessException } from '@/common/errors/business.exception'; +import { ContactsModule } from '@/modules/contacts/contacts.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; -import { JmapClient } from './jmap/jmap.client'; import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; @@ -21,18 +21,14 @@ import { MailFilterRunController, } from './mail-filter.controller'; import { MailEventsService } from './mail-events.service'; +import { MailEngineModule } from './mail-engine.module'; import { MailUploadTempCleanup, mailUploadStorage, } from './mail-upload-storage'; import { MAIL_PROVIDER } from './mail.provider'; import { MailService } from './mail.service'; -import { - MAIL_SETTINGS, - type MailSettings, - buildMailSettings, - isMailEngineConfigured, -} from './mail.settings'; +import { type MailSettings, buildMailSettings } from './mail.settings'; import { MailboxController, MailQuotaController, @@ -48,17 +44,6 @@ function readMailSettings(config: ConfigService): MailSettings { return buildMailSettings((key) => config.get(key)); } -function createMailSettings(config: ConfigService): MailSettings { - const settings = readMailSettings(config); - if (!isMailEngineConfigured(settings)) { - // 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다 - new Logger('MailModule').warn( - '메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일 API 가 MAIL_001 을 돌려줍니다.', - ); - } - return settings; -} - /** * 메일 첨부 업로드 설정 — 임시 디스크에 받아 메일 엔진으로 흘려보내고 지운다 (mail-upload-storage.ts). * 메모리에 통째로 올리지 않는다 (운영 메모리 한도 1GiB — 동시 업로드에 버티게). @@ -92,10 +77,11 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { /** * 메일 모듈 — 웹메일 API 와 메일 엔진 포트. * 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다. - * 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다. + * 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정(MailEngineModule)을 내보낸다. * - * 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 — - * 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음). + * 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 주소록 · 사내 목록은 ContactsModule(엔진의 주소록 · 계정 목록 — 8단계), + * 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록은 MailDirectoryModule 이 맡는다 — 세 모듈은 메일 모듈을 가져오지 않는다 (순환 없음). + * 엔진 연결(설정 · JmapClient)은 MailEngineModule 하나를 메일 · 주소록이 함께 쓴다. */ @Module({ imports: [ @@ -103,9 +89,13 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { inject: [ConfigService], useFactory: attachmentUploadOptions, }), + // 메일 엔진 설정 · JMAP 클라이언트 (주소록 모듈과 함께 쓴다) + MailEngineModule, // 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명") MailSignatureModule, - // 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천") + // 받는 사람 추천의 주소록 · 사내 목록 (계약 3절 "메일과 함께") + ContactsModule, + // 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천") MailDirectoryModule, ], controllers: [ @@ -123,12 +113,6 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { RecipientSuggestionController, ], providers: [ - { - provide: MAIL_SETTINGS, - inject: [ConfigService], - useFactory: createMailSettings, - }, - JmapClient, // 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다 JmapFilterScriptStore, // 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다 @@ -140,6 +124,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { RecipientSuggestionService, MailUploadTempCleanup, ], - exports: [MAIL_PROVIDER, MAIL_SETTINGS], + // 메일 엔진 설정(MAIL_SETTINGS)은 MailEngineModule 을 그대로 내보낸다 — 인증 모듈이 주소 정책에 쓴다 + exports: [MAIL_PROVIDER, MailEngineModule], }) export class MailModule {} diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 50c52dc..62d709d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -40,7 +40,11 @@ const settings: MailSettings = { gatewayPassword: 'secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; /** 포트의 각 메서드를 타입이 있는 jest.fn 속성으로 — 메서드가 아니라 속성이라 그대로 expect 에 넘길 수 있다 */ diff --git a/backend/src/modules/mail/mail.settings.spec.ts b/backend/src/modules/mail/mail.settings.spec.ts index e646285..fe67d81 100644 --- a/backend/src/modules/mail/mail.settings.spec.ts +++ b/backend/src/modules/mail/mail.settings.spec.ts @@ -1,6 +1,7 @@ import { DEFAULT_ATTACHMENT_MAX_SIZE_MB, DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB, + DEFAULT_MAIL_DIRECTORY_LOCAL_PART, buildMailSettings, isMailEngineConfigured, parseAccountList, @@ -100,7 +101,7 @@ describe('buildMailSettings', () => { expect([ ...buildMailSettings(reader({ MAIL_DOMAIN: 'AraSystem.Test' })) .blockedAccounts, - ]).toEqual(['admin@arasystem.test']); + ]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']); expect([ ...buildMailSettings( @@ -111,6 +112,7 @@ describe('buildMailSettings', () => { ).blockedAccounts, ]).toEqual([ 'admin@arasystem.test', + 'mail-directory@arasystem.test', 'ops@arasystem.test', 'audit@arasystem.test', ]); @@ -127,6 +129,7 @@ describe('buildMailSettings', () => { expect([...blocked]).toEqual([ 'mail-admin@arasystem.test', + 'mail-directory@arasystem.test', 'ops@arasystem.test', ]); expect(blocked.has('admin@arasystem.test')).toBe(false); @@ -137,14 +140,45 @@ describe('buildMailSettings', () => { ...buildMailSettings( reader({ MAIL_DOMAIN: 'arasystem.test', MAIL_ADMIN_LOCAL_PART: ' ' }), ).blockedAccounts, - ]).toEqual(['admin@arasystem.test']); + ]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']); }); - it('도메인이 없으면 목록에 적은 계정만 막는다', () => { - expect([ - ...buildMailSettings(reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' })) - .blockedAccounts, - ]).toEqual(['ops@x.test']); + it(`사내 목록 계정은 기본 ${DEFAULT_MAIL_DIRECTORY_LOCAL_PART}@<도메인> — 넘어오지 않거나 빈 값이면 기본값, 로그인 · 대리 접근은 막는다`, () => { + const variants: Record[] = [ + { MAIL_DOMAIN: 'AraSystem.Test' }, + { MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: '' }, + { MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: ' ' }, + ]; + for (const values of variants) { + const settings = buildMailSettings(reader(values)); + expect(settings.directoryAccount).toBe('mail-directory@arasystem.test'); + expect( + settings.blockedAccounts.has('mail-directory@arasystem.test'), + ).toBe(true); + } + }); + + it('사내 목록 계정 아이디를 MAIL_DIRECTORY_LOCAL_PART 로 바꾸면 그 주소를 쓰고 막는다 (소문자)', () => { + const settings = buildMailSettings( + reader({ + MAIL_DOMAIN: 'arasystem.test', + MAIL_DIRECTORY_LOCAL_PART: ' Company-Directory ', + }), + ); + + expect(settings.directoryAccount).toBe('company-directory@arasystem.test'); + expect([...settings.blockedAccounts]).toEqual([ + 'admin@arasystem.test', + 'company-directory@arasystem.test', + ]); + }); + + it('도메인이 없으면 목록에 적은 계정만 막는다 — 사내 목록 계정도 없다', () => { + const settings = buildMailSettings( + reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }), + ); + expect([...settings.blockedAccounts]).toEqual(['ops@x.test']); + expect(settings.directoryAccount).toBeNull(); expect(buildMailSettings(reader({})).blockedAccounts.size).toBe(0); }); }); diff --git a/backend/src/modules/mail/mail.settings.ts b/backend/src/modules/mail/mail.settings.ts index c1e7983..d5f3814 100644 --- a/backend/src/modules/mail/mail.settings.ts +++ b/backend/src/modules/mail/mail.settings.ts @@ -25,12 +25,20 @@ export interface MailSettings { */ readonly attachmentTotalMaxBytes: number; /** - * 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `@<도메인>`(기본 admin) + MAIL_BLOCKED_ACCOUNTS. + * 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `@<도메인>`(기본 admin) + + * 사내 목록 계정 `@<도메인>`(기본 mail-directory) + MAIL_BLOCKED_ACCOUNTS. * 게이트웨이는 관리자 계정도 대리 접근할 수 있고, 그 세션은 **관리 권한**을 가진다(Stalwart 0.16 에서 확인). * 웹메일이 관리자 계정을 다루지 않게 하고, 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게 - * 메일 엔진에 묻기 **전에** 거절한다. + * 메일 엔진에 묻기 **전에** 거절한다. 사내 목록(계약 3절)도 같은 목록으로 거른다 (contacts/directory-policy.ts) */ readonly blockedAccounts: ReadonlySet; + /** + * 사내 목록 계정 주소 전체 (소문자, 도메인이 없으면 null) — 서버만 쓰는 계정이다: 비밀번호가 없고, 역할에 로그인과 계정 목록 읽기 + * (authenticate · jmapPrincipalGet · jmapPrincipalQuery)만 있다. 게이트웨이 대리 접근으로 엔진의 계정 목록을 읽는 데만 쓴다 + * (jmap/jmap.client.ts 의 callDirectory — 계약 3절 "사내 목록"). 로그인 · 직원 메일함 대리 접근은 막는다(blockedAccounts). + * 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 이 계정을 만든다 + */ + readonly directoryAccount: string | null; } /** 메일 엔진에 붙을 수 있는 설정 — 필수값이 모두 있는 상태 */ @@ -57,6 +65,12 @@ export const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25; */ export const DEFAULT_MAIL_ADMIN_LOCAL_PART = 'admin'; +/** + * 사내 목록 계정 아이디의 기본값 — 루트 env `MAIL_DIRECTORY_LOCAL_PART`(선택)로 바꾼다. compose 가 넘기지 않거나 빈 값이면 이 값이다. + * 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 그 계정을 만든다 — 두 쪽이 한 값을 읽는다 + */ +export const DEFAULT_MAIL_DIRECTORY_LOCAL_PART = 'mail-directory'; + const MEGABYTE = 1024 * 1024; function readTrimmed( @@ -97,6 +111,11 @@ export function buildMailSettings( const adminLocalPart = ( readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART ).toLowerCase(); + const directoryLocalPart = ( + readTrimmed(read, 'MAIL_DIRECTORY_LOCAL_PART') ?? + DEFAULT_MAIL_DIRECTORY_LOCAL_PART + ).toLowerCase(); + const directoryAccount = domain ? `${directoryLocalPart}@${domain}` : null; return { jmapBaseUrl: toOrigin(readTrimmed(read, 'MAIL_JMAP_URL')), @@ -111,8 +130,10 @@ export function buildMailSettings( ), blockedAccounts: new Set([ ...(domain ? [`${adminLocalPart}@${domain}`] : []), + ...(directoryAccount ? [directoryAccount] : []), ...parseAccountList(read('MAIL_BLOCKED_ACCOUNTS')), ]), + directoryAccount, }; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 8cc8caa..7a9c86b 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -1,5 +1,7 @@ import type { Readable } from 'stream'; +import type { ContactEmailLabel } from '@/modules/contacts/contacts.types'; + /** * 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다. * @@ -514,17 +516,24 @@ export interface Correspondence { readonly at: string; } -/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */ +/** 사내 목록의 한 사람 — 엔진의 사내 목록(8단계, 직원 · 공용 주소) 또는 그것을 읽지 못할 때 웹메일에 로그인한 적 있는 직원 */ export interface DirectoryContact { readonly email: string; readonly name: string; } +/** 받는 사람 추천의 묶음 — contact 내 주소록(8단계) · directory 사내 · recent 최근 주고받은 주소 */ +export type RecipientSuggestionSource = 'contact' | 'directory' | 'recent'; + /** 받는 사람 추천 (계약 2절 "받는 사람 추천") */ export interface RecipientSuggestion { readonly name: string | null; readonly email: string; - readonly source: 'directory' | 'recent'; + readonly source: RecipientSuggestionSource; + /** contact 만 — 그 연락처의 회사 (그 밖에는 null) */ + readonly company: string | null; + /** contact 만 — 그 주소의 종류 (그 밖에는 null) */ + readonly emailLabel: ContactEmailLabel | null; } /** diff --git a/backend/src/modules/mail/recipient-match.spec.ts b/backend/src/modules/mail/recipient-match.spec.ts new file mode 100644 index 0000000..0ca2c7f --- /dev/null +++ b/backend/src/modules/mail/recipient-match.spec.ts @@ -0,0 +1,156 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + recipientEmailMatcher, + recipientMatcher, + recipientNameMatcher, +} from './recipient-match'; + +/** + * 받는 사람 맞추기 표본 — [찾는 글(q), 이름(없으면 null), 주소, 맞는가] (계약 2절 "받는 사람 추천"). + * frontend shared/utils/recipient-match.ts 의 matchName · matchEmail 과 **같은 표**다 (행과 순서까지 그대로 — 답은 frontend 함수로 만들었다). + * ⚠️ 맞추기를 바꾸면 두 파일(recipient-match.ts 두 쪽)과 두 표를 함께 고친다 — 서버가 다르게 맞추면 쓰기 화면이 받아 둔 것에서 찾은 것과 서버의 답이 어긋난다 + */ +const RECIPIENT_MATCH_SAMPLES: readonly (readonly [ + query: string, + name: string | null, + email: string, + matches: boolean, +])[] = [ + ['도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['Lee', 'Doyun Lee', 'd.lee@example.com', true], + ['lEE', 'Doyun Lee', 'd.lee@example.com', true], + ['윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['박', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false], + ['ㄲ', '꽃님', 'flower@example.com', true], + ['ㄱ', '꽃님', 'flower@example.com', false], + ['ㅃ', '빵집', 'bread@example.com', true], + ['도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['도유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['동', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이동', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['돈', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['도요', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['지후', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박지', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['닭', '달걀', 'egg@example.com', true], + ['넋', '너서', 'neo@example.com', false], + ['값', '가비', 'gabi@example.com', false], + ['값', '갑시다', 'gapsi@example.com', true], + ['갑', '갑시다', 'gapsi@example.com', true], + ['ㄳ', '넋', 'neok@example.com', false], + ['ㄱ', '넋', 'neok@example.com', false], + ['김 아', '김 아라', 'ara.kim@arasystem.it.kr', true], + ['김아', '김 아라', 'ara.kim@arasystem.it.kr', false], + ['ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true], + ['ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false], + [' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['@arasystem', '', 'doyun.lee@arasystem.it.kr', true], + ['lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true], + ['a@b@c', '', 'a@x.example', true], + ['Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true], + ['kr', null, 'doyun.lee@arasystem.it.kr', true], + ['istanbul', null, 'İstanbul@example.com', false], + ['example', null, 'İstanbul@example.com', false], + ['도윤', null, 'doyun.lee@arasystem.it.kr', false], + ['도윤', '', 'x@example.com', false], + ['😀', '팀😀', 'team@example.com', true], + ['팀😀', '팀😀', 'team@example.com', true], + ['😀팀', '팀😀', 'team@example.com', false], + ['𠀋', '𠀋田', 'kanji@example.com', true], + ['ㅏ', '아라', 'ara@example.com', false], + ['ㅇㅏ', '아라', 'ara@example.com', false], + ['2팀', '영업2팀', 'sales2@example.com', true], + ['(주)', '(주)한빛', 'hanbit@example.com', true], + ['도윤'.normalize('NFD'), '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['도윤', '이도윤'.normalize('NFD'), 'x@example.com', false], + [ + 'ㄱ'.repeat(33), + 'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가', + 'long@example.com', + true, + ], + ['가'.repeat(32) + '나', '가'.repeat(40) + '나', 'long@example.com', true], + [ + '가'.repeat(31) + '나다', + '가'.repeat(40) + '나다', + 'long@example.com', + true, + ], + [ + '가'.repeat(31) + '나라', + '가'.repeat(40) + '나다', + 'long@example.com', + false, + ], +]; + +describe('받는 사람 맞추기 (recipient-match — 쓰기 화면과 같은 규칙)', () => { + it.each(RECIPIENT_MATCH_SAMPLES)( + 'q=%j 이름=%j 주소=%j → %s', + (query, name, email, matches) => { + expect(recipientMatcher(query)({ name, email })).toBe(matches); + }, + ); + + it('빈 글은 아무것에도 맞지 않는다 (거르지 않는 것은 부르는 쪽이 정한다)', () => { + expect(recipientNameMatcher('')('이도윤')).toBe(false); + expect(recipientNameMatcher(' ')('이도윤')).toBe(false); + expect(recipientEmailMatcher(' ')('a@b.example')).toBe(false); + }); + + it('한 쿼리로 여러 이름을 견줘도 답이 섞이지 않는다 (글자마다 기억한 표)', () => { + const matches = recipientNameMatcher('ㄷ윤'); + expect(['이도윤', '박지훈', '도윤', '두윤', '도요'].map(matches)).toEqual([ + true, + false, + true, + true, + false, + ]); + }); + + it('긴 이름 × 긴 글도 이름 길이에 비례한다 — 제곱이 아니다 (CPU 시간)', () => { + // 시작 자리마다 다시 견주면(화면의 방법) 이름 100만 자 × 글 100자 — 1억 번 비교로 수십 초 + const name = `${'가'.repeat(1_000_000)}나`; + const names = Array.from( + { length: 3_000 }, + (_, index) => `${'가'.repeat(99)}${index}`, + ); + const cpuMs = startCpuTimer(); + expect(recipientNameMatcher(`${'가'.repeat(99)}다`)(name)).toBe(false); + expect(recipientNameMatcher(`${'가'.repeat(99)}나`)(name)).toBe(true); + const matches = recipientNameMatcher(`${'가'.repeat(99)}다`); + expect(names.filter(matches)).toEqual([]); + // 지금 0.06~0.14초 — 한도는 10배쯤 (testing 규칙). 옛 방법(1억 번 비교)은 확실히 넘는다 + expect(cpuMs()).toBeLessThan(1_500); + }); + + it('추천 줄 거르기 — 같은 이름은 한 번만 견준다 (연락처 하나의 주소 여러 줄)', () => { + const matches = recipientMatcher('ㄷㅇ'); + const lines = Array.from({ length: 20 }, (_, index) => ({ + name: '이도윤', + email: `doyun${index}@x.example`, + })); + expect(lines.every(matches)).toBe(true); + expect(matches({ name: '박지훈', email: 'jihoon@x.example' })).toBe(false); + }); +}); diff --git a/backend/src/modules/mail/recipient-match.ts b/backend/src/modules/mail/recipient-match.ts new file mode 100644 index 0000000..6d4c010 --- /dev/null +++ b/backend/src/modules/mail/recipient-match.ts @@ -0,0 +1,262 @@ +/** + * 받는 사람 추천의 맞추기 (순수 함수) — 계약 2절 "받는 사람 추천" 의 `q`. **쓰기 화면과 같은 맞추기**다: + * 짝은 frontend `shared/utils/recipient-match.ts` 의 matchName · matchEmail 이고, 두 쪽 테스트가 같은 표본 표를 쓴다 + * (recipient-match.spec.ts 의 RECIPIENT_MATCH_SAMPLES — 한쪽을 고치면 다른 쪽과 표도 함께). + * + * - 이름: 친 글자가 이름 어딘가에 이어서 들어 있으면 맞다(대소문자 무시 — `toLowerCase`). 친 글자가 초성(호환 자모)이면 그 초성의 글자에 맞는다 + * (ㄷㅇ → 이"도윤", 도ㅇ → 이"도윤"). 마지막으로 친 글자는 아직 조합 중일 수 있다 — 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤)에, + * 받침이 있는 글자(동)는 그 받침이 다음 글자의 초성으로 넘어간 것(도 + ㅇ윤)에도 맞는다(겹받침은 뒤 자음이 넘어간다 — 닭 → 달 + ㄱ). + * 글자는 코드 포인트로 센다(서로게이트 쌍 하나가 한 글자). + * - 주소: 친 글자가 든 주소(대소문자 무시). 완전한 주소를 쳤으면 같은 아이디(@ 앞, @ 포함)의 주소도 맞다. 대소문자를 바꾸면 길이가 달라지는 글자가 든 주소는 맞추지 않는다. + * + * 화면은 받은 추천 100줄에서 이 규칙으로 고르고, 서버는 전체에서 고른다 — 같은 규칙이라 서버에 물어도 받아 둔 것에서 찾은 것이 그대로 나온다. + * 화면은 맞은 범위(굵게 보일 곳)를 찾지만 서버는 맞는지만 본다 — 이름은 비트 병렬(shift-and)로 한 번 훑어 이름 길이에 비례한다 + * (화면처럼 시작 자리마다 다시 견주면 긴 이름 × 긴 글이 제곱이 된다 — 다른 프로그램이 넣은 긴 이름도 받는다). + */ + +const SYLLABLE_START = 0xac00; +const SYLLABLE_END = 0xd7a3; +const MEDIAL_COUNT = 21; +const FINAL_COUNT = 28; +/** 초성 — 호환 자모 */ +const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'); +const INITIAL_SET: ReadonlySet = new Set(INITIALS); +/** 받침 — 0 은 받침 없음 */ +const FINALS = [ + '', + ...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ'), +]; +/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */ +const SPLIT_FINALS: Readonly< + Record +> = { + ㄳ: ['ㄱ', 'ㅅ'], + ㄵ: ['ㄴ', 'ㅈ'], + ㄶ: ['ㄴ', 'ㅎ'], + ㄺ: ['ㄹ', 'ㄱ'], + ㄻ: ['ㄹ', 'ㅁ'], + ㄼ: ['ㄹ', 'ㅂ'], + ㄽ: ['ㄹ', 'ㅅ'], + ㄾ: ['ㄹ', 'ㅌ'], + ㄿ: ['ㄹ', 'ㅍ'], + ㅀ: ['ㄹ', 'ㅎ'], + ㅄ: ['ㅂ', 'ㅅ'], +}; + +/** 비트 병렬 상태의 한 칸 (32비트) */ +const WORD_BITS = 32; +/** 글자마다 기억하는 비트 표의 수 — 한 요청 안에서만 쓰고, 넘으면 비운다(메모리 상한) */ +const MASK_CACHE_LIMIT = 10_000; + +interface Syllable { + readonly initial: number; + readonly medial: number; + readonly final: number; +} + +function syllableOf(char: string): Syllable | null { + const code = char.codePointAt(0) ?? 0; + if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) { + return null; + } + const offset = code - SYLLABLE_START; + return { + initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)), + medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT), + final: offset % FINAL_COUNT, + }; +} + +function composeSyllable(syllable: Syllable): string { + return String.fromCharCode( + SYLLABLE_START + + (syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT + + syllable.final, + ); +} + +function initialOf(char: string): string | null { + const syllable = syllableOf(char); + return syllable ? (INITIALS[syllable.initial] ?? null) : null; +} + +/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */ +function matchesChar(typed: string, char: string): boolean { + if (typed.toLowerCase() === char.toLowerCase()) return true; + return INITIAL_SET.has(typed) && initialOf(char) === typed; +} + +/** + * 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0. + * - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다 + * - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤) + */ +function matchesLastChar( + typed: string, + char: string, + next: string | undefined, +): number { + if (matchesChar(typed, char)) return 1; + const typedSyllable = syllableOf(typed); + const syllable = syllableOf(char); + if (!typedSyllable || !syllable) return 0; + if ( + typedSyllable.initial !== syllable.initial || + typedSyllable.medial !== syllable.medial + ) { + return 0; + } + if (typedSyllable.final === 0) return 1; + if (next === undefined) return 0; + const finalJamo = FINALS[typedSyllable.final] ?? ''; + const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]; + const restSyllable = composeSyllable({ + ...typedSyllable, + final: FINALS.indexOf(rest), + }); + return restSyllable === char && initialOf(next) === moved ? 2 : 0; +} + +/** + * 마지막으로 친 글자의 맞추기를 글자마다 한 번만 — 다음 글자와 상관없는 몫(matchesLastChar 의 1)은 글자(코드 포인트)마다 기억하고, + * 받침이 다음 글자의 초성으로 넘어간 몫(2)은 그 글자가 "받침을 뗀 글자"일 때만 다음 글자의 초성을 본다 + */ +function lastCharMatcher( + last: string, +): (point: number, next: number | undefined) => boolean { + const simple = new Map(); + const typedSyllable = syllableOf(last); + let carry: { readonly rest: number; readonly moved: string } | null = null; + if (typedSyllable && typedSyllable.final !== 0) { + const finalJamo = FINALS[typedSyllable.final] ?? ''; + const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]; + carry = { + rest: composeSyllable({ + ...typedSyllable, + final: FINALS.indexOf(rest), + }).charCodeAt(0), + moved, + }; + } + return (point, next) => { + let matched = simple.get(point); + if (matched === undefined) { + matched = + matchesLastChar(last, String.fromCodePoint(point), undefined) > 0; + if (simple.size >= MASK_CACHE_LIMIT) simple.clear(); + simple.set(point, matched); + } + if (matched) return true; + if (carry === null || point !== carry.rest || next === undefined) { + return false; + } + return initialOf(String.fromCodePoint(next)) === carry.moved; + }; +} + +/** + * 이름 맞추기 — 친 글자(초성 · 조합 중 포함)가 이름 어딘가에 맞는가. 쿼리 하나로 여러 이름을 견주는 함수를 돌려준다. + * 마지막 글자 앞까지는 비트 병렬(shift-and — 상태의 k 번째 비트: 앞의 k+1 글자가 지금 자리에서 끝나게 맞았다)로 훑고, + * 그것이 다 맞은 다음 자리에서 마지막 글자를 견준다 — 화면의 matchName 과 같은 답이다. + * 이름은 코드 포인트로 한 번 훑는다(글자를 배열로 만들지 않는다) — 글자마다의 비트 표 · 마지막 글자 답은 쿼리 안에서 기억한다 + */ +export function recipientNameMatcher(query: string): (name: string) => boolean { + const typed = Array.from(query.trim()); + const last = typed.at(-1); + if (last === undefined) return () => false; + const lastMatches = lastCharMatcher(last); + const prefix = typed.slice(0, -1); + const prefixLower = prefix.map((char) => char.toLowerCase()); + const length = prefix.length; + const words = Math.ceil(length / WORD_BITS); + const finalWord = Math.floor((length - 1) / WORD_BITS); + const finalBit = 1 << ((length - 1) % WORD_BITS); + const masks = new Map(); + const maskOf = (point: number): Uint32Array => { + const known = masks.get(point); + if (known) return known; + const char = String.fromCodePoint(point); + const lower = char.toLowerCase(); + const initial = initialOf(char); + const mask = new Uint32Array(words); + prefix.forEach((typedChar, index) => { + // matchesChar 와 같다 — 같은 글자(대소문자 무시) 또는 초성 + if ( + prefixLower[index] === lower || + (INITIAL_SET.has(typedChar) && initial === typedChar) + ) { + mask[Math.floor(index / WORD_BITS)] |= 1 << (index % WORD_BITS); + } + }); + if (masks.size >= MASK_CACHE_LIMIT) masks.clear(); + masks.set(point, mask); + return mask; + }; + + return (name: string) => { + const state = length === 0 ? null : new Uint32Array(words); + let at = 0; + while (at < name.length) { + const point = name.codePointAt(at) ?? 0; + const nextAt = at + (point > 0xffff ? 2 : 1); + const next = nextAt < name.length ? name.codePointAt(nextAt) : undefined; + // 앞 글자들이 바로 앞 자리에서 끝나게 맞았다(앞 글자가 없으면 늘) — 이 자리에서 마지막 글자를 견준다 + const ready = + state === null || ((state[finalWord] ?? 0) & finalBit) !== 0; + if (ready && lastMatches(point, next)) return true; + if (state !== null) { + const mask = maskOf(point); + let carry = 1; + for (let word = 0; word < words; word += 1) { + const value = state[word] ?? 0; + state[word] = ((value << 1) | carry) & (mask[word] ?? 0); + carry = value >>> (WORD_BITS - 1); + } + } + at = nextAt; + } + return false; + }; +} + +/** 주소 맞추기 — 친 글자가 든 주소(대소문자 무시), 완전한 주소를 쳤으면 같은 아이디(@ 포함)의 주소도 */ +export function recipientEmailMatcher( + query: string, +): (email: string) => boolean { + const typed = query.trim().toLowerCase(); + const localEnd = typed.indexOf('@'); + const local = localEnd > 0 ? typed.slice(0, localEnd + 1) : null; + return (email: string) => { + const target = email.toLowerCase(); + // 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 화면이 범위를 믿을 수 없어 맞추지 않는다 — 같게 + if (typed === '' || target.length !== email.length) return false; + if (target.includes(typed)) return true; + return local !== null && target.includes(local); + }; +} + +/** + * 추천 한 줄이 친 글자에 맞는가 — 이름(있으면) · 주소 가운데 하나라도. + * 이름의 답은 이 쿼리 안에서 기억한다 — 주소록 한 연락처의 주소 여러 줄은 이름이 같다(연락처 3,000 × 주소 20 = 6만 줄도 이름은 3,000번만 본다) + */ +export function recipientMatcher( + query: string, +): (suggestion: { + readonly name: string | null; + readonly email: string; +}) => boolean { + const name = recipientNameMatcher(query); + const email = recipientEmailMatcher(query); + const names = new Map(); + const nameMatches = (text: string): boolean => { + let matched = names.get(text); + if (matched === undefined) { + matched = name(text); + names.set(text, matched); + } + return matched; + }; + return (suggestion) => + (suggestion.name ? nameMatches(suggestion.name) : false) || + email(suggestion.email); +} diff --git a/backend/src/modules/mail/recipient-ranking.spec.ts b/backend/src/modules/mail/recipient-ranking.spec.ts index 713bb8b..ce50bc5 100644 --- a/backend/src/modules/mail/recipient-ranking.spec.ts +++ b/backend/src/modules/mail/recipient-ranking.spec.ts @@ -1,8 +1,10 @@ -import type { Correspondence } from './mail.types'; +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import type { Correspondence, RecipientSuggestion } from './mail.types'; import { MAX_RECIPIENT_SUGGESTIONS, filterRecipientSuggestions, rankRecipientSuggestions, + withContactSuggestions, } from './recipient-ranking'; const NOW = new Date('2026-10-27T12:00:00.000Z'); @@ -26,6 +28,28 @@ const sent = ( at: ago(days), }); +/** 사내 · 최근 줄 — 주소록 칸(company · emailLabel)은 null */ +const directoryItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'directory', + company: null, + emailLabel: null, +}); +const recentItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'recent', + company: null, + emailLabel: null, +}); + const rank = ( correspondence: Correspondence[], directory: { email: string; name: string }[] = [], @@ -49,17 +73,9 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { ); expect(result).toEqual([ - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory', - }, - { - name: '박서준', - email: 'seojun.park@arasystem.test', - source: 'directory', - }, - { name: null, email: 'partner@vendor.example', source: 'recent' }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'partner@vendor.example'), ]); }); @@ -118,9 +134,7 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { [{ email: 'admin@arasystem.test', name: '관리자' }], ); - expect(result).toEqual([ - { name: null, email: 'kept@service.example', source: 'recent' }, - ]); + expect(result).toEqual([recentItem(null, 'kept@service.example')]); }); it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => { @@ -135,12 +149,8 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { ); expect(result).toEqual([ - { - name: '메일에 적힌 이름', - email: 'colleague@arasystem.test', - source: 'directory', - }, - { name: '새 이름', email: 'partner@vendor.example', source: 'recent' }, + directoryItem('메일에 적힌 이름', 'colleague@arasystem.test'), + recentItem('새 이름', 'partner@vendor.example'), ]); }); @@ -155,14 +165,100 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { }); }); +describe('withContactSuggestions (주소록 묶음을 앞에)', () => { + const line = ( + name: string | null, + email: string, + overrides: Partial = {}, + ): ContactSuggestionLine => ({ + name, + email, + company: null, + emailLabel: 'work', + ...overrides, + }); + + it('주소록 → 사내 → 최근 — 주소록에 있는 주소는 뒤 묶음에서 빠진다(대소문자 무시), 회사 · 종류는 주소록 줄에만', () => { + const ranked = [ + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem('한빛', 'jihoon.park@hanbit.example'), + recentItem(null, 'vendor@supply.example'), + ]; + + const result = withContactSuggestions( + [ + line('박지훈', 'Jihoon.Park@hanbit.example', { company: '한빛상사' }), + line('박지훈', 'jihoon.home@mail.example', { + company: '한빛상사', + emailLabel: 'personal', + }), + line('이도윤', 'doyun.lee@arasystem.test'), + ], + ranked, + ME, + EXCLUDED, + ); + + expect(result).toEqual([ + { + name: '박지훈', + email: 'Jihoon.Park@hanbit.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon.home@mail.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + source: 'contact', + company: null, + emailLabel: 'work', + }, + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'vendor@supply.example'), + ]); + }); + + it('같은 주소가 연락처 둘에 있으면 앞의 것 하나 — 나 · 시스템 계정(관리자 등)은 주소록에 있어도 빼고, noreply 류는 직원이 넣은 것이라 둔다', () => { + const result = withContactSuggestions( + [ + line('거래처 A', 'sales@partner.example'), + line('거래처 B', 'SALES@partner.example'), + line('나', 'Ara.Kim@arasystem.test'), + line('관리자', 'admin@arasystem.test'), + line(null, 'no-reply@bank.example'), + line(' ', 'nameless@partner.example', { emailLabel: 'other' }), + ], + [], + ME, + EXCLUDED, + ); + + expect(result.map((item) => [item.name, item.email])).toEqual([ + ['거래처 A', 'sales@partner.example'], + [null, 'no-reply@bank.example'], + [null, 'nameless@partner.example'], + ]); + }); + + it('주소록이 비면 받은 목록 그대로', () => { + const ranked = [recentItem(null, 'vendor@supply.example')]; + expect(withContactSuggestions([], ranked, ME, EXCLUDED)).toEqual(ranked); + }); +}); + describe('filterRecipientSuggestions (q 로 거르기)', () => { const list = [ - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory' as const, - }, - { name: null, email: 'Partner@Vendor.example', source: 'recent' as const }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + recentItem(null, 'Partner@Vendor.example'), ]; it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => { @@ -172,12 +268,21 @@ describe('filterRecipientSuggestions (q 로 거르기)', () => { expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]); }); - it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => { - expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([ - list[0], + it('쓰기 화면과 같은 맞추기 — 초성 · 조합 중 글자 · 완전한 주소의 아이디 (recipient-match.ts)', () => { + expect(filterRecipientSuggestions(list, 'ㄷㅇ')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, '도유')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, '동')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, 'partner@other.example')).toEqual([ + list[1], ]); }); + it('조합형(NFD)으로 친 한글은 같은 글자가 아니다 — 쓰기 화면과 같게 (받아 둔 것에서 찾은 것과 서버의 답이 같아야 한다)', () => { + expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual( + [], + ); + }); + it('q 가 없거나 공백뿐이면 그대로 준다', () => { expect(filterRecipientSuggestions(list, undefined)).toBe(list); expect(filterRecipientSuggestions(list, ' ')).toBe(list); diff --git a/backend/src/modules/mail/recipient-ranking.ts b/backend/src/modules/mail/recipient-ranking.ts index cb2d204..7480485 100644 --- a/backend/src/modules/mail/recipient-ranking.ts +++ b/backend/src/modules/mail/recipient-ranking.ts @@ -1,3 +1,5 @@ +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import { recipientMatcher } from './recipient-match'; import type { Correspondence, DirectoryContact, @@ -5,16 +7,20 @@ import type { } from './mail.types'; /** - * 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천". + * 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천" · 3절 "메일과 함께". * - * - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다 - * - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기) - * - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다 + * - 주소록(8단계) → 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. + * 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 + * - 주소록은 연락처의 이름 차례(주소록이 정한 차례 그대로 — 대표 주소가 먼저), 사내 · 최근은 주고받은 횟수와 최근성으로 — + * 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기) + * - 나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정(excluded)은 어느 묶음에서도 뺀다. noreply 류는 사내 · 최근에서만 뺀다 — + * 주소록은 직원이 넣은 것이라 그대로 둔다 (계약 3절 "메일과 함께") + * - q 로 거르기는 쓰기 화면과 같은 맞추기(recipient-match.ts — 초성 · 조합 중 글자 · 완전한 주소의 아이디) */ /** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */ const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; -/** 한 사람에게 기억하는 추천 수 상한 */ +/** 한 사람에게 기억하는 추천 수 상한 (사내 · 최근 — 주소록 묶음은 기억하지 않고 요청마다 붙인다) */ export const MAX_RECIPIENT_SUGGESTIONS = 1000; /** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */ @@ -48,16 +54,25 @@ function cleanName(name: string | null | undefined): string | null { return trimmed ? trimmed : null; } -/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */ -export function isSuggestable( +/** 받는 사람이 될 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록(시스템 계정)이 아닌지 */ +function isAddressable( email: string, me: string, excluded: ReadonlySet, ): boolean { const at = email.lastIndexOf('@'); if (at <= 0 || at === email.length - 1) return false; - if (email === me || excluded.has(email)) return false; - return !NOREPLY_LOCAL_PART.test(email.slice(0, at)); + return email !== me && !excluded.has(email); +} + +/** 추천할 수 있는 주소인가 (사내 · 최근) — isAddressable 이고 noreply 류가 아닌지 */ +export function isSuggestable( + email: string, + me: string, + excluded: ReadonlySet, +): boolean { + if (!isAddressable(email, me, excluded)) return false; + return !NOREPLY_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@'))); } function tally( @@ -92,7 +107,7 @@ function tally( const byName = new Intl.Collator('ko'); -/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */ +/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) — 주소록 묶음은 withContactSuggestions 가 앞에 붙인다 */ export function rankRecipientSuggestions( input: RankingInput, ): RecipientSuggestion[] { @@ -113,7 +128,13 @@ export function rankRecipientSuggestions( } const colleagues: RecipientSuggestion[] = [...directory] - .map(([email, name]) => ({ name, email, source: 'directory' as const })) + .map(([email, name]) => ({ + name, + email, + source: 'directory' as const, + company: null, + emailLabel: null, + })) .sort( (a, b) => scoreOf(b.email) - scoreOf(a.email) || @@ -136,26 +157,53 @@ export function rankRecipientSuggestions( name: entry.name, email, source: 'recent' as const, + company: null, + emailLabel: null, })); return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS); } -/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */ -function folded(text: string): string { - return text.normalize('NFC').toLowerCase(); +/** + * 주소록 묶음을 앞에 붙인다 — 연락처의 주소마다 한 줄(받은 차례 그대로 — 이름 차례, 대표 주소가 먼저). + * 주소록에 있는 주소는 뒤 묶음(사내 · 최근)에서 뺀다. 같은 주소가 연락처 둘에 있으면 앞의 것 하나만. + * 나와 시스템 계정(관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정 — excluded)만 주소록에 있어도 뺀다 — noreply 류는 직원이 넣은 것이라 그대로 둔다 + */ +export function withContactSuggestions( + contacts: readonly ContactSuggestionLine[], + ranked: readonly RecipientSuggestion[], + me: string, + excluded: ReadonlySet, +): RecipientSuggestion[] { + const self = normalizeEmail(me); + const seen = new Set(); + const lines: RecipientSuggestion[] = []; + for (const contact of contacts) { + const key = normalizeEmail(contact.email); + if (seen.has(key) || !isAddressable(key, self, excluded)) continue; + seen.add(key); + lines.push({ + name: cleanName(contact.name), + email: contact.email.trim(), + source: 'contact', + company: contact.company, + emailLabel: contact.emailLabel, + }); + } + if (lines.length === 0) return [...ranked]; + return [ + ...lines, + ...ranked.filter((item) => !seen.has(normalizeEmail(item.email))), + ]; } -/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */ +/** 이름·주소가 q 에 맞는 것만 — 쓰기 화면과 같은 맞추기(recipient-match.ts). q 가 없으면 그대로 */ export function filterRecipientSuggestions( suggestions: readonly RecipientSuggestion[], query: string | undefined, ): readonly RecipientSuggestion[] { - const q = query ? folded(query.trim()) : ''; + const q = query?.trim() ?? ''; if (q === '') return suggestions; - return suggestions.filter( - (suggestion) => - folded(suggestion.email).includes(q) || - (suggestion.name !== null && folded(suggestion.name).includes(q)), - ); + const matches = recipientMatcher(q); + return suggestions.filter(matches); } diff --git a/backend/src/modules/mail/recipient-suggestion-cursor.ts b/backend/src/modules/mail/recipient-suggestion-cursor.ts index 512e3c9..5d4cd80 100644 --- a/backend/src/modules/mail/recipient-suggestion-cursor.ts +++ b/backend/src/modules/mail/recipient-suggestion-cursor.ts @@ -1,45 +1,19 @@ -import { createHash } from 'crypto'; - import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; /** * 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치. - * 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식). + * 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts — 주소록 목록과 같은 형식). * 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다). */ -const CURSOR_VERSION = 1; -const MAX_CURSOR_LENGTH = 256; -const SCOPE_HASH_LENGTH = 16; - -interface EncodedCursor { - v: number; - o: number; - s: string; -} - -function scopeHash(query: string | undefined): string { - return createHash('sha256') - .update(query ?? '') - .digest('base64url') - .slice(0, SCOPE_HASH_LENGTH); -} - -function invalidCursor(reason: string): BusinessException { - return new BusinessException('VAL_001', { - context: { reason: `invalid-suggestion-cursor:${reason}` }, - }); -} - export function encodeSuggestionCursor( offset: number, query: string | undefined, ): string { - const payload: EncodedCursor = { - v: CURSOR_VERSION, - o: offset, - s: scopeHash(query), - }; - return Buffer.from(JSON.stringify(payload)).toString('base64url'); + return encodeOffsetCursor(offset, query ?? ''); } /** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */ @@ -47,23 +21,11 @@ export function decodeSuggestionCursor( cursor: string, query: string | undefined, ): number { - if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long'); - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); - } catch { - throw invalidCursor('not-json'); + const reading = decodeOffsetCursor(cursor, query ?? ''); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-suggestion-cursor:${reading.problem}` }, + }); } - - const { v, o, s } = (parsed ?? {}) as Partial; - const valid = - v === CURSOR_VERSION && - typeof o === 'number' && - Number.isSafeInteger(o) && - o >= 0 && - typeof s === 'string'; - if (!valid) throw invalidCursor('malformed'); - if (s !== scopeHash(query)) throw invalidCursor('other-query'); - return o; + return reading.offset; } diff --git a/backend/src/modules/mail/recipient-suggestion.controller.ts b/backend/src/modules/mail/recipient-suggestion.controller.ts index ede8672..cc429c1 100644 --- a/backend/src/modules/mail/recipient-suggestion.controller.ts +++ b/backend/src/modules/mail/recipient-suggestion.controller.ts @@ -14,7 +14,7 @@ import { RecipientSuggestionPageDto } from './dto/mail-response.dto'; import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import { RecipientSuggestionService } from './recipient-suggestions.service'; -// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다. +// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") — 요청한 직원 자신의 주소록 · 메일함과 사내 목록에서만 모은다. @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -29,9 +29,14 @@ export class RecipientSuggestionController { @ApiOperation({ summary: '받는 사람 추천 (커서 페이지네이션)', description: - '사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' + - '묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' + - 'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.', + '내 주소록(source contact — 연락처의 주소마다 한 줄, 이름 차례 · 대표 주소가 먼저, company · emailLabel 포함) → ' + + '사내(엔진의 사내 목록의 직원 · 공용 주소 — 읽지 못하면 최근 90일 안에 웹메일에 로그인한 직원) → ' + + '최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' + + '사내 · 최근 안에서는 주고받은 횟수와 최근성으로. 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다. 그룹은 넣지 않는다. ' + + '나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 뺀다(noreply 류는 사내 · 최근에서만 — 주소록은 직원이 넣은 것이라 그대로). ' + + 'q 가 있으면 쓰기 화면과 같은 맞추기 — 이름은 초성 · 조합 중 글자를 섞어도(ㄷㅇ · 도ㅇ · 도유 · 동 → 이도윤), 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. ' + + '서버는 사내 · 최근을 사용자마다 10분 기억하고 메일을 보내면 다시 모은다 — ' + + '주소록은 요청마다 엔진 상태를 확인한 주소록에서 붙인다(연락처를 바꾸면 바로 보인다). Cache-Control: private, no-store.', }) @ApiStandardResponse(RecipientSuggestionPageDto, { description: '추천 목록 (total 은 걸러진 개수)', diff --git a/backend/src/modules/mail/recipient-suggestions.service.spec.ts b/backend/src/modules/mail/recipient-suggestions.service.spec.ts index 13688c0..ec0e1d0 100644 --- a/backend/src/modules/mail/recipient-suggestions.service.spec.ts +++ b/backend/src/modules/mail/recipient-suggestions.service.spec.ts @@ -1,11 +1,21 @@ import { Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; +import type { ContactsService } from '@/modules/contacts/contacts.service'; +import type { + ContactSuggestionLine, + DirectoryEntry, +} from '@/modules/contacts/contacts.types'; +import type { DirectoryService } from '@/modules/contacts/directory.service'; import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import type { MailProvider } from './mail.provider'; import type { MailSettings } from './mail.settings'; -import type { Correspondence, DirectoryContact } from './mail.types'; +import type { + Correspondence, + DirectoryContact, + RecipientSuggestion, +} from './mail.types'; import { RecipientSuggestionService, SUGGESTION_CACHE_MAX_USERS, @@ -21,7 +31,11 @@ const settings: MailSettings = { gatewayPassword: 'secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const NOW = Date.parse('2026-10-27T03:00:00Z'); @@ -32,14 +46,46 @@ const query = ( overrides: Partial = {}, ): RecipientSuggestionQueryDto => ({ limit: 20, ...overrides }); +const directoryItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'directory', + company: null, + emailLabel: null, +}); +const recentItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'recent', + company: null, + emailLabel: null, +}); + +/** 엔진의 사내 목록 한 줄 */ +const entry = ( + id: string, + name: string, + email: string, + kind: DirectoryEntry['kind'] = 'person', +): DirectoryEntry => ({ id, name, email, kind }); + describe('RecipientSuggestionService', () => { let listCorrespondents: jest.Mock< Promise, Parameters >; let listContacts: jest.Mock, [number?]>; + let suggestionLines: jest.Mock, [string]>; + let directoryEntries: jest.Mock, []>; let service: RecipientSuggestionService; let debugSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW); @@ -59,6 +105,7 @@ describe('RecipientSuggestionService', () => { { name: null, email: 'admin@arasystem.test', at: daysAgo(0) }, { name: null, email: 'no-reply@service.test', at: daysAgo(0) }, ]); + // 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 listContacts = jest .fn, [number?]>() .mockResolvedValue([ @@ -66,14 +113,30 @@ describe('RecipientSuggestionService', () => { { email: 'doyun.lee@arasystem.test', name: '이도윤' }, { email: 'ara.kim@arasystem.test', name: '김아라' }, ]); + // 엔진의 사내 목록 — 걸러 내기는 사내 목록 서비스가 이미 했다 + directoryEntries = jest + .fn, []>() + .mockResolvedValue([ + entry('b', '박서준', 'seojun.park@arasystem.test'), + entry('d', '이도윤', 'doyun.lee@arasystem.test'), + entry('a', '김아라', 'ara.kim@arasystem.test'), + ]); + suggestionLines = jest + .fn, [string]>() + .mockResolvedValue([]); service = new RecipientSuggestionService( { listCorrespondents } as unknown as MailProvider, settings, { listContacts } as unknown as MailDirectoryService, + { suggestionLines } as unknown as ContactsService, + { entries: directoryEntries } as unknown as DirectoryService, ); debugSpy = jest .spyOn(Logger.prototype, 'debug') .mockImplementation(() => undefined); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); }); afterEach(() => jest.restoreAllMocks()); @@ -83,19 +146,11 @@ describe('RecipientSuggestionService', () => { expect(page.items).toEqual([ // 사내 묶음 안에서는 주고받은 사람이 먼저 - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory', - }, - { - name: '박서준', - email: 'seojun.park@arasystem.test', - source: 'directory', - }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), // 대소문자만 다른 주소는 한 사람 — 두 번 주고받았다 - { name: '고객사', email: 'client@partner.test', source: 'recent' }, - { name: null, email: 'vendor@supply.test', source: 'recent' }, + recentItem('고객사', 'client@partner.test'), + recentItem(null, 'vendor@supply.test'), ]); expect(page.pageInfo).toEqual({ hasNext: false, @@ -106,6 +161,144 @@ describe('RecipientSuggestionService', () => { sent: 500, received: 500, }); + // 사내는 엔진의 사내 목록 — 로그인 기록은 읽지 않는다 + expect(directoryEntries).toHaveBeenCalledTimes(1); + expect(listContacts).not.toHaveBeenCalled(); + }); + + it('주소록 → 사내 → 최근 — 주소록은 주소마다 한 줄(회사 · 종류), 주소록에 있는 주소는 뒤 묶음에서 빠진다', async () => { + suggestionLines.mockResolvedValue([ + { + name: '고객사 담당', + email: 'Client@Partner.test', + company: '파트너', + emailLabel: 'work', + }, + { + name: '고객사 담당', + email: 'home@partner.test', + company: '파트너', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + company: null, + emailLabel: 'work', + }, + ]); + + const page = await service.suggest(ARA, query()); + + expect(page.items).toEqual([ + { + name: '고객사 담당', + email: 'Client@Partner.test', + source: 'contact', + company: '파트너', + emailLabel: 'work', + }, + { + name: '고객사 담당', + email: 'home@partner.test', + source: 'contact', + company: '파트너', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + source: 'contact', + company: null, + emailLabel: 'work', + }, + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'vendor@supply.test'), + ]); + expect(page.pageInfo.total).toBe(5); + expect(suggestionLines).toHaveBeenCalledWith('ara.kim@arasystem.test'); + }); + + it('연락처를 바꾸면 다음 추천에 바로 보인다 — 주소록 묶음은 요청마다 붙이고, 최근 주고받은 주소는 다시 모으지 않는다', async () => { + await service.suggest(ARA, query()); + suggestionLines.mockResolvedValue([ + { + name: '새 거래처', + email: 'new@client.test', + company: null, + emailLabel: 'other', + }, + ]); + + const page = await service.suggest(ARA, query({ q: 'client.test' })); + + expect(page.items.map((item) => [item.source, item.email])).toEqual([ + ['contact', 'new@client.test'], + ]); + expect(suggestionLines).toHaveBeenCalledTimes(2); + expect(listCorrespondents).toHaveBeenCalledTimes(1); + }); + + it('주소록을 읽지 못하면 그 묶음만 빼고 답한다 — warn 로그(주소 없이)', async () => { + suggestionLines.mockRejectedValue( + new BusinessException('MAIL_001', { + context: { reason: 'http-error' }, + }), + ); + + const page = await service.suggest(ARA, query()); + + expect(page.pageInfo.total).toBe(4); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('주소록을 읽지 못해'); + expect(logged).toContain('MAIL_001/http-error'); + expect(logged).not.toContain('ara.kim@'); + }); + + it('사내 목록을 읽지 못하면 로그인한 직원 기록으로 대신한다 — 알림은 10분에 한 번', async () => { + directoryEntries.mockRejectedValue( + new BusinessException('MAIL_001', { + context: { reason: 'directory-account-missing' }, + }), + ); + + const page = await service.suggest(ARA, query()); + expect(page.items.slice(0, 2)).toEqual([ + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + ]); + expect(listContacts).toHaveBeenCalledTimes(1); + + // 다른 직원이 모아도 10분 안에는 다시 알리지 않는다 + await service.suggest( + { email: 'doyun.lee@arasystem.test', name: '이도윤' }, + query(), + ); + const fallbackWarnings = () => + (warnSpy.mock.calls as unknown[][]) + .map(([message]) => String(message)) + .filter((message) => + message.includes('로그인한 직원 기록으로 대신합니다'), + ); + expect(listContacts).toHaveBeenCalledTimes(2); + expect(fallbackWarnings()).toHaveLength(1); + expect(fallbackWarnings()[0]).toContain( + 'MAIL_001/directory-account-missing', + ); + }); + + it('사내 목록의 공용 주소도 사내로 추천한다', async () => { + directoryEntries.mockResolvedValue([ + entry('s', '영업팀', 'sales@arasystem.test', 'shared'), + ]); + + const page = await service.suggest(ARA, query({ q: 'sales' })); + + expect(page.items).toEqual([ + directoryItem('영업팀', 'sales@arasystem.test'), + ]); }); it('커서 페이지네이션 — 걸러진 목록의 다음 위치, total 은 걸러진 개수', async () => { @@ -165,7 +358,8 @@ describe('RecipientSuggestionService', () => { expect((error as BusinessException).code).toBe('VAL_001'); } expect(listCorrespondents).not.toHaveBeenCalled(); - expect(listContacts).not.toHaveBeenCalled(); + expect(directoryEntries).not.toHaveBeenCalled(); + expect(suggestionLines).not.toHaveBeenCalled(); }); it('사용자마다 10분 기억한다 — 그 안에서는 다시 모으지 않고, 지나면 다시 모은다', async () => { diff --git a/backend/src/modules/mail/recipient-suggestions.service.ts b/backend/src/modules/mail/recipient-suggestions.service.ts index 1bb5e9a..9c5e2c1 100644 --- a/backend/src/modules/mail/recipient-suggestions.service.ts +++ b/backend/src/modules/mail/recipient-suggestions.service.ts @@ -5,8 +5,14 @@ import { type PaginatedResult, toPaginatedResult, } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { DIRECTORY_FAILURE_LOG_INTERVAL_MS } from '@/modules/contacts/contacts.constants'; +import { ContactsService } from '@/modules/contacts/contacts.service'; +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import { DirectoryService } from '@/modules/contacts/directory.service'; import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import { SUGGESTION_RECEIVED_SCAN, @@ -14,10 +20,11 @@ import { } from './mail.constants'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; -import type { RecipientSuggestion } from './mail.types'; +import type { DirectoryContact, RecipientSuggestion } from './mail.types'; import { filterRecipientSuggestions, rankRecipientSuggestions, + withContactSuggestions, } from './recipient-ranking'; import { decodeSuggestionCursor, @@ -50,11 +57,21 @@ function normalizeUser(email: string): string { return email.trim().toLowerCase(); } +/** 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다) */ +function failureSummary(error: BusinessException): string { + const reason = error.context?.reason; + return typeof reason === 'string' ? `${error.code}/${reason}` : error.code; +} + /** - * 받는 사람 추천 (계약 2절 "받는 사람 추천") — 사내 목록 + 그 사람이 최근 주고받은 주소. + * 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") — 내 주소록 → 사내 목록 → 그 사람이 최근 주고받은 주소. * * - 주고받은 주소는 **요청한 직원 자신의** 메일함에서만 모은다 (대리 접근이 그 사람으로만 들어간다). 다른 직원의 것과 섞지 않는다 - * - 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다 + * - 사내는 엔진의 사내 목록(contacts/directory.service.ts — 모든 직원이 함께 10분 기억), 읽지 못하면 로그인한 직원 기록(최근 90일)으로 대신한다 + * - 사내 · 최근은 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다 + * - 주소록 묶음은 기억하지 않고 요청마다 붙인다 — 주소록 모듈이 판(엔진 상태)을 확인한 주소록에서 만든다. 그래서 연락처를 바꾸면 + * (만들기 · 고치기 · 지우기 · 그룹 구성원) 다음 추천에 바로 보인다(계약의 "추천 캐시를 지운다"와 같은 결과 — 최근 주고받은 주소는 다시 모으지 않는다). + * 주소록을 읽지 못하면 그 묶음만 빼고 답한다 * - 로그에는 개수만 남긴다 — 주소·검색어는 남기지 않는다 * * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 (틀리지는 않고, 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 줄 수 있다). @@ -66,11 +83,16 @@ export class RecipientSuggestionService { /** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */ private readonly collecting = new Map(); private readonly excluded: ReadonlySet; + /** 사내 목록을 읽지 못해 로그인 기록으로 대신했다고 마지막으로 알린 때 — 계정이 없는 동안 모을 때마다 남기지 않게 */ + private lastDirectoryFallbackReportAt = Number.NEGATIVE_INFINITY; constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, @Inject(MAIL_SETTINGS) settings: MailSettings, - private readonly directory: MailDirectoryService, + /** 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 */ + private readonly loginDirectory: MailDirectoryService, + private readonly contacts: ContactsService, + private readonly directory: DirectoryService, ) { this.excluded = new Set([ ...settings.blockedAccounts, @@ -86,8 +108,12 @@ export class RecipientSuggestionService { // 커서는 엔진에 묻기 전에 확인한다 — 틀린 커서로 목록을 모으지 않게 const start = query.cursor ? decodeSuggestionCursor(query.cursor, q) : 0; + const [ranked, contactLines] = await Promise.all([ + this.suggestionsOf(user.email), + this.contactLinesOf(user.email), + ]); const matched = filterRecipientSuggestions( - await this.suggestionsOf(user.email), + withContactSuggestions(contactLines, ranked, user.email, this.excluded), q, ); const items = matched.slice(start, start + query.limit); @@ -142,7 +168,7 @@ export class RecipientSuggestionService { sent: SUGGESTION_SENT_SCAN, received: SUGGESTION_RECEIVED_SCAN, }), - this.directory.listContacts(), + this.colleagues(), ]); const items = rankRecipientSuggestions({ me: user, @@ -164,6 +190,48 @@ export class RecipientSuggestionService { return items; } + /** 주소록 묶음 — 읽지 못하면(엔진 장애 등) 그 묶음만 빼고 warn 로그를 남긴다 */ + private async contactLinesOf( + userEmail: string, + ): Promise { + try { + return await this.contacts.suggestionLines(userEmail); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + this.logger.warn( + `[${error.code}]${traceTag()} 주소록을 읽지 못해 받는 사람 추천에서 주소록 묶음을 뺐습니다: user=${maskEmail(userEmail)}, 원인=${failureSummary(error)}`, + ); + return []; + } + } + + /** 사내 — 엔진의 사내 목록, 읽지 못하면(사내 목록 계정이 없음 · 권한 · 엔진 장애) 로그인한 직원 기록 */ + private async colleagues(): Promise { + try { + const entries = await this.directory.entries(); + return entries.map(({ email, name }) => ({ email, name })); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + this.reportDirectoryFallback(error); + return this.loginDirectory.listContacts(); + } + } + + private reportDirectoryFallback(error: BusinessException): void { + const now = Date.now(); + if ( + now - this.lastDirectoryFallbackReportAt < + DIRECTORY_FAILURE_LOG_INTERVAL_MS + ) { + return; + } + this.lastDirectoryFallbackReportAt = now; + this.logger.warn( + `[${error.code}]${traceTag()} 사내 목록을 읽지 못해 받는 사람 추천의 "사내"를 로그인한 직원 기록으로 대신합니다 — ` + + `사내 목록 계정(MAIL_DIRECTORY_LOCAL_PART)과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요. 원인=${failureSummary(error)} (같은 알림은 10분에 한 번)`, + ); + } + private remember(user: string, items: readonly RecipientSuggestion[]): void { const now = Date.now(); // 만료된 목록은 그때그때 지운다 — 다시 오지 않은 사람의 목록이 상한까지 쌓여 메모리를 잡지 않게 diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index b7c0224..988f083 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -279,6 +279,80 @@ describe('validateEnv', () => { } }); + it('사내 목록 계정 아이디(MAIL_DIRECTORY_LOCAL_PART)는 선택 — 없거나 비면 기본값, 관리자 아이디와 같은 모양 규칙', () => { + for (const value of ['', ' ', 'mail-directory', 'Company.Directory']) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).not.toThrow(); + } + for (const value of ['mail-directory@arasystem.test', '@dir', 'dir@']) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).toThrow( + /MAIL_DIRECTORY_LOCAL_PART 는 주소가 아니라 앞부분\(아이디\)만/, + ); + } + for (const value of [ + 'dir%mail-gateway', + 'mail directory', + '.dir', + 'dir.', + '사내목록', + 'a'.repeat(65), + ]) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).toThrow(/MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자/); + } + }); + + it('사내 목록 계정 아이디가 관리자 · 게이트웨이 아이디와 같으면 기동을 실패시킨다 — 비면 기본값으로, 대소문자 없이 견준다', () => { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'Admin', + }), + ).toThrow(/관리자 아이디\(MAIL_ADMIN_LOCAL_PART\)와 같습니다/); + // 사내 목록 계정 아이디를 비워 두면 기본값(mail-directory)으로 견준다 + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_ADMIN_LOCAL_PART: 'Mail-Directory', + }), + ).toThrow(/관리자 아이디\(MAIL_ADMIN_LOCAL_PART\)와 같습니다/); + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'mail-gateway', + MAIL_GATEWAY_USER: 'Mail-Gateway@arasystem.test', + }), + ).toThrow(/게이트웨이 아이디\(MAIL_GATEWAY_USER\)와 같습니다/); + expect(() => + validateEnv( + prod({ MAIL_GATEWAY_USER: 'mail-directory@arasystem.it.kr' }), + ), + ).toThrow(/게이트웨이 아이디\(MAIL_GATEWAY_USER\)와 같습니다/); + + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'company-directory', + MAIL_ADMIN_LOCAL_PART: 'mail-admin', + MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test', + }), + ).not.toThrow(); + expect(() => validateEnv(prod())).not.toThrow(); + }); + it('로그인을 막는 계정 목록(MAIL_BLOCKED_ACCOUNTS)은 주소 전체여야 한다 — 아이디만 쓰면 아무것도 막지 못한다', () => { for (const value of [ 'admin', diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 3d51093..4a14a7c 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -14,7 +14,11 @@ import { REFRESH_TTL_MAX_SECONDS, } from '@/modules/auth/auth.constants'; import { isLocalPart } from '@/modules/auth/employee-address'; -import { parseAccountList } from '@/modules/mail/mail.settings'; +import { + DEFAULT_MAIL_ADMIN_LOCAL_PART, + DEFAULT_MAIL_DIRECTORY_LOCAL_PART, + parseAccountList, +} from '@/modules/mail/mail.settings'; import { parseDurationSeconds } from '@/shared/utils/duration'; import { isPositiveIntString } from '@/shared/utils/env'; import { parseOriginList } from '@/shared/utils/request-origin'; @@ -265,6 +269,51 @@ function isAccountAddress(value: string): boolean { ); } +/** + * 사내 목록 계정 아이디(선택, 기본 mail-directory) — 백엔드는 `<아이디>@` 으로 엔진의 계정 목록을 읽고 그 주소의 로그인을 막는다 + * (mail.settings.ts). 메일 엔진 설정 스크립트가 같은 env 로 그 계정을 만든다(같은 규칙). 관리자 · 게이트웨이와 같은 아이디면 + * 그 계정의 권한이 섞이므로 기동을 막는다 — 비교는 실제로 쓰는 값(비면 기본값)으로, 소문자로 한다 + */ +function validateDirectoryLocalPart( + config: Record, + adminLocalPart: string | undefined, + gatewayUser: string | undefined, + errors: string[], +): void { + const raw = readString(config, 'MAIL_DIRECTORY_LOCAL_PART'); + if (raw !== undefined && !isLocalPart(raw)) { + errors.push( + raw.includes('@') + ? `MAIL_DIRECTORY_LOCAL_PART 는 주소가 아니라 앞부분(아이디)만 씁니다 — '@' 와 도메인을 빼 주세요. (예: mail-directory, 현재 값: "${raw}")` + : `MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·'.'·'_'·'-' 로 된 아이디여야 합니다 (앞뒤는 영문·숫자, 64자 이하). (예: mail-directory, 현재 값: "${raw}")`, + ); + return; + } + + const directory = (raw ?? DEFAULT_MAIL_DIRECTORY_LOCAL_PART).toLowerCase(); + // 관리자 아이디가 형식부터 틀렸으면 그 오류만 알린다 (위에서 이미 알렸다) + if (adminLocalPart === undefined || isLocalPart(adminLocalPart)) { + const admin = ( + adminLocalPart ?? DEFAULT_MAIL_ADMIN_LOCAL_PART + ).toLowerCase(); + if (directory === admin) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART(${directory})가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.`, + ); + } + } + const at = gatewayUser?.lastIndexOf('@') ?? -1; + const gatewayLocal = + gatewayUser !== undefined && at > 0 + ? gatewayUser.slice(0, at).toLowerCase() + : null; + if (gatewayLocal !== null && directory === gatewayLocal) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART(${directory})가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.`, + ); + } +} + /** * 메일 설정 형식 검사 (값이 있을 때만, 모든 환경). * 형식이 틀리면 기동은 되는데 로그인이 전부 실패하는 조용한 장애가 되므로 먼저 막는다. @@ -306,6 +355,8 @@ function validateMailSettings( ); } + validateDirectoryLocalPart(config, adminLocalPart, gatewayUser, errors); + // 막는 계정은 주소 전체로 비교한다 — 아이디(admin)만 쓰면 아무것도 막지 못하는 조용한 설정 실수가 된다 const invalidBlocked = parseAccountList( readString(config, 'MAIL_BLOCKED_ACCOUNTS'), diff --git a/backend/src/shared/utils/offset-cursor.spec.ts b/backend/src/shared/utils/offset-cursor.spec.ts new file mode 100644 index 0000000..31b4bda --- /dev/null +++ b/backend/src/shared/utils/offset-cursor.spec.ts @@ -0,0 +1,52 @@ +import { decodeOffsetCursor, encodeOffsetCursor } from './offset-cursor'; + +const encoded = (payload: unknown) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +describe('위치(offset) 커서', () => { + it('같은 범위로 되돌리면 위치가 그대로다', () => { + for (const scope of [ + '', + '도윤', + JSON.stringify(['contacts', 'ㅂㅈㅎ', 'g1']), + ]) { + expect(decodeOffsetCursor(encodeOffsetCursor(40, scope), scope)).toEqual({ + offset: 40, + }); + } + }); + + it('다른 범위의 커서는 other-query — 걸러진 목록이 달라 위치가 맞지 않는다', () => { + const cursor = encodeOffsetCursor( + 20, + JSON.stringify(['contacts', null, 'g1']), + ); + expect( + decodeOffsetCursor(cursor, JSON.stringify(['contacts', null, 'g2'])), + ).toEqual({ problem: 'other-query' }); + expect(decodeOffsetCursor(cursor, '')).toEqual({ problem: 'other-query' }); + }); + + it('형식이 틀리면 그 까닭 — 너무 김 · JSON 아님 · 모르는 판 · 음수 · 소수 · 문자열 위치 · 범위 없음', () => { + const { s } = JSON.parse( + Buffer.from(encodeOffsetCursor(0, ''), 'base64url').toString('utf8'), + ) as { s: string }; + + expect(decodeOffsetCursor('x'.repeat(257), '')).toEqual({ + problem: 'too-long', + }); + expect(decodeOffsetCursor('@@@', '')).toEqual({ problem: 'not-json' }); + for (const payload of [ + null, + { v: 2, o: 0, s }, + { v: 1, o: -1, s }, + { v: 1, o: 1.5, s }, + { v: 1, o: '1', s }, + { v: 1, o: 1 }, + ]) { + expect(decodeOffsetCursor(encoded(payload), '')).toEqual({ + problem: 'malformed', + }); + } + }); +}); diff --git a/backend/src/shared/utils/offset-cursor.ts b/backend/src/shared/utils/offset-cursor.ts new file mode 100644 index 0000000..ab1bc8c --- /dev/null +++ b/backend/src/shared/utils/offset-cursor.ts @@ -0,0 +1,72 @@ +import { createHash } from 'crypto'; + +/** + * 위치(offset) 커서 (순수 함수) — 서버가 기억해 두거나 그 자리에서 거른 목록에서 다음 항목의 위치를 클라이언트에게 불투명 문자열로 준다. + * 다른 목록(검색어 · 그룹 등)의 커서를 섞어 쓰지 못하도록 목록 범위(scope)의 해시를 함께 넣는다 — 범위는 부르는 쪽이 정한다. + * 받는 사람 추천(modules/mail/recipient-suggestion-cursor.ts)과 주소록 · 사내 목록(modules/contacts)이 쓴다. + * + * 목록이 그사이 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다 (계약 3절 — 위치만 기억한다). + * 해시에는 검색어가 들어간다 — 요청 주소 로그에서는 cursor 를 길이만 남긴다 (mask.ts 의 maskUrlForLog) + */ +const CURSOR_VERSION = 1; +const MAX_CURSOR_LENGTH = 256; +const SCOPE_HASH_LENGTH = 16; + +/** 해석하지 못한 까닭 — 너무 김 · JSON 아님 · 모양이 틀림 · 다른 범위의 커서 */ +export type OffsetCursorProblem = + | 'too-long' + | 'not-json' + | 'malformed' + | 'other-query'; + +export type OffsetCursorReading = + | { readonly offset: number } + | { readonly problem: OffsetCursorProblem }; + +interface EncodedCursor { + v: number; + o: number; + s: string; +} + +function scopeHash(scope: string): string { + return createHash('sha256') + .update(scope) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +export function encodeOffsetCursor(offset: number, scope: string): string { + const payload: EncodedCursor = { + v: CURSOR_VERSION, + o: offset, + s: scopeHash(scope), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 해석한다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */ +export function decodeOffsetCursor( + cursor: string, + scope: string, +): OffsetCursorReading { + if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' }; + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + return { problem: 'not-json' }; + } + + const { v, o, s } = (parsed ?? {}) as Partial; + const valid = + v === CURSOR_VERSION && + typeof o === 'number' && + Number.isSafeInteger(o) && + o >= 0 && + typeof s === 'string'; + if (!valid) return { problem: 'malformed' }; + if (s !== scopeHash(scope)) return { problem: 'other-query' }; + return { offset: o }; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index bd16bf1..46f397d 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -15,6 +15,19 @@ import { AppModule } from '@/app.module'; import { configureApp } from '@/app.setup'; import { BusinessException } from '@/common/errors/business.exception'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; +import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants'; +import { + CONTACTS_PROVIDER, + DIRECTORY_PROVIDER, + type AddressBookCard, + type AddressBookChanges, + type AddressBookContactCard, + type AddressBookGroupCard, + type AddressBookSnapshot, + type AddressBookWriteOutcome, + type DirectoryAccount, + type RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, @@ -125,6 +138,9 @@ const CORRECT_PASSWORD = 'correct-password-for-e2e'; /** 메일 엔진 관리자 주소 — 로그인할 수 없는 계정 (mail.settings.ts 의 blockedAccounts) */ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test'; +/** 사내 목록 계정 — 로그인할 수 없고 엔진의 계정 목록을 읽는 데만 쓴다 (mail.settings.ts 의 directoryAccount — 8단계) */ +const DIRECTORY_ACCOUNT = 'mail-directory@e2e.arasystem.test'; + /** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test'; @@ -322,9 +338,20 @@ const TEST_MAIL_SETTINGS: MailSettings = { gatewayPassword: null, attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set([ADMIN_ACCOUNT]), + blockedAccounts: new Set([ADMIN_ACCOUNT, DIRECTORY_ACCOUNT]), + directoryAccount: DIRECTORY_ACCOUNT, }; +/** 목록 응답의 data — 계약의 { items, pageInfo } */ +interface Page { + items: T[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number | null; + }; +} + describe('응답 계약 (e2e)', () => { let app: NestExpressApplication; let server: App; @@ -393,6 +420,144 @@ describe('응답 계약 (e2e)', () => { closed: boolean; }[] = []; + /** + * 8단계 — 대역 엔진의 주소록(직원마다 하나) — 판(state)이 같을 때만 쓰고 쓸 때마다 판이 오른다 (ifInState). + * 카드 모양(JSContact) · 엔진의 거절은 jmap-contacts.provider.spec.ts 가 본다 + */ + const addressBooks = new Map< + string, + { + state: number; + contacts: Map; + groups: Map; + } + >(); + let addressBookSequence = 0; + const addressBookOf = (user: string) => { + let book = addressBooks.get(user); + if (!book) { + book = { state: 1, contacts: new Map(), groups: new Map() }; + addressBooks.set(user, book); + } + return book; + }; + const fakeContactsProvider = { + getAddressBookVersion: (user: string) => + Promise.resolve(`v${addressBookOf(user).state}`), + readAddressBook: (user: string): Promise => { + const book = addressBookOf(user); + return Promise.resolve({ + version: `v${book.state}`, + contacts: [...book.contacts.values()], + groups: [...book.groups.values()], + }); + }, + writeAddressBook: ( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise => { + const book = addressBookOf(user); + if (version !== `v${book.state}`) { + return Promise.resolve({ kind: 'conflict' }); + } + const createdIds = new Map(); + const cards = new Map(); + const removed = new Map(); + for (const id of changes.destroy) { + const card = book.contacts.get(id) ?? book.groups.get(id); + if (card) { + const source = JSON.stringify(card); + removed.set(id, { source, bytes: Buffer.byteLength(source) }); + } + } + for (const card of changes.create) { + addressBookSequence += 1; + const id = `card${addressBookSequence}`; + createdIds.set(card.key, id); + if (card.kind === 'contact') { + const stored = { id, uid: card.uid, ...card.fields }; + book.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else if (card.kind === 'group') { + const stored = { + id, + uid: card.uid, + name: card.name, + memberUids: [...card.memberUids], + }; + book.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } else if (card.card.kind === 'contact') { + const stored = { ...card.card.card, id, uid: card.uid }; + book.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else { + const stored = { + ...card.card.card, + id, + uid: card.uid, + memberUids: [...(card.memberUids ?? [])], + }; + book.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } + } + for (const update of changes.update) { + if (update.kind === 'contact') { + const current = book.contacts.get(update.id); + if (current) { + const stored = { + ...current, + ...Object.fromEntries( + Object.entries(update.fields).filter( + ([, value]) => value !== undefined, + ), + ), + }; + book.contacts.set(update.id, stored); + cards.set(update.id, { kind: 'contact', card: stored }); + } + } else { + const current = book.groups.get(update.id); + if (current) { + book.groups.set(update.id, { + ...current, + name: update.name ?? current.name, + memberUids: update.memberUids + ? [...update.memberUids] + : current.memberUids, + }); + } + } + } + for (const id of changes.destroy) { + book.contacts.delete(id); + book.groups.delete(id); + } + book.state += 1; + return Promise.resolve({ + kind: 'written', + version: `v${book.state}`, + createdIds, + cards, + removed, + }); + }, + }; + /** 8단계 — 대역 엔진의 계정 목록. null 이면 읽지 못한다(사내 목록 계정이 없음 — 받는 사람 추천은 로그인 기록으로 대신한다) */ + let directoryAccounts: DirectoryAccount[] | null = null; + const fakeDirectoryProvider = { + listAccounts: () => + directoryAccounts + ? Promise.resolve(directoryAccounts) + : Promise.reject( + new BusinessException('MAIL_001', { + context: { reason: 'e2e-directory-account-missing' }, + }), + ), + }; + /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => jwt.sign(payload, { @@ -817,6 +982,10 @@ describe('응답 계약 (e2e)', () => { }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) + .overrideProvider(CONTACTS_PROVIDER) + .useValue(fakeContactsProvider) + .overrideProvider(DIRECTORY_PROVIDER) + .useValue(fakeDirectoryProvider) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1107,6 +1276,17 @@ describe('응답 계약 (e2e)', () => { expect(engineChecks.has(ADMIN_ACCOUNT)).toBe(false); }); + it('사내 목록 계정(8단계)도 로그인할 수 없다 — 401 AUTH_004 이고 메일 엔진에 묻지 않는다', async () => { + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '203.0.113.21') + .send({ email: DIRECTORY_ACCOUNT, password: CORRECT_PASSWORD }) + .expect(401); + + expect(body(res).error?.code).toBe('AUTH_004'); + expect(engineChecks.has(DIRECTORY_ACCOUNT)).toBe(false); + }); + it('쿠키 없이 /auth/me 는 401 AUTH_001', async () => { const res = await request(server).get('/api/auth/me').expect(401); @@ -1888,7 +2068,13 @@ describe('응답 계약 (e2e)', () => { .query(params) .set('Cookie', cookieOf(ME)); interface SuggestionPage { - items: { name: string | null; email: string; source: string }[]; + items: { + name: string | null; + email: string; + source: string; + company: string | null; + emailLabel: string | null; + }[]; pageInfo: { hasNext: boolean; nextCursor: string | null; @@ -1914,7 +2100,7 @@ describe('응답 계약 (e2e)', () => { ]); }); - it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => { + it('사내(사내 목록을 읽지 못하면 로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => { // 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다 const res = await suggest({ q: 'e2e.arasystem.test', @@ -1928,6 +2114,8 @@ describe('응답 계약 (e2e)', () => { name: '가나다', email: COLLEAGUE_A, source: 'directory', + company: null, + emailLabel: null, }); // 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만 expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan( @@ -1938,6 +2126,8 @@ describe('응답 계약 (e2e)', () => { name: '협력사', email: 'partner@vendor.e2e.arasystem.test', source: 'recent', + company: null, + emailLabel: null, }); expect(emails).not.toContain(ME); expect(emails).not.toContain('mail-gateway@e2e.arasystem.test'); @@ -3466,6 +3656,726 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 8단계 — 주소록 (계약 3절). 엔진 쪽 판단(JSContact 카드 모양 · ifInState · 계정 목록 읽기)은 jmap-contacts.provider.spec.ts · + * contact-card.spec.ts · jmap-directory.provider.spec.ts, 찾기 · 차례 · 한도 · 그룹 정리는 contacts.service.spec.ts 가 본다 — + * 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 에러 코드 · 응답 형식 · 머리글을 본다 + */ + describe('8단계 — 주소록 (/api/contacts · /api/contact-groups · /api/directory-entries)', () => { + const ME = 'contacts.me@e2e.arasystem.test'; + interface ContactBody { + id: string; + name: string | null; + emails: { address: string; label: string }[]; + phones: { number: string; label: string }[]; + company: string | null; + department: string | null; + title: string | null; + note: string | null; + groupIds: string[]; + } + interface GroupBody { + id: string; + name: string; + memberCount: number; + } + interface DirectoryEntryBody { + id: string; + name: string; + email: string; + kind: string; + } + const contactInput = (overrides: Record = {}) => ({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당', + groupIds: [], + ...overrides, + }); + const asUser = (email: string) => cookieOf(email); + const get = (path: string, user = ME) => + request(server).get(path).set('Cookie', asUser(user)); + const post = (path: string, user = ME) => + request(server).post(path).set('Cookie', asUser(user)); + const patch = (path: string, user = ME) => + request(server).patch(path).set('Cookie', asUser(user)); + const del = (path: string, user = ME) => + request(server).delete(path).set('Cookie', asUser(user)); + const expectCode = (res: Response, code: string) => { + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe(code); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + }; + + it('로그인 없이는 401 AUTH_001', async () => { + for (const [method, path] of [ + ['get', '/api/contacts'], + ['post', '/api/contacts'], + ['get', '/api/contacts/abc'], + ['patch', '/api/contacts/abc'], + ['delete', '/api/contacts/abc'], + ['get', '/api/contact-groups'], + ['post', '/api/contact-groups'], + ['patch', '/api/contact-groups/abc'], + ['delete', '/api/contact-groups/abc'], + ['post', '/api/contact-groups/abc/members'], + ['post', '/api/contact-restorations'], + ['get', '/api/directory-entries'], + ['get', '/api/directory-entries/abc'], + ] as const) { + const res = await request(server)[method](path).expect(401); + expectCode(res, 'AUTH_001'); + } + }); + + it('사내 목록을 읽지 못하면(사내 목록 계정이 없음) 503 MAIL_001', async () => { + directoryAccounts = null; + const res = await get('/api/directory-entries').expect(503); + expectCode(res, 'MAIL_001'); + }); + + it('사내 목록 — 관리자 · 게이트웨이 · 사내 목록 계정 · 바깥 주소는 빠지고 이름 차례, kind · q(초성) · 커서, Cache-Control: private, no-store', async () => { + directoryAccounts = [ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: ADMIN_ACCOUNT, + }, + { + id: 'g', + kind: 'person', + displayName: '메일 게이트웨이 (백엔드 대리 접근 전용) [setup.mjs]', + email: 'mail-gateway@e2e.arasystem.test', + }, + { + id: 'm', + kind: 'person', + displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]', + email: DIRECTORY_ACCOUNT, + }, + { + id: 'd', + kind: 'person', + displayName: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + }, + { + id: 'k', + kind: 'person', + displayName: '김아라', + email: 'ara.kim@e2e.arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@e2e.arasystem.test', + }, + { + id: 'x', + kind: 'person', + displayName: '바깥', + email: 'outside@partner.e2e.example', + }, + ]; + + const res = await get('/api/directory-entries').expect(200); + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(body(res).success).toBe(true); + expect(dataOf>(res)).toEqual({ + items: [ + { + id: 'k', + name: '김아라', + email: 'ara.kim@e2e.arasystem.test', + kind: 'person', + }, + { + id: 's', + name: '영업팀', + email: 'sales@e2e.arasystem.test', + kind: 'shared', + }, + { + id: 'd', + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + kind: 'person', + }, + ], + pageInfo: { hasNext: false, nextCursor: null, total: 3 }, + }); + + const idsOf = async (query: Record) => + dataOf>( + await get('/api/directory-entries').query(query).expect(200), + ).items.map((entry) => entry.id); + expect(await idsOf({ kind: 'shared' })).toEqual(['s']); + expect(await idsOf({ q: 'ㄷㅇ' })).toEqual(['d']); + expect(await idsOf({ q: 'ARA.KIM' })).toEqual(['k']); + + const first = dataOf>( + await get('/api/directory-entries').query({ limit: 1 }).expect(200), + ); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + const cursor = first.pageInfo.nextCursor ?? ''; + expect(await idsOf({ limit: 1, cursor })).toEqual(['s']); + + for (const query of [ + { cursor, kind: 'person' }, + { cursor: 'not-a-cursor' }, + { kind: 'group' }, + { q: 'x'.repeat(255) }, + { limit: 101 }, + ]) { + expectCode( + await get('/api/directory-entries').query(query).expect(400), + 'VAL_001', + ); + } + // 메일 주소 하나(254자)는 찾을 수 있다 + expect(await idsOf({ q: 'x'.repeat(254) })).toEqual([]); + + // 한 항목 — 기억한 목록에서, 빼는 계정 · 없는 id · 형식이 틀린 id 는 404 RES_001 + const one = await get('/api/directory-entries/d').expect(200); + expect(one.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(one)).toEqual({ + id: 'd', + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + kind: 'person', + }); + for (const id of ['a', 'm', 'nope', 'bad.id']) { + expectCode( + await get(`/api/directory-entries/${id}`).expect(404), + 'RES_001', + ); + } + }); + + it('사내 목록 excludeGroupId — 내 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다, 없는 그룹은 404 RES_001', async () => { + const PICKER = 'contacts.picker@e2e.arasystem.test'; + const group = dataOf( + await post('/api/contact-groups', PICKER) + .send({ name: '팀' }) + .expect(201), + ); + const member = dataOf( + await post('/api/contacts', PICKER) + .send( + contactInput({ + emails: [ + { address: 'Doyun.Lee@e2e.arasystem.test', label: 'work' }, + ], + groupIds: [group.id], + }), + ) + .expect(201), + ); + expect(member.groupIds).toEqual([group.id]); + + const ids = dataOf>( + await get('/api/directory-entries', PICKER) + .query({ excludeGroupId: group.id }) + .expect(200), + ).items.map((entry) => entry.id); + expect(ids).toEqual(['k', 's']); + expectCode( + await get('/api/directory-entries', PICKER) + .query({ excludeGroupId: 'nogroup' }) + .expect(404), + 'RES_001', + ); + }); + + it('연락처 추가 201 · 하나 · 목록(초성 · 전화번호 숫자 · 대소문자) · 고치기 · 지우기 — 표준 래퍼, 지운 뒤에는 404 RES_001', async () => { + const created = await post('/api/contacts') + .send( + contactInput({ name: ' 박지훈 ', note: '견적 담당\n월요일 오전' }), + ) + .expect(201); + expect(body(created).success).toBe(true); + const contact = dataOf(created); + expect(contact).toEqual({ + id: contact.id, + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', + groupIds: [], + }); + + const one = await get(`/api/contacts/${contact.id}`).expect(200); + expect(one.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(one)).toEqual(contact); + + for (const q of ['ㅂㅈㅎ', '31925540', 'HANBIT', '구매']) { + const page = dataOf>( + await get('/api/contacts').query({ q }).expect(200), + ); + expect(page.items.map((item) => item.id)).toEqual([contact.id]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 1, + }); + } + + // 고치기는 바꾼 칸만 — 보내지 않은 칸은 그대로 + const updated = await patch(`/api/contacts/${contact.id}`) + .send({ title: '부장', note: null }) + .expect(200); + expect(dataOf(updated)).toEqual({ + ...contact, + title: '부장', + note: null, + }); + + const removed = await del(`/api/contacts/${contact.id}`).expect(200); + const deleted = dataOf<{ contact: ContactBody; restoreToken: string }>( + removed, + ); + expect(deleted.contact).toMatchObject({ id: contact.id, title: '부장' }); + expect(deleted.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expectCode( + await get(`/api/contacts/${contact.id}`).expect(404), + 'RES_001', + ); + expectCode( + await del(`/api/contacts/${contact.id}`).expect(404), + 'RES_001', + ); + + // 되돌리기 — 새 id 로 그대로, 표는 한 번만 · 다른 직원은 쓸 수 없다 + expectCode( + await post( + '/api/contact-restorations', + 'someone.else@e2e.arasystem.test', + ) + .send({ token: deleted.restoreToken }) + .expect(404), + 'RES_001', + ); + const restored = await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(201); + const back = dataOf<{ kind: string; contact: ContactBody }>(restored); + expect(back.kind).toBe('contact'); + expect(back.contact).toEqual({ ...deleted.contact, id: back.contact.id }); + expect(back.contact.id).not.toBe(contact.id); + expectCode( + await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(404), + 'RES_001', + ); + for (const bad of [ + {}, + { token: 'short' }, + { token: deleted.restoreToken, extra: 1 }, + ]) { + expectCode( + await post('/api/contact-restorations').send(bad).expect(400), + 'VAL_001', + ); + } + // 다음 테스트를 위해 비운다 + await del(`/api/contacts/${back.contact.id}`).expect(200); + }); + + it('형식 오류는 400 VAL_001 — 이름도 이메일도 없음 · 같은 주소 두 번 · 전화번호 형식 · 칸이 빠짐 · 모르는 칸 / 형식이 틀린 id 는 404 RES_001', async () => { + const missingNote: Record = contactInput(); + delete missingNote.note; + for (const bad of [ + contactInput({ name: null, emails: [] }), + contactInput({ name: ' ', emails: [] }), + contactInput({ + emails: [ + { address: 'same@x.e2e.example', label: 'work' }, + { address: 'SAME@X.e2e.example', label: 'other' }, + ], + }), + contactInput({ phones: [{ number: '내선 12', label: 'work' }] }), + contactInput({ + emails: [{ address: 'not-an-address', label: 'work' }], + }), + missingNote, + contactInput({ birthday: '1990-05-01' }), + ]) { + expectCode( + await post('/api/contacts').send(bad).expect(400), + 'VAL_001', + ); + } + expectCode(await get('/api/contacts/bad.id').expect(404), 'RES_001'); + expectCode( + await patch('/api/contacts/bad.id').send({ title: 'x' }).expect(404), + 'RES_001', + ); + + // 고치기 — 빈 본문 · 모르는 칸 · 보낸 칸의 형식 오류 · 합친 결과에 이름도 이메일도 없음은 400, PUT 은 없다 + const VALIDATOR = 'contacts.validate@e2e.arasystem.test'; + const target = dataOf( + await post('/api/contacts', VALIDATOR) + .send(contactInput({ emails: [] })) + .expect(201), + ); + for (const bad of [ + {}, + { birthday: '1990-05-01' }, + { phones: [{ number: '내선', label: 'work' }] }, + { emails: null }, + { name: null }, + ]) { + expectCode( + await patch(`/api/contacts/${target.id}`, VALIDATOR) + .send(bad) + .expect(400), + 'VAL_001', + ); + } + expectCode( + await request(server) + .put(`/api/contacts/${target.id}`) + .set('Cookie', asUser(VALIDATOR)) + .send(contactInput()) + .expect(404), + 'RES_001', + ); + }); + + it('그룹 — 만들기 201 · 같은 이름(대소문자 무시) 409 CONTACT_001 · 이름 바꾸기 · 구성원 넣기(내 연락처 · 사내 목록) · 그룹의 구성원 목록 · 지우기와 [되돌리기]', async () => { + const group = dataOf( + await post('/api/contact-groups') + .send({ name: 'Partners' }) + .expect(201), + ); + expect(group).toEqual({ id: group.id, name: 'Partners', memberCount: 0 }); + expectCode( + await post('/api/contact-groups') + .send({ name: ' partners ' }) + .expect(409), + 'CONTACT_001', + ); + expectCode( + await post('/api/contact-groups').send({ name: '' }).expect(400), + 'VAL_001', + ); + + const renamed = await patch(`/api/contact-groups/${group.id}`) + .send({ name: '거래처' }) + .expect(200); + expect(dataOf(renamed)).toEqual({ + id: group.id, + name: '거래처', + memberCount: 0, + }); + + const contact = dataOf( + await post('/api/contacts').send(contactInput()).expect(201), + ); + const added = await post(`/api/contact-groups/${group.id}/members`) + .send({ contactIds: [contact.id], directoryIds: ['d'] }) + .expect(200); + expect(dataOf(added)).toEqual({ + group: { id: group.id, name: '거래처', memberCount: 2 }, + addedCount: 2, + createdCount: 1, + }); + // 다시 넣으면 세지 않는다 + expect( + dataOf<{ addedCount: number }>( + await post(`/api/contact-groups/${group.id}/members`) + .send({ contactIds: [contact.id], directoryIds: ['d'] }) + .expect(200), + ).addedCount, + ).toBe(0); + + const members = dataOf>( + await get('/api/contacts') + .query({ groupId: group.id, limit: 100 }) + .expect(200), + ); + expect(members.items.map((item) => item.emails[0]?.address)).toEqual([ + 'jihoon.park@hanbit.e2e.example', + 'doyun.lee@e2e.arasystem.test', + ]); + expect( + members.items.every((item) => item.groupIds.includes(group.id)), + ).toBe(true); + const groups = dataOf>( + await get('/api/contact-groups').expect(200), + ); + expect(groups.items).toContainEqual({ + id: group.id, + name: '거래처', + memberCount: 2, + }); + + expectCode( + await post(`/api/contact-groups/${group.id}/members`) + .send({}) + .expect(400), + 'VAL_001', + ); + for (const bad of [{ contactIds: ['nope'] }, { directoryIds: ['a'] }]) { + expectCode( + await post(`/api/contact-groups/${group.id}/members`) + .send(bad) + .expect(404), + 'RES_001', + ); + } + expectCode( + await patch('/api/contact-groups/nope').send({ name: 'x' }).expect(404), + 'RES_001', + ); + + const deleted = dataOf<{ + id: string; + name: string; + memberIds: string[]; + restoreToken: string; + }>(await del(`/api/contact-groups/${group.id}`).expect(200)); + expect(deleted).toEqual({ + id: group.id, + name: '거래처', + memberIds: members.items.map((item) => item.id), + restoreToken: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) as string, + }); + // 연락처는 그대로 — 그 그룹은 이제 없다 + expect( + dataOf>( + await get('/api/contacts').query({ limit: 100 }).expect(200), + ).items.map((item) => item.groupIds), + ).toEqual([[], []]); + expectCode( + await get('/api/contacts').query({ groupId: group.id }).expect(404), + 'RES_001', + ); + + // 만들기는 { name } 만 — 지운 그룹은 되돌리기로 + expectCode( + await post('/api/contact-groups') + .send({ name: deleted.name, memberIds: deleted.memberIds }) + .expect(400), + 'VAL_001', + ); + const restored = dataOf<{ kind: string; group: GroupBody }>( + await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(201), + ); + expect(restored).toMatchObject({ + kind: 'group', + group: { name: '거래처', memberCount: 2 }, + }); + expect(restored.group.id).not.toBe(group.id); + + // excludeGroupId — 그 그룹의 구성원을 빼고 (groupId 와 함께 쓰면 400) + expect( + dataOf>( + await get('/api/contacts') + .query({ excludeGroupId: restored.group.id }) + .expect(200), + ).items, + ).toEqual([]); + expectCode( + await get('/api/contacts') + .query({ + groupId: restored.group.id, + excludeGroupId: restored.group.id, + }) + .expect(400), + 'VAL_001', + ); + }); + + it(`한도 — 연락처 ${MAX_CONTACTS}개면 409 CONTACT_002 (아무것도 만들지 않는다)`, async () => { + const FULL = 'contacts.full@e2e.arasystem.test'; + const book = addressBookOf(FULL); + for (let index = 0; index < MAX_CONTACTS; index += 1) { + book.contacts.set(`f${index}`, { + id: `f${index}`, + uid: `urn:uuid:f${index}`, + name: `연락처 ${index}`, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }); + } + + expectCode( + await post('/api/contacts', FULL).send(contactInput()).expect(409), + 'CONTACT_002', + ); + expect(book.contacts.size).toBe(MAX_CONTACTS); + }); + + it('커서는 받은 그 q · groupId 로만 — 다르면 400 VAL_001, 없는 groupId 는 404 RES_001, limit 범위 밖은 400', async () => { + const LISTER = 'contacts.lister@e2e.arasystem.test'; + for (const name of ['가나다', '나비']) { + await post('/api/contacts', LISTER) + .send(contactInput({ name, emails: [] })) + .expect(201); + } + const first = dataOf>( + await get('/api/contacts', LISTER).query({ limit: 1 }).expect(200), + ); + expect(first.items.map((item) => item.name)).toEqual(['가나다']); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 }); + const cursor = first.pageInfo.nextCursor ?? ''; + expect( + dataOf>( + await get('/api/contacts', LISTER) + .query({ limit: 1, cursor }) + .expect(200), + ).items.map((item) => item.name), + ).toEqual(['나비']); + + expectCode( + await get('/api/contacts', LISTER) + .query({ cursor, q: '나' }) + .expect(400), + 'VAL_001', + ); + expectCode( + await get('/api/contacts', LISTER).query({ limit: 0 }).expect(400), + 'VAL_001', + ); + expectCode( + await get('/api/contacts', LISTER) + .query({ groupId: 'nogroup' }) + .expect(404), + 'RES_001', + ); + }); + + it('받는 사람 추천 — 주소록(회사 · 종류) → 사내(엔진의 사내 목록) → 최근, 연락처를 바꾸면 바로 보인다', async () => { + const SUGGESTER = 'contacts.suggest@e2e.arasystem.test'; + const now = new Date().toISOString(); + correspondents.set(SUGGESTER, [ + { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', at: now }, + { name: '협력사', email: 'vendor@partner.e2e.example', at: now }, + ]); + const suggestions = async () => + dataOf< + Page<{ + name: string | null; + email: string; + source: string; + company: string | null; + emailLabel: string | null; + }> + >( + await request(server) + .get('/api/recipient-suggestions') + .query({ limit: 100 }) + .set('Cookie', cookieOf(SUGGESTER)) + .expect(200), + ).items; + + expect(await suggestions()).toEqual([ + // 사내 — 주고받은 사람이 먼저, 나머지는 이름 차례 + { + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '김아라', + email: 'ara.kim@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '영업팀', + email: 'sales@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '협력사', + email: 'vendor@partner.e2e.example', + source: 'recent', + company: null, + emailLabel: null, + }, + ]); + + await post('/api/contacts', SUGGESTER) + .send( + contactInput({ + emails: [ + { address: 'jihoon.park@hanbit.e2e.example', label: 'work' }, + { address: 'jihoon@home.e2e.example', label: 'personal' }, + ], + }), + ) + .expect(201); + await post('/api/contacts', SUGGESTER) + .send( + contactInput({ + name: '이도윤(거래처)', + company: null, + emails: [ + { address: 'Doyun.Lee@e2e.arasystem.test', label: 'other' }, + ], + }), + ) + .expect(201); + + const after = await suggestions(); + expect(after.slice(0, 3)).toEqual([ + { + name: '박지훈', + email: 'jihoon.park@hanbit.e2e.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon@home.e2e.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'personal', + }, + { + name: '이도윤(거래처)', + email: 'Doyun.Lee@e2e.arasystem.test', + source: 'contact', + company: null, + emailLabel: 'other', + }, + ]); + // 주소록에 있는 주소는 사내 묶음에서 빠진다 + expect(after.map((item) => item.source)).toEqual([ + 'contact', + 'contact', + 'contact', + 'directory', + 'directory', + 'recent', + ]); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). @@ -3494,6 +4404,8 @@ describe('응답 계약 (e2e)', () => { ['delete', '/api/messages/eaaaaab'], ['post', '/api/message-deletions'], ['post', '/api/mail-attachments'], + ['patch', '/api/contacts/abc'], + ['post', '/api/contact-restorations'], ] as const)( 'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)', async (method, path) => { diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index a9e5f76..19cc4a5 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -26,6 +26,8 @@ const Map kErrorCodeMessages = { 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', 'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', 'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 1470a18..1841bb2 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -26,6 +26,8 @@ export const ERROR_CODE_MESSAGES = { MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', MAIL_013: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES -- 2.52.0 From e2c41d38bbfeecb2b6a1690549d203277560c9ff Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 14:13:20 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(frontend):=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=C2=B7=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 — 과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다. 디자인 README "8단계", 계약 3절. 리뷰로 고친 것: - 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다. - [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만. - [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다. - 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다. - 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId). - 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다). - 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다. Co-Authored-By: Claude Opus 5.5 --- .../src/components/contacts/ContactAvatar.vue | 66 ++ .../src/components/contacts/ContactDetail.vue | 576 +++++++++ .../components/contacts/ContactDialog.spec.ts | 550 +++++++++ .../src/components/contacts/ContactDialog.vue | 761 ++++++++++++ .../components/contacts/ContactEntryRows.vue | 354 ++++++ .../contacts/ContactGroupMembersDialog.vue | 650 +++++++++++ .../contacts/ContactGroupNameDialog.vue | 213 ++++ .../components/contacts/ContactMoreMenu.vue | 130 +++ .../components/contacts/ContactsDialogs.vue | 78 ++ .../components/contacts/ContactsGroupRow.vue | 151 +++ .../src/components/contacts/ContactsList.vue | 523 +++++++++ .../components/contacts/ContactsListItem.vue | 133 +++ .../src/components/contacts/ContactsPanel.vue | 401 +++++++ .../components/contacts/ContactsSearchBox.vue | 206 ++++ .../src/components/contacts/contacts-ui.ts | 32 + .../src/components/feedback/ModalDialog.vue | 14 +- .../components/mail/MailContactDialogHost.vue | 38 + .../src/components/mail/MailToolButton.vue | 36 +- .../src/components/mail/MailboxMoreButton.vue | 18 +- .../src/components/mail/MessageMoreMenu.vue | 90 +- .../src/components/mail/MessageReader.spec.ts | 189 ++- .../src/components/mail/MessageReader.vue | 3 +- .../components/mail/RecipientField.spec.ts | 256 +++- .../src/components/mail/RecipientField.vue | 283 +++-- .../mail/RecipientSuggestionList.vue | 137 ++- frontend/src/components/shell/AppRail.spec.ts | 7 +- frontend/src/composables/useApi.spec.ts | 2 + frontend/src/composables/useContactActions.ts | 254 ++++ .../composables/useContactDuplicateCheck.ts | 82 ++ frontend/src/composables/useContacts.ts | 160 +++ .../src/composables/useContactsDialogs.ts | 53 + frontend/src/composables/useMail.ts | 5 +- .../src/composables/useMailContactDialog.ts | 42 + .../useRecipientSuggestions.spec.ts | 178 ++- .../composables/useRecipientSuggestions.ts | 71 +- .../src/pages/contacts/ContactsLayout.vue | 43 + .../src/pages/contacts/ContactsPage.spec.ts | 1034 +++++++++++++++++ frontend/src/pages/contacts/ContactsPage.vue | 81 ++ frontend/src/pages/mail/ComposePage.spec.ts | 62 + frontend/src/pages/mail/ComposePage.vue | 12 +- frontend/src/pages/mail/MailLayout.vue | 7 + frontend/src/router/routes.ts | 24 + frontend/src/shared/constants/app-modules.ts | 3 +- frontend/src/shared/constants/contacts.ts | 328 ++++++ frontend/src/shared/constants/icons.ts | 23 + frontend/src/shared/types/contacts.ts | 140 +++ frontend/src/shared/types/mail.ts | 14 +- frontend/src/shared/utils/clipboard.ts | 13 + .../src/shared/utils/compose-prefill.spec.ts | 67 ++ frontend/src/shared/utils/compose-prefill.ts | 47 + .../src/shared/utils/contacts-route.spec.ts | 86 ++ frontend/src/shared/utils/contacts-route.ts | 131 +++ frontend/src/shared/utils/contacts.spec.ts | 421 +++++++ frontend/src/shared/utils/contacts.ts | 511 ++++++++ .../src/shared/utils/mail-address.spec.ts | 48 + frontend/src/shared/utils/mail-address.ts | 55 + frontend/src/shared/utils/mail-filter.ts | 11 +- .../src/shared/utils/recipient-match.spec.ts | 62 +- frontend/src/shared/utils/recipient-match.ts | 68 +- frontend/src/stores/contacts.store.spec.ts | 464 ++++++++ frontend/src/stores/contacts.store.ts | 712 ++++++++++++ frontend/src/styles/base.css | 21 + frontend/src/styles/tokens.css | 6 + .../src/test-utils/recipient-match-samples.ts | 86 ++ frontend/src/test-utils/router.ts | 22 + 65 files changed, 11130 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/contacts/ContactAvatar.vue create mode 100644 frontend/src/components/contacts/ContactDetail.vue create mode 100644 frontend/src/components/contacts/ContactDialog.spec.ts create mode 100644 frontend/src/components/contacts/ContactDialog.vue create mode 100644 frontend/src/components/contacts/ContactEntryRows.vue create mode 100644 frontend/src/components/contacts/ContactGroupMembersDialog.vue create mode 100644 frontend/src/components/contacts/ContactGroupNameDialog.vue create mode 100644 frontend/src/components/contacts/ContactMoreMenu.vue create mode 100644 frontend/src/components/contacts/ContactsDialogs.vue create mode 100644 frontend/src/components/contacts/ContactsGroupRow.vue create mode 100644 frontend/src/components/contacts/ContactsList.vue create mode 100644 frontend/src/components/contacts/ContactsListItem.vue create mode 100644 frontend/src/components/contacts/ContactsPanel.vue create mode 100644 frontend/src/components/contacts/ContactsSearchBox.vue create mode 100644 frontend/src/components/contacts/contacts-ui.ts create mode 100644 frontend/src/components/mail/MailContactDialogHost.vue create mode 100644 frontend/src/composables/useContactActions.ts create mode 100644 frontend/src/composables/useContactDuplicateCheck.ts create mode 100644 frontend/src/composables/useContacts.ts create mode 100644 frontend/src/composables/useContactsDialogs.ts create mode 100644 frontend/src/composables/useMailContactDialog.ts create mode 100644 frontend/src/pages/contacts/ContactsLayout.vue create mode 100644 frontend/src/pages/contacts/ContactsPage.spec.ts create mode 100644 frontend/src/pages/contacts/ContactsPage.vue create mode 100644 frontend/src/shared/constants/contacts.ts create mode 100644 frontend/src/shared/types/contacts.ts create mode 100644 frontend/src/shared/utils/clipboard.ts create mode 100644 frontend/src/shared/utils/compose-prefill.spec.ts create mode 100644 frontend/src/shared/utils/compose-prefill.ts create mode 100644 frontend/src/shared/utils/contacts-route.spec.ts create mode 100644 frontend/src/shared/utils/contacts-route.ts create mode 100644 frontend/src/shared/utils/contacts.spec.ts create mode 100644 frontend/src/shared/utils/contacts.ts create mode 100644 frontend/src/stores/contacts.store.spec.ts create mode 100644 frontend/src/stores/contacts.store.ts create mode 100644 frontend/src/test-utils/recipient-match-samples.ts diff --git a/frontend/src/components/contacts/ContactAvatar.vue b/frontend/src/components/contacts/ContactAvatar.vue new file mode 100644 index 0000000..2f6201a --- /dev/null +++ b/frontend/src/components/contacts/ContactAvatar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDetail.vue b/frontend/src/components/contacts/ContactDetail.vue new file mode 100644 index 0000000..8e35c1e --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDialog.spec.ts b/frontend/src/components/contacts/ContactDialog.spec.ts new file mode 100644 index 0000000..b4bffaf --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.spec.ts @@ -0,0 +1,550 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { Contact, ContactGroup, ContactInput } from '@/shared/types/contacts' +import type { PaginatedResult } from '@/shared/types/pagination' +import type { ContactPrefill } from '@/shared/utils/contacts' +import { useAuthStore } from '@/stores/auth.store' +import { useContactsStore } from '@/stores/contacts.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter, routeViewProvide } from '@/test-utils/router' +import ContactDialog from './ContactDialog.vue' + +// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") + +const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } +const GROUPS: ContactGroup[] = [ + { id: 'g1', name: '거래처', memberCount: 4 }, + { id: 'g2', name: '프로젝트 알파', memberCount: 4 }, +] +const SEOYUN: Contact = { + id: 'c1', + name: '강서윤', + emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }], + phones: [], + company: '(주)한빛상사', + department: '구매팀', + title: '과장', + note: null, + groupIds: ['g1'], +} +const JIHOON: Contact = { + id: 'c2', + name: '박지훈', + emails: [ + { address: 'jihoon.park@hanbit.example', label: 'work' }, + { address: 'jihoon.p@mail.example', label: 'personal' }, + ], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '(주)한빛상사', + department: '영업팀', + title: '팀장', + note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.', + groupIds: ['g1', 'g2'], +} + +function pageOf(items: T[]): PaginatedResult { + return { items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } } +} + +interface Server { + /** GET /contacts?q= — 같은 주소 찾기 */ + search?: (q: string) => Contact[] + save?: (request: MockRequest) => MockResult | Promise +} + +describe('ContactDialog', () => { + let pinia: Pinia + let requests: MockRequest[] + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().startSession(USER) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + function installServer(server: Server = {}) { + requests = installMockApi((request) => { + if (request.url === '/contact-groups') return ok(pageOf(GROUPS)) + if (request.url === '/contacts' && request.method === 'GET') { + return ok(pageOf(server.search?.(String(request.params.q ?? '')) ?? [])) + } + if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201) + if (request.method === 'POST' || request.method === 'PATCH') { + return server.save ? server.save(request) : ok({ ...JIHOON, ...(request.data as ContactInput), id: 'c9' }) + } + if (request.method === 'DELETE') return ok({ contact: JIHOON, restoreToken: 't-c2' }) + return ok(null) + }) + } + + async function mountDialog( + props: { contact?: Contact | null; prefill?: ContactPrefill | null } = {}, + server: Server = {}, + path = '/contacts', + ) { + installServer(server) + const router = createTestRouter() + await router.push(path) + await router.isReady() + const closes: number[] = [] + const wrapper: VueWrapper = mount(ContactDialog, { + attachTo: document.body, + props: { + open: true, + contact: props.contact ?? null, + prefill: props.prefill ?? null, + onClose: () => { + closes.push(closes.length + 1) + return wrapper.setProps({ open: false }) + }, + }, + global: { plugins: [pinia, router], provide: routeViewProvide(router) }, + }) + mounted.push(wrapper) + await flushPromises() + return { wrapper, router, closes } + } + + // 이메일 칸은 type="text" + inputmode="email" (type="email" 이면 Chrome 이 한글 도메인을 퓨니코드로 바꿀 수 있다) + const emailInputs = (wrapper: VueWrapper) => wrapper.findAll('input[inputmode="email"]') + const phoneInputs = (wrapper: VueWrapper) => wrapper.findAll('input[type="tel"]') + const saves = () => + requests.filter( + (request) => (request.method === 'POST' && request.url === '/contacts') || request.method === 'PATCH', + ) + const toasts = () => useToastStore().toasts.map((toast) => toast.message) + const isOpen = (wrapper: VueWrapper, selector: string) => wrapper.find(`${selector}[open]`).exists() + + it('새 연락처 — 이메일 · 전화번호 빈 줄 하나씩과 안내, 그룹은 받아 둔다 · 처음 초점은 이름', async () => { + const { wrapper } = await mountDialog() + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가') + expect(document.activeElement?.id).toBe('contact-name') + expect(emailInputs(wrapper).map((input) => input.attributes('aria-label'))).toEqual(['첫째 이메일']) + expect(wrapper.get('select[aria-label="첫째 이메일의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '회사', + '개인', + '기타', + ]) + expect(wrapper.get('select[aria-label="첫째 전화번호의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '휴대전화', + '회사', + '집', + '기타', + ]) + expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다') + expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000') + expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([ + 'g1', + 'g2', + ]) + expect(wrapper.find('#contact-remove').exists()).toBe(false) + expect(requests.map((request) => request.url)).toEqual(['/contact-groups']) + }) + + it('잘못은 [저장]을 누를 때만 칸 아래 빨간 글 — 첫 잘못된 칸에 초점, 알림 없음 · 고치면 그 칸의 잘못을 내린다', async () => { + const { wrapper } = await mountDialog() + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-required').classes()).toContain('form-message--error') + expect(document.activeElement?.id).toBe('contact-name') + + await wrapper.get('#contact-name').setValue('박지훈') + await emailInputs(wrapper)[0]?.setValue('jihoon.park@hanbit') + await phoneInputs(wrapper)[0]?.setValue('내선 12') + await wrapper.get('#contact-save').trigger('click') + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get(`#${email?.attributes('aria-describedby')}`).text()).toBe('이메일 형식이 올바르지 않습니다.') + expect(document.activeElement).toBe(email?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(saves()).toEqual([]) + expect(toasts()).toEqual([]) + + await email?.setValue('jihoon.park@hanbit.example') + expect(email?.attributes('aria-invalid')).toBe('false') + }) + + it('저장 — 앞뒤 공백 · 빈 줄을 빼서 보내고 "연락처를 저장했습니다." · 창을 닫는다', async () => { + const { wrapper, closes } = await mountDialog() + + await wrapper.get('#contact-name').setValue(' 한도경 ') + await emailInputs(wrapper)[0]?.setValue(' dokyung.han@mail.example ') + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-company').setValue(' ') + await wrapper.get('#contact-note').setValue('대학 동기\n') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('6 / 1,000') + await wrapper.get('[data-contact-group="g2"]').setValue(true) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves().map((request) => [request.method, request.url])).toEqual([['POST', '/contacts']]) + expect(saves()[0]?.data).toEqual({ + name: '한도경', + emails: [{ address: 'dokyung.han@mail.example', label: 'personal' }], + phones: [], + company: null, + department: null, + title: null, + note: '대학 동기', + groupIds: ['g2'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + expect(closes).toHaveLength(1) + }) + + it('이메일 칸은 type="text" + inputmode="email" · [더하기]는 옆 안내("5개까지 …")를 까닭으로 잇는다(aria-describedby)', async () => { + const { wrapper } = await mountDialog() + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('type')).toBe('text') + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe( + '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다', + ) + }) + + it('고치기 — PATCH 로 바꾼 칸만 보낸다(이메일 · 전화번호 · 그룹은 바뀌면 통째로) · 바꾼 것이 없으면 보내지 않고 닫는다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toEqual([]) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-title').setValue('부장') + await phoneInputs(wrapper)[0]?.setValue('010-3192-0000') + await wrapper.get('[data-contact-group="g2"]').setValue(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [{ number: '010-3192-0000', label: 'mobile' }], + title: '부장', + groupIds: ['g1'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + }) + + it('고치기는 바꾼 칸만 검사한다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 회사)은 그대로 저장된다', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [{ number: '내선 12', label: 'work' }], + company: '한'.repeat(150), + note: '가'.repeat(1200), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()[0]?.data).toEqual({ title: '부장' }) + }) + + it('고친 칸은 그 칸 전부가 규칙에 맞아야 한다 — 전화번호를 고치면 남의 "내선" 번호도, 회사를 고치면 100자, 이메일을 고치면 20개', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [ + { number: '내선 12', label: 'work' }, + { number: '010-1111-2222', label: 'mobile' }, + ], + company: '한'.repeat(150), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await phoneInputs(wrapper)[1]?.setValue('010-1111-3333') + await wrapper.get('#contact-company').setValue('한'.repeat(149)) + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves()).toEqual([]) + expect(wrapper.get('[data-entry-count-error]').text()).toBe('이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요.') + expect(document.activeElement).toBe(emailInputs(wrapper)[0]?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company-error').text()).toBe('100자까지 쓸 수 있습니다.') + }) + + it('고치기 — 다른 프로그램이 넣은 6개 이상의 주소는 그대로 보이고 저장된다 · [이메일 더하기]는 5개에서 멈춘다', async () => { + const many: Contact = { + ...JIHOON, + emails: Array.from({ length: 6 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + } + const { wrapper } = await mountDialog({ contact: many }) + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 고치기') + expect(emailInputs(wrapper)).toHaveLength(6) + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(addEmail?.attributes('aria-disabled')).toBe('true') + await addEmail?.trigger('click') + expect(emailInputs(wrapper)).toHaveLength(6) + // 전화번호는 하나 — 더할 수 있다 + const addPhone = wrapper.findAll('.entry-rows__add-button')[1] + expect(addPhone?.attributes('aria-disabled')).toBeUndefined() + await addPhone?.trigger('click') + await flushPromises() + expect(phoneInputs(wrapper)).toHaveLength(2) + expect(document.activeElement).toBe(phoneInputs(wrapper)[1]?.element) + + await phoneInputs(wrapper)[1]?.setValue('02-555-0147') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 이메일은 손대지 않았다 — 전화번호만 보낸다 + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [ + { number: '010-3192-5540', label: 'mobile' }, + { number: '02-555-0147', label: 'mobile' }, + ], + }) + }) + + it('같은 주소 — 이메일 칸을 떠나면 알림 없이 찾아 갈색 글 + [그 연락처 보기] (자기 자신은 뺀다) · 막지 않는다', async () => { + const { wrapper } = await mountDialog({}, { search: () => [SEOYUN] }) + await useContactsStore().loadGroups() + + const email = emailInputs(wrapper)[0] + await email?.setValue('Seoyun.Kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + const search = requests.find((request) => request.url === '/contacts') + expect(search?.params).toEqual({ q: 'Seoyun.Kang@hanbit.example', limit: 100 }) + expect(search?.config.silent).toBe(true) + const warning = wrapper.get('[data-contact-duplicate]') + expect(warning.text()).toBe( + '이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기', + ) + expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id')) + expect(email?.attributes('aria-invalid')).toBe('false') + + // 고치기 창에서 자기 자신의 주소는 경고하지 않는다 + const own = await mountDialog({ contact: SEOYUN }, { search: () => [SEOYUN] }) + const ownEmail = emailInputs(own.wrapper)[0] + await ownEmail?.trigger('blur') + await flushPromises() + expect(own.wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + // 고치는 동안은 옛 주소의 안내를 내린다 + await email?.setValue('seoyun.k@hanbit.example') + expect(wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toHaveLength(1) + }) + + it('채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아보고, 채운 그대로면 닫을 때 묻지 않는다', async () => { + const { wrapper, closes } = await mountDialog( + { prefill: { name: '강서윤', email: 'seoyun.kang@hanbit.example' } }, + { search: () => [SEOYUN] }, + ) + + expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤') + expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + }) + + it('[그 연락처 보기] — 창을 닫고 그 연락처를 고른 주소록으로 (바꾼 것이 있으면 먼저 묻는다)', async () => { + const { wrapper, router, closes } = await mountDialog({}, { search: () => [SEOYUN] }, '/mail/inbox') + await wrapper.get('#contact-name').setValue('강 과장') + const email = emailInputs(wrapper)[0] + await email?.setValue('seoyun.kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + await wrapper.get('.entry-rows__link').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-title').text()).toBe('저장하지 않은 연락처가 있습니다') + expect(wrapper.get('#contact-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.') + await wrapper.get('#contact-leave-confirm').trigger('click') + await flushPromises() + + expect(closes).toHaveLength(1) + expect(router.currentRoute.value.name).toBe('contacts-contact') + expect(router.currentRoute.value.params).toEqual({ contactId: 'c1' }) + }) + + it('다른 프로그램이 넣은 제어 문자(탭 · CR LF)는 열 때 고친다 — 고친 것만으로는 [취소]에 묻지 않고, 저장하면 고친 값을 보낸다', async () => { + const imported: Contact = { ...JIHOON, name: '박지훈\t', title: '팀장\u0000', note: '첫 줄\r\n둘째\t줄' } + const { wrapper, closes } = await mountDialog({ contact: imported }) + + expect((wrapper.get('#contact-note').element as HTMLTextAreaElement).value).toBe('첫 줄\n둘째 줄') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-department').setValue('영업\t2팀') + await wrapper.get('#contact-note').setValue('첫 줄\n셋째\t줄') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 고친 칸만 고친 값으로 — 손대지 않은 이름 · 직책(남의 제어 문자)은 보내지 않는다 + expect(saves()[0]?.data).toEqual({ department: '영업 2팀', note: '첫 줄\n셋째 줄' }) + }) + + it('다른 프로그램이 넣은 1,000자 넘는 메모를 고쳤으면 [저장] 때 메모 아래에 알린다 — 보내지 않는다', async () => { + const { wrapper } = await mountDialog({ contact: { ...JIHOON, note: '가'.repeat(1200) } }) + + expect(wrapper.get('[data-contact-note-count]').text()).toBe('1,200 / 1,000') + await wrapper.get('#contact-note').setValue('가'.repeat(1100)) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-note').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-note-error').text()).toBe('메모는 1,000자까지 쓸 수 있습니다.') + expect(document.activeElement?.id).toBe('contact-note') + expect(saves()).toEqual([]) + }) + + it('나가기 확인 — 바꾼 것이 있으면 [취소] · Esc 에 묻는다: [계속 고치기]면 그대로, [닫기]면 닫는다 · 다른 화면으로 가면 [나가기]', async () => { + const { wrapper, router, closes } = await mountDialog({ contact: JIHOON }, {}, '/contacts/all/c2') + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(document.activeElement?.id).toBe('contact-leave-stay') + expect(wrapper.get('#contact-leave-stay').text()).toBe('계속 고치기') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('닫기') + + await wrapper.get('#contact-leave-stay').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(isOpen(wrapper, '#contact-dialog')).toBe(true) + expect(closes).toEqual([]) + + const leaving = router.push('/mail/inbox') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('나가기') + await wrapper.get('#contact-leave-confirm').trigger('click') + await leaving + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('서버의 형식 거절(VAL_001)은 단추 줄 위에 사전 문구 — 알림 없이 · 한도(CONTACT_002)는 사전 문구 알림, 창은 그대로', async () => { + let reply: MockResult = fail(400, 'VAL_001') + const { wrapper, closes } = await mountDialog( + { prefill: { name: null, email: 'dokyung.han@mail.example' } }, + { save: () => reply }, + ) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001) + expect(toasts()).toEqual([]) + + reply = fail(409, 'CONTACT_002') + await wrapper.get('#contact-name').setValue('한도경') + expect(wrapper.find('#contact-error').exists()).toBe(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.CONTACT_002]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + expect(closes).toEqual([]) + }) + + it('[이 연락처 지우기] — 확인 없이 지우고 닫는다 · 알림의 [되돌리기]가 지운 연락처를 다시 만든다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + await useContactsStore().loadGroups() + + await wrapper.get('#contact-remove').trigger('click') + await flushPromises() + + expect(requests.some((request) => request.method === 'DELETE' && request.url === '/contacts/c2')).toBe(true) + expect(closes).toHaveLength(1) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('‘박지훈’ 연락처를 지웠습니다.') + expect(toast?.action?.label).toBe('되돌리기') + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + + // 되돌리기는 지우기 응답의 표로 — 서버가 지운 그대로(다른 프로그램의 칸까지) 다시 만든다 + expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({ + method: 'POST', + data: { token: 't-c2' }, + }) + expect(saves()).toEqual([]) + expect(toasts()).toContain('연락처를 되돌렸습니다.') + }) + + it('저장을 보내는 사이 창을 닫았으면(취소) 창 안에 보일 거절(VAL_001)을 알림으로 알린다', async () => { + let finish: (result: MockResult) => void = () => undefined + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { + save: () => + new Promise((resolve) => { + finish = resolve + }), + }, + ) + + await wrapper.get('#contact-save').trigger('click') + // 보내는 중에는 묻지 않고 닫는다 (결과는 알림으로) + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(closes).toHaveLength(1) + finish(fail(400, 'VAL_001')) + await flushPromises() + + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.VAL_001]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + }) + + it('보내는 사이 세션이 바뀌면(로그아웃 · 다른 계정) 늦게 온 응답으로 알리지도 닫지도 않는다', async () => { + let finish: (result: MockResult) => void = () => undefined + const late = new Promise((resolve) => { + finish = resolve + }) + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { save: () => late }, + ) + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-save').attributes('aria-disabled')).toBe('true') + useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }) + finish(ok({ ...JIHOON, id: 'c9' })) + await flushPromises() + + expect(toasts()).toEqual([]) + expect(closes).toEqual([]) + }) +}) diff --git a/frontend/src/components/contacts/ContactDialog.vue b/frontend/src/components/contacts/ContactDialog.vue new file mode 100644 index 0000000..5a9b085 --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.vue @@ -0,0 +1,761 @@ + + +

링크글X13p7mark